Add per-listener connection limits

Configurable maximum concurrent connections per listener. When the
limit is reached, new connections are closed immediately after accept.
0 means unlimited (default, preserving existing behavior).

Config: Listener gains max_connections field, validated non-negative.

DB: Migration 3 adds listeners.max_connections column.
UpdateListenerMaxConns method for runtime changes via gRPC.
CreateListener updated to persist max_connections on seed.

Server: ListenerState/ListenerData gain MaxConnections. Limit checked
in serve() after Accept but before handleConn — if ActiveConnections
>= MaxConnections, connection is closed and the accept loop continues.
SetMaxConnections method for runtime updates.

Proto: SetListenerMaxConnections RPC added. ListenerStatus gains
max_connections field. Generated code regenerated.

gRPC server: SetListenerMaxConnections implements write-through
(DB first, then in-memory update). GetStatus includes max_connections.

Client: SetListenerMaxConnections method, MaxConnections in
ListenerStatus.

Tests: DB CRUD and UpdateListenerMaxConns, server connection limit
enforcement (accept 2, reject 3rd, close one, accept again), gRPC
SetListenerMaxConnections round-trip with DB persistence, not-found
error handling.

Also updates PROJECT_PLAN.md with phases 6-8 and PROGRESS.md with
tracking for the new features.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 16:42:53 -07:00
co-authored by Claude Opus 4.6
parent 5bc8f4fc8e
commit 564e0a9c67
16 changed files with 595 additions and 102 deletions
+50 -11
View File
@@ -41,7 +41,7 @@ func TestIsEmpty(t *testing.T) {
t.Fatal("expected empty database")
}
if _, err := store.CreateListener(":443", false); err != nil {
if _, err := store.CreateListener(":443", false, 0); err != nil {
t.Fatalf("create listener: %v", err)
}
@@ -57,7 +57,7 @@ func TestIsEmpty(t *testing.T) {
func TestListenerCRUD(t *testing.T) {
store := openTestDB(t)
id, err := store.CreateListener(":443", false)
id, err := store.CreateListener(":443", false, 0)
if err != nil {
t.Fatalf("create: %v", err)
}
@@ -103,7 +103,7 @@ func TestListenerCRUD(t *testing.T) {
func TestListenerProxyProtocol(t *testing.T) {
store := openTestDB(t)
id, err := store.CreateListener(":443", true)
id, err := store.CreateListener(":443", true, 0)
if err != nil {
t.Fatalf("create: %v", err)
}
@@ -120,13 +120,52 @@ func TestListenerProxyProtocol(t *testing.T) {
}
}
func TestListenerMaxConnections(t *testing.T) {
store := openTestDB(t)
id, err := store.CreateListener(":443", false, 5000)
if err != nil {
t.Fatalf("create: %v", err)
}
l, err := store.GetListenerByAddr(":443")
if err != nil {
t.Fatalf("get: %v", err)
}
if l.MaxConnections != 5000 {
t.Fatalf("max_connections = %d, want 5000", l.MaxConnections)
}
// Update max connections.
if err := store.UpdateListenerMaxConns(id, 10000); err != nil {
t.Fatalf("update: %v", err)
}
l, err = store.GetListenerByAddr(":443")
if err != nil {
t.Fatalf("get after update: %v", err)
}
if l.MaxConnections != 10000 {
t.Fatalf("max_connections = %d, want 10000", l.MaxConnections)
}
// Set to 0 (unlimited).
if err := store.UpdateListenerMaxConns(id, 0); err != nil {
t.Fatalf("update to 0: %v", err)
}
l, _ = store.GetListenerByAddr(":443")
if l.MaxConnections != 0 {
t.Fatalf("max_connections = %d, want 0", l.MaxConnections)
}
}
func TestListenerDuplicateAddr(t *testing.T) {
store := openTestDB(t)
if _, err := store.CreateListener(":443", false); err != nil {
if _, err := store.CreateListener(":443", false, 0); err != nil {
t.Fatalf("first create: %v", err)
}
if _, err := store.CreateListener(":443", false); err == nil {
if _, err := store.CreateListener(":443", false, 0); err == nil {
t.Fatal("expected error for duplicate addr")
}
}
@@ -134,7 +173,7 @@ func TestListenerDuplicateAddr(t *testing.T) {
func TestRouteCRUD(t *testing.T) {
store := openTestDB(t)
listenerID, err := store.CreateListener(":443", false)
listenerID, err := store.CreateListener(":443", false, 0)
if err != nil {
t.Fatalf("create listener: %v", err)
}
@@ -177,7 +216,7 @@ func TestRouteCRUD(t *testing.T) {
func TestRouteL7Fields(t *testing.T) {
store := openTestDB(t)
listenerID, _ := store.CreateListener(":443", false)
listenerID, _ := store.CreateListener(":443", false, 0)
_, err := store.CreateRoute(listenerID, "api.example.com", "127.0.0.1:8080", "l7",
"/certs/api.crt", "/certs/api.key", false, true)
@@ -214,7 +253,7 @@ func TestRouteL7Fields(t *testing.T) {
func TestRouteDuplicateHostname(t *testing.T) {
store := openTestDB(t)
listenerID, _ := store.CreateListener(":443", false)
listenerID, _ := store.CreateListener(":443", false, 0)
if _, err := store.CreateRoute(listenerID, "example.com", "127.0.0.1:8443", "l4", "", "", false, false); err != nil {
t.Fatalf("first create: %v", err)
}
@@ -226,7 +265,7 @@ func TestRouteDuplicateHostname(t *testing.T) {
func TestRouteCascadeDelete(t *testing.T) {
store := openTestDB(t)
listenerID, _ := store.CreateListener(":443", false)
listenerID, _ := store.CreateListener(":443", false, 0)
store.CreateRoute(listenerID, "a.example.com", "127.0.0.1:8443", "l4", "", "", false, false)
store.CreateRoute(listenerID, "b.example.com", "127.0.0.1:9443", "l4", "", "", false, false)
@@ -373,7 +412,7 @@ func TestSeed(t *testing.T) {
func TestSnapshot(t *testing.T) {
store := openTestDB(t)
store.CreateListener(":443", false)
store.CreateListener(":443", false, 0)
dest := filepath.Join(t.TempDir(), "backup.db")
if err := store.Snapshot(dest); err != nil {
@@ -432,7 +471,7 @@ func TestMigrationV2Upgrade(t *testing.T) {
}
// Insert a listener and route with defaults to verify new columns work.
lid, err := store.CreateListener(":443", false)
lid, err := store.CreateListener(":443", false, 0)
if err != nil {
t.Fatalf("create listener: %v", err)
}