Skip to content
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
10 changes: 10 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
version: "2"
linters:
enable:
- staticcheck
- errcheck
- revive

issues:
max-issues-per-linter: 0
max-same-issues: 0
76 changes: 76 additions & 0 deletions internal/deghost/deghost.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,79 @@ func ShouldKill(report *IPReport) bool {

return report.Security.IsAbuser || report.Security.IsAttacker || report.Security.IsThreat
}

// DomainReport matches the API payload for domain reputation checks.
type DomainReport struct {
Status string `json:"status"`
HasMX bool `json:"has_mx"`
Disposable bool `json:"disposable"`
Spam bool `json:"spam"`
PublicDomain bool `json:"public_domain"`
RelayDomain bool `json:"relay_domain"`
Blacklisted bool `json:"blacklisted"`
DomainAgeInDays int `json:"domain_age_in_days"`
}

// CheckDomain fetches a reputation report for a single domain.
// Unlike CheckIP's 403 handling (nil report for private/reserved IPs), this endpoint
// returns a real JSON body on HTTP 200, 400, and 403 — all are decoded into a DomainReport.
// Only 499/504/500/other status codes are treated as hard errors.
func (c *Client) CheckDomain(ctx context.Context, domain string) (*DomainReport, error) {
if c == nil {
return nil, errors.New("nil deghost client")
}
if c.httpClient == nil {
return nil, errors.New("nil deghost http client")
}

baseURL := strings.TrimSpace(c.baseURL)
if baseURL == "" {
return nil, errors.New("deghost base URL is required")
}

domain = strings.ToLower(strings.TrimSpace(domain))
if domain == "" {
return nil, errors.New("domain is required")
}

endpoint, err := url.JoinPath(baseURL, "domain", domain)
if err != nil {
return nil, fmt.Errorf("build endpoint: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}

resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()

if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusForbidden {
return nil, fmt.Errorf("deghost returned status %d %s", resp.StatusCode, http.StatusText(resp.StatusCode))
}

var report DomainReport
if err := json.NewDecoder(resp.Body).Decode(&report); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}

return &report, nil
}

// ShouldBlockDomain reports whether the domain report matches the current block policy.
// It triggers only on explicit negative signals (status "not_allowed" or blacklisted),
// ignoring softer signals like disposable, spam, or domain age which are too noisy
// to act on alone — consistent with how ShouldKill only fires on explicit threat fields.
func ShouldBlockDomain(report *DomainReport) bool {
if report == nil {
return false
}

return report.Status == "not_allowed" || report.Blacklisted
}
284 changes: 284 additions & 0 deletions internal/deghost/deghost_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
package deghost

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)

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

want := DomainReport{
Status: "allowed",
HasMX: true,
Disposable: false,
Spam: false,
PublicDomain: true,
RelayDomain: false,
Blacklisted: false,
DomainAgeInDays: 11117,
}

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("method = %s, want GET", r.Method)
}
if r.URL.Path != "/domain/example.com" {
t.Errorf("path = %s, want /domain/example.com", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(want)
}))
defer srv.Close()

client := NewClient(srv.URL, srv.Client().Timeout)
got, err := client.CheckDomain(context.Background(), "example.com")
if err != nil {
t.Fatalf("CheckDomain() error = %v", err)
}
if got == nil {
t.Fatal("CheckDomain() = nil, want report")
}
if got.Status != want.Status {
t.Errorf("Status = %q, want %q", got.Status, want.Status)
}
if got.HasMX != want.HasMX {
t.Errorf("HasMX = %v, want %v", got.HasMX, want.HasMX)
}
if got.DomainAgeInDays != want.DomainAgeInDays {
t.Errorf("DomainAgeInDays = %d, want %d", got.DomainAgeInDays, want.DomainAgeInDays)
}
}

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

want := DomainReport{
Status: "not_allowed",
HasMX: false,
Disposable: false,
Spam: false,
PublicDomain: false,
RelayDomain: false,
Blacklisted: false,
DomainAgeInDays: 0,
}

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
_ = json.NewEncoder(w).Encode(want)
}))
defer srv.Close()

client := NewClient(srv.URL, srv.Client().Timeout)
got, err := client.CheckDomain(context.Background(), "blocked.example.com")
if err != nil {
t.Fatalf("CheckDomain() on 403 error = %v", err)
}
if got == nil {
t.Fatal("CheckDomain() on 403 = nil, want decoded report (not nil like IP endpoint)")
}
if got.Status != "not_allowed" {
t.Errorf("Status = %q, want %q", got.Status, "not_allowed")
}
}

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

