From 0014df61849b3337e7cb6c34c60f947bff9bd33f Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Sun, 19 Jul 2026 14:45:04 +0200 Subject: [PATCH] feat(server): splits user and client into separate key access --- src/ReverseLlama.Server/AdminEndpoints.cs | 25 ++- src/ReverseLlama.Server/ManagementStore.cs | 204 ++++++++++++++++++ src/ReverseLlama.Server/Program.cs | 2 +- src/ReverseLlama.Server/ServerSettings.cs | 3 + .../TokenAuthentication.cs | 125 +++++++++-- src/ReverseLlama.Server/wwwroot/admin/app.js | 90 ++++++++ .../wwwroot/admin/index.html | 1 + 7 files changed, 435 insertions(+), 15 deletions(-) diff --git a/src/ReverseLlama.Server/AdminEndpoints.cs b/src/ReverseLlama.Server/AdminEndpoints.cs index c4acc2d..4e40c57 100644 --- a/src/ReverseLlama.Server/AdminEndpoints.cs +++ b/src/ReverseLlama.Server/AdminEndpoints.cs @@ -288,6 +288,26 @@ internal static class AdminEndpoints ? Results.NoContent() : Results.NotFound(new { error = $"API key '{id}' was not found." })); + api.MapGet("/client-keys", (ManagementStore store) => + Results.Json(store.ListClientKeys())); + + api.MapPost("/client-keys", (CreateApiKeyRequest request, ManagementStore store) => + { + try + { + return Results.Json(store.CreateClientKey(request.Name)); + } + catch (Exception exception) + { + return Results.BadRequest(new { error = exception.Message }); + } + }); + + api.MapDelete("/client-keys/{id}", (string id, ManagementStore store) => + store.DeleteClientKey(id) + ? Results.NoContent() + : Results.NotFound(new { error = $"Client key '{id}' was not found." })); + api.MapGet("/groups", (ManagementStore store) => Results.Json(store.ListGroups())); @@ -599,7 +619,9 @@ internal static class AdminEndpoints { keycloakConfigured = settings.Keycloak.IsConfigured, sharedTokenConfigured = !string.IsNullOrWhiteSpace(settings.Token), - apiKeysConfigured = store.HasApiKeys + clientTokenConfigured = !string.IsNullOrWhiteSpace(settings.ClientToken), + apiKeysConfigured = store.HasApiKeys, + clientKeysConfigured = store.HasClientKeys }, management = new { @@ -610,6 +632,7 @@ internal static class AdminEndpoints clients = BuildClientSummaries(hub, store), models = BuildModelSummaries(hub, store), apiKeys = store.ListApiKeys(), + clientKeys = store.ListClientKeys(), groups = store.ListGroups(), apiKeyGroups = store.ListApiKeyGroups(), clientGroups = store.ResolveClientGroups( diff --git a/src/ReverseLlama.Server/ManagementStore.cs b/src/ReverseLlama.Server/ManagementStore.cs index 04c9ff2..16867d2 100644 --- a/src/ReverseLlama.Server/ManagementStore.cs +++ b/src/ReverseLlama.Server/ManagementStore.cs @@ -10,6 +10,7 @@ internal sealed class ManagementStore private static readonly TimeSpan ApiKeyLastUsedWriteInterval = TimeSpan.FromMinutes(1); private readonly Dictionary _apiKeysByHash = new(StringComparer.Ordinal); + private readonly Dictionary _clientKeysByHash = new(StringComparer.Ordinal); private readonly string _connectionString = ""; private readonly string _databasePath = ""; private readonly object _lock = new(); @@ -66,6 +67,22 @@ internal sealed class ManagementStore } } + public bool HasClientKeys + { + get + { + if (!_isAvailable) + { + return false; + } + + lock (_lock) + { + return _clientKeysByHash.Count > 0; + } + } + } + public bool IsApiKeyValid(string apiKey, bool updateLastUsed) { if (!_isAvailable || string.IsNullOrWhiteSpace(apiKey)) @@ -200,6 +217,155 @@ internal sealed class ManagementStore } } + public bool IsClientKeyValid(string clientKey, bool updateLastUsed) + { + if (!_isAvailable || string.IsNullOrWhiteSpace(clientKey)) + { + return false; + } + + var hash = HashApiKey(clientKey); + var now = DateTimeOffset.UtcNow; + + lock (_lock) + { + if (!_clientKeysByHash.TryGetValue(hash, out var key)) + { + return false; + } + + if (!updateLastUsed + || key.LastUsedUtc is not null + && now - key.LastUsedUtc.Value < ApiKeyLastUsedWriteInterval) + { + return true; + } + + key.LastUsedUtc = now; + + try + { + using var connection = OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "UPDATE client_keys SET last_used_at_utc = $last_used_at_utc WHERE id = $id"; + command.Parameters.AddWithValue("$last_used_at_utc", now.ToString("O")); + command.Parameters.AddWithValue("$id", key.Id); + command.ExecuteNonQuery(); + } + catch (Exception exception) when (exception is SqliteException or IOException or UnauthorizedAccessException) + { + _logger.LogWarning(exception, "Failed to update client key last-used timestamp."); + } + + return true; + } + } + + public IReadOnlyList ListClientKeys() + { + if (!_isAvailable) + { + return []; + } + + lock (_lock) + { + return _clientKeysByHash.Values + .OrderBy(key => key.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(key => key.CreatedAtUtc) + .Select(key => new ApiKeyInfo( + key.Id, + key.Name, + key.KeyPrefix, + key.CreatedAtUtc, + key.LastUsedUtc)) + .ToList(); + } + } + + public CreatedApiKey CreateClientKey(string? name) + { + EnsureAvailable(); + + var apiKey = GenerateApiKey(); + var now = DateTimeOffset.UtcNow; + var state = new ApiKeyState + { + Id = Guid.NewGuid().ToString("n"), + Name = string.IsNullOrWhiteSpace(name) ? "Client key" : name.Trim(), + KeyHash = HashApiKey(apiKey), + KeyPrefix = GetKeyPrefix(apiKey), + CreatedAtUtc = now + }; + + lock (_lock) + { + using var connection = OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO client_keys (id, name, key_hash, key_prefix, created_at_utc) + VALUES ($id, $name, $key_hash, $key_prefix, $created_at_utc) + """; + command.Parameters.AddWithValue("$id", state.Id); + command.Parameters.AddWithValue("$name", state.Name); + command.Parameters.AddWithValue("$key_hash", state.KeyHash); + command.Parameters.AddWithValue("$key_prefix", state.KeyPrefix); + command.Parameters.AddWithValue("$created_at_utc", state.CreatedAtUtc.ToString("O")); + command.ExecuteNonQuery(); + + _clientKeysByHash[state.KeyHash] = state; + } + + return new CreatedApiKey( + state.Id, + state.Name, + state.KeyPrefix, + state.CreatedAtUtc, + apiKey); + } + + public bool DeleteClientKey(string id) + { + if (!_isAvailable || string.IsNullOrWhiteSpace(id)) + { + return false; + } + + lock (_lock) + { + using var connection = OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "DELETE FROM client_keys WHERE id = $id"; + command.Parameters.AddWithValue("$id", id); + var deleted = command.ExecuteNonQuery() > 0; + + if (deleted) + { + foreach (var pair in _clientKeysByHash.Where(pair => pair.Value.Id == id).ToArray()) + { + _clientKeysByHash.Remove(pair.Key); + } + } + + return deleted; + } + } + + public string? GetClientKeyId(string clientKey) + { + if (!_isAvailable || string.IsNullOrWhiteSpace(clientKey)) + { + return null; + } + + var hash = HashApiKey(clientKey); + + lock (_lock) + { + return _clientKeysByHash.TryGetValue(hash, out var key) ? key.Id : null; + } + } + public ClientAccess GetClientAccess(string clientId) { if (!_isAvailable || string.IsNullOrWhiteSpace(clientId)) @@ -1825,6 +1991,15 @@ internal sealed class ManagementStore group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, PRIMARY KEY (api_key_id, group_id) ); + + CREATE TABLE IF NOT EXISTS client_keys ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + key_prefix TEXT NOT NULL, + created_at_utc TEXT NOT NULL, + last_used_at_utc TEXT NULL + ); """; command.ExecuteNonQuery(); @@ -1925,6 +2100,35 @@ internal sealed class ManagementStore "Loaded {ApiKeyCount} API key(s) from {DatabasePath}.", _apiKeysByHash.Count, _databasePath); + + using (var command = connection.CreateCommand()) + { + command.CommandText = """ + SELECT id, name, key_hash, key_prefix, created_at_utc, last_used_at_utc + FROM client_keys + """; + + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + var state = new ApiKeyState + { + Id = reader.GetString(0), + Name = reader.GetString(1), + KeyHash = reader.GetString(2), + KeyPrefix = reader.GetString(3), + CreatedAtUtc = ReadDateTimeOffset(reader.GetString(4)), + LastUsedUtc = ReadNullableDateTimeOffset(reader, 5) + }; + + _clientKeysByHash[state.KeyHash] = state; + } + } + + _logger.LogInformation( + "Loaded {ClientKeyCount} client key(s) from {DatabasePath}.", + _clientKeysByHash.Count, + _databasePath); } private SqliteConnection OpenConnection() diff --git a/src/ReverseLlama.Server/Program.cs b/src/ReverseLlama.Server/Program.cs index 8168509..198591f 100644 --- a/src/ReverseLlama.Server/Program.cs +++ b/src/ReverseLlama.Server/Program.cs @@ -296,7 +296,7 @@ app.MapGet(settings.StatusPath, (HttpContext context, TunnelHub hub, ServerSetti app.Map(settings.TunnelPath, async (HttpContext context, TunnelHub hub, ServerSettings serverSettings, ManagementStore managementStore) => { - if (!TokenAuthentication.IsAuthorized(context.Request, serverSettings, managementStore, allowQueryToken: true)) + if (!TokenAuthentication.IsClientAuthorized(context.Request, serverSettings, managementStore, allowQueryToken: true)) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; await context.Response.WriteAsync($"Missing or invalid {ProtocolConstants.TokenHeader}.", context.RequestAborted); diff --git a/src/ReverseLlama.Server/ServerSettings.cs b/src/ReverseLlama.Server/ServerSettings.cs index 4cdc220..84431a4 100644 --- a/src/ReverseLlama.Server/ServerSettings.cs +++ b/src/ReverseLlama.Server/ServerSettings.cs @@ -11,6 +11,8 @@ internal sealed class ServerSettings public string? Token { get; init; } + public string? ClientToken { get; init; } + public int ChunkSize { get; init; } = 64 * 1024; public string? EmbeddingCachePath { get; init; } @@ -28,6 +30,7 @@ internal sealed class ServerSettings StatusPath = NormalizePath(Read(configuration, "ReverseLlama:StatusPath", "status-path") ?? ProtocolConstants.DefaultStatusPath), TunnelPath = NormalizePath(Read(configuration, "ReverseLlama:TunnelPath", "tunnel-path") ?? ProtocolConstants.DefaultTunnelPath), Token = Read(configuration, "ReverseLlama:Token", "token") ?? Environment.GetEnvironmentVariable("REVERSE_LLAMA_TOKEN"), + ClientToken = Read(configuration, "ReverseLlama:ClientToken", "client-token") ?? Environment.GetEnvironmentVariable("REVERSE_LLAMA_CLIENT_TOKEN"), ChunkSize = ReadInt(configuration, 64 * 1024, "ReverseLlama:ChunkSize", "chunk-size", "REVERSE_LLAMA_CHUNK_SIZE"), EmbeddingCachePath = Read( configuration, diff --git a/src/ReverseLlama.Server/TokenAuthentication.cs b/src/ReverseLlama.Server/TokenAuthentication.cs index f05e527..4a5b228 100644 --- a/src/ReverseLlama.Server/TokenAuthentication.cs +++ b/src/ReverseLlama.Server/TokenAuthentication.cs @@ -15,16 +15,11 @@ internal static class TokenAuthentication bool allowQueryToken, bool allowPathToken = false) { - if (string.IsNullOrWhiteSpace(settings.Token) && !managementStore.HasApiKeys) - { - return AuthResult.Success(null); - } - if (request.Headers.TryGetValue(ProtocolConstants.TokenHeader, out var headerValues)) { foreach (var value in headerValues) { - var result = AuthorizeToken(value, settings, managementStore, updateApiKeyLastUsed: true); + var result = AuthorizeApiToken(value, settings, managementStore, updateApiKeyLastUsed: true); if (result.IsAuthorized) { return result; @@ -38,7 +33,7 @@ internal static class TokenAuthentication { if (TryGetBearerToken(value, out var bearerToken)) { - var result = AuthorizeToken(bearerToken, settings, managementStore, updateApiKeyLastUsed: true); + var result = AuthorizeApiToken(bearerToken, settings, managementStore, updateApiKeyLastUsed: true); if (result.IsAuthorized) { return result; @@ -53,7 +48,7 @@ internal static class TokenAuthentication if (allowPathToken && TryGetPathToken(request.Path, out var pathToken, out _)) { - var result = AuthorizeToken(pathToken, settings, managementStore, updateApiKeyLastUsed: true); + var result = AuthorizeApiToken(pathToken, settings, managementStore, updateApiKeyLastUsed: true); if (result.IsAuthorized) { return result; @@ -68,7 +63,67 @@ internal static class TokenAuthentication { foreach (var value in queryValues) { - var result = AuthorizeToken(value, settings, managementStore, updateApiKeyLastUsed: true); + var result = AuthorizeApiToken(value, settings, managementStore, updateApiKeyLastUsed: true); + if (result.IsAuthorized) + { + return result; + } + } + } + + return AuthResult.Failure; + } + + public static AuthResult AuthorizeClient( + HttpRequest request, + ServerSettings settings, + ManagementStore managementStore, + bool allowQueryToken, + bool allowPathToken = false) + { + if (request.Headers.TryGetValue(ProtocolConstants.TokenHeader, out var headerValues)) + { + foreach (var value in headerValues) + { + var result = AuthorizeClientToken(value, settings, managementStore, updateClientKeyLastUsed: true); + if (result.IsAuthorized) + { + return result; + } + } + } + + if (request.Headers.TryGetValue("Authorization", out var authorizationValues)) + { + foreach (var value in authorizationValues) + { + if (TryGetBearerToken(value, out var bearerToken)) + { + var result = AuthorizeClientToken(bearerToken, settings, managementStore, updateClientKeyLastUsed: true); + if (result.IsAuthorized) + { + return result; + } + } + } + } + + if (allowPathToken + && TryGetPathToken(request.Path, out var pathToken, out _)) + { + var result = AuthorizeClientToken(pathToken, settings, managementStore, updateClientKeyLastUsed: true); + if (result.IsAuthorized) + { + return result; + } + } + + if (allowQueryToken + && request.Query.TryGetValue("token", out var queryValues)) + { + foreach (var value in queryValues) + { + var result = AuthorizeClientToken(value, settings, managementStore, updateClientKeyLastUsed: true); if (result.IsAuthorized) { return result; @@ -87,6 +142,14 @@ internal static class TokenAuthentication bool allowPathToken = false) => Authorize(request, settings, managementStore, allowQueryToken, allowPathToken).IsAuthorized; + public static bool IsClientAuthorized( + HttpRequest request, + ServerSettings settings, + ManagementStore managementStore, + bool allowQueryToken, + bool allowPathToken = false) => + AuthorizeClient(request, settings, managementStore, allowQueryToken, allowPathToken).IsAuthorized; + public static bool TryRemovePathToken( PathString path, ServerSettings settings, @@ -96,7 +159,7 @@ internal static class TokenAuthentication remainingPath = path; if (!TryGetPathToken(path, out var pathToken, out var tokenRemainingPath) - || !IsTokenAuthorized(pathToken, settings, managementStore, updateApiKeyLastUsed: false)) + || !IsApiTokenAuthorized(pathToken, settings, managementStore, updateApiKeyLastUsed: false)) { return false; } @@ -109,9 +172,9 @@ internal static class TokenAuthentication public static bool IsOwnBearerValue(string? value, ServerSettings settings, ManagementStore managementStore) => TryGetBearerToken(value, out var token) - && IsTokenAuthorized(token, settings, managementStore, updateApiKeyLastUsed: false); + && IsApiTokenAuthorized(token, settings, managementStore, updateApiKeyLastUsed: false); - private static AuthResult AuthorizeToken( + private static AuthResult AuthorizeApiToken( string? token, ServerSettings settings, ManagementStore managementStore, @@ -140,12 +203,48 @@ internal static class TokenAuthentication return AuthResult.Failure; } + private static AuthResult AuthorizeClientToken( + string? token, + ServerSettings settings, + ManagementStore managementStore, + bool updateClientKeyLastUsed) + { + if (string.IsNullOrWhiteSpace(token)) + { + return AuthResult.Failure; + } + + if (!string.IsNullOrWhiteSpace(settings.ClientToken) + && CryptographicOperations.FixedTimeEquals( + SHA256.HashData(Encoding.UTF8.GetBytes(token)), + SHA256.HashData(Encoding.UTF8.GetBytes(settings.ClientToken)))) + { + return AuthResult.Success(null); + } + + var clientKeyId = managementStore.GetClientKeyId(token); + if (clientKeyId is not null) + { + managementStore.IsClientKeyValid(token, updateClientKeyLastUsed); + return AuthResult.Success(clientKeyId); + } + + return AuthResult.Failure; + } + public static bool IsTokenAuthorized( string? token, ServerSettings settings, ManagementStore managementStore, bool updateApiKeyLastUsed) => - AuthorizeToken(token, settings, managementStore, updateApiKeyLastUsed).IsAuthorized; + AuthorizeApiToken(token, settings, managementStore, updateApiKeyLastUsed).IsAuthorized; + + private static bool IsApiTokenAuthorized( + string? token, + ServerSettings settings, + ManagementStore managementStore, + bool updateApiKeyLastUsed) => + AuthorizeApiToken(token, settings, managementStore, updateApiKeyLastUsed).IsAuthorized; private static bool TryGetBearerToken(string? authorization, out string token) { diff --git a/src/ReverseLlama.Server/wwwroot/admin/app.js b/src/ReverseLlama.Server/wwwroot/admin/app.js index 6d575bc..01f7451 100644 --- a/src/ReverseLlama.Server/wwwroot/admin/app.js +++ b/src/ReverseLlama.Server/wwwroot/admin/app.js @@ -3,6 +3,7 @@ const state = { detail: null, groupDetail: null, newKey: null, + newClientKey: null, loading: false, usageData: null }; @@ -85,6 +86,16 @@ content.addEventListener("click", async (event) => { await refresh(); } + if (action === "delete-client-key") { + if (!confirm("Delete this client key?")) { + return; + } + + await api(`/client-keys/${encodeURIComponent(keyId)}`, { method: "DELETE" }); + setNotice("Client key deleted."); + await refresh(); + } + if (action === "copy-key") { await navigator.clipboard.writeText(button.dataset.key); setNotice("API key copied."); @@ -174,6 +185,15 @@ content.addEventListener("submit", async (event) => { await refresh(); } + if (form.dataset.form === "client-key") { + state.newClientKey = await api("/client-keys", { + method: "POST", + body: { name: data.name } + }); + setNotice("Client key created."); + await refresh(); + } + if (form.dataset.form === "create-group") { const result = await api("/groups", { method: "POST", @@ -359,6 +379,11 @@ async function renderRoute(showLoading = true) { return; } + if (view === "client-keys") { + renderClientKeys(); + return; + } + if (view === "groups") { renderGroups(); return; @@ -382,6 +407,8 @@ function updateShell() {
${escapeHtml(summary.user?.name || "Signed in")}
${summary.clients.length} clients
${summary.models.length} models
+
${(summary.clientKeys || []).length} client keys
+
${(summary.apiKeys || []).length} API keys
${(summary.groups || []).length} groups
${formatDate(summary.generatedAtUtc)}
`); @@ -714,6 +741,39 @@ function renderApiKeys() { `); } +function renderClientKeys() { + const keys = state.summary?.clientKeys || []; + pageTitle.textContent = "Client Keys"; + pageSubtitle.textContent = "Keys accepted by GPU clients to establish tunnel connections."; + + patchContent(` + ${state.newClientKey ? newKeyPanel(state.newClientKey) : ""} +
+
+

Create client key

+
+
+
+
+ + +
+ +
+
+
+
+
+

Client keys

+ ${keys.length} total +
+
+ ${keys.length ? clientKeysTable(keys) : emptyState("No client keys have been created.")} +
+
+ `); +} + function newKeyPanel(key) { return `
@@ -756,6 +816,36 @@ function apiKeysTable(keys) { `; } +function clientKeysTable(keys) { + const rows = keys.map((key) => ` + + +
${escapeHtml(key.name)}
+
${escapeHtml(key.keyPrefix)}...
+ + ${formatDate(key.createdAtUtc)} + ${key.lastUsedUtc ? formatDate(key.lastUsedUtc) : "Never"} + + + + + `).join(""); + + return ` + + + + + + + + + + ${rows} +
NameCreatedLast used
+ `; +} + function renderGroups() { const groups = state.summary?.groups || []; pageTitle.textContent = "Groups"; diff --git a/src/ReverseLlama.Server/wwwroot/admin/index.html b/src/ReverseLlama.Server/wwwroot/admin/index.html index b8ac06b..3fb2f12 100644 --- a/src/ReverseLlama.Server/wwwroot/admin/index.html +++ b/src/ReverseLlama.Server/wwwroot/admin/index.html @@ -19,6 +19,7 @@