Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
04c0f3f
feat: Zeek SNI detection and domain reputation layer
Dhar01 Aug 1, 2026
493b940
add: golangci
Dhar01 Aug 3, 2026
72a2ade
add (enforcer): enforcer package terminates processess
Dhar01 Aug 3, 2026
4c0b523
add (store): ip_observations table, SNIOutcome handling, and Prune me…
Dhar01 Aug 3, 2026
4d8d978
update (somo): moved process kill to enforcer package
Dhar01 Aug 3, 2026
c00e181
update (monitor): RunScan improved with IP evaluation
Dhar01 Aug 3, 2026
89274cb
update (main): Prune database for old records
Dhar01 Aug 3, 2026
86ee303
chore: ignore local design notes
Dhar01 Aug 3, 2026
6fe9c88
Merge origin/main: sqlite 1.53.0, actions/checkout v7
Dhar01 Aug 4, 2026
a78953d
fix: golangci-lint errors
Dhar01 Aug 4, 2026
9728e0b
test: add coverage for somo, monitor, and pihole cache
Dhar01 Sep 20, 2026
a41293b
refactor(zeek): split ssl.log SNI cache from notice.log alert path
Dhar01 Sep 21, 2026
1f5b261
refactor(monitor): collapse RunScan's 9 params into a Scanner struct
Dhar01 Sep 21, 2026
acbc62a
docs: bring README in line with the Zeek/deghost decision ladder
Dhar01 Sep 21, 2026
d4fae4a
test: add coverage for internal/detector
Dhar01 Sep 21, 2026
6f21df6
fix: enforcer.go fails to vet/build on windows (undefined: syscall.Kill)
Dhar01 Sep 21, 2026
5b84bd6
feat(enforcer): kill the single connection via ss -K, not the whole p…
Dhar01 Sep 22, 2026
7079e87
ci: drop Windows build checks
Dhar01 Sep 22, 2026
1b5b97f
docs: document connection listing tools
Dhar01 Sep 22, 2026
bdd0939
feat: select ss before somo
Dhar01 Sep 22, 2026
5dbcc60
test: cover connection backend selection
Dhar01 Sep 22, 2026
6a80b77
test: cover backend availability
Dhar01 Sep 22, 2026
06eb300
feat: add ss connection lister
Dhar01 Sep 22, 2026
d471ba3
test: cover ss connection parsing
Dhar01 Sep 22, 2026
e092f0f
test: update connection tool references
Dhar01 Sep 22, 2026
23419f1
docs: mark Windows enforcer stub unsupported
Dhar01 Sep 22, 2026
c3f39d2
feat: inject connection lister into monitor
Dhar01 Sep 22, 2026
0fffa92
test: cover monitor connection lister
Dhar01 Sep 22, 2026
472f9ce
feat: log OS and selected connection tool
Dhar01 Sep 22, 2026
716877a
fix: README still called somo the primary tool; add IPv6 parser test
Dhar01 Sep 22, 2026
a89560a
feat: skip probing for ss on macOS, notify user of selected tool on s…
Dhar01 Sep 23, 2026
1df86fc
fix(zeek): parse Zeek's default TSV log format, not JSON-only
Dhar01 Sep 23, 2026
2672392
docs: README describes ss -K single-connection kill and macOS backend…
Dhar01 Sep 28, 2026
7fd74ba
fix(pihole): don't trust IPs of domains Pi-hole blocked
Dhar01 Oct 10, 2026
6abdb40
feat(pihole): apply Pi-hole regex allow and deny lists
Dhar01 Oct 11, 2026
18dfff2
feat: make deghost reputation checks opt-in
Dhar01 Oct 11, 2026
a538bd1
fix(zeek): read DNS-bypass notices from real Zeek output
Dhar01 Oct 11, 2026
b1c7ab0
feat(monitor): kill connections behind blocked Zeek bypass alerts
Dhar01 Oct 11, 2026
bb6c928
feat: add hexwall zeek-setup
Dhar01 Oct 11, 2026
6d1ed05
feat: add hexwall install-service
Dhar01 Oct 11, 2026
a8382a6
docs: README covers zeek-setup, install-service, bypass-alert kills a…
Dhar01 Oct 11, 2026
aaed219
feat: add hexwall uninstall
Dhar01 Oct 11, 2026
ac2199d
fix(pihole): stop the trust refresh from keeping itself alive
Dhar01 Oct 11, 2026
0f2cbb8
feat: refuse enforce mode without root, warn in watch mode
Dhar01 Oct 12, 2026
5e26818
style(pihole): gofmt IPCache constructor
Dhar01 Oct 12, 2026
9b73cfc
fix(zeek): say once that notice.log is missing, and when it appears
Dhar01 Oct 12, 2026
869ab04
fix(monitor): Zeek alerts respect the allowlist and local network
Dhar01 Oct 12, 2026
545eee3
fix: record what happened to each kill, not just that one was tried
Dhar01 Oct 12, 2026
6f5dfac
test: cover the decision ladder, the kill, IP reputation and zeek-setup
Dhar01 Oct 12, 2026
5e6c11e
fix(allowlist): trust every range that can't be on the internet
Dhar01 Oct 12, 2026
9107587
style: split functions into code paragraphs (wsl)
Dhar01 Oct 12, 2026
29f94e3
feat: warn at startup when Pi-hole uses groups
Dhar01 Oct 12, 2026
f433d43
fix(zeek): wait for ssl.log instead of exiting
Dhar01 Oct 12, 2026
5b9bb03
fix(deghost): kill on a blacklisted domain only, not on status
Dhar01 Oct 12, 2026
cb7952e
fix: a reputation verdict can't kill when an allow pattern is unreadable
Dhar01 Oct 12, 2026
165c4aa
fix(zeek): report a read error while looking for the ssl.log header
Dhar01 Oct 12, 2026
536a6d4
refactor(pihole): remove unused IsDomainKnown
Dhar01 Oct 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 9 additions & 21 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@ name: Go
on:
push:
paths:
- '**/*.go'
- 'go.mod'
- 'go.sum'
- '**/*.yml'
- "**/*.go"
- "go.mod"
- "go.sum"
- "**/*.yml"
pull_request:
paths:
- '**/*.go'
- 'go.mod'
- 'go.sum'
- '**/*.yml'
- "**/*.go"
- "go.mod"
- "go.sum"
- "**/*.yml"
create:

permissions:
Expand Down Expand Up @@ -48,38 +48,26 @@ jobs:
- name: Examine source code for MacOS AMD
run: GOOS=darwin GOARCH=amd64 go vet -v ./...

- name: Examine source code for Windows AMD
run: GOOS=windows GOARCH=amd64 go vet -v ./...

- name: Examine source code for Linux ARM
run: GOOS=linux GOARCH=arm64 go vet -v ./...

- name: Examine source code for MacOS ARM
run: GOOS=darwin GOARCH=arm64 go vet -v ./...

- name: Examine source code for Windows ARM
run: GOOS=windows GOARCH=arm64 go vet -v ./...

- name: Build for Linux AMD
run: GOOS=linux GOARCH=amd64 go build -v ./...

- name: Build for MacOS AMD
run: GOOS=darwin GOARCH=amd64 go build -v ./...

- name: Build for Windows AMD
run: GOOS=windows GOARCH=amd64 go build -v ./...

- name: Build for Linux ARM
run: GOOS=linux GOARCH=arm64 go build -v ./...

- name: Build for MacOS ARM
run: GOOS=darwin GOARCH=arm64 go build -v ./...

- name: Build for Windows ARM
run: GOOS=windows GOARCH=arm64 go build -v ./...

- name: Test
# https://stackoverflow.com/questions/58139406/only-run-job-on-specific-branch-with-github-actions
# https://stackoverflow.com/questions/58139406/only-run-job-on-specific-branch-with-github-actions
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: go test -race -v -cover -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic ./...

Expand Down
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,14 @@ tmp
.vscode
coverage.txt
.build
pihole-guard.db*
inventory.ndjson

# local design notes and session artifacts — not for publication
CHAT_SUMMARY_*.md
CLAUDE.md
OVERVIEW.md
SETUP.md
SNI_DETECTION_DESIGN.md
zeek-integration-guide.md
IMPLEMENTATION_NOTES.md
13 changes: 13 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
version: "2"
linters:
enable:
- staticcheck
- errcheck
- revive
# Blank lines split each function into "code paragraphs", one step each.
# `golangci-lint run --fix` adds them; CI checks they stay.
- wsl_v5

issues:
max-issues-per-linter: 0
max-same-issues: 0
218 changes: 158 additions & 60 deletions README.md

Large diffs are not rendered by default.

23 changes: 15 additions & 8 deletions internal/allowlist/allowlist.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Package allowlist defines statically trusted IP networks in CIDR notation.
// These networks bypass runtime reputation checks because they cover loopback,
// private-use space, and the link-local cloud metadata endpoint.
// These networks bypass runtime reputation checks because none of them is
// reachable on the public internet: loopback, private-use, shared (CGNAT,
// Tailscale), link-local, and their IPv6 equivalents. hexwall guards data
// leaving for the internet; a connection to one of these can't be that.
package allowlist

