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:
@@ -12,6 +12,7 @@ func rootCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
cmd.AddCommand(serverCmd())
|
||||
cmd.AddCommand(snapshotCmd())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -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
54
cmd/mc-proxy/snapshot.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user