From 3ae912359776e133bff73117ee10ae1b122d6294 Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Wed, 29 Jul 2026 13:18:19 +0200 Subject: [PATCH] feat(server): adds minimum keepalive and headroom, chore: updates packages to fix vulnerabilities --- src/Ngino.Client/Ngino.Client.csproj | 2 +- src/Ngino.Client/Properties/AssemblyInfo.cs | 1 + src/Ngino.Server/AdminEndpoints.cs | 14 ++- src/Ngino.Server/ManagementStore.cs | 108 ++++++++++++++++-- src/Ngino.Server/Ngino.Server.csproj | 20 ++-- src/Ngino.Server/Properties/AssemblyInfo.cs | 3 + src/Ngino.Server/wwwroot/admin/app.js | 30 ++++- .../GroupKeepalivePolicyTests.cs | 18 +++ .../Ngino.Client.Tests.csproj} | 7 +- .../PackageAuditTests.cs | 0 .../TunnelClientModelTests.cs | 0 .../UpstreamRequestTests.cs | 0 12 files changed, 181 insertions(+), 22 deletions(-) create mode 100644 src/Ngino.Server/Properties/AssemblyInfo.cs create mode 100644 tests/Ngino.Client.Tests/GroupKeepalivePolicyTests.cs rename tests/{ReverseLlama.Client.Tests/ReverseLlama.Client.Tests.csproj => Ngino.Client.Tests/Ngino.Client.Tests.csproj} (81%) rename tests/{ReverseLlama.Client.Tests => Ngino.Client.Tests}/PackageAuditTests.cs (100%) rename tests/{ReverseLlama.Client.Tests => Ngino.Client.Tests}/TunnelClientModelTests.cs (100%) rename tests/{ReverseLlama.Client.Tests => Ngino.Client.Tests}/UpstreamRequestTests.cs (100%) diff --git a/src/Ngino.Client/Ngino.Client.csproj b/src/Ngino.Client/Ngino.Client.csproj index 31c5cc5..3a45db0 100644 --- a/src/Ngino.Client/Ngino.Client.csproj +++ b/src/Ngino.Client/Ngino.Client.csproj @@ -5,7 +5,7 @@ - + diff --git a/src/Ngino.Client/Properties/AssemblyInfo.cs b/src/Ngino.Client/Properties/AssemblyInfo.cs index 6e9394c..60b6732 100644 --- a/src/Ngino.Client/Properties/AssemblyInfo.cs +++ b/src/Ngino.Client/Properties/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Ngino.Client.Tests")] +[assembly: InternalsVisibleTo("ReverseLlama.Client.Tests")] diff --git a/src/Ngino.Server/AdminEndpoints.cs b/src/Ngino.Server/AdminEndpoints.cs index 6ab9b66..172037b 100644 --- a/src/Ngino.Server/AdminEndpoints.cs +++ b/src/Ngino.Server/AdminEndpoints.cs @@ -369,7 +369,14 @@ internal static class AdminEndpoints try { - var member = store.AddGroupClient(id, request.ClientId, request.Model, request.ClientPattern); + var member = store.AddGroupClient( + id, + request.ClientId, + request.Model, + request.ClientPattern, + request.KeepaliveInstancesToKeepAlive, + request.KeepaliveMaxParallelismPerClient, + request.KeepaliveParallelismHeadroom); return Results.Json(member); } catch (ArgumentException exception) @@ -889,7 +896,10 @@ internal sealed record UpdateGroupRequest(string Name); internal sealed record AddGroupClientRequest( string? ClientId, string? Model, - string? ClientPattern); + string? ClientPattern, + int? KeepaliveInstancesToKeepAlive, + int? KeepaliveMaxParallelismPerClient, + int? KeepaliveParallelismHeadroom); internal sealed record SetUserKeyGroupsRequest(IReadOnlyList? GroupIds); diff --git a/src/Ngino.Server/ManagementStore.cs b/src/Ngino.Server/ManagementStore.cs index c8aa592..479d93d 100644 --- a/src/Ngino.Server/ManagementStore.cs +++ b/src/Ngino.Server/ManagementStore.cs @@ -824,7 +824,10 @@ internal sealed class ManagementStore using var connection = OpenConnection(); using var command = connection.CreateCommand(); command.CommandText = """ - SELECT id, group_id, client_id, model, client_pattern + SELECT id, group_id, client_id, model, client_pattern, + keepalive_instances_to_keep_alive, + keepalive_max_parallelism_per_client, + keepalive_parallelism_headroom FROM group_members WHERE group_id = $group_id ORDER BY client_id, model, client_pattern @@ -840,14 +843,22 @@ internal sealed class ManagementStore reader.GetString(1), reader.IsDBNull(2) ? null : reader.GetString(2), reader.IsDBNull(3) ? null : reader.GetString(3), - reader.IsDBNull(4) ? null : reader.GetString(4))); + reader.IsDBNull(4) ? null : reader.GetString(4), + ReadKeepalivePolicy(reader, 5, 6, 7))); } return result; } } - public GroupClientInfo AddGroupClient(string groupId, string? clientId, string? model, string? clientPattern) + public GroupClientInfo AddGroupClient( + string groupId, + string? clientId, + string? model, + string? clientPattern, + int? keepaliveInstancesToKeepAlive, + int? keepaliveMaxParallelismPerClient, + int? keepaliveParallelismHeadroom) { EnsureAvailable(); @@ -873,24 +884,46 @@ internal sealed class ManagementStore } } + var policy = NormalizeKeepalivePolicy( + keepaliveInstancesToKeepAlive, + keepaliveMaxParallelismPerClient, + keepaliveParallelismHeadroom); + lock (_lock) { using var connection = OpenConnection(); using var command = connection.CreateCommand(); command.CommandText = """ - INSERT INTO group_members (group_id, client_id, model, client_pattern) - VALUES ($group_id, $client_id, $model, $client_pattern) + INSERT INTO group_members ( + group_id, + client_id, + model, + client_pattern, + keepalive_instances_to_keep_alive, + keepalive_max_parallelism_per_client, + keepalive_parallelism_headroom) + VALUES ( + $group_id, + $client_id, + $model, + $client_pattern, + $keepalive_instances_to_keep_alive, + $keepalive_max_parallelism_per_client, + $keepalive_parallelism_headroom) """; command.Parameters.AddWithValue("$group_id", groupId); command.Parameters.AddWithValue("$client_id", string.IsNullOrWhiteSpace(clientId) ? DBNull.Value : clientId); command.Parameters.AddWithValue("$model", string.IsNullOrWhiteSpace(model) ? DBNull.Value : model); command.Parameters.AddWithValue("$client_pattern", string.IsNullOrWhiteSpace(clientPattern) ? DBNull.Value : clientPattern); + command.Parameters.AddWithValue("$keepalive_instances_to_keep_alive", policy.InstancesToKeepAlive); + command.Parameters.AddWithValue("$keepalive_max_parallelism_per_client", policy.MaxParallelismPerClient); + command.Parameters.AddWithValue("$keepalive_parallelism_headroom", policy.ParallelismHeadroom); command.ExecuteNonQuery(); using var idCommand = connection.CreateCommand(); idCommand.CommandText = "SELECT last_insert_rowid()"; var insertedId = (long)idCommand.ExecuteScalar()!; - return new GroupClientInfo(insertedId, groupId, clientId, model, clientPattern); + return new GroupClientInfo(insertedId, groupId, clientId, model, clientPattern, policy); } } @@ -1987,7 +2020,10 @@ internal sealed class ManagementStore group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, client_id TEXT NULL, model TEXT NULL, - client_pattern TEXT NULL + client_pattern TEXT NULL, + keepalive_instances_to_keep_alive INTEGER NOT NULL DEFAULT 1, + keepalive_max_parallelism_per_client INTEGER NOT NULL DEFAULT 1, + keepalive_parallelism_headroom INTEGER NOT NULL DEFAULT 1 ); CREATE INDEX IF NOT EXISTS idx_group_members_group_id @@ -2010,6 +2046,29 @@ internal sealed class ManagementStore """; command.ExecuteNonQuery(); + using (var migrate = connection.CreateCommand()) + { + migrate.CommandText = """ + SELECT COUNT(*) FROM pragma_table_info('group_members') WHERE name = 'keepalive_instances_to_keep_alive' + """; + var hasKeepaliveInstancesColumn = (long)migrate.ExecuteScalar()! > 0; + + if (!hasKeepaliveInstancesColumn) + { + using var addKeepaliveInstances = connection.CreateCommand(); + addKeepaliveInstances.CommandText = "ALTER TABLE group_members ADD COLUMN keepalive_instances_to_keep_alive INTEGER NOT NULL DEFAULT 1"; + addKeepaliveInstances.ExecuteNonQuery(); + + using var addKeepaliveMaxParallelism = connection.CreateCommand(); + addKeepaliveMaxParallelism.CommandText = "ALTER TABLE group_members ADD COLUMN keepalive_max_parallelism_per_client INTEGER NOT NULL DEFAULT 1"; + addKeepaliveMaxParallelism.ExecuteNonQuery(); + + using var addKeepaliveHeadroom = connection.CreateCommand(); + addKeepaliveHeadroom.CommandText = "ALTER TABLE group_members ADD COLUMN keepalive_parallelism_headroom INTEGER NOT NULL DEFAULT 1"; + addKeepaliveHeadroom.ExecuteNonQuery(); + } + } + using (var migrate = connection.CreateCommand()) { migrate.CommandText = """ @@ -2231,6 +2290,30 @@ internal sealed class ManagementStore return connection; } + private static GroupClientKeepalivePolicy? ReadKeepalivePolicy(SqliteDataReader reader, int instancesOrdinal, int maxParallelismOrdinal, int headroomOrdinal) + { + if (reader.IsDBNull(instancesOrdinal) || reader.IsDBNull(maxParallelismOrdinal) || reader.IsDBNull(headroomOrdinal)) + { + return null; + } + + return NormalizeKeepalivePolicy( + reader.GetInt32(instancesOrdinal), + reader.GetInt32(maxParallelismOrdinal), + reader.GetInt32(headroomOrdinal)); + } + + private static GroupClientKeepalivePolicy NormalizeKeepalivePolicy( + int? instancesToKeepAlive, + int? maxParallelismPerClient, + int? parallelismHeadroom) + { + return new GroupClientKeepalivePolicy( + Math.Max(1, instancesToKeepAlive ?? GroupClientKeepalivePolicy.Default.InstancesToKeepAlive), + Math.Max(1, maxParallelismPerClient ?? GroupClientKeepalivePolicy.Default.MaxParallelismPerClient), + Math.Max(1, parallelismHeadroom ?? GroupClientKeepalivePolicy.Default.ParallelismHeadroom)); + } + private void EnsureAvailable() { if (!_isAvailable) @@ -2420,7 +2503,16 @@ internal sealed record GroupClientInfo( string GroupId, string? ClientId, string? Model, - string? ClientPattern); + string? ClientPattern, + GroupClientKeepalivePolicy? KeepalivePolicy = null); + +internal sealed record GroupClientKeepalivePolicy( + int InstancesToKeepAlive, + int MaxParallelismPerClient, + int ParallelismHeadroom) +{ + public static GroupClientKeepalivePolicy Default { get; } = new(1, 1, 1); +} internal sealed record UserKeyGroupInfo( string UserKeyId, diff --git a/src/Ngino.Server/Ngino.Server.csproj b/src/Ngino.Server/Ngino.Server.csproj index e681b53..5d00305 100644 --- a/src/Ngino.Server/Ngino.Server.csproj +++ b/src/Ngino.Server/Ngino.Server.csproj @@ -3,14 +3,20 @@ - - - - - - - + + + + + + + + + + + + <_Parameter1>Ngino.Client.Tests + diff --git a/src/Ngino.Server/Properties/AssemblyInfo.cs b/src/Ngino.Server/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..4458d19 --- /dev/null +++ b/src/Ngino.Server/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ReverseLlama.Client.Tests")] diff --git a/src/Ngino.Server/wwwroot/admin/app.js b/src/Ngino.Server/wwwroot/admin/app.js index 6b20d63..951950e 100644 --- a/src/Ngino.Server/wwwroot/admin/app.js +++ b/src/Ngino.Server/wwwroot/admin/app.js @@ -228,6 +228,9 @@ content.addEventListener("submit", async (event) => { if (data.clientPattern && data.clientPattern.trim()) { body.clientPattern = data.clientPattern.trim(); } + body.keepaliveInstancesToKeepAlive = parseInt(data.keepaliveInstancesToKeepAlive, 10) || 1; + body.keepaliveMaxParallelismPerClient = parseInt(data.keepaliveMaxParallelismPerClient, 10) || 1; + body.keepaliveParallelismHeadroom = parseInt(data.keepaliveParallelismHeadroom, 10) || 1; if (!body.clientId && !body.clientPattern) { throw new Error("Either Client ID or Client pattern is required."); @@ -981,9 +984,21 @@ function renderGroupDetail() { +
+ + +
+
+ + +
+
+ + +
-
Provide either a Client ID (for explicit client access) or a Client pattern (for regex-based matching). Model is optional to restrict to a specific model.
+
Provide either a Client ID (for explicit client access) or a Client pattern (for regex-based matching). Model is optional to restrict to a specific model. Keepalive defaults to 1 instance, 1 parallelism, and 1 headroom.
@@ -1096,6 +1111,17 @@ function renderGroupDetail() { `); } +function formatKeepalivePolicy(policy) { + if (!policy) { + return `
Default (1 / 1 / 1)
`; + } + + return ` +
${policy.instancesToKeepAlive} instance${policy.instancesToKeepAlive === 1 ? "" : "s"}
+
Parallelism ${policy.maxParallelismPerClient} ยท headroom ${policy.parallelismHeadroom}
+ `; +} + function groupClientsTable(clients, groupId) { const rows = clients.map((client) => ` @@ -1117,6 +1143,7 @@ function groupClientsTable(clients, groupId) { : `
-
` } + ${formatKeepalivePolicy(client.keepalivePolicy)} @@ -1130,6 +1157,7 @@ function groupClientsTable(clients, groupId) { Client ID Model Client pattern + Keepalive policy diff --git a/tests/Ngino.Client.Tests/GroupKeepalivePolicyTests.cs b/tests/Ngino.Client.Tests/GroupKeepalivePolicyTests.cs new file mode 100644 index 0000000..a57e07c --- /dev/null +++ b/tests/Ngino.Client.Tests/GroupKeepalivePolicyTests.cs @@ -0,0 +1,18 @@ +using Ngino.Server; +using Xunit; + +namespace ReverseLlama.Client.Tests; + +public class GroupKeepalivePolicyTests +{ + [Fact] + public void KeepalivePolicy_IsRoundTripped_ThroughGroupClientInfo() + { + var policy = new GroupClientKeepalivePolicy(2, 3, 4); + var info = new GroupClientInfo(1, "group-1", "client-1", "model-a", "pattern", policy); + + Assert.Equal(2, info.KeepalivePolicy?.InstancesToKeepAlive); + Assert.Equal(3, info.KeepalivePolicy?.MaxParallelismPerClient); + Assert.Equal(4, info.KeepalivePolicy?.ParallelismHeadroom); + } +} diff --git a/tests/ReverseLlama.Client.Tests/ReverseLlama.Client.Tests.csproj b/tests/Ngino.Client.Tests/Ngino.Client.Tests.csproj similarity index 81% rename from tests/ReverseLlama.Client.Tests/ReverseLlama.Client.Tests.csproj rename to tests/Ngino.Client.Tests/Ngino.Client.Tests.csproj index a1d0bde..faac861 100644 --- a/tests/ReverseLlama.Client.Tests/ReverseLlama.Client.Tests.csproj +++ b/tests/Ngino.Client.Tests/Ngino.Client.Tests.csproj @@ -9,9 +9,9 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -19,6 +19,7 @@ + diff --git a/tests/ReverseLlama.Client.Tests/PackageAuditTests.cs b/tests/Ngino.Client.Tests/PackageAuditTests.cs similarity index 100% rename from tests/ReverseLlama.Client.Tests/PackageAuditTests.cs rename to tests/Ngino.Client.Tests/PackageAuditTests.cs diff --git a/tests/ReverseLlama.Client.Tests/TunnelClientModelTests.cs b/tests/Ngino.Client.Tests/TunnelClientModelTests.cs similarity index 100% rename from tests/ReverseLlama.Client.Tests/TunnelClientModelTests.cs rename to tests/Ngino.Client.Tests/TunnelClientModelTests.cs diff --git a/tests/ReverseLlama.Client.Tests/UpstreamRequestTests.cs b/tests/Ngino.Client.Tests/UpstreamRequestTests.cs similarity index 100% rename from tests/ReverseLlama.Client.Tests/UpstreamRequestTests.cs rename to tests/Ngino.Client.Tests/UpstreamRequestTests.cs