src: renames projects
This commit is contained in:
@@ -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);
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "*"
|
||||
}
|
||||
@@ -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
@@ -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 |
Reference in New Issue
Block a user