want := DomainReport{
Status: "not_allowed",
HasMX: false,
Blacklisted: true,
DomainAgeInDays: 0,
}

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(want)
}))
defer srv.Close()

client := NewClient(srv.URL, srv.Client().Timeout)
got, err := client.CheckDomain(context.Background(), "bad.example.com")
if err != nil {
t.Fatalf("CheckDomain() on 400 error = %v", err)
}
if got == nil {
t.Fatal("CheckDomain() on 400 = nil, want decoded report")
}
if !got.Blacklisted {
t.Error("Blacklisted = false, want true")
}
}

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

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()

client := NewClient(srv.URL, srv.Client().Timeout)
got, err := client.CheckDomain(context.Background(), "example.com")
if err == nil {
t.Fatalf("CheckDomain() on 500 = %v, want error", got)
}
}

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

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusGatewayTimeout)
}))
defer srv.Close()

client := NewClient(srv.URL, srv.Client().Timeout)
got, err := client.CheckDomain(context.Background(), "example.com")
if err == nil {
t.Fatalf("CheckDomain() on 504 = %v, want error", got)
}
}

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

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(499)
}))
defer srv.Close()

client := NewClient(srv.URL, srv.Client().Timeout)
got, err := client.CheckDomain(context.Background(), "example.com")
if err == nil {
t.Fatalf("CheckDomain() on 499 = %v, want error", got)
}
}

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

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
if _, err := fmt.Fprint(w, `{"status": "allowed" bad json`); err != nil {
t.Errorf("fmt.Fprint: %v", err)
}
}))
defer srv.Close()

client := NewClient(srv.URL, srv.Client().Timeout)
got, err := client.CheckDomain(context.Background(), "example.com")
if err == nil {
t.Fatalf("CheckDomain() on bad JSON = %v, want error", got)
}
}

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

var c *Client
got, err := c.CheckDomain(context.Background(), "example.com")
if err == nil {
t.Fatalf("CheckDomain() on nil client = %v, want error", got)
}
}

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

client := &Client{baseURL: "", httpClient: &http.Client{}}
got, err := client.CheckDomain(context.Background(), "example.com")
if err == nil {
t.Fatalf("CheckDomain() on empty baseURL = %v, want error", got)
}
}

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

client := NewClient("https://example.com", 5)
got, err := client.CheckDomain(context.Background(), " ")
if err == nil {
t.Fatalf("CheckDomain() on empty domain = %v, want error", got)
}
}

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

var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(DomainReport{Status: "allowed"})
}))
defer srv.Close()

client := NewClient(srv.URL, srv.Client().Timeout)
_, err := client.CheckDomain(context.Background(), " Example.COM ")
if err != nil {
t.Fatalf("CheckDomain() error = %v", err)
}
want := "/domain/example.com"
if gotPath != want {
t.Errorf("request path = %q, want %q (domain should be trimmed and lowercased)", gotPath, want)
}
}

func TestShouldBlockDomainNilReport(t *testing.T) {
t.Parallel()
if ShouldBlockDomain(nil) {
t.Error("ShouldBlockDomain(nil) = true, want false")
}
}

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

report := &DomainReport{Status: "not_allowed"}
if !ShouldBlockDomain(report) {
t.Error("ShouldBlockDomain(not_allowed) = false, want true")
}
}

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

report := &DomainReport{Status: "allowed", Blacklisted: true}
if !ShouldBlockDomain(report) {
t.Error("ShouldBlockDomain(blacklisted) = false, want true")
}
}

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

report := &DomainReport{Status: "allowed", Blacklisted: false}
if ShouldBlockDomain(report) {
t.Error("ShouldBlockDomain(allowed, not blacklisted) = true, want false")
}
}

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

report := &DomainReport{Status: "allowed", Disposable: true}
if ShouldBlockDomain(report) {
t.Error("ShouldBlockDomain(disposable alone) = true, want false")
}
}

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

report := &DomainReport{Status: "allowed", Spam: true}
if ShouldBlockDomain(report) {
t.Error("ShouldBlockDomain(spam alone) = true, want false")
}
}
Loading
Loading