From bc293bf7ec78b9456c281791af2be14673c7024a Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Tue, 30 Dec 2025 22:18:26 +0100 Subject: [PATCH] Added proper server config model, added proper apikey authorization with swagger integration, added allowlist and denylist to config --- src/Client/Client.cs | 96 +++++++-------------- src/Server/Controllers/AccountController.cs | 4 +- src/Server/Models/Auth.cs | 13 --- src/Server/Models/ConfigModels.cs | 36 ++++++++ src/Server/Program.cs | 88 ++++++++++--------- src/Server/appsettings.Development.json | 15 ++-- src/Server/appsettings.json | 9 -- 7 files changed, 126 insertions(+), 135 deletions(-) delete mode 100644 src/Server/Models/Auth.cs create mode 100644 src/Server/Models/ConfigModels.cs diff --git a/src/Client/Client.cs b/src/Client/Client.cs index 85718a0..be7b92e 100644 --- a/src/Client/Client.cs +++ b/src/Client/Client.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Configuration; using System.Reflection.Metadata.Ecma335; using Shared.Models; +using System.Net; namespace Client; @@ -41,8 +42,8 @@ public class Client public async Task EntityListAsync(string searchdomain, bool returnEmbeddings = false) { - var url = $"{baseUri}/Entities?apiKey={HttpUtility.UrlEncode(apiKey)}&searchdomain={HttpUtility.UrlEncode(searchdomain)}&returnEmbeddings={HttpUtility.UrlEncode(returnEmbeddings.ToString())}"; - return await GetUrlAndProcessJson(url); + var url = $"{baseUri}/Entities?searchdomain={HttpUtility.UrlEncode(searchdomain)}&returnEmbeddings={HttpUtility.UrlEncode(returnEmbeddings.ToString())}"; + return await FetchUrlAndProcessJson(HttpMethod.Get, url); } public async Task EntityIndexAsync(List jsonEntity) @@ -53,7 +54,7 @@ public class Client public async Task EntityIndexAsync(string jsonEntity) { var content = new StringContent(jsonEntity, Encoding.UTF8, "application/json"); - return await PutUrlAndProcessJson(GetUrl($"{baseUri}", "Entities", apiKey, []), content); + return await FetchUrlAndProcessJson(HttpMethod.Put, GetUrl($"{baseUri}", "Entities", []), content); } public async Task EntityDeleteAsync(string entityName) @@ -64,12 +65,12 @@ public class Client public async Task EntityDeleteAsync(string searchdomain, string entityName) { var url = $"{baseUri}/Entity?apiKey={HttpUtility.UrlEncode(apiKey)}&searchdomain={HttpUtility.UrlEncode(searchdomain)}&entity={HttpUtility.UrlEncode(entityName)}"; - return await DeleteUrlAndProcessJson(url); + return await FetchUrlAndProcessJson(HttpMethod.Delete, url); } public async Task SearchdomainListAsync() { - return await GetUrlAndProcessJson(GetUrl($"{baseUri}", "Searchdomains", apiKey, [])); + return await FetchUrlAndProcessJson(HttpMethod.Get, GetUrl($"{baseUri}", "Searchdomains", [])); } public async Task SearchdomainCreateAsync() @@ -79,7 +80,7 @@ public class Client public async Task SearchdomainCreateAsync(string searchdomain, SearchdomainSettings searchdomainSettings = new()) { - return await PostUrlAndProcessJson(GetUrl($"{baseUri}", "Searchdomain", apiKey, new Dictionary() + return await FetchUrlAndProcessJson(HttpMethod.Post, GetUrl($"{baseUri}", "Searchdomain", new Dictionary() { {"searchdomain", searchdomain} }), new StringContent(JsonSerializer.Serialize(searchdomainSettings), Encoding.UTF8, "application/json")); @@ -92,7 +93,7 @@ public class Client public async Task SearchdomainDeleteAsync(string searchdomain) { - return await DeleteUrlAndProcessJson(GetUrl($"{baseUri}", "Searchdomain", apiKey, new Dictionary() + return await FetchUrlAndProcessJson(HttpMethod.Delete, GetUrl($"{baseUri}", "Searchdomain", new Dictionary() { {"searchdomain", searchdomain} })); @@ -112,7 +113,7 @@ public class Client public async Task SearchdomainUpdateAsync(string searchdomain, string newName, string settings = "{}") { - return await PutUrlAndProcessJson(GetUrl($"{baseUri}", "Searchdomain", apiKey, new Dictionary() + return await FetchUrlAndProcessJson(HttpMethod.Put, GetUrl($"{baseUri}", "Searchdomain", new Dictionary() { {"searchdomain", searchdomain}, {"newName", newName} @@ -125,7 +126,7 @@ public class Client { {"searchdomain", searchdomain} }; - return await GetUrlAndProcessJson(GetUrl($"{baseUri}/Searchdomain", "Queries", apiKey, parameters)); + return await FetchUrlAndProcessJson(HttpMethod.Get, GetUrl($"{baseUri}/Searchdomain", "Queries", parameters)); } public async Task SearchdomainQueryAsync(string query) @@ -143,7 +144,7 @@ public class Client if (topN is not null) parameters.Add("topN", ((int)topN).ToString()); if (returnAttributes) parameters.Add("returnAttributes", returnAttributes.ToString()); - return await PostUrlAndProcessJson(GetUrl($"{baseUri}/Searchdomain", "Query", apiKey, parameters), null); + return await FetchUrlAndProcessJson(HttpMethod.Post, GetUrl($"{baseUri}/Searchdomain", "Query", parameters), null); } public async Task SearchdomainDeleteQueryAsync(string searchdomain, string query) @@ -153,7 +154,7 @@ public class Client {"searchdomain", searchdomain}, {"query", query} }; - return await DeleteUrlAndProcessJson(GetUrl($"{baseUri}/Searchdomain", "Query", apiKey, parameters)); + return await FetchUrlAndProcessJson(HttpMethod.Delete, GetUrl($"{baseUri}/Searchdomain", "Query", parameters)); } public async Task SearchdomainUpdateQueryAsync(string searchdomain, string query, List results) @@ -163,8 +164,9 @@ public class Client {"searchdomain", searchdomain}, {"query", query} }; - return await PatchUrlAndProcessJson( - GetUrl($"{baseUri}/Searchdomain", "Query", apiKey, parameters), + return await FetchUrlAndProcessJson( + HttpMethod.Patch, + GetUrl($"{baseUri}/Searchdomain", "Query", parameters), new StringContent(JsonSerializer.Serialize(results), Encoding.UTF8, "application/json")); } @@ -174,7 +176,7 @@ public class Client { {"searchdomain", searchdomain} }; - return await GetUrlAndProcessJson(GetUrl($"{baseUri}/Searchdomain", "Settings", apiKey, parameters)); + return await FetchUrlAndProcessJson(HttpMethod.Get, GetUrl($"{baseUri}/Searchdomain", "Settings", parameters)); } public async Task SearchdomainUpdateSettingsAsync(string searchdomain, SearchdomainSettings searchdomainSettings) @@ -184,7 +186,7 @@ public class Client {"searchdomain", searchdomain} }; StringContent content = new(JsonSerializer.Serialize(searchdomainSettings), Encoding.UTF8, "application/json"); - return await PutUrlAndProcessJson(GetUrl($"{baseUri}/Searchdomain", "Settings", apiKey, parameters), content); + return await FetchUrlAndProcessJson(HttpMethod.Put, GetUrl($"{baseUri}/Searchdomain", "Settings", parameters), content); } public async Task SearchdomainGetQueryCacheSizeAsync(string searchdomain) @@ -193,7 +195,7 @@ public class Client { {"searchdomain", searchdomain} }; - return await GetUrlAndProcessJson(GetUrl($"{baseUri}/Searchdomain/QueryCache", "Size", apiKey, parameters)); + return await FetchUrlAndProcessJson(HttpMethod.Get, GetUrl($"{baseUri}/Searchdomain/QueryCache", "Size", parameters)); } public async Task SearchdomainClearQueryCache(string searchdomain) @@ -202,7 +204,7 @@ public class Client { {"searchdomain", searchdomain} }; - return await PostUrlAndProcessJson(GetUrl($"{baseUri}/Searchdomain/QueryCache", "Clear", apiKey, parameters), null); + return await FetchUrlAndProcessJson(HttpMethod.Post, GetUrl($"{baseUri}/Searchdomain/QueryCache", "Clear", parameters), null); } public async Task SearchdomainGetDatabaseSizeAsync(string searchdomain) @@ -211,74 +213,40 @@ public class Client { {"searchdomain", searchdomain} }; - return await GetUrlAndProcessJson(GetUrl($"{baseUri}/Searchdomain/Database", "Size", apiKey, parameters)); + return await FetchUrlAndProcessJson(HttpMethod.Get, GetUrl($"{baseUri}/Searchdomain/Database", "Size", parameters)); } public async Task ServerGetModelsAsync() { - return await GetUrlAndProcessJson(GetUrl($"{baseUri}/Server", "Models", apiKey, [])); + return await FetchUrlAndProcessJson(HttpMethod.Get, GetUrl($"{baseUri}/Server", "Models", [])); } public async Task ServerGetEmbeddingCacheSizeAsync() { - return await GetUrlAndProcessJson(GetUrl($"{baseUri}/Server/EmbeddingCache", "Size", apiKey, [])); + return await FetchUrlAndProcessJson(HttpMethod.Get, GetUrl($"{baseUri}/Server/EmbeddingCache", "Size", [])); } - private static async Task GetUrlAndProcessJson(string url) + private async Task FetchUrlAndProcessJson(HttpMethod httpMethod, string url, HttpContent? content = null) { + HttpRequestMessage requestMessage = new(httpMethod, url) + { + Content = content, + }; + requestMessage.Headers.Add("X-API-KEY", apiKey); using var client = new HttpClient(); - var response = await client.GetAsync(url); + var response = await client.SendAsync(requestMessage); string responseContent = await response.Content.ReadAsStringAsync(); + if (response.StatusCode == HttpStatusCode.Forbidden || response.StatusCode == HttpStatusCode.Unauthorized) throw new UnauthorizedAccessException(responseContent); // TODO implement distinct exceptions + if (response.StatusCode == HttpStatusCode.InternalServerError) throw new Exception($"Request was unsuccessful due to an internal server error: {responseContent}"); // TODO implement proper InternalServerErrorException var result = JsonSerializer.Deserialize(responseContent) ?? throw new Exception($"Failed to deserialize JSON to type {typeof(T).Name}"); return result; } - private static async Task PostUrlAndProcessJson(string url, HttpContent? content) - { - using var client = new HttpClient(); - var response = await client.PostAsync(url, content); - string responseContent = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(responseContent) - ?? throw new Exception($"Failed to deserialize JSON to type {typeof(T).Name}"); - return result; - } - - private static async Task PutUrlAndProcessJson(string url, HttpContent content) - { - using var client = new HttpClient(); - var response = await client.PutAsync(url, content); - string responseContent = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(responseContent) - ?? throw new Exception($"Failed to deserialize JSON to type {typeof(T).Name}"); - return result; - } - - private static async Task PatchUrlAndProcessJson(string url, HttpContent content) - { - using var client = new HttpClient(); - var response = await client.PatchAsync(url, content); - string responseContent = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(responseContent) - ?? throw new Exception($"Failed to deserialize JSON to type {typeof(T).Name}"); - return result; - } - - private static async Task DeleteUrlAndProcessJson(string url) - { - using var client = new HttpClient(); - var response = await client.DeleteAsync(url); - string responseContent = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(responseContent) - ?? throw new Exception($"Failed to deserialize JSON to type {typeof(T).Name}"); - return result; - } - - public static string GetUrl(string baseUri, string endpoint, string apiKey, Dictionary parameters) + public static string GetUrl(string baseUri, string endpoint, Dictionary parameters) { var uriBuilder = new UriBuilder($"{baseUri}/{endpoint}"); var query = HttpUtility.ParseQueryString(uriBuilder.Query); - if (apiKey.Length > 0) query["apiKey"] = apiKey; foreach (var param in parameters) { query[param.Key] = param.Value; diff --git a/src/Server/Controllers/AccountController.cs b/src/Server/Controllers/AccountController.cs index af218ec..708cfe8 100644 --- a/src/Server/Controllers/AccountController.cs +++ b/src/Server/Controllers/AccountController.cs @@ -12,9 +12,9 @@ public class AccountController : Controller { private readonly SimpleAuthOptions _options; - public AccountController(IOptions options) + public AccountController(EmbeddingSearchOptions options) { - _options = options.Value; + _options = options.SimpleAuth; } [HttpGet("Login")] diff --git a/src/Server/Models/Auth.cs b/src/Server/Models/Auth.cs deleted file mode 100644 index 1bd0fa4..0000000 --- a/src/Server/Models/Auth.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Server.Models; - -public class SimpleAuthOptions -{ - public List Users { get; set; } = new(); -} - -public class SimpleUser -{ - public string Username { get; set; } = ""; - public string Password { get; set; } = ""; - public string[] Roles { get; set; } = Array.Empty(); -} diff --git a/src/Server/Models/ConfigModels.cs b/src/Server/Models/ConfigModels.cs new file mode 100644 index 0000000..4ea56ff --- /dev/null +++ b/src/Server/Models/ConfigModels.cs @@ -0,0 +1,36 @@ +using System.Configuration; +using ElmahCore; + +namespace Server.Models; + +public class EmbeddingSearchOptions +{ + public required ConnectionStringsSection ConnectionStrings { get; set; } + public ElmahOptions? Elmah { get; set; } + public required long EmbeddingCacheMaxCount { get; set; } + public required AiProvider[] AiProviders { get; set; } + public required SimpleAuthOptions SimpleAuth { get; set; } + public string[]? ApiKeys { get; set; } + public required bool UseHttpsRedirection { get; set; } +} + +public class AiProvider +{ + public required string Handler { get; set; } + public required string BaseURL { get; set; } + public required string ApiKey { get; set; } + public required string[] Allowlist { get; set; } + public required string[] Denylist { get; set; } +} + +public class SimpleAuthOptions +{ + public List Users { get; set; } = []; +} + +public class SimpleUser +{ + public string Username { get; set; } = ""; + public string Password { get; set; } = ""; + public string[] Roles { get; set; } = []; +} diff --git a/src/Server/Program.cs b/src/Server/Program.cs index 2fa7ccc..770b7f6 100644 --- a/src/Server/Program.cs +++ b/src/Server/Program.cs @@ -10,11 +10,12 @@ using Server.Models; using Server.Services; using System.Text.Json.Serialization; using System.Reflection; +using System.Configuration; +using Microsoft.OpenApi.Models; var builder = WebApplication.CreateBuilder(args); -// Add services to the container. - +// Add Controllers with views & string conversion for enums builder.Services.AddControllersWithViews() .AddJsonOptions(options => { @@ -23,6 +24,12 @@ builder.Services.AddControllersWithViews() ); }); +// Add Configuration +IConfigurationSection configurationSection = builder.Configuration.GetSection("Embeddingsearch"); +EmbeddingSearchOptions configuration = configurationSection.Get() ?? throw new ConfigurationErrorsException("Unable to start server due to an invalid configration"); + +builder.Services.Configure(configurationSection); + // Add Localization builder.Services.AddLocalization(options => options.ResourcesPath = "Resources"); builder.Services.Configure(options => @@ -43,6 +50,31 @@ builder.Services.AddSwaggerGen(c => var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); c.IncludeXmlComments(xmlPath); + if (configuration.ApiKeys is not null) + { + c.AddSecurityDefinition("ApiKey", new OpenApiSecurityScheme + { + Description = "ApiKey must appear in header", + Type = SecuritySchemeType.ApiKey, + Name = "X-API-KEY", + In = ParameterLocation.Header, + Scheme = "ApiKeyScheme" + }); + var key = new OpenApiSecurityScheme() + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "ApiKey" + }, + In = ParameterLocation.Header + }; + var requirement = new OpenApiSecurityRequirement + { + { key, []} + }; + c.AddSecurityRequirement(requirement); + } }); Log.Logger = new LoggerConfiguration() .ReadFrom.Configuration(builder.Configuration) @@ -58,7 +90,12 @@ builder.Services.AddHealthChecks() builder.Services.AddElmah(Options => { - Options.LogPath = builder.Configuration.GetValue("Embeddingsearch:Elmah:LogFolder") ?? "~/logs"; + Options.OnPermissionCheck = context => + context.User.Claims.Any(claim => + claim.Value.Equals("Admin", StringComparison.OrdinalIgnoreCase) + || claim.Value.Equals("Elmah", StringComparison.OrdinalIgnoreCase) + ); + Options.LogPath = configuration.Elmah?.LogPath ?? "~/logs"; }); builder.Services @@ -76,35 +113,11 @@ builder.Services.AddAuthorization(options => policy => policy.RequireRole("Admin")); }); -IConfigurationSection simpleAuthSection = builder.Configuration.GetSection("Embeddingsearch:SimpleAuth"); -if (simpleAuthSection.Exists()) builder.Services.Configure(simpleAuthSection); var app = builder.Build(); -List? allowedIps = builder.Configuration.GetSection("Embeddingsearch:Elmah:AllowedHosts") - .Get>(); - -app.Use(async (context, next) => -{ - bool requestIsElmah = context.Request.Path.StartsWithSegments("/elmah"); - bool requestIsSwagger = context.Request.Path.StartsWithSegments("/swagger"); - - if (requestIsElmah || requestIsSwagger) - { - var remoteIp = context.Connection.RemoteIpAddress?.ToString(); - bool blockRequest = allowedIps is null - || remoteIp is null - || !allowedIps.Contains(remoteIp); - if (blockRequest) - { - context.Response.StatusCode = 403; - await context.Response.WriteAsync("Forbidden"); - return; - } - } - - await next(); -}); +app.UseAuthentication(); +app.UseAuthorization(); app.UseElmah(); @@ -123,14 +136,14 @@ bool IsDevelopment = app.Environment.IsDevelopment(); bool useSwagger = app.Configuration.GetValue("UseSwagger"); bool? UseMiddleware = app.Configuration.GetValue("UseMiddleware"); -// Configure the HTTP request pipeline. -if (IsDevelopment || useSwagger) +app.UseSwagger(); +app.UseSwaggerUI(options => { - app.UseSwagger(); - app.UseSwaggerUI(); - //app.UseElmahExceptionPage(); // Messes with JSON response for API calls. Leaving this here so I don't accidentally put this in again later on. -} -if (UseMiddleware == true && !IsDevelopment) + options.EnablePersistAuthorization(); +}); +//app.UseElmahExceptionPage(); // Messes with JSON response for API calls. Leaving this here so I don't accidentally put this in again later on. + +if (configuration.ApiKeys is not null) { app.UseMiddleware(); } @@ -143,9 +156,6 @@ var localizationOptions = new RequestLocalizationOptions() .AddSupportedUICultures(supportedCultures); app.UseRequestLocalization(localizationOptions); -app.UseAuthentication(); -app.UseAuthorization(); - app.MapControllers(); app.UseStaticFiles(); diff --git a/src/Server/appsettings.Development.json b/src/Server/appsettings.Development.json index 7c373a1..6fa3313 100644 --- a/src/Server/appsettings.Development.json +++ b/src/Server/appsettings.Development.json @@ -18,22 +18,21 @@ "SQL": "server=localhost;database=embeddingsearch;uid=embeddingsearch;pwd=somepassword!;" }, "Elmah": { - "AllowedHosts": [ - "127.0.0.1", - "::1", - "172.17.0.1" - ] + "LogPath": "~/logs" }, "EmbeddingCacheMaxCount": 10000000, "AiProviders": { "ollama": { "handler": "ollama", - "baseURL": "http://localhost:11434" + "baseURL": "http://192.168.0.101:11434", + "Allowlist": ["*"], + "Denylist": ["qwen3-coder:latest", "qwen3:0.6b", "deepseek-v3.1:671b-cloud", "qwen3-vl", "deepseek-ocr"] }, "localAI": { "handler": "openai", - "baseURL": "http://localhost:8080", - "ApiKey": "Some API key here" + "ApiKey": "Some API key here", + "Allowlist": ["*"], + "Denylist": ["cross-encoder", "kitten-tts", "jina-reranker-v1-tiny-en", "whisper-small", "qwen3-vl-2b-instruct"] } }, "SimpleAuth": { diff --git a/src/Server/appsettings.json b/src/Server/appsettings.json index 657168d..e6db786 100644 --- a/src/Server/appsettings.json +++ b/src/Server/appsettings.json @@ -16,14 +16,5 @@ "Application": "Embeddingsearch.Server" } }, - "EmbeddingsearchIndexer": { - "Elmah": { - "AllowedHosts": [ - "127.0.0.1", - "::1" - ], - "LogFolder": "./logs" - } - }, "AllowedHosts": "*" }