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 == "" {