hexwall is an outbound exfiltration brake for servers already protected by Pi-hole. When a supply-chain attack, malware implant, or compromised package is already running inside the machine, it can skip DNS entirely and send stolen data straight to a hard-coded IP. Pi-hole does not see that hop. somo can see the live connection. hexwall uses that visibility to flag and, in enforce mode, kill suspicious direct-IP connections before data leaves the server.
It monitors active network connections and kills any that connect to IPs that are not trusted by Pi-hole history, the built-in local allowlist, or recent already-established traffic — closing the gap that Pi-hole leaves open for direct-IP connections.
In short: Pi-hole + somo + hexwall gives you a practical containment layer for post-compromise outbound traffic, especially the kind of direct-IP exfiltration occasionally used in supply-chain attacks.
| Situation | Server without Pi-hole DNS | Server with only Pi-hole | Server with Pi-hole + somo + hexwall |
|---|---|---|---|
| Malicious domain resolved over DNS | ❌ Exposed | ✅ Can block at DNS layer | ✅ Can block at DNS layer |
| Malware connects to a hard-coded IP directly | ❌ Exposed | ❌ Pi-hole does not see it | ✅ Direct-IP connection is visible and checked |
| Identify which process owns the connection | ❌ No built-in visibility | ❌ No process visibility | ✅ somo shows PID/program |
| Stop outbound exfiltration after compromise | ❌ No containment layer | ✅ Can kill suspicious established connections | |
| Supply-chain malware bypassing DNS | ❌ High risk | ❌ Still exposed | ✅ Stronger containment |
| Overall outbound protection posture | ❌ Weak | 🛡️ Strongest of the three |
Pi-hole operates at the DNS layer. It can block a domain, but it has no visibility into connections that bypass DNS entirely — processes that dial a hard-coded IP address directly. Malware and compromised packages commonly use this technique to phone home without triggering any DNS-based block.
hexwall works from the inverse assumption: if an IP is not trusted, the connection is suspicious by default. Trust is granted when the IP matches the built-in CIDR allowlist, was refreshed from Pi-hole query history within the last hour, or was already observed as an established connection within the last 60 seconds. On startup it immediately refreshes trusted IPs from Pi-hole, then refreshes them every 30 seconds while scanning connections every 10 seconds. Anything that falls outside that trust set is checked against an external fraud API, with results cached locally for 6 hours per IP. Confirmed threats are either logged (watch mode) or killed (enforce mode).
| Dependency | Role | Required |
|---|---|---|
somo |
Lists established TCP/UDP connections; kills by PID | Must be in $PATH |
| Pi-hole FTL database | Source of trusted DNS history | Auto-detected or via --db |
deghostapi.hexbyte.dev |
Fraud/threat classification for unknown IPs | Network access required |
Build dependency: modernc.org/sqlite (pure-Go SQLite, no CGo or system libsqlite3 required).
CI publishes lightweight Linux binaries for:
linux/amd64linux/arm64
You can download them from the project's GitHub Releases page:
For production Linux servers, download the matching binary from Releases, place it where your deployment expects ./app/hexwall, and use the included demo Docker Compose file at docker-compose.yml to launch it on the server.
For macOS and Windows, prebuilt binaries are not published by CI. Clone the repository and build the binary manually before deployment.
If somo is not already in your $PATH, this is the quickest way to install and verify it on Ubuntu or Debian:
curl https://sh.rustup.rs -sSf | sh
sudo apt install build-essential
cargo install somo
sudo ln -s "$HOME/.cargo/bin/somo" /usr/local/bin/somo
sudo somoWhat these steps do:
- install Rust tooling with
rustup - install the compiler toolchain needed to build
somo - build and install
somowith Cargo - expose
somosystem-wide through/usr/local/bin - run
sudo somoonce to confirm it can see live connections and PIDs
Auto-detection checks in this order:
/etc/pihole/pihole-FTL.db— bare-metal / package install- A running Docker container whose name or image contains
pihole, inspecting its/etc/piholemount
If neither is found, start fails. Use --db to specify the path manually.
| Flag | Default | Description |
|---|---|---|
--db |
(auto-detected) | Path to pihole-FTL.db |
--hexwall-db |
./hexwall.db |
Path to the local hexwall database (created on first run) |
--mode |
watch |
watch — detect and log only; enforce — detect, log, and kill |
--debug |
false |
Enable verbose per-connection scan logging |
--mode accepts watch or enforce (case-insensitive). Any other value exits with an error.
When --debug is enabled, each scan cycle logs every connection it encounters with its status:
- allowed — IP is in the local trust cache or allowlist
- unrecognized-clean — IP is not in cache, but the fraud API returned a clean verdict (or 403 for private/reserved ranges)
- vulnerable — IP is not in cache, fraud API flagged it as abuser/attacker/threat
When debug is disabled (default), only non-allowed results are logged. Additionally, empty scans (somo returning zero connections) are always logged regardless of debug mode.
A connection is killed only when all of the following are true:
--mode enforceis active.somoreports the connection as currently established.- The remote IP is not trusted.
- The remote IP's current fraud decision, either fetched live or reused from the local 6-hour cache, marks it as
is_abuser,is_attacker, oris_threat.
An IP is considered trusted when any of these are true:
- It matches the built-in CIDR allowlist.
- It was refreshed from Pi-hole history within the last hour.
- It was already seen as an established allowed connection within the last 60 seconds.
Connections are not killed in these cases:
--mode watchis active. The connection is only logged aswould kill.- The IP is on the built-in allowlist.
- The IP is still trusted from a recent Pi-hole refresh or recent established-connection activity.
- The IP already has a cached clean fraud verdict from the last 6 hours.
- The fraud API returns HTTP
403, which is treated as clean/private/reserved. - The fraud API returns a report but none of
is_abuser,is_attacker, oris_threatare true. - The fraud lookup itself fails.
When a kill does happen, hexwall first records the event in the local killed_connections audit table and then asks somo to kill the owning PID.
Fraud lookups are cached in the local SQLite database for 6 hours per IP.
- If an untrusted IP has a cached fraud decision newer than 6 hours, hexwall reuses that cached result and does not call the fraud API again.
- If the cached decision is older than 6 hours, the next scan calls the fraud API again and refreshes the cache timestamp.
- Both clean and kill-worthy fraud decisions are cached.
- HTTP
403responses are treated as clean/private/reserved and cached as a non-kill result. - Fraud lookup failures are not cached.
This cache lives in the local hexwall database as Unix timestamps (INTEGER / int64-style seconds) and survives restarts.
Pi-hole trust is built from recent DNS history, not by directly trusting every current connection. The refresh flow is:
- Read the
queriestable frompihole-FTL.db. - Select distinct non-empty domains seen within the last hour.
- Normalize them to lowercase and trim whitespace.
- Resolve each domain through the system DNS resolver, with bounded concurrency and a 1 second lookup timeout per domain.
- Ignore domains that do not resolve. This is normal for blocked domains, expired records, and similar cases.
- Deduplicate resolved IPs. If multiple domains resolve to the same IP, the first domain in sorted order is stored for that IP.
- Upsert each resolved IP into the local
allowed_ipstable, setting or refreshinglast_refreshed.
Important details:
- Startup performs this refresh immediately before the first monitoring scan, so normal traffic seen in recent Pi-hole history is trusted before enforcement begins.
- The refresh repeats every 30 seconds.
- Trust from Pi-hole refresh lasts for 1 hour from the most recent successful upsert.
- Existing rows keep their original
first_approvedandlast_establishedvalues when refreshed; only the stored domain andlast_refreshedtimestamp are updated. - The monitor also extends trust for long-lived allowed connections by updating
last_establishedwhenever an already-trusted connection is seen still established.
This means an IP becomes trusted from Pi-hole history only after all of these happen:
- Pi-hole recorded a domain query for it within the last hour.
- That domain still resolves during a refresh cycle.
- One of the resolved IPs is written into the local
allowed_ipscache.
If a domain was seen in Pi-hole history but no longer resolves during refresh, no IP is added for it, so there is nothing to trust from that domain alone.