import (
Expand All @@ -11,12 +13,15 @@ import (
)

var cidrs = []string{
"127.0.0.0/8", // entire IPv4 loopback block
"::1/128", // single IPv6 loopback address
"10.0.0.0/8", // RFC 1918 private-use block
"172.16.0.0/12", // RFC 1918 private-use block
"192.168.0.0/16", // RFC 1918 private-use block
"169.254.169.254/32", // link-local cloud metadata endpoint
"127.0.0.0/8", // entire IPv4 loopback block
"::1/128", // single IPv6 loopback address
"10.0.0.0/8", // RFC 1918 private-use block
"172.16.0.0/12", // RFC 1918 private-use block
"192.168.0.0/16", // RFC 1918 private-use block
"100.64.0.0/10", // RFC 6598 shared address space: carrier-grade NAT, Tailscale
"169.254.0.0/16", // IPv4 link-local, including the cloud metadata endpoint 169.254.169.254
"fc00::/7", // IPv6 unique local addresses, IPv6's private-use space
"fe80::/10", // IPv6 link-local
}

var nets []*net.IPNet
Expand All @@ -32,6 +37,7 @@ func loadCIDRs(values []string) {
slog.Error("failed to parse allowlist CIDRs", "err", err)
continue
}

nets = append(nets, net)
}
}
Expand All @@ -44,6 +50,7 @@ func parseCIDR(value string) (*net.IPNet, error) {
if ip == nil {
return nil, fmt.Errorf("invalid IP or CIDR: %q", value)
}

if ip.To4() != nil {
cidr += "/32"
} else {
Expand Down
43 changes: 42 additions & 1 deletion internal/allowlist/allowlist_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,11 @@ func TestParseCIDR(t *testing.T) {
if err == nil {
t.Fatalf("parseCIDR(%q) error = nil, want %q", tc.value, tc.wantErr)
}

if err.Error() != tc.wantErr {
t.Fatalf("parseCIDR(%q) error = %q, want %q", tc.value, err.Error(), tc.wantErr)
}

return
}

Expand All @@ -94,6 +96,7 @@ func TestParseCIDR(t *testing.T) {
if ip == nil {
t.Fatalf("net.ParseIP(%q) = nil", ipText)
}

if !got.Contains(ip) {
t.Fatalf("network %q does not contain %q", got.String(), ipText)
}
Expand All @@ -104,6 +107,7 @@ func TestParseCIDR(t *testing.T) {
if ip == nil {
t.Fatalf("net.ParseIP(%q) = nil", ipText)
}

if got.Contains(ip) {
t.Fatalf("network %q unexpectedly contains %q", got.String(), ipText)
}
Expand All @@ -121,7 +125,7 @@ func TestLoadCIDRs(t *testing.T) {
{
name: "loads built in cidrs",
values: cidrs,
wantCIDRs: []string{"127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.169.254/32"},
wantCIDRs: []string{"127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16", "fc00::/7", "fe80::/10"},
},
{
name: "skips invalid entries and keeps valid ones",
Expand All @@ -143,11 +147,13 @@ func TestLoadCIDRs(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
nets = nil

if tc.name == "keeps existing nets and appends new ones" {
existing, err := parseCIDR("127.0.0.0/8")
if err != nil {
t.Fatalf("parseCIDR(existing) error = %v", err)
}

nets = []*net.IPNet{existing}
}

Expand All @@ -161,6 +167,7 @@ func TestLoadCIDRs(t *testing.T) {
if nets[i] == nil {
t.Fatalf("nets[%d] = nil, want %q", i, wantCIDR)
}

if nets[i].String() != wantCIDR {
t.Fatalf("nets[%d] = %q, want %q", i, nets[i].String(), wantCIDR)
}
Expand All @@ -176,6 +183,7 @@ func TestContains(t *testing.T) {
}()

nets = nil

loadCIDRs([]string{"10.0.0.0/8", "::1", "169.254.169.254"})

testCases := []struct {
Expand Down Expand Up @@ -232,6 +240,7 @@ func TestContains(t *testing.T) {

func TestContainsWithNoNetworks(t *testing.T) {
originalNets := nets

nets = nil
defer func() {
nets = originalNets
Expand All @@ -241,3 +250,35 @@ func TestContainsWithNoNetworks(t *testing.T) {
t.Fatal("Contains returned true with empty allowlist")
}
}

// The built-in list, checked at the edges of each range: one address inside the edge must
// be trusted, the address just outside must not. Public addresses are never trusted.
func TestBuiltInRangesEdges(t *testing.T) {
trusted := []string{
"100.64.0.1", "100.127.255.254", // shared space: CGNAT, Tailscale
"::ffff:100.101.2.3", // the same, written as IPv4-mapped IPv6
"169.254.1.1", "169.254.169.254", // link-local, cloud metadata
"fd7a:115c:a1e0::1", "fc00::1", // IPv6 unique local (the first is Tailscale's)
"fe80::9518:1f57:3e8:56d7", // IPv6 link-local
"192.168.0.223", "10.0.0.1", "127.0.0.1", "::1",
}
public := []string{
"100.63.255.255", "100.128.0.0", // just outside 100.64.0.0/10
"169.253.255.255", "169.255.0.0", // just outside 169.254.0.0/16
"fbff::1", "fe00::1", // just outside fc00::/7
"fec0::1", // just outside fe80::/10
"8.8.8.8", "1.1.1.1", "2001:4860:4860::8888",
}

for _, addr := range trusted {
if !Contains(net.ParseIP(addr)) {
t.Errorf("Contains(%s) = false, want true", addr)
}
}

for _, addr := range public {
if Contains(net.ParseIP(addr)) {
t.Errorf("Contains(%s) = true, want false: it can be a public address", addr)
}
}
}
133 changes: 133 additions & 0 deletions internal/connlist/backend_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package connlist

import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)

// writeFakeBinary writes an executable script into dir under the given name.
func writeFakeBinary(t *testing.T, dir, name, script string) {
t.Helper()

if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil {
t.Fatalf("write fake %s: %v", name, err)
}
}

func TestSelectBackend(t *testing.T) {
okScript := "#!/bin/sh\nexit 0\n"

tests := []struct {
name string
binaries []string // names to place on an otherwise-empty PATH
wantName string
wantErr bool
}{
{name: "ss present picks ss", binaries: []string{"ss", "somo"}, wantName: "ss"},
{name: "only somo present picks somo", binaries: []string{"somo"}, wantName: "somo"},
{name: "neither present fails", binaries: nil, wantErr: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
for _, bin := range tt.binaries {
writeFakeBinary(t, dir, bin, okScript)
}

t.Setenv("PATH", dir)

lister, name, err := SelectBackend()
if tt.wantErr {
if err == nil {
t.Fatal("SelectBackend() error = nil, want error")
}

return
}

if err != nil {
t.Fatalf("SelectBackend() error = %v, want nil", err)
}

if name != tt.wantName {
t.Fatalf("SelectBackend() name = %q, want %q", name, tt.wantName)
}

if lister == nil {
t.Fatal("SelectBackend() lister = nil, want non-nil")
}
})
}
}

// ---- selectBackend: macOS never probes for ss ---------------------------

func TestSelectBackendDarwinNeverCallsSSAvailable(t *testing.T) {
t.Parallel()

ssAvailableCalled := func() bool {
t.Error("ssAvailable() was called on darwin, want it never probed")
return true
}
somoInstalled := func() error { return nil }

_, name, err := selectBackend("darwin", ssAvailableCalled, somoInstalled)
if err != nil {
t.Fatalf("selectBackend(darwin) error = %v, want nil", err)
}

if name != "somo" {
t.Fatalf("selectBackend(darwin) name = %q, want %q (ss must never be picked on darwin, even if it claims available)", name, "somo")
}
}

func TestSelectBackendDarwinFailureMessageOmitsSS(t *testing.T) {
t.Parallel()

_, _, err := selectBackend("darwin", func() bool { return false }, func() error { return errors.New("not found") })
if err == nil {
t.Fatal("selectBackend(darwin) error = nil, want error when somo is also missing")
}

if !strings.Contains(err.Error(), "somo") {
t.Fatalf("selectBackend(darwin) error = %q, want it to mention somo", err)
}

if strings.Contains(err.Error(), "install ss") {
t.Fatalf("selectBackend(darwin) error = %q, want it to NOT suggest installing ss on macOS", err)
}
}

func TestSelectBackendLinuxUnaffected(t *testing.T) {
t.Parallel()

lister, name, err := selectBackend("linux", func() bool { return true }, func() error { return nil })
if err != nil {
t.Fatalf("selectBackend(linux) error = %v, want nil", err)
}

if name != "ss" {
t.Fatalf("selectBackend(linux) name = %q, want %q", name, "ss")
}

if lister == nil {
t.Fatal("selectBackend(linux) lister = nil, want non-nil")
}
}

func TestSelectBackendLinuxFailureMessageMentionsBoth(t *testing.T) {
t.Parallel()

_, _, err := selectBackend("linux", func() bool { return false }, func() error { return errors.New("not found") })
if err == nil {
t.Fatal("selectBackend(linux) error = nil, want error")
}

if !strings.Contains(err.Error(), "ss") || !strings.Contains(err.Error(), "somo") {
t.Fatalf("selectBackend(linux) error = %q, want it to mention both ss and somo", err)
}
}
Loading
Loading