Skip to content

Zeek integration - #11

Open
Dhar01 wants to merge 57 commits into
mainfrom
zeek_integration
Open

Dhar01 wants to merge 57 commits into
mainfrom
zeek_integration

Conversation

@Dhar01

@Dhar01 Dhar01 commented Aug 3, 2026 •

Copy link
Copy Markdown
Collaborator

name: Pull request
about: Submit a pull request for hexwall
title: Integration of zeek to catch network connections
labels: 'new feature'
assignees: Loknath


Description

The previous version of hexwall was designed to fix the gap pi-hole introduced, 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

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Refactor or internal cleanup
  • Documentation update
  • Breaking change

How has this been tested?

List the commands you ran and any manual verification steps.

go test ./...
golangci-lint run ./...

If you skipped any checks, explain why.

  • go test ./...
  • golangci-lint run ./...
  • Manual runtime verification

Test Configuration:

  • OS name and version: Linux pop-os 7.0.11-76070011-generic
  • Go compiler version: go1.26.1 linux/amd64
  • somo version: 1.3.1
  • Pi-hole DB source: pihole-FTL.db, gravity.db.
  • Mode used (watch or enforce): both

Newly added: zeek version 8.2.1

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation
  • My changes generate no new lint or vet issues
  • I have updated or intentionally skipped tests with explanation above

Copilot AI lite review requested due to automatic review settings August 3, 2026 11:42
@Dhar01 Dhar01 self-assigned this Aug 3, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/zeek tailers for ssl.log (SNI correlation cache) and notice.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/enforcer process 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.

Comment thread internal/zeek/zeek.go Outdated
Comment on lines +143 to +153
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
}
}
Comment thread internal/monitor/monitor.go Outdated
Comment on lines +85 to +90
selectedMode := normalizeMode(mode)
blocked, err := checker.IsBlockedByPolicy(event.SNI)
if err != nil {
slog.Warn("zeek policy lookup failed", "sni", event.SNI, "err", err)
return
}
Comment thread internal/monitor/monitor.go Outdated
Comment on lines +411 to +426
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)
}
Comment thread CLAUDE.md Outdated
Comment on lines +29 to +34
**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)`

Comment thread CHAT_SUMMARY_2026-04-06.md Outdated
- `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)`.
Comment thread internal/zeek/zeek.go Outdated
Comment on lines +206 to +229
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()
}
Dhar01 added 8 commits August 3, 2026 18:21
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.
Copilot AI review requested due to automatic review settings August 3, 2026 12:23
@Dhar01
Dhar01 force-pushed the zeek_integration branch from 9f415f8 to 86ee303 Compare August 3, 2026 12:23

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • KillProcess treats any error from syscall.Kill(pid, 0) as meaning the process is gone, but kill(pid, 0) can also fail with EPERM when the process still exists (e.g., insufficient permissions). That would incorrectly report success and skip the SIGKILL escalation. Only ESRCH should 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
	}

@Dhar01
Dhar01 force-pushed the zeek_integration branch from d264331 to 6fe9c88 Compare August 4, 2026 07:50
Copilot AI review requested due to automatic review settings August 4, 2026 07:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
	}

Dhar01 and others added 8 commits September 20, 2026 17:06
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants