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:
@@ -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 == "" {
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user