Add per-IP rate limiting and Unix socket support for gRPC admin API

Rate limiting: per-source-IP connection rate limiter in the firewall layer
with configurable limit and sliding window. Blocklisted IPs are rejected
before rate limit evaluation to avoid wasting quota. Unix socket: the gRPC
admin API can now listen on a Unix domain socket (no TLS required), secured
by file permissions (0600), as a simpler alternative for local-only access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-17 14:37:21 -07:00
parent e84093b7fb
commit b25e1b0e79
16 changed files with 694 additions and 43 deletions

View File

@@ -68,7 +68,7 @@ func serverCmd() *cobra.Command {
}
// Load firewall rules from DB.
fw, err := loadFirewallFromDB(store, cfg.Firewall.GeoIPDB)
fw, err := loadFirewallFromDB(store, cfg.Firewall)
if err != nil {
return err
}
@@ -90,7 +90,12 @@ func serverCmd() *cobra.Command {
logger.Error("gRPC server error", "error", err)
}
}()
defer grpcSrv.GracefulStop()
defer func() {
grpcSrv.GracefulStop()
if cfg.GRPC.IsUnixSocket() {
os.Remove(cfg.GRPC.SocketPath())
}
}()
}
// SIGHUP reloads the GeoIP database.
@@ -140,7 +145,7 @@ func loadListenersFromDB(store *db.Store) ([]server.ListenerData, error) {
return result, nil
}
func loadFirewallFromDB(store *db.Store, geoIPPath string) (*firewall.Firewall, error) {
func loadFirewallFromDB(store *db.Store, fwCfg config.Firewall) (*firewall.Firewall, error) {
rules, err := store.ListFirewallRules()
if err != nil {
return nil, fmt.Errorf("loading firewall rules: %w", err)
@@ -158,7 +163,7 @@ func loadFirewallFromDB(store *db.Store, geoIPPath string) (*firewall.Firewall,
}
}
fw, err := firewall.New(geoIPPath, ips, cidrs, countries)
fw, err := firewall.New(fwCfg.GeoIPDB, ips, cidrs, countries, fwCfg.RateLimit, fwCfg.RateWindow.Duration)
if err != nil {
return nil, fmt.Errorf("initializing firewall: %w", err)
}

View File

@@ -2,7 +2,9 @@ package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
@@ -32,10 +34,29 @@ func snapshotCmd() *cobra.Command {
}
defer store.Close()
dataDir := filepath.Dir(cfg.Database.Path)
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))
outputPath = filepath.Join(dataDir, "backups", fmt.Sprintf("mc-proxy-%s.db", ts))
}
// Validate the output path is within the data directory.
absOutput, err := filepath.Abs(filepath.Clean(outputPath))
if err != nil {
return fmt.Errorf("resolving output path: %w", err)
}
absDataDir, err := filepath.Abs(dataDir)
if err != nil {
return fmt.Errorf("resolving data directory: %w", err)
}
if !strings.HasPrefix(absOutput, absDataDir+string(os.PathSeparator)) {
return fmt.Errorf("output path must be within the data directory (%s)", absDataDir)
}
// Ensure the parent directory exists.
if err := os.MkdirAll(filepath.Dir(outputPath), 0700); err != nil {
return fmt.Errorf("creating backup directory: %w", err)
}
if err := store.Snapshot(outputPath); err != nil {

View File

@@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
pb "git.wntrmute.dev/kyle/mc-proxy/gen/mc_proxy/v1"
"git.wntrmute.dev/kyle/mc-proxy/internal/config"
@@ -70,6 +71,11 @@ func statusCmd() *cobra.Command {
}
func dialGRPC(cfg config.GRPC) (*grpc.ClientConn, error) {
if cfg.IsUnixSocket() {
return grpc.NewClient("unix://"+cfg.SocketPath(),
grpc.WithTransportCredentials(insecure.NewCredentials()))
}
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS13,
}