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

@@ -12,6 +12,7 @@ func rootCmd() *cobra.Command {
}
cmd.AddCommand(serverCmd())
cmd.AddCommand(snapshotCmd())
return cmd
}

View File

@@ -2,14 +2,18 @@ package main
import (
"context"
"fmt"
"log/slog"
"os"
"os/signal"
"strings"
"syscall"
"github.com/spf13/cobra"
"git.wntrmute.dev/kyle/mc-proxy/internal/config"
"git.wntrmute.dev/kyle/mc-proxy/internal/db"
"git.wntrmute.dev/kyle/mc-proxy/internal/firewall"
"git.wntrmute.dev/kyle/mc-proxy/internal/grpcserver"
"git.wntrmute.dev/kyle/mc-proxy/internal/server"
)
@@ -30,17 +34,53 @@ func serverCmd() *cobra.Command {
Level: parseLogLevel(cfg.Log.Level),
}))
srv, err := server.New(cfg, logger, version)
// Open and migrate the database.
store, err := db.Open(cfg.Database.Path)
if err != nil {
return fmt.Errorf("opening database: %w", err)
}
defer store.Close()
if err := store.Migrate(); err != nil {
return fmt.Errorf("running migrations: %w", err)
}
// Seed from config on first run, or load from DB.
empty, err := store.IsEmpty()
if err != nil {
return fmt.Errorf("checking database: %w", err)
}
if empty {
if len(cfg.Listeners) == 0 {
return fmt.Errorf("database is empty and no listeners defined in config for seeding")
}
logger.Info("seeding database from config")
if err := store.Seed(cfg.Listeners, cfg.Firewall); err != nil {
return fmt.Errorf("seeding database: %w", err)
}
}
// Load listeners and routes from DB.
listenerData, err := loadListenersFromDB(store)
if err != nil {
return err
}
// Load firewall rules from DB.
fw, err := loadFirewallFromDB(store, cfg.Firewall.GeoIPDB)
if err != nil {
return err
}
srv := server.New(cfg, fw, listenerData, logger, version)
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
// Start gRPC admin API if configured.
if cfg.GRPC.Addr != "" {
grpcSrv, ln, err := grpcserver.New(cfg.GRPC, srv, logger)
grpcSrv, ln, err := grpcserver.New(cfg.GRPC, srv, store, logger)
if err != nil {
return err
}
@@ -75,6 +115,56 @@ func serverCmd() *cobra.Command {
return cmd
}
func loadListenersFromDB(store *db.Store) ([]server.ListenerData, error) {
dbListeners, err := store.ListListeners()
if err != nil {
return nil, fmt.Errorf("loading listeners: %w", err)
}
var result []server.ListenerData
for _, l := range dbListeners {
dbRoutes, err := store.ListRoutes(l.ID)
if err != nil {
return nil, fmt.Errorf("loading routes for listener %q: %w", l.Addr, err)
}
routes := make(map[string]string, len(dbRoutes))
for _, r := range dbRoutes {
routes[strings.ToLower(r.Hostname)] = r.Backend
}
result = append(result, server.ListenerData{
ID: l.ID,
Addr: l.Addr,
Routes: routes,
})
}
return result, nil
}
func loadFirewallFromDB(store *db.Store, geoIPPath string) (*firewall.Firewall, error) {
rules, err := store.ListFirewallRules()
if err != nil {
return nil, fmt.Errorf("loading firewall rules: %w", err)
}
var ips, cidrs, countries []string
for _, r := range rules {
switch r.Type {
case "ip":
ips = append(ips, r.Value)
case "cidr":
cidrs = append(cidrs, r.Value)
case "country":
countries = append(countries, r.Value)
}
}
fw, err := firewall.New(geoIPPath, ips, cidrs, countries)
if err != nil {
return nil, fmt.Errorf("initializing firewall: %w", err)
}
return fw, nil
}
func parseLogLevel(s string) slog.Level {
switch s {
case "debug":

54
cmd/mc-proxy/snapshot.go Normal file
View File

@@ -0,0 +1,54 @@
package main
import (
"fmt"
"path/filepath"
"time"
"github.com/spf13/cobra"
"git.wntrmute.dev/kyle/mc-proxy/internal/config"
"git.wntrmute.dev/kyle/mc-proxy/internal/db"
)
func snapshotCmd() *cobra.Command {
var (
configPath string
outputPath string
)
cmd := &cobra.Command{
Use: "snapshot",
Short: "Create a database backup",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath)
if err != nil {
return err
}
store, err := db.Open(cfg.Database.Path)
if err != nil {
return fmt.Errorf("opening database: %w", err)
}
defer store.Close()
if outputPath == "" {
dir := filepath.Dir(cfg.Database.Path)
ts := time.Now().UTC().Format("20060102T150405Z")
outputPath = filepath.Join(dir, "backups", fmt.Sprintf("mc-proxy-%s.db", ts))
}
if err := store.Snapshot(outputPath); err != nil {
return err
}
fmt.Printf("snapshot written to %s\n", outputPath)
return nil
},
}
cmd.Flags().StringVarP(&configPath, "config", "c", "mc-proxy.toml", "path to configuration file")
cmd.Flags().StringVarP(&outputPath, "output", "o", "", "output path (default: backups/mc-proxy-<timestamp>.db)")
return cmd
}