diff options
Diffstat (limited to 'apps/VNLib.WebServer/src')
46 files changed, 5483 insertions, 0 deletions
diff --git a/apps/VNLib.WebServer/src/Bootstrap/ReleaseWebserver.cs b/apps/VNLib.WebServer/src/Bootstrap/ReleaseWebserver.cs new file mode 100644 index 0000000..e7834bb --- /dev/null +++ b/apps/VNLib.WebServer/src/Bootstrap/ReleaseWebserver.cs @@ -0,0 +1,333 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: ReleaseWebserver.cs +* +* ReleaseWebserver.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Data; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Collections.Generic; + +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Net.Http; +using VNLib.Plugins.Runtime; + +using VNLib.WebServer.Config; +using VNLib.WebServer.Config.Model; +using VNLib.WebServer.Plugins; +using VNLib.WebServer.Compression; +using VNLib.WebServer.Middlewares; +using VNLib.WebServer.RuntimeLoading; +using static VNLib.WebServer.Entry; + +namespace VNLib.WebServer.Bootstrap +{ + + /* + * This class represents a normally loaded "Relase" webserver to allow + * for module webserver use-cases. It relies on a system configuration + * file and command line arguments to configure the server. + */ + + internal class ReleaseWebserver(ServerLogger logger, IServerConfig config, ProcessArguments procArgs) + : WebserverBase(logger, config, procArgs) + { + + const string PLUGIN_DATA_TEMPLATE = +@" +---------------------------------- + | Plugin configuration: + | Enabled: {enabled} + | Directory: {dir} + | Hot Reload: {hr} + | Reload Delay: {delay}s +----------------------------------"; + + private readonly ProcessArguments args = procArgs; + + ///<inheritdoc/> + protected override PluginStackBuilder? ConfigurePlugins() + { + //do not load plugins if disabled + if (args.HasArgument("--no-plugins")) + { + logger.AppLog.Information("Plugin loading disabled via command-line flag"); + return null; + } + + JsonElement confEl = config.GetDocumentRoot(); + + if (!confEl.TryGetProperty(PLUGINS_CONFIG_PROP_NAME, out JsonElement plCfg)) + { + logger.AppLog.Debug("No plugin configuration found"); + return null; + } + + ServerPluginConfig? conf = plCfg.DeserializeElement<ServerPluginConfig>(); + Validate.EnsureNotNull(conf, "Your plugin configuration object is null or malformatted"); + + if (!conf.Enabled) + { + logger.AppLog.Information("Plugin loading disabled via configuration flag"); + return null; + } + + Validate.EnsureNotNull(conf.Path, "If plugins are enabled, you must specify a directory to load them from"); + + //Init new plugin stack builder + PluginStackBuilder pluginBuilder = PluginStackBuilder.Create() + .WithDebugLog(logger.AppLog) + .WithSearchDirectories([ conf.Path ]) + .WithLoaderFactory(PluginAsemblyLoading.Create); + + //Setup plugin config data + if (!string.IsNullOrWhiteSpace(conf.ConfigDir)) + { + pluginBuilder.WithJsonConfigDir(confEl, new(conf.ConfigDir)); + } + else + { + pluginBuilder.WithLocalJsonConfig(confEl); + } + + if (conf.HotReload) + { + Validate.EnsureRange(conf.ReloadDelaySec, 1, 120); + + pluginBuilder.EnableHotReload(TimeSpan.FromSeconds(conf.ReloadDelaySec)); + } + + logger.AppLog.Information( + PLUGIN_DATA_TEMPLATE, + true, + conf.Path, + conf.HotReload, + conf.ReloadDelaySec + ); + + if (conf.HotReload) + { + logger.AppLog.Warn("Plugin hot-reload is not recommended for production deployments!"); + } + + return pluginBuilder; + } + + ///<inheritdoc/> + protected override HttpConfig GetHttpConfig() + { + JsonElement rootEl = config.GetDocumentRoot(); + + try + { + HttpGlobalConfig? gConf = rootEl.GetProperty("http").DeserializeElement<HttpGlobalConfig>(); + Validate.EnsureNotNull(gConf, "Missing required HTTP configuration variables"); + + gConf.ValidateConfig(); + + //Attempt to load the compressor manager, if null, compression is disabled + IHttpCompressorManager? compressorManager = HttpCompressor.LoadOrDefaultCompressor(procArgs, gConf.Compression, config, logger.AppLog); + + IHttpMemoryPool memPool = MemoryPoolManager.GetHttpPool(procArgs.ZeroAllocations); + + HttpConfig conf = new(Encoding.ASCII) + { + ActiveConnectionRecvTimeout = gConf.RecvTimeoutMs, + CompressorManager = compressorManager, + ConnectionKeepAlive = TimeSpan.FromMilliseconds(gConf.KeepAliveMs), + CompressionLimit = gConf.Compression.CompressionMax, + CompressionMinimum = gConf.Compression.CompressionMin, + DebugPerformanceCounters = procArgs.HasArgument("--http-counters"), + DefaultHttpVersion = HttpHelpers.ParseHttpVersion(gConf.DefaultHttpVersion), + MaxFormDataUploadSize = gConf.MultipartMaxSize, + MaxUploadSize = gConf.MaxEntitySize, + MaxRequestHeaderCount = gConf.MaxRequestHeaderCount, + MaxOpenConnections = gConf.MaxConnections, + MaxUploadsPerRequest = gConf.MaxUploadsPerRequest, + SendTimeout = gConf.SendTimeoutMs, + ServerLog = logger.AppLog, + MemoryPool = memPool, + + RequestDebugLog = procArgs.LogHttp ? logger.AppLog : null, + + //Buffer config update + BufferConfig = new() + { + RequestHeaderBufferSize = gConf.HeaderBufSize, + ResponseHeaderBufferSize = gConf.ResponseHeaderBufSize, + FormDataBufferSize = gConf.MultipartMaxBufSize, + + //Align response buffer size with transport buffer to avoid excessive copy + ResponseBufferSize = TcpConfig.TcpTxBufferSize, + + /* + * Chunk buffers are written to the transport when they are fully accumulated. These buffers + * should be aligned with the transport sizes. It should also be large enough not to put too much + * back pressure on compressors. This buffer will be segmented into smaller buffers if it has to + * at the transport level, but we should avoid that if possible due to multiple allocations and + * copies. + * + * Aligning chunk buffer to the transport buffer size is the easiest solution to avoid excessive + * copyies + */ + ChunkedResponseAccumulatorSize = compressorManager != null ? TcpConfig.TcpTxBufferSize : 0 + }, + + }; + + Validate.Assert( + condition: conf.DefaultHttpVersion != HttpVersion.None, + message: "Your default HTTP version is invalid, specify an RFC formatted http version 'HTTP/x.x'" + ); + + return conf; + } + catch (KeyNotFoundException kne) + { + logger.AppLog.Error("Missing required HTTP configuration variables {var}", kne.Message); + throw new ServerConfigurationException("Missing required http variables. Cannot continue"); + } + } + + ///<inheritdoc/> + protected override VirtualHostConfig[] GetAllVirtualHosts() + { + JsonElement rootEl = config.GetDocumentRoot(); + ILogProvider log = logger.AppLog; + + LinkedList<VirtualHostConfig> configs = new(); + + try + { + //execution timeout + TimeSpan execTimeout = rootEl.GetProperty(SESSION_TIMEOUT_PROP_NAME).GetTimeSpan(TimeParseType.Milliseconds); + + int index = 0; + + //Enumerate all virtual host configurations + foreach (VirtualHostServerConfig vhConfig in GetVirtualHosts()) + { + + VirtualHostConfig conf = new JsonWebConfigBuilder(vhConfig, execTimeout, log).GetBaseConfig(); + + //Configure event hooks + conf.EventHooks = new VirtualHostHooks(conf); + + //Init middleware stack + conf.CustomMiddleware.Add(new MainServerMiddlware(log, conf, vhConfig.ForcePortCheck)); + + /* + * In benchmark mode, skip other middleware that might slow connections down + */ + if (vhConfig.Benchmark?.Enabled == true) + { + conf.CustomMiddleware.Add(new BenchmarkMiddleware(vhConfig.Benchmark)); + log.Information("BENCHMARK: Enabled for virtual host {vh}", conf.Hostnames); + } + else + { + /* + * We only enable cors if the configuration has a value for the allow cors property. + * The user may disable cors totally, deny cors requests, or enable cors with a whitelist + * + * Only add the middleware if the confg has a value for the allow cors property + */ + if (vhConfig.Cors?.Enabled == true) + { + conf.CustomMiddleware.Add(new CORSMiddleware(log, vhConfig.Cors)); + } + + //Add whitelist middleware if the configuration has a whitelist + if (conf.WhiteList != null) + { + conf.CustomMiddleware.Add(new IpWhitelistMiddleware(log, conf.WhiteList)); + } + + //Add blacklist middleware if the configuration has a blacklist + if (conf.BlackList != null) + { + conf.CustomMiddleware.Add(new IpBlacklistMiddleware(log, conf.BlackList)); + } + + //Add tracing middleware if enabled + if (vhConfig.RequestTrace) + { + conf.CustomMiddleware.Add(new ConnectionLogMiddleware(log)); + } + } + + if (!conf.RootDir.Exists) + { + conf.RootDir.Create(); + } + + configs.AddLast(conf); + + index++; + } + } + catch (KeyNotFoundException kne) + { + throw new ServerConfigurationException("Missing required configuration varaibles", kne); + } + catch (FormatException fe) + { + throw new ServerConfigurationException("Failed to parse IP address", fe); + } + + return configs.ToArray(); + } + + private VirtualHostServerConfig[] GetVirtualHosts() + { + JsonElement rootEl = config.GetDocumentRoot(); + ILogProvider log = logger.AppLog; + + if (!rootEl.TryGetProperty("virtual_hosts", out _)) + { + log.Warn("No virtual hosts array was defined. Continuing without hosts"); + return []; + } + + return rootEl.GetProperty("virtual_hosts") + .EnumerateArray() + .Select(GetVhConfig) + .ToArray(); + + + static VirtualHostServerConfig GetVhConfig(JsonElement rootEl) + { + VirtualHostServerConfig? conf = rootEl.DeserializeElement<VirtualHostServerConfig>(); + + Validate.EnsureNotNull(conf, "Empty virtual host configuration, check your virtual hosts array for an empty element"); + Validate.EnsureNotNull(conf.DirPath, "A virtual host was defined without a root directory property: 'dirPath'"); + Validate.EnsureNotNull(conf.Hostnames, "A virtual host was defined without a hostname property: 'hostnames'"); + Validate.EnsureNotNull(conf.Interfaces, "An interface configuration is required for every virtual host"); + + return conf; + } + } + } +} diff --git a/apps/VNLib.WebServer/src/Bootstrap/VariableLogFormatter.cs b/apps/VNLib.WebServer/src/Bootstrap/VariableLogFormatter.cs new file mode 100644 index 0000000..45d7bbb --- /dev/null +++ b/apps/VNLib.WebServer/src/Bootstrap/VariableLogFormatter.cs @@ -0,0 +1,57 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: VariableLogFormatter.cs +* +* VariableLogFormatter.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Text; +using System.Collections.Generic; + +using VNLib.Utils.Logging; + +namespace VNLib.WebServer.Bootstrap +{ + internal sealed class VariableLogFormatter(ILogProvider logger, LogLevel level) + { + private readonly StringBuilder _logFormatSb = new(); + private readonly List<object?> _formatArgs = []; + + public void AppendLine(string line) => _logFormatSb.AppendLine(line); + + public void Append(string value) => _logFormatSb.Append(value); + + public void AppendFormat(string format, params object?[] formatargs) + { + _logFormatSb.Append(format); + _formatArgs.AddRange(formatargs); + } + + public void AppendLine() => _logFormatSb.AppendLine(); + + public void Flush() + { + logger.Write(level, _logFormatSb.ToString(), [.._formatArgs]); + + _logFormatSb.Clear(); + _formatArgs.Clear(); + } + } +} diff --git a/apps/VNLib.WebServer/src/Bootstrap/WebserverBase.cs b/apps/VNLib.WebServer/src/Bootstrap/WebserverBase.cs new file mode 100644 index 0000000..f3832c6 --- /dev/null +++ b/apps/VNLib.WebServer/src/Bootstrap/WebserverBase.cs @@ -0,0 +1,203 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: WebserverBase.cs +* +* WebserverBase.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Linq; +using System.Text.Json; +using System.Diagnostics; + +using VNLib.Net.Http; +using VNLib.Utils; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Runtime; +using VNLib.Plugins.Essentials.ServiceStack; +using VNLib.Plugins.Essentials.ServiceStack.Construction; + +using VNLib.WebServer.Config; +using VNLib.WebServer.Transport; +using VNLib.WebServer.RuntimeLoading; + +namespace VNLib.WebServer.Bootstrap +{ + + internal abstract class WebserverBase(ServerLogger logger, IServerConfig config, ProcessArguments procArgs) + : VnDisposeable + { + + protected readonly ProcessArguments procArgs = procArgs; + protected readonly IServerConfig config = config; + protected readonly ServerLogger logger = logger; + protected readonly TcpServerLoader TcpConfig = new(config, procArgs, logger.SysLog); + + private HttpServiceStack? _serviceStack; + + /// <summary> + /// Gets the internal <see cref="HttpServiceStack"/> this + /// controller is managing + /// </summary> + public HttpServiceStack ServiceStack + { + get + { + if (_serviceStack is null) + { + throw new InvalidOperationException("Service stack has not been configured yet"); + } + + return _serviceStack; + } + } + + /// <summary> + /// Configures the http server for the application so + /// its ready to start + /// </summary> + public virtual void Configure() + { + _serviceStack = ConfiugreServiceStack(); + } + + protected virtual HttpServiceStack ConfiugreServiceStack() + { + bool loadPluginsConcurrently = !procArgs.HasArgument("--sequential-load"); + + JsonElement conf = config.GetDocumentRoot(); + + HttpConfig http = GetHttpConfig(); + + VirtualHostConfig[] virtualHosts = GetAllVirtualHosts(); + + PluginStackBuilder? plugins = ConfigurePlugins(); + + HttpServiceStackBuilder builder = new HttpServiceStackBuilder() + .LoadPluginsConcurrently(loadPluginsConcurrently) + .WithBuiltInHttp(TcpConfig.ReduceBindingsForGroups, http) + .WithDomain(domain => + { + domain.WithServiceGroups(vh => + { + /* + * Must pass the virtual host configuration as the state object + * so transport providers can be loaded from a given virtual host + */ + virtualHosts.ForEach(vhConfig => vh.WithVirtualHost(vhConfig, vhConfig)); + }); + }); + + if (plugins != null) + { + builder.WithPluginStack(plugins.ConfigureStack); + } + + PrintLogicalRouting(virtualHosts); + + return builder.Build(); + } + + protected abstract VirtualHostConfig[] GetAllVirtualHosts(); + + protected abstract HttpConfig GetHttpConfig(); + + protected abstract PluginStackBuilder? ConfigurePlugins(); + + /// <summary> + /// Starts the server and returns immediately + /// after server start listening + /// </summary> + public void Start() + { + /* Since this api is uses internally, knowing the order of operations is a bug, not a rumtime accident */ + Debug.Assert(Disposed == false, "Server was disposed"); + Debug.Assert(_serviceStack != null, "Server was not configured"); + + //Attempt to load plugins before starting server + _serviceStack.LoadPlugins(logger.AppLog); + + _serviceStack.StartServers(); + } + + /// <summary> + /// Stops the server and waits for all connections to close and + /// servers to fully shut down + /// </summary> + public void Stop() + { + Debug.Assert(Disposed == false, "Server was disposed"); + Debug.Assert(_serviceStack != null, "Server was not configured"); + + //Stop the server and wait synchronously + _serviceStack.StopAndWaitAsync() + .GetAwaiter() + .GetResult(); + } + + private void PrintLogicalRouting(VirtualHostConfig[] hosts) + { + const string header =@" +=================================================== + --- HTTP Service Domain --- + + {enabledRoutes} routes enabled +"; + + VariableLogFormatter sb = new(logger.AppLog, Utils.Logging.LogLevel.Information); + sb.AppendFormat(header, hosts.Length); + + foreach (VirtualHostConfig host in hosts) + { + sb.AppendLine(); + + sb.AppendFormat("Virtual Host: {hostnames}\n", (object)host.Hostnames); + sb.AppendFormat(" Root directory {rdir}\n", host.RootDir); + sb.AppendLine(); + + //Print interfaces + + string[] interfaces = host.Transports + .Select(i =>$" - {i.Address}:{i.Port} TLS: {i.Ssl}, Client cert: {i.ClientCertRequired}, OS Ciphers: {i.UseOsCiphers}") + .ToArray(); + + sb.AppendLine(" Interfaces:"); + sb.AppendFormat("{interfaces}", string.Join("\n", interfaces)); + sb.AppendLine(); + + sb.AppendLine(" Options:"); + sb.AppendFormat(" - Whitelist: {wl}\n", host.WhiteList); + sb.AppendFormat(" - Blacklist: {bl}\n", host.BlackList); + sb.AppendFormat(" - Path filter: {filter}\n", host.PathFilter); + sb.AppendFormat(" - Cache default time: {cache}\n", host.CacheDefault); + sb.AppendFormat(" - Cached error files: {files}\n", host.FailureFiles.Select(static p => (int)p.Key)); + sb.AppendFormat(" - Downstream servers: {dsServers}\n", host.DownStreamServers); + sb.AppendFormat(" - Middlewares loaded {mw}\n", host.CustomMiddleware.Count); + sb.AppendLine(); + + sb.Flush(); + } + } + + + ///<inheritdoc/> + protected override void Free() => _serviceStack?.Dispose(); + } +} diff --git a/apps/VNLib.WebServer/src/CommandListener.cs b/apps/VNLib.WebServer/src/CommandListener.cs new file mode 100644 index 0000000..7082d40 --- /dev/null +++ b/apps/VNLib.WebServer/src/CommandListener.cs @@ -0,0 +1,251 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: CommandListener.cs +* +* CommandListener.cs is part of VNLib.WebServer which is part of +* the larger VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.IO; +using System.Threading; + +using VNLib.Utils.Memory; +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Utils.Memory.Diagnostics; +using VNLib.Net.Http; +using VNLib.Plugins.Essentials.ServiceStack; +using VNLib.Plugins.Essentials.ServiceStack.Plugins; + +using VNLib.WebServer.Bootstrap; + +namespace VNLib.WebServer +{ + + internal sealed class CommandListener(ManualResetEvent shutdownEvent, WebserverBase server, ILogProvider log) + { + const string MANAGED_HEAP_STATS = @" + Managed Heap Stats +-------------------------------------- + Collections: + Gen0: {g0} Gen1: {g1} Gen2: {g2} + + Heap: + High Watermark: {hw} KB + Last GC Heap Size: {hz} KB + Current Load: {ld} KB + Fragmented: {fb} KB + + Heap Info: + Last GC concurrent? {con} + Last GC compacted? {comp} + Pause time: {pt} % + Pending finalizers: {pf} + Pinned objects: {po} +"; + + const string HEAPSTATS = @" + Unmanaged Heap Stats +--------------------------- + userHeap? {rp} + Allocated bytes: {ab} + Allocated handles: {h} + Max block size: {mb} + Min block size: {mmb} + Max heap size: {hs} +"; + const string HELP = @" + VNLib.WebServer console help menu + + p <plugin-name> <command> - Sends a command to a plugin + cmd <plugin-name> - Enters a command loop for the specified plugin + reload - Reloads all plugins + memstats - Prints memory stats + collect - Flushes server caches, collects, and compacts memory + stop - Stops the server + help - Prints this help menu +"; + + + private readonly HttpServiceStack _serviceStack = server.ServiceStack; + private readonly IHttpPluginManager _plugins = server.ServiceStack.PluginManager; + + + /// <summary> + /// Listens for commands and processes them in a continuous loop + /// </summary> + /// <param name="shutdownEvent">A <see cref="ManualResetEvent"/> that is set when the Stop command is received</param> + /// <param name="server">The webserver for the current process</param> + public void ListenForCommands(TextReader input, TextWriter output, string name) + { + log.Information("Listening for commands on {con}", name); + + while (shutdownEvent.WaitOne(0) == false) + { + string[]? s = input.ReadLine()?.Split(' '); + if (s == null) + { + continue; + } + switch (s[0].ToLower(null)) + { + case "help": + output.WriteLine(HELP); + break; + //handle plugin + case "p": + { + if (s.Length < 3) + { + output.WriteLine("Plugin name and command are required"); + break; + } + + string message = string.Join(' ', s[2..]); + + bool sent = _plugins.SendCommandToPlugin(s[1], message, StringComparison.OrdinalIgnoreCase); + + if (!sent) + { + output.WriteLine("Plugin not found"); + } + } + break; + + case "cmd": + { + if (s.Length < 2) + { + output.WriteLine("Plugin name is required"); + break; + } + + //Enter plugin command loop + EnterPluginLoop(input, output, s[1], _plugins); + } + break; + case "reload": + { + try + { + //Reload all plugins + _plugins.ForceReloadAllPlugins(); + } + catch (Exception ex) + { + log.Error(ex); + } + } + break; + case "memstats": + { + + + //Collect gc info for managed heap stats + int gen0 = GC.CollectionCount(0); + int gen1 = GC.CollectionCount(1); + int gen2 = GC.CollectionCount(2); + GCMemoryInfo mi = GC.GetGCMemoryInfo(); + + log.Debug(MANAGED_HEAP_STATS, + gen0, + gen1, + gen2, + mi.HighMemoryLoadThresholdBytes / 1024, + mi.HeapSizeBytes / 1024, + mi.MemoryLoadBytes / 1024, + mi.FragmentedBytes / 1024, + mi.Concurrent, + mi.Compacted, + mi.PauseTimePercentage, + mi.FinalizationPendingCount, + mi.PinnedObjectsCount + ); + + //Get heap stats + HeapStatistics hs = MemoryUtil.GetSharedHeapStats(); + + //Print unmanaged heap stats + log.Debug(HEAPSTATS, + MemoryUtil.IsUserDefinedHeap, + hs.AllocatedBytes, + hs.AllocatedBlocks, + hs.MaxBlockSize, + hs.MinBlockSize, + hs.MaxHeapSize + ); + } + break; + case "collect": + CollectCache(_serviceStack); + GC.Collect(2, GCCollectionMode.Forced, false, true); + GC.WaitForFullGCComplete(); + break; + case "stop": + shutdownEvent.Set(); + return; + } + } + } + + /* + * Function scopes commands as if the user is writing directly to + * the plugin. All commands are passed to the plugin manager for + * processing. + */ + private static void EnterPluginLoop( + TextReader input, + TextWriter output, + string pluignName, + IHttpPluginManager man + ) + { + output.WriteLine("Entering plugin {0}. Type 'exit' to leave", pluignName); + + while (true) + { + output.Write("{0}>", pluignName); + + string? cmdText = input.ReadLine(); + + if (string.IsNullOrWhiteSpace(cmdText)) + { + output.WriteLine("Please enter a command or type 'exit' to leave"); + continue; + } + + if (string.Equals(cmdText, "exit", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + //Exec command + if (!man.SendCommandToPlugin(pluignName, cmdText, StringComparison.OrdinalIgnoreCase)) + { + output.WriteLine("Plugin does not exist exiting loop"); + break; + } + } + } + + private static void CollectCache(HttpServiceStack controller) + => controller.Servers.ForEach(static server => (server as HttpServer)!.CacheClear()); + } +} diff --git a/apps/VNLib.WebServer/src/Compression/FallbackCompressionManager.cs b/apps/VNLib.WebServer/src/Compression/FallbackCompressionManager.cs new file mode 100644 index 0000000..d2eb719 --- /dev/null +++ b/apps/VNLib.WebServer/src/Compression/FallbackCompressionManager.cs @@ -0,0 +1,144 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: FallbackCompressionManager.cs +* +* FallbackCompressionManager.cs is part of VNLib.WebServer which is part +* of the larger VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Buffers; +using System.Diagnostics; +using System.IO.Compression; + +using VNLib.Net.Http; + +namespace VNLib.WebServer.Compression +{ + + /* + * The fallback compression manager is used when the user did not configure a + * compression manager library. Since .NET only exposes a brotli encoder, that + * is not a stream api, (gzip and deflate are stream api's) Im only supporting + * brotli for now. This is better than nothing lol + */ + + + internal sealed class FallbackCompressionManager : IHttpCompressorManager + { + /// <inheritdoc/> + public object AllocCompressor() => new BrCompressorState(); + + /// <inheritdoc/> + public CompressionMethod GetSupportedMethods() => CompressionMethod.Brotli; + + /// <inheritdoc/> + public int InitCompressor(object compressorState, CompressionMethod compMethod) + { + BrCompressorState compressor = (BrCompressorState)compressorState; + ref BrotliEncoder encoder = ref compressor.GetEncoder(); + + //Init new brotli encoder struct + encoder = new(9, 24); + return 0; + } + + /// <inheritdoc/> + public void DeinitCompressor(object compressorState) + { + BrCompressorState compressor = (BrCompressorState)compressorState; + ref BrotliEncoder encoder = ref compressor.GetEncoder(); + + //Clean up the encoder + encoder.Dispose(); + encoder = default; + } + + /// <inheritdoc/> + public CompressionResult CompressBlock(object compressorState, ReadOnlyMemory<byte> input, Memory<byte> output) + { + //Output buffer should never be empty, server guards this + Debug.Assert(!output.IsEmpty, "Exepcted a non-zero length output buffer"); + + BrCompressorState compressor = (BrCompressorState)compressorState; + ref BrotliEncoder encoder = ref compressor.GetEncoder(); + + //Compress the supplied block + OperationStatus status = encoder.Compress(input.Span, output.Span, out int bytesConsumed, out int bytesWritten, false); + + /* + * Should always return done, because the output buffer is always + * large enough and that data/state cannot be invalid + */ + Debug.Assert(status == OperationStatus.Done); + + return new() + { + BytesRead = bytesConsumed, + BytesWritten = bytesWritten, + }; + } + + /// <inheritdoc/> + public int Flush(object compressorState, Memory<byte> output) + { + OperationStatus status; + + //Output buffer should never be empty, server guards this + Debug.Assert(!output.IsEmpty, "Exepcted a non-zero length output buffer"); + + BrCompressorState compressor = (BrCompressorState)compressorState; + ref BrotliEncoder encoder = ref compressor.GetEncoder(); + + /* + * A call to compress with the isFinalBlock flag set to true will + * cause a BROTLI_OPERATION_FINISH operation to be performed. This is + * actually the proper way to complete a brotli compression stream. + * + * See vnlib_compress project for more details. + */ + status = encoder.Compress( + source: default, + destination: output.Span, + bytesConsumed: out _, + bytesWritten: out int bytesWritten, + isFinalBlock: true + ); + + /* + * Function can return Done or DestinationTooSmall if there is still more data + * stored in the compressor to be written. If InvaliData is returned, then there + * is a problem with the encoder state or the output buffer, this condition should + * never happen. + */ + Debug.Assert(status != OperationStatus.InvalidData, $"Failed with status {status}, written {bytesWritten}, buffer size {output.Length}"); + + //Return the number of bytes actually accumulated + return bytesWritten; + } + + + private sealed class BrCompressorState + { + private BrotliEncoder _encoder; + + public ref BrotliEncoder GetEncoder() => ref _encoder; + } + } +} diff --git a/apps/VNLib.WebServer/src/Compression/HttpCompressor.cs b/apps/VNLib.WebServer/src/Compression/HttpCompressor.cs new file mode 100644 index 0000000..beda67b --- /dev/null +++ b/apps/VNLib.WebServer/src/Compression/HttpCompressor.cs @@ -0,0 +1,123 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: HttpCompressor.cs +* +* HttpCompressor.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.IO; +using System.Text.Json; +using System.Reflection; +using System.Runtime.Loader; + +using VNLib.Utils.IO; +using VNLib.Utils.Logging; +using VNLib.Utils.Resources; +using VNLib.Net.Http; + +using VNLib.WebServer.Config; +using VNLib.WebServer.RuntimeLoading; +using VNLib.WebServer.Config.Model; + +namespace VNLib.WebServer.Compression +{ + + internal static class HttpCompressor + { + /* + * A function delegate that is invoked on the user-defined http compressor library + * when loaded + */ + private delegate void OnHttpLibLoad(ILogProvider log, JsonElement? configData); + + /// <summary> + /// Attempts to load a user-defined http compressor library from the specified path in the config, + /// otherwise falls back to the default http compressor, unless the command line disabled compression. + /// </summary> + /// <param name="args">Process wide- argument list</param> + /// <param name="config">The top-level config element</param> + /// <param name="logger">The application logger to write logging events to</param> + /// <returns>The <see cref="IHttpCompressorManager"/> that the user configured, or null if disabled</returns> + public static IHttpCompressorManager? LoadOrDefaultCompressor(ProcessArguments args, HttpCompressorConfig compConfig, IServerConfig config, ILogProvider logger) + { + const string EXTERN_LIB_LOAD_METHOD_NAME = "OnLoad"; + + if (args.HasArgument("--compression-off")) + { + logger.Information("Http compression disabled by cli args"); + return null; + } + + if(!compConfig.Enabled) + { + logger.Information("Http compression disabled by config"); + return null; + } + + if (string.IsNullOrWhiteSpace(compConfig.AssemblyPath)) + { + logger.Information("Falling back to default http compressor"); + return new FallbackCompressionManager(); + } + + //Make sure the file exists + if (!FileOperations.FileExists(compConfig.AssemblyPath)) + { + logger.Warn("The specified http compressor assembly file does not exist, falling back to default http compressor"); + return new FallbackCompressionManager(); + } + + //Try to load the assembly into our process alc, we dont need to worry about unloading + ManagedLibrary lib = ManagedLibrary.LoadManagedAssembly(compConfig.AssemblyPath, AssemblyLoadContext.Default); + + logger.Debug("Loading user defined compressor assembly: {asm}", Path.GetFileName(lib.AssemblyPath)); + + try + { + //Load the compressor manager type from the assembly + IHttpCompressorManager instance = lib.LoadTypeFromAssembly<IHttpCompressorManager>(); + + /* + * We can provide some optional library initialization functions if the library + * supports it. First we can allow the library to write logs to our log provider + * and second we can provide the library with the raw configuration data as a byte array + */ + + //Invoke the on load method with the logger and config data + OnHttpLibLoad? onlibLoadConfig = ManagedLibrary.TryGetMethod<OnHttpLibLoad>(instance, EXTERN_LIB_LOAD_METHOD_NAME); + onlibLoadConfig?.Invoke(logger, config.GetDocumentRoot()); + + //Invoke parameterless on load method + Action? onLibLoad = ManagedLibrary.TryGetMethod<Action>(instance, EXTERN_LIB_LOAD_METHOD_NAME); + onLibLoad?.Invoke(); + + logger.Information("Custom compressor library loaded"); + + return instance; + } + //Catch TIE and throw the inner exception for cleaner debug + catch (TargetInvocationException te) when (te.InnerException != null) + { + throw te.InnerException; + } + } + } +} diff --git a/apps/VNLib.WebServer/src/Config/IServerConfig.cs b/apps/VNLib.WebServer/src/Config/IServerConfig.cs new file mode 100644 index 0000000..b0b06ba --- /dev/null +++ b/apps/VNLib.WebServer/src/Config/IServerConfig.cs @@ -0,0 +1,34 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: IServerConfig.cs +* +* IServerConfig.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Text.Json; + + +namespace VNLib.WebServer.Config +{ + internal interface IServerConfig + { + JsonElement GetDocumentRoot(); + } +} diff --git a/apps/VNLib.WebServer/src/Config/JsonConfigOptions.cs b/apps/VNLib.WebServer/src/Config/JsonConfigOptions.cs new file mode 100644 index 0000000..a48b4e9 --- /dev/null +++ b/apps/VNLib.WebServer/src/Config/JsonConfigOptions.cs @@ -0,0 +1,42 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: JsonConfigOptions.cs +* +* JsonConfigOptions.cs is part of VNLib.WebServer which is part of +* the larger VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Text.Json; + +namespace VNLib.WebServer.Config +{ + internal static class JsonConfigOptions + { + private static readonly JsonSerializerOptions _ops = new() + { + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + }; + + public static T? DeserializeElement<T>(this JsonElement el) + { + return el.Deserialize<T>(_ops); + } + } +} diff --git a/apps/VNLib.WebServer/src/Config/JsonServerConfig.cs b/apps/VNLib.WebServer/src/Config/JsonServerConfig.cs new file mode 100644 index 0000000..ada9902 --- /dev/null +++ b/apps/VNLib.WebServer/src/Config/JsonServerConfig.cs @@ -0,0 +1,156 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: JsonServerConfig.cs +* +* JsonServerConfig.cs is part of VNLib.WebServer which is part +* of the larger VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.IO; +using System.Text.Json; + +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +using VNLib.Utils.IO; + +namespace VNLib.WebServer.Config +{ + internal sealed class JsonServerConfig(JsonDocument doc) : IServerConfig + { + public JsonElement GetDocumentRoot() => doc.RootElement; + + public static JsonServerConfig? FromFile(string filename) + { + string nameOnly = Path.GetFileName(filename); + Console.WriteLine("Loading configuration file from {0}", nameOnly); + + if (filename.EndsWith(".json")) + { + return FromJson(filename); + } + else if (filename.EndsWith(".yaml") || filename.EndsWith(".yml")) + { + return FromYaml(filename); + } + else + { + return null; + } + } + + /// <summary> + /// Reads a server configuration from the specified JSON document + /// </summary> + /// <param name="configPath">The file path of the json cofiguration file</param> + /// <returns>A new <see cref="JsonServerConfig"/> wrapping the server config</returns> + public static JsonServerConfig? FromJson(string fileName) + { + if (!FileOperations.FileExists(fileName)) + { + return null; + } + + //Open the config file + using FileStream fs = File.OpenRead(fileName); + + //Allow comments + JsonDocumentOptions jdo = new() + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + return new JsonServerConfig(JsonDocument.Parse(fs, jdo)); + } + + public static JsonServerConfig? FromYaml(string fileName) + { + if (!FileOperations.FileExists(fileName)) + { + return null; + } + + /* + * The following code reads the configuration as a yaml + * object and then serializes it over to json. + */ + + using StreamReader reader = OpenFileRead(fileName); + + object? yamlObject = new DeserializerBuilder() + .WithNodeTypeResolver(new NumberTypeResolver()) + .Build() + .Deserialize(reader); + + ISerializer serializer = new SerializerBuilder() + .JsonCompatible() + .Build(); + + using VnMemoryStream ms = new(); + using (StreamWriter sw = new(ms, leaveOpen: true)) + { + serializer.Serialize(sw, yamlObject); + } + + ms.Seek(0, SeekOrigin.Begin); + + return new JsonServerConfig(JsonDocument.Parse(ms)); + } + + private static StreamReader OpenFileRead(string fileName) + { + return new StreamReader( + stream: File.OpenRead(fileName), + encoding: System.Text.Encoding.UTF8, + detectEncodingFromByteOrderMarks: false, + leaveOpen: false + ); + } + + public class NumberTypeResolver : INodeTypeResolver + { + public bool Resolve(NodeEvent? nodeEvent, ref Type currentType) + { + if (nodeEvent is Scalar scalar) + { + if(long.TryParse(scalar.Value, out _)) + { + currentType = typeof(int); + return true; + } + + if (double.TryParse(scalar.Value, out _)) + { + currentType = typeof(double); + return true; + } + + if (bool.TryParse(scalar.Value, out _)) + { + currentType = typeof(bool); + return true; + } + } + return false; + } + } + } +} diff --git a/apps/VNLib.WebServer/src/Config/Model/BenchmarkConfig.cs b/apps/VNLib.WebServer/src/Config/Model/BenchmarkConfig.cs new file mode 100644 index 0000000..c569b95 --- /dev/null +++ b/apps/VNLib.WebServer/src/Config/Model/BenchmarkConfig.cs @@ -0,0 +1,42 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: BenchmarkConfig.cs +* +* BenchmarkConfig.cs is part of VNLib.WebServer which is part of the +* larger VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Text.Json.Serialization; + +namespace VNLib.WebServer.Config.Model +{ + internal class BenchmarkConfig + { + + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + [JsonPropertyName("size")] + public int Size { get; set; } + + [JsonPropertyName("random")] + public bool Random { get; set; } + + } +} diff --git a/apps/VNLib.WebServer/src/Config/Model/CorsSecurityConfig.cs b/apps/VNLib.WebServer/src/Config/Model/CorsSecurityConfig.cs new file mode 100644 index 0000000..2f95697 --- /dev/null +++ b/apps/VNLib.WebServer/src/Config/Model/CorsSecurityConfig.cs @@ -0,0 +1,41 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: CorsSecurityConfig.cs +* +* CorsSecurityConfig.cs is part of VNLib.WebServer which is part of the +* larger VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Text.Json.Serialization; + +namespace VNLib.WebServer.Config.Model +{ + internal class CorsSecurityConfig + { + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = false; + + [JsonPropertyName("deny_cors_connections")] + public bool DenyCorsCons { get; set; } = false; + + [JsonPropertyName("allowed_authority")] + public string[] AllowedCorsAuthority { get; set; } = Array.Empty<string>(); + } +}
\ No newline at end of file diff --git a/apps/VNLib.WebServer/src/Config/Model/ErrorFileConfig.cs b/apps/VNLib.WebServer/src/Config/Model/ErrorFileConfig.cs new file mode 100644 index 0000000..c4355d6 --- /dev/null +++ b/apps/VNLib.WebServer/src/Config/Model/ErrorFileConfig.cs @@ -0,0 +1,37 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: ErrorFileConfig.cs +* +* ErrorFileConfig.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Text.Json.Serialization; + +namespace VNLib.WebServer.Config.Model +{ + internal sealed class ErrorFileConfig + { + [JsonPropertyName("code")] + public int Code { get; set; } + + [JsonPropertyName("path")] + public string? Path { get; set; } + } +} diff --git a/apps/VNLib.WebServer/src/Config/Model/HttpCompressorConfig.cs b/apps/VNLib.WebServer/src/Config/Model/HttpCompressorConfig.cs new file mode 100644 index 0000000..eb8e68c --- /dev/null +++ b/apps/VNLib.WebServer/src/Config/Model/HttpCompressorConfig.cs @@ -0,0 +1,47 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: HttpCompressorConfig.cs +* +* HttpCompressorConfig.cs is part of VNLib.WebServer which is part of +* the larger VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Text.Json.Serialization; + +namespace VNLib.WebServer.Config.Model +{ + internal sealed class HttpCompressorConfig + { + [JsonPropertyName("assembly")] + public string? AssemblyPath { get; set; } + + /// <summary> + /// If this compressor is enabled. The default is true, to use built-in + /// compressors. + /// </summary> + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; + + [JsonPropertyName("max_size")] + public long CompressionMax { get; set; } = 104857600; //100MB + + [JsonPropertyName("min_size")] + public int CompressionMin { get; set; } = 256; + } +} diff --git a/apps/VNLib.WebServer/src/Config/Model/HttpGlobalConfig.cs b/apps/VNLib.WebServer/src/Config/Model/HttpGlobalConfig.cs new file mode 100644 index 0000000..22bfe75 --- /dev/null +++ b/apps/VNLib.WebServer/src/Config/Model/HttpGlobalConfig.cs @@ -0,0 +1,137 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: HttpGlobalConfig.cs +* +* HttpGlobalConfig.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Text.Json.Serialization; + +namespace VNLib.WebServer.Config.Model +{ + internal class HttpGlobalConfig + { + + [JsonPropertyName("default_version")] + public string DefaultHttpVersion { get; set; } = "HTTP/1.1"; + + /// <summary> + /// The maximum size of a request entity that can be sent to the server. + /// </summary> + [JsonPropertyName("max_entity_size")] + public long MaxEntitySize { get; set; } = long.MaxValue; + + /// <summary> + /// The maximum size of a multipart form data upload. + /// </summary> + [JsonPropertyName("multipart_max_size")] + public int MultipartMaxSize { get; set; } = 1048576; //1MB + + /// <summary> + /// The time in milliseconds for an HTTP/1.1 connection to remain open + /// before the server closes it. + /// </summary> + [JsonPropertyName("keepalive_ms")] + public int KeepAliveMs { get; set; } = 60000; //60 seconds + + /// <summary> + /// The time in milliseconds to wait for data on an active connection. + /// IE: A connection that has been established and has signaled that + /// it is ready to transfer data. + /// </summary> + [JsonPropertyName("recv_timeout_ms")] + public int RecvTimeoutMs { get; set; } = 5000; //5 seconds + + /// <summary> + /// The time in milliseconds to wait for data to be sent on a connection. + /// </summary> + [JsonPropertyName("send_timeout_ms")] + public int SendTimeoutMs { get; set; } = 60000; //60 seconds + + /// <summary> + /// The maximum number of headers that can be sent in a request. + /// </summary> + [JsonPropertyName("max_request_header_count")] + public int MaxRequestHeaderCount { get; set; } = 32; + + /// <summary> + /// The maximum number of open connections that can be made to the server, before + /// the server starts rejecting new connections. + /// </summary> + [JsonPropertyName("max_connections")] + public int MaxConnections { get; set; } = int.MaxValue; + + /// <summary> + /// The maximum number of uploads that can be made in a single request. If + /// this value is exceeded, the request will be rejected. + /// </summary> + [JsonPropertyName("max_uploads_per_request")] + public ushort MaxUploadsPerRequest { get; set; } = 10; + + /// <summary> + /// The size of the buffer used to store request headers. + /// </summary> + [JsonPropertyName("header_buf_size")] + public int HeaderBufSize { get; set; } + + /// <summary> + /// The size of the buffer used to store response headers. + /// </summary> + [JsonPropertyName("response_header_buf_size")] + public int ResponseHeaderBufSize { get; set; } + + /// <summary> + /// The size of the buffer used to store form data. + /// </summary> + [JsonPropertyName("multipart_max_buf_size")] + public int MultipartMaxBufSize { get; set; } + + /// <summary> + /// The configuration for the HTTP compression settings. + /// </summary> + [JsonPropertyName("compression")] + public HttpCompressorConfig? Compression { get; set; } = new(); + + public void ValidateConfig() + { + Validate.EnsureNotNull(DefaultHttpVersion, "Default HTTP version is required"); + + Validate.EnsureRange(MaxEntitySize, 0, long.MaxValue); + Validate.EnsureRange(MultipartMaxSize, -1, int.MaxValue); + Validate.EnsureRange(KeepAliveMs, -1, int.MaxValue); + + //Timeouts may be disabled by setting 0 or -1. Both are allowed for readability + Validate.EnsureRange(RecvTimeoutMs, -2, int.MaxValue); + Validate.EnsureRange(SendTimeoutMs, -2, int.MaxValue); + + Validate.EnsureRange(MaxRequestHeaderCount, 0, 1024); + Validate.EnsureRange(MaxConnections, 0, int.MaxValue); + Validate.EnsureRange(MaxUploadsPerRequest, 0, 1024); + Validate.EnsureRange(HeaderBufSize, 0, int.MaxValue); + Validate.EnsureRange(ResponseHeaderBufSize, 0, int.MaxValue); + Validate.EnsureRange(MultipartMaxBufSize, 0, int.MaxValue); + + //Validate compression config + Validate.EnsureNotNull(Compression, "Compression configuration should not be set to null. Comment to enable defaults"); + Validate.EnsureRange(Compression.CompressionMax, -1, long.MaxValue); + Validate.EnsureRange(Compression.CompressionMin, -1, int.MaxValue); + } + } +} diff --git a/apps/VNLib.WebServer/src/Config/Model/ServerPluginConfig.cs b/apps/VNLib.WebServer/src/Config/Model/ServerPluginConfig.cs new file mode 100644 index 0000000..42b91b1 --- /dev/null +++ b/apps/VNLib.WebServer/src/Config/Model/ServerPluginConfig.cs @@ -0,0 +1,46 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: ServerPluginConfig.cs +* +* ServerPluginConfig.cs is part of VNLib.WebServer which is part of +* the larger VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Text.Json.Serialization; + +namespace VNLib.WebServer.Config.Model +{ + internal class ServerPluginConfig + { + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; //default to true if config is defined, then it can be assumed we want to load plugins unless explicitly disabled + + [JsonPropertyName("path")] + public string? Path { get; set; } + + [JsonPropertyName("config_dir")] + public string? ConfigDir { get; set; } + + [JsonPropertyName("hot_reload")] + public bool HotReload { get; set; } + + [JsonPropertyName("reload_delay_sec")] + public int ReloadDelaySec { get; set; } = 2; + } +} diff --git a/apps/VNLib.WebServer/src/Config/Model/TcpConfigJson.cs b/apps/VNLib.WebServer/src/Config/Model/TcpConfigJson.cs new file mode 100644 index 0000000..5bd2b94 --- /dev/null +++ b/apps/VNLib.WebServer/src/Config/Model/TcpConfigJson.cs @@ -0,0 +1,73 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: TcpServerLoader.cs +* +* TcpServerLoader.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Text.Json.Serialization; + +namespace VNLib.WebServer.Config.Model +{ + internal sealed class TcpConfigJson + { + [JsonPropertyName("keepalive_sec")] + public int TcpKeepAliveTime { get; set; } = 4; + + [JsonPropertyName("keepalive_interval_sec")] + public int KeepaliveInterval { get; set; } = 4; + + [JsonPropertyName("max_recv_buffer")] + public int MaxRecvBufferData { get; set; } = 10 * 64 * 1024; + + [JsonPropertyName("backlog")] + public int BackLog { get; set; } = 1000; + + [JsonPropertyName("max_connections")] + public long MaxConnections { get; set; } = long.MaxValue; + + [JsonPropertyName("no_delay")] + public bool NoDelay { get; set; } = false; + + /* + * Buffer sizes are a pain, this is a good default size for medium bandwith connections (100mbps) + * using the BDP calculations + * + * BDP = Bandwidth * RTT + */ + + [JsonPropertyName("tx_buffer")] + public int TcpSendBufferSize { get; set; } = 625 * 1024; + + [JsonPropertyName("rx_buffer")] + public int TcpRecvBufferSize { get; set; } = 625 * 1024; + + + public void ValidateConfig() + { + Validate.EnsureRange(TcpKeepAliveTime, 0, 60); + Validate.EnsureRange(KeepaliveInterval, 0, 60); + Validate.EnsureRange(BackLog, 0, 10000); + Validate.EnsureRange(MaxConnections, 0, long.MaxValue); + Validate.EnsureRange(MaxRecvBufferData, 0, 10 * 1024 * 1024); //10MB + Validate.EnsureRange(TcpSendBufferSize, 0, 10 * 1024 * 1024); //10MB + } + } +} diff --git a/apps/VNLib.WebServer/src/Config/Model/TransportInterface.cs b/apps/VNLib.WebServer/src/Config/Model/TransportInterface.cs new file mode 100644 index 0000000..e0e2551 --- /dev/null +++ b/apps/VNLib.WebServer/src/Config/Model/TransportInterface.cs @@ -0,0 +1,137 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: TransportInterface.cs +* +* TransportInterface.cs is part of VNLib.WebServer which is part of +* the larger VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.IO; +using System.Net; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json.Serialization; + +using VNLib.Utils.Memory; +using VNLib.Utils.Resources; + +namespace VNLib.WebServer.Config.Model +{ + /// <summary> + /// Represents a transport interface configuration element for a virtual host + /// </summary> + internal class TransportInterface + { + [JsonPropertyName("port")] + public int Port { get; set; } + + [JsonPropertyName("address")] + public string? Address { get; set; } + + [JsonPropertyName("certificate")] + public string? Cert { get; set; } + + [JsonPropertyName("private_key")] + public string? PrivKey { get; set; } + + [JsonPropertyName("ssl")] + public bool Ssl { get; set; } + + [JsonPropertyName("client_cert_required")] + public bool ClientCertRequired { get; set; } + + [JsonPropertyName("password")] + public string? PrivKeyPassword { get; set; } + + [JsonPropertyName("use_os_ciphers")] + public bool UseOsCiphers { get; set; } + + public IPEndPoint GetEndpoint() + { + IPAddress addr = string.IsNullOrEmpty(Address) ? IPAddress.Any : IPAddress.Parse(Address); + return new IPEndPoint(addr, Port); + } + + public X509Certificate? LoadCertificate() + { + if (!Ssl) + { + return null; + } + + Validate.EnsureNotNull(Cert, "TLS Certificate is required when ssl is enabled"); + Validate.FileExists(Cert); + + X509Certificate? cert = null; + + /* + * Default to use a PEM encoded certificate and private key file. Unless the file + * is a pfx file, then we will use the private key from the pfx file. + */ + + if (Path.GetExtension(Cert).EndsWith("pfx", StringComparison.OrdinalIgnoreCase)) + { + //Create from pfx file including private key + cert = X509Certificate.CreateFromCertFile(Cert); + } + else + { + Validate.EnsureNotNull(PrivKey, "TLS Private Key is required ssl is enabled"); + Validate.FileExists(PrivKey); + + /* + * Attempt to capture the private key password. This will wrap the + * string in a private string instance, and setting the value to true + * will ensure the password memory is wiped when this function returns + */ + using PrivateString? password = PrivateString.ToPrivateString(PrivKeyPassword, true); + + //Load the cert and decrypt with password if set + using X509Certificate2 cert2 = password == null ? X509Certificate2.CreateFromPemFile(Cert, PrivKey) + : X509Certificate2.CreateFromEncryptedPemFile(Cert, password.ToReadOnlySpan(), PrivKey); + + /* + * Workaround for a silly Windows SecureChannel module bug for parsing + * X509Certificate2 from pem cert and private key files. + * + * Must export into pkcs12 format then create a new X509Certificate2 from the + * exported bytes. + */ + + //Copy the cert in pkcs12 format + byte[] pkcs = cert2.Export(X509ContentType.Pkcs12); + cert = new X509Certificate2(pkcs); + MemoryUtil.InitializeBlock(pkcs); + } + + return cert; + } + + /// <summary> + /// Builds a deterministic hash-code base on the configuration state. + /// </summary> + /// <returns>The hash-code that represents the current instance</returns> + public override int GetHashCode() => HashCode.Combine(Address, Port); + + public override bool Equals(object? obj) => obj is TransportInterface iface && GetHashCode() == iface.GetHashCode(); + + public override string ToString() => $"[{Address}:{Port}]"; + + } +} diff --git a/apps/VNLib.WebServer/src/Config/Model/VirtualHostServerConfig.cs b/apps/VNLib.WebServer/src/Config/Model/VirtualHostServerConfig.cs new file mode 100644 index 0000000..2f18cf7 --- /dev/null +++ b/apps/VNLib.WebServer/src/Config/Model/VirtualHostServerConfig.cs @@ -0,0 +1,96 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: VirtualHostServerConfig.cs +* +* VirtualHostServerConfig.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Linq; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace VNLib.WebServer.Config.Model +{ + internal sealed class VirtualHostServerConfig + { + [JsonPropertyName("trace")] + public bool RequestTrace { get; set; } = false; + + [JsonPropertyName("force_port_check")] + public bool ForcePortCheck { get; set; } = false; + + [JsonPropertyName("benchmark")] + public BenchmarkConfig? Benchmark { get; set; } + + [JsonPropertyName("interfaces")] + public TransportInterface[] Interfaces { get; set; } = Array.Empty<TransportInterface>(); + + [JsonPropertyName("hostnames")] + public string[]? Hostnames { get; set; } = Array.Empty<string>(); + + [JsonPropertyName("hostname")] + public string? Hostname + { + get => Hostnames?.FirstOrDefault(); + set + { + if (value != null) + { + Hostnames = [value]; + } + } + } + + [JsonPropertyName("path")] + public string? DirPath { get; set; } = string.Empty; + + [JsonPropertyName("downstream_servers")] + public string[] DownstreamServers { get; set; } = Array.Empty<string>(); + + [JsonPropertyName("whitelist")] + public string[]? Whitelist { get; set; } + + [JsonPropertyName("blacklist")] + public string[]? Blacklist { get; set; } + + [JsonPropertyName("deny_extensions")] + public string[]? DenyExtensions { get; set; } + + [JsonPropertyName("default_files")] + public string[]? DefaultFiles { get; set; } + + [JsonPropertyName("headers")] + public Dictionary<string, string> Headers { get; set; } = []; + + [JsonPropertyName("cors")] + public CorsSecurityConfig Cors { get; set; } = new(); + + [JsonPropertyName("error_files")] + public ErrorFileConfig[] ErrorFiles { get; set; } = Array.Empty<ErrorFileConfig>(); + + [JsonPropertyName("cache_default_sec")] + public int CacheDefaultTimeSeconds { get; set; } = 0; + + [JsonPropertyName("path_filter")] + public string? PathFilter { get; set; } + + } +} diff --git a/apps/VNLib.WebServer/src/Config/ServerConfigurationException.cs b/apps/VNLib.WebServer/src/Config/ServerConfigurationException.cs new file mode 100644 index 0000000..39c18e5 --- /dev/null +++ b/apps/VNLib.WebServer/src/Config/ServerConfigurationException.cs @@ -0,0 +1,40 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: ServerConfigurationException.cs +* +* ServerConfigurationException.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; + +namespace VNLib.WebServer.Config +{ + public class ServerConfigurationException : Exception + { + public ServerConfigurationException() + { } + + public ServerConfigurationException(string? message) : base(message) + { } + + public ServerConfigurationException(string? message, Exception? innerException) : base(message, innerException) + { } + } +} diff --git a/apps/VNLib.WebServer/src/Config/Validate.cs b/apps/VNLib.WebServer/src/Config/Validate.cs new file mode 100644 index 0000000..773d787 --- /dev/null +++ b/apps/VNLib.WebServer/src/Config/Validate.cs @@ -0,0 +1,113 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: Validate.cs +* +* Validate.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Net; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +using VNLib.Utils.IO; + +namespace VNLib.WebServer.Config +{ + internal static class Validate + { + [DoesNotReturn] + public static void EnsureNotNull<T>(T? obj, string message) where T : class + { + if (obj is null) + { + throw new ServerConfigurationException(message); + } + + if (obj is string s && string.IsNullOrWhiteSpace(s)) + { + throw new ServerConfigurationException(message); + } + } + + public static void Assert([DoesNotReturnIf(false)] bool condition, string message) + { + if (!condition) + { + throw new ServerConfigurationException(message); + } + } + + public static void EnsureValidIp(string? address, string message) + { + if (!IPAddress.TryParse(address, out _)) + { + throw new ServerConfigurationException(message); + } + } + + public static void EnsureNotEqual<T>(T a, T b, string message) + { + if (a is null || b is null) + { + throw new ServerConfigurationException(message); + } + + if (a.Equals(b)) + { + throw new ServerConfigurationException(message); + } + } + + public static void EnsureRangeEx(ulong value, ulong min, ulong max, string message) + { + if (value < min || value > max) + { + throw new ServerConfigurationException(message); + } + } + + public static void EnsureRangeEx(long value, long min, long max, string message) + { + if (value < min || value > max) + { + throw new ServerConfigurationException(message); + } + } + + public static void EnsureRange(ulong value, ulong min, ulong max, [CallerArgumentExpression(nameof(value))] string? paramName = null) + { + EnsureRangeEx(value, min, max, $"Value for {paramName} must be between {min} and {max}. Value: {value}"); + } + + public static void EnsureRange(long value, long min, long max, [CallerArgumentExpression(nameof(value))] string? paramName = null) + { + EnsureRangeEx(value, min, max, $"Value for {paramName} must be between {min} and {max}. Value: {value}"); + } + + public static void FileExists(string path) + { + if (!FileOperations.FileExists(path)) + { + throw new ServerConfigurationException($"Required file: {path} not found"); + } + } + } +} diff --git a/apps/VNLib.WebServer/src/Entry.cs b/apps/VNLib.WebServer/src/Entry.cs new file mode 100644 index 0000000..731e4f4 --- /dev/null +++ b/apps/VNLib.WebServer/src/Entry.cs @@ -0,0 +1,336 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: Entry.cs +* +* Entry.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Net.Sockets; +using System.Runtime.ExceptionServices; + +using VNLib.Utils.IO; +using VNLib.Utils.Memory; +using VNLib.Utils.Logging; +using VNLib.Hashing; +using VNLib.Hashing.Native.MonoCypher; + +using VNLib.WebServer.Config; +using VNLib.WebServer.Bootstrap; +using VNLib.WebServer.RuntimeLoading; + +namespace VNLib.WebServer +{ + + static class Entry + { + const string STARTUP_MESSAGE = +@"VNLib.Webserver - runtime host Copyright (C) Vaughn Nugent +This program comes with ABSOLUTELY NO WARRANTY. +Licensing for this software and other libraries can be found at https://www.vaughnnugent.com/resources/software +Starting... +"; + + private static readonly DirectoryInfo EXE_DIR = new(Environment.CurrentDirectory); + + private const string DEFAULT_CONFIG_PATH = "config.json"; + internal const string SESSION_TIMEOUT_PROP_NAME = "max_execution_time_ms"; + internal const string TCP_CONF_PROP_NAME = "tcp"; + internal const string LOAD_DEFAULT_HOSTNAME_VALUE = "[system]"; + internal const string PLUGINS_CONFIG_PROP_NAME = "plugins"; + + + static int Main(string[] args) + { + ProcessArguments procArgs = new(args); + + //Print the help menu + if (args.Length == 0 || procArgs.HasArgument("-h") || procArgs.HasArgument("--help")) + { + PrintHelpMenu(); + return 0; + } + + Console.WriteLine(STARTUP_MESSAGE); + + //Init log config builder + ServerLogBuilder logBuilder = new(); + logBuilder.BuildForConsole(procArgs); + + //try to load the json configuration file + IServerConfig? config = LoadConfig(procArgs); + if (config is null) + { + logBuilder.AppLogConfig.CreateLogger().Error("No configuration file was found"); + return -1; + } + + //Build logs from config + logBuilder.BuildFromConfig(config.GetDocumentRoot()); + + //Create the logger + using ServerLogger logger = logBuilder.GetLogger(); + + //Dump config to console + if (procArgs.HasArgument("--dump-config")) + { + DumpConfig(config.GetDocumentRoot(), logger); + } + + //Setup the app-domain listener + InitAppDomainListener(procArgs, logger.AppLog); + +#if !DEBUG + if (procArgs.LogHttp) + { + logger.AppLog.Warn("HTTP Logging is only enabled in builds compiled with DEBUG symbols"); + } +#endif + + if (procArgs.ZeroAllocations && !MemoryUtil.Shared.CreationFlags.HasFlag(HeapCreation.GlobalZero)) + { + logger.AppLog.Debug("Zero allocation flag was set, but the shared heap was not created with the GlobalZero flag, consider enabling zero allocations globally"); + } + + using WebserverBase server = GetWebserver(logger, config, procArgs); + + try + { + logger.AppLog.Information("Building service stack, populating service domain..."); + + server.Configure(); + } + catch (ServerConfigurationException sce) when (sce.InnerException is not null) + { + logger.AppLog.Fatal("Failed to configure server. Reason: {sce}", sce.InnerException.Message); + return -1; + } + catch (ServerConfigurationException sce) + { + logger.AppLog.Fatal("Failed to configure server. Reason: {sce}", sce.Message); + return -1; + } + catch (Exception ex) when (ex.InnerException is ServerConfigurationException sce) + { + logger.AppLog.Fatal("Failed to configure server. Reason: {sce}", sce.Message); + return -1; + } + catch (Exception ex) + { + logger.AppLog.Fatal(ex, "Failed to configure server"); + return -1; + } + + logger.AppLog.Verbose("Server configuration stage complete"); + + using ManualResetEvent ShutdownEvent = new(false); + + try + { + logger.AppLog.Information("Starting services..."); + + server.Start(); + + logger.AppLog.Information("Service stack started, servers are listening."); + + //Register console cancel to cause cleanup + Console.CancelKeyPress += (object? sender, ConsoleCancelEventArgs e) => + { + e.Cancel = true; + ShutdownEvent.Set(); + }; + + /* + * Optional background thread to listen for commands on stdin which + * can also request a server shutdown. + * + * The loop runs in a background thread and will not block the main thread + * The loop can request a server shutdown by setting the shutdown event + */ + + if (!procArgs.HasArgument("--input-off")) + { + CommandListener cmdLoop = new(ShutdownEvent, server, logger.AppLog); + + Thread consoleListener = new(() => cmdLoop.ListenForCommands(Console.In, Console.Out, name: "stdin")) + { + IsBackground = true + }; + + consoleListener.Start(); + } + + logger.AppLog.Information("Main thread waiting for exit signal, press ctrl + c to exit"); + + //Wait for user signal to exit + ShutdownEvent.WaitOne(); + + logger.AppLog.Information("Stopping service stack"); + + server.Stop(); + + //Wait for all plugins to unload and cleanup (temporary) + Thread.Sleep(500); + + return 0; + } + catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse) + { + logger.AppLog.Fatal("Failed to start servers, address already in use"); + return (int)se.SocketErrorCode; + } + catch (SocketException se) + { + logger.AppLog.Fatal(se, "Failed to start servers due to a socket exception"); + return (int)se.SocketErrorCode; + } + catch (Exception ex) + { + logger.AppLog.Fatal(ex, "Failed to start web servers"); + } + + return -1; + } + + static void PrintHelpMenu() + { + const string TEMPLATE = +@$" + VNLib.Webserver Copyright (C) 2024 Vaughn Nugent + + A high-performance, cross-platform, single process, reference webserver built on the .NET 8.0 Core runtime. + + Option flags: + --config <path> - Specifies the path to the configuration file (relative or absolute) + --input-off - Disables the STDIN listener, no runtime commands will be processed + --inline-scheduler - Enables inline scheduling for TCP transport IO processing (not available when using TLS) + --no-plugins - Disables loading of dynamic plugins + --log-http - Enables logging of HTTP request and response headers to the system logger (debug builds only) + --log-transport - Enables logging of transport events to the system logger (debug builds only) + --dump-config - Dumps the JSON configuration to the console during loading + --compression-off - Disables dynamic response compression + --zero-alloc - Forces all http/tcp memory pool allocations to be zeroed before use (reduced performance) + --sequential-load - Loads all plugins sequentially (default is concurrently) + --no-reuse-socket - Disables socket reuse for TCP connections (Windows only) + --reuse-address - Enables address reuse for TCP connections + -h, --help - Prints this help menu + -t, --threads <num> - Specifies the number of socket accept threads. Defaults to processor count + -s, --silent - Disables all console logging + -v, --verbose - Enables verbose logging + -d, --debug - Enables debug logging for the process and all plugins + -vv - Enables very verbose logging (attaches listeners for app-domain events and logs them to the output) + + Your configuration file must be a JSON or YAML encoded file and be readable to the process. You may consider keeping it in a safe + location outside the application and only readable to this process. + + You should disable hot-reload for production environments, for security and performance reasons. + + You may consider using the --input-off flag to disable STDIN listening for production environments for security reasons. + + Optional environment variables: + {MemoryUtil.SHARED_HEAP_FILE_PATH} - Specifies the path to the native heap allocator library + {MemoryUtil.SHARED_HEAP_ENABLE_DIAGNOISTICS_ENV} - Enables heap diagnostics for the shared heap 1 = enabled, 0 = disabled + {MemoryUtil.SHARED_HEAP_GLOBAL_ZERO} - Enables zeroing of all allocations from the shared heap 1 = enabled, 0 = disabled + {MemoryUtil.SHARED_HEAP_RAW_FLAGS} - Raw flags to pass to the shared heap allocator's HeapCreate function, hexadeciaml encoded + {VnArgon2.ARGON2_LIB_ENVIRONMENT_VAR_NAME} - Specifies the path to the Argon2 native library + {MonoCypherLibrary.MONOCYPHER_LIB_ENVIRONMENT_VAR_NAME} - Specifies the path to the Monocypher native library + + Usage: + VNLib.Webserver --config <path> ... (other options) #Starts the server from the configuration (basic usage) + +"; + Console.WriteLine(TEMPLATE); + } + + #region config + + /// <summary> + /// Initializes the configuration DOM from the specified cmd args + /// or the default configuration path + /// </summary> + /// <param name="args">The command-line-arguments</param> + /// <returns>A new <see cref="JsonDocument"/> that contains the application configuration</returns> + private static IServerConfig? LoadConfig(ProcessArguments args) + { + //Get the config path or default config + string configPath = args.GetArgument("--config") ?? Path.Combine(EXE_DIR.FullName, DEFAULT_CONFIG_PATH); + + return JsonServerConfig.FromFile(configPath); + } + + private static WebserverBase GetWebserver(ServerLogger logger, IServerConfig config, ProcessArguments procArgs) + { + logger.AppLog.Information("Configuring production webserver"); + return new ReleaseWebserver(logger, config, procArgs); + } + + private static void DumpConfig(JsonElement doc, ServerLogger logger) + { + //Dump the config to the console + using VnMemoryStream ms = new(); + using (Utf8JsonWriter writer = new(ms, new() { Indented = true })) + { + doc.WriteTo(writer); + } + + string json = Encoding.UTF8.GetString(ms.AsSpan()); + logger.AppLog.Information("Dumping configuration to console...\n{c}", json); + } + + #endregion + + private static void InitAppDomainListener(ProcessArguments args, ILogProvider log) + { + AppDomain currentDomain = AppDomain.CurrentDomain; + currentDomain.UnhandledException += delegate (object sender, UnhandledExceptionEventArgs e) + { + log.Fatal("UNHANDLED APPDOMAIN EXCEPTION \n {e}", e); + }; + //If double verbose is specified, log app-domain messages + if (args.DoubleVerbose) + { + log.Verbose("Double verbose mode enabled, registering app-domain listeners"); + + currentDomain.FirstChanceException += delegate (object? sender, FirstChanceExceptionEventArgs e) + { + log.Verbose(e.Exception, "Exception occured in app-domain "); + }; + currentDomain.AssemblyLoad += delegate (object? sender, AssemblyLoadEventArgs args) + { + log.Verbose( + "Assembly loaded {asm} to appdomain {domain} from\n{location}", + args.LoadedAssembly.FullName, + currentDomain.FriendlyName, + args.LoadedAssembly.Location + ); + }; + currentDomain.DomainUnload += delegate (object? sender, EventArgs e) + { + log.Verbose("Domain {domain} unloaded", currentDomain.FriendlyName); + }; + } + } + + } +} diff --git a/apps/VNLib.WebServer/src/MemoryPoolManager.cs b/apps/VNLib.WebServer/src/MemoryPoolManager.cs new file mode 100644 index 0000000..577bdb2 --- /dev/null +++ b/apps/VNLib.WebServer/src/MemoryPoolManager.cs @@ -0,0 +1,137 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: MemoryPoolManager.cs +* +* MemoryPoolManager.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Buffers; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +using VNLib.Utils.Memory; +using VNLib.Net.Http; + +namespace VNLib.WebServer +{ + /// <summary> + /// recovers a memory pool for the TCP server to alloc buffers from + /// </summary> + internal static class MemoryPoolManager + { + /// <summary> + /// Gets an unmanaged memory pool provider for the TCP server to alloc buffers from + /// </summary> + /// <returns>The memory pool</returns> + public static MemoryPool<byte> GetTcpPool(bool zeroOnAlloc) => new HttpMemoryPool(zeroOnAlloc); + + /// <summary> + /// Gets a memory pool provider for the HTTP server to alloc buffers from + /// </summary> + /// <returns>The http server memory pool</returns> + public static IHttpMemoryPool GetHttpPool(bool zeroOnAlloc) => new HttpMemoryPool(zeroOnAlloc); + + /* + * Fun little umnanaged memory pool that allows for allocating blocks + * with fast pointer access and zero cost pinning + * + * All blocks are allocated to the nearest page size + */ + + internal sealed class HttpMemoryPool(bool zeroOnAlloc) : MemoryPool<byte>, IHttpMemoryPool + { + //Avoid the shared getter on every alloc call + private readonly IUnmangedHeap _heap = MemoryUtil.Shared; + + ///<inheritdoc/> + public override int MaxBufferSize { get; } = int.MaxValue; + + ///<inheritdoc/> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public IMemoryOwner<byte> AllocateBufferForContext(int bufferSize) => Rent(bufferSize); + + ///<inheritdoc/> + public IResizeableMemoryHandle<T> AllocFormDataBuffer<T>(int initialSize) where T : unmanaged + { + return MemoryUtil.SafeAllocNearestPage<T>(_heap, initialSize, zeroOnAlloc); + } + + ///<inheritdoc/> + public override IMemoryOwner<byte> Rent(int minBufferSize = -1) + { + nint initSize = MemoryUtil.NearestPage(minBufferSize); + return new UnsafeMemoryManager(_heap, (nuint)initSize, zeroOnAlloc); + } + + ///<inheritdoc/> + protected override void Dispose(bool disposing) + { } + + sealed class UnsafeMemoryManager(IUnmangedHeap heap, nuint bufferSize, bool zero) : MemoryManager<byte> + { + + private nint _pointer = heap.Alloc(bufferSize, sizeof(byte), zero); + private int _size = (int)bufferSize; + + ///<inheritdoc/> + public override Span<byte> GetSpan() + { + Debug.Assert(_pointer != nint.Zero, "Pointer to memory block is null, was not allocated properly or was released"); + + return MemoryUtil.GetSpan<byte>(_pointer, _size); + } + + ///<inheritdoc/> + public override MemoryHandle Pin(int elementIndex = 0) + { + //Guard + ArgumentOutOfRangeException.ThrowIfNegative(elementIndex); + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(elementIndex, _size); + + Debug.Assert(_pointer != nint.Zero, "Pointer to memory block is null, was not allocated properly or was released"); + + //Get pointer offset from index + nint offset = nint.Add(_pointer, elementIndex); + + //Return handle at offser + return MemoryUtil.GetMemoryHandleFromPointer(offset, pinnable: this); + } + + //No-op + public override void Unpin() + { } + + protected override void Dispose(bool disposing) + { + Debug.Assert(_pointer != nint.Zero, "Pointer to memory block is null, was not allocated properly"); + + bool freed = heap.Free(ref _pointer); + + //Free the memory, should also zero the pointer + Debug.Assert(freed, "Failed to free an allocated block"); + + //Set size to 0 + _size = 0; + } + } + } + } +} diff --git a/apps/VNLib.WebServer/src/Middlewares/BenchmarkMiddleware.cs b/apps/VNLib.WebServer/src/Middlewares/BenchmarkMiddleware.cs new file mode 100644 index 0000000..2e2020c --- /dev/null +++ b/apps/VNLib.WebServer/src/Middlewares/BenchmarkMiddleware.cs @@ -0,0 +1,102 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: BenchmarkMiddleware.cs +* +* BenchmarkMiddleware.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + + +using System; +using System.Net; +using System.Buffers; +using System.Diagnostics; +using System.Threading.Tasks; +using System.Security.Cryptography; + +using VNLib.Utils.Memory; +using VNLib.Utils.Extensions; +using VNLib.Net.Http; +using VNLib.Plugins.Essentials; +using VNLib.Plugins.Essentials.Middleware; + +using VNLib.WebServer.Config.Model; + +namespace VNLib.WebServer.Middlewares +{ + /* + * This is a cheatsy little syntethic benchmark middleware that will + * return a fixed size memory response for every request to simulate + * a file response for synthetic benchmarking. + * + * The buffer may optionally be filled with random data to put a + * load on the compressor instead of a zero filled buffer + */ + + internal sealed class BenchmarkMiddleware(BenchmarkConfig config) : IHttpMiddleware + { + private readonly MemoryManager<byte> data = AllocBuffer(config.Size, config.Random); + + public ValueTask<FileProcessArgs> ProcessAsync(HttpEntity entity) + { + entity.CloseResponse( + HttpStatusCode.OK, + ContentType.Binary, + new BenchmarkResponseData(data.Memory) + ); + + return ValueTask.FromResult(FileProcessArgs.VirtualSkip); + } + + private static MemoryManager<byte> AllocBuffer(int size, bool random) + { + /* + * Even though this is testing, the buffer is zeroed to avoid leaking + * any data that may be in heap memory after allocation. + */ + MemoryManager<byte> man = MemoryUtil.Shared.DirectAlloc<byte>(size, true); + + if (random) + { + RandomNumberGenerator.Fill(man.GetSpan()); + } + + return man; + } + + private sealed class BenchmarkResponseData(ReadOnlyMemory<byte> data) : IMemoryResponseReader + { + int read; + readonly int len = data.Length; + + public int Remaining => len - read; + + public void Advance(int written) + { + read += written; + Debug.Assert(Remaining >= 0); + } + + public void Close() + { } + + public ReadOnlyMemory<byte> GetMemory() => data.Slice(read, Remaining); + } + } +}
\ No newline at end of file diff --git a/apps/VNLib.WebServer/src/Middlewares/CORSMiddleware.cs b/apps/VNLib.WebServer/src/Middlewares/CORSMiddleware.cs new file mode 100644 index 0000000..08b7cee --- /dev/null +++ b/apps/VNLib.WebServer/src/Middlewares/CORSMiddleware.cs @@ -0,0 +1,182 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: CORSMiddleware.cs +* +* CORSMiddleware.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Net; +using System.Threading.Tasks; +using System.Collections.Frozen; + +using VNLib.Net.Http; +using VNLib.Utils.Logging; +using VNLib.Plugins.Essentials; +using VNLib.Plugins.Essentials.Sessions; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Essentials.Middleware; + +using VNLib.WebServer.Config.Model; + +namespace VNLib.WebServer.Middlewares +{ + + /// <summary> + /// Adds HTTP CORS protection to http servers + /// </summary> + /// <param name="Log"></param> + /// <param name="VirtualHostOptions"></param> + [MiddlewareImpl(MiddlewareImplOptions.SecurityCritical)] + internal sealed class CORSMiddleware(ILogProvider Log, CorsSecurityConfig secConfig) : IHttpMiddleware + { + private readonly FrozenSet<string> _corsAuthority = secConfig.AllowedCorsAuthority.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + public ValueTask<FileProcessArgs> ProcessAsync(HttpEntity entity) + { + //Check coors enabled + bool isCors = entity.Server.IsCors(); + bool isCrossSite = entity.Server.IsCrossSite(); + + /* + * Deny/allow cross site/cors requests at the site-level + */ + if (!secConfig.DenyCorsCons) + { + //Confirm the origin is allowed during cors connections + if (entity.Server.CrossOrigin && _corsAuthority.Count > 0) + { + //If the authority is not allowed, deny the connection + if (!_corsAuthority.Contains(entity.Server.Origin!.Authority)) + { + Log.Debug("Denied a connection from a cross-origin site {s}, the origin was not whitelisted", entity.Server.Origin); + return ValueTask.FromResult(FileProcessArgs.Deny); + } + } + + if (isCors) + { + //set the allow credentials header + entity.Server.Headers["Access-Control-Allow-Credentials"] = "true"; + + //If cross site flag is set, or the connection has cross origin flag set, set explicit origin + if (entity.Server.CrossOrigin || isCrossSite && entity.Server.Origin != null) + { + entity.Server.Headers["Access-Control-Allow-Origin"] = $"{entity.Server.RequestUri.Scheme}://{entity.Server.Origin!.Authority}"; + //Add origin to the response vary header when setting cors origin + entity.Server.Headers.Append(HttpResponseHeader.Vary, "Origin"); + } + } + + //Add sec vary headers for cors enabled sites + entity.Server.Headers.Append(HttpResponseHeader.Vary, "Sec-Fetch-Dest,Sec-Fetch-Mode,Sec-Fetch-Site"); + } + else if (isCors | isCrossSite) + { + Log.Verbose("Denied a cross-site/cors request from {con} because this site does not allow cross-site/cors requests", entity.TrustedRemoteIp); + return ValueTask.FromResult(FileProcessArgs.Deny); + } + + //If user-navigation is set and method is get, make sure it does not contain object/embed + if (entity.Server.IsNavigation() && entity.Server.Method == HttpMethod.GET) + { + string? dest = entity.Server.Headers["sec-fetch-dest"]; + if (dest != null && (dest.Contains("object", StringComparison.OrdinalIgnoreCase) || dest.Contains("embed", StringComparison.OrdinalIgnoreCase))) + { + Log.Debug("Denied a browser navigation request from {con} because it contained an object/embed", entity.TrustedRemoteIp); + return ValueTask.FromResult(FileProcessArgs.Deny); + } + } + + //If the connection is a cross-site, then an origin header must be supplied + if (isCrossSite && entity.Server.Origin is null) + { + Log.Debug("Denied cross-site request because origin header was not supplied"); + return ValueTask.FromResult(FileProcessArgs.Deny); + } + + //If same origin is supplied, enforce origin header on post/options/put/patch + if (string.Equals("same-origin", entity.Server.Headers["Sec-Fetch-Site"], StringComparison.OrdinalIgnoreCase)) + { + //If method is not get/head, then origin is required + if ((entity.Server.Method & (HttpMethod.GET | HttpMethod.HEAD)) == 0 && entity.Server.Origin == null) + { + Log.Debug("Denied same-origin POST/PUT... request because origin header was not supplied"); + return ValueTask.FromResult(FileProcessArgs.Deny); + } + } + + if(!IsSessionSecured(entity)) + { + return ValueTask.FromResult(FileProcessArgs.Deny); + } + + return ValueTask.FromResult(FileProcessArgs.Continue); + } + + private bool IsSessionSecured(HttpEntity entity) + { + ref readonly SessionInfo session = ref entity.Session; + + /* + * When sessions are created for connections that come from a different + * origin, their origin is stored for later. + * + * If the session was created from a different origin or the current connection + * is cross origin, then the origin must be allowed by the configuration + */ + + //No session loaded, nothing to check + if (!session.IsSet) + { + return true; + } + + if (entity.Server.Origin is null) + { + return true; + } + + if (session.IsNew || session.SessionType != SessionType.Web) + { + return true; + } + + bool sameOrigin = string.Equals( + entity.Server.Origin.Authority, + session.SpecifiedOrigin?.Authority, + StringComparison.OrdinalIgnoreCase + ); + + if (sameOrigin || _corsAuthority.Contains(entity.Server.Origin.Authority)) + { + return true; + } + + Log.Debug("Denied connection from {0} because the user's origin {org} changed to {other} and is not whitelisted.", + entity.TrustedRemoteIp, + session.SpecifiedOrigin?.Authority, + entity.Server.Origin.Authority + ); + + return false; + } + } +}
\ No newline at end of file diff --git a/apps/VNLib.WebServer/src/Middlewares/ConnectionLogMiddleware.cs b/apps/VNLib.WebServer/src/Middlewares/ConnectionLogMiddleware.cs new file mode 100644 index 0000000..939fd0d --- /dev/null +++ b/apps/VNLib.WebServer/src/Middlewares/ConnectionLogMiddleware.cs @@ -0,0 +1,108 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: ConnectionLogMiddleware.cs +* +* ConnectionLogMiddleware.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +/* + * Provides an Nginx-style log of incoming connections. + */ + +using System.Threading.Tasks; +using System.Security.Authentication; + +using VNLib.Net.Http; +using VNLib.Utils.Logging; +using VNLib.Plugins.Essentials; +using VNLib.Plugins.Essentials.Middleware; + +namespace VNLib.WebServer.Middlewares +{ + internal sealed class ConnectionLogMiddleware(ILogProvider Log) : IHttpMiddleware + { + const string template = @"{ip} - {usr} [{local_time}] {tls} '{method} {url} {http_version}' {hostname} '{refer}' '{user_agent}' '{forwarded_for}'"; + + ///<inheritdoc/> + public ValueTask<FileProcessArgs> ProcessAsync(HttpEntity entity) + { + if (Log.IsEnabled(LogLevel.Information)) + { + string userId = string.Empty; + if (entity.Session.IsSet) + { + userId = entity.Session.UserID; + } + + Log.Information(template, + entity.TrustedRemoteIp, + userId, + entity.RequestedTimeUtc.ToLocalTime().ToString("dd/MMM/yyyy:HH:mm:ss zzz", null), + GetTlsInfo(entity), + entity.Server.Method, + entity.Server.RequestUri.PathAndQuery, + GetProtocolVersionString(entity), + entity.RequestedRoot.Hostname, + entity.Server.Referer, + entity.Server.UserAgent, + entity.Server.Headers["X-Forwarded-For"] ?? string.Empty + ); + } + + return ValueTask.FromResult(FileProcessArgs.Continue); + } + + static string GetProtocolVersionString(HttpEntity entity) + { + return entity.Server.ProtocolVersion switch + { + HttpVersion.Http09 => "HTTP/0.9", + HttpVersion.Http1 => "HTTP/1.0", + HttpVersion.Http11 => "HTTP/1.1", + HttpVersion.Http2 => "HTTP/2.0", + HttpVersion.Http3 => "HTTP/3.0", + _ => "HTTP/1.1" + }; + } + + static string GetTlsInfo(HttpEntity entity) + { + ref readonly TransportSecurityInfo? secInfo = ref entity.Server.GetTransportSecurityInfo(); + + if(!secInfo.HasValue) + { + return string.Empty; + } + +#pragma warning disable CA5398, CA5397, SYSLIB0039 // Avoid hardcoded SslProtocols values + + return secInfo.Value.SslProtocol switch + { + SslProtocols.Tls => "TLSv1.0", + SslProtocols.Tls11 => "TLSv1.1", + SslProtocols.Tls12 => "TLSv1.2", + SslProtocols.Tls13 => "TLSv1.3", + _ => "Unknown" + }; + +#pragma warning restore CA5397, CA5398, SYSLIB0039 // Do not use deprecated SslProtocols values + } + } +}
\ No newline at end of file diff --git a/apps/VNLib.WebServer/src/Middlewares/IpBlacklistMiddleware.cs b/apps/VNLib.WebServer/src/Middlewares/IpBlacklistMiddleware.cs new file mode 100644 index 0000000..5959a95 --- /dev/null +++ b/apps/VNLib.WebServer/src/Middlewares/IpBlacklistMiddleware.cs @@ -0,0 +1,49 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: WhitelistMiddleware.cs +* +* WhitelistMiddleware.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Net; +using System.Threading.Tasks; +using System.Collections.Frozen; + +using VNLib.Utils.Logging; +using VNLib.Plugins.Essentials; +using VNLib.Plugins.Essentials.Middleware; + +namespace VNLib.WebServer.Middlewares +{ + [MiddlewareImpl(MiddlewareImplOptions.SecurityCritical)] + internal sealed class IpBlacklistMiddleware(ILogProvider Log, FrozenSet<IPAddress> Blacklist) : IHttpMiddleware + { + public ValueTask<FileProcessArgs> ProcessAsync(HttpEntity entity) + { + if (Blacklist.Contains(entity.TrustedRemoteIp)) + { + Log.Verbose("Client {ip} is blacklisted, blocked", entity.TrustedRemoteIp); + return ValueTask.FromResult(FileProcessArgs.Deny); + } + + return ValueTask.FromResult(FileProcessArgs.Continue); + } + } +}
\ No newline at end of file diff --git a/apps/VNLib.WebServer/src/Middlewares/IpWhitelistMiddleware.cs b/apps/VNLib.WebServer/src/Middlewares/IpWhitelistMiddleware.cs new file mode 100644 index 0000000..bda754d --- /dev/null +++ b/apps/VNLib.WebServer/src/Middlewares/IpWhitelistMiddleware.cs @@ -0,0 +1,53 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: WhitelistMiddleware.cs +* +* WhitelistMiddleware.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Net; +using System.Threading.Tasks; +using System.Collections.Frozen; + +using VNLib.Utils.Logging; +using VNLib.Plugins.Essentials; +using VNLib.Plugins.Essentials.Middleware; + +namespace VNLib.WebServer.Middlewares +{ + /* + * Middelware that matches clients real ip addresses against a whitelist + * and blocks them if they are not on the list + */ + [MiddlewareImpl(MiddlewareImplOptions.SecurityCritical)] + internal sealed class IpWhitelistMiddleware(ILogProvider Log, FrozenSet<IPAddress> WhiteList) : IHttpMiddleware + { + public ValueTask<FileProcessArgs> ProcessAsync(HttpEntity entity) + { + if (!WhiteList.Contains(entity.TrustedRemoteIp)) + { + Log.Verbose("Client {ip} is not whitelisted, blocked", entity.TrustedRemoteIp); + return ValueTask.FromResult(FileProcessArgs.Deny); + } + + return ValueTask.FromResult(FileProcessArgs.Continue); + } + } +}
\ No newline at end of file diff --git a/apps/VNLib.WebServer/src/Middlewares/MainServerMiddlware.cs b/apps/VNLib.WebServer/src/Middlewares/MainServerMiddlware.cs new file mode 100644 index 0000000..da7eb3d --- /dev/null +++ b/apps/VNLib.WebServer/src/Middlewares/MainServerMiddlware.cs @@ -0,0 +1,100 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: MainServerMiddlware.cs +* +* MainServerMiddlware.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Net; +using System.Threading.Tasks; +using System.Collections.Generic; + +using VNLib.Net.Http; +using VNLib.Utils.Logging; +using VNLib.Plugins.Essentials; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Essentials.Middleware; + +namespace VNLib.WebServer.Middlewares +{ + + /// <summary> + /// Provides required/essential server functionality as a middelware processor + /// </summary> + /// <param name="Log"></param> + /// <param name="VirtualHostOptions"></param> + internal sealed class MainServerMiddlware(ILogProvider Log, VirtualHostConfig VirtualHostOptions, bool forcePorts) : IHttpMiddleware + { + public ValueTask<FileProcessArgs> ProcessAsync(HttpEntity entity) + { + //Set special server header + VirtualHostOptions.TrySetSpecialHeader(entity.Server, SpecialHeaders.Server); + + //Block websocket requests + if (entity.Server.IsWebSocketRequest) + { + Log.Verbose("Client {ip} made a websocket request", entity.TrustedRemoteIp); + } + + ref readonly TransportSecurityInfo? tlsSecInfo = ref entity.Server.GetTransportSecurityInfo(); + + //Check transport security if set + if (tlsSecInfo.HasValue) + { + + } + + //If not behind upstream server, uri ports and server ports must match + bool enforcePortCheck = !entity.IsBehindDownStreamServer && forcePorts; + + if (enforcePortCheck && !entity.Server.EnpointPortsMatch()) + { + Log.Debug("Connection {ip} received on port {p} but the client host port did not match at {pp}", + entity.TrustedRemoteIp, + entity.Server.LocalEndpoint.Port, + entity.Server.RequestUri.Port + ); + + return ValueTask.FromResult(FileProcessArgs.Deny); + } + + /* + * downstream server will handle the transport security, + * if the connection is not from an downstream server + * and is using transport security then we can specify HSTS + */ + if (entity.IsSecure) + { + VirtualHostOptions.TrySetSpecialHeader(entity.Server, SpecialHeaders.Hsts); + } + + //Add response headers from vh config + for (int i = 0; i < VirtualHostOptions.AdditionalHeaders.Length; i++) + { + //Get and append the client header value + ref KeyValuePair<string, string> header = ref VirtualHostOptions.AdditionalHeaders[i]; + + entity.Server.Headers.Append(header.Key, header.Value); + } + + return ValueTask.FromResult(FileProcessArgs.Continue); + } + } +}
\ No newline at end of file diff --git a/apps/VNLib.WebServer/src/Plugins/PluginAssemblyLoader.cs b/apps/VNLib.WebServer/src/Plugins/PluginAssemblyLoader.cs new file mode 100644 index 0000000..b49c782 --- /dev/null +++ b/apps/VNLib.WebServer/src/Plugins/PluginAssemblyLoader.cs @@ -0,0 +1,125 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: PluginAssemblyLoader.cs +* +* PluginAssemblyLoader.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Diagnostics; +using System.Reflection; +using System.Runtime.Loader; + +#if USE_MCMASTER +using McMaster.NETCore.Plugins; +#endif + +using VNLib.Plugins.Runtime; +using VNLib.Utils.Resources; + +namespace VNLib.WebServer.Plugins +{ + + internal static class PluginAsemblyLoading + { + public static IAssemblyLoader Create(IPluginAssemblyLoadConfig config) + { + return config.Unloadable ? new UnloadableAlc(config) : new ImmutableAl(config); + } + + //Immutable assembly loader + internal sealed record class ImmutableAl(IPluginAssemblyLoadConfig Config) : IAssemblyLoader + { + private readonly AssemblyLoadContext ctx = new(Config.AssemblyFile, Config.Unloadable); + private ManagedLibrary ml = null!; + + ///<inheritdoc/> + public Assembly GetAssembly() => ml.Assembly; + + ///<inheritdoc/> + public void Load() => ml = ManagedLibrary.LoadManagedAssembly(Config.AssemblyFile, ctx); + + ///<inheritdoc/> + public void Unload() => Debug.Fail("Unload was called on an immutable assembly loader"); + + public void Dispose() { } + } + + internal sealed record class UnloadableAlc(IPluginAssemblyLoadConfig Config) : IAssemblyLoader + { + +#if USE_MCMASTER + private readonly PluginLoader _loader = new(new(Config.AssemblyFile) + { + PreferSharedTypes = true, + IsUnloadable = Config.Unloadable, + LoadInMemory = Config.Unloadable + }); + + ///<inheritdoc/> + public Assembly GetAssembly() => _loader.LoadDefaultAssembly(); + + ///<inheritdoc/> + public void Load() => _loader.Load(); + + ///<inheritdoc/> + public void Unload() + { + if (Config.Unloadable) + { + //Cleanup old loader, dont invoke GC because runtime will handle it + _loader.Destroy(false); + //ctx.Unload(); + //ml = null!; + + //Init new load context with the same name + //ctx = new AssemblyLoadContext(Config.AssemblyFile, Config.Unloadable); + } + } + + public void Dispose() => Unload(); +#else + + private AssemblyLoadContext ctx = null!; + private ManagedLibrary ml = null!; + + public void Dispose() => Unload(); + + public Assembly GetAssembly() => ml.Assembly; + + public void Load() + { + Debug.Assert(Config.Unloadable, "Assumed unloadable context when using UnloadableAlc"); + + //A new load context is created for each load + ctx = new(Config.AssemblyFile, Config.Unloadable); + ml = ManagedLibrary.LoadManagedAssembly(Config.AssemblyFile, ctx); + } + + public void Unload() + { + ctx.Unload(); + ml = null!; + } +#endif + + } + } +} diff --git a/apps/VNLib.WebServer/src/RuntimeLoading/ProcessArguments.cs b/apps/VNLib.WebServer/src/RuntimeLoading/ProcessArguments.cs new file mode 100644 index 0000000..b6db2e6 --- /dev/null +++ b/apps/VNLib.WebServer/src/RuntimeLoading/ProcessArguments.cs @@ -0,0 +1,40 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: ProcessArguments.cs +* +* ProcessArguments.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Collections.Generic; + +using VNLib.Utils; + +namespace VNLib.WebServer.RuntimeLoading +{ + internal sealed class ProcessArguments(IEnumerable<string> args) : ArgumentList(args) + { + public bool Verbose => HasArgument("-v") || HasArgument("--verbose"); + public bool Debug => HasArgument("-d") || HasArgument("--debug"); + public bool Silent => HasArgument("-s") || HasArgument("--silent"); + public bool DoubleVerbose => Verbose && HasArgument("-vv"); + public bool LogHttp => HasArgument("--log-http"); + public bool ZeroAllocations => HasArgument("--zero-alloc"); + } +} diff --git a/apps/VNLib.WebServer/src/RuntimeLoading/ServerLogBuilder.cs b/apps/VNLib.WebServer/src/RuntimeLoading/ServerLogBuilder.cs new file mode 100644 index 0000000..4c55258 --- /dev/null +++ b/apps/VNLib.WebServer/src/RuntimeLoading/ServerLogBuilder.cs @@ -0,0 +1,154 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: ServerLogBuilder.cs +* +* ServerLogBuilder.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Collections.Generic; + +using Serilog; + +using VNLib.Utils.Extensions; + +namespace VNLib.WebServer.RuntimeLoading +{ + internal sealed class ServerLogBuilder + { + public LoggerConfiguration SysLogConfig { get; } + public LoggerConfiguration AppLogConfig { get; } + public LoggerConfiguration? DebugConfig { get; } + + public ServerLogBuilder() + { + AppLogConfig = new(); + SysLogConfig = new(); + } + + public ServerLogBuilder BuildForConsole(ProcessArguments args) + { + InitConsoleLog(args, AppLogConfig, "Application"); + InitConsoleLog(args, SysLogConfig, "System"); + return this; + } + + public ServerLogBuilder BuildFromConfig(JsonElement logEl) + { + InitSingleLog(logEl, "app_log", "Application", AppLogConfig); + InitSingleLog(logEl, "sys_log", "System", SysLogConfig); + return this; + } + + public ServerLogger GetLogger() + { + //Return logger + return new ( + new(AppLogConfig), + new(SysLogConfig), + DebugConfig == null ? null : new(DebugConfig) + ); + } + + private static void InitConsoleLog(ProcessArguments args, LoggerConfiguration conf, string logName) + { + //Set verbosity level, defaul to informational + if (args.Verbose) + { + conf.MinimumLevel.Verbose(); + } + else if (args.Debug) + { + conf.MinimumLevel.Debug(); + } + else + { + conf.MinimumLevel.Information(); + } + + //Setup loggers to write to console unless the -s silent arg is set + if (!args.Silent) + { + string template = $"{{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{Level:u3}}] {logName} {{Message:lj}}{{NewLine}}{{Exception}}"; + _ = conf.WriteTo.Console(outputTemplate: template); + } + } + + private static void InitSingleLog(JsonElement el, string elPath, string logName, LoggerConfiguration logConfig) + { + string? filePath = null; + string? template = null; + + TimeSpan flushInterval = TimeSpan.FromSeconds(10); + int retainedLogs = 31; + //Default to 500mb log file size + int fileSizeLimit = 500 * 1000 * 1024; + RollingInterval interval = RollingInterval.Infinite; + + //try to get the log config object + if (el.TryGetProperty(elPath, out JsonElement logEl)) + { + IReadOnlyDictionary<string, JsonElement> conf = logEl.EnumerateObject().ToDictionary(static s => s.Name, static s => s.Value); + + filePath = conf.GetPropString("path"); + template = conf.GetPropString("template"); + + if (conf.TryGetValue("flush_sec", out JsonElement flushEl)) + { + flushInterval = flushEl.GetTimeSpan(TimeParseType.Seconds); + } + + if (conf.TryGetValue("retained_files", out JsonElement retainedEl)) + { + retainedLogs = retainedEl.GetInt32(); + } + + if (conf.TryGetValue("file_size_limit", out JsonElement sizeEl)) + { + fileSizeLimit = sizeEl.GetInt32(); + } + + if (conf.TryGetValue("interval", out JsonElement intervalEl)) + { + interval = Enum.Parse<RollingInterval>(intervalEl.GetString()!, true); + } + + //Set default objects + filePath ??= Path.Combine(Environment.CurrentDirectory, $"{elPath}.txt"); + template ??= $"{{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{Level:u3}}] {logName} {{Message:lj}}{{NewLine}}{{Exception}}"; + + //Configure the log file writer + logConfig.WriteTo.File(filePath, + buffered: true, + retainedFileCountLimit: retainedLogs, + formatProvider:null, + fileSizeLimitBytes: fileSizeLimit, + rollingInterval: interval, + outputTemplate: template, + flushToDiskInterval: flushInterval); + } + + //If the log element is not specified in config, do not write log files + } + } +} diff --git a/apps/VNLib.WebServer/src/RuntimeLoading/ServerLogger.cs b/apps/VNLib.WebServer/src/RuntimeLoading/ServerLogger.cs new file mode 100644 index 0000000..7b93bfe --- /dev/null +++ b/apps/VNLib.WebServer/src/RuntimeLoading/ServerLogger.cs @@ -0,0 +1,45 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: ServerLogger.cs +* +* ServerLogger.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using VNLib.Utils; + +namespace VNLib.WebServer.RuntimeLoading +{ + internal sealed class ServerLogger(VLogProvider applog, VLogProvider syslog, VLogProvider? debuglog) : VnDisposeable + { + + public VLogProvider AppLog { get; } = applog; + + public VLogProvider SysLog { get; } = syslog; + + public VLogProvider? DebugLog { get; } = debuglog; + + protected override void Free() + { + AppLog.Dispose(); + SysLog.Dispose(); + DebugLog?.Dispose(); + } + } +} diff --git a/apps/VNLib.WebServer/src/Transport/HostAwareServerSslOptions.cs b/apps/VNLib.WebServer/src/Transport/HostAwareServerSslOptions.cs new file mode 100644 index 0000000..ed97830 --- /dev/null +++ b/apps/VNLib.WebServer/src/Transport/HostAwareServerSslOptions.cs @@ -0,0 +1,108 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: HostAwareServerSslOptions.cs +* +* HostAwareServerSslOptions.cs is part of VNLib.WebServer which is part +* of the larger VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Net.Security; +using System.Collections.Generic; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; + +using VNLib.WebServer.Config; +using VNLib.WebServer.Config.Model; + +namespace VNLib.WebServer.Transport +{ + + internal sealed class HostAwareServerSslOptions : SslServerAuthenticationOptions + { + //TODO programatically setup ssl protocols, but for now we only use HTTP/1.1 so this can be hard-coded + internal static readonly List<SslApplicationProtocol> SslAppProtocols = new() + { + SslApplicationProtocol.Http11, + //SslApplicationProtocol.Http2, + }; + + private readonly bool _clientCertRequired; + private readonly X509Certificate _cert; + + private readonly SslPolicyErrors _errorLevel; + + public HostAwareServerSslOptions(TransportInterface iFace) + { + ArgumentNullException.ThrowIfNull(iFace); + Validate.Assert(iFace.Ssl, "An interface was selected that does not have SSL enabled. This is likely a bug"); + + _clientCertRequired = iFace.ClientCertRequired; + _cert = iFace.LoadCertificate()!; + + /* + * If client certificates are required, then no policy errors are allowed + * and the certificate must be requested from the user. Otherwise, no errors + * except missing certificates are allowed + */ + _errorLevel = _clientCertRequired + ? SslPolicyErrors.None + : SslPolicyErrors.RemoteCertificateNotAvailable; + + //Set validation callback + RemoteCertificateValidationCallback = OnRemoteCertVerification; + ServerCertificateSelectionCallback = OnGetCertificatForHost; + + ConfigureBaseDefaults(iFace.UseOsCiphers); + } + + private void ConfigureBaseDefaults(bool doNotForceProtocols) + { + //Eventually when HTTP2 is supported, we can select the ssl version to match + ApplicationProtocols = SslAppProtocols; + + AllowRenegotiation = false; + EncryptionPolicy = EncryptionPolicy.RequireEncryption; + + //Allow user to disable forced protocols and let the os decide + EnabledSslProtocols = doNotForceProtocols + ? SslProtocols.None + : SslProtocols.Tls12 | SslProtocols.Tls13; + } + + private bool OnRemoteCertVerification(object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors) + { + /* + * Since certificates are loaded at the interface level, an interface is defined at the virtual host level + * and can only accept one certificate per virtual host. So SNI is not useful here since certificates are + * verified at the interface level. + */ + + return _errorLevel == sslPolicyErrors; + } + + /* + * Callback for getting the certificate from a hostname + * + * Always used the certificate defined at the interface level + */ + private X509Certificate OnGetCertificatForHost(object sender, string? hostName) => _cert; + + } +} diff --git a/apps/VNLib.WebServer/src/Transport/SslTcpTransportContext.cs b/apps/VNLib.WebServer/src/Transport/SslTcpTransportContext.cs new file mode 100644 index 0000000..8e3c2fd --- /dev/null +++ b/apps/VNLib.WebServer/src/Transport/SslTcpTransportContext.cs @@ -0,0 +1,103 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: SslTcpTransportContext.cs +* +* SslTcpTransportContext.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + + +using System.Net.Security; +using System.Threading.Tasks; +using System.Runtime.CompilerServices; + +using VNLib.Net.Http; +using VNLib.Net.Transport.Tcp; + +namespace VNLib.WebServer.Transport +{ + internal sealed class SslTcpTransportContext(ITcpListner server, ITcpConnectionDescriptor descriptor, SslStream stream) + : TcpTransportContext(server, descriptor, stream) + { + private TransportSecurityInfo? _securityInfo; + private readonly SslStream _baseStream = stream; + + ///<inheritdoc/> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public async override ValueTask CloseConnectionAsync() + { + try + { + //Shutdown the ssl stream before cleaning up the connection + await _baseStream.ShutdownAsync(); + await _connectionStream.DisposeAsync(); + } + finally + { + //Always close the underlying connection + await base.CloseConnectionAsync(); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override ref readonly TransportSecurityInfo? GetSecurityInfo() + { + //Value has not been loaded yet, so lazy load it + if (!_securityInfo.HasValue) + { + //Create sec info from the ssl stream + GetSecInfo(ref _securityInfo, _baseStream); + } + + return ref _securityInfo; + } + + + //Lazy load sec info + private static void GetSecInfo(ref TransportSecurityInfo? tsi, SslStream ssl) + { + //Build sec info + tsi = new() + { + SslProtocol = ssl.SslProtocol, + HashAlgorithm = ssl.HashAlgorithm, + CipherAlgorithm = ssl.CipherAlgorithm, + + HashStrength = ssl.HashStrength, + CipherStrength = ssl.CipherStrength, + + IsSigned = ssl.IsSigned, + IsEncrypted = ssl.IsEncrypted, + IsAuthenticated = ssl.IsAuthenticated, + IsMutuallyAuthenticated = ssl.IsMutuallyAuthenticated, + CheckCertRevocationStatus = ssl.CheckCertRevocationStatus, + + KeyExchangeStrength = ssl.KeyExchangeStrength, + KeyExchangeAlgorithm = ssl.KeyExchangeAlgorithm, + + LocalCertificate = ssl.LocalCertificate, + RemoteCertificate = ssl.RemoteCertificate, + + TransportContext = ssl.TransportContext, + NegotiatedCipherSuite = ssl.NegotiatedCipherSuite, + NegotiatedApplicationProtocol = ssl.NegotiatedApplicationProtocol, + }; + } + } +} diff --git a/apps/VNLib.WebServer/src/Transport/TcpServerLoader.cs b/apps/VNLib.WebServer/src/Transport/TcpServerLoader.cs new file mode 100644 index 0000000..12ad52f --- /dev/null +++ b/apps/VNLib.WebServer/src/Transport/TcpServerLoader.cs @@ -0,0 +1,221 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: TcpServerLoader.cs +* +* TcpServerLoader.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Linq; +using System.Text.Json; +using System.Net.Sockets; +using System.Collections.Generic; + +using VNLib.Utils.Logging; +using VNLib.Utils.Resources; +using VNLib.Net.Http; +using VNLib.Net.Transport.Tcp; + +using VNLib.WebServer.Config; +using VNLib.WebServer.RuntimeLoading; +using VNLib.WebServer.Config.Model; + +using VNLib.Plugins.Essentials.ServiceStack; +using VNLib.Plugins.Essentials.ServiceStack.Construction; + +namespace VNLib.WebServer.Transport +{ + internal sealed class TcpServerLoader(IServerConfig hostConfig, ProcessArguments args, ILogProvider tcpLogger) + { + const int CacheQuotaDefault = 0; //Disable cache quota by default, allows unlimited cache + + const string TransportLogTemplate = +@"Interface (TCP/IP): {iface} RX: {rx} TX: {tx} TLS: {tls} threads: {threads} max-cons: {max} Keepalive: {keepalive}"; + + private readonly LazyInitializer<TcpConfigJson> _conf = new(() => + { + JsonElement rootElement = hostConfig.GetDocumentRoot(); + + if (rootElement.TryGetProperty(Entry.TCP_CONF_PROP_NAME, out JsonElement tcpEl)) + { + return tcpEl.DeserializeElement<TcpConfigJson>()!; + } + + return new TcpConfigJson(); + }); + + private readonly bool UseInlineScheduler = args.HasArgument("--inline-scheduler"); + private readonly string? ThreadCountArg = args.GetArgument("-t") ?? args.GetArgument("--threads"); + private readonly bool EnableTransportLogging = args.HasArgument("--log-transport"); + private readonly bool NoReuseSocket = args.HasArgument("--no-reuse-socket"); + private readonly bool ReuseAddress = args.HasArgument("--reuse-address"); + + /// <summary> + /// The user confiugred TCP transmission buffer size + /// </summary> + public int TcpTxBufferSize => _conf.Instance.TcpSendBufferSize; + + /// <summary> + /// The user-conifuigred TCP receive buffer size + /// </summary> + public int TcpRxBufferSize => _conf.Instance.TcpRecvBufferSize; + + public HttpTransportMapping[] ReduceBindingsForGroups(IReadOnlyCollection<ServiceGroup> groups) + { + /* + * All transports can be reduced by their endpoints to reduce the number of + * TCP server instances that need to be created. + * + * The following code attempts to reorder the many-to-many set mapping of + * transports to virtual hosts into a set of one-to-many http bingings. + * + * Example: + * + * Virtual hosts Transports + * ex.com 0.0.0.0:80 (no ssl) + * ex.com 0.0.0.0:443 (ssl) (shared) + * + * ex1.com 192.168.1.6:80 (no ssl) + * ex1.com 0.0.0.0:443 (ssl) (shared) + */ + + Dictionary<TransportInterface, ITransportProvider> transportMap = groups + .SelectMany(static g => g.Hosts) + .Select(static c => (VirtualHostConfig)c.UserState!) + .SelectMany(static c => c.Transports) + .DistinctBy(static t => t.GetHashCode()) + .ToDictionary(static k => k, GetProviderForInterface); + + /* + * The following code groups virtual hosts that share the same transport + * interface and creates a new HttpTransportMapping instance for each + * group of shared interfaces, pulling the transport provider from the + * transport map above. + */ + + HttpTransportMapping[] bindings = groups + .SelectMany(static s => s.Hosts) + .SelectMany(static host => + { + //The vhost config is stored as a user-object on the service host + VirtualHostConfig config = (VirtualHostConfig)host.UserState!; + + return config.Transports.Select(iface => new OneToOneHostMapping(host, iface)); + }) + .GroupBy(static m => m.Interface.GetHashCode()) + .Select(otoMap => + { + IServiceHost[] sharedTransportHosts = otoMap + .Select(static m => m.Host) + .ToArray(); + + TransportInterface sharedInterface = otoMap.First().Interface; + + //Find any duplicate hostnames that share the same transport interface and raise validation exception + string[] sharedHostErrors = sharedTransportHosts.GroupBy(static h => h.Processor.Hostname) + .Where(static g => g.Count() > 1) + .Select(duplicteGroup => + { + string hostnames = string.Join(", ", duplicteGroup.Select(h => h.Processor.Hostname)); + + return $"Duplicate hostnames: {hostnames} share the same transport interface {sharedInterface}"; + }) + .ToArray(); + + //If any duplicate hostnames are found, raise a validation exception + if (sharedHostErrors.Length > 0) + { + throw new ServerConfigurationException(string.Join('\n', sharedHostErrors)); + } + + ITransportProvider mappedTransport = transportMap[sharedInterface]; + + return new HttpTransportMapping(sharedTransportHosts, mappedTransport); + }) + .ToArray(); + + return bindings; + } + + sealed record class OneToOneHostMapping(IServiceHost Host, TransportInterface Interface); + + private ITransportProvider GetProviderForInterface(TransportInterface iface) + { + if (!uint.TryParse(ThreadCountArg, out uint threadCount)) + { + threadCount = (uint)Environment.ProcessorCount; + } + + TcpConfigJson baseConfig = _conf.Instance; + baseConfig.ValidateConfig(); + + TCPConfig tcpConf = new() + { + LocalEndPoint = iface.GetEndpoint(), + AcceptThreads = threadCount, + CacheQuota = CacheQuotaDefault, + Log = tcpLogger, + DebugTcpLog = EnableTransportLogging, //Only available in debug logging + BackLog = baseConfig.BackLog, + MaxConnections = baseConfig.MaxConnections, + TcpKeepAliveTime = baseConfig.TcpKeepAliveTime, + KeepaliveInterval = baseConfig.KeepaliveInterval, + MaxRecvBufferData = baseConfig.MaxRecvBufferData, + ReuseSocket = !NoReuseSocket, //Default to always reuse socket if allowed + OnSocketCreated = OnSocketConfiguring, + BufferPool = MemoryPoolManager.GetTcpPool(args.ZeroAllocations) + }; + + //Print warning message, since inline scheduler is an avanced feature + if (iface.Ssl && UseInlineScheduler) + { + tcpLogger.Debug("[WARN]: Inline scheduler is not available on server {server} when using TLS", tcpConf.LocalEndPoint); + } + + tcpLogger.Verbose(TransportLogTemplate, + iface, + baseConfig.TcpRecvBufferSize, + baseConfig.TcpSendBufferSize, + iface.Ssl, + threadCount, + tcpConf.MaxConnections, + tcpConf.BackLog, + tcpConf.TcpKeepAliveTime > 0 ? $"{tcpConf.TcpKeepAliveTime} sec" : "Disabled" + ); + + //Init new tcp server with/without ssl + return iface.Ssl + ? TcpTransport.CreateServer(in tcpConf, ssl: new HostAwareServerSslOptions(iface)) + : TcpTransport.CreateServer(in tcpConf, UseInlineScheduler); + + } + + + private void OnSocketConfiguring(Socket serverSock) + { + TcpConfigJson baseConf = _conf.Instance; + + serverSock.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.NoDelay, baseConf.NoDelay); + serverSock.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.SendBuffer, baseConf.TcpSendBufferSize); + serverSock.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReceiveBuffer, baseConf.TcpRecvBufferSize); + serverSock.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, ReuseAddress); + } + } +} diff --git a/apps/VNLib.WebServer/src/Transport/TcpTransport.cs b/apps/VNLib.WebServer/src/Transport/TcpTransport.cs new file mode 100644 index 0000000..fd6e7d1 --- /dev/null +++ b/apps/VNLib.WebServer/src/Transport/TcpTransport.cs @@ -0,0 +1,188 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: TcpTransport.cs +* +* TcpTransport.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Threading; +using System.IO.Pipelines; +using System.Net.Security; +using System.Threading.Tasks; +using System.Security.Authentication; + +using VNLib.Net.Http; +using VNLib.Net.Transport.Tcp; +using VNLib.Utils.Logging; + +namespace VNLib.WebServer.Transport +{ + /// <summary> + /// Creates the TCP/HTTP translation layer providers + /// </summary> + internal static class TcpTransport + { + /// <summary> + /// Creates a new <see cref="ITransportProvider"/> that will listen for tcp connections + /// </summary> + /// <param name="config">The server configuration</param> + /// <param name="inlineScheduler">Use the inline pipeline scheduler</param> + /// <returns>The configured <see cref="ITransportProvider"/></returns> + public static ITransportProvider CreateServer(ref readonly TCPConfig config, bool inlineScheduler) + { + //Create tcp server + TcpServer server = new (config, CreateCustomPipeOptions(in config, inlineScheduler)); + //Return provider + return new TcpTransportProvider(server); + } + + /// <summary> + /// Creates a new <see cref="ITransportProvider"/> that will listen for tcp connections + /// and use SSL + /// </summary> + /// <param name="config"></param> + /// <param name="ssl">The server authentication options</param> + /// <returns>The ssl configured transport context</returns> + public static ITransportProvider CreateServer(in TCPConfig config, SslServerAuthenticationOptions ssl) + { + /* + * SSL STREAM WORKAROUND + * + * The HttpServer impl calls Read() synchronously on the calling thread, + * it assumes that the call will make it synchronously to the underlying + * transport. SslStream calls ReadAsync() interally on the current + * synchronization context, which causes a deadlock... So the threadpool + * scheduler on the pipeline ensures that all continuations are run on the + * threadpool, which fixes this issue. + */ + + //Create tcp server + TcpServer server = new (config, CreateCustomPipeOptions(in config, false)); + //Return provider + return new SslTcpTransportProvider(server, ssl); + } + + private static PipeOptions CreateCustomPipeOptions(ref readonly TCPConfig config, bool inlineScheduler) + { + return new PipeOptions( + config.BufferPool, + //Noticable performance increase when using inline scheduler for reader (handles send operations) + readerScheduler: inlineScheduler ? PipeScheduler.Inline : PipeScheduler.ThreadPool, + writerScheduler: inlineScheduler ? PipeScheduler.Inline : PipeScheduler.ThreadPool, + pauseWriterThreshold: config.MaxRecvBufferData, + minimumSegmentSize: 8192, + useSynchronizationContext: false + ); + } + + /// <summary> + /// A TCP server transport provider class + /// </summary> + private class TcpTransportProvider(TcpServer Server) : ITransportProvider + { + protected ITcpListner? _listener; + protected CancellationTokenRegistration _reg; + + ///<inheritdoc/> + void ITransportProvider.Start(CancellationToken stopToken) + { + //TEMPORARY (unless it works) + if(_listener is not null) + { + throw new InvalidOperationException("The server has already been started."); + } + + //Start the server + _listener = Server.Listen(); + _reg = stopToken.Register(_listener.Close, false); + } + + ///<inheritdoc/> + public virtual async ValueTask<ITransportContext> AcceptAsync(CancellationToken cancellation) + { + //Wait for tcp event and wrap in ctx class + ITcpConnectionDescriptor descriptor = await _listener!.AcceptConnectionAsync(cancellation); + + return new TcpTransportContext(_listener, descriptor, descriptor.GetStream()); + } + + ///<inheritdoc/> + public override string ToString() => $"{Server.Config.LocalEndPoint} tcp/ip"; + } + + private sealed class SslTcpTransportProvider(TcpServer Server, SslServerAuthenticationOptions AuthOptions) + : TcpTransportProvider(Server) + { + /* + * An SslStream may throw a win32 exception with HRESULT 0x80090327 + * when processing a client certificate (I believe anyway) only + * an issue on some clients (browsers) + */ + + private const int UKNOWN_CERT_AUTH_HRESULT = unchecked((int)0x80090327); + + /// <summary> + /// An invlaid frame size may happen if data is recieved on an open socket + /// but does not contain valid SSL handshake data + /// </summary> + private const int INVALID_FRAME_HRESULT = unchecked((int)0x80131501); + + public override async ValueTask<ITransportContext> AcceptAsync(CancellationToken cancellation) + { + //Loop to handle ssl exceptions ourself + do + { + //Wait for tcp event and wrap in ctx class + ITcpConnectionDescriptor descriptor = await _listener.AcceptConnectionAsync(cancellation); + + //Create ssl stream and auth + SslStream stream = new(descriptor.GetStream(), false); + + try + { + //auth the new connection + await stream.AuthenticateAsServerAsync(AuthOptions, cancellation); + return new SslTcpTransportContext(_listener, descriptor, stream); + } + catch (AuthenticationException ae) when (ae.HResult == INVALID_FRAME_HRESULT) + { + Server.Config.Log.Debug("A TLS connection attempt was made but an invalid TLS frame was received"); + + await _listener.CloseConnectionAsync(descriptor, true); + await stream.DisposeAsync(); + + //continue listening loop + } + catch + { + await _listener.CloseConnectionAsync(descriptor, true); + await stream.DisposeAsync(); + throw; + } + } + while (true); + } + + ///<inheritdoc/> + public override string ToString() => $"{Server.Config.LocalEndPoint} tcp/ip (TLS enabled)"; + } + } +} diff --git a/apps/VNLib.WebServer/src/Transport/TcpTransportContext.cs b/apps/VNLib.WebServer/src/Transport/TcpTransportContext.cs new file mode 100644 index 0000000..2bb1b54 --- /dev/null +++ b/apps/VNLib.WebServer/src/Transport/TcpTransportContext.cs @@ -0,0 +1,93 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: TcpTransportContext.cs +* +* TcpTransportContext.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System.IO; +using System.Net; +using System.Threading.Tasks; +using System.Runtime.CompilerServices; + +using VNLib.Net.Http; +using VNLib.Net.Transport.Tcp; + +namespace VNLib.WebServer.Transport +{ + /// <summary> + /// The TCP connection context + /// </summary> + internal class TcpTransportContext : ITransportContext + { + //Store static empty security info to pass in default case + private static readonly TransportSecurityInfo? EmptySecInfo; + + protected readonly ITcpConnectionDescriptor _descriptor; + + protected readonly Stream _connectionStream; + protected readonly IPEndPoint _localEndoint; + protected readonly IPEndPoint _remoteEndpoint; + protected readonly ITcpListner _server; + + public TcpTransportContext(ITcpListner server, ITcpConnectionDescriptor descriptor, Stream stream) + { + _descriptor = descriptor; + _connectionStream = stream; + _server = server; + //Get the endpoints + descriptor.GetEndpoints(out _localEndoint, out _remoteEndpoint); + } + + ///<inheritdoc/> + public virtual Stream ConnectionStream + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _connectionStream; + } + + ///<inheritdoc/> + public virtual IPEndPoint LocalEndPoint + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _localEndoint; + } + + ///<inheritdoc/> + public virtual IPEndPoint RemoteEndpoint + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _remoteEndpoint; + } + + ///<inheritdoc/> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public virtual async ValueTask CloseConnectionAsync() + { + //Close the stream before the descriptor + await _connectionStream.DisposeAsync(); + await _server.CloseConnectionAsync(_descriptor, true); + } + + //Ssl is not supported in this transport + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public virtual ref readonly TransportSecurityInfo? GetSecurityInfo() => ref EmptySecInfo; + } +} diff --git a/apps/VNLib.WebServer/src/VLogProvider.cs b/apps/VNLib.WebServer/src/VLogProvider.cs new file mode 100644 index 0000000..d20437f --- /dev/null +++ b/apps/VNLib.WebServer/src/VLogProvider.cs @@ -0,0 +1,84 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: VLogProvider.cs +* +* VLogProvider.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Linq; +using System.Runtime.CompilerServices; + +using Serilog; +using Serilog.Core; +using Serilog.Events; + +using VNLib.Utils; +using VNLib.Utils.Logging; + +namespace VNLib.WebServer +{ + internal sealed class VLogProvider : VnDisposeable, ILogProvider + { + private readonly Logger LogCore; + + public VLogProvider(LoggerConfiguration config) + { + LogCore = config.CreateLogger(); + } + public void Flush() { } + + public object GetLogProvider() => LogCore; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsEnabled(LogLevel level) => LogCore.IsEnabled((LogEventLevel)level); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Write(LogLevel level, string value) + { + LogCore.Write((LogEventLevel)level, value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Write(LogLevel level, Exception exception, string value = "") + { + LogCore.Write((LogEventLevel)level, exception, value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Write(LogLevel level, string value, params object[] args) + { + LogCore.Write((LogEventLevel)level, value, args); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Write(LogLevel level, string value, params ValueType[] args) + { + //Serilog logger supports passing valuetypes to avoid boxing objects + if (LogCore.IsEnabled((LogEventLevel)level)) + { + object[] ar = args.Select(a => (object)a).ToArray(); + LogCore.Write((LogEventLevel)level, value, ar); + } + } + + protected override void Free() => LogCore.Dispose(); + } +} diff --git a/apps/VNLib.WebServer/src/VNLib.WebServer.csproj b/apps/VNLib.WebServer/src/VNLib.WebServer.csproj new file mode 100644 index 0000000..37808b5 --- /dev/null +++ b/apps/VNLib.WebServer/src/VNLib.WebServer.csproj @@ -0,0 +1,88 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net8.0</TargetFramework> + <Nullable>enable</Nullable> + <AssemblyName>VNLib.WebServer</AssemblyName> + <RootNamespace>VNLib.WebServer</RootNamespace> + <ServerGarbageCollection>true</ServerGarbageCollection> + <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection> + <RetainVMGarbageCollection>true</RetainVMGarbageCollection> + <StartupObject>VNLib.WebServer.Entry</StartupObject> + </PropertyGroup> + + <!-- Dotnet tool stuff --> + <PropertyGroup> + <PackAsTool>true</PackAsTool> + <ToolCommandName>VNLib.WebServer</ToolCommandName> + </PropertyGroup> + + <PropertyGroup> + <AnalysisLevel Condition="'$(BuildingInsideVisualStudio)' == true">latest-all</AnalysisLevel> + </PropertyGroup> + + <PropertyGroup> + <Authors>Vaughn Nugent</Authors> + <Company>Vaughn Nugent</Company> + <Description>A high performance, reference .NET 8 web/http server using VNLib.Core, with the VNLib.Plugins.Essentials web framework</Description> + <Copyright>Copyright © 2024 Vaughn Nugent</Copyright> + <Product>VNLib.Webserver</Product> + <PackageProjectUrl>https://www.vaughnnugent.com/resources/software/modules/vnlib.core</PackageProjectUrl> + <RepositoryUrl>https://github.com/VnUgE/VNLib.Core/tree/master/app/VNLib.Webserver/</RepositoryUrl> + <PackageReadmeFile>README.md</PackageReadmeFile> + <PackageLicenseFile>LICENSE</PackageLicenseFile> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> + <Deterministic>False</Deterministic> + <DefineConstants>$(DefineConstants);USE_MCMASTER</DefineConstants> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'"> + <Deterministic>False</Deterministic> + <DefineConstants>$(DefineConstants);USE_MCMASTER</DefineConstants> + </PropertyGroup> + + <ItemGroup> + <None Include="..\LICENSE"> + <Pack>True</Pack> + <PackagePath>\</PackagePath> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </None> + <None Include="..\README.md"> + <Pack>True</Pack> + <PackagePath>\</PackagePath> + </None> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="ErrorProne.NET.CoreAnalyzers" Version="0.1.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="ErrorProne.NET.Structs" Version="0.1.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="Serilog" Version="4.0.1" /> + <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" /> + <PackageReference Include="Serilog.Sinks.File" Version="6.0.0" /> + <PackageReference Include="YamlDotNet" Version="16.0.0" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\lib\Plugins.Essentials\src\VNLib.Plugins.Essentials.csproj" /> + <ProjectReference Include="..\..\..\lib\Net.Http\src\VNLib.Net.Http.csproj" /> + <ProjectReference Include="..\..\..\lib\Net.Transport.SimpleTCP\src\VNLib.Net.Transport.SimpleTCP.csproj" /> + <ProjectReference Include="..\..\..\lib\Plugins.Essentials.ServiceStack\src\VNLib.Plugins.Essentials.ServiceStack.csproj" /> + <ProjectReference Include="..\..\..\third-party\DotNetCorePlugins\src\McMaster.NETCore.Plugins.csproj" /> + </ItemGroup> + + <ItemGroup> + <None Update="sample.config.json"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </None> + </ItemGroup> + +</Project> diff --git a/apps/VNLib.WebServer/src/VirtualHosts/FileCache.cs b/apps/VNLib.WebServer/src/VirtualHosts/FileCache.cs new file mode 100644 index 0000000..de4efd9 --- /dev/null +++ b/apps/VNLib.WebServer/src/VirtualHosts/FileCache.cs @@ -0,0 +1,134 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: FileCache.cs +* +* FileCache.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.IO; +using System.Net; + +using VNLib.Net.Http; +using VNLib.Utils; +using VNLib.Utils.IO; + +namespace VNLib.WebServer +{ + /// <summary> + /// File the server will keep in memory and return to user when a specified error code is requested + /// </summary> + internal sealed class FileCache : VnDisposeable, IFSChangeHandler + { + private readonly string _filePath; + + public readonly HttpStatusCode Code; + + private Lazy<byte[]> _templateData; + + + /// <summary> + /// Catch an http error code and return the selected file to user + /// </summary> + /// <param name="code">Http status code to catch</param> + /// <param name="filePath">Path to file contating data to return to use on status code</param> + private FileCache(HttpStatusCode code, string filePath) + { + Code = code; + _filePath = filePath; + _templateData = new(LoadTemplateData); + } + + private byte[] LoadTemplateData() + { + //Get file data as binary + return File.ReadAllBytes(_filePath); + } + + /// <summary> + /// Gets a <see cref="IMemoryResponseReader"/> wrapper that may read a copy of the + /// file representation + /// </summary> + /// <returns>The <see cref="IMemoryResponseReader"/> wrapper around the file data</returns> + public IMemoryResponseReader GetReader() => new MemReader(_templateData.Value); + + + void IFSChangeHandler.OnFileChanged(FileSystemEventArgs e) + { + //Update lazy loader for new file update + _templateData = new(LoadTemplateData); + } + + protected override void Free() + { + //Unsubscribe from file watcher + FileWatcher.Unsubscribe(_filePath, this); + } + + /// <summary> + /// Create a new file cache for a specific error code + /// and begins listening for changes to the file + /// </summary> + /// <param name="code">The status code to produce the file for</param> + /// <param name="filePath">The path to the file to read</param> + /// <returns>The new <see cref="FileCache"/> instance if the file exists and is readable, null otherwise</returns> + public static FileCache? Create(HttpStatusCode code, string filePath) + { + //If the file does not exist, return null + if(!FileOperations.FileExists(filePath)) + { + return null; + } + + FileCache ff = new(code, filePath); + + //Subscribe to file changes + FileWatcher.Subscribe(filePath, ff); + + return ff; + } + + private sealed class MemReader : IMemoryResponseReader + { + private readonly byte[] _memory; + + private int _written; + + public int Remaining { get; private set; } + + internal MemReader(byte[] data) + { + //Store ref as memory + _memory = data; + Remaining = data.Length; + } + + public void Advance(int written) + { + _written += written; + Remaining -= written; + } + + void IMemoryResponseReader.Close() { } + + ReadOnlyMemory<byte> IMemoryResponseReader.GetMemory() => _memory.AsMemory(_written, Remaining); + } + } +}
\ No newline at end of file diff --git a/apps/VNLib.WebServer/src/VirtualHosts/IVirtualHostConfigBuilder.cs b/apps/VNLib.WebServer/src/VirtualHosts/IVirtualHostConfigBuilder.cs new file mode 100644 index 0000000..7e837e7 --- /dev/null +++ b/apps/VNLib.WebServer/src/VirtualHosts/IVirtualHostConfigBuilder.cs @@ -0,0 +1,35 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: IVirtualHostConfigBuilder.cs +* +* IVirtualHostConfigBuilder.cs is part of VNLib.WebServer which is +* part of the larger VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +namespace VNLib.WebServer +{ + internal interface IVirtualHostConfigBuilder + { + /// <summary> + /// Gets the base configuration for the virtualhost + /// </summary> + /// <returns></returns> + VirtualHostConfig GetBaseConfig(); + } +} diff --git a/apps/VNLib.WebServer/src/VirtualHosts/JsonWebConfigBuilder.cs b/apps/VNLib.WebServer/src/VirtualHosts/JsonWebConfigBuilder.cs new file mode 100644 index 0000000..820664c --- /dev/null +++ b/apps/VNLib.WebServer/src/VirtualHosts/JsonWebConfigBuilder.cs @@ -0,0 +1,276 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: JsonWebConfigBuilder.cs +* +* JsonWebConfigBuilder.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.IO; +using System.Net; +using System.Data; +using System.Linq; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; + +using VNLib.WebServer.Config; +using VNLib.WebServer.Config.Model; + +namespace VNLib.WebServer +{ + + internal sealed partial class JsonWebConfigBuilder(VirtualHostServerConfig VhConfig, TimeSpan execTimeout, ILogProvider logger) + : IVirtualHostConfigBuilder + { + //Use pre-compiled default regex + private static readonly Regex DefaultRootRegex = MyRegex(); + + ///<inheritdoc/> + public VirtualHostConfig GetBaseConfig() + { + //Declare the vh config + return new() + { + //File root is required + RootDir = new(VhConfig.DirPath!), + LogProvider = logger, + ExecutionTimeout = execTimeout, + WhiteList = GetIpWhitelist(VhConfig), + DownStreamServers = GetDownStreamServers(VhConfig), + ExcludedExtensions = GetExlcudedExtensions(VhConfig), + DefaultFiles = GetDefaultFiles(VhConfig), + PathFilter = GetPathFilter(VhConfig), + CacheDefault = TimeSpan.FromSeconds(VhConfig.CacheDefaultTimeSeconds), + AdditionalHeaders = GetConfigHeaders(VhConfig), + SpecialHeaders = GetSpecialHeaders(VhConfig), + FailureFiles = GetFailureFiles(VhConfig), + FilePathCacheMaxAge = TimeSpan.MaxValue, + Hostnames = GetHostnames(VhConfig), + Transports = GetInterfaces(VhConfig), + BlackList = GetIpBlacklist(VhConfig), + }; + } + + private static string[] GetHostnames(VirtualHostServerConfig conf) + { + Validate.EnsureNotNull(conf.Hostnames, "Hostnames array was set to null, you must define at least one hostname"); + + foreach (string hostname in conf.Hostnames) + { + Validate.EnsureNotNull(hostname, "Hostname is null, all hostnames must be defined"); + } + + return conf.Hostnames; + } + + private static TransportInterface[] GetInterfaces(VirtualHostServerConfig conf) + { + Validate.EnsureNotNull(conf.Interfaces, "Interfaces array was set to null, you must define at least one network interface"); + Validate.Assert(conf.Interfaces.Length > 0, $"You must define at least one interface for host"); + + for(int i = 0; i < conf.Interfaces.Length; i++) + { + TransportInterface iFace = conf.Interfaces[i]; + + Validate.EnsureNotNull(iFace, $"Vrtual host interface [{i}] is undefined"); + + Validate.EnsureNotNull(iFace.Address, $"The interface IP address is required for interface [{i}]"); + Validate.EnsureValidIp(iFace.Address, $"The interface IP address is invalid for interface [{i}]"); + Validate.EnsureRange(iFace.Port, 1, 65535, "Interface port"); + } + + return conf.Interfaces; + } + + private static Regex GetPathFilter(VirtualHostServerConfig conf) + { + //Allow site to define a regex filter pattern + return conf.PathFilter is not null ? new(conf.PathFilter!) : DefaultRootRegex; + } + + private FrozenDictionary<HttpStatusCode, FileCache> GetFailureFiles(VirtualHostServerConfig conf) + { + //if a failure file array is specified, capure all files and + if (conf.ErrorFiles is null || conf.ErrorFiles.Length < 1) + { + return new Dictionary<HttpStatusCode, FileCache>().ToFrozenDictionary(); + } + + //Get the error files + IEnumerable<KeyValuePair<HttpStatusCode, string>> ffs = conf.ErrorFiles + .Select(static f => new KeyValuePair<HttpStatusCode, string>((HttpStatusCode)f.Code, f.Path!)); + + //Create the file cache dictionary + (HttpStatusCode, string, FileCache?)[] loadCache = ffs.Select(static kv => + { + FileCache? cached = FileCache.Create(kv.Key, kv.Value); + return (kv.Key, kv.Value, cached); + + }).ToArray(); + + //Only include files that exist and were loaded + int loadedFiles = loadCache + .Where(static loadCache => loadCache.Item3 != null) + .Count(); + + string[] notFoundFiles = loadCache + .Where(static loadCache => loadCache.Item3 == null) + .Select(static l => Path.GetFileName(l.Item2)) + .ToArray(); + + if (notFoundFiles.Length > 0) + { + logger.Warn("Failed to load error files {files} for host {hosts}", notFoundFiles, conf.Hostnames); + } + + //init frozen dictionary from valid cached files + return loadCache + .Where(static kv => kv.Item3 != null) + .ToDictionary(static kv => kv.Item1, static kv => kv.Item3!) + .ToFrozenDictionary(); + } + + private static FrozenSet<IPAddress> GetDownStreamServers(VirtualHostServerConfig conf) + { + //Find downstream servers + HashSet<IPAddress>? downstreamServers = null; + + //See if element is set + if (conf.DownstreamServers is not null) + { + //hash addresses, make is distinct + downstreamServers = conf.DownstreamServers + .Where(static addr => !string.IsNullOrWhiteSpace(addr)) + .Select(static addr => IPAddress.Parse(addr)) + .Distinct() + .ToHashSet(); + } + + return (downstreamServers ?? []).ToFrozenSet(); + } + + private static FrozenSet<IPAddress>? GetIpWhitelist(VirtualHostServerConfig conf) + { + if(conf.Whitelist is null) + { + return null; + } + + //See if whitelist is defined, if so, get a distinct list of addresses + return conf.Whitelist + .Where(static addr => !string.IsNullOrWhiteSpace(addr)) + .Select(static addr => IPAddress.Parse(addr)) + .Distinct() + .ToHashSet() + .ToFrozenSet(); + } + + private static FrozenSet<IPAddress>? GetIpBlacklist(VirtualHostServerConfig conf) + { + if (conf.Blacklist is null) + { + return null; + } + + //See if whitelist is defined, if so, get a distinct list of addresses + return conf.Blacklist + .Where(static addr => !string.IsNullOrWhiteSpace(addr)) + .Select(static addr => IPAddress.Parse(addr)) + .Distinct() + .ToHashSet() + .ToFrozenSet(); + } + + private static FrozenSet<string> GetExlcudedExtensions(VirtualHostServerConfig conf) + { + //Get exlucded/denied extensions from config, ignore null strings + if (conf.DenyExtensions is not null) + { + return conf.DenyExtensions + .Where(static s => !string.IsNullOrWhiteSpace(s)) + .Distinct() + .ToHashSet() + .ToFrozenSet(StringComparer.OrdinalIgnoreCase); + } + else + { + return new HashSet<string>().ToFrozenSet(); + } + } + + private static IReadOnlyCollection<string> GetDefaultFiles(VirtualHostServerConfig conf) + { + if(conf.DefaultFiles is null) + { + return Array.Empty<string>(); + } + + //Get blocked extensions for the root + return conf.DefaultFiles + .Where(static s => !string.IsNullOrWhiteSpace(s)) + .Distinct() + .ToList(); + } + + private static KeyValuePair<string, string>[] GetConfigHeaders(VirtualHostServerConfig conf) + { + if (conf.Headers is null) + { + return []; + } + + //Enumerate kv headers + return conf.Headers + //Ignore empty keys or values + .Where(static p => !string.IsNullOrWhiteSpace(p.Key) && string.IsNullOrWhiteSpace(p.Value)) + //Exclude special headers + .Where(static p => !SpecialHeaders.SpecialHeader.Contains(p.Key, StringComparer.OrdinalIgnoreCase)) + .Select(static p => new KeyValuePair<string, string>(p.Key!, p.Value)) + .ToArray(); + } + + private static FrozenDictionary<string, string> GetSpecialHeaders(VirtualHostServerConfig conf) + { + //get the headers array property + if (conf.Headers is null) + { + return new Dictionary<string, string>().ToFrozenDictionary(); + } + + //Enumerate kv header + return conf.Headers + //Ignore empty keys or values + .Where(static p => !string.IsNullOrWhiteSpace(p.Key) && !string.IsNullOrWhiteSpace(p.Value)) + //Only include special headers + .Where(static p => SpecialHeaders.SpecialHeader.Contains(p.Key, StringComparer.OrdinalIgnoreCase)) + //Create the special dictionary + .ToDictionary(static k => k.Key, static k => k.Value, StringComparer.OrdinalIgnoreCase) + .ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + } + + + [GeneratedRegex(@"(\/\.\.)|(\\\.\.)|[\[\]^*<>|`~'\n\r\t\n]|(\s$)|^(\s)", RegexOptions.Compiled)] + private static partial Regex MyRegex(); + } +} diff --git a/apps/VNLib.WebServer/src/VirtualHosts/SpecialHeaders.cs b/apps/VNLib.WebServer/src/VirtualHosts/SpecialHeaders.cs new file mode 100644 index 0000000..a4a797d --- /dev/null +++ b/apps/VNLib.WebServer/src/VirtualHosts/SpecialHeaders.cs @@ -0,0 +1,71 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: SpecialHeaders.cs +* +* SpecialHeaders.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Runtime.CompilerServices; + +using VNLib.Net.Http; + +namespace VNLib.WebServer +{ + /// <summary> + /// Contains constants for internal/special headers by their name + /// </summary> + internal static class SpecialHeaders + { + public const string ContentSecPolicy = "Content-Security-Policy"; + public const string XssProtection = "X-XSS-Protection"; + public const string XContentOption = "X-Content-Type-Options"; + public const string Hsts = "Strict-Transport-Security"; + public const string Server = "Server"; + + /// <summary> + /// An array of the special headers to quickly compare against + /// </summary> + public static string[] SpecialHeader = + { + ContentSecPolicy, + XssProtection, + XContentOption, + Hsts, + Server + }; + + /// <summary> + /// Appends the special header by the given name, if it is present + /// in the current configruation's special headers + /// </summary> + /// <param name="config"></param> + /// <param name="server">The connection to set the response headers on</param> + /// <param name="headerName">The name of the special header to get</param> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void TrySetSpecialHeader(this VirtualHostConfig config, IConnectionInfo server, string headerName) + { + //Try to get the special header value, + if(config.SpecialHeaders.TryGetValue(headerName, out string? headerValue)) + { + server.Headers.Append(headerName, headerValue); + } + } + } +} diff --git a/apps/VNLib.WebServer/src/VirtualHosts/VirtualHostConfig.cs b/apps/VNLib.WebServer/src/VirtualHosts/VirtualHostConfig.cs new file mode 100644 index 0000000..8af58aa --- /dev/null +++ b/apps/VNLib.WebServer/src/VirtualHosts/VirtualHostConfig.cs @@ -0,0 +1,101 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: VirtualHostConfig.cs +* +* VirtualHostConfig.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.IO; +using System.Net; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +using VNLib.Plugins.Essentials; +using VNLib.Plugins.Essentials.ServiceStack.Construction; + +using VNLib.WebServer.Config.Model; + +namespace VNLib.WebServer +{ + /// <summary> + /// Implementation of <see cref="IEpProcessingOptions"/> + /// with <see cref="VirtualHostHooks"/> extra processing options + /// </summary> + internal sealed class VirtualHostConfig : VirtualHostConfiguration, IEpProcessingOptions + { + public VirtualHostConfig() + { + //Update file attributes + AllowedAttributes = FileAttributes.Archive | FileAttributes.Compressed | FileAttributes.Normal | FileAttributes.ReadOnly; + DissallowedAttributes = FileAttributes.Device + | FileAttributes.Directory + | FileAttributes.Encrypted + | FileAttributes.Hidden + | FileAttributes.IntegrityStream + | FileAttributes.Offline + | FileAttributes.ReparsePoint + | FileAttributes.System; + } + + /// <summary> + /// A regex filter instance to filter incoming filesystem paths + /// </summary> + public Regex? PathFilter { get; init; } + + /// <summary> + /// The default response entity cache value + /// </summary> + public required TimeSpan CacheDefault { get; init; } + + /// <summary> + /// A collection of in-memory files to send in response to processing error + /// codes. + /// </summary> + public FrozenDictionary<HttpStatusCode, FileCache> FailureFiles { get; init; } = new Dictionary<HttpStatusCode, FileCache>().ToFrozenDictionary(); + + /// <summary> + /// Allows config to specify contant additional headers + /// </summary> + public KeyValuePair<string, string>[] AdditionalHeaders { get; init; } = Array.Empty<KeyValuePair<string, string>>(); + + /// <summary> + /// Contains internal headers used for specific purposes, cherrypicked from the config headers + /// </summary> + public FrozenDictionary<string, string> SpecialHeaders { get; init; } = new Dictionary<string, string>().ToFrozenDictionary(); + + /// <summary> + /// The array of interfaces the host wishes to listen on + /// </summary> + internal required TransportInterface[] Transports { get; init; } + + /// <summary> + /// An optional whitelist set of ipaddresses that are allowed to make connections to this site + /// </summary> + internal required FrozenSet<IPAddress>? WhiteList { get; init; } + + /// <summary> + /// An optional blacklist set of ipaddresses that are not allowed to make connections to this site + /// </summary> + internal required FrozenSet<IPAddress>? BlackList { get; init; } + + } +} diff --git a/apps/VNLib.WebServer/src/VirtualHosts/VirtualHostHooks.cs b/apps/VNLib.WebServer/src/VirtualHosts/VirtualHostHooks.cs new file mode 100644 index 0000000..e61b0a0 --- /dev/null +++ b/apps/VNLib.WebServer/src/VirtualHosts/VirtualHostHooks.cs @@ -0,0 +1,231 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.WebServer +* File: VirtualHostHooks.cs +* +* VirtualHostHooks.cs is part of VNLib.WebServer which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.WebServer is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.WebServer is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.WebServer. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.IO; +using System.Net; +using System.Globalization; +using System.Runtime.CompilerServices; + +using VNLib.Net.Http; +using VNLib.Utils.Memory; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Essentials; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Essentials.ServiceStack.Construction; + +namespace VNLib.WebServer +{ + + internal sealed class VirtualHostHooks(VirtualHostConfig config) : IVirtualHostHooks + { + private const int FILE_PATH_BUILDER_BUFFER_SIZE = 4096; + + private static readonly string CultreInfo = CultureInfo.InstalledUICulture.Name; + + private readonly string DefaultCacheString = HttpHelpers.GetCacheString(CacheType.Public, (int)config.CacheDefault.TotalSeconds); + + public bool ErrorHandler(HttpStatusCode errorCode, IHttpEvent ev) + { + //Make sure the connection accepts html + if (ev.Server.Accepts(ContentType.Html) && config.FailureFiles.TryGetValue(errorCode, out FileCache? ff)) + { + ev.Server.SetNoCache(); + ev.CloseResponse(errorCode, ContentType.Html, ff.GetReader()); + return true; + } + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public string TranslateResourcePath(string requestPath) + { + + /* + * This function must safely translate a request URL as "unsanitized" + * user input to a safe filesystem path for a desired resource. + * + * A user may supply a custom regex file as a first line of defense + * for illegal fs characters. + * + * It is safe to assume the path is a local path, (not absolute) so + * it should not contain an illegal FS scheme + */ + + requestPath = config.PathFilter?.Replace(requestPath, string.Empty) ?? requestPath; + + using UnsafeMemoryHandle<char> charBuffer = MemoryUtil.UnsafeAlloc<char>(FILE_PATH_BUILDER_BUFFER_SIZE); + + ForwardOnlyWriter<char> sb = new(charBuffer.Span); + + //Start with the root filename + sb.Append(config.RootDir.FullName); + + //Supply a "leading" dir separator character + if (requestPath[0] != '/') + { + sb.Append('/'); + } + + //Add the path (trimmed for whitespace) + sb.Append(requestPath); + + //Attmept to filter traversals + sb.Replace("..", string.Empty); + + //if were on windows, convert to windows directory separators + if (OperatingSystem.IsWindows()) + { + sb.Replace("/", "\\"); + } + else + { + sb.Replace("\\", "/"); + } + + /* + * DEFAULT: If no file extension is listed or, is not a / separator, then + * add a .html extension + */ + if (!Path.EndsInDirectorySeparator(requestPath) && !Path.HasExtension(requestPath)) + { + sb.AppendSmall(".html"); + } + + return sb.ToString(); + } + + public void PreProcessEntityAsync(HttpEntity entity, out FileProcessArgs args) + { + args = FileProcessArgs.Continue; + } + + public void PostProcessFile(HttpEntity entity, ref FileProcessArgs chosenRoutine) + { + //Do not respond to virtual processors + if (chosenRoutine == FileProcessArgs.VirtualSkip) + { + return; + } + + //Get-set the x-content options headers from the client config + config.TrySetSpecialHeader(entity.Server, SpecialHeaders.XContentOption); + + //Get the re-written url or + ReadOnlySpan<char> ext; + switch (chosenRoutine.Routine) + { + case FpRoutine.Deny: + case FpRoutine.Error: + case FpRoutine.NotFound: + case FpRoutine.Redirect: + { + ReadOnlySpan<char> filePath = entity.Server.Path.AsSpan(); + + //disable cache + entity.Server.SetNoCache(); + + //If the file is an html file or does not include an extension (inferred html) + ext = Path.GetExtension(filePath); + } + break; + case FpRoutine.ServeOther: + case FpRoutine.ServeOtherFQ: + { + ReadOnlySpan<char> filePath = chosenRoutine.Alternate.AsSpan(); + + //Use the alternate file path for extension + ext = Path.GetExtension(filePath); + + //Set default cache + ContentType ct = HttpHelpers.GetContentTypeFromFile(filePath); + SetCache(entity, ct); + } + break; + default: + { + ReadOnlySpan<char> filePath = entity.Server.Path.AsSpan(); + + //If the file is an html file or does not include an extension (inferred html) + ext = Path.GetExtension(filePath); + if (ext.IsEmpty) + { + //If no extension, use .html extension + SetCache(entity, ContentType.Html); + } + else + { + //Set default cache + ContentType ct = HttpHelpers.GetContentTypeFromFile(filePath); + SetCache(entity, ct); + } + } + break; + } + + //if the file is an html file, we are setting the csp and xss special headers + if (ext.IsEmpty || ext.Equals(".html", StringComparison.OrdinalIgnoreCase)) + { + //Get/set xss protection header + config.TrySetSpecialHeader(entity.Server, SpecialHeaders.XssProtection); + config.TrySetSpecialHeader(entity.Server, SpecialHeaders.ContentSecPolicy); + } + + //Set language of the server's os if the user code did not set it + if (!entity.Server.Headers.HeaderSet(HttpResponseHeader.ContentLanguage)) + { + entity.Server.Headers[HttpResponseHeader.ContentLanguage] = CultreInfo; + } + } + + private void SetCache(HttpEntity entity, ContentType ct) + { + //If request issued no cache request, set nocache headers + if (!entity.Server.NoCache()) + { + //Otherwise set caching based on the file extension type + switch (ct) + { + case ContentType.Css: + case ContentType.Jpeg: + case ContentType.Javascript: + case ContentType.Svg: + case ContentType.Img: + case ContentType.Png: + case ContentType.Apng: + case ContentType.Avi: + case ContentType.Avif: + case ContentType.Gif: + entity.Server.Headers[HttpResponseHeader.CacheControl] = DefaultCacheString; + return; + case ContentType.NonSupported: + return; + default: + break; + } + } + entity.Server.SetNoCache(); + } + } +}
\ No newline at end of file diff --git a/apps/VNLib.WebServer/src/sample.config.json b/apps/VNLib.WebServer/src/sample.config.json new file mode 100644 index 0000000..a7d268b --- /dev/null +++ b/apps/VNLib.WebServer/src/sample.config.json @@ -0,0 +1,167 @@ +{ + + //Host application config, config is loaded as a read-only DOM that is available + //to the host and loaded child plugins, all elements are available to plugins via the 'HostConfig' property + + "tcp": { + "keepalive_sec": 0, //How long to wait for a keepalive response before closing the connection (0 to disable tcp keepalive) + "keepalive_interval_sec": 0, //How long to wait between keepalive probes + "max_recv_size": 655360, //640k absolute maximum recv buffer (defaults to OS socket buffer size) + "max_connections": 50000, //Per listener instance + "backlog": 1000, //OS socket backlog, + + "tx_buffer": 65536, //OS socket send buffer size + "rx_buffer": 65536 //OS socket recv buffer size + }, + + "http": { + "default_version": "HTTP/1.1", //The defaut HTTP version to being requests with (does not support http/2 yet) + "multipart_max_buf_size": 20480, //The size of the buffer to use when parsing multipart/form data uploads + "multipart_max_size": 80240, //The maxium ammount of data (in bytes) allows for mulitpart/form data file uploads + "max_entity_size": 1024000, //Absolute maximum size (in bytes) of the request entity body (exludes headers) + "keepalive_ms": 1000000, //Keepalive ms for HTTP1.1 keepalive connections + "header_buf_size": 8128, //The buffer size to use when parsing headers (also the maxium request header size allowed) + "max_request_header_count": 50, //The maxium number of headers allowed in an HTTP request message + "max_connections": 5000, //The maxium number of allowed network connections, before 503s will be issued automatically and connections closed + "recv_timeout_ms": 5000, //time (in ms) to wait for a response from an active connection in recv mode, before dropping it + "send_timeout_ms": 60000, //Time in ms to wait for the client to accept transport data before terminating the connection + "response_header_buf_size": 16384, //The size (in bytes) of the buffer used to store all response header data + "max_uploads_per_request": 10, //Max number of multi-part file uploads allowed per request + + "compression": { + "enabled": true, //controls compression globally + "assembly": "", //A custom assembly path (ex: 'VNLib.Net.Compression.dll') + "max_size": 512000, //Maxium size of a response to compress before it's bypassed + "min_size": 2048 //Minium size of a response to compress, if smaller compression is bypassed + } + }, + + //Maxium ammount of time a request is allowed to be processed (includes loading or waiting for sessions) before operations will be cancelled and a 503 returned + "max_execution_time_ms": 20000, + + //Collection of objects to define hosts+interfaces to build server listeners from + "virtual_hosts": [ + { + + //The directory path for files served by this endpoint + "path": "path/to/website/root", + + //The hostname to listen for, "*" as wildcard, and "[system]" as the default hostname for the current machine. Must be unique + "hostnames": [ "*", "localhost" ], + + "trace": false, //Enables connection trace logging for this endpoint + "force_port_check": false, //If set, requires the port in the host header to match the transport port + + //Enable synthetic benchmarking + "benchmark": { + "enabled": false, + "random": true, + "size": 128 + }, + + //The interface to bind to, you may not mix TLS and non-TLS connections on the same interface + "interfaces": [ + { + "address": "0.0.0.0", + "port": 8080, + + "ssl": true, //Enables TLS for this interface for this host specifically + "certificate": "/path/to/cert.pfx|pem", //Cert may be pem or pfx (include private key in pfx, or include private key in a pem file) + "private_key": "/path/to/private_key.pem", //A pem encoded private key, REQUIRED if using a PEM certificate, may be encrypted with a password + "password": "plain-text-password", //An optional password for the ssl private key + "client_cert_required": false, //requires that any client connecting to this host present a valid certificate + "use_os_ciphers": false //Use the OS's ciphers instead of the hard-coded ciphers + } + ], + + + //Collection of "trusted" servers to allow proxy header support from + "downstream_servers": [ "127.0.0.1" ], + + /* + Specify a list of ip addresses that are allowed to connect to the server, 403 will be returned if connections are not on this list + whitelist works behind a trusted downstream server that supports X-Forwared-For headers + */ + "whitelist": [ "127.0.0.1" ], + + "blacklist": [ "127.0.0.1" ], //Individual IP addresses to blacklist + + //A list of file extensions to deny access to, if a resource is requested and has one of the following extensions, a 404 is returned + "deny_extensions": [ ".env", ".htaccess", ".php", ".gitignore" ], + + //The default file extensions to append to a resource that does not have a file extension + "default_files": [ "index.html", "index.htm" ], + + //Key-value headers object, some headers are special and are controlled by the vh processor + "headers": { + "header1": "header-value" + }, + + "cors": { + "enabled": true, //Enables cors protections for this host + "deny_cors_connections": false, //If true, all cors connections will be denied + "allowed_origins": [ "localhost:8089" ] + }, + + //A list of error file objects, files are loaded into memory (and watched for changes) and returned when the specified error code occurs + "error_files": [ + { + "code": 404, + "path": "path/to/404" + }, + { + "code": 403, + "path": "path/to/403" + } + ], + + //Default http cache time for files + "cache_default_sec": 864000 + } + ], + + + //Defines the directory where plugin's are to be loaded from + "plugins": { + "enabled": true, //Enable plugin loading + //Hot-reload creates collectable assemblies that allow full re-load support in the host application, should only be used for development purposes! + "hot_reload": false, + "reload_delay_sec": 2, + "paths": [ + "/path/to/plugins_dir" + ] + //"assets":"", + //"config_dir": "" + }, + + "sys_log": { + //"path": "path/to/syslog/file", + //"template": "serilog template for writing to file", + //"flush_sec": 5, + //"retained_files": 31, + //"file_size_limit": 10485760, + //"interval": "infinite" + }, + + "app_log": { + //"path": "path/to/applog/file", + //"template": "serilog template for writing to file", + //"flush_sec": 5, + //"retained_files": 31, + //"file_size_limit": 10485760, + //"interval": "infinite" + }, + + + //Global secrets object, used by the host and pluings for a specialized secrets + "secrets": { + + } + + //global or local configuration to define custom password hashing requirements instead of defaults + /* + "passwords": { + + } + */ +} |