Add SQLite persistence and write-through gRPC mutations

Database (internal/db) stores listeners, routes, and firewall rules with
WAL mode, foreign keys, and idempotent migrations. First run seeds from
TOML config; subsequent runs load from DB as source of truth.

gRPC admin API now writes to the database before updating in-memory state
(write-through cache pattern). Adds snapshot command for VACUUM INTO
backups. Refactors firewall.New to accept raw rule slices instead of
config struct for flexibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-17 03:07:30 -07:00
parent d63859c28f
commit 9cba3241e8
20 changed files with 1148 additions and 135 deletions

View File

@@ -8,7 +8,6 @@ import (
"github.com/oschwald/maxminddb-golang"
"git.wntrmute.dev/kyle/mc-proxy/internal/config"
)
type geoIPRecord struct {
@@ -27,15 +26,15 @@ type Firewall struct {
mu sync.RWMutex // protects all mutable state
}
// New creates a Firewall from the given configuration.
func New(cfg config.Firewall) (*Firewall, error) {
// New creates a Firewall from raw rule lists and an optional GeoIP database path.
func New(geoIPPath string, ips, cidrs, countries []string) (*Firewall, error) {
f := &Firewall{
blockedIPs: make(map[netip.Addr]struct{}),
blockedCountries: make(map[string]struct{}),
geoDBPath: cfg.GeoIPDB,
geoDBPath: geoIPPath,
}
for _, ip := range cfg.BlockedIPs {
for _, ip := range ips {
addr, err := netip.ParseAddr(ip)
if err != nil {
return nil, fmt.Errorf("invalid blocked IP %q: %w", ip, err)
@@ -43,7 +42,7 @@ func New(cfg config.Firewall) (*Firewall, error) {
f.blockedIPs[addr] = struct{}{}
}
for _, cidr := range cfg.BlockedCIDRs {
for _, cidr := range cidrs {
prefix, err := netip.ParsePrefix(cidr)
if err != nil {
return nil, fmt.Errorf("invalid blocked CIDR %q: %w", cidr, err)
@@ -51,12 +50,12 @@ func New(cfg config.Firewall) (*Firewall, error) {
f.blockedCIDRs = append(f.blockedCIDRs, prefix)
}
for _, code := range cfg.BlockedCountries {
for _, code := range countries {
f.blockedCountries[strings.ToUpper(code)] = struct{}{}
}
if len(f.blockedCountries) > 0 {
if err := f.loadGeoDB(cfg.GeoIPDB); err != nil {
if len(f.blockedCountries) > 0 && geoIPPath != "" {
if err := f.loadGeoDB(geoIPPath); err != nil {
return nil, fmt.Errorf("loading GeoIP database: %w", err)
}
}

View File

@@ -3,12 +3,10 @@ package firewall
import (
"net/netip"
"testing"
"git.wntrmute.dev/kyle/mc-proxy/internal/config"
)
func TestEmptyFirewall(t *testing.T) {
fw, err := New(config.Firewall{})
fw, err := New("", nil, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -24,9 +22,7 @@ func TestEmptyFirewall(t *testing.T) {
}
func TestIPBlocking(t *testing.T) {
fw, err := New(config.Firewall{
BlockedIPs: []string{"192.0.2.1", "2001:db8::dead"},
})
fw, err := New("", []string{"192.0.2.1", "2001:db8::dead"}, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -51,9 +47,7 @@ func TestIPBlocking(t *testing.T) {
}
func TestCIDRBlocking(t *testing.T) {
fw, err := New(config.Firewall{
BlockedCIDRs: []string{"198.51.100.0/24", "2001:db8::/32"},
})
fw, err := New("", nil, []string{"198.51.100.0/24", "2001:db8::/32"}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -79,15 +73,12 @@ func TestCIDRBlocking(t *testing.T) {
}
func TestIPv4MappedIPv6(t *testing.T) {
fw, err := New(config.Firewall{
BlockedIPs: []string{"192.0.2.1"},
})
fw, err := New("", []string{"192.0.2.1"}, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer fw.Close()
// IPv4-mapped IPv6 representation of 192.0.2.1.
addr := netip.MustParseAddr("::ffff:192.0.2.1")
if !fw.Blocked(addr) {
t.Fatal("expected IPv4-mapped IPv6 address to be blocked")
@@ -95,28 +86,21 @@ func TestIPv4MappedIPv6(t *testing.T) {
}
func TestInvalidIP(t *testing.T) {
_, err := New(config.Firewall{
BlockedIPs: []string{"not-an-ip"},
})
_, err := New("", []string{"not-an-ip"}, nil, nil)
if err == nil {
t.Fatal("expected error for invalid IP")
}
}
func TestInvalidCIDR(t *testing.T) {
_, err := New(config.Firewall{
BlockedCIDRs: []string{"not-a-cidr"},
})
_, err := New("", nil, []string{"not-a-cidr"}, nil)
if err == nil {
t.Fatal("expected error for invalid CIDR")
}
}
func TestCombinedRules(t *testing.T) {
fw, err := New(config.Firewall{
BlockedIPs: []string{"10.0.0.1"},
BlockedCIDRs: []string{"192.168.0.0/16"},
})
fw, err := New("", []string{"10.0.0.1"}, []string{"192.168.0.0/16"}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -126,10 +110,10 @@ func TestCombinedRules(t *testing.T) {
addr string
blocked bool
}{
{"10.0.0.1", true}, // IP match
{"10.0.0.2", false}, // no match
{"192.168.1.1", true}, // CIDR match
{"172.16.0.1", false}, // no match
{"10.0.0.1", true},
{"10.0.0.2", false},
{"192.168.1.1", true},
{"172.16.0.1", false},
}
for _, tt := range tests {
@@ -139,3 +123,30 @@ func TestCombinedRules(t *testing.T) {
}
}
}
func TestRuntimeMutation(t *testing.T) {
fw, err := New("", nil, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer fw.Close()
addr := netip.MustParseAddr("10.0.0.1")
if fw.Blocked(addr) {
t.Fatal("should not be blocked initially")
}
if err := fw.AddIP("10.0.0.1"); err != nil {
t.Fatalf("add IP: %v", err)
}
if !fw.Blocked(addr) {
t.Fatal("should be blocked after AddIP")
}
if err := fw.RemoveIP("10.0.0.1"); err != nil {
t.Fatalf("remove IP: %v", err)
}
if fw.Blocked(addr) {
t.Fatal("should not be blocked after RemoveIP")
}
}