Repository navigation
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds a Zeek-based sensing path to hexwall so it can (a) correlate live connections with observed TLS SNI from ssl.log and (b) ingest Zeek DNS-bypass notices from notice.log, then evaluate those signals against Pi-hole policy (allow/deny/gravity) and the existing trust store.
Changes:
- Introduces
internal/zeektailers forssl.log(SNI correlation cache) andnotice.log(DNS-bypass events). - Expands Pi-hole integration to include gravity/policy lookups and adds domain/SNI matching + new store tables for per-IP domain sets and observations.
- Refactors the monitor into a “decision ladder”, adds domain-based deghost checks, and replaces somo-based enforcement with a stopgap
internal/enforcerprocess terminator.
Reviewed changes
Copilot reviewed 22 out of 23 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| zeek-integration-guide.md | Design/ops guide for Zeek DNS-bypass notice ingestion. |
| SNI_DETECTION_DESIGN.md | Design notes for SNI mismatch detection and correlation strategy. |
| SETUP.md | Local setup guide for Pi-hole + tool testing. |
| OVERVIEW.md | High-level product overview and threat-model explanation. |
| main.go | Adds flags, logging setup, Zeek watchers, pruning, and updated monitor wiring. |
| internal/zeek/zeek.go | Implements ssl.log tailer + notice.log watcher + parsers. |
| internal/zeek/zeek_test.go | Unit tests for Zeek log parsing/tailing behavior. |
| internal/store/store.go | Adds schema/tables, migrations, pruning, and new domain/SNI/IP observation APIs. |
| internal/store/store_test.go | Extensive tests for new store schema and behaviors. |
| internal/somo/somo.go | Removes PID kill helper (enforcement moved elsewhere). |
| internal/pihole/policy_test.go | Adds tests for gravity/policy lookup and counts. |
| internal/pihole/pihole.go | Adds gravity.db support, policy lookups, and domain matching helper. |
| internal/pihole/domain_match_test.go | Tests for DomainMatches. |
| internal/pihole/cache.go | Stores full IP→domains sets (not single domain per IP) and writes new store table. |
| internal/monitor/monitor.go | New decision ladder, Zeek event handler, domain policy gating, and enforcement path. |
| internal/enforcer/enforcer.go | Adds stopgap process-based enforcement and PID parsing. |
| internal/enforcer/enforcer_test.go | Tests for enforcer guardrails and PID parsing. |
| internal/deghost/deghost.go | Adds domain reputation endpoint + block predicate. |
| internal/deghost/deghost_test.go | Tests for domain reputation client + predicate. |
| CLAUDE.md | Adds repo guidance doc (currently out of sync with new flow/enforcement). |
| CHAT_SUMMARY_2026-04-06.md | Historical implementation summary (now partially out of date). |
| .golangci.yml | Adds golangci-lint configuration. |
| .gitignore | Adds local DB/inventory ignores. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| event := Event{Timestamp: time.Now()} | ||
| if ts, ok := payload["ts"].(string); ok { | ||
| if parsed, err := time.Parse(time.RFC3339Nano, ts); err == nil { | ||
| event.Timestamp = parsed | ||
| } | ||
| } | ||
| if ts, ok := payload["timestamp"].(string); ok { | ||
| if parsed, err := time.Parse(time.RFC3339Nano, ts); err == nil { | ||
| event.Timestamp = parsed | ||
| } | ||
| } |
| selectedMode := normalizeMode(mode) | ||
| blocked, err := checker.IsBlockedByPolicy(event.SNI) | ||
| if err != nil { | ||
| slog.Warn("zeek policy lookup failed", "sni", event.SNI, "err", err) | ||
| return | ||
| } |
| if err := hexwallStore.LogKill(ipStr, conn.PID, conn.Program); err != nil { | ||
| slog.Error("failed to log kill", "address", conn.RAddress, "err", err) | ||
| } | ||
|
|
||
| // Connection-level termination via `ss -K` (requires CONFIG_INET_DIAG_DESTROY) | ||
| // is deferred work. KillProcess terminates the owning process, which is coarser. | ||
| pidInt, err := enforcer.ParsePID(conn.PID) | ||
| if err != nil { | ||
| slog.Warn("invalid PID, skipping kill", "pid", conn.PID, "address", conn.RAddress, "err", err) | ||
| return | ||
| } | ||
| if err := enforcer.KillProcess(pidInt, conn.Program, conn.RAddress); err != nil { | ||
| slog.Warn("failed to kill process", "address", conn.RAddress, "pid", conn.PID, "err", err) | ||
| } else { | ||
| slog.Info("killed connection", "address", conn.RAddress) | ||
| } |
| **Flow:** | ||
| 1. `main.go` — entry point; runs `runCheck()` immediately, then on a 10s ticker | ||
| 2. `runCheck()` calls `somo.GetEstablishedConnections()` to get live TCP/UDP connections | ||
| 3. Each connection's remote IP is passed to `isVulnerable()` (currently a no-op stub) | ||
| 4. Vulnerable connections are killed via `somo.KillConnection(pid)` | ||
|
|
| - `ShouldKill(report) == false` -> log as unrecognized but clean; allow. | ||
| - `ShouldKill(report) == true`: | ||
| - if mode is `watch`: log "would kill" only. | ||
| - if mode is `enforce`: `store.LogKill(...)` then `somo.KillConnection(pid)`. |
| scanner := bufio.NewScanner(file) | ||
| for scanner.Scan() { | ||
| line := strings.TrimSpace(scanner.Text()) | ||
| if line == "" { | ||
| continue | ||
| } | ||
|
|
||
| event, err := ParseNoticeLine(line) | ||
| if err != nil { | ||
| continue | ||
| } | ||
|
|
||
| select { | ||
| case events <- event: | ||
| default: | ||
| } | ||
| } | ||
| if err := scanner.Err(); err != nil { | ||
| slog.Debug("zeek: notice log scan failed", "path", logPath, "err", err) | ||
| } | ||
| } | ||
| _ = file.Close() | ||
| offset = fi.Size() | ||
| } |
Adds a second trust signal alongside Pi-hole's IP history. Zeek reads the TLS SNI off the wire, giving the domain a connection actually claims rather than just the IP it dialed — closing the gap where a shared CDN edge IP is trusted for one domain and abused for another. - internal/zeek: new package. Tails ssl.log into a short-lived per-connection SNI cache keyed on local port, and watches notice.log for DNS-bypass alerts. Holds partial trailing lines across polls so records flushed mid-write are not silently dropped. - internal/pihole: IsBlockedByPolicy checks Pi-hole's blocklist directly, independent of query history. - internal/deghost: CheckDomain + ShouldBlockDomain for domain-shaped reputation lookups. Unlike the IP endpoint, 400/403 carry a real body and are decoded rather than treated as "safe". - internal/store: domain_checks cache (6h), plus zeek_alerts and sni_observations audit tables. - internal/monitor: SNI-bypass connections escalate through blocklist, then cache, then the domain API — never the IP reputation check, which reads clean on shared CDN addresses regardless of what is fronted through them. - main.go: --zeek-log, --zeek-notice-log, --enable-zeek flags; flag parsing extracted into parseFlags/runConfig. Zeek support defaults to off. Watch mode remains the default.
…thod for clearing the database
9f415f8 to
86ee303
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (1)
internal/enforcer/enforcer.go:90
KillProcesstreats any error fromsyscall.Kill(pid, 0)as meaning the process is gone, butkill(pid, 0)can also fail withEPERMwhen the process still exists (e.g., insufficient permissions). That would incorrectly report success and skip the SIGKILL escalation. OnlyESRCHshould be treated as “process no longer exists”; other errors should fall through to the escalation path (or be handled explicitly).
// Check whether the process still exists.
if err := syscall.Kill(pid, 0); err != nil {
// Process is gone — SIGTERM worked.
slog.Info("process terminated by SIGTERM", "pid", pid, "program", program)
return nil
}
d264331 to
6fe9c88
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (6)
internal/store/store.go:617
- RecentZeekAlert queries by (src_ip, sni) using exact equality. Without normalizing inputs to match LogZeekAlert's stored canonical form, lookups can miss equivalent values that differ only in case/whitespace.
cutoff := time.Now().Add(-time.Hour).Unix()
internal/monitor/monitor.go:413
- applyBlockedOutcome logs a kill to killed_connections before validating the PID or attempting the termination. With the new enforcer guardrails (protected programs, invalid PID, own PID, etc.), this can record "killed" rows even when nothing was actually killed, reducing audit accuracy. Log the kill only after KillProcess succeeds (or rename/store intent separately).
if err := hexwallStore.LogKill(ipStr, conn.PID, conn.Program); err != nil {
slog.Error("failed to log kill", "address", conn.RAddress, "err", err)
}
internal/monitor/monitor.go:83
- HandleZeekEvent persists Zeek notices to zeek_alerts, but it only checks event.SNI. The zeek_alerts schema requires src_ip/dst_ip/dst_port/sni NOT NULL, so events missing any of these fields will trigger repeated insert errors and noisy logs. Normalize SNI before policy lookup/persist and skip events missing required fields.
if event.SNI == "" {
return
}
internal/store/store.go:383
- UpsertAllowedIPDomain lowercases/trims the domain but still allows an empty string, which can create meaningless rows and waste space (and complicate later matching/debugging). Reject empty domains explicitly.
This issue also appears on line 617 of the same file.
func (s *Store) UpsertAllowedIPDomain(ip, domain string) error {
domain = strings.ToLower(strings.TrimSpace(domain))
now := time.Now().Unix()
internal/zeek/zeek.go:206
- WatchNoticeLog uses bufio.Scanner with the default 64K token limit; a long JSON notice line will cause scanner.Err() (typically ErrTooLong) and the event will be skipped. Increase the scanner buffer to avoid silently dropping legitimate Zeek notices.
scanner := bufio.NewScanner(file)
internal/store/store.go:603
- LogZeekAlert stores SNI and IP fields without normalization/validation. Because RecentZeekAlert matches on exact sni/src_ip and the table columns are NOT NULL, mixed-case or whitespace in SNI can prevent dedupe/lookups and missing fields will fail at INSERT time. Normalize/validate inputs before writing.
func (s *Store) LogZeekAlert(srcIP, dstIP, dstPort, sni string, blocked bool, confidence, actionTaken string) error {
blockedInt := 0
if blocked {
blockedInt = 1
}
internal/somo and internal/monitor had zero tests despite monitor.RunScan being the core kill-decision path. Adds unit tests for both, plus for pihole's DNS-history cache refresh. cache.go gains injectable domainsSeenSince/lookupHost fields on IPCache so the refresh logic can be tested without a real Pi-hole DB or live DNS resolution. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018yCDQPH4oP4xQcGZUKMqaH
zeek.go bundled two unrelated systems: the ssl.log tailer that builds the in-memory SNI cache monitor.RunScan queries, and the independent notice.log watcher that feeds the DNS-bypass alert path. Split into zeek.go (Client/SNI cache) and notice.go (Event/ParseNoticeLine/ WatchNoticeLog), with tests split the same way. Also added missing doc comments on the two exported notice-parsing errors, per golangci-lint. No behavior change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018yCDQPH4oP4xQcGZUKMqaH
RunScan took 9 parameters and evaluateIPOnly took 8, all long-lived collaborators (checker, store, deghost/zeek clients) and config flags (mode, deghostIP, deghostDomain, debug) that don't change between scan ticks. Introduces monitor.Scanner holding them as fields; RunScan, evaluateIPOnly, applyBlockedOutcome, and HandleZeekEvent become methods on it. main.go builds one Scanner after its dependencies exist and calls scanner.Run(ctx) / scanner.HandleZeekEvent(event) instead of threading the same 8-9 arguments through every call site. No behavior change. Tests updated to construct *Scanner instead of calling the old free functions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018yCDQPH4oP4xQcGZUKMqaH
The CLI flags table was missing --enable-zeek, --zeek-log, --zeek-notice-log, --enable-deghost-ip, --enable-deghost-domain, and --version entirely. Worse, "When a connection is killed" only described the IP-only fraud-API path, implying that was the sole trigger — it omitted that a Pi-hole domain denylist match (via Zeek SNI) can kill on its own with both deghost flags disabled. Also refreshed the stale three-category debug-logging description and added Zeek to Dependencies. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018yCDQPH4oP4xQcGZUKMqaH
detector had zero tests. Split FindDBPath's body into an unexported findDBPath that takes the physical path and Docker fallback as parameters (same injectable-dependency pattern already used in pihole.IPCache), so tests can exercise the physical-found, physical-is-a-directory, and Docker-fallback branches without touching the real /etc/pihole or requiring a Docker daemon. findPiholeContainer and findDockerDBPath are covered via the same fake-executable-on-PATH technique already used for somo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018yCDQPH4oP4xQcGZUKMqaH
syscall.Kill is POSIX-only and doesn't exist in Go's windows syscall package, breaking `go vet`/`go build` for windows/amd64 and windows/arm64 in CI. Split the platform-specific signal-sending out of KillProcess into terminate(pid, program), implemented in enforcer_unix.go (the real SIGTERM/grace-period/SIGKILL logic, behind //go:build !windows) and enforcer_windows.go (a stub returning "not supported", behind //go:build windows). The portable guardrail checks in KillProcess itself are unchanged. Verified go vet and go build succeed for all six OS/arch combinations CI checks.
…rocess Resolves the STOPGAP noted in enforcer.go and ARCHITECTURE.md: killing the owning process took down every other connection it held, not just the blocked one. Adds KillConnection(pid, program, localPort, remoteIP, remotePort), which tries ss -K dst <ip> dport <port> sport <localPort> first (CONFIG_INET_DIAG_DESTROY, requires CAP_NET_ADMIN) and falls back to the existing whole-process KillProcess only when that isn't possible. ss -K's own exit code isn't trustworthy (observed exiting 0 even on EPERM), so success is confirmed by re-querying the socket table for the exact tuple afterward, not by trusting ss's return value. Guardrail checks (PID <= 0, PID 1, own PID, protected programs) are shared between both kill paths via checkGuardrails, applied once before either is attempted. monitor.Scanner.applyBlockedOutcome now calls KillConnection instead of KillProcess directly. Verified go vet/go build clean across all six OS/arch combinations CI checks, plus new tests covering KillConnection's guardrails, killSocket against a fake ss on PATH, and socketStillEstablished against a real loopback TCP connection.
Review of the ss/somo backend-selection work found two gaps:
- README's intro, tagline, and two other prose spots still described
somo as THE connection tool, unqualified, even though the newer
Dependencies table and "Connection backend" section correctly say ss
is now the default. Fixed all five stale spots to match.
- The ss output parser had zero test coverage for IPv6. Verified
manually against a real IPv6 loopback connection: ss brackets IPv6
addresses ("[::1]:PORT"), and the parser's LastIndex(":") split
leaves the brackets in RAddress rather than stripping them. This
works today only because monitor.remoteIP() strips brackets itself
as a fallback — not a bug, but an untested implicit dependency
between two packages. Added a test pinning the current (correct)
behavior so a future change to either side can't silently break it.
…creen ss depends on CONFIG_INET_DIAG_DESTROY, a Linux kernel feature — a binary named "ss" on macOS could never actually destroy a socket even if one existed, so probing for it there was always pointless. Split SelectBackend into a thin public wrapper and an injectable selectBackend(goos, ssAvailable, somoInstalled) so this can be tested deterministically regardless of the host OS: on darwin, ssAvailable is never even called, and the "neither found" error mentions only somo instead of suggesting an ss install that could never work. Also make the connection-listing tool choice visible on screen (fmt.Println), not just in the structured slog line, both on success and on the fatal "neither tool found" exit.
Real, previously undiscovered bug: zeek.go's parser only ever handled JSON-formatted Zeek logs (json.Unmarshal on every line), but a stock Zeek install's default LogAscii output is tab-separated, not JSON. Every line silently failed to parse and was skipped -- no error, no warning. This meant rung 2 of the decision ladder (Zeek SNI lookup) permanently reported "no SNI" for every single connection on any default Zeek setup, silently disabling the entire SNI/domain-matching capability with zero indication anything was wrong. Found by live end-to-end testing against a real Zeek 9.0.0 instance (the existing unit tests only ever used hand-written JSON fixtures, which is why this was never caught). Adds TSV parsing: the column order is learned once from the log's own "#fields" header rather than hardcoded, so it survives Zeek versions or configs that add columns. Since the tailer normally starts reading from the current end of the file (past the header, which Zeek writes once at file creation), NewClient now does a separate, small read from the start of the file specifically to learn the header. A "#fields" line seen during normal tailing (the post-rotation case) also updates the learned column map, so a rotated log's fresh header is picked up the same way. JSON parsing is unchanged and still supported. Confirmed fixed live: after this change, a real held-open TLS connection's SNI was correctly matched, and the full decision ladder (rungs 2 through 6, including a live Pi-hole gravity.db check and a live deghost API call) fired end-to-end for the first time. README updated: documents both supported log formats and the Zeek version (9.0.0) this was verified against.
DomainsSeenSince read every query in the FTL log, including blocked ones. The trust cache then re-resolved those domains through the host's resolver, so with a fallback nameserver a blocked domain's real IP became trusted on the IP-only rung. Only statuses where Pi-hole answered the query now feed the cache; the list is an allow-list so new FTL status codes stay untrusted until reviewed.
Rungs 3 and 4 only checked the exact lists and gravity; the regex views were counted at startup and never used. A regex-denied domain reached by direct IP was not blocked, and a regex-allowed domain fell through to the reputation API, which could kill it in enforce mode. Patterns are compiled once, matched case-insensitively like FTL, and reloaded on the 30s refresh only when the lists change. FTL options hexwall cannot honour for a connection (querytype, unknown ones) resolve toward not killing: an allow rule still applies, a deny rule is skipped.
Both --enable-deghost-* flags defaulted to true while the code comment called deghost opt-in. Default them to false so no connection data leaves the machine unless asked, and only the user's own Pi-hole rules can kill by default. Without --zeek-log every connection takes the IP-only path, which can't kill with IP deghost off; warn at startup when enforce mode would therefore protect nothing.
The notice.log parser only accepted JSON and pulled the SNI out of msg with a regex. Zeek's default TSV output failed every line, and the JSON output had no SNI, so every bypass alert was dropped without a warning. The detection script now ships embedded in the binary and writes the SNI to notice's sub field. The parser reads TSV (columns from #fields) and JSON, keeps only DNSBypass::PossibleBypass notices, records the source port, and waits for complete lines. Alerts already in the file at startup are skipped so a restart can't act on old ones. Parse errors and a full alert channel are now warned about. Fixtures are real Zeek 9.0.0 output.
A bypass alert for a Pi-hole-blocked domain was only logged, even in enforce mode. The alert's 4-tuple is now looked up among this host's sockets and, when found, goes through applyBlockedOutcome, the same guarded kill path the decision ladder uses. Alerts about other devices can't be killed here; they are recorded as not_on_this_host with a warning.
Installing the DNS-bypass script was a manual step documented nowhere users would look. `sudo hexwall zeek-setup` writes the embedded script into Zeek's site directory, loads it from local.zeek once, checks that node.cfg's interfaces exist, and prints the zeekctl deploy step and the hexwall flags for the configured log directory. It does not restart Zeek or edit node.cfg.
Nothing restarted hexwall or Zeek after a reboot, so protection stopped silently. `sudo hexwall install-service [flags]` validates the flags, then writes hexwall.service (this binary's absolute path, state in /var/lib/hexwall, restart on failure) and, when the flags use Zeek, a zeekctl-driven zeek.service that hexwall is ordered after. A zeek.service hexwall didn't write is left alone. The unit runs in watch mode unless --mode is given explicitly. go run binaries are refused because they are deleted on exit. parseFlags now takes its arguments so install-service can validate the service's flags without running hexwall.
…nd regex lists Also corrects two claims the code had proved wrong: notice.log does need Zeek-side setup, and blocked domains are excluded from trust up front rather than assumed not to resolve.
Undoing install-service and zeek-setup took seven hand-typed commands and knowledge of every file hexwall touched. `sudo hexwall uninstall` stops, disables and deletes hexwall's units, removes the @load lines it added to local.zeek, and deletes the script. Only files carrying hexwall's signature are touched, so units and scripts someone else wrote survive. The database (kill and alert history) and the binary are kept, with the command to delete the data printed.
Every 30s the refresh re-resolved every domain seen in the last hour. On a host whose resolver is Pi-hole, each lookup was logged as a new query, so every domain stayed "seen in the last hour" and its IPs were trusted forever; Pi-hole logged ~530 queries a minute of hexwall's own. Queries are now read once, by id, and hexwall remembers when it looked up each domain so the matching logged query is recognised as its own echo. Trust lasts an hour from a client's last real query. A flushed query log (ids restarting) is detected and the last hour re-read. Measured live: Pi-hole's query rate went from ~530 to ~7 a minute.
Without root, ss can't name other users' programs, ss -K can't close sockets and other users' processes can't be signalled, so enforce mode started and then failed every kill quietly. It now refuses to start; watch mode, which still sees connections, starts with a warning. A hexwall.db owned by another user (often root, from an earlier sudo run) failed with "attempt to write a readonly database"; the error now says what that means and what to do.
The notice watcher polls every 2s and logged "cannot open notice log" on every poll until Zeek wrote its first alert. The tailer now remembers what it last reported about the file and logs only when that changes: once while waiting (normal until Zeek's first alert), once when the file is found, and once as a warning when it exists but can't be opened. A file that appears after going missing is read from the start, so no alert in it is lost.
HandleZeekEvent decided "blocked" from rung 4 alone. A domain on both gravity and the Pi-hole allowlist (the allowlist's main use: undoing a blocklist's mistake) is answered by Pi-hole and spared by the scan, but a bypass alert about it killed the connection in enforce mode. A connection to the local network could be killed the same way. Alerts now go through rungs 1, 3 and 4 in the scan's order. An allowlist lookup error is reported instead of read as "not allowed", so it can't lead to a kill.
applyBlockedOutcome wrote the killed_connections row before the guardrails ran, so a kill refused for PID 1, hexwall itself or sshd, or one that failed, read as a kill in the audit log. Writing first is kept so the row survives a crash mid-kill, but it now starts as "attempting" and is updated to killed, refused or failed. Guardrail refusals wrap enforcer.ErrRefused so callers can tell them from failures. Existing databases gain the outcome column on startup; their old rows say "unknown".
Run's SNI rungs (2-6) had no tests: the scanner took a concrete *zeek.Client, whose SNI cache only a real ssl.log and the 5s tail loop can fill. Scanner.ZeekClient is now an SNISource interface (one Lookup method); production passes the Zeek client, tests a fixed map. Client.Lookup answers "no SNI" on a nil receiver, since a nil client in the interface is not a nil interface. New tests walk every rung (allowlist over gravity; denylist, gravity and regex kills; known-for-IP trust; unknown never killed with deghost off; deghost verdicts cached; no SNI goes to the IP path), KillConnection (ss -K targets the exact tuple, process fallback, guardrails before ss), deghost CheckIP/ShouldKill, and zeek-setup/uninstall end to end against a fake zeek-config. No test signals a real process. Coverage: monitor 58->78%, enforcer 54->78%, deghost 42->87%, zeek 55->69%.
The static allowlist covered loopback, RFC 1918 and the cloud metadata address only. Connections to Tailscale peers and carrier-grade NAT (100.64.0.0/10), IPv4 link-local (169.254.0.0/16), IPv6 unique local (fc00::/7) and IPv6 link-local (fe80::/10) went through the reputation path like public addresses and, with deghost on, could be killed. A new test checks each range at its edges: the first address inside is trusted, the neighbour just outside is not.
Enable the wsl_v5 linter and apply its fixes across the repo: blank lines separate each step of a function, lines stay together only when they're about the same variable, and a return that ends a longer block stands on its own. Consecutive var lines are grouped into one block. Whitespace and declaration grouping only; no behaviour change.
hexwall can't tell which Pi-hole group this machine's queries fall into, so a domain blocked for any group counts as blocked here. With only the Default group that is exact; with more, it can be stricter than Pi-hole's own rules for this machine. Warn once at startup when enabled groups other than Default exist, instead of supporting groups.
Zeek creates ssl.log at the first TLS connection it sees, so on a quiet server or right after boot it may not exist yet. hexwall exited, and under systemd restarted every 5s, checking nothing in between although the IP-only path needs no Zeek. It now starts, says once that it is waiting, keeps scanning, and reads the file from its first line once it appears. A file that can't be opened for another reason still stops startup. A missing file now also resets the read position, which fixes a rotation bug: a new ssl.log that grew past the old file's offset before the next poll had its first lines skipped. The waiting/found/unreadable messages are shared with notice.log in logfile.go.
ShouldBlockDomain also blocked on status "not_allowed". The API's status judges a domain as an email domain: it says "not_allowed" for any domain without an MX record, which covers most API and CDN hosts (api.github.com, cdn.jsdelivr.net), and "allowed" for an ad tracker with MX records. With --enable-deghost-domain in enforce mode, hexwall would have killed GitHub's API. Only blacklisted counts now. Verdicts are cached for 6h, so on a machine that ran the old rule with domain reputation on, old verdicts expire within 6h of upgrading.
A Pi-hole regex allow rule Go can't compile is skipped, so a domain the user allowed with it reads as "not allowed", and a domain-reputation verdict could then kill it. While any allow rule is unreadable, rung 6 reports the verdict instead of killing, the same rule as a rung-3 lookup error.
readTSVHeader never checked the scanner's error, so a read failure (or a header line over bufio.Scanner's 64 KiB limit) looked the same as "no header", after which no TSV line can be read, silently. It now warns.
name: Pull request
about: Submit a pull request for hexwall
title: Integration of zeek to catch network connections
labels: 'new feature'
assignees:
LoknathDescription
The previous version of
hexwallwas designed to fix the gappi-holeintroduced, but it still fails for alternate resolvers, connecting via CNAME alias. A shared CDN edge address legitimately serves thousands of unrelated domains, an attacker can ride an already trusted IP by setting the TLS SNI to their hostname.This branch closes that gap by reading the SNI directly off the wire via
Zeek, by checking domains against pi-hole's real policy lists rather than inferring from history.Type of change
How has this been tested?
List the commands you ran and any manual verification steps.
If you skipped any checks, explain why.
go test ./...golangci-lint run ./...Test Configuration:
Linux pop-os 7.0.11-76070011-genericgo1.26.1 linux/amd64somoversion:1.3.1pihole-FTL.db,gravity.db.watchorenforce): bothChecklist