What this is: the implementation guide for a home monitoring stack built from three separate machines. A cheap managed switch mirrors the internet link. Security Onion runs Zeek, Suricata, and the Elasticsearch store on a dedicated sensor box. A plain Python script polls the store every five minutes and prints only when something is new. When it finds a real threat it pushes the host to the router, and the router, running OpenWrt with ipset and iptables, drops it. I use TP-Link for the tap, Security Onion for detection, and a GL.iNet router for enforcement. Substitute your equivalents.
Environment used here: Security Onion 2.4.211 standalone on a dedicated box, TP-Link TL-SG105E as the tap, Python 3 scripts on cron, OpenWrt on the router. Defensive, read-only monitoring with one enforcement path.
Placeholders: every IP and subnet is an RFC 5737 test-net placeholder (
203.0.113.0/24,198.51.100.0/24,192.0.2.0/24). Swap in your own values; no address here is real. The country zone files are namedcountry-a.zoneandcountry-b.zone: substitute the codes for the countries on your own list, for reasons you do not have to publish.What you need first: a managed switch that can mirror a port, a dedicated x86 box for the sensor with two NICs (one on your LAN, one on the mirror port with no IP), and a router that runs OpenWrt so
ipsetandiptablesare available to the block loop.Not shipped here. Four artifacts this post describes but does not hand you: the poller script (section 4 gives both of its patterns and the query it runs), the candidate filter and its unit test (section 6), the block registry and its TTL sweep (sections 6 and 10), and the evasion-tolerant exploit rule text (section 7). Each is 20 to 60 lines of Python or a single rule, and each is specified here in enough detail to write. The switch mirror is configured in that switch’s own management UI, which the vendor’s user guide for the model covers menu by menu.
Verified here: every command and config below ran on this setup, and the figures behind them come from it. Estimates are labeled. Reference steps say so and keep my as-built specifics separate.
Follow it top to bottom, or hand it to an AI agent to work the phases in order (section 12).
0. Goal and acceptance
Home routers are the new edge. They sit in front of every TV, phone, laptop, and IoT device you own, and most do nothing but forward packets. When something on your LAN phones home to a command-and-control server, or a scanner probes your forwarded services, nobody tells you. A cloud provider emails you when a compute instance gets pwned; your router sends you nothing. This stack closes that gap: you see the traffic, get told when it matters, and the router blocks what the automation calls hostile.
One design decision shapes everything: detection and enforcement are separate machines. Security Onion only sees traffic and raises alerts; it cannot block. The router is the enforcement point. Between them sits a plain script on a schedule. Keep the three roles apart and the whole thing stays easy to reason about.
Acceptance criteria for this build (all met):
| # | Criterion | Measured / target |
|---|---|---|
| 1 | Every device’s internet traffic, both directions, reaches the sensor | by construction: one ONT link mirrored; weekly capture check |
| 2 | Event to alert latency | poller cadence 5 min, so 0 to 5 min |
| 3 | A brand-new LAN device is noticed | registry check every 2 min |
| 4 | Auto-block lands and is verified | ipset test return code 0 on every push |
| 5 | False blocks | zero in operation; filter unit-tested (section 6) |
| 6 | No-exemption block on a chosen set of countries, both directions | no data-exchanged contact since enforcement |
| 7 | Full set rebuild | ~1 s at ~11,400 prefixes (per-entry load: ~18 s) |
| 8 | Noise floor stays readable | ~4 Suricata alerts, ~8 detections per 24 h at build time |
Time: a weekend. Cost: about $40 of hardware plus a spare machine; the software is free (section 11).
1. The design gate: visibility, not software
A network sensor is a passive listener: it only sees traffic that reaches its interface, its own chatter, and whatever is mirrored or tapped to it. No tap or mirror, and your dashboards show nothing useful. Solve visibility before you pick software. Suricata here runs as an intrusion detection system (alerts only); inline prevention is a deployment nobody runs in a home.
Port mirroring does not slow the forwarded path: it is a copy made by the switch ASIC, and an overflow drops copies, never live traffic. The one sizing rule: the switch’s port speed must be at least your WAN speed. At or below 1 Gbps a gigabit managed switch is lossless.
The wiring, as built:
| Port | Connected to |
|---|---|
| 1 | ISP modem / ONT (mirror source) |
| 2 | Router WAN |
| 3 | Sensor monitor NIC (mirror destination) |
| 4, 5 | spare |
The mirror copies port 1 to port 3. Every device’s internet traffic, wired and Wi-Fi, crosses that one ONT link, so all of it is captured. East-west traffic between LAN devices stays dark; acceptable at home, and it keeps the sensor’s data volume sane.
Configure the mirror before it carries traffic. Give the switch a static address on your LAN and reach its management UI over a spare port. Port 1 becomes the mirror source, port 3 the destination, save, and the copy starts. Do this on the bench, wired to a laptop, not in the WAN path. The vendor guide for your model has the exact menu; the setting is called port mirroring or port monitoring depending on the firmware.
Bench-configure first. Out of the box, and after a factory reset, the switch is a DHCP client. Plug it in between the ONT and the router and it can grab a public ISP address instead of its static config, and its management interface goes silent. The fix is a physical factory reset (hold RESET about 10 seconds until the LEDs blink). Configure the mirror on a bench, then wire it in. Never configure a DHCP-client device while it sits in the WAN path.
Power loss fails closed. A dead managed switch passes nothing. The tap’s “fails open” only covers config loss, where a wiped mirror reverts to plain switching and traffic flows. Power loss takes the whole network down because the switch is inline. Power it from the same strip as the modem and router, and keep a short bypass patch cable (modem straight to router) so a failure is a 10-second restore, not an outage.
Acceptance check: on the bench, plug a laptop into port 3 and generate traffic on another port; the capture shows both directions. Then confirm the mirror survives a switch power cycle before going inline.
2. The sensor box: Security Onion
Security Onion is a free network security monitoring platform that bundles the stack on one box:
| Component | What it produces | Why it matters |
|---|---|---|
| Zeek | conn, http, dns, ssl, files logs | What it answers: who talked to whom, on what port, how many bytes, connection state, hostname |
| Suricata | alerts with rule name, severity, classification | Known-bad signatures from the open Emerging Threats ruleset, roughly 50,000 rules |
| Elasticsearch | the search store behind the console | Where everything lands and where the poller queries it |
Everything logs to Elasticsearch under data-stream indices; Zeek and Suricata data live in separate streams (section 3).
Reference (supported base OS): Security Onion pins to one supported operating system per major release, and it has changed across versions. The 3.0.0 release notes, for example, were explicit that 3.0 “will not work with anything but Oracle Linux 9” and that Ubuntu, Debian, and other distros were not tested or supported (3.0.0 release notes). The 2.4.211 standalone install documented here runs on Ubuntu 22.04; the 2.4 branch is the last one that supports Ubuntu. Read the release notes for the exact version you install (docs.securityonion.net). Never assume the newest OS release is the supported one. First-time installers should follow the project’s wizard walkthrough (First Time Users).
As built: Security Onion 2.4.211 in a standalone deployment (detection, storage, and console on one box) on a dedicated x86 machine running Ubuntu 22.04, mostly idle until needed, with two NICs:
- Management NIC on my LAN, used to reach the console.
- Monitor NIC on the mirror port, with no IP. Security Onion puts it into promiscuous mode at setup; the sensor must be silent, it only listens. A mirror arriving on the management NIC is ignored by the IP stack and shows nothing by design.
The wizard access list is a trap. The wizard asks which hosts may
reach the web console and pre-fills the sensor’s own IP. Accept it and
you lock everyone else out: the console answers locally but remote HTTPS
dies. The signature: local curl returns a redirect, remote curl
times out, SSH still works. Fix it over SSH:
sudo so-firewall includehost analyst 198.51.100.50
includehost takes one address at a time; repeat it for each host you want
on the list (so-firewall reference).
The host firewall is config-managed: Security Onion locks it down by
default and reverts hand-edited iptables on the next apply. The durable
paths are the console’s firewall screen and so-firewall includehost.
Acceptance check: from a second LAN host the console answers over
HTTPS after the includehost fix; tcpdump on the monitor NIC shows
mirrored traffic; core services report healthy.
3. The query surface: the fields that matter
You cannot build the automation without knowing how Security Onion maps
Zeek fields into Elasticsearch JSON. My streams (-so- is my deployment
name; yours will differ):
.ds-logs-zeek-so-* all Zeek logs (conn, http, dns, ssl)
.ds-logs-suricata.alerts-so-* Suricata alert events
.ds-logs-detections.alerts-so-* detection-rule events
Auth is HTTP basic auth against a self-signed certificate, so shell
queries look like curl -sk -u "$ES_USER:$ES_PASS". The field map,
verified against real documents:
source.ip / source.port / source.geo.country_iso_code
source.as.number / source.as.organization.name
destination.ip / destination.port
network.transport (tcp/udp/icmp) / network.bytes
connection.state <- Zeek conn state: S0, SF, RSTO, OTH...
container.id <- which Zeek log, by filename: conn.log, http.log, dns.log, ssl.log
http.uri / http.method / http.user_agent
ssl.server_name <- SNI, the domain that was requested
connection.state is the single most useful field. It tells you
whether a connection actually happened. Zeek’s documentation defines the
states
(docs.zeek.org):
S0 = attempt seen, no reply, the firewall dropped it, usually noise;
SF = normal establishment and termination, a real connection worth
investigating when the bytes are real; RSTO / RSTR = reset; OTH =
no SYN seen, midstream traffic. That one field separates scanner noise from
a real exchange, which is what the poller’s thresholds key on.
The empty-field gotcha. A query that returns zero hits usually means
you guessed the path. dns.query is top-level here, not nested under a
zeek.* namespace, and conn state lives at connection.state, not
zeek.conn.state. When a query comes back empty, pull one document and
read the real key names, or check any field’s mapping with
/_mapping/field/<field>.
Acceptance check: run a conn-state query and a geo query on your own data. Correct fields return documents; a deliberately wrong field returns zero, which proves you understand the gotcha.
4. The poller: a quiet watchdog
Detection logs are only useful if something looks at them on a schedule. I run one Python script every 5 minutes, built on two patterns.
Stateful windowing and dedupe. The script persists JSON state between runs: last check time, a set of seen event IDs, a set of seen device keys. Each run queries for events newer than the last check minus a 60-second overlap, reports only unseen IDs, then saves state. With no state file (first run) it looks back 6 hours. Without this you re-alert on every event forever.
Stdout is the alert channel. The script prints a report only when something is new; empty stdout delivers nothing. Any scheduler can deliver stdout verbatim when it is non-empty. That keeps the channel free of false all-clear messages and alert fatigue. The alert path is pure scripts; nothing in the daily loop depends on a model being reachable.
The query pattern that does most of the work:
body = {
"size": 50,
"query": {"bool": {"filter": [
{"range": {"@timestamp": {"gte": "now-35m"}}},
{"term": {"container.id": "http.log"}},
{"bool": {"should": [{"wildcard": {"http.uri": "*invokefunction*"}}]}}
]}},
"_source": ["@timestamp", "source.ip", "http.uri", "http.response.status_code"],
"sort": [{"@timestamp": "asc"}]
}
What the poller watches, each as one query:
- New Suricata alerts, severity 1 to 2 flagged as high.
- New detection-rule events from the rules engine.
- Geo policy contacts: any flow reaching one of the blocked
countries, in two tiers: a real contact (
network.bytesgreater than 0, data exchanged, a genuine bypass) versus a scan surge of 30 or more blocked attempts, which means the router is doing its job and gets one quiet line. - Exploit probes: HTTP URIs carrying remote-code-execution payload
markers (
invokefunction,shell_exec,wget,kaizen,/tmp/.k,certutil,base64 -d,powershell IEX,eval(,system(and friends). - Scan bursts: 15 or more connections in the window from one source to the forwarded services (raised from 5 after scanner fleets learned to probe exactly 5 times per source).
- New DHCP devices, keyed by MAC plus hostname.
Each alert line carries what triage needs without a second query: source country and ASN, ports hit, connection states, bytes. The header stamps the window, so a line without a timestamp is never ambiguous.
Acceptance check: inject one synthetic event (or wait for a real
one). The script prints a report exactly once; an immediate rerun prints
nothing and the state file’s last_check advanced. That pair of runs
proves windowing and dedupe in one minute.
5. Enforcement: the router drops, with zero exemptions
Security Onion sees and reports. It blocks nothing. Enforcement lives on the router, which runs OpenWrt, using ipset and iptables.
The policy, stated plainly because it is the whole point: a hard block on a chosen list of countries, in both directions, with no exemptions. Nothing on my LAN may reach those countries, and nothing in those countries may reach my forwarded services. Any contact that still happens is a policy bypass by definition.
I am not naming the countries on that list, or my reason for keeping it. The mechanism does not change with the list, and the mechanism is what this post is about: the zone files, the one set, the two rules, and the checks that prove they work. Pick your own countries and the rest of this section stands as written.
The mechanism is one ipset set plus two DROP rules. Four zone files on the router feed the set:
| Zone file | Contents |
|---|---|
country-a.zone | Country CIDRs for one of the countries on your list, from ipdeny. The pattern is /ipblocks/data/countries/<cc>.zone, where <cc> is the ISO 3166-1 alpha-2 code |
country-b.zone | A second example file. The loader takes every .zone file it finds, so one file per country is the whole convention |
cloud.zone | Cloud-region prefixes physically hosted in the countries you blocked, pulled from the providers’ published feeds: AWS ip-ranges.json, Microsoft Service Tags, Google cloud.json. Region and zone names carry their geography (a continent prefix, a city name, an availability-zone suffix), so sorting each feed by region gives you the prefixes that belong to the countries on your list. Closes the “registered elsewhere, hosted there” gap |
c2.zone | Auto-blocked command-and-control and payload hosts (section 6) |
The rebuild script, deployed version in full (router paths under
/etc/geo-block/):
#!/bin/sh
# Geo-block: country list plus blocklist, both directions, ZERO exemptions.
# Covers registered country CIDRs (the per-country zone files) plus cloud-region
# prefixes physically hosted in those countries (cloud.zone) plus the
# auto/manual C2 blocklist (c2.zone).
# Uses `ipset restore` for a single-pass rebuild (~1s at 11k entries vs ~18s
# with per-entry `ipset add`). Idempotent: safe to re-run.
GEO=/etc/geo-block
MAXELEM=131072
# (Re)create the set if missing, then empty it. flush keeps the set object
# alive so iptables rules referencing it stay valid during the rebuild.
ipset create geo-block hash:net family inet hashsize 8192 maxelem $MAXELEM -exist 2>/dev/null
ipset flush geo-block
# Batch-load all zone files in one restore call (single process, ~1s).
{
# Every .zone file in $GEO is loaded, so adding a country means dropping
# in a file. cloud.zone and c2.zone ride the same path.
for f in "$GEO"/*.zone; do
[ -f "$f" ] || continue
while read -r net; do
case "$net" in *.*/*) echo "add geo-block $net" ;; esac
done < "$f"
done
} | ipset restore -exist
# Remove any previously-inserted geo rules (incl. legacy exempt rules).
while iptables -D forwarding_rule -m conntrack --ctstate NEW -m set --match-set geo-block dst -j DROP -m comment --comment "geo-block outbound country list" 2>/dev/null; do :; done
while iptables -D forwarding_rule -m conntrack --ctstate NEW -m set --match-set geo-block src -j DROP -m comment --comment "geo-block inbound country list" 2>/dev/null; do :; done
while iptables -D forwarding_rule -m set --match-set ms-exempt dst -j ACCEPT -m comment --comment "geo-block ms exempt" 2>/dev/null; do :; done
while iptables -D forwarding_rule -m set --match-set cf-exempt src -j ACCEPT -m comment --comment "geo-block cf exempt" 2>/dev/null; do :; done
# Two drops, NEW-state only (established traffic still passes).
iptables -I forwarding_rule -m conntrack --ctstate NEW -m set --match-set geo-block dst -j DROP -m comment --comment "geo-block outbound country list"
iptables -I forwarding_rule -m conntrack --ctstate NEW -m set --match-set geo-block src -j DROP -m comment --comment "geo-block inbound country list"
logger -t geo-block "applied: $(ipset list geo-block | grep -c '^' 2>/dev/null) entries, no exemptions"
A marker in /etc/firewall.user re-runs the script at every firewall
start, which is how the block survives reboots (OpenWrt executes this
file on each firewall reload; see the
firewall documentation):
# /etc/firewall.user marker
[ -x /etc/geo-block/geo-block.sh ] && /etc/geo-block/geo-block.sh
The design choices that matter, each learned from a mistake:
hash:netlookup is O(1) per packet regardless of set size (ipset docs); 11,000 entries or 65,000 cost the same.- Batch the load. The first version ran
ipset addper entry, a subprocess per line: 18 seconds for 11,000 entries.ipset restoredoes the same rebuild in about 1 second. ipset destroyfails while iptables references the set. Flush and batch-restore instead; the set object stays alive, rules stay valid.- NEW-state only. Established, legitimate sessions are not cut mid-stream when a prefix lands in the set.
- Push files with
scp, not pipes. The router’s shell lacksbase64, so encoded pipes fail confusingly. - An earlier version exempted Microsoft outbound and Cloudflare inbound. Gone. The zero-exemption policy is unconditional, and the current script actively removes the old exemption rules when it runs.
One honest caveat from my triage log: a country zone is a registry allocation, not a physical location. It catches the bulk of real traffic from the countries on your list; deliberate evasion via foreign-registered cloud or VPN exits passes. IP geo-blocking is a policy control, not a security boundary, and the cloud zone closes the biggest hole, not all of them.
Acceptance check: after the rebuild the set holds thousands of entries and both DROP rules carry their comment labels. From a LAN host, a connection to a host in a blocked country times out while control traffic answers. Reboot the router and confirm the rules return on their own.
6. The auto-block loop: the poller closes the circle
Three detectors feed one shared push function:
- Exploit-payload hosts: IPs extracted from the URI of a
remote-code-execution request, the download host a
wgetorcurlcradle points at. - High-severity Suricata targets: external IPs in severity 1 to 2 alerts, the destination of an outbound callout or the source of an inbound attack.
- Geo-policy bypass endpoints: the external peer of any real contact with a country on your list (bytes exchanged). Any such flow is a bypass, because the router should have dropped it.
Scan bursts and volume heuristics are reported, never auto-blocked; blocking is reserved for high-confidence signals.
push(candidates, reason):
keep = public IPv4 only: never LAN/private/reserved,
never the router's own WAN address, never CDN anycast
expire = registry entries older than 30 days (TTL sweep)
merge into c2.zone, keeping manual entries alive
scp c2.zone to the router, run geo-block.sh
verify each new IP: ipset test geo-block <ip> must return 0
record first_seen and reason in the block registry
The safety rails, each learned from a near-miss:
- Never block a LAN, private, or reserved range. One bad parse and you brick your own network access.
- Never block your own WAN IP. It appears as a candidate when the geo logic picks the far side of an inbound flow. The tap sits between the ONT and the router, so inbound traffic arrives pre-NAT with your own WAN address as the local peer. The detector blocks the geo-tagged external side instead, and the filter refuses the WAN address.
- Never block anycast CDN ranges. Cloudflare, Akamai, and Fastly addresses get geo-tagged as various countries constantly, because one anycast IP serves many regions and a static database pins it to one. Blocking one breaks legitimate fronted sites. The block is a static CIDR set, so ASN ownership is triage data, not a block rule: use the daily report’s ASN organization names to see who a hit belongs to. CDN sources are also excluded from the scan-burst heuristic: origin pulls legitimately hammer forwarded ports and are never attacks. The accepted tradeoff: a Cloudflare edge that sits in a blocked country is collateral the zero-exemption policy accepts, and the daily report carries a standing caveat so country counts do not mislead.
- TTL on auto-blocks. Botnet command-and-control hosts are
ephemeral. A 30-day expiry, tracked by each entry’s
first_seen, keeps the list from growing toward the set cap. Manualc2.zoneentries are not in the registry, so they survive the sweep. - Verify the push. Check the
ipset testreturn code, not stdout; the success message goes to stderr on this platform.
The registry is a small JSON state file holding first_seen and
source per blocked IP, which the weekly summary reads so you can see
what was blocked this week and why.
Acceptance check: unit-test the filter first: LAN, CDN, and private
candidates, and none may be pushed. Then run one synthetic
high-severity alert with a payload host, confirm the push end to end
(c2.zone gains a /32, ipset test returns 0, registry records
first_seen), and purge the fake entry from router and registry.
7. Tuning and writing your own rules
A sensor is only as good as its rule set. Two layers exist in Security Onion: Suricata signatures for network traffic, detection rules for log events.
Tune what exists before you write anything. Alert noise kills visibility. When a rule fires too often, tune it rather than disable it: suppress silences a specific source or destination, threshold alerts only after a rate is hit, modify adjusts the rule’s fields. Tune against real data: replay 24 hours of traffic through the proposed filter and set the threshold above the scanner-fleet pattern. My scan-burst threshold moved from 5 to 15 for exactly this reason.
Write a new rule by testing it first. Before creating a rule, run the equivalent query in the hunt interface. Finds nothing? The rule will never fire. Finds too much? Tune before you create. Profile the noise floor while you are at it: my WAN address draws roughly 17,700 inbound connections a week from all countries, China about 2 percent. Knowing the floor is what lets you call something anomalous with confidence.
The most useful rule I added made an existing signature more robust. The stock rule for the ThinkPHP remote-code-execution family was evaded by an injected path segment that broke its proximity constraint. The evasion-tolerant variant matches the payload markers themselves, without the proximity requirement, plus a generic variant that catches the encoded form. Malware authors tune around rule engines constantly; a rule that matches the payload rather than its position is more durable.
Verify a rule change actually loaded. The reload command responds, but check the engine log for zero failed rules before you trust it. A rule that fails to parse silently does nothing.
Acceptance check: write the rule as a query first and confirm it finds what you expect. Create it, trigger it with test traffic, confirm the alert lands, and confirm zero failed rules in the engine log after reload.
8. Verification battery
Run these against the live stack, not in a simulator. A mirror that quietly stopped, or a rule that failed to load, only shows up end to end.
| Check | What it proves | Result |
|---|---|---|
| Mirror smoke test: capture on the monitor NIC while a LAN host browses | Tap wiring and the capture path both work | PASS |
| File-analysis test: fetch the EICAR test file from a LAN host | File extraction and signature scanning reach the console within a minute or two | PASS |
| Dedupe probe: synthetic event through the poller, run twice | Stateful windowing reports each event once | PASS |
| Batch timing: full rebuild at ~11,400 prefixes | ipset restore ~1 s versus per-entry ~18 s | PASS |
Enforcement probe: ipset test on a blocked prefix and a control | Return 0 for blocked, non-zero for control | PASS |
| Reboot persistence: power-cycle the router | firewall.user reapplies set and both DROP rules | PASS |
| Rails probe: LAN, private, CDN candidates through the filter | Zero false pushes | PASS |
| Field probe: known field versus invented path | Empty-field gotcha understood | PASS |
| Rule reload: rule change, then engine log | Zero failed rules | PASS |
| Geo end-to-end: same geo query 24 h after enforcement | Data-exchanged contacts with the blocked countries at zero | PASS |
9. Avoid My Mistakes
- Configure the mirror on a bench, never in the WAN path. A factory default switch is a DHCP client; inline, it can grab a public ISP address and go silent.
- A dead switch passes nothing. Power loss fails closed and takes the network down. Shared power strip, plus a bypass patch cable for a 10-second restore.
- The wizard pre-fills the console access list with the sensor’s own
IP. Remote HTTPS dies while local curl answers. Fix with
so-firewall includehost analyst <host-ip>; the host firewall reverts hand-editediptables. - Per-entry
ipset addis a subprocess per line. 18 seconds for 11,000 entries. Batch withipset restore: about 1 second. ipset destroyfails while iptables references the set. Flush and restore, never destroy.- Stdout is the alert channel, and empty stdout must mean silence. An “all clear” report is invented alert fatigue.
- An empty query usually means you guessed the field path. Dump one document and read the real key names.
- Verify the push by return code, not stdout. The
ipset testsuccess message goes to stderr on some platforms. - Do not assume your distro is supported. The supported OS changed across Security Onion major versions: this 2.4.211 install runs on Ubuntu 22.04, and the 3.x release notes pin 3.0 and later to Oracle Linux 9 only. Read the release notes for the exact version you install; the newest distro is not automatically the supported one.
- Treat ASN ownership as context, not a block rule. Geo databases mislabel anycast CDN addresses, so the block stays a static CIDR set and the ASN organization name in the report tells you who a hit belongs to.
10. Day-2 operations
Monitoring is a habit, not an install. The daily review is five steps and takes a few minutes:
- Read the summary line, then pull the full document. Alert titles lie: a “brute force” alert was a crypto-miner exploit, a country tag was a Cloudflare edge. Read the raw fields.
- Classify by connection state and bytes.
S0with zero bytes is a dropped attempt, noise.SFwith real bytes is an exchange worth investigating. - Check the source’s ASN, not just its country. Geo on anycast is a guess; the organization name is reliable. That is how a cloud edge that geolocates to a blocked country gets recognized as CDN infrastructure rather than a contact worth chasing.
- Follow the payload. The HTTP URI often carries the exploit, and the host it downloads from is exactly what you want to block.
- Check the router’s health. The sensor can be down and you will not know until you look.
The automation schedule, as running:
| Script | Cadence | Job |
|---|---|---|
| Tripwire poller | every 5 min | alerts, geo, exploit probes, scan bursts, auto-block |
| Digest | every 30 min | same watch, one compact report when there is news |
| Device watch | every 2 min | DHCP registry, per-device DNS, new-device alerts |
| Daily report | 06:00 | per-device DNS with deviation notes, ASNs, talkers, geo, probes |
| Weekly summary | Sunday 09:00 | 7-day aggregates plus the auto-block registry |
| Cloud zone refresh | 5th of month, 04:00 | re-fetch cloud-region prefixes, push, rebuild |
All standalone scripts; any scheduler will run them. The per-country zone
files (country-a.zone, country-b.zone) are refreshed by hand, not on a
schedule. Two notes from
running this a while:
- The state files are the system’s memory. The poller state holds
the window and the seen-ID set, so a missed run is survivable: the
next run picks up from the last recorded check. The block registry
holds
first_seen, which the 30-day TTL sweep reads. Device activity files roll off after 7 days. - Weekly, check for new devices and confirm the sensor is still capturing. Compare volume against the prior week; a quiet drop toward zero means the mirror stopped, and nothing will tell you until you look.
11. What it costs
| Item | Cost |
|---|---|
| TP-Link TL-SG105E managed switch (the tap) | ~$28 |
| USB gigabit adapter (sensor monitor NIC, if needed) | ~$12 |
| Spare machine for the sensor | whatever is lying around |
| Security Onion, Zeek, Suricata, Elasticsearch, ipset, iptables | $0 (open source) |
| Total | ~$40 plus a box |
Zero licensing anywhere. Enterprise monitoring pricing buys stacking, SNMP, and a vendor CLI a home setup never touches.
Threat model
This stack ends with root-owned firewall rules on your router, built from lists downloaded off the internet, driven by a script that polls a database with credentials. Every one of those is a place where a mistake is expensive, so the limits deserve stating as plainly as the wins.
What the guardrails stop. The auto-block path has rails on purpose, all of them learned from near-misses: it never blocks LAN, private, or reserved space, never blocks your own WAN address, never blocks anycast CDN ranges, and every automatic block carries a TTL of about 30 days. Match on ASN ownership rather than geo-location, because GeoIP on anycast is a guess. The push is verified, not assumed. The enforcement script is idempotent and rebuilds the set in one pass, both drops are NEW-state only so established connections keep working, and the whole thing is a script you can read, with no model in the daily path.
What they cannot stop. Six things. Geo-location is imprecise by nature, and “registered elsewhere, hosted there” cloud capacity walks straight around a country block, which is exactly why the cloud-region zone file exists. A marker-based rule loses to obfuscation eventually: base64, chunked encoding, a payload split across requests, or simply TLS, which is a large share of modern malicious traffic and invisible to the HTTP log the poller reads. The zone files come off the internet, so a poisoned or hijacked list becomes root firewall rules on your router: that is the sharpest edge in this build, and it is why the install step validates before it applies. The poller holds credentials for the query surface and the router push, so the poller is a privileged component, not a harmless monitor. The policy blocks scans and contacts from certain origins, not intent, and an attacker renting a host in a permitted country passes every rule above. And an automatic block list with no review path will eventually block something you wanted.
Minimum hardening. Run the poller as a dedicated user with a sudoers rule that permits exactly the ipset and iptables commands it needs, nothing wider. Give it a read-only Elasticsearch account scoped to the indices and fields it queries, not an admin. Fetch zone files over TLS and verify a checksum or signature before the install step applies them. Keep the auto-block TTL short and tested. Keep the sensor’s own capture data access controlled: Zeek logs contain every hostname and flow on your network, including your family’s. Log every push with what was blocked and why, and keep one documented unblock command that you have actually run once. And keep the automation away from the router’s base configuration; it may own its own set, and nothing else.
12. Hand it to an agent
This guide is structured so an AI agent can execute the phases that consist of commands a machine can run: the sensor install, the Elasticsearch field work, the router rules, and the verification battery. It is not a runnable build on its own. Four artifacts are specified above but not published (the poller, the filter, the registry, and the exploit rule text), and the switch mirror is a vendor UI task, so budget the agent time to write those from their sections rather than time to run them. Point an agent with shell access at this page and have it work the phases in order, reporting after each one: wire the tap, stand up the sensor, verify the query surface, deploy the poller, enforce on the router, then run the verification battery from section 8. Keep yourself in the approval path for anything touching the router’s firewall, the zone files, or the block registry. The alert loop itself is deliberately scripts: nothing in detection or enforcement depends on a model being reachable, and an agent rebuild should respect that.
