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

@@ -10,12 +10,17 @@ import (
type Config struct {
Listeners []Listener `toml:"listeners"`
Database Database `toml:"database"`
GRPC GRPC `toml:"grpc"`
Firewall Firewall `toml:"firewall"`
Proxy Proxy `toml:"proxy"`
Log Log `toml:"log"`
}
type Database struct {
Path string `toml:"path"`
}
type GRPC struct {
Addr string `toml:"addr"`
TLSCert string `toml:"tls_cert"`
@@ -80,18 +85,15 @@ func Load(path string) (*Config, error) {
}
func (c *Config) validate() error {
if len(c.Listeners) == 0 {
return fmt.Errorf("at least one listener is required")
if c.Database.Path == "" {
return fmt.Errorf("database.path is required")
}
// Validate listeners if provided (used for seeding on first run).
for i, l := range c.Listeners {
if l.Addr == "" {
return fmt.Errorf("listener %d: addr is required", i)
}
if len(l.Routes) == 0 {
return fmt.Errorf("listener %d (%s): at least one route is required", i, l.Addr)
}
seen := make(map[string]bool)
for j, r := range l.Routes {
if r.Hostname == "" {

View File

@@ -11,6 +11,9 @@ func TestLoadValid(t *testing.T) {
path := filepath.Join(dir, "test.toml")
data := `
[database]
path = "/tmp/test.db"
[[listeners]]
addr = ":443"
@@ -49,11 +52,37 @@ level = "info"
}
}
func TestLoadNoListeners(t *testing.T) {
func TestLoadNoDatabasePath(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.toml")
data := `
[[listeners]]
addr = ":443"
[[listeners.routes]]
hostname = "example.com"
backend = "127.0.0.1:8443"
`
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
_, err := Load(path)
if err == nil {
t.Fatal("expected error for missing database path")
}
}
func TestLoadNoListenersValid(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.toml")
// No listeners is valid — DB may already have them.
data := `
[database]
path = "/tmp/test.db"
[log]
level = "info"
`
@@ -62,26 +91,8 @@ level = "info"
}
_, err := Load(path)
if err == nil {
t.Fatal("expected error for missing listeners")
}
}
func TestLoadNoRoutes(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.toml")
data := `
[[listeners]]
addr = ":443"
`
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
_, err := Load(path)
if err == nil {
t.Fatal("expected error for missing routes")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
@@ -90,6 +101,9 @@ func TestLoadDuplicateHostnames(t *testing.T) {
path := filepath.Join(dir, "test.toml")
data := `
[database]
path = "/tmp/test.db"
[[listeners]]
addr = ":443"
@@ -116,6 +130,9 @@ func TestLoadGeoIPRequiredWithCountries(t *testing.T) {
path := filepath.Join(dir, "test.toml")
data := `
[database]
path = "/tmp/test.db"
[[listeners]]
addr = ":443"
@@ -141,6 +158,9 @@ func TestLoadMultipleListeners(t *testing.T) {
path := filepath.Join(dir, "test.toml")
data := `
[database]
path = "/tmp/test.db"
[[listeners]]
addr = ":443"