src: renames projects

This commit is contained in:
lucretia.dietz
2026-07-20 07:51:36 +02:00
parent 3e9055d9b1
commit 5c4809e9d4
39 changed files with 0 additions and 0 deletions
+931
View File
@@ -0,0 +1,931 @@
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.EntityFrameworkCore;
using ReverseLlama.Server.Data;
using ReverseLlama.Server.Models;
namespace ReverseLlama.Server;
internal static class AdminEndpoints
{
private static readonly FileExtensionContentTypeProvider ContentTypes = new();
public static void MapAdminEndpoints(this WebApplication app, ServerSettings settings)
{
if (settings.Keycloak.IsConfigured)
{
app.MapGet("/admin/login", (string? returnUrl) =>
Results.Challenge(
new AuthenticationProperties { RedirectUri = NormalizeLocalReturnUrl(returnUrl) },
[OpenIdConnectDefaults.AuthenticationScheme]))
.AllowAnonymous();
app.MapPost("/admin/logout", () =>
Results.SignOut(
new AuthenticationProperties { RedirectUri = "/admin" },
[CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme]))
.RequireAuthorization();
}
else
{
app.MapGet("/admin/login", async (HttpContext context, SignInManager<ApplicationUser> signInManager, IAntiforgery antiforgery, string? returnUrl) =>
{
if (context.User.Identity?.IsAuthenticated == true)
return Results.Redirect(NormalizeLocalReturnUrl(returnUrl));
if (await signInManager.UserManager.Users.AnyAsync())
{
var tokens = antiforgery.GetAndStoreTokens(context);
return Results.Content(LoginPage(NormalizeLocalReturnUrl(returnUrl), null, tokens.RequestToken!), "text/html");
}
return Results.Redirect("/admin/setup");
}).AllowAnonymous();
app.MapPost("/admin/login", async (HttpContext context, SignInManager<ApplicationUser> signInManager, IAntiforgery antiforgery, string? returnUrl, [FromForm] string? username, [FromForm] string? password) =>
{
if (await signInManager.UserManager.Users.AnyAsync() == false)
return Results.Redirect("/admin/setup");
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
var tokens = antiforgery.GetAndStoreTokens(context);
return Results.Content(LoginPage(NormalizeLocalReturnUrl(returnUrl), "Username and password are required.", tokens.RequestToken!), "text/html");
}
var result = await signInManager.PasswordSignInAsync(username, password, true, true);
if (result.Succeeded)
return Results.Redirect(NormalizeLocalReturnUrl(returnUrl));
if (result.IsLockedOut)
{
var tokens = antiforgery.GetAndStoreTokens(context);
return Results.Content(LoginPage(NormalizeLocalReturnUrl(returnUrl), "Account is locked out.", tokens.RequestToken!), "text/html");
}
{
var tokens = antiforgery.GetAndStoreTokens(context);
return Results.Content(LoginPage(NormalizeLocalReturnUrl(returnUrl), "Invalid username or password.", tokens.RequestToken!), "text/html");
}
}).AllowAnonymous();
app.MapGet("/admin/setup", async (HttpContext context, SignInManager<ApplicationUser> signInManager, IAntiforgery antiforgery) =>
{
if (context.User.Identity?.IsAuthenticated == true)
return Results.Redirect("/admin");
if (await signInManager.UserManager.Users.AnyAsync())
return Results.Redirect("/admin/login");
var tokens = antiforgery.GetAndStoreTokens(context);
return Results.Content(SetupPage(null, tokens.RequestToken!), "text/html");
}).AllowAnonymous();
app.MapPost("/admin/setup", async (HttpContext context, SignInManager<ApplicationUser> signInManager, IAntiforgery antiforgery, [FromForm] string? username, [FromForm] string? email, [FromForm] string? password, [FromForm] string? confirmPassword) =>
{
if (await signInManager.UserManager.Users.AnyAsync())
return Results.Redirect("/admin/login");
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
var tokens = antiforgery.GetAndStoreTokens(context);
return Results.Content(SetupPage("Username and password are required.", tokens.RequestToken!), "text/html");
}
if (password != confirmPassword)
{
var tokens = antiforgery.GetAndStoreTokens(context);
return Results.Content(SetupPage("Passwords do not match.", tokens.RequestToken!), "text/html");
}
var user = new ApplicationUser { UserName = username, Email = email };
var result = await signInManager.UserManager.CreateAsync(user, password);
if (result.Succeeded)
{
await signInManager.SignInAsync(user, true);
return Results.Redirect("/admin");
}
var errors = string.Join(" ", result.Errors.Select(e => e.Description));
{
var tokens = antiforgery.GetAndStoreTokens(context);
return Results.Content(SetupPage(errors, tokens.RequestToken!), "text/html");
}
}).AllowAnonymous();
app.MapPost("/admin/logout", async (SignInManager<ApplicationUser> signInManager) =>
{
await signInManager.SignOutAsync();
return Results.Redirect("/admin/login");
}).RequireAuthorization();
}
app.MapGet("/admin/auth-error", () =>
Results.Text(
"Login failed while processing the Keycloak callback. The exception was written to ELMAH.",
"text/plain"))
.AllowAnonymous();
var api = app.MapGroup("/api/admin");
if (settings.Keycloak.IsConfigured)
{
api.RequireAuthorization();
}
else
{
api.RequireAuthorization();
}
api.MapGet("/summary", (HttpContext context, TunnelHub hub, ManagementStore store) =>
Results.Json(BuildSummary(context.User, hub, store, settings)));
api.MapGet("/me", (HttpContext context, ManagementStore store) =>
Results.Json(new
{
authenticated = context.User.Identity?.IsAuthenticated ?? false,
name = GetUserName(context.User),
keycloakConfigured = settings.Keycloak.IsConfigured,
management = new
{
available = store.IsAvailable,
databasePath = store.DatabasePath,
lastError = store.LastError
}
}));
api.MapPost("/clients/{clientId}/disable", (string clientId, DisableClientRequest request, ManagementStore store) =>
{
try
{
var manual = string.Equals(request.Mode, "manual", StringComparison.OrdinalIgnoreCase);
TimeSpan? duration = manual
? null
: TimeSpan.FromMinutes(Math.Clamp(request.DurationMinutes ?? 60, 1, 24 * 60));
store.DisableClient(clientId, duration, manual, request.Reason);
return Results.Ok(new { clientId, disabled = true });
}
catch (Exception exception)
{
return Results.BadRequest(new { error = exception.Message });
}
});
api.MapPost("/clients/{clientId}/enable", (string clientId, ManagementStore store) =>
{
try
{
store.EnableClient(clientId);
return Results.Ok(new { clientId, disabled = false });
}
catch (Exception exception)
{
return Results.BadRequest(new { error = exception.Message });
}
});
api.MapGet("/models/detail", async (
HttpContext context,
string model,
string? clientId,
TunnelHub hub,
ManagementStore store) =>
{
if (string.IsNullOrWhiteSpace(model))
{
return Results.BadRequest(new { error = "Model is required." });
}
var modelSummary = BuildModelSummaries(hub, store)
.FirstOrDefault(item => item.Name.Equals(model, StringComparison.OrdinalIgnoreCase));
var selectedClientId = ResolveModelClientId(hub, modelSummary, model, clientId);
object? show = null;
if (!string.IsNullOrWhiteSpace(selectedClientId))
{
var connection = hub.Get(selectedClientId);
if (connection is not null)
{
show = await SendModelCommandForApiAsync(
connection,
"show",
model,
TimeSpan.FromSeconds(60),
context.RequestAborted);
}
}
return Results.Json(new
{
model,
listedClients = modelSummary?.ListedClients ?? [],
activeClients = modelSummary?.ActiveClients ?? [],
metrics = modelSummary?.Metrics ?? EmptyModelMetrics(),
selectedClientId,
show
});
});
api.MapPost("/models/actions", async (
HttpContext context,
ModelActionRequest request,
TunnelHub hub) =>
{
if (string.IsNullOrWhiteSpace(request.ClientId)
|| string.IsNullOrWhiteSpace(request.Model)
|| string.IsNullOrWhiteSpace(request.Action))
{
return Results.BadRequest(new { error = "Client id, model, and action are required." });
}
if (!TryMapModelAction(request.Action, out var command, out var timeout))
{
return Results.BadRequest(new { error = $"Unsupported action '{request.Action}'." });
}
var connection = hub.Get(request.ClientId);
if (connection is null)
{
return Results.NotFound(new { error = $"Client '{request.ClientId}' is not connected." });
}
var result = await SendModelCommandForApiAsync(
connection,
command,
request.Model,
timeout,
context.RequestAborted);
return Results.Json(result);
});
api.MapGet("/user-keys", (ManagementStore store) =>
Results.Json(store.ListUserKeys()));
api.MapPost("/user-keys", (CreateUserKeyRequest request, ManagementStore store) =>
{
try
{
return Results.Json(store.CreateUserKey(request.Name));
}
catch (Exception exception)
{
return Results.BadRequest(new { error = exception.Message });
}
});
api.MapDelete("/user-keys/{id}", (string id, ManagementStore store) =>
store.DeleteUserKey(id)
? Results.NoContent()
: Results.NotFound(new { error = $"User key '{id}' was not found." }));
api.MapGet("/client-keys", (ManagementStore store) =>
Results.Json(store.ListClientKeys()));
api.MapPost("/client-keys", (CreateUserKeyRequest 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()));
api.MapPost("/groups", (CreateGroupRequest request, ManagementStore store) =>
{
try
{
return Results.Json(store.CreateGroup(request.Name));
}
catch (Exception exception)
{
return Results.BadRequest(new { error = exception.Message });
}
});
api.MapGet("/groups/{id}", (string id, ManagementStore store) =>
{
var group = store.GetGroup(id);
return group is not null
? Results.Json(group)
: Results.NotFound(new { error = $"Group '{id}' was not found." });
});
api.MapPut("/groups/{id}", (string id, UpdateGroupRequest request, ManagementStore store) =>
{
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest(new { error = "Name is required." });
}
return store.UpdateGroup(id, request.Name)
? Results.Ok(store.GetGroup(id))
: Results.NotFound(new { error = $"Group '{id}' was not found." });
});
api.MapDelete("/groups/{id}", (string id, ManagementStore store) =>
store.DeleteGroup(id)
? Results.NoContent()
: Results.NotFound(new { error = $"Group '{id}' was not found." }));
api.MapGet("/groups/{id}/clients", (string id, ManagementStore store) =>
{
var group = store.GetGroup(id);
if (group is null)
{
return Results.NotFound(new { error = $"Group '{id}' was not found." });
}
return Results.Json(store.ListGroupClients(id));
});
api.MapPost("/groups/{id}/clients", (string id, AddGroupClientRequest request, ManagementStore store) =>
{
var group = store.GetGroup(id);
if (group is null)
{
return Results.NotFound(new { error = $"Group '{id}' was not found." });
}
try
{
var member = store.AddGroupClient(id, request.ClientId, request.Model, request.ClientPattern);
return Results.Json(member);
}
catch (ArgumentException exception)
{
return Results.BadRequest(new { error = exception.Message });
}
catch (Exception exception)
{
return Results.BadRequest(new { error = $"Failed to add member: {exception.Message}" });
}
});
api.MapDelete("/groups/{groupId}/clients/{clientId:long}", (string groupId, long clientId, ManagementStore store) =>
{
var group = store.GetGroup(groupId);
if (group is null)
{
return Results.NotFound(new { error = $"Group '{groupId}' was not found." });
}
return store.RemoveGroupClient(clientId)
? Results.NoContent()
: Results.NotFound(new { error = $"Client '{clientId}' was not found." });
});
api.MapGet("/user-keys/groups", (ManagementStore store) =>
Results.Json(store.ListUserKeyGroups()));
api.MapPut("/user-keys/{id}/groups", (string id, SetUserKeyGroupsRequest request, ManagementStore store) =>
{
var keys = store.ListUserKeys();
if (!keys.Any(k => k.Id == id))
{
return Results.NotFound(new { error = $"User key '{id}' was not found." });
}
try
{
store.SetUserKeyGroups(id, request.GroupIds ?? []);
return Results.Ok(new { userKeyId = id, groupIds = store.GetUserKeyGroupIds(id) });
}
catch (Exception exception)
{
return Results.BadRequest(new { error = exception.Message });
}
});
api.MapGet("/groups/{id}/billing", (string id, ManagementStore store) =>
{
var group = store.GetGroup(id);
if (group is null)
{
return Results.NotFound(new { error = $"Group '{id}' was not found." });
}
var billing = store.GetGroupBilling(id);
return billing is not null
? Results.Json(billing)
: Results.Json(new GroupBillingInfo(id, "EUR", 0, 0, false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow));
});
api.MapPut("/groups/{id}/billing", (string id, UpdateBillingRequest request, ManagementStore store) =>
{
var group = store.GetGroup(id);
if (group is null)
{
return Results.NotFound(new { error = $"Group '{id}' was not found." });
}
try
{
var billing = store.UpsertGroupBilling(
id,
request.Currency ?? "EUR",
request.DefaultRatePer1k,
request.RefuseBelowBalance,
request.Enabled);
return Results.Ok(billing);
}
catch (Exception exception)
{
return Results.BadRequest(new { error = exception.Message });
}
});
api.MapGet("/groups/{id}/billing/rules", (string id, ManagementStore store) =>
{
var group = store.GetGroup(id);
if (group is null)
{
return Results.NotFound(new { error = $"Group '{id}' was not found." });
}
return Results.Json(store.ListGroupBillingRules(id));
});
api.MapPost("/groups/{id}/billing/rules", (string id, AddBillingRuleRequest request, ManagementStore store) =>
{
var group = store.GetGroup(id);
if (group is null)
{
return Results.NotFound(new { error = $"Group '{id}' was not found." });
}
try
{
var rule = store.AddBillingRule(id, request.ModelRegex, request.RatePer1k);
return Results.Json(rule);
}
catch (ArgumentException exception)
{
return Results.BadRequest(new { error = exception.Message });
}
});
api.MapPut("/groups/{id}/billing/rules/{ruleId:long}", (string id, long ruleId, UpdateBillingRuleRequest request, ManagementStore store) =>
{
try
{
return store.UpdateBillingRule(ruleId, request.ModelRegex, request.RatePer1k)
? Results.Ok(new { id = ruleId })
: Results.NotFound(new { error = $"Rule '{ruleId}' was not found." });
}
catch (ArgumentException exception)
{
return Results.BadRequest(new { error = exception.Message });
}
});
api.MapDelete("/groups/{id}/billing/rules/{ruleId:long}", (string id, long ruleId, ManagementStore store) =>
store.DeleteBillingRule(ruleId)
? Results.NoContent()
: Results.NotFound(new { error = $"Rule '{ruleId}' was not found." }));
api.MapGet("/groups/{id}/billing/payments", (string id, ManagementStore store) =>
{
var group = store.GetGroup(id);
if (group is null)
{
return Results.NotFound(new { error = $"Group '{id}' was not found." });
}
return Results.Json(store.ListGroupPayments(id));
});
api.MapPost("/groups/{id}/billing/payments", (string id, AddPaymentRequest request, ManagementStore store, HttpContext context) =>
{
var group = store.GetGroup(id);
if (group is null)
{
return Results.NotFound(new { error = $"Group '{id}' was not found." });
}
try
{
var userName = GetUserName(context.User);
var payment = store.AddPayment(id, request.Amount, request.Description, userName);
return Results.Json(payment);
}
catch (Exception exception)
{
return Results.BadRequest(new { error = exception.Message });
}
});
api.MapDelete("/groups/{id}/billing/payments/{paymentId:long}", (string id, long paymentId, ManagementStore store) =>
store.DeletePayment(paymentId)
? Results.NoContent()
: Results.NotFound(new { error = $"Payment '{paymentId}' was not found." }));
api.MapGet("/groups/{id}/billing/balance", (string id, ManagementStore store) =>
{
var group = store.GetGroup(id);
if (group is null)
{
return Results.NotFound(new { error = $"Group '{id}' was not found." });
}
return Results.Json(store.GetGroupBalance(id));
});
api.MapGet("/usage/tokens", (ManagementStore store) =>
Results.Json(new
{
byModel = store.GetTokenStatsByModel(),
byClient = store.GetTokenStatsByClient(),
byUserKey = store.GetTokenStatsByUserKey(),
byGroup = store.GetTokenStatsByGroup()
}));
api.MapGet("/usage/revenue", (ManagementStore store) =>
Results.Json(store.GetClientRevenue()));
var adminHome = app.MapGet("/admin", (IWebHostEnvironment environment) =>
ServeAdminAsset(environment, null));
var adminAssets = app.MapGet("/admin/{**assetPath}", (IWebHostEnvironment environment, string? assetPath) =>
ServeAdminAsset(environment, assetPath));
if (settings.Keycloak.IsConfigured)
{
adminHome.RequireAuthorization();
adminAssets.RequireAuthorization();
}
else
{
adminHome.RequireAuthorization();
adminAssets.RequireAuthorization();
}
}
private static string LoginPage(string returnUrl, string? error, string? antiforgeryToken)
{
var errorHtml = string.IsNullOrEmpty(error)
? ""
: "<div class=\"error\">" + HtmlEncode(error) + "</div>";
var loginAction = "/admin/login" + (returnUrl != "/admin" ? "?returnUrl=" + Uri.EscapeDataString(returnUrl) : "");
return "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Reverse Llama - Login</title>\n<style>\nbody{font-family:system-ui,sans-serif;background:#1a1a2e;color:#e0e0e0;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0}\n.card{background:#16213e;border:1px solid #0f3460;border-radius:12px;padding:2rem;width:100%;max-width:400px}\nh1{margin:0 0 1.5rem;font-size:1.5rem;text-align:center;color:#e94560}\nlabel{display:block;margin-bottom:.25rem;font-size:.875rem;color:#a0a0b0}\ninput{width:100%;padding:.5rem;border:1px solid #0f3460;border-radius:6px;background:#1a1a2e;color:#e0e0e0;font-size:1rem;margin-bottom:1rem;box-sizing:border-box}\ninput:focus{outline:none;border-color:#e94560}\nbutton{width:100%;padding:.625rem;border:none;border-radius:6px;background:#e94560;color:#fff;font-size:1rem;font-weight:600;cursor:pointer}\nbutton:hover{background:#c73650}\n.error{background:#3d1a1a;border:1px solid #e94560;border-radius:6px;padding:.5rem .75rem;margin-bottom:1rem;font-size:.875rem;color:#ff6b7a}\n</style>\n</head>\n<body>\n<div class=\"card\">\n<h1>Reverse Llama Admin</h1>\n" + errorHtml + "\n<form method=\"post\" action=\"" + HtmlEncode(loginAction) + "\">\n<input type=\"hidden\" name=\"__RequestVerificationToken\" value=\"" + HtmlEncode(antiforgeryToken) + "\">\n<label for=\"username\">Username</label>\n<input type=\"text\" id=\"username\" name=\"username\" autocomplete=\"username\" required autofocus>\n<label for=\"password\">Password</label>\n<input type=\"password\" id=\"password\" name=\"password\" autocomplete=\"current-password\" required>\n<input type=\"hidden\" name=\"returnUrl\" value=\"" + HtmlEncode(returnUrl) + "\">\n<button type=\"submit\">Sign In</button>\n</form>\n</div>\n</body>\n</html>";
}
private static string SetupPage(string? error, string? antiforgeryToken)
{
var errorHtml = string.IsNullOrEmpty(error)
? ""
: "<div class=\"error\">" + HtmlEncode(error) + "</div>";
return "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Reverse Llama - Initial Setup</title>\n<style>\nbody{font-family:system-ui,sans-serif;background:#1a1a2e;color:#e0e0e0;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0}\n.card{background:#16213e;border:1px solid #0f3460;border-radius:12px;padding:2rem;width:100%;max-width:400px}\nh1{margin:0 0 .25rem;font-size:1.5rem;text-align:center;color:#e94560}\n.subtitle{text-align:center;color:#a0a0b0;margin-bottom:1.5rem;font-size:.875rem}\nlabel{display:block;margin-bottom:.25rem;font-size:.875rem;color:#a0a0b0}\ninput{width:100%;padding:.5rem;border:1px solid #0f3460;border-radius:6px;background:#1a1a2e;color:#e0e0e0;font-size:1rem;margin-bottom:1rem;box-sizing:border-box}\ninput:focus{outline:none;border-color:#e94560}\nbutton{width:100%;padding:.625rem;border:none;border-radius:6px;background:#e94560;color:#fff;font-size:1rem;font-weight:600;cursor:pointer}\nbutton:hover{background:#c73650}\n.error{background:#3d1a1a;border:1px solid #e94560;border-radius:6px;padding:.5rem .75rem;margin-bottom:1rem;font-size:.875rem;color:#ff6b7a}\n</style>\n</head>\n<body>\n<div class=\"card\">\n<h1>Reverse Llama</h1>\n<p class=\"subtitle\">Initial Setup - Create Admin Account</p>\n" + errorHtml + "\n<form method=\"post\" action=\"/admin/setup\" id=\"setupForm\">\n<input type=\"hidden\" name=\"__RequestVerificationToken\" value=\"" + HtmlEncode(antiforgeryToken) + "\">\n<label for=\"username\">Username</label>\n<input type=\"text\" id=\"username\" name=\"username\" autocomplete=\"username\" required autofocus>\n<label for=\"email\">Email (optional)</label>\n<input type=\"email\" id=\"email\" name=\"email\" autocomplete=\"email\">\n<label for=\"password\">Password</label>\n<input type=\"password\" id=\"password\" name=\"password\" autocomplete=\"new-password\" required>\n<label for=\"confirmPassword\">Confirm Password</label>\n<input type=\"password\" id=\"confirmPassword\" name=\"confirmPassword\" autocomplete=\"new-password\" required>\n<button type=\"submit\">Create Account</button>\n</form>\n</div>\n<script>\ndocument.getElementById('setupForm').addEventListener('submit',function(e){\nvar p=document.getElementById('password').value;\nvar c=document.getElementById('confirmPassword').value;\nvar msg=[];\nif(p.length<8)msg.push('at least 8 characters');\nif(!/[a-z]/.test(p))msg.push('a lowercase letter');\nif(!/[A-Z]/.test(p))msg.push('an uppercase letter');\nif(!/[0-9]/.test(p))msg.push('a digit');\nif(p!==c)msg.push('passwords must match');\nif(msg.length){e.preventDefault();var d=document.querySelector('.error');if(!d){d=document.createElement('div');d.className='error';document.getElementById('setupForm').parentNode.insertBefore(d,document.getElementById('setupForm'));}d.textContent='Password needs: '+msg.join(', ')+'.';}});\n</script>\n</body>\n</html>";
}
private static string? HtmlEncode(string? value) =>
string.IsNullOrEmpty(value) ? null : System.Net.WebUtility.HtmlEncode(value);
private static object BuildSummary(
ClaimsPrincipal user,
TunnelHub hub,
ManagementStore store,
ServerSettings settings) =>
new
{
generatedAtUtc = DateTimeOffset.UtcNow,
user = new
{
name = GetUserName(user),
authenticated = user.Identity?.IsAuthenticated ?? false
},
auth = new
{
keycloakConfigured = settings.Keycloak.IsConfigured,
sharedTokenConfigured = !string.IsNullOrWhiteSpace(settings.Token),
clientTokenConfigured = !string.IsNullOrWhiteSpace(settings.ClientToken),
userKeysConfigured = store.HasUserKeys,
clientKeysConfigured = store.HasClientKeys
},
management = new
{
available = store.IsAvailable,
databasePath = store.DatabasePath,
lastError = store.LastError
},
clients = BuildClientSummaries(hub, store),
models = BuildModelSummaries(hub, store),
userKeys = store.ListUserKeys(),
clientKeys = store.ListClientKeys(),
groups = store.ListGroups(),
userKeyGroups = store.ListUserKeyGroups(),
clientGroups = store.ResolveClientGroups(
hub.ClientSnapshots.Select(c => c.Id).ToList())
};
private static IReadOnlyList<ClientSummary> BuildClientSummaries(TunnelHub hub, ManagementStore store)
{
var connected = hub.ClientSnapshots.ToDictionary(client => client.Id, StringComparer.OrdinalIgnoreCase);
var controls = store.ListClientControls();
var stats = store.GetClientRequestStats();
var clientIds = connected.Keys
.Concat(controls.Keys)
.Concat(stats.Keys)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(clientId => clientId, StringComparer.OrdinalIgnoreCase);
var result = new List<ClientSummary>();
foreach (var clientId in clientIds)
{
connected.TryGetValue(clientId, out var snapshot);
controls.TryGetValue(clientId, out var access);
stats.TryGetValue(clientId, out var requestStats);
access ??= ClientAccess.Enabled;
result.Add(new ClientSummary(
clientId,
snapshot is not null,
snapshot?.PendingRequests ?? 0,
snapshot?.Models ?? [],
snapshot?.ActiveModels ?? [],
snapshot?.ModelsUpdatedAt,
access.IsDisabled,
access.DisabledUntilUtc,
access.DisabledManually,
access.DisabledReason,
requestStats ?? new ClientRequestStats(0, 0, 0)));
}
return result;
}
private static IReadOnlyList<ModelSummary> BuildModelSummaries(TunnelHub hub, ManagementStore store)
{
var listedClients = new Dictionary<string, SortedSet<string>>(StringComparer.OrdinalIgnoreCase);
var activeClients = new Dictionary<string, SortedSet<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var client in hub.ClientSnapshots)
{
AddModelClients(listedClients, client.Models, client.Id);
AddModelClients(activeClients, client.ActiveModels, client.Id);
}
var metrics = store.GetModelUsageStats();
var modelNames = listedClients.Keys
.Concat(activeClients.Keys)
.Concat(metrics.Keys)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(model => model, StringComparer.OrdinalIgnoreCase);
var result = new List<ModelSummary>();
foreach (var model in modelNames)
{
metrics.TryGetValue(model, out var modelMetrics);
result.Add(new ModelSummary(
model,
listedClients.TryGetValue(model, out var listed) ? listed.ToArray() : [],
activeClients.TryGetValue(model, out var active) ? active.ToArray() : [],
modelMetrics ?? EmptyModelMetrics()));
}
return result;
}
private static void AddModelClients(
Dictionary<string, SortedSet<string>> target,
IEnumerable<string> models,
string clientId)
{
foreach (var model in models)
{
if (!target.TryGetValue(model, out var clients))
{
clients = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
target[model] = clients;
}
clients.Add(clientId);
}
}
private static string? ResolveModelClientId(
TunnelHub hub,
ModelSummary? modelSummary,
string model,
string? requestedClientId)
{
if (!string.IsNullOrWhiteSpace(requestedClientId)
&& hub.Get(requestedClientId) is not null)
{
return requestedClientId;
}
return modelSummary?.ActiveClients.FirstOrDefault(clientId => hub.Get(clientId) is not null)
?? modelSummary?.ListedClients.FirstOrDefault(clientId => hub.Get(clientId) is not null)
?? hub.SelectBest(model)?.ClientId;
}
private static ModelUsageStats EmptyModelMetrics() =>
new(0, 0, 0, 0, 0);
private static async Task<object> SendModelCommandForApiAsync(
TunnelConnection connection,
string command,
string model,
TimeSpan timeout,
CancellationToken cancellationToken)
{
try
{
var response = await connection.SendModelCommandAsync(
command,
model,
payloadJson: null,
timeout,
cancellationToken);
var body = response.Body is { Length: > 0 }
? Encoding.UTF8.GetString(response.Body)
: "";
return new
{
ok = response.StatusCode is >= 200 and < 300,
statusCode = response.StatusCode,
reasonPhrase = response.ReasonPhrase,
body = ParseJsonOrText(body)
};
}
catch (OperationCanceledException)
{
return new
{
ok = false,
statusCode = StatusCodes.Status504GatewayTimeout,
reasonPhrase = "Timed out",
body = "The model command timed out."
};
}
catch (Exception exception)
{
return new
{
ok = false,
statusCode = StatusCodes.Status502BadGateway,
reasonPhrase = "Command failed",
body = exception.Message
};
}
}
private static object? ParseJsonOrText(string body)
{
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
try
{
using var document = JsonDocument.Parse(body);
return document.RootElement.Clone();
}
catch (JsonException)
{
return body.Length <= 100_000 ? body : body[..100_000];
}
}
private static bool TryMapModelAction(string action, out string command, out TimeSpan timeout)
{
command = action.Trim().ToLowerInvariant() switch
{
"add" or "pull" => "pull",
"remove" or "delete" => "delete",
"load" => "load",
"unload" => "unload",
_ => ""
};
timeout = command == "pull" ? TimeSpan.FromMinutes(30) : TimeSpan.FromMinutes(2);
return command.Length > 0;
}
private static IResult ServeAdminAsset(IWebHostEnvironment environment, string? assetPath)
{
var path = string.IsNullOrWhiteSpace(assetPath) ? "index.html" : assetPath;
if (path.Contains("..", StringComparison.Ordinal)
|| path.Contains('\\'))
{
return Results.BadRequest();
}
var file = environment.WebRootFileProvider.GetFileInfo($"admin/{path}");
if (!file.Exists && !Path.HasExtension(path))
{
file = environment.WebRootFileProvider.GetFileInfo("admin/index.html");
}
if (!file.Exists)
{
return Results.NotFound();
}
ContentTypes.TryGetContentType(file.Name, out var contentType);
return Results.Stream(file.CreateReadStream(), contentType ?? "application/octet-stream");
}
private static string NormalizeLocalReturnUrl(string? returnUrl)
{
if (string.IsNullOrWhiteSpace(returnUrl)
|| !returnUrl.StartsWith("/", StringComparison.Ordinal)
|| returnUrl.StartsWith("//", StringComparison.Ordinal))
{
return "/admin";
}
return returnUrl;
}
private static string? GetUserName(ClaimsPrincipal user) =>
user.FindFirst("preferred_username")?.Value
?? user.FindFirst(ClaimTypes.Name)?.Value
?? user.Identity?.Name;
}
internal sealed record DisableClientRequest(
string? Mode,
int? DurationMinutes,
string? Reason);
internal sealed record ModelActionRequest(
string ClientId,
string Model,
string Action);
internal sealed record CreateUserKeyRequest(string? Name);
internal sealed record CreateGroupRequest(string? Name);
internal sealed record UpdateGroupRequest(string Name);
internal sealed record AddGroupClientRequest(
string? ClientId,
string? Model,
string? ClientPattern);
internal sealed record SetUserKeyGroupsRequest(IReadOnlyList<string>? GroupIds);
internal sealed record UpdateBillingRequest(
string? Currency,
double DefaultRatePer1k,
double RefuseBelowBalance,
bool Enabled);
internal sealed record AddBillingRuleRequest(
string ModelRegex,
double RatePer1k);
internal sealed record UpdateBillingRuleRequest(
string ModelRegex,
double RatePer1k);
internal sealed record AddPaymentRequest(
double Amount,
string? Description);
internal sealed record ClientSummary(
string Id,
bool Connected,
int PendingRequests,
IReadOnlyList<string> Models,
IReadOnlyList<string> ActiveModels,
DateTimeOffset? ModelsUpdatedAt,
bool Disabled,
DateTimeOffset? DisabledUntilUtc,
bool DisabledManually,
string? DisabledReason,
ClientRequestStats RequestStats);
internal sealed record ModelSummary(
string Name,
IReadOnlyList<string> ListedClients,
IReadOnlyList<string> ActiveClients,
ModelUsageStats Metrics);
+155
View File
@@ -0,0 +1,155 @@
using System.Collections.Concurrent;
using ElmahCore;
namespace ReverseLlama.Server;
internal sealed class AuthRateLimiter
{
private const int DecayIntervalMinutes = 144; // ~1 step per 2.4 hours
private readonly ConcurrentDictionary<string, AuthAttemptInfo> _attempts = new(StringComparer.OrdinalIgnoreCase);
private readonly ILogger<AuthRateLimiter> _logger;
private readonly ErrorLog _errorLog;
public AuthRateLimiter(ILogger<AuthRateLimiter> logger, ErrorLog errorLog)
{
_logger = logger;
_errorLog = errorLog;
}
public void RecordFailure(string ipAddress, string endpoint)
{
var info = _attempts.GetOrAdd(ipAddress, _ => new AuthAttemptInfo());
lock (info)
{
info.Count++;
info.LastAttemptUtc = DateTime.UtcNow;
if (info.Count >= 20)
{
info.BlockedUntilUtc = DateTime.UtcNow.AddHours(48);
_logger.LogWarning(
"IP {IpAddress} blocked for 48 hours after {Count} failed auth attempts (last: {Endpoint})",
ipAddress, info.Count, endpoint);
}
else
{
_logger.LogWarning(
"Failed auth attempt #{Count} from {IpAddress} on {Endpoint}",
info.Count, ipAddress, endpoint);
}
_errorLog.Log(new Error(new AuthFailureException(ipAddress, endpoint, info.Count)));
}
}
public void RecordSuccess(string ipAddress)
{
if (!_attempts.TryGetValue(ipAddress, out var info))
return;
lock (info)
{
if (info.Count > 0)
{
var before = info.Count;
info.Count /= 2;
info.LastAttemptUtc = DateTime.UtcNow;
_logger.LogInformation(
"Auth success from {IpAddress}: count reduced from {Before} to {After}",
ipAddress, before, info.Count);
}
}
}
public (bool Allowed, TimeSpan? RetryAfter, bool IsBlocked) CheckRateLimit(string ipAddress)
{
if (!_attempts.TryGetValue(ipAddress, out var info))
{
return (true, null, false);
}
lock (info)
{
if (info.BlockedUntilUtc is { } blockedUntil)
{
if (blockedUntil > DateTime.UtcNow)
{
return (false, blockedUntil - DateTime.UtcNow, true);
}
info.Count = 0;
info.BlockedUntilUtc = null;
info.LastAttemptUtc = DateTime.MinValue;
return (true, null, false);
}
if (info.Count > 0)
{
var elapsed = DateTime.UtcNow - info.LastAttemptUtc;
var decayTicks = (int)(elapsed.TotalMinutes / DecayIntervalMinutes);
if (decayTicks > 0)
{
info.Count = Math.Max(0, info.Count - decayTicks);
}
}
var waitTime = CalculateWaitTime(info.Count);
if (waitTime is { } wait)
{
var elapsed = DateTime.UtcNow - info.LastAttemptUtc;
if (elapsed < wait)
{
return (false, wait - elapsed, false);
}
}
return (true, null, false);
}
}
public static string GetClientIp(HttpRequest request)
{
if (request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor))
{
var first = forwardedFor.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(first))
{
var commaIndex = first.IndexOf(',');
return commaIndex > 0 ? first[..commaIndex].Trim() : first.Trim();
}
}
if (request.Headers.TryGetValue("X-Real-IP", out var realIp))
{
var first = realIp.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(first))
{
return first.Trim();
}
}
return request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
}
private static TimeSpan? CalculateWaitTime(int attemptCount) =>
attemptCount switch
{
< 3 => null,
< 5 => TimeSpan.FromSeconds(5),
< 10 => TimeSpan.FromSeconds(5 + (attemptCount - 5) * 5),
< 20 => TimeSpan.FromMinutes(attemptCount - 9),
_ => null
};
private sealed class AuthAttemptInfo
{
public int Count;
public DateTime LastAttemptUtc;
public DateTime? BlockedUntilUtc;
}
private sealed class AuthFailureException(string ipAddress, string endpoint, int attemptCount)
: Exception($"Failed auth attempt #{attemptCount} from {ipAddress} on {endpoint}");
}
@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using ReverseLlama.Server.Models;
namespace ReverseLlama.Server.Data;
internal sealed class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
}
+566
View File
@@ -0,0 +1,566 @@
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Data.Sqlite;
using ReverseLlama.Protocol;
namespace ReverseLlama.Server;
internal sealed class EmbeddingCache
{
private const string JsonContentType = "application/json; charset=utf-8";
private readonly ConcurrentDictionary<EmbeddingCacheKey, CachedEmbedding> _entries = new();
private string _connectionString = "";
private string _databasePath = "";
private bool _isAvailable;
private string? _lastError;
private readonly ILogger<EmbeddingCache> _logger;
private readonly SemaphoreSlim _storeLock = new(1, 1);
public EmbeddingCache(ServerSettings settings, ILogger<EmbeddingCache> logger)
{
_logger = logger;
try
{
_databasePath = ResolveDatabasePath(settings.EmbeddingCachePath);
_connectionString = new SqliteConnectionStringBuilder
{
DataSource = _databasePath,
Mode = SqliteOpenMode.ReadWriteCreate,
Pooling = true
}.ToString();
Initialize();
_isAvailable = true;
}
catch (Exception exception)
{
_lastError = exception.Message;
_logger.LogError(
exception,
"Embedding cache is disabled because SQLite could not be initialized at {DatabasePath}.",
string.IsNullOrWhiteSpace(_databasePath) ? settings.EmbeddingCachePath : _databasePath);
}
}
public int Count => _entries.Count;
public string DatabasePath => _databasePath;
public bool IsAvailable => _isAvailable;
public string? LastError => _lastError;
public async Task<EmbeddingCacheRequest?> TryReadRequestAsync(HttpRequest request, PathString path)
{
if (!HttpMethods.IsPost(request.Method)
|| !TryGetEndpointKind(path, out var kind)
|| !CanHaveBody(request))
{
return null;
}
request.EnableBuffering();
try
{
using var document = await JsonDocument.ParseAsync(
request.Body,
cancellationToken: request.HttpContext.RequestAborted);
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| !TryReadRequiredString(root, "model", out var model)
|| !TryReadInputTexts(root, kind, out var texts))
{
return null;
}
return new EmbeddingCacheRequest(kind, model, texts);
}
catch (JsonException)
{
return null;
}
finally
{
if (request.Body.CanSeek)
{
request.Body.Position = 0;
}
}
}
public async Task<bool> TryWriteCachedResponseAsync(HttpContext context, EmbeddingCacheRequest request)
{
var embeddings = new List<CachedEmbedding>(request.Texts.Count);
foreach (var text in request.Texts)
{
if (!_entries.TryGetValue(new EmbeddingCacheKey(request.Model, text), out var embedding))
{
return false;
}
embeddings.Add(embedding);
}
byte[] body;
try
{
body = BuildResponseBody(request, embeddings);
}
catch (JsonException exception)
{
_logger.LogWarning(exception, "Ignoring invalid cached embedding JSON for model {Model}.", request.Model);
return false;
}
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.ContentType = JsonContentType;
context.Response.ContentLength = body.Length;
context.Response.Headers["X-Reverse-Llama-Embedding-Cache"] = "hit";
await context.Response.Body.WriteAsync(body, context.RequestAborted);
return true;
}
public async Task StoreResponseAsync(
EmbeddingCacheRequest request,
TunnelMessage responseHeaders,
byte[] body,
CancellationToken cancellationToken)
{
if (!_isAvailable
|| responseHeaders.StatusCode is not >= 200 or >= 300
|| HasContentEncoding(responseHeaders)
|| body.Length == 0)
{
return;
}
List<CachedEmbedding> embeddings;
try
{
embeddings = ExtractEmbeddings(request, body);
}
catch (JsonException exception)
{
_logger.LogDebug(exception, "Embedding response for model {Model} was not cacheable JSON.", request.Model);
return;
}
if (embeddings.Count != request.Texts.Count)
{
_logger.LogDebug(
"Embedding response for model {Model} returned {EmbeddingCount} vector(s) for {TextCount} text(s); skipping cache store.",
request.Model,
embeddings.Count,
request.Texts.Count);
return;
}
await _storeLock.WaitAsync(cancellationToken);
try
{
using var connection = OpenConnection();
using var transaction = connection.BeginTransaction();
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
INSERT INTO embedding_cache (model, text, embedding_json, created_at_utc, updated_at_utc)
VALUES ($model, $text, $embedding_json, $now, $now)
ON CONFLICT(model, text) DO UPDATE SET
embedding_json = excluded.embedding_json,
updated_at_utc = excluded.updated_at_utc
""";
var modelParameter = command.Parameters.Add("$model", SqliteType.Text);
var textParameter = command.Parameters.Add("$text", SqliteType.Text);
var embeddingParameter = command.Parameters.Add("$embedding_json", SqliteType.Text);
var nowParameter = command.Parameters.Add("$now", SqliteType.Text);
var now = DateTimeOffset.UtcNow.ToString("O");
var stored = new List<(EmbeddingCacheKey Key, CachedEmbedding Embedding)>(embeddings.Count);
for (var index = 0; index < request.Texts.Count; index++)
{
var key = new EmbeddingCacheKey(request.Model, request.Texts[index]);
var embedding = embeddings[index] with { UpdatedAtUtc = now };
modelParameter.Value = key.Model;
textParameter.Value = key.Text;
embeddingParameter.Value = embedding.EmbeddingJson;
nowParameter.Value = now;
command.ExecuteNonQuery();
stored.Add((key, embedding));
}
transaction.Commit();
foreach (var (key, embedding) in stored)
{
_entries[key] = embedding;
}
}
catch (Exception exception) when (exception is SqliteException or IOException or UnauthorizedAccessException)
{
_logger.LogWarning(exception, "Failed to persist embedding cache entries to {DatabasePath}.", _databasePath);
}
finally
{
_storeLock.Release();
}
}
private void Initialize()
{
var directory = Path.GetDirectoryName(_databasePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
using var connection = OpenConnection();
using (var pragma = connection.CreateCommand())
{
pragma.CommandText = "PRAGMA journal_mode=WAL";
pragma.ExecuteNonQuery();
}
using (var command = connection.CreateCommand())
{
command.CommandText = """
CREATE TABLE IF NOT EXISTS embedding_cache (
model TEXT NOT NULL,
text TEXT NOT NULL,
embedding_json TEXT NOT NULL,
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL,
PRIMARY KEY (model, text)
)
""";
command.ExecuteNonQuery();
}
using (var command = connection.CreateCommand())
{
command.CommandText = "SELECT model, text, embedding_json, updated_at_utc FROM embedding_cache";
using var reader = command.ExecuteReader();
while (reader.Read())
{
var key = new EmbeddingCacheKey(reader.GetString(0), reader.GetString(1));
var embedding = new CachedEmbedding(reader.GetString(2), reader.GetString(3));
_entries[key] = embedding;
}
}
_logger.LogInformation(
"Loaded {EmbeddingCacheCount} embedding cache entries from {DatabasePath}.",
_entries.Count,
_databasePath);
}
private SqliteConnection OpenConnection()
{
var connection = new SqliteConnection(_connectionString);
connection.Open();
return connection;
}
private static bool TryGetEndpointKind(PathString path, out EmbeddingEndpointKind kind)
{
var value = (path.Value ?? "").TrimEnd('/');
if (value.Length == 0)
{
value = "/";
}
if (value.Equals("/api/embed", StringComparison.OrdinalIgnoreCase))
{
kind = EmbeddingEndpointKind.OllamaEmbed;
return true;
}
if (value.Equals("/api/embeddings", StringComparison.OrdinalIgnoreCase))
{
kind = EmbeddingEndpointKind.OllamaEmbeddings;
return true;
}
if (value.Equals("/v1/embeddings", StringComparison.OrdinalIgnoreCase))
{
kind = EmbeddingEndpointKind.OpenAi;
return true;
}
kind = default;
return false;
}
private static bool CanHaveBody(HttpRequest request)
{
var bodyDetection = request.HttpContext.Features.Get<IHttpRequestBodyDetectionFeature>();
if (bodyDetection?.CanHaveBody is bool canHaveBody)
{
return canHaveBody;
}
return request.ContentLength is > 0 || request.Headers.ContainsKey("Transfer-Encoding");
}
private static bool TryReadRequiredString(JsonElement root, string propertyName, out string value)
{
if (!TryReadString(root, propertyName, out value))
{
return false;
}
return !string.IsNullOrWhiteSpace(value);
}
private static bool TryReadString(JsonElement root, string propertyName, out string value)
{
value = "";
if (!root.TryGetProperty(propertyName, out var element)
|| element.ValueKind != JsonValueKind.String)
{
return false;
}
value = element.GetString() ?? "";
return true;
}
private static bool TryReadInputTexts(JsonElement root, EmbeddingEndpointKind kind, out IReadOnlyList<string> texts)
{
texts = [];
if (kind == EmbeddingEndpointKind.OllamaEmbeddings)
{
if (!TryReadString(root, "prompt", out var prompt))
{
return false;
}
texts = [prompt];
return true;
}
if (!root.TryGetProperty("input", out var input))
{
return false;
}
if (input.ValueKind == JsonValueKind.String)
{
texts = [input.GetString() ?? ""];
return true;
}
if (input.ValueKind != JsonValueKind.Array)
{
return false;
}
var values = new List<string>();
foreach (var item in input.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.String)
{
return false;
}
values.Add(item.GetString() ?? "");
}
texts = values;
return values.Count > 0;
}
private static bool HasContentEncoding(TunnelMessage responseHeaders) =>
responseHeaders.Headers.Any(header => header.Name.Equals("Content-Encoding", StringComparison.OrdinalIgnoreCase));
private static List<CachedEmbedding> ExtractEmbeddings(EmbeddingCacheRequest request, byte[] body)
{
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
return request.Kind switch
{
EmbeddingEndpointKind.OllamaEmbeddings => ExtractOllamaEmbeddings(root),
EmbeddingEndpointKind.OllamaEmbed => ExtractOllamaEmbed(root),
EmbeddingEndpointKind.OpenAi => ExtractOpenAiEmbeddings(root),
_ => []
};
}
private static List<CachedEmbedding> ExtractOllamaEmbeddings(JsonElement root)
{
if (root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty("embedding", out var embedding)
&& embedding.ValueKind == JsonValueKind.Array)
{
return [new CachedEmbedding(embedding.GetRawText(), "")];
}
return [];
}
private static List<CachedEmbedding> ExtractOllamaEmbed(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("embeddings", out var embeddings)
|| embeddings.ValueKind != JsonValueKind.Array)
{
return [];
}
var values = new List<CachedEmbedding>();
foreach (var embedding in embeddings.EnumerateArray())
{
if (embedding.ValueKind != JsonValueKind.Array)
{
return [];
}
values.Add(new CachedEmbedding(embedding.GetRawText(), ""));
}
return values;
}
private static List<CachedEmbedding> ExtractOpenAiEmbeddings(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("data", out var data)
|| data.ValueKind != JsonValueKind.Array)
{
return [];
}
var values = new List<(int Index, int Position, CachedEmbedding Embedding)>();
var position = 0;
foreach (var item in data.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object
|| !item.TryGetProperty("embedding", out var embedding)
|| embedding.ValueKind != JsonValueKind.Array)
{
return [];
}
var index = item.TryGetProperty("index", out var indexElement)
&& indexElement.ValueKind == JsonValueKind.Number
&& indexElement.TryGetInt32(out var parsedIndex)
? parsedIndex
: position;
values.Add((index, position, new CachedEmbedding(embedding.GetRawText(), "")));
position++;
}
return values
.OrderBy(value => value.Index)
.ThenBy(value => value.Position)
.Select(value => value.Embedding)
.ToList();
}
private static byte[] BuildResponseBody(EmbeddingCacheRequest request, IReadOnlyList<CachedEmbedding> embeddings)
{
using var memory = new MemoryStream();
using var writer = new Utf8JsonWriter(memory);
writer.WriteStartObject();
switch (request.Kind)
{
case EmbeddingEndpointKind.OllamaEmbeddings:
writer.WritePropertyName("embedding");
writer.WriteRawValue(embeddings[0].EmbeddingJson);
break;
case EmbeddingEndpointKind.OllamaEmbed:
writer.WriteString("model", request.Model);
writer.WritePropertyName("embeddings");
WriteEmbeddingArray(writer, embeddings);
break;
case EmbeddingEndpointKind.OpenAi:
writer.WriteString("object", "list");
writer.WritePropertyName("data");
writer.WriteStartArray();
for (var index = 0; index < embeddings.Count; index++)
{
writer.WriteStartObject();
writer.WriteString("object", "embedding");
writer.WritePropertyName("embedding");
writer.WriteRawValue(embeddings[index].EmbeddingJson);
writer.WriteNumber("index", index);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteString("model", request.Model);
writer.WriteStartObject("usage");
writer.WriteNumber("prompt_tokens", 0);
writer.WriteNumber("total_tokens", 0);
writer.WriteEndObject();
break;
}
writer.WriteEndObject();
writer.Flush();
return memory.ToArray();
}
private static void WriteEmbeddingArray(Utf8JsonWriter writer, IEnumerable<CachedEmbedding> embeddings)
{
writer.WriteStartArray();
foreach (var embedding in embeddings)
{
writer.WriteRawValue(embedding.EmbeddingJson);
}
writer.WriteEndArray();
}
private static string ResolveDatabasePath(string? configuredPath)
{
if (!string.IsNullOrWhiteSpace(configuredPath))
{
var expanded = Environment.ExpandEnvironmentVariables(configuredPath);
return Path.IsPathRooted(expanded)
? expanded
: Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, expanded));
}
return Path.Combine(AppContext.BaseDirectory, "App_Data", "embedding-cache.sqlite");
}
}
internal sealed record EmbeddingCacheRequest(
EmbeddingEndpointKind Kind,
string Model,
IReadOnlyList<string> Texts);
internal enum EmbeddingEndpointKind
{
OllamaEmbeddings,
OllamaEmbed,
OpenAi
}
internal readonly record struct EmbeddingCacheKey(string Model, string Text);
internal readonly record struct CachedEmbedding(string EmbeddingJson, string UpdatedAtUtc);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
using Microsoft.AspNetCore.Identity;
namespace ReverseLlama.Server.Models;
internal sealed class ApplicationUser : IdentityUser
{
}
+22
View File
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<PackageReference Include="ElmahCore" Version="2.1.2" />
<PackageReference Include="ElmahCore.MySql" Version="2.1.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.28" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.28" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.0" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
<PackageReference Include="System.Text.Encodings.Web" Version="8.0.0" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<ProjectReference Include="..\ReverseLlama.Protocol\ReverseLlama.Protocol.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+17
View File
@@ -0,0 +1,17 @@
using ReverseLlama.Protocol;
namespace ReverseLlama.Server;
internal sealed class PendingCommand
{
private readonly TaskCompletionSource<TunnelMessage> _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<TunnelMessage> WaitAsync(CancellationToken cancellationToken) =>
_completion.Task.WaitAsync(cancellationToken);
public void Complete(TunnelMessage message) =>
_completion.TrySetResult(message);
public void Fail(string error) =>
_completion.TrySetException(new InvalidOperationException(error));
}
+52
View File
@@ -0,0 +1,52 @@
using System.Threading.Channels;
using ReverseLlama.Protocol;
namespace ReverseLlama.Server;
internal sealed class PendingProxyRequest
{
private readonly Channel<byte[]> _body = Channel.CreateUnbounded<byte[]>(
new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false
});
private readonly TaskCompletionSource<TunnelMessage> _responseHeaders =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public ChannelReader<byte[]> Body => _body.Reader;
public Task<TunnelMessage> WaitForHeadersAsync(CancellationToken cancellationToken) =>
_responseHeaders.Task.WaitAsync(cancellationToken);
public void SetResponseHeaders(TunnelMessage message) =>
_responseHeaders.TrySetResult(message);
public void AddBody(byte[] body)
{
if (body.Length > 0)
{
_body.Writer.TryWrite(body);
}
}
public void Complete()
{
if (!_responseHeaders.Task.IsCompleted)
{
_responseHeaders.TrySetException(new InvalidOperationException("The client completed a response before sending response headers."));
}
_body.Writer.TryComplete();
}
public void Fail(string message) =>
Fail(new InvalidOperationException(message));
public void Fail(Exception exception)
{
_responseHeaders.TrySetException(exception);
_body.Writer.TryComplete(exception);
}
}
+339
View File
@@ -0,0 +1,339 @@
using System.Data.SqlClient;
using System.Net.WebSockets;
using ElmahCore;
using ElmahCore.Mvc;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using ReverseLlama.Protocol;
using ReverseLlama.Server;
using ReverseLlama.Server.Data;
using ReverseLlama.Server.Models;
var builder = WebApplication.CreateBuilder(args);
var settings = ServerSettings.FromConfiguration(builder.Configuration);
builder.Services.AddSingleton(settings);
builder.Services.AddSingleton<TunnelHub>();
builder.Services.AddSingleton<EmbeddingCache>();
builder.Services.AddSingleton<ManagementStore>();
builder.Services.AddSingleton<AuthRateLimiter>();
builder.Services.AddElmah<ElmahCore.MySql.MySqlErrorLog>().Configure<ElmahOptions>(
options => options.ConnectionString = builder.Configuration.GetConnectionString("ElmahConnection"));
if (settings.Keycloak.IsConfigured)
{
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.Cookie.Name = "ReverseLlama.Admin";
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = settings.SecureCookies ? CookieSecurePolicy.Always : CookieSecurePolicy.None;
options.LoginPath = "/admin/login";
options.LogoutPath = "/admin/logout";
})
.AddOpenIdConnect(options =>
{
options.Authority = settings.Keycloak.Authority;
options.ClientId = settings.Keycloak.ClientId;
options.ClientSecret = settings.Keycloak.ClientSecret;
options.RequireHttpsMetadata = settings.Keycloak.RequireHttpsMetadata;
options.ResponseType = OpenIdConnectResponseType.Code;
options.ResponseMode = OpenIdConnectResponseMode.Query;
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.CorrelationCookie.SameSite = SameSiteMode.Lax;
options.CorrelationCookie.SecurePolicy = settings.SecureCookies ? CookieSecurePolicy.Always : CookieSecurePolicy.None;
options.NonceCookie.SameSite = SameSiteMode.Lax;
options.NonceCookie.SecurePolicy = settings.SecureCookies ? CookieSecurePolicy.Always : CookieSecurePolicy.None;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.Events = new OpenIdConnectEvents
{
OnRemoteFailure = context =>
{
var errorLog = context.HttpContext.RequestServices.GetService<ErrorLog>();
if (context.Failure is not null)
{
errorLog?.Log(new Error(context.Failure));
}
context.HandleResponse();
context.Response.Redirect("/admin/auth-error");
return Task.CompletedTask;
}
};
});
}
if (!settings.Keycloak.IsConfigured)
{
var identityDbPath = Path.Combine(AppContext.BaseDirectory, "App_Data", "identity.sqlite");
var identityConnectionString = new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder
{
DataSource = identityDbPath,
Mode = Microsoft.Data.Sqlite.SqliteOpenMode.ReadWriteCreate
}.ToString();
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlite(identityConnectionString));
builder.Services
.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8;
options.User.RequireUniqueEmail = true;
options.SignIn.RequireConfirmedAccount = false;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
builder.Services.ConfigureApplicationCookie(options =>
{
options.Cookie.Name = "ReverseLlama.Admin";
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = settings.SecureCookies ? CookieSecurePolicy.Always : CookieSecurePolicy.None;
options.LoginPath = "/admin/login";
options.LogoutPath = "/admin/logout";
options.AccessDeniedPath = "/admin/login";
});
}
builder.Services.AddAntiforgery();
builder.Services.AddAuthorization();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
if (settings.Cors.AllowedOrigins.Contains("*"))
{
policy.AllowAnyOrigin();
}
else
{
policy.WithOrigins(settings.Cors.AllowedOrigins);
}
if (settings.Cors.AllowedMethods.Contains("*"))
{
policy.AllowAnyMethod();
}
else
{
policy.WithMethods(settings.Cors.AllowedMethods);
}
if (settings.Cors.AllowedHeaders.Contains("*"))
{
policy.AllowAnyHeader();
}
else
{
policy.WithHeaders(settings.Cors.AllowedHeaders);
}
if (settings.Cors.AllowCredentials)
{
policy.AllowCredentials();
}
});
});
var app = builder.Build();
var managementStore = app.Services.GetRequiredService<ManagementStore>();
var tunnelHub = app.Services.GetRequiredService<TunnelHub>();
managementStore.SetConnectedClientProvider(() => tunnelHub.ClientSnapshots.Select(c => c.Id));
app.UseCors();
if (settings.Keycloak.IsConfigured)
{
app.UseAuthentication();
app.UseAuthorization();
}
if (!settings.Keycloak.IsConfigured)
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
dbContext.Database.EnsureCreated();
app.UseAuthentication();
app.UseAuthorization();
}
app.UseAntiforgery();
app.UseElmah();
app.Use(async (context, next) =>
{
context.Response.Headers.XFrameOptions = "DENY";
context.Response.Headers.ContentSecurityPolicy = "frame-ancestors 'none'";
await next();
});
var rateLimiter = app.Services.GetRequiredService<AuthRateLimiter>();
app.Use(async (context, next) =>
{
if (HttpMethods.IsOptions(context.Request.Method))
{
await next();
return;
}
var ip = AuthRateLimiter.GetClientIp(context.Request);
var (allowed, retryAfter, _) = rateLimiter.CheckRateLimit(ip);
if (!allowed)
{
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.Response.Headers.RetryAfter = ((int)retryAfter!.Value.TotalSeconds).ToString();
context.Response.ContentType = "application/json";
var seconds = (int)retryAfter!.Value.TotalSeconds;
string retryMessage;
if (seconds >= 3600)
{
var hours = seconds / 3600;
retryMessage = $"Please try again in {hours} hour{(hours == 1 ? "" : "s")}.";
}
else if (seconds >= 60)
{
var minutes = seconds / 60;
retryMessage = $"Please try again in {minutes} minute{(minutes == 1 ? "" : "s")}.";
}
else
{
retryMessage = $"Please try again in {seconds} second{(seconds == 1 ? "" : "s")}.";
}
await context.Response.WriteAsJsonAsync(new { error = $"Too many requests. {retryMessage}" }, context.RequestAborted);
return;
}
context.Response.OnStarting(() =>
{
if (context.Response.StatusCode is StatusCodes.Status401Unauthorized)
{
rateLimiter.RecordFailure(ip, context.Request.Path);
}
else if (context.Response.StatusCode is StatusCodes.Status302Found
&& context.Request.Path.StartsWithSegments("/api/admin"))
{
var location = context.Response.Headers.Location.FirstOrDefault();
if (location is not null
&& location.Contains("/admin/login", StringComparison.OrdinalIgnoreCase))
{
rateLimiter.RecordFailure(ip, context.Request.Path);
}
else if (location is not null
&& location.StartsWith("/admin", StringComparison.OrdinalIgnoreCase))
{
rateLimiter.RecordSuccess(ip);
}
}
else if (context.Response.StatusCode is >= 200 and < 300
&& context.Request.Path.StartsWithSegments("/api/admin"))
{
rateLimiter.RecordSuccess(ip);
}
return Task.CompletedTask;
});
await next();
});
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30)
});
app.UseStaticFiles();
app.MapAdminEndpoints(settings);
app.MapGet("/", () => Results.Redirect("/admin"));
app.MapGet(settings.StatusPath, (HttpContext context, TunnelHub hub, ServerSettings serverSettings, EmbeddingCache embeddingCache, ManagementStore managementStore) =>
{
// Query token allowed so the status page can be checked in a browser.
if (!TokenAuthentication.IsAuthorized(context.Request, serverSettings, managementStore, allowQueryToken: true))
{
return Results.Unauthorized();
}
return Results.Json(new
{
connected = hub.HasClient,
pendingRequests = hub.PendingRequestCount,
tunnelPath = serverSettings.TunnelPath,
embeddingCache = new
{
available = embeddingCache.IsAvailable,
count = embeddingCache.Count
},
management = new
{
available = managementStore.IsAvailable
},
clients = hub.ClientsSnapshot
});
});
app.Map(settings.TunnelPath, async (HttpContext context, TunnelHub hub, ServerSettings serverSettings, ManagementStore managementStore) =>
{
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);
return;
}
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsync("This endpoint only accepts WebSocket tunnel connections.", context.RequestAborted);
return;
}
var clientId = context.Request.Headers[ProtocolConstants.ClientIdHeader].FirstOrDefault();
if (string.IsNullOrWhiteSpace(clientId))
{
clientId = $"anonymous-{Guid.NewGuid():n}";
}
using var socket = await context.WebSockets.AcceptWebSocketAsync();
await hub.AcceptAsync(clientId, socket, context.RequestAborted);
});
app.Map("/clients/{clientId}/{**path}", ReverseProxyEndpoint.HandleClientAsync);
app.Map("/{**path}", ReverseProxyEndpoint.HandleRootAsync)
.WithOrder(1000);
var elmahService = app.Services.GetRequiredService<ErrorLog>();
try
{
app.Run();
}
catch (Exception exception)
{
elmahService.Log(new Error(exception));
throw;
}
@@ -0,0 +1,38 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:4407",
"sslPort": 44305
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5001",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7183;http://localhost:5174",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+165
View File
@@ -0,0 +1,165 @@
using System.Text;
using System.Text.Json;
namespace ReverseLlama.Server;
internal sealed class ResponseTokenCounter
{
private const int MaxBufferedBytes = 4 * 1024 * 1024;
private readonly MemoryStream _buffer = new();
public void Add(ReadOnlySpan<byte> chunk)
{
if (chunk.Length == 0 || _buffer.Length >= MaxBufferedBytes)
{
return;
}
var available = MaxBufferedBytes - (int)_buffer.Length;
var length = Math.Min(chunk.Length, available);
_buffer.Write(chunk[..length]);
}
public TokenCounts CountTokens()
{
if (_buffer.Length == 0)
{
return new TokenCounts(0, 0, 0);
}
var payload = Encoding.UTF8.GetString(_buffer.ToArray());
var totalPrompt = 0;
var totalCompletion = 0;
var parsedLines = false;
foreach (var rawLine in payload.Split('\n'))
{
var line = rawLine.Trim();
if (line.Length == 0)
{
continue;
}
if (line.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
line = line["data:".Length..].Trim();
}
if (line.Equals("[DONE]", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (TryExtractTokenCountsFromJson(line, out var prompt, out var completion))
{
parsedLines = true;
totalPrompt += prompt;
totalCompletion += completion;
}
}
if (parsedLines)
{
return new TokenCounts(totalPrompt, totalCompletion, totalPrompt + totalCompletion);
}
if (TryExtractTokenCountsFromJson(payload, out var promptFallback, out var completionFallback))
{
return new TokenCounts(promptFallback, completionFallback, promptFallback + completionFallback);
}
return new TokenCounts(0, 0, 0);
}
private static bool TryExtractTokenCountsFromJson(string json, out int promptTokens, out int completionTokens)
{
promptTokens = 0;
completionTokens = 0;
try
{
using var document = JsonDocument.Parse(json);
ExtractTokenCounts(document.RootElement, out promptTokens, out completionTokens);
return promptTokens > 0 || completionTokens > 0;
}
catch (JsonException)
{
return false;
}
}
private static void ExtractTokenCounts(JsonElement element, out int promptTokens, out int completionTokens)
{
promptTokens = 0;
completionTokens = 0;
if (element.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.EnumerateArray())
{
ExtractTokenCounts(item, out var itemPrompt, out var itemCompletion);
promptTokens += itemPrompt;
completionTokens += itemCompletion;
}
return;
}
if (element.ValueKind != JsonValueKind.Object)
{
return;
}
if (element.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object)
{
if (TryGetInt(usage, "prompt_tokens", out var pt))
{
promptTokens += pt;
}
if (TryGetInt(usage, "completion_tokens", out var ct))
{
completionTokens += ct;
}
if (promptTokens == 0 && completionTokens == 0)
{
if (TryGetInt(usage, "input_tokens", out var it))
{
promptTokens += it;
}
if (TryGetInt(usage, "output_tokens", out var ot))
{
completionTokens += ot;
}
}
if (promptTokens > 0 || completionTokens > 0)
{
return;
}
}
if (TryGetInt(element, "prompt_eval_count", out var promptEval))
{
promptTokens += promptEval;
}
if (TryGetInt(element, "eval_count", out var eval))
{
completionTokens += eval;
}
}
private static bool TryGetInt(JsonElement element, string propertyName, out int value)
{
value = 0;
return element.TryGetProperty(propertyName, out var property)
&& property.ValueKind == JsonValueKind.Number
&& property.TryGetInt32(out value);
}
}
internal sealed record TokenCounts(int PromptTokens, int CompletionTokens, int TotalTokens);
+797
View File
@@ -0,0 +1,797 @@
using System.Text.Json;
using ElmahCore;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.Primitives;
using ReverseLlama.Protocol;
namespace ReverseLlama.Server;
internal static class ReverseProxyEndpoint
{
private const string UnauthorizedMessage = "Missing or invalid ReverseLlama token.";
private static readonly HashSet<string> HopByHopHeaders = new(StringComparer.OrdinalIgnoreCase)
{
"Connection",
"Expect",
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"TE",
"Trailer",
"Transfer-Encoding",
"Upgrade"
};
private static readonly HashSet<string> InternalHeaders = new(StringComparer.OrdinalIgnoreCase)
{
ProtocolConstants.TokenHeader
};
public static async Task HandleRootAsync(
HttpContext context,
TunnelHub hub,
ServerSettings settings,
ILoggerFactory loggerFactory,
EmbeddingCache embeddingCache,
ManagementStore managementStore)
{
var auth = TokenAuthentication.Authorize(context.Request, settings, managementStore, allowQueryToken: false, allowPathToken: true);
if (!auth.IsAuthorized)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync(UnauthorizedMessage, context.RequestAborted);
return;
}
var billingCheck = managementStore.CheckBalanceForUserKey(auth.UserKeyId);
if (!billingCheck.Allowed)
{
context.Response.StatusCode = StatusCodes.Status402PaymentRequired;
await context.Response.WriteAsJsonAsync(new
{
error = "Insufficient balance.",
balance = billingCheck.Balance,
currency = billingCheck.Currency,
threshold = billingCheck.Threshold
}, context.RequestAborted);
return;
}
var groupAccess = ResolveGroupAccess(auth.UserKeyId, managementStore);
var pathTokenRemoved = TokenAuthentication.TryRemovePathToken(context.Request.Path, settings, managementStore, out var proxyPath);
if (!pathTokenRemoved)
{
proxyPath = context.Request.Path;
}
if (pathTokenRemoved && HttpMethods.IsGet(context.Request.Method) && IsRootPath(proxyPath))
{
await WriteRootStatusAsync(context, hub);
return;
}
if (TryGetClientAddress(proxyPath, out var pathClientId, out var clientPath))
{
if (!groupAccess.IsClientAllowed(pathClientId))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync($"Access to client '{pathClientId}' is not permitted.", context.RequestAborted);
return;
}
await ForwardToClientAsync(
context,
pathClientId,
clientPath,
$"{clientPath}{context.Request.QueryString}",
hub,
settings,
loggerFactory,
embeddingCache,
managementStore,
groupAccess,
auth.UserKeyId);
return;
}
if (IsTagsRequest(context.Request, proxyPath))
{
await HandleTagsAsync(context, hub, managementStore, groupAccess);
return;
}
var embeddingRequest = await embeddingCache.TryReadRequestAsync(context.Request, proxyPath);
if (embeddingRequest is not null
&& await embeddingCache.TryWriteCachedResponseAsync(context, embeddingRequest))
{
return;
}
var requestedModel = embeddingRequest?.Model ?? await GetRequestedModelAsync(context.Request, proxyPath);
var connection = hub.SelectBest(
requestedModel,
clientId => !managementStore.GetClientAccess(clientId).IsDisabled
&& (requestedModel is null
? groupAccess.IsClientAllowed(clientId)
: groupAccess.IsClientModelAllowed(clientId, requestedModel)));
if (connection is null)
{
if (!hub.HasClient)
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsync("No tunnel client is connected.", context.RequestAborted);
return;
}
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsync(GetNoRouteMessage(requestedModel), context.RequestAborted);
return;
}
var pathAndQuery = $"{proxyPath}{context.Request.QueryString}";
await ForwardAsync(
context,
connection,
pathAndQuery,
requestedModel,
settings,
loggerFactory,
embeddingCache,
embeddingRequest,
managementStore,
auth.UserKeyId);
}
public static async Task HandleClientAsync(
HttpContext context,
string clientId,
string? path,
TunnelHub hub,
ServerSettings settings,
ILoggerFactory loggerFactory,
EmbeddingCache embeddingCache,
ManagementStore managementStore)
{
var auth = TokenAuthentication.Authorize(context.Request, settings, managementStore, allowQueryToken: false, allowPathToken: true);
if (!auth.IsAuthorized)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync(UnauthorizedMessage, context.RequestAborted);
return;
}
var billingCheck = managementStore.CheckBalanceForUserKey(auth.UserKeyId);
if (!billingCheck.Allowed)
{
context.Response.StatusCode = StatusCodes.Status402PaymentRequired;
await context.Response.WriteAsJsonAsync(new
{
error = "Insufficient balance.",
balance = billingCheck.Balance,
currency = billingCheck.Currency,
threshold = billingCheck.Threshold
}, context.RequestAborted);
return;
}
var groupAccess = ResolveGroupAccess(auth.UserKeyId, managementStore);
if (!groupAccess.IsClientAllowed(clientId))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync($"Access to client '{clientId}' is not permitted.", context.RequestAborted);
return;
}
var pathAndQuery = $"/{path}{context.Request.QueryString}";
var clientPath = new PathString($"/{path}");
await ForwardToClientAsync(
context,
clientId,
clientPath,
pathAndQuery,
hub,
settings,
loggerFactory,
embeddingCache,
managementStore,
groupAccess,
auth.UserKeyId);
}
private static async Task ForwardToClientAsync(
HttpContext context,
string clientId,
PathString clientPath,
string pathAndQuery,
TunnelHub hub,
ServerSettings settings,
ILoggerFactory loggerFactory,
EmbeddingCache embeddingCache,
ManagementStore managementStore,
GroupAccess? groupAccess = null,
string? userKeyId = null)
{
var clientAccess = managementStore.GetClientAccess(clientId);
if (clientAccess.IsDisabled)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync(GetClientDisabledMessage(clientId, clientAccess), context.RequestAborted);
return;
}
var embeddingRequest = await embeddingCache.TryReadRequestAsync(context.Request, clientPath);
if (embeddingRequest is not null
&& await embeddingCache.TryWriteCachedResponseAsync(context, embeddingRequest))
{
return;
}
var connection = hub.Get(clientId);
if (connection is null)
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsync($"No tunnel client with id '{clientId}' is connected.", context.RequestAborted);
return;
}
var requestedModel = embeddingRequest?.Model ?? await GetRequestedModelAsync(context.Request, clientPath);
if (requestedModel is not null && groupAccess is not null && !groupAccess.IsClientModelAllowed(clientId, requestedModel))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync($"Access to model '{requestedModel}' on client '{clientId}' is not permitted.", context.RequestAborted);
return;
}
await ForwardAsync(
context,
connection,
pathAndQuery,
requestedModel,
settings,
loggerFactory,
embeddingCache,
embeddingRequest,
managementStore,
userKeyId);
}
private static bool IsRootPath(PathString path) =>
string.IsNullOrEmpty(path.Value) || path.Value.Equals("/", StringComparison.Ordinal);
private static Task WriteRootStatusAsync(HttpContext context, TunnelHub hub) =>
context.Response.WriteAsJsonAsync(
new
{
status = "ok",
connected = hub.HasClient,
pendingRequests = hub.PendingRequestCount,
clients = hub.ClientsSnapshot.Count
},
context.RequestAborted);
private static bool TryGetClientAddress(PathString path, out string clientId, out PathString clientPath)
{
clientId = "";
clientPath = PathString.Empty;
if (!path.StartsWithSegments(new PathString("/clients"), out var pathAfterPrefix))
{
return false;
}
var value = pathAfterPrefix.Value ?? "";
if (value.Length <= 1 || value[0] != '/')
{
return false;
}
var nextSlash = value.IndexOf('/', 1);
clientId = nextSlash < 0
? value[1..]
: value[1..nextSlash];
if (string.IsNullOrWhiteSpace(clientId))
{
return false;
}
clientPath = nextSlash < 0
? new PathString("/")
: new PathString(value[nextSlash..]);
return true;
}
private static GroupAccess ResolveGroupAccess(string? userKeyId, ManagementStore managementStore)
{
if (string.IsNullOrWhiteSpace(userKeyId))
{
return GroupAccess.Unrestricted;
}
return managementStore.ResolveGroupAccess(userKeyId);
}
private static bool IsTagsRequest(HttpRequest request, PathString proxyPath) =>
HttpMethods.IsGet(request.Method)
&& string.Equals(proxyPath.Value, "/api/tags", StringComparison.OrdinalIgnoreCase);
private static async Task HandleTagsAsync(
HttpContext context,
TunnelHub hub,
ManagementStore managementStore,
GroupAccess groupAccess)
{
var models = new Dictionary<string, SortedSet<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var client in hub.ClientSnapshots)
{
foreach (var model in client.Models)
{
if (!models.TryGetValue(model, out var clients))
{
clients = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
models[model] = clients;
}
clients.Add(client.Id);
}
}
var filteredModels = new List<object>();
foreach (var (model, clients) in models.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
{
var accessibleClients = clients
.Where(clientId => groupAccess.IsClientModelAllowed(clientId, model))
.ToList();
if (accessibleClients.Count > 0)
{
filteredModels.Add(new
{
name = model,
model,
modified_at = DateTimeOffset.UtcNow,
size = 0,
digest = "",
details = new { }
});
}
}
var response = new { models = filteredModels };
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(response, context.RequestAborted);
}
private static string GetNoRouteMessage(string? requestedModel) =>
string.IsNullOrWhiteSpace(requestedModel)
? "No tunnel client is available for this request."
: $"No connected tunnel client reports model '{requestedModel}'. Check the status endpoint for connected client model lists.";
private static string GetClientDisabledMessage(string clientId, ClientAccess access)
{
if (access.DisabledManually)
{
return $"Tunnel client '{clientId}' is disabled until it is enabled manually.";
}
return access.DisabledUntilUtc is { } disabledUntil
? $"Tunnel client '{clientId}' is disabled until {disabledUntil:O}."
: $"Tunnel client '{clientId}' is disabled.";
}
private static async Task<string?> GetRequestedModelAsync(HttpRequest request, PathString proxyPath)
{
if (TryGetModelFromPath(proxyPath, out var pathModel))
{
return pathModel;
}
if (request.Query.TryGetValue("model", out var queryValues))
{
var queryModel = queryValues.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(queryModel))
{
return queryModel;
}
}
if (!CanHaveBody(request) || (!IsJsonRequest(request) && !IsLikelyModelRequestPath(proxyPath)))
{
return null;
}
request.EnableBuffering();
try
{
using var document = await JsonDocument.ParseAsync(
request.Body,
cancellationToken: request.HttpContext.RequestAborted);
return TryGetModelFromJson(document.RootElement, out var bodyModel)
? bodyModel
: null;
}
catch (JsonException)
{
return null;
}
finally
{
if (request.Body.CanSeek)
{
request.Body.Position = 0;
}
}
}
private static bool TryGetModelFromPath(PathString path, out string model)
{
model = "";
const string openAiModelPrefix = "/v1/models/";
var value = path.Value ?? "";
if (!value.StartsWith(openAiModelPrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var remaining = value[openAiModelPrefix.Length..];
var nextSlash = remaining.IndexOf('/');
model = Uri.UnescapeDataString(nextSlash < 0 ? remaining : remaining[..nextSlash]);
return !string.IsNullOrWhiteSpace(model);
}
private static bool TryGetModelFromJson(JsonElement root, out string model)
{
model = "";
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("model", out var modelElement)
|| modelElement.ValueKind != JsonValueKind.String)
{
return false;
}
model = modelElement.GetString() ?? "";
return !string.IsNullOrWhiteSpace(model);
}
private static bool IsJsonRequest(HttpRequest request)
{
if (string.IsNullOrWhiteSpace(request.ContentType))
{
return false;
}
var mediaType = request.ContentType.Split(';', 2)[0].Trim();
return mediaType.Equals("application/json", StringComparison.OrdinalIgnoreCase)
|| mediaType.EndsWith("+json", StringComparison.OrdinalIgnoreCase);
}
private static bool IsLikelyModelRequestPath(PathString path)
{
var value = path.Value ?? "";
return value.Equals("/api/generate", StringComparison.OrdinalIgnoreCase)
|| value.Equals("/api/chat", StringComparison.OrdinalIgnoreCase)
|| value.Equals("/api/embed", StringComparison.OrdinalIgnoreCase)
|| value.Equals("/api/embeddings", StringComparison.OrdinalIgnoreCase)
|| value.Equals("/api/show", StringComparison.OrdinalIgnoreCase)
|| value.Equals("/v1/chat/completions", StringComparison.OrdinalIgnoreCase)
|| value.Equals("/v1/completions", StringComparison.OrdinalIgnoreCase)
|| value.Equals("/v1/embeddings", StringComparison.OrdinalIgnoreCase)
|| value.Equals("/v1/responses", StringComparison.OrdinalIgnoreCase);
}
private static async Task ForwardAsync(
HttpContext context,
TunnelConnection connection,
string pathAndQuery,
string? requestedModel,
ServerSettings settings,
ILoggerFactory loggerFactory,
EmbeddingCache embeddingCache,
EmbeddingCacheRequest? embeddingRequest,
ManagementStore managementStore,
string? userKeyId = null)
{
var logger = loggerFactory.CreateLogger("ReverseLlama.Server.ReverseProxy");
var requestId = Guid.NewGuid().ToString("n");
var pending = connection.RegisterPending(requestId);
var startedAt = DateTimeOffset.UtcNow;
var tokenCounter = new ResponseTokenCounter();
int? statusCode = null;
var responseCompleted = false;
Task? requestBodyTask = null;
try
{
var hasBody = CanHaveBody(context.Request);
var requestMessage = new TunnelMessage
{
Type = TunnelMessageTypes.HttpRequest,
RequestId = requestId,
Method = context.Request.Method,
PathAndQuery = pathAndQuery,
HasBody = hasBody,
Headers = CollectRequestHeaders(context.Request, settings, managementStore)
};
await connection.SendAsync(requestMessage, context.RequestAborted);
requestBodyTask = ForwardRequestBodyAsync(context.Request, connection, requestId, hasBody, settings, logger);
_ = requestBodyTask.ContinueWith(
task => pending.Fail(task.Exception!.GetBaseException()),
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted,
TaskScheduler.Default);
var responseHeaders = await pending.WaitForHeadersAsync(context.RequestAborted);
statusCode = responseHeaders.StatusCode;
ApplyResponseHeaders(context.Response, responseHeaders);
await context.Response.StartAsync(context.RequestAborted);
if (embeddingRequest is not null)
{
var body = await ReadResponseBodyAsync(pending, context.RequestAborted);
tokenCounter.Add(body);
await context.Response.Body.WriteAsync(body, context.RequestAborted);
await context.Response.Body.FlushAsync(context.RequestAborted);
await embeddingCache.StoreResponseAsync(
embeddingRequest,
responseHeaders,
body,
CancellationToken.None);
}
else
{
await foreach (var chunk in pending.Body.ReadAllAsync(context.RequestAborted))
{
tokenCounter.Add(chunk);
await context.Response.Body.WriteAsync(chunk, context.RequestAborted);
await context.Response.Body.FlushAsync(context.RequestAborted);
}
}
responseCompleted = true;
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
logger.LogDebug("Proxy request {RequestId} was cancelled by the downstream caller.", requestId);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Proxy request {RequestId} failed.", requestId);
var errorLog = context.RequestServices.GetService<ErrorLog>();
if (errorLog is not null)
{
await errorLog.LogAsync(new Error(exception, context));
}
if (!context.Response.HasStarted)
{
context.Response.StatusCode = StatusCodes.Status502BadGateway;
await context.Response.WriteAsync("Bad gateway", CancellationToken.None);
}
else
{
context.Abort();
}
}
finally
{
connection.RemovePending(requestId);
var completedAt = DateTimeOffset.UtcNow;
var tokenCounts = tokenCounter.CountTokens();
var cost = 0.0;
if (!string.IsNullOrWhiteSpace(userKeyId) && tokenCounts.TotalTokens > 0)
{
var billing = managementStore.ResolveBillingForUserKey(userKeyId);
if (billing is not null)
{
cost = managementStore.CalculateCost(billing.GroupId, requestedModel, tokenCounts.TotalTokens);
}
}
managementStore.RecordRequest(new RequestMetric(
connection.ClientId,
requestedModel,
context.Request.Method,
pathAndQuery,
statusCode ?? (context.Response.HasStarted ? context.Response.StatusCode : null),
tokenCounts.PromptTokens,
tokenCounts.CompletionTokens,
tokenCounts.TotalTokens,
userKeyId,
cost,
startedAt,
completedAt,
completedAt - startedAt));
if (!responseCompleted && connection.IsOpen)
{
try
{
await connection.SendAsync(
new TunnelMessage
{
Type = TunnelMessageTypes.Cancel,
RequestId = requestId
},
CancellationToken.None);
}
catch
{
// The tunnel is already gone; nothing useful remains to notify.
}
}
if (requestBodyTask is { IsCompleted: true })
{
try
{
await requestBodyTask;
}
catch
{
// Already reflected through the proxy response path above.
}
}
}
}
private static async Task<byte[]> ReadResponseBodyAsync(PendingProxyRequest pending, CancellationToken cancellationToken)
{
using var memory = new MemoryStream();
await foreach (var chunk in pending.Body.ReadAllAsync(cancellationToken))
{
await memory.WriteAsync(chunk, cancellationToken);
}
return memory.ToArray();
}
private static async Task ForwardRequestBodyAsync(
HttpRequest request,
TunnelConnection connection,
string requestId,
bool hasBody,
ServerSettings settings,
ILogger logger)
{
try
{
if (hasBody)
{
var buffer = new byte[settings.ChunkSize];
while (true)
{
var bytesRead = await request.Body.ReadAsync(buffer, request.HttpContext.RequestAborted);
if (bytesRead == 0)
{
break;
}
await connection.SendAsync(
new TunnelMessage
{
Type = TunnelMessageTypes.HttpRequestBody,
RequestId = requestId,
Body = buffer.AsSpan(0, bytesRead).ToArray()
},
request.HttpContext.RequestAborted);
}
}
await connection.SendAsync(
new TunnelMessage
{
Type = TunnelMessageTypes.HttpRequestComplete,
RequestId = requestId
},
request.HttpContext.RequestAborted);
}
catch (Exception exception)
{
logger.LogDebug(exception, "Failed while forwarding request body {RequestId}.", requestId);
throw;
}
}
private static bool CanHaveBody(HttpRequest request)
{
var bodyDetection = request.HttpContext.Features.Get<IHttpRequestBodyDetectionFeature>();
if (bodyDetection?.CanHaveBody is bool canHaveBody)
{
return canHaveBody;
}
return request.ContentLength is > 0 || request.Headers.ContainsKey("Transfer-Encoding");
}
private static List<HeaderPair> CollectRequestHeaders(
HttpRequest request,
ServerSettings settings,
ManagementStore managementStore)
{
var headers = new List<HeaderPair>();
var skip = HeadersToSkip(request.Headers);
foreach (var header in request.Headers)
{
if (skip.Contains(header.Key) || InternalHeaders.Contains(header.Key))
{
continue;
}
foreach (var value in header.Value)
{
if (IsOwnBearerToken(header.Key, value, settings, managementStore))
{
continue;
}
headers.Add(new HeaderPair(header.Key, value ?? ""));
}
}
return headers;
}
// Our token in Bearer form authenticates against the proxy and must not
// leak upstream; any other Authorization header is forwarded untouched.
private static bool IsOwnBearerToken(
string headerName,
string? value,
ServerSettings settings,
ManagementStore managementStore) =>
string.Equals(headerName, "Authorization", StringComparison.OrdinalIgnoreCase)
&& TokenAuthentication.IsOwnBearerValue(value, settings, managementStore);
private static void ApplyResponseHeaders(HttpResponse response, TunnelMessage responseHeaders)
{
response.StatusCode = responseHeaders.StatusCode ?? StatusCodes.Status502BadGateway;
foreach (var group in responseHeaders.Headers.GroupBy(header => header.Name, StringComparer.OrdinalIgnoreCase))
{
if (ShouldSkipResponseHeader(group.Key))
{
continue;
}
response.Headers[group.Key] = new StringValues(group.Select(header => header.Value).ToArray());
}
}
private static HashSet<string> HeadersToSkip(IHeaderDictionary headers)
{
var skip = new HashSet<string>(HopByHopHeaders, StringComparer.OrdinalIgnoreCase);
if (headers.TryGetValue("Connection", out var connectionHeader))
{
foreach (var value in connectionHeader)
{
foreach (var headerName in value?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
{
skip.Add(headerName);
}
}
}
return skip;
}
private static bool ShouldSkipResponseHeader(string headerName) =>
HopByHopHeaders.Contains(headerName);
}
+142
View File
@@ -0,0 +1,142 @@
using Microsoft.Extensions.Configuration;
using ReverseLlama.Protocol;
namespace ReverseLlama.Server;
internal sealed class ServerSettings
{
public string StatusPath { get; init; } = ProtocolConstants.DefaultStatusPath;
public string TunnelPath { get; init; } = ProtocolConstants.DefaultTunnelPath;
public string? Token { get; init; }
public string? ClientToken { get; init; }
public int ChunkSize { get; init; } = 64 * 1024;
public string? EmbeddingCachePath { get; init; }
public string? ManagementDatabasePath { get; init; }
public bool SecureCookies { get; init; } = true;
public KeycloakSettings Keycloak { get; init; } = new();
public CorsSettings Cors { get; init; } = new();
public static ServerSettings FromConfiguration(IConfiguration configuration)
{
return new 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,
"ReverseLlama:EmbeddingCachePath",
"embedding-cache-path",
"REVERSE_LLAMA_EMBEDDING_CACHE_PATH"),
ManagementDatabasePath = Read(
configuration,
"ReverseLlama:ManagementDatabasePath",
"management-database-path",
"REVERSE_LLAMA_MANAGEMENT_DATABASE_PATH"),
SecureCookies = ReadBool(configuration, true, "ReverseLlama:SecureCookies", "secure-cookies", "REVERSE_LLAMA_SECURE_COOKIES"),
Keycloak = new KeycloakSettings
{
Authority = Read(configuration, "Authentication:Keycloak:Authority", "REVERSE_LLAMA_KEYCLOAK_AUTHORITY"),
ClientId = Read(configuration, "Authentication:Keycloak:ClientId", "REVERSE_LLAMA_KEYCLOAK_CLIENT_ID"),
ClientSecret = Read(configuration, "Authentication:Keycloak:ClientSecret", "REVERSE_LLAMA_KEYCLOAK_CLIENT_SECRET"),
RequireHttpsMetadata = ReadBool(
configuration,
true,
"Authentication:Keycloak:RequireHttpsMetadata",
"REVERSE_LLAMA_KEYCLOAK_REQUIRE_HTTPS_METADATA")
},
Cors = new CorsSettings
{
AllowedOrigins = ReadStringArray(configuration, ["CORS:AllowedOrigins"]),
AllowedMethods = ReadStringArray(configuration, ["CORS:AllowedMethods"]),
AllowedHeaders = ReadStringArray(configuration, ["CORS:AllowedHeaders"]),
AllowCredentials = ReadBool(configuration, false, "CORS:AllowCredentials")
}
};
}
private static string? Read(IConfiguration configuration, params string[] keys)
{
foreach (var key in keys)
{
var value = configuration[key] ?? Environment.GetEnvironmentVariable(key);
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
private static int ReadInt(IConfiguration configuration, int fallback, params string[] keys)
{
var value = Read(configuration, keys);
return int.TryParse(value, out var parsed) && parsed > 0 ? parsed : fallback;
}
private static bool ReadBool(IConfiguration configuration, bool fallback, params string[] keys)
{
var value = Read(configuration, keys);
return bool.TryParse(value, out var parsed) ? parsed : fallback;
}
private static string[] ReadStringArray(IConfiguration configuration, params string[] keys)
{
var section = configuration.GetSection(keys[0]);
var children = section.GetChildren().ToList();
if (children.Count > 0)
{
return children.Select(c => c.Value!).Where(v => !string.IsNullOrWhiteSpace(v)).ToArray();
}
var value = Read(configuration, keys);
if (string.IsNullOrWhiteSpace(value))
{
return [];
}
return value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static string NormalizePath(string path) =>
path.StartsWith('/') ? path : $"/{path}";
}
internal sealed class CorsSettings
{
public string[] AllowedOrigins { get; init; } = ["*"];
public string[] AllowedMethods { get; init; } = ["*"];
public string[] AllowedHeaders { get; init; } = ["*"];
public bool AllowCredentials { get; init; }
}
internal sealed class KeycloakSettings
{
public string? Authority { get; init; }
public string? ClientId { get; init; }
public string? ClientSecret { get; init; }
public bool RequireHttpsMetadata { get; init; } = true;
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(Authority)
&& !string.IsNullOrWhiteSpace(ClientId)
&& !string.IsNullOrWhiteSpace(ClientSecret);
}
+311
View File
@@ -0,0 +1,311 @@
using System.Security.Cryptography;
using System.Text;
using ReverseLlama.Protocol;
namespace ReverseLlama.Server;
internal static class TokenAuthentication
{
private static readonly PathString PathTokenPrefix = new("/token");
public static AuthResult Authorize(
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 = AuthorizeUserToken(value, settings, managementStore, updateUserKeyLastUsed: 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 = AuthorizeUserToken(bearerToken, settings, managementStore, updateUserKeyLastUsed: true);
if (result.IsAuthorized)
{
return result;
}
}
}
}
// Path-token auth: useful for clients that cannot send headers.
// SECURITY: the token appears in the URL and will be logged by
// web servers, proxies, and browsers. Prefer header auth when possible.
if (allowPathToken
&& TryGetPathToken(request.Path, out var pathToken, out _))
{
var result = AuthorizeUserToken(pathToken, settings, managementStore, updateUserKeyLastUsed: true);
if (result.IsAuthorized)
{
return result;
}
}
// Query-string auth: needed for clients that cannot send headers
// (e.g. browser address bar, status page).
// SECURITY: same URL-logging risks as path-token auth above.
if (allowQueryToken
&& request.Query.TryGetValue("token", out var queryValues))
{
foreach (var value in queryValues)
{
var result = AuthorizeUserToken(value, settings, managementStore, updateUserKeyLastUsed: 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;
}
}
}
return AuthResult.Failure;
}
public static bool IsAuthorized(
HttpRequest request,
ServerSettings settings,
ManagementStore managementStore,
bool allowQueryToken,
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,
ManagementStore managementStore,
out PathString remainingPath)
{
remainingPath = path;
if (!TryGetPathToken(path, out var pathToken, out var tokenRemainingPath)
|| !IsUserTokenAuthorized(pathToken, settings, managementStore, updateUserKeyLastUsed: false))
{
return false;
}
remainingPath = string.IsNullOrEmpty(tokenRemainingPath.Value)
? new PathString("/")
: tokenRemainingPath;
return true;
}
public static bool IsOwnBearerValue(string? value, ServerSettings settings, ManagementStore managementStore) =>
TryGetBearerToken(value, out var token)
&& IsUserTokenAuthorized(token, settings, managementStore, updateUserKeyLastUsed: false);
private static AuthResult AuthorizeUserToken(
string? token,
ServerSettings settings,
ManagementStore managementStore,
bool updateUserKeyLastUsed)
{
if (string.IsNullOrWhiteSpace(token))
{
return AuthResult.Failure;
}
if (!string.IsNullOrWhiteSpace(settings.Token)
&& CryptographicOperations.FixedTimeEquals(
SHA256.HashData(Encoding.UTF8.GetBytes(token)),
SHA256.HashData(Encoding.UTF8.GetBytes(settings.Token))))
{
return AuthResult.Success(null);
}
var userKeyId = managementStore.GetUserKeyId(token);
if (userKeyId is not null)
{
managementStore.IsUserKeyValid(token, updateUserKeyLastUsed);
return AuthResult.Success(userKeyId);
}
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 updateUserKeyLastUsed) =>
AuthorizeUserToken(token, settings, managementStore, updateUserKeyLastUsed).IsAuthorized;
private static bool IsUserTokenAuthorized(
string? token,
ServerSettings settings,
ManagementStore managementStore,
bool updateUserKeyLastUsed) =>
AuthorizeUserToken(token, settings, managementStore, updateUserKeyLastUsed).IsAuthorized;
private static bool TryGetBearerToken(string? authorization, out string token)
{
token = "";
if (string.IsNullOrWhiteSpace(authorization)
|| !authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return false;
}
token = authorization["Bearer ".Length..].Trim();
return token.Length > 0;
}
private static bool TryGetPathToken(PathString path, out string pathToken, out PathString remainingPath)
{
pathToken = "";
remainingPath = PathString.Empty;
if (!path.StartsWithSegments(PathTokenPrefix, out var pathAfterPrefix))
{
return false;
}
var value = pathAfterPrefix.Value ?? "";
if (value.Length <= 1 || value[0] != '/')
{
return false;
}
var nextSlash = value.IndexOf('/', 1);
pathToken = nextSlash < 0
? value[1..]
: value[1..nextSlash];
if (string.IsNullOrEmpty(pathToken))
{
return false;
}
remainingPath = nextSlash < 0
? PathString.Empty
: new PathString(value[nextSlash..]);
return true;
}
}
internal sealed class AuthResult
{
public static AuthResult Failure { get; } = new(false, null);
public static AuthResult Success(string? userKeyId) => new(true, userKeyId);
public bool IsAuthorized { get; }
public string? UserKeyId { get; }
private AuthResult(bool isAuthorized, string? userKeyId)
{
IsAuthorized = isAuthorized;
UserKeyId = userKeyId;
}
}
+333
View File
@@ -0,0 +1,333 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using ReverseLlama.Protocol;
namespace ReverseLlama.Server;
internal sealed class TunnelConnection
{
private readonly ConcurrentDictionary<string, PendingCommand> _commands = new();
private readonly object _modelsLock = new();
private readonly ConcurrentDictionary<string, PendingProxyRequest> _pending = new();
private readonly SemaphoreSlim _sendLock = new(1, 1);
private readonly WebSocket _socket;
private readonly ILogger<TunnelConnection> _logger;
private string[] _activeModels = [];
private string[] _models = [];
private DateTimeOffset? _modelsUpdatedAt;
public TunnelConnection(string clientId, WebSocket socket, ILogger<TunnelConnection> logger)
{
ClientId = clientId;
_socket = socket;
_logger = logger;
}
public string ClientId { get; }
public string Id { get; } = Guid.NewGuid().ToString("n");
public bool IsOpen => _socket.State == WebSocketState.Open;
public int PendingRequestCount => _pending.Count;
public IReadOnlyList<string> Models
{
get
{
lock (_modelsLock)
{
return _models;
}
}
}
public IReadOnlyList<string> ActiveModels
{
get
{
lock (_modelsLock)
{
return _activeModels;
}
}
}
public DateTimeOffset? ModelsUpdatedAt
{
get
{
lock (_modelsLock)
{
return _modelsUpdatedAt;
}
}
}
public PendingProxyRequest RegisterPending(string requestId)
{
var pending = new PendingProxyRequest();
if (!_pending.TryAdd(requestId, pending))
{
throw new InvalidOperationException($"Request id {requestId} is already registered.");
}
return pending;
}
public void RemovePending(string requestId)
{
_pending.TryRemove(requestId, out _);
}
public bool HasModel(string model)
{
if (string.IsNullOrWhiteSpace(model))
{
return false;
}
var requested = model.Trim();
foreach (var available in Models.Concat(ActiveModels))
{
if (ModelNamesMatch(requested, available))
{
return true;
}
}
return false;
}
public void UpdateModels(IEnumerable<string> models, IEnumerable<string> activeModels)
{
var snapshot = models
.Where(model => !string.IsNullOrWhiteSpace(model))
.Select(model => model.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(model => model, StringComparer.OrdinalIgnoreCase)
.ToArray();
var activeSnapshot = activeModels
.Where(model => !string.IsNullOrWhiteSpace(model))
.Select(model => model.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(model => model, StringComparer.OrdinalIgnoreCase)
.ToArray();
lock (_modelsLock)
{
_models = snapshot;
_activeModels = activeSnapshot;
_modelsUpdatedAt = DateTimeOffset.UtcNow;
}
}
public async Task<TunnelMessage> SendModelCommandAsync(
string command,
string model,
string? payloadJson,
TimeSpan timeout,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(command))
{
throw new ArgumentException("Command is required.", nameof(command));
}
if (string.IsNullOrWhiteSpace(model))
{
throw new ArgumentException("Model is required.", nameof(model));
}
var requestId = Guid.NewGuid().ToString("n");
var pending = new PendingCommand();
if (!_commands.TryAdd(requestId, pending))
{
throw new InvalidOperationException($"Command id {requestId} is already registered.");
}
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
try
{
await SendAsync(
new TunnelMessage
{
Type = TunnelMessageTypes.ModelCommand,
RequestId = requestId,
Command = command,
Model = model,
PayloadJson = payloadJson
},
timeoutCts.Token);
return await pending.WaitAsync(timeoutCts.Token);
}
finally
{
_commands.TryRemove(requestId, out _);
}
}
public async Task SendAsync(TunnelMessage message, CancellationToken cancellationToken)
{
await _sendLock.WaitAsync(cancellationToken);
try
{
if (_socket.State != WebSocketState.Open)
{
throw new InvalidOperationException("The tunnel client is not connected.");
}
await WebSocketMessageTransport.SendAsync(_socket, message, cancellationToken);
}
finally
{
_sendLock.Release();
}
}
public async Task RunReceiveLoopAsync(CancellationToken cancellationToken)
{
try
{
while (_socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
var message = await WebSocketMessageTransport.ReceiveAsync(_socket, cancellationToken);
if (message is null)
{
break;
}
Dispatch(message);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch (Exception exception)
{
_logger.LogWarning(exception, "Tunnel receive loop failed.");
}
finally
{
FailAll("Tunnel client disconnected.");
}
}
public async Task CloseAsync(string reason, string? closeDescription = null)
{
try
{
if (_socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
{
await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, closeDescription ?? reason, CancellationToken.None);
}
}
catch
{
}
finally
{
FailAll(reason);
}
}
private void Dispatch(TunnelMessage message)
{
if (message.Type == TunnelMessageTypes.ModelSnapshot)
{
UpdateModels(message.Models, message.ActiveModels);
_logger.LogInformation(
"Tunnel client {ClientId} reported {ModelCount} listed and {ActiveModelCount} active model(s).",
ClientId,
Models.Count,
ActiveModels.Count);
return;
}
if (message.Type == TunnelMessageTypes.ModelCommandResult)
{
if (_commands.TryRemove(message.RequestId, out var pendingCommand))
{
if (!string.IsNullOrWhiteSpace(message.Error))
{
pendingCommand.Fail(message.Error);
}
else
{
pendingCommand.Complete(message);
}
}
return;
}
if (string.IsNullOrWhiteSpace(message.RequestId))
{
_logger.LogDebug("Ignoring tunnel message without a request id: {MessageType}", message.Type);
return;
}
if (!_pending.TryGetValue(message.RequestId, out var pending))
{
_logger.LogDebug("Ignoring tunnel message for unknown request {RequestId}: {MessageType}", message.RequestId, message.Type);
return;
}
switch (message.Type)
{
case TunnelMessageTypes.HttpResponseHeaders:
pending.SetResponseHeaders(message);
break;
case TunnelMessageTypes.HttpResponseBody:
pending.AddBody(message.Body ?? []);
break;
case TunnelMessageTypes.HttpResponseComplete:
pending.Complete();
break;
case TunnelMessageTypes.Error:
pending.Fail(message.Error ?? "The tunnel client reported an error.");
break;
default:
_logger.LogDebug("Ignoring unsupported tunnel message type from client: {MessageType}", message.Type);
break;
}
}
private static bool ModelNamesMatch(string requested, string available) =>
string.Equals(requested, available, StringComparison.OrdinalIgnoreCase)
|| string.Equals(StripLatestTag(requested), StripLatestTag(available), StringComparison.OrdinalIgnoreCase);
private static string StripLatestTag(string model) =>
model.EndsWith(":latest", StringComparison.OrdinalIgnoreCase)
? model[..^":latest".Length]
: model;
private void FailAll(string reason)
{
foreach (var pair in _pending.ToArray())
{
if (_pending.TryRemove(pair.Key, out var pending))
{
pending.Fail(reason);
}
}
foreach (var pair in _commands.ToArray())
{
if (_commands.TryRemove(pair.Key, out var pending))
{
pending.Fail(reason);
}
}
}
}
+156
View File
@@ -0,0 +1,156 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Threading;
using ReverseLlama.Protocol;
namespace ReverseLlama.Server;
internal sealed class TunnelHub
{
private readonly ConcurrentDictionary<string, TunnelConnection> _connections = new(StringComparer.OrdinalIgnoreCase);
private readonly ILogger<TunnelHub> _logger;
private readonly ILoggerFactory _loggerFactory;
private long _roundRobinCounter;
public TunnelHub(ILogger<TunnelHub> logger, ILoggerFactory loggerFactory)
{
_logger = logger;
_loggerFactory = loggerFactory;
}
public bool HasClient => _connections.Values.Any(connection => connection.IsOpen);
public int PendingRequestCount => _connections.Values.Sum(connection => connection.PendingRequestCount);
public TunnelConnection? Get(string clientId) =>
_connections.TryGetValue(clientId, out var connection) && connection.IsOpen ? connection : null;
public TunnelConnection? SelectBest(string? model, Func<string, bool>? isAvailable = null)
{
var allOpen = _connections.Values
.Where(connection => connection.IsOpen)
.Where(connection => isAvailable?.Invoke(connection.ClientId) ?? true)
.ToList();
if (allOpen.Count == 0)
{
return null;
}
if (!string.IsNullOrWhiteSpace(model))
{
var withModel = allOpen.Where(connection => connection.HasModel(model)).ToList();
if (withModel.Count > 0)
{
return PickBest(withModel);
}
}
return PickBest(allOpen);
}
private TunnelConnection PickBest(List<TunnelConnection> candidates)
{
var tick = (int)(Interlocked.Increment(ref _roundRobinCounter) & 0x7FFFFFFF);
return candidates
.Select((connection, index) => (connection, index))
.OrderBy(x => x.connection.PendingRequestCount)
.ThenBy(x => (tick + x.index) % candidates.Count)
.First()
.connection;
}
/// <summary>The only open connection, or null when zero or more than one client is connected.</summary>
public TunnelConnection? Single
{
get
{
TunnelConnection? single = null;
foreach (var connection in _connections.Values)
{
if (!connection.IsOpen)
{
continue;
}
if (single is not null)
{
return null;
}
single = connection;
}
return single;
}
}
public IReadOnlyList<TunnelClientSnapshot> ClientSnapshots =>
_connections.Values
.Where(connection => connection.IsOpen)
.OrderBy(connection => connection.ClientId, StringComparer.OrdinalIgnoreCase)
.Select(connection => new TunnelClientSnapshot(
connection.ClientId,
connection.PendingRequestCount,
connection.Models,
connection.ActiveModels,
connection.ModelsUpdatedAt))
.ToList();
public IReadOnlyList<object> ClientsSnapshot =>
ClientSnapshots
.Select(client => (object)new
{
id = client.Id,
pendingRequests = client.PendingRequests,
models = client.Models,
activeModels = client.ActiveModels,
modelsUpdatedAt = client.ModelsUpdatedAt
})
.ToList();
public async Task AcceptAsync(string clientId, WebSocket socket, CancellationToken cancellationToken)
{
var connection = new TunnelConnection(clientId, socket, _loggerFactory.CreateLogger<TunnelConnection>());
TunnelConnection? previous = null;
_connections.AddOrUpdate(
clientId,
connection,
(_, existing) =>
{
previous = existing;
return connection;
});
if (previous is not null)
{
_logger.LogInformation(
"Replacing existing tunnel client {ClientId} ({ConnectionId}) with {NewConnectionId}.",
clientId, previous.Id, connection.Id);
await previous.CloseAsync("A newer tunnel client connected.", ProtocolConstants.ReplacedCloseDescription);
}
_logger.LogInformation("Tunnel client {ClientId} ({ConnectionId}) connected.", clientId, connection.Id);
try
{
await connection.RunReceiveLoopAsync(cancellationToken);
}
finally
{
_connections.TryRemove(new KeyValuePair<string, TunnelConnection>(clientId, connection));
await connection.CloseAsync("Tunnel closed.");
_logger.LogInformation("Tunnel client {ClientId} ({ConnectionId}) disconnected.", clientId, connection.Id);
}
}
}
internal sealed record TunnelClientSnapshot(
string Id,
int PendingRequests,
IReadOnlyList<string> Models,
IReadOnlyList<string> ActiveModels,
DateTimeOffset? ModelsUpdatedAt);
+19
View File
@@ -0,0 +1,19 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"ElmahConnection": "Data Source=localhost;Initial Catalog=elmah;Persist Security Info=True;User ID=elmah;Password=elmah;"
},
"Authentication": {
"Keycloak": {
"Authority": "http://your-keycloak-server/realms/master",
"ClientId": "ReverseLlama",
"ClientSecret": "YOUR-Client-SECRET-GOES-HERE-AND-YES-ITS-VERY-LONG",
"RequireHttpsMetadata": false
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"CORS": {
"AllowedOrigins": [ "*" ],
"AllowedMethods": [ "GET", "POST", "PUT", "DELETE", "PATCH" ],
"AllowedHeaders": [ "Content-Type", "Authorization" ],
"AllowCredentials": false
},
"AllowedHosts": "*"
}
+572
View File
@@ -0,0 +1,572 @@
:root {
color-scheme: light;
--bg: #f5f7f9;
--panel: #ffffff;
--panel-alt: #eef2f6;
--text: #18212f;
--muted: #667085;
--line: #d9e0e7;
--line-strong: #b8c3cf;
--blue: #2f6fed;
--blue-dark: #1f4fb7;
--green: #117a55;
--red: #b42318;
--amber: #9a6500;
--shadow: 0 12px 30px rgba(24, 33, 47, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: Inter, "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 14px;
}
button,
input,
select,
textarea {
font: inherit;
}
.app-shell {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 20px;
padding: 20px 16px;
background: #151b24;
color: #f8fafc;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.brand-mark {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: 1px solid #4c5d74;
border-radius: 6px;
background: #223044;
color: #c8f2df;
font-weight: 800;
letter-spacing: 0;
}
.brand strong,
.brand small {
display: block;
overflow-wrap: anywhere;
}
.brand small {
color: #aeb8c6;
margin-top: 2px;
}
.nav {
display: grid;
gap: 6px;
}
.nav a {
color: #d8dee8;
text-decoration: none;
padding: 9px 10px;
border-radius: 6px;
}
.nav a.active,
.nav a:hover {
background: #263449;
color: #ffffff;
}
.sidebar-meta {
margin-top: auto;
padding-top: 16px;
border-top: 1px solid #344154;
color: #b8c3cf;
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.main {
min-width: 0;
padding: 22px;
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.topbar h1 {
margin: 0;
font-size: 24px;
line-height: 1.2;
letter-spacing: 0;
}
.topbar p {
margin: 6px 0 0;
color: var(--muted);
}
.topbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.content {
display: grid;
gap: 14px;
}
.toolbar {
display: flex;
align-items: end;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.form-row {
display: flex;
align-items: end;
gap: 8px;
flex-wrap: wrap;
}
.field {
display: grid;
gap: 5px;
}
.field label {
color: var(--muted);
font-size: 12px;
font-weight: 600;
}
.input,
.select,
.textarea {
width: 100%;
min-height: 36px;
border: 1px solid var(--line-strong);
border-radius: 6px;
background: #ffffff;
color: var(--text);
padding: 8px 10px;
}
.textarea {
min-height: 72px;
resize: vertical;
}
.input:focus,
.select:focus,
.textarea:focus {
outline: 2px solid rgba(47, 111, 237, 0.25);
border-color: var(--blue);
}
.button {
min-height: 36px;
border: 1px solid var(--blue);
border-radius: 6px;
background: var(--blue);
color: #ffffff;
padding: 8px 12px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.button:hover {
background: var(--blue-dark);
border-color: var(--blue-dark);
}
.button.secondary {
background: #ffffff;
border-color: var(--line-strong);
color: var(--text);
}
.button.secondary:hover {
background: var(--panel-alt);
}
.button.danger {
background: #ffffff;
border-color: #e5aaa4;
color: var(--red);
}
.button.danger:hover {
background: #fff1f0;
}
.button.warning {
background: #ffffff;
border-color: #e4c37c;
color: var(--amber);
}
.button.warning:hover {
background: #fff8e5;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
.panel-header h2 {
margin: 0;
font-size: 15px;
letter-spacing: 0;
}
.panel-body {
padding: 14px 16px;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
min-width: 900px;
}
th,
td {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.col-models {
width: 40%;
overflow-wrap: anywhere;
}
th {
color: var(--muted);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
tr:last-child td {
border-bottom: 0;
}
.cell-main {
font-weight: 800;
overflow-wrap: anywhere;
}
.cell-sub {
margin-top: 3px;
color: var(--muted);
font-size: 12px;
overflow-wrap: anywhere;
}
.actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.badge-row {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.badge {
display: inline-flex;
align-items: center;
min-height: 24px;
border: 1px solid var(--line);
border-radius: 999px;
padding: 3px 8px;
background: var(--panel-alt);
color: var(--text);
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.badge.good {
border-color: #9bd7bd;
background: #eaf8f1;
color: var(--green);
}
.badge.bad {
border-color: #efb2ad;
background: #fff1f0;
color: var(--red);
}
.badge.warn {
border-color: #ead09a;
background: #fff8e5;
color: var(--amber);
}
.badge[href] {
text-decoration: none;
cursor: pointer;
}
.badge[href]:hover {
border-color: var(--blue);
background: #edf4ff;
color: var(--blue);
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 10px;
}
.metric {
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
}
.metric strong {
display: block;
font-size: 22px;
letter-spacing: 0;
}
.metric span {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: 12px;
}
.notice {
margin-bottom: 12px;
padding: 10px 12px;
border: 1px solid #bad2ff;
border-radius: 8px;
background: #edf4ff;
color: #163d85;
}
.notice.error {
border-color: #efb2ad;
background: #fff1f0;
color: var(--red);
}
.empty {
padding: 28px 16px;
color: var(--muted);
text-align: center;
}
.pre {
max-height: 460px;
overflow: auto;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #101820;
color: #e9eef5;
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.new-key {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid #9bd7bd;
border-radius: 8px;
background: #eaf8f1;
}
.new-key code {
display: block;
padding: 10px;
border: 1px solid #9bd7bd;
border-radius: 6px;
background: #ffffff;
color: var(--text);
overflow-wrap: anywhere;
}
.group-link {
color: var(--blue);
text-decoration: none;
font-weight: 800;
overflow-wrap: anywhere;
}
.group-link:hover {
text-decoration: underline;
}
.code-inline {
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 12px;
background: var(--panel-alt);
border: 1px solid var(--line);
border-radius: 4px;
padding: 2px 6px;
display: inline-block;
overflow-wrap: anywhere;
}
.balance-display {
display: flex;
align-items: center;
gap: 24px;
}
.balance-current strong {
display: block;
font-size: 28px;
letter-spacing: 0;
}
.balance-current span {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: 12px;
}
.balance-detail {
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.billing-toggle {
display: flex;
flex-direction: column;
justify-content: center;
}
.billing-toggle input[type="checkbox"] {
width: 18px;
height: 18px;
margin-top: 4px;
}
@media (max-width: 860px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
position: sticky;
top: 0;
z-index: 2;
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.nav {
grid-template-columns: repeat(5, 1fr);
}
.nav a {
text-align: center;
}
.sidebar-meta {
display: none;
}
.main {
padding: 16px;
}
.topbar {
display: grid;
}
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 520px) {
.nav {
grid-template-columns: 1fr;
}
.metric-grid {
grid-template-columns: 1fr;
}
.button,
.input,
.select {
width: 100%;
}
}
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ReverseLlama Admin</title>
<link rel="stylesheet" href="/admin/app.css">
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<div class="brand">
<span class="brand-mark">RL</span>
<span>
<strong>ReverseLlama</strong>
<small>Admin</small>
</span>
</div>
<nav class="nav" aria-label="Admin sections">
<a href="#clients" data-nav="clients">Clients</a>
<a href="#models" data-nav="models">Models</a>
<a href="#client-keys" data-nav="client-keys">Client keys</a>
<a href="#user-keys" data-nav="user-keys">User keys</a>
<a href="#groups" data-nav="groups">Groups</a>
<a href="#usage" data-nav="usage">Usage</a>
</nav>
<div class="sidebar-meta" id="sidebarMeta">Loading</div>
</aside>
<main class="main">
<header class="topbar">
<div>
<h1 id="pageTitle">Clients</h1>
<p id="pageSubtitle">Connected tunnel clients and forwarding controls.</p>
</div>
<div class="topbar-actions">
<button class="button secondary" id="refreshButton" type="button">Refresh</button>
<form method="post" action="/admin/logout">
<button class="button secondary" type="submit">Logout</button>
</form>
</div>
</header>
<section id="notice" class="notice" hidden></section>
<section id="content" class="content" aria-live="polite"></section>
</main>
</div>
<script src="/admin/morphdom-umd.js"></script>
<script src="/admin/app.js" defer></script>
</body>
</html>
@@ -0,0 +1,783 @@
/*!
* morphdom
* Copyright (c) Patrick Steele-Idem
* Licensed under the MIT License.
* See /docs/licenses/LICENSE.morphdom
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = global || self, global.morphdom = factory());
}(this, function () { 'use strict';
var DOCUMENT_FRAGMENT_NODE = 11;
function morphAttrs(fromNode, toNode) {
var toNodeAttrs = toNode.attributes;
var attr;
var attrName;
var attrNamespaceURI;
var attrValue;
var fromValue;
// document-fragments dont have attributes so lets not do anything
if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE || fromNode.nodeType === DOCUMENT_FRAGMENT_NODE) {
return;
}
// update attributes on original DOM element
for (var i = toNodeAttrs.length - 1; i >= 0; i--) {
attr = toNodeAttrs[i];
attrName = attr.name;
attrNamespaceURI = attr.namespaceURI;
attrValue = attr.value;
if (attrNamespaceURI) {
attrName = attr.localName || attrName;
fromValue = fromNode.getAttributeNS(attrNamespaceURI, attrName);
if (fromValue !== attrValue) {
if (attr.prefix === 'xmlns'){
attrName = attr.name; // It's not allowed to set an attribute with the XMLNS namespace without specifying the `xmlns` prefix
}
fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue);
}
} else {
fromValue = fromNode.getAttribute(attrName);
if (fromValue !== attrValue) {
fromNode.setAttribute(attrName, attrValue);
}
}
}
// Remove any extra attributes found on the original DOM element that
// weren't found on the target element.
var fromNodeAttrs = fromNode.attributes;
for (var d = fromNodeAttrs.length - 1; d >= 0; d--) {
attr = fromNodeAttrs[d];
attrName = attr.name;
attrNamespaceURI = attr.namespaceURI;
if (attrNamespaceURI) {
attrName = attr.localName || attrName;
if (!toNode.hasAttributeNS(attrNamespaceURI, attrName)) {
fromNode.removeAttributeNS(attrNamespaceURI, attrName);
}
} else {
if (!toNode.hasAttribute(attrName)) {
fromNode.removeAttribute(attrName);
}
}
}
}
var range; // Create a range object for efficently rendering strings to elements.
var NS_XHTML = 'http://www.w3.org/1999/xhtml';
var doc = typeof document === 'undefined' ? undefined : document;
var HAS_TEMPLATE_SUPPORT = !!doc && 'content' in doc.createElement('template');
var HAS_RANGE_SUPPORT = !!doc && doc.createRange && 'createContextualFragment' in doc.createRange();
function createFragmentFromTemplate(str) {
var template = doc.createElement('template');
template.innerHTML = str;
return template.content.childNodes[0];
}
function createFragmentFromRange(str) {
if (!range) {
range = doc.createRange();
range.selectNode(doc.body);
}
var fragment = range.createContextualFragment(str);
return fragment.childNodes[0];
}
function createFragmentFromWrap(str) {
var fragment = doc.createElement('body');
fragment.innerHTML = str;
return fragment.childNodes[0];
}
/**
* This is about the same
* var html = new DOMParser().parseFromString(str, 'text/html');
* return html.body.firstChild;
*
* @method toElement
* @param {String} str
*/
function toElement(str) {
str = str.trim();
if (HAS_TEMPLATE_SUPPORT) {
// avoid restrictions on content for things like `<tr><th>Hi</th></tr>` which
// createContextualFragment doesn't support
// <template> support not available in IE
return createFragmentFromTemplate(str);
} else if (HAS_RANGE_SUPPORT) {
return createFragmentFromRange(str);
}
return createFragmentFromWrap(str);
}
/**
* Returns true if two node's names are the same.
*
* NOTE: We don't bother checking `namespaceURI` because you will never find two HTML elements with the same
* nodeName and different namespace URIs.
*
* @param {Element} a
* @param {Element} b The target element
* @return {boolean}
*/
function compareNodeNames(fromEl, toEl) {
var fromNodeName = fromEl.nodeName;
var toNodeName = toEl.nodeName;
var fromCodeStart, toCodeStart;
if (fromNodeName === toNodeName) {
return true;
}
fromCodeStart = fromNodeName.charCodeAt(0);
toCodeStart = toNodeName.charCodeAt(0);
// If the target element is a virtual DOM node or SVG node then we may
// need to normalize the tag name before comparing. Normal HTML elements that are
// in the "http://www.w3.org/1999/xhtml"
// are converted to upper case
if (fromCodeStart <= 90 && toCodeStart >= 97) { // from is upper and to is lower
return fromNodeName === toNodeName.toUpperCase();
} else if (toCodeStart <= 90 && fromCodeStart >= 97) { // to is upper and from is lower
return toNodeName === fromNodeName.toUpperCase();
} else {
return false;
}
}
/**
* Create an element, optionally with a known namespace URI.
*
* @param {string} name the element name, e.g. 'div' or 'svg'
* @param {string} [namespaceURI] the element's namespace URI, i.e. the value of
* its `xmlns` attribute or its inferred namespace.
*
* @return {Element}
*/
function createElementNS(name, namespaceURI) {
return !namespaceURI || namespaceURI === NS_XHTML ?
doc.createElement(name) :
doc.createElementNS(namespaceURI, name);
}
/**
* Copies the children of one DOM element to another DOM element
*/
function moveChildren(fromEl, toEl) {
var curChild = fromEl.firstChild;
while (curChild) {
var nextChild = curChild.nextSibling;
toEl.appendChild(curChild);
curChild = nextChild;
}
return toEl;
}
function syncBooleanAttrProp(fromEl, toEl, name) {
if (fromEl[name] !== toEl[name]) {
fromEl[name] = toEl[name];
if (fromEl[name]) {
fromEl.setAttribute(name, '');
} else {
fromEl.removeAttribute(name);
}
}
}
var specialElHandlers = {
OPTION: function(fromEl, toEl) {
var parentNode = fromEl.parentNode;
if (parentNode) {
var parentName = parentNode.nodeName.toUpperCase();
if (parentName === 'OPTGROUP') {
parentNode = parentNode.parentNode;
parentName = parentNode && parentNode.nodeName.toUpperCase();
}
if (parentName === 'SELECT' && !parentNode.hasAttribute('multiple')) {
if (fromEl.hasAttribute('selected') && !toEl.selected) {
// Workaround for MS Edge bug where the 'selected' attribute can only be
// removed if set to a non-empty value:
// https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/12087679/
fromEl.setAttribute('selected', 'selected');
fromEl.removeAttribute('selected');
}
// We have to reset select element's selectedIndex to -1, otherwise setting
// fromEl.selected using the syncBooleanAttrProp below has no effect.
// The correct selectedIndex will be set in the SELECT special handler below.
parentNode.selectedIndex = -1;
}
}
syncBooleanAttrProp(fromEl, toEl, 'selected');
},
/**
* The "value" attribute is special for the <input> element since it sets
* the initial value. Changing the "value" attribute without changing the
* "value" property will have no effect since it is only used to the set the
* initial value. Similar for the "checked" attribute, and "disabled".
*/
INPUT: function(fromEl, toEl) {
syncBooleanAttrProp(fromEl, toEl, 'checked');
syncBooleanAttrProp(fromEl, toEl, 'disabled');
if (fromEl.value !== toEl.value) {
fromEl.value = toEl.value;
}
if (!toEl.hasAttribute('value')) {
fromEl.removeAttribute('value');
}
},
TEXTAREA: function(fromEl, toEl) {
var newValue = toEl.value;
if (fromEl.value !== newValue) {
fromEl.value = newValue;
}
var firstChild = fromEl.firstChild;
if (firstChild) {
// Needed for IE. Apparently IE sets the placeholder as the
// node value and vise versa. This ignores an empty update.
var oldValue = firstChild.nodeValue;
if (oldValue == newValue || (!newValue && oldValue == fromEl.placeholder)) {
return;
}
firstChild.nodeValue = newValue;
}
},
SELECT: function(fromEl, toEl) {
if (!toEl.hasAttribute('multiple')) {
var selectedIndex = -1;
var i = 0;
// We have to loop through children of fromEl, not toEl since nodes can be moved
// from toEl to fromEl directly when morphing.
// At the time this special handler is invoked, all children have already been morphed
// and appended to / removed from fromEl, so using fromEl here is safe and correct.
var curChild = fromEl.firstChild;
var optgroup;
var nodeName;
while(curChild) {
nodeName = curChild.nodeName && curChild.nodeName.toUpperCase();
if (nodeName === 'OPTGROUP') {
optgroup = curChild;
curChild = optgroup.firstChild;
} else {
if (nodeName === 'OPTION') {
if (curChild.hasAttribute('selected')) {
selectedIndex = i;
break;
}
i++;
}
curChild = curChild.nextSibling;
if (!curChild && optgroup) {
curChild = optgroup.nextSibling;
optgroup = null;
}
}
}
fromEl.selectedIndex = selectedIndex;
}
}
};
var ELEMENT_NODE = 1;
var DOCUMENT_FRAGMENT_NODE$1 = 11;
var TEXT_NODE = 3;
var COMMENT_NODE = 8;
function noop() {}
function defaultGetNodeKey(node) {
if (node) {
return (node.getAttribute && node.getAttribute('id')) || node.id;
}
}
function morphdomFactory(morphAttrs) {
return function morphdom(fromNode, toNode, options) {
if (!options) {
options = {};
}
if (typeof toNode === 'string') {
if (fromNode.nodeName === '#document' || fromNode.nodeName === 'HTML' || fromNode.nodeName === 'BODY') {
var toNodeHtml = toNode;
toNode = doc.createElement('html');
toNode.innerHTML = toNodeHtml;
} else {
toNode = toElement(toNode);
}
} else if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE$1) {
toNode = toNode.firstElementChild;
}
var getNodeKey = options.getNodeKey || defaultGetNodeKey;
var onBeforeNodeAdded = options.onBeforeNodeAdded || noop;
var onNodeAdded = options.onNodeAdded || noop;
var onBeforeElUpdated = options.onBeforeElUpdated || noop;
var onElUpdated = options.onElUpdated || noop;
var onBeforeNodeDiscarded = options.onBeforeNodeDiscarded || noop;
var onNodeDiscarded = options.onNodeDiscarded || noop;
var onBeforeElChildrenUpdated = options.onBeforeElChildrenUpdated || noop;
var skipFromChildren = options.skipFromChildren || noop;
var addChild = options.addChild || function(parent, child){ return parent.appendChild(child); };
var childrenOnly = options.childrenOnly === true;
// This object is used as a lookup to quickly find all keyed elements in the original DOM tree.
var fromNodesLookup = Object.create(null);
var keyedRemovalList = [];
function addKeyedRemoval(key) {
keyedRemovalList.push(key);
}
function walkDiscardedChildNodes(node, skipKeyedNodes) {
if (node.nodeType === ELEMENT_NODE) {
var curChild = node.firstChild;
while (curChild) {
var key = undefined;
if (skipKeyedNodes && (key = getNodeKey(curChild))) {
// If we are skipping keyed nodes then we add the key
// to a list so that it can be handled at the very end.
addKeyedRemoval(key);
} else {
// Only report the node as discarded if it is not keyed. We do this because
// at the end we loop through all keyed elements that were unmatched
// and then discard them in one final pass.
onNodeDiscarded(curChild);
if (curChild.firstChild) {
walkDiscardedChildNodes(curChild, skipKeyedNodes);
}
}
curChild = curChild.nextSibling;
}
}
}
/**
* Removes a DOM node out of the original DOM
*
* @param {Node} node The node to remove
* @param {Node} parentNode The nodes parent
* @param {Boolean} skipKeyedNodes If true then elements with keys will be skipped and not discarded.
* @return {undefined}
*/
function removeNode(node, parentNode, skipKeyedNodes) {
if (onBeforeNodeDiscarded(node) === false) {
return;
}
if (parentNode) {
parentNode.removeChild(node);
}
onNodeDiscarded(node);
walkDiscardedChildNodes(node, skipKeyedNodes);
}
// // TreeWalker implementation is no faster, but keeping this around in case this changes in the future
// function indexTree(root) {
// var treeWalker = document.createTreeWalker(
// root,
// NodeFilter.SHOW_ELEMENT);
//
// var el;
// while((el = treeWalker.nextNode())) {
// var key = getNodeKey(el);
// if (key) {
// fromNodesLookup[key] = el;
// }
// }
// }
// // NodeIterator implementation is no faster, but keeping this around in case this changes in the future
//
// function indexTree(node) {
// var nodeIterator = document.createNodeIterator(node, NodeFilter.SHOW_ELEMENT);
// var el;
// while((el = nodeIterator.nextNode())) {
// var key = getNodeKey(el);
// if (key) {
// fromNodesLookup[key] = el;
// }
// }
// }
function indexTree(node) {
if (node.nodeType === ELEMENT_NODE || node.nodeType === DOCUMENT_FRAGMENT_NODE$1) {
var curChild = node.firstChild;
while (curChild) {
var key = getNodeKey(curChild);
if (key) {
fromNodesLookup[key] = curChild;
}
// Walk recursively
indexTree(curChild);
curChild = curChild.nextSibling;
}
}
}
indexTree(fromNode);
function handleNodeAdded(el) {
onNodeAdded(el);
var curChild = el.firstChild;
while (curChild) {
var nextSibling = curChild.nextSibling;
var key = getNodeKey(curChild);
if (key) {
var unmatchedFromEl = fromNodesLookup[key];
// if we find a duplicate #id node in cache, replace `el` with cache value
// and morph it to the child node.
if (unmatchedFromEl && compareNodeNames(curChild, unmatchedFromEl)) {
curChild.parentNode.replaceChild(unmatchedFromEl, curChild);
morphEl(unmatchedFromEl, curChild);
} else {
handleNodeAdded(curChild);
}
} else {
// recursively call for curChild and it's children to see if we find something in
// fromNodesLookup
handleNodeAdded(curChild);
}
curChild = nextSibling;
}
}
function cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey) {
// We have processed all of the "to nodes". If curFromNodeChild is
// non-null then we still have some from nodes left over that need
// to be removed
while (curFromNodeChild) {
var fromNextSibling = curFromNodeChild.nextSibling;
if ((curFromNodeKey = getNodeKey(curFromNodeChild))) {
// Since the node is keyed it might be matched up later so we defer
// the actual removal to later
addKeyedRemoval(curFromNodeKey);
} else {
// NOTE: we skip nested keyed nodes from being removed since there is
// still a chance they will be matched up later
removeNode(curFromNodeChild, fromEl, true /* skip keyed nodes */);
}
curFromNodeChild = fromNextSibling;
}
}
function morphEl(fromEl, toEl, childrenOnly) {
var toElKey = getNodeKey(toEl);
if (toElKey) {
// If an element with an ID is being morphed then it will be in the final
// DOM so clear it out of the saved elements collection
delete fromNodesLookup[toElKey];
}
if (!childrenOnly) {
// optional
var beforeUpdateResult = onBeforeElUpdated(fromEl, toEl);
if (beforeUpdateResult === false) {
return;
} else if (beforeUpdateResult instanceof HTMLElement) {
fromEl = beforeUpdateResult;
// reindex the new fromEl in case it's not in the same
// tree as the original fromEl
// (Phoenix LiveView sometimes returns a cloned tree,
// but keyed lookups would still point to the original tree)
indexTree(fromEl);
}
// update attributes on original DOM element first
morphAttrs(fromEl, toEl);
// optional
onElUpdated(fromEl);
if (onBeforeElChildrenUpdated(fromEl, toEl) === false) {
return;
}
}
if (fromEl.nodeName !== 'TEXTAREA') {
morphChildren(fromEl, toEl);
} else {
specialElHandlers.TEXTAREA(fromEl, toEl);
}
}
function morphChildren(fromEl, toEl) {
var skipFrom = skipFromChildren(fromEl, toEl);
var curToNodeChild = toEl.firstChild;
var curFromNodeChild = fromEl.firstChild;
var curToNodeKey;
var curFromNodeKey;
var fromNextSibling;
var toNextSibling;
var matchingFromEl;
// walk the children
outer: while (curToNodeChild) {
toNextSibling = curToNodeChild.nextSibling;
curToNodeKey = getNodeKey(curToNodeChild);
// walk the fromNode children all the way through
while (!skipFrom && curFromNodeChild) {
fromNextSibling = curFromNodeChild.nextSibling;
if (curToNodeChild.isSameNode && curToNodeChild.isSameNode(curFromNodeChild)) {
curToNodeChild = toNextSibling;
curFromNodeChild = fromNextSibling;
continue outer;
}
curFromNodeKey = getNodeKey(curFromNodeChild);
var curFromNodeType = curFromNodeChild.nodeType;
// this means if the curFromNodeChild doesnt have a match with the curToNodeChild
var isCompatible = undefined;
if (curFromNodeType === curToNodeChild.nodeType) {
if (curFromNodeType === ELEMENT_NODE) {
// Both nodes being compared are Element nodes
if (curToNodeKey) {
// The target node has a key so we want to match it up with the correct element
// in the original DOM tree
if (curToNodeKey !== curFromNodeKey) {
// The current element in the original DOM tree does not have a matching key so
// let's check our lookup to see if there is a matching element in the original
// DOM tree
if ((matchingFromEl = fromNodesLookup[curToNodeKey])) {
if (fromNextSibling === matchingFromEl) {
// Special case for single element removals. To avoid removing the original
// DOM node out of the tree (since that can break CSS transitions, etc.),
// we will instead discard the current node and wait until the next
// iteration to properly match up the keyed target element with its matching
// element in the original tree
isCompatible = false;
} else {
// We found a matching keyed element somewhere in the original DOM tree.
// Let's move the original DOM node into the current position and morph
// it.
// NOTE: We use insertBefore instead of replaceChild because we want to go through
// the `removeNode()` function for the node that is being discarded so that
// all lifecycle hooks are correctly invoked
fromEl.insertBefore(matchingFromEl, curFromNodeChild);
// fromNextSibling = curFromNodeChild.nextSibling;
if (curFromNodeKey) {
// Since the node is keyed it might be matched up later so we defer
// the actual removal to later
addKeyedRemoval(curFromNodeKey);
} else {
// NOTE: we skip nested keyed nodes from being removed since there is
// still a chance they will be matched up later
removeNode(curFromNodeChild, fromEl, true /* skip keyed nodes */);
}
curFromNodeChild = matchingFromEl;
curFromNodeKey = getNodeKey(curFromNodeChild);
}
} else {
// The nodes are not compatible since the "to" node has a key and there
// is no matching keyed node in the source tree
isCompatible = false;
}
}
} else if (curFromNodeKey) {
// The original has a key
isCompatible = false;
}
isCompatible = isCompatible !== false && compareNodeNames(curFromNodeChild, curToNodeChild);
if (isCompatible) {
// We found compatible DOM elements so transform
// the current "from" node to match the current
// target DOM node.
// MORPH
morphEl(curFromNodeChild, curToNodeChild);
}
} else if (curFromNodeType === TEXT_NODE || curFromNodeType == COMMENT_NODE) {
// Both nodes being compared are Text or Comment nodes
isCompatible = true;
// Simply update nodeValue on the original node to
// change the text value
if (curFromNodeChild.nodeValue !== curToNodeChild.nodeValue) {
curFromNodeChild.nodeValue = curToNodeChild.nodeValue;
}
}
}
if (isCompatible) {
// Advance both the "to" child and the "from" child since we found a match
// Nothing else to do as we already recursively called morphChildren above
curToNodeChild = toNextSibling;
curFromNodeChild = fromNextSibling;
continue outer;
}
// No compatible match so remove the old node from the DOM and continue trying to find a
// match in the original DOM. However, we only do this if the from node is not keyed
// since it is possible that a keyed node might match up with a node somewhere else in the
// target tree and we don't want to discard it just yet since it still might find a
// home in the final DOM tree. After everything is done we will remove any keyed nodes
// that didn't find a home
if (curFromNodeKey) {
// Since the node is keyed it might be matched up later so we defer
// the actual removal to later
addKeyedRemoval(curFromNodeKey);
} else {
// NOTE: we skip nested keyed nodes from being removed since there is
// still a chance they will be matched up later
removeNode(curFromNodeChild, fromEl, true /* skip keyed nodes */);
}
curFromNodeChild = fromNextSibling;
} // END: while(curFromNodeChild) {}
// If we got this far then we did not find a candidate match for
// our "to node" and we exhausted all of the children "from"
// nodes. Therefore, we will just append the current "to" node
// to the end
if (curToNodeKey && (matchingFromEl = fromNodesLookup[curToNodeKey]) && compareNodeNames(matchingFromEl, curToNodeChild)) {
// MORPH
if(!skipFrom){ addChild(fromEl, matchingFromEl); }
morphEl(matchingFromEl, curToNodeChild);
} else {
var onBeforeNodeAddedResult = onBeforeNodeAdded(curToNodeChild);
if (onBeforeNodeAddedResult !== false) {
if (onBeforeNodeAddedResult) {
curToNodeChild = onBeforeNodeAddedResult;
}
if (curToNodeChild.actualize) {
curToNodeChild = curToNodeChild.actualize(fromEl.ownerDocument || doc);
}
addChild(fromEl, curToNodeChild);
handleNodeAdded(curToNodeChild);
}
}
curToNodeChild = toNextSibling;
curFromNodeChild = fromNextSibling;
}
cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey);
var specialElHandler = specialElHandlers[fromEl.nodeName];
if (specialElHandler) {
specialElHandler(fromEl, toEl);
}
} // END: morphChildren(...)
var morphedNode = fromNode;
var morphedNodeType = morphedNode.nodeType;
var toNodeType = toNode.nodeType;
if (!childrenOnly) {
// Handle the case where we are given two DOM nodes that are not
// compatible (e.g. <div> --> <span> or <div> --> TEXT)
if (morphedNodeType === ELEMENT_NODE) {
if (toNodeType === ELEMENT_NODE) {
if (!compareNodeNames(fromNode, toNode)) {
onNodeDiscarded(fromNode);
morphedNode = moveChildren(fromNode, createElementNS(toNode.nodeName, toNode.namespaceURI));
}
} else {
// Going from an element node to a text node
morphedNode = toNode;
}
} else if (morphedNodeType === TEXT_NODE || morphedNodeType === COMMENT_NODE) { // Text or comment node
if (toNodeType === morphedNodeType) {
if (morphedNode.nodeValue !== toNode.nodeValue) {
morphedNode.nodeValue = toNode.nodeValue;
}
return morphedNode;
} else {
// Text node to something else
morphedNode = toNode;
}
}
}
if (morphedNode === toNode) {
// The "to node" was not compatible with the "from node" so we had to
// toss out the "from node" and use the "to node"
onNodeDiscarded(fromNode);
} else {
if (toNode.isSameNode && toNode.isSameNode(morphedNode)) {
return;
}
morphEl(morphedNode, toNode, childrenOnly);
// We now need to loop over any keyed nodes that might need to be
// removed. We only do the removal if we know that the keyed node
// never found a match. When a keyed node is matched up we remove
// it out of fromNodesLookup and we use fromNodesLookup to determine
// if a keyed node has been matched up or not
if (keyedRemovalList) {
for (var i=0, len=keyedRemovalList.length; i<len; i++) {
var elToRemove = fromNodesLookup[keyedRemovalList[i]];
if (elToRemove) {
removeNode(elToRemove, elToRemove.parentNode, false);
}
}
}
}
if (!childrenOnly && morphedNode !== fromNode && fromNode.parentNode) {
if (morphedNode.actualize) {
morphedNode = morphedNode.actualize(fromNode.ownerDocument || doc);
}
// If we had to swap out the from node with a new node because the old
// node was not compatible with the target node then we need to
// replace the old DOM node in the original DOM tree. This is only
// possible if the original DOM node was part of a DOM tree which
// we know is the case if it has a parent node.
fromNode.parentNode.replaceChild(morphedNode, fromNode);
}
return morphedNode;
};
}
var morphdom = morphdomFactory(morphAttrs);
return morphdom;
}));
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB