quorate/services/oci-l4-relay-guest/cmd/smtp-evidence/main_test.go

270 lines
11 KiB
Go
Raw Normal View History

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"os"
"path/filepath"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
)
func TestParseRecordsSortsAndBoundsEvidence(t *testing.T) {
now := time.Date(2026, 7, 30, 0, 0, 0, 0, time.UTC)
contents := []byte(`{"nftables":[{"metainfo":{"json_schema_version":1}},{"set":{"family":"inet","name":"smtp_syn_quarantine_v4","table":"oci_l4_relay","elem":[{"elem":{"val":"198.18.0.2","expires":598,"counter":{"packets":4,"bytes":160}}},{"elem":{"val":"198.18.0.1","expires":599,"counter":{"packets":1,"bytes":40}}}]}}]}`)
records, total, err := parseRecords(now, contents, "smtp_syn_quarantine_v4", 1)
if err != nil {
t.Fatal(err)
}
if total != 2 || len(records) != 1 || records[0].Address != "198.18.0.1" || records[0].Count != 1 || !records[0].ExpiresAt.Equal(now.Add(599*time.Second)) {
t.Fatalf("unexpected records %#v", records)
}
}
func TestParseRecordsRequiresCanonicalIPv4(t *testing.T) {
contents := []byte(`{"nftables":[{"set":{"family":"inet","name":"smtp_syn_quarantine_v4","table":"oci_l4_relay","elem":[{"elem":{"val":"2001:db8::1","expires":598,"counter":{"packets":1}}}]}}]}`)
if _, _, err := parseRecords(time.Now(), contents, "smtp_syn_quarantine_v4", 1); err == nil {
t.Fatal("IPv6 evidence was accepted")
}
}
func TestParseDropMetricsRequiresEveryBoundedCounter(t *testing.T) {
items := make([]string, 0, len(dropMetricSpecs))
for index, spec := range dropMetricSpecs {
items = append(items, fmt.Sprintf(`{"counter":{"name":%q,"packets":%d}}`, spec.Name, index+1))
}
metrics, err := parseDropMetrics([]byte(`{"nftables":[`+strings.Join(items, ",")+`]}`), true)
if err != nil {
t.Fatal(err)
}
if len(metrics) != len(dropMetricSpecs) || metrics[0].Service != "web" || metrics[0].Reason != "admission" || metrics[0].Packets != 1 {
t.Fatalf("unexpected metrics %#v", metrics)
}
if _, err := parseDropMetrics([]byte(`{"nftables":[]}`), true); err == nil {
t.Fatal("missing counters were accepted")
}
}
func TestMetricsHandlerExposesBoundedEvidenceAndDrops(t *testing.T) {
generated := time.Date(2026, 7, 30, 1, 0, 0, 0, time.UTC)
var latest atomic.Pointer[metricsSnapshot]
latest.Store(&metricsSnapshot{
GeneratedAt: generated,
Services: []serviceMetrics{
{Service: "web", Records: []evidenceRecord{{Address: "198.18.0.1", Count: 2, ExpiresAt: generated.Add(time.Minute)}}, Total: 2},
{Service: "smtp", Records: []evidenceRecord{{Address: "198.18.0.2", Count: 4, ExpiresAt: generated.Add(10 * time.Minute)}}, Total: 1},
},
Drops: []dropMetric{{Service: "web", Reason: "source_rate", Packets: 7}},
Health: relayHealth{ConntrackCount: 12, ConntrackMaximum: 262144, NftablesActive: true, WebAllowActive: true, WebAllowCached: true, WireGuardActive: true},
QdiscDrops: []qdiscMetric{{Channel: "web", Packets: 3}},
TelemetryAt: generated,
})
var conntrackState conntrackMetrics
conntrackState.Up.Store(true)
conntrackState.Emitted.Store(2)
request := httptest.NewRequest(http.MethodGet, "/metrics", nil)
response := httptest.NewRecorder()
metricsHandler{Conntrack: &conntrackState, Latest: &latest, SMTPConfigured: true}.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d", response.Code)
}
for _, expected := range []string{
`oci_l4_relay_tcp_quarantine_packets{service="web",address="198.18.0.1"} 2`,
`oci_l4_relay_tcp_quarantine_expires_timestamp_seconds{service="smtp",address="198.18.0.2"} 1785373800`,
`oci_l4_relay_tcp_evidence_records{service="smtp"} 1`,
`oci_l4_relay_tcp_evidence_truncated{service="web"} 1`,
`oci_l4_relay_tcp_evidence_generated_timestamp_seconds 1785373200`,
`oci_l4_relay_nftables_dropped_packets_total{service="web",reason="source_rate"} 7`,
`oci_l4_relay_qdisc_dropped_packets_total{channel="web"} 3`,
`oci_l4_relay_component_healthy{component="web_allow"} 1`,
`oci_l4_relay_conntrack_entries 12`,
`oci_l4_relay_conntrack_observer_events_total{outcome="emitted"} 2`,
`oci_l4_relay_conntrack_observer_up 1`,
`oci_l4_relay_smtp_deny_configured 1`,
} {
if !strings.Contains(response.Body.String(), expected) {
t.Errorf("metrics do not contain %q", expected)
}
}
}
func TestRunCollectsAndServesMetrics(t *testing.T) {
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
if errors.Is(err, syscall.EPERM) {
t.Skip("network namespace does not permit listeners")
}
t.Fatal(err)
}
listenAddress := listener.Addr().String()
listener.Close()
items := make([]string, 0, len(dropMetricSpecs))
for index, spec := range dropMetricSpecs {
items = append(items, fmt.Sprintf(`{"counter":{"name":%q,"packets":%d}}`, spec.Name, index+1))
}
script := fmt.Sprintf(`#!/bin/sh
case "$*" in
*smtp_syn_quarantine_v4*) printf '%%s' '{"nftables":[{"set":{"family":"inet","name":"smtp_syn_quarantine_v4","table":"oci_l4_relay","elem":[{"elem":{"val":"198.18.0.2","expires":599,"counter":{"packets":4}}}]}}]}' ;;
*syn_quarantine_v4*) printf '%%s' '{"nftables":[{"set":{"family":"inet","name":"syn_quarantine_v4","table":"oci_l4_relay","elem":[{"elem":{"val":"198.18.0.1","expires":59,"counter":{"packets":2}}}]}}]}' ;;
*list\ counters*) printf '%%s' '{"nftables":[%s]}' ;;
*) exit 1 ;;
esac
`, strings.Join(items, ","))
nftPath := filepath.Join(t.TempDir(), "nft")
if err := os.WriteFile(nftPath, []byte(script), 0700); err != nil {
t.Fatal(err)
}
snapshotPath := filepath.Join(t.TempDir(), "current.json")
tcPath := filepath.Join(t.TempDir(), "tc")
if err := os.WriteFile(tcPath, []byte("#!/bin/sh\nprintf '%s' '[{\"parent\":\"1:10\",\"drops\":3}]'\n"), 0700); err != nil {
t.Fatal(err)
}
telemetryPath := filepath.Join(t.TempDir(), "telemetry.json")
if err := os.WriteFile(telemetryPath, []byte(`{"generated_at":"2026-07-30T01:00:00Z","status":{"conntrack_count":12,"conntrack_maximum":262144,"handshake_age_seconds":10,"nftables_active":true,"smtp_deny_active":true,"smtp_deny_active_source":"current","smtp_deny_bundle_age_seconds":60,"smtp_deny_bundle_remaining_seconds":600,"smtp_deny_cached":true,"smtp_deny_policy_valid":true,"smtp_deny_refresh_completed_age_seconds":30,"smtp_deny_refresh_successful":true,"smtp_deny_remote_current_age_seconds":30,"web_allow_active":true,"web_allow_cached":true,"wireguard_active":true}}`), 0600); err != nil {
t.Fatal(err)
}
previousObserver := startConntrackObserver
startConntrackObserver = func(ctx context.Context, _ relayConfig, _ *conntrackMetrics, _ io.Writer) error {
<-ctx.Done()
return ctx.Err()
}
defer func() { startConntrackObserver = previousObserver }()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- run(ctx, validRelayConfig(listenAddress), nftPath, tcPath, telemetryPath, snapshotPath, false)
}()
var body []byte
client := http.Client{Timeout: time.Second}
for attempt := 0; attempt < 50; attempt++ {
response, err := client.Get("http://" + listenAddress + "/metrics")
if err == nil {
body, err = io.ReadAll(response.Body)
response.Body.Close()
if err == nil && response.StatusCode == http.StatusOK {
break
}
}
time.Sleep(10 * time.Millisecond)
}
cancel()
if err := <-done; !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
for _, expected := range []string{
`oci_l4_relay_tcp_quarantine_packets{service="web",address="198.18.0.1"} 2`,
`oci_l4_relay_tcp_quarantine_packets{service="smtp",address="198.18.0.2"} 4`,
`oci_l4_relay_nftables_dropped_packets_total{service="authenticated_mail",reason="aggregate_packet"} 14`,
} {
if !strings.Contains(string(body), expected) {
t.Errorf("metrics do not contain %q", expected)
}
}
contents, err := os.ReadFile(snapshotPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(contents), `"address":"198.18.0.2"`) || strings.Contains(string(contents), `"address":"198.18.0.1"`) {
t.Fatalf("unexpected SMTP snapshot %s", contents)
}
}
func TestWriteSnapshotReplacesCurrentFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "current.json")
if err := os.WriteFile(path, []byte("old"), 0600); err != nil {
t.Fatal(err)
}
value := snapshot{SchemaVersion: 1, Records: []evidenceRecord{}}
if err := writeSnapshot(path, value); err != nil {
t.Fatal(err)
}
contents, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var actual snapshot
if err := json.Unmarshal(contents, &actual); err != nil {
t.Fatal(err)
}
if actual.SchemaVersion != 1 || actual.Records == nil {
t.Fatalf("unexpected snapshot %s", contents)
}
}
func TestLoadConfigAcceptsTestedCadences(t *testing.T) {
for _, interval := range []int{30, 60} {
path := filepath.Join(t.TempDir(), "config.json")
contents := relayConfigJSON(interval)
if err := os.WriteFile(path, contents, 0600); err != nil {
t.Fatal(err)
}
if _, err := loadConfig(path); err != nil {
t.Fatalf("interval %d: %v", interval, err)
}
}
}
func TestLoadConfigRejectsUntestedCadence(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
contents := relayConfigJSON(45)
if err := os.WriteFile(path, contents, 0600); err != nil {
t.Fatal(err)
}
if _, err := loadConfig(path); err == nil {
t.Fatal("untested cadence was accepted")
}
}
func TestLoadConfigRejectsUnknownField(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
contents := []byte(strings.TrimSuffix(string(relayConfigJSON(60)), "}") + `,"upload":true}`)
if err := os.WriteFile(path, contents, 0600); err != nil {
t.Fatal(err)
}
if _, err := loadConfig(path); err == nil {
t.Fatal("unknown field was accepted")
}
}
func TestLoadConfigRejectsNonSpecificMetricsAddress(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
contents := []byte(strings.Replace(string(relayConfigJSON(60)), "10.40.68.115:19100", "0.0.0.0:19100", 1))
if err := os.WriteFile(path, contents, 0600); err != nil {
t.Fatal(err)
}
if _, err := loadConfig(path); err == nil {
t.Fatal("wildcard metrics address was accepted")
}
}
func validRelayConfig(listenAddress string) relayConfig {
return relayConfig{
ConntrackEmissionBurst: 10,
ConntrackEmissionRatePerSecond: 1,
ConntrackEventBuffer: 4096,
ConntrackSampleDenominator: 10,
ConntrackServices: []conntrackService{{
Name: "web",
Ports: []uint16{80, 443},
TargetAddress: netip.MustParseAddr("10.104.40.127"),
}},
MaximumRecords: 4096,
MetricsListenAddress: listenAddress,
ObservationWindowSeconds: 600,
SnapshotIntervalSeconds: 60,
}
}
func relayConfigJSON(interval int) []byte {
return []byte(fmt.Sprintf(`{"conntrack_emission_burst":10,"conntrack_emission_rate_per_second":1,"conntrack_event_buffer":4096,"conntrack_sample_denominator":10,"conntrack_services":[{"name":"web","ports":[80,443],"target_address":"10.104.40.127"}],"maximum_records":4096,"metrics_listen_address":"10.40.68.115:19100","observation_window_seconds":600,"snapshot_interval_seconds":%d}`, interval))
}