From bf58ef83a166fa3b1590d7a01e228c9085b1637c Mon Sep 17 00:00:00 2001 From: DannyArends Date: Wed, 18 Mar 2026 09:20:46 +0000 Subject: [PATCH 01/22] Gaurd against malformed range requests --- danode/request.d | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/danode/request.d b/danode/request.d index cc00131..c538b94 100644 --- a/danode/request.d +++ b/danode/request.d @@ -93,9 +93,11 @@ struct Request { string r = headers.from("Range"); if (r.length == 0 || !r.startsWith("bytes=")) return [-1, -1]; string[] parts = r[6 .. $].split("-"); - long start = parts[0].length > 0 ? to!long(parts[0]) : 0; - long end = (parts.length > 1 && parts[1].length > 0) ? to!long(parts[1]) : -1; - return [start, end]; + try { + long start = parts[0].length > 0 ? to!long(parts[0]) : 0; + long end = (parts.length > 1 && parts[1].length > 0) ? to!long(parts[1]) : -1; + return [start, end]; + } catch (Exception e) { return [-1, -1]; } } final @property @nogc bool hasRange() const nothrow { return headers.from("Range").startsWith("bytes="); } @@ -262,4 +264,8 @@ unittest { r10.headers["Accept-Encoding"] = "gzip, deflate"; assert(r10.acceptsEncoding("deflate"), "deflate must be accepted"); assert(!r10.acceptsEncoding("br"), "br must not be accepted"); + + Request r11; + r11.headers["Range"] = "bytes=abc-def"; + assert(r11.range() == [-1, -1], "malformed range must return [-1, -1]"); } From 248e66f7ce03a884b935ac66d1e11495d18526f5 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Wed, 18 Mar 2026 09:33:24 +0000 Subject: [PATCH 02/22] No linear scans of arrays --- danode/server.d | 39 +++++++++++++++++---------------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/danode/server.d b/danode/server.d index 7979f04..713ed4b 100644 --- a/danode/server.d +++ b/danode/server.d @@ -3,7 +3,7 @@ module danode.server; import danode.imports; -import danode.functions : Msecs, sISelect, resolveFolder; +import danode.functions : from, Msecs, sISelect, resolveFolder; import danode.client : Client; import danode.interfaces : DriverInterface; import danode.http : HTTP; @@ -21,12 +21,14 @@ immutable int MAX_CLIENTS_PER_IP = 32; class Server : Thread { private: - Socket socket; // The server socket - SocketSet set; // SocketSet for server socket and client listeners - Client[] clients; // List of clients - bool terminated; // Server running - SysTime starttime; // Start time of the server - Router router; // Router to route requests + Socket socket; // The server socket + SocketSet set; // SocketSet for server socket and client listeners + Client[] clients; // List of clients + bool terminated; // Server running + SysTime starttime; // Start time of the server + Router router; // Router to route requests + long[string] nAlivePerIP; + public: string wwwFolder = "www/"; @@ -72,7 +74,7 @@ class Server : Thread { // Accept an incoming connection and create a client object final void accept(ref Appender!(Client[]) persistent, Socket socket, bool secure = false) { - if (set.sISelect(socket) <= 0 || nAlive() >= MAX_CLIENTS) return; + if (set.sISelect(socket) <= 0 || nAlive >= MAX_CLIENTS) return; log(Level.Trace, "Accepting %s request", secure ? "HTTPs" : "HTTP"); try { DriverInterface driver = null; @@ -81,7 +83,7 @@ class Server : Thread { if (driver is null) return; Client client = new Client(router, driver); client.start(); - if (nAliveFromIP(client.ip) <= MAX_CLIENTS_PER_IP) { + if (nAlivePerIP.from(client.ip, 0) <= MAX_CLIENTS_PER_IP) { persistent.put(client); } else { log(Level.Always, "Rate limit exceeded [%s]", client.ip); client.stop(); } } catch(Exception e) { error("Unable to accept connection: %s", e.msg); } @@ -96,22 +98,18 @@ class Server : Thread { } } } - final long nAliveFromIP(string ip) { synchronized { - long sum = 0; - foreach(Client client; clients){ if(client.running && client.ip == ip) sum++; } - return sum; - } } - // Stop all clients and shutdown the server final void stop(){ synchronized { foreach(ref Client client; clients){ client.stop(); } terminated = true; } } + final @property long nAlive() { return nAlivePerIP.byValue.sum; } + // Returns a Duration object holding the server uptime final @property Duration uptime() const { return(Clock.currTime() - starttime); } // Print some server information - final @property void info() { log(Level.Always, "Uptime %s, Connections: %d / %d", uptime(), nAlive(), clients.length); } + final @property void info() { log(Level.Always, "Uptime %s, Connections: %d / %d", uptime(), nAlive, clients.length); } // Hostname of the server final @property string hostname() { return(socket.hostName()); } @@ -120,11 +118,6 @@ class Server : Thread { final @property string accountKey() { return(sslPath ~ account); } } - // Number of alive connections - final @property long nAlive() { - long sum = 0; foreach(Client client; clients){ if(client.running){ sum++; } } return sum; - } - final void run() { Appender!(Client[]) persistent; SysTime lastScan = Clock.currTime(); @@ -134,8 +127,10 @@ class Server : Thread { persistent.clear(); // Clear the Appender accept(persistent, socket); version (SSL) { accept(persistent, sslsocket, true); } + + nAlivePerIP = null; foreach (Client client; previous) { // Foreach through the Slice reference - if(client.running) persistent.put(client); // Add the backlog of persistent clients + if(client.running) { nAlivePerIP[client.ip]++; persistent.put(client); } else if(!client.isRunning) client.join(); // join finished threads } clients = persistent.data; From c5ae5198415b8d813ba58b8cf6b6c2dad0e0b43b Mon Sep 17 00:00:00 2001 From: DannyArends Date: Wed, 18 Mar 2026 09:54:31 +0000 Subject: [PATCH 03/22] A singsISelect function for read and write --- danode/client.d | 1 - danode/functions.d | 4 ++-- danode/http.d | 6 +++--- danode/https.d | 2 ++ danode/interfaces.d | 12 ++++++++++-- danode/server.d | 4 ++-- 6 files changed, 19 insertions(+), 10 deletions(-) diff --git a/danode/client.d b/danode/client.d index a5bc551..6477a7a 100644 --- a/danode/client.d +++ b/danode/client.d @@ -81,7 +81,6 @@ class Client : Thread, ClientInterface { stop(); continue; } log(Level.Trace, "Connection %s:%s (%s msecs) %s", ip, port, starttime, to!string(driver.inbuffer.data)); - Thread.sleep(dur!"msecs"(2)); } this.log(request, response); } catch(Exception e) { log(Level.Verbose, "Unknown Client Exception: %s", e); stop(); diff --git a/danode/functions.d b/danode/functions.d index 7bb3d78..f30e49d 100644 --- a/danode/functions.d +++ b/danode/functions.d @@ -171,10 +171,10 @@ string browseDir(in string root, in string localpath) { } // Reset the socketset and add a server socket to the set -int sISelect(SocketSet set, Socket socket, int timeout = 10) { +int sISelect(SocketSet set, Socket socket, bool write = false, int timeout = 5) { set.reset(); set.add(socket); - return Socket.select(set, null, null, dur!"msecs"(timeout)); + return(write ? Socket.select(null, set, null, dur!"msecs"(timeout)) : Socket.select(set, null, null, dur!"msecs"(timeout))); } unittest { diff --git a/danode/http.d b/danode/http.d index 124d372..77bceb9 100644 --- a/danode/http.d +++ b/danode/http.d @@ -3,6 +3,8 @@ module danode.http; import danode.imports; + +import danode.functions : sISelect; import danode.interfaces : DriverInterface; import danode.response : Response; import danode.log : log, tag, error, Level; @@ -28,9 +30,7 @@ class HTTP : DriverInterface { override void send(ref Response response, Socket socket, ptrdiff_t maxsize = 4096) { if (!socketReady()) return; // Wait until socket is writable before sending - SocketSet writeSet = new SocketSet(); - writeSet.add(socket); - if (Socket.select(null, writeSet, null, dur!"msecs"(0)) <= 0) return; + if (socketSet.sISelect(socket, true, 0) <= 0) return; ptrdiff_t send = socket.send(response.bytes(maxsize)); if (send > 0) { log(Level.Trace, "Send result=%d index=%d length=%d", send, response.index, response.length); diff --git a/danode/https.d b/danode/https.d index b23be9e..7153a63 100644 --- a/danode/https.d +++ b/danode/https.d @@ -6,6 +6,7 @@ version(SSL) { import danode.imports; import danode.includes; + import danode.functions : sISelect; import danode.response : Response; import danode.log : tag, log, error, Level; import danode.interfaces : DriverInterface; @@ -87,6 +88,7 @@ version(SSL) { // Send upto maxsize bytes from the response to the client override void send(ref Response response, Socket socket, ptrdiff_t maxsize = 4096){ if (!socketReady()) return; + if (socketSet.sISelect(socket, true, 0) <= 0) return; // SSL requires retrying with exact same buffer on WANT_WRITE if (pending.length == 0) pending = response.bytes(maxsize).dup; if (pending.length == 0) return; diff --git a/danode/interfaces.d b/danode/interfaces.d index 5c57e3c..5430168 100644 --- a/danode/interfaces.d +++ b/danode/interfaces.d @@ -3,7 +3,7 @@ module danode.interfaces; import danode.imports; -import danode.functions : Msecs, bodystart, endofheader, fullheader; +import danode.functions : Msecs, sISelect, bodystart, endofheader, fullheader; import danode.response : Response; import danode.statuscode : StatusCode; import danode.log : log, error, Level; @@ -27,6 +27,7 @@ abstract class DriverInterface { public: Appender!(char[]) inbuffer; /// Input appender buffer Socket socket; /// Client socket for reading and writing + SocketSet socketSet; long requests = 0; /// Number of requests we handled long[long] senddata; /// Size of data send per request SysTime systime; /// Time in ms since this process came alive @@ -34,7 +35,13 @@ abstract class DriverInterface { Address address; /// Private address field bool blocking = false; /// Blocking communication ? - this(Socket socket, bool blocking = false) { this.socket = socket; this.blocking = blocking; systime = Clock.currTime(); touch(); } + this(Socket socket, bool blocking = false) { + this.socket = socket; + this.socketSet = new SocketSet(); + this.blocking = blocking; + systime = Clock.currTime(); + touch(); + } bool socketReady() const { if (socket !is null) { return socket.isAlive(); } return false; }; /// Is the connection alive ? void touch() { modtime = Clock.currTime(); } void closeSocket() { @@ -46,6 +53,7 @@ abstract class DriverInterface { // Receive upto maxsize of bytes from the client into the input buffer ptrdiff_t receive(Socket socket, ptrdiff_t maxsize = 4096) { if (!socketReady()) return(-1); + if (socketSet.sISelect(socket) <= 0) return(0); ptrdiff_t received; char[] tmpbuffer = new char[](maxsize); if ((received = receiveData(tmpbuffer)) > 0) { inbuffer.put(tmpbuffer[0 .. received]); touch(); } diff --git a/danode/server.d b/danode/server.d index 713ed4b..28a7c82 100644 --- a/danode/server.d +++ b/danode/server.d @@ -83,7 +83,7 @@ class Server : Thread { if (driver is null) return; Client client = new Client(router, driver); client.start(); - if (nAlivePerIP.from(client.ip, 0) <= MAX_CLIENTS_PER_IP) { + if (nAlivePerIP.from(client.ip, 0L) <= MAX_CLIENTS_PER_IP) { persistent.put(client); } else { log(Level.Always, "Rate limit exceeded [%s]", client.ip); client.stop(); } } catch(Exception e) { error("Unable to accept connection: %s", e.msg); } @@ -109,7 +109,7 @@ class Server : Thread { final @property Duration uptime() const { return(Clock.currTime() - starttime); } // Print some server information - final @property void info() { log(Level.Always, "Uptime %s, Connections: %d / %d", uptime(), nAlive, clients.length); } + final @property void info() { log(Level.Always, "Uptime %s, Connections: %d / %d", uptime, nAlive, clients.length); } // Hostname of the server final @property string hostname() { return(socket.hostName()); } From 4a8df5736301d3d9a05e7dceadece31178e24957 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Wed, 18 Mar 2026 09:57:56 +0000 Subject: [PATCH 04/22] Change defaults --- danode/functions.d | 2 +- danode/interfaces.d | 2 +- danode/server.d | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/danode/functions.d b/danode/functions.d index f30e49d..5d2320e 100644 --- a/danode/functions.d +++ b/danode/functions.d @@ -171,7 +171,7 @@ string browseDir(in string root, in string localpath) { } // Reset the socketset and add a server socket to the set -int sISelect(SocketSet set, Socket socket, bool write = false, int timeout = 5) { +int sISelect(SocketSet set, Socket socket, bool write = false, int timeout = 25) { set.reset(); set.add(socket); return(write ? Socket.select(null, set, null, dur!"msecs"(timeout)) : Socket.select(set, null, null, dur!"msecs"(timeout))); diff --git a/danode/interfaces.d b/danode/interfaces.d index 5430168..5015685 100644 --- a/danode/interfaces.d +++ b/danode/interfaces.d @@ -53,7 +53,7 @@ abstract class DriverInterface { // Receive upto maxsize of bytes from the client into the input buffer ptrdiff_t receive(Socket socket, ptrdiff_t maxsize = 4096) { if (!socketReady()) return(-1); - if (socketSet.sISelect(socket) <= 0) return(0); + if (socketSet.sISelect(socket, false, 25) <= 0) return(0); ptrdiff_t received; char[] tmpbuffer = new char[](maxsize); if ((received = receiveData(tmpbuffer)) > 0) { inbuffer.put(tmpbuffer[0 .. received]); touch(); } diff --git a/danode/server.d b/danode/server.d index 28a7c82..298e308 100644 --- a/danode/server.d +++ b/danode/server.d @@ -74,7 +74,7 @@ class Server : Thread { // Accept an incoming connection and create a client object final void accept(ref Appender!(Client[]) persistent, Socket socket, bool secure = false) { - if (set.sISelect(socket) <= 0 || nAlive >= MAX_CLIENTS) return; + if (set.sISelect(socket, false, 5) <= 0 || nAlive >= MAX_CLIENTS) return; log(Level.Trace, "Accepting %s request", secure ? "HTTPs" : "HTTP"); try { DriverInterface driver = null; From c9a08594fcf6560bd149fe572d91efc2f761fe03 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Wed, 18 Mar 2026 10:02:25 +0000 Subject: [PATCH 05/22] fromStringz --- danode/ssl.d | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/danode/ssl.d b/danode/ssl.d index 07a2a58..af868d4 100644 --- a/danode/ssl.d +++ b/danode/ssl.d @@ -54,7 +54,7 @@ version(SSL) { ptrdiff_t findContext(string hostname) { for (size_t x = 0; x < contexts.length; x++) { - if (hostname.endsWith(to!string(contexts[x].hostname.ptr))) return(x); + if (hostname.endsWith(fromStringz(contexts[x].hostname))) return(x); } return(-1); } @@ -129,7 +129,7 @@ version(SSL) { foreach (DirEntry d; dirEntries(sslPath, SpanMode.shallow)) { if (d.name.endsWith(".chain")) { string hostname = baseName(d.name, ".chain"); - if (hostname.length < 255) { + if (hostname.length < 254) { string chainFile = d.name; log(Level.Verbose, "SSL: [I] Reloading certificate at: '%s'", chainFile); auto lc = loadContext(chainFile, hostname, sslKey); From 2e032a30fdf676da4701e9b5d3339e1265e4c303 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Wed, 18 Mar 2026 10:09:14 +0000 Subject: [PATCH 06/22] Fixing a race condition possible during daily reload via loadSSL --- danode/ssl.d | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/danode/ssl.d b/danode/ssl.d index af868d4..96fd6d5 100644 --- a/danode/ssl.d +++ b/danode/ssl.d @@ -29,14 +29,22 @@ version(SSL) { string hostname = to!(string)(cast(const(char*)) SSL_get_servername(ssl, TLSEXT_NAMETYPE_host_name)); log(Level.Verbose, "SSL: [I] Looking for hostname: %s", hostname); if(hostname is null) { log(Level.Verbose, "SSL: [W] Client no SNI support, using default: contexts[0]"); return; } - ptrdiff_t idx = findContext(hostname); - if (idx >= 0) { - log(Level.Verbose, "SSL: [I] Switching SSL context to %s", hostname); - SSL_set_SSL_CTX(ssl, contexts[idx].context); - }else{ error("SSL: Callback failed to find certificate for %s", hostname); } + synchronized(contextsMutex) { + ptrdiff_t idx = findContext(hostname); + if (idx >= 0) { + log(Level.Verbose, "SSL: [I] Switching SSL context to %s", hostname); + SSL_set_SSL_CTX(ssl, contexts[idx].context); + }else{ error("SSL: Callback failed to find certificate for %s", hostname); } + } } } + __gshared Mutex contextsMutex; + + shared static this() { contextsMutex = new Mutex(); } + ptrdiff_t findContextLocked(string hostname) { synchronized(contextsMutex) { return findContext(hostname); } } + + void generateKey(string path, int bits = 4096) { if (path.exists()) return; log(Level.Always, "SSL: Generating %d-bit RSA key at %s", bits, path); From 2517d943d0c8f7bede524d6573d24b1127d590e4 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Wed, 18 Mar 2026 10:11:30 +0000 Subject: [PATCH 07/22] Fixing a race condition possible during daily reload via loadSSL --- danode/ssl.d | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/danode/ssl.d b/danode/ssl.d index 96fd6d5..86e1a80 100644 --- a/danode/ssl.d +++ b/danode/ssl.d @@ -42,8 +42,6 @@ version(SSL) { __gshared Mutex contextsMutex; shared static this() { contextsMutex = new Mutex(); } - ptrdiff_t findContextLocked(string hostname) { synchronized(contextsMutex) { return findContext(hostname); } } - void generateKey(string path, int bits = 4096) { if (path.exists()) return; @@ -92,7 +90,8 @@ version(SSL) { // Does the hostname requested have a certificate ? bool hasCertificate(string hostname) { - bool found = (findContext(hostname) >= 0); + bool found; + synchronized(contextsMutex) { found = (findContext(hostname) >= 0); } log(Level.Trace, "SSL: [T] '%s' certificate? %s", hostname, found); return found; } @@ -145,7 +144,7 @@ version(SSL) { } } } - contexts = localContexts; // atomic single assignment + synchronized(contextsMutex) { contexts = localContexts; } log(Level.Always, "SSL: [I] Loaded %s SSL certificates", contexts.length); } @@ -154,8 +153,10 @@ version(SSL) { log(Level.Verbose, "SSL: [I] Closing server SSL socket"); socket.close(); log(Level.Verbose, "SSL: [I] Cleaning up %d SSL contexts", contexts.length); - foreach (ref ctx; contexts) { SSL_CTX_free(ctx.context); } - contexts = null; + synchronized(contextsMutex) { + foreach (ref ctx; contexts) { SSL_CTX_free(ctx.context); } + contexts = null; + } } void sslAssert(bool ret) { if (!ret) { ERR_print_errors_fp(null); throw new Exception("SSL_ERROR"); } } From f854fe9513b87b0a7e3a377cb6854a5973cec73a Mon Sep 17 00:00:00 2001 From: DannyArends Date: Wed, 18 Mar 2026 13:13:50 +0000 Subject: [PATCH 08/22] =?UTF-8?q?Magic=20=F0=9F=AA=84=20numeric=20constant?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- danode/ssl.d | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/danode/ssl.d b/danode/ssl.d index 86e1a80..a688e03 100644 --- a/danode/ssl.d +++ b/danode/ssl.d @@ -17,6 +17,9 @@ version(SSL) { alias size_t VERSION; immutable VERSION SSL23 = 0, SSL3 = 1, TLS1 = 2, DTLS1 = 3; + immutable int EVP_PKEY_RSA = 6; + immutable int EVP_PKEY_OP_KEYGEN = 8; + immutable int EVP_PKEY_CTRL_RSA_KEYGEN_BITS = 1; alias ExternC(T) = SetFunctionAttributes!(T, "C", functionAttributes!T); @@ -46,10 +49,10 @@ version(SSL) { void generateKey(string path, int bits = 4096) { if (path.exists()) return; log(Level.Always, "SSL: Generating %d-bit RSA key at %s", bits, path); - EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(6, null); // 6 = EVP_PKEY_RSA + EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, null); scope(exit) EVP_PKEY_CTX_free(ctx); EVP_PKEY_keygen_init(ctx); - EVP_PKEY_CTX_ctrl(ctx, 6, 8, 1, bits, null); // set_rsa_keygen_bits: op=KEYGEN(8), ctrl=KEYBITS(1) + EVP_PKEY_CTX_ctrl(ctx, EVP_PKEY_RSA, EVP_PKEY_OP_KEYGEN, EVP_PKEY_CTRL_RSA_KEYGEN_BITS, bits, null); EVP_PKEY* pkey; if (EVP_PKEY_keygen(ctx, &pkey) <= 0) { error("SSL: Keygen failed for %s", path); return; } scope(exit) EVP_PKEY_free(pkey); From f89a4acedf6b2ef916ae899a37e1945566d6cf53 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Wed, 18 Mar 2026 13:21:22 +0000 Subject: [PATCH 09/22] fix: environment.toAA() leaks full server environment --- danode/request.d | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/danode/request.d b/danode/request.d index c538b94..279af92 100644 --- a/danode/request.d +++ b/danode/request.d @@ -167,7 +167,7 @@ struct Request { } final string[string] environ(string localpath) const { - string[string] env = environment.toAA(); + string[string] env; env["REQUEST_METHOD"] = to!string(method); env["QUERY_STRING"] = query.length > 1 ? query[1 .. $] : ""; env["REQUEST_URI"] = decodeComponent(uripath); @@ -179,7 +179,8 @@ struct Request { env["HTTP_HOST"] = host; env["HTTPS"] = isSecure ? "on" : ""; env["REDIRECT_STATUS"] = "200"; - foreach (k, v; headers) env["HTTP_" ~ k.toUpper().replace("-", "_")] = v; + env["PATH"] = environment.get("PATH", ""); + foreach (k, v; headers) { env["HTTP_" ~ k.toUpper().replace("-", "_")] = v; } return env; } From bcff4f2da78d87bbbadd16574cada2c7b4012295 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Wed, 18 Mar 2026 13:29:20 +0000 Subject: [PATCH 10/22] webconfig from cache --- danode/router.d | 16 ++++++++-------- danode/webconfig.d | 9 ++++++++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/danode/router.d b/danode/router.d index 6367d14..e9bcbcd 100644 --- a/danode/router.d +++ b/danode/router.d @@ -10,7 +10,7 @@ import danode.statuscode : StatusCode; import danode.request : Request; import danode.response : Response, setPayload, create, badRequest, domainNotFound, forbidden, redirect, serveCGI, serveDirectory, notFound; import danode.files : serveStaticFile; -import danode.webconfig : WebConfig; +import danode.webconfig : getConfig, WebConfig; import danode.functions : isCGI, isFILE, isDIR, isAllowed, safePath; import danode.filesystem : FileSystem; import danode.post : parsePost; @@ -23,7 +23,7 @@ version(SSL) { class Router { private: FileSystem filesystem; - WebConfig config; + WebConfig[string] configs; Address address; public: @@ -60,7 +60,7 @@ class Router { version(SSL) { if (serveACMEChallenge(request, response)) return; } - config = WebConfig(filesystem.file(localroot, "/web.config")); + auto config = getConfig(configs, filesystem.file(localroot, "/web.config"), localroot); string fqdn = config.domain(request.shorthost()); string localpath = safePath(localroot, decodeComponent(request.path)); if (localpath is null) return(response.forbidden()); @@ -95,10 +95,10 @@ class Router { } if (pathIsDIR && config.dirAllowed(localroot, localpath)) { log(Level.Trace, "Router: [T] localpath %s is a directory [%s,%s]", localpath, config.redirectdir(), config.index()); - if (config.redirectdir() && !finalrewrite) { return(redirectDirectory(request, response)); } + if (config.redirectdir() && !finalrewrite) { return(redirectDirectory(config, request, response)); } if (config.redirect() && exists(localpath ~ "/" ~ config.index()) && !finalrewrite) { if (!config.allowcgi) return(response.notFound()); - return(redirectCanonical(request, response)); + return(redirectCanonical(config, request, response)); } return(response.serveDirectory(request, config, filesystem)); } @@ -108,7 +108,7 @@ class Router { log(Level.Trace, "Router: [T] Redirect: %s %d", config.redirect, finalrewrite); if(config.redirect && !finalrewrite) { if (!config.allowcgi) return(response.notFound()); - return(this.redirectCanonical(request, response)); + return(this.redirectCanonical(config, request, response)); } return(response.notFound()); // Request is not hosted on this server } @@ -134,14 +134,14 @@ class Router { final void scan() { filesystem.scan(); } // Redirect a directory browsing request to the index script - void redirectDirectory(ref Request request, ref Response response){ + void redirectDirectory(WebConfig config, ref Request request, ref Response response){ log(Level.Trace, "Router: [T] Redirecting directory request to index page"); request.redirectdir(config); return deliver(request, response, true); } // Perform a canonical redirect of a non-existing page to the index script - void redirectCanonical(ref Request request, ref Response response){ + void redirectCanonical(WebConfig config, ref Request request, ref Response response){ log(Level.Trace, "Router: [T] Redirecting canonical url to the index page"); request.url = format("%s?%s", config.index, request.query); return deliver(request, response, true); diff --git a/danode/webconfig.d b/danode/webconfig.d index d610d21..3c1c94e 100644 --- a/danode/webconfig.d +++ b/danode/webconfig.d @@ -9,9 +9,11 @@ import danode.files : FilePayload; import danode.log : log, tag, Level; struct WebConfig { - string[string] data; + string[string] data; + SysTime mtime; this(FilePayload file, string def = "no") { + mtime = file.mtime; string[] elements; foreach (line; split(file.content, "\n")) { if (chomp(strip(line)) != "" && line[0] != '#') { @@ -70,6 +72,11 @@ struct WebConfig { } } +WebConfig getConfig(ref WebConfig[string] configs, FilePayload fp, string key) { + if (key !in configs || fp.mtime > configs[key].mtime) { configs[key] = WebConfig(fp); } + return configs[key]; +} + unittest { tag(Level.Always, "FILE", "%s", __FILE__); import danode.filesystem : FileSystem; From ccff1c2591b97ba2c8c96f1acf499f39cc2dd6c8 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Wed, 18 Mar 2026 13:41:51 +0000 Subject: [PATCH 11/22] Minor tweaks --- danode/client.d | 2 +- danode/router.d | 4 ++-- danode/webconfig.d | 33 ++++++++++++++++++--------------- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/danode/client.d b/danode/client.d index 6477a7a..99b803b 100644 --- a/danode/client.d +++ b/danode/client.d @@ -118,7 +118,7 @@ void log(in ClientInterface cl, in Request rq, in Response rs) { int code = cast(int)(rs.payload ? rs.statuscode.code : 0); long ms = rq.starttime == SysTime.init ? -1 : Msecs(rq.starttime); tag(Level.Always, format("%d", code), - "%s %s:%s %s%s [%d] %s %skb", htmltime(), cl.ip, cl.port, rq.shorthost, uri.replace("%", "%%"), cl.requests, ms, bytes/1024); + "%s %s:%s %s%s [%d] %.1fkb in %s ms ", htmltime(), cl.ip, cl.port, rq.shorthost, uri.replace("%", "%%"), cl.requests, bytes/1024f, ms); } // serve a 408 connection timed out page diff --git a/danode/router.d b/danode/router.d index e9bcbcd..8eb990a 100644 --- a/danode/router.d +++ b/danode/router.d @@ -61,8 +61,8 @@ class Router { version(SSL) { if (serveACMEChallenge(request, response)) return; } auto config = getConfig(configs, filesystem.file(localroot, "/web.config"), localroot); - string fqdn = config.domain(request.shorthost()); - string localpath = safePath(localroot, decodeComponent(request.path)); + auto fqdn = config.domain(request.shorthost()); + auto localpath = safePath(localroot, decodeComponent(request.path)); if (localpath is null) return(response.forbidden()); bool pathExists = localpath.exists(); diff --git a/danode/webconfig.d b/danode/webconfig.d index 3c1c94e..0946e9c 100644 --- a/danode/webconfig.d +++ b/danode/webconfig.d @@ -9,24 +9,27 @@ import danode.files : FilePayload; import danode.log : log, tag, Level; struct WebConfig { - string[string] data; - SysTime mtime; - - this(FilePayload file, string def = "no") { - mtime = file.mtime; - string[] elements; - foreach (line; split(file.content, "\n")) { - if (chomp(strip(line)) != "" && line[0] != '#') { - elements = split(line, "="); - string key = toLower(chomp(strip(elements[0]))); - if (elements.length == 1) { - data[key] = def; - }else if (elements.length >= 2) { - data[key] = toLower(chomp(strip(join(elements[1 .. $], "=")))); + private: + string[string] data; + SysTime mtime; + + public: + + this(FilePayload file, string def = "no") { + mtime = file.mtime; + string[] elements; + foreach (line; split(file.content, "\n")) { + if (chomp(strip(line)) != "" && line[0] != '#') { + elements = split(line, "="); + string key = toLower(chomp(strip(elements[0]))); + if (elements.length == 1) { + data[key] = def; + }else if (elements.length >= 2) { + data[key] = toLower(chomp(strip(join(elements[1 .. $], "=")))); + } } } } - } private @nogc bool flag(string key, string def, string match) const nothrow { return data.from(key, def) == match; } From eeb107c3777d3cdc37da35f12f71016a2cc98d6d Mon Sep 17 00:00:00 2001 From: DannyArends Date: Wed, 18 Mar 2026 13:52:33 +0000 Subject: [PATCH 12/22] Minor updates to post.d (fix: ConvException) --- danode/post.d | 15 +++++++++------ danode/response.d | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/danode/post.d b/danode/post.d index 4ce9a3c..5ed259a 100644 --- a/danode/post.d +++ b/danode/post.d @@ -36,15 +36,19 @@ struct PostItem { final bool parsePost(ref Request request, ref Response response, in FileSystem filesystem) { if (response.havepost || request.method != RequestMethod.POST) { return(response.havepost = true); } - long expectedlength = to!long(from(request.headers, "Content-Length", "0")); + long expectedlength; + try { + expectedlength = to!long(from(request.headers, "Content-Length", "0")); + } catch (Exception e) { + return(response.setPayload(StatusCode.BadRequest, "400 - Bad Request\n", "text/plain")); + } string content = request.body; if (expectedlength == 0) { log(Level.Trace, "Post: [T] Content-Length not specified (or 0), length: %s", content.length); return(response.havepost = true); // When we don't receive any post data it is meaningless to scan for any content } else if (expectedlength > MAX_REQUEST_SIZE) { log(Level.Verbose, "Post: [W] Upload too large: %d bytes from %s", expectedlength, request.ip); - response.setPayload(StatusCode.PayloadTooLarge, "413 - Payload Too Large\n", "text/plain"); - return(response.havepost = true); + return(response.setPayload(StatusCode.PayloadTooLarge, "413 - Payload Too Large\n", "text/plain")); } log(Level.Trace, "Post: [T] Received %s of %s", content.length, expectedlength); if(content.length < expectedlength) return(false); @@ -58,7 +62,7 @@ final bool parsePost(ref Request request, ref Response response, in FileSystem f log(Level.Verbose, "XFORM: [T] # of items: %s", request.postinfo.length); } else if (contenttype.indexOf(MPHEADER) >= 0) { auto parts = split(contenttype, "boundary="); - if (parts.length < 2) return response.havepost = true; + if (parts.length < 2) return(response.havepost = true); string mpid = parts[1]; log(Level.Verbose, "MPART: [I] header: %s, parsing %d bytes", mpid, expectedlength); request.parseMultipart(filesystem, content, mpid); @@ -70,8 +74,7 @@ final bool parsePost(ref Request request, ref Response response, in FileSystem f error("parsePost: Unsupported POST content type: %s [%s] -> %s", contenttype, expectedlength, content); request.parseXform(content); } - response.havepost = true; - return(response.havepost); + return(response.havepost = true); } // Parse X-form content in the body of the request diff --git a/danode/response.d b/danode/response.d index c264502..be8d4d2 100644 --- a/danode/response.d +++ b/danode/response.d @@ -136,7 +136,7 @@ Response create(in Request request, Address address, in StatusCode statuscode = bool setPayload(ref Response response, StatusCode code, string msg = "", in string mimetype = UNSUPPORTED_FILE) { response.payload = new Message(code, msg, mimetype); - return(response.ready = true); + return(response.ready = response.havepost = true); } // send a redirect permanently response From e2a7ce53420c0a2cc7fdee2feb41690bd6dd409b Mon Sep 17 00:00:00 2001 From: DannyArends Date: Wed, 18 Mar 2026 13:53:22 +0000 Subject: [PATCH 13/22] Minor --- danode/post.d | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/danode/post.d b/danode/post.d index 5ed259a..64ad99e 100644 --- a/danode/post.d +++ b/danode/post.d @@ -38,7 +38,7 @@ final bool parsePost(ref Request request, ref Response response, in FileSystem f long expectedlength; try { - expectedlength = to!long(from(request.headers, "Content-Length", "0")); + expectedlength = to!long(request.headers.from("Content-Length", "0")); } catch (Exception e) { return(response.setPayload(StatusCode.BadRequest, "400 - Bad Request\n", "text/plain")); } From ab74ddca19c8c8ce4b7b5123e6e0b1de2b6cf8fd Mon Sep 17 00:00:00 2001 From: DannyArends Date: Wed, 18 Mar 2026 13:58:24 +0000 Subject: [PATCH 14/22] Fix: TOCTOU Race condition --- danode/files.d | 1 + 1 file changed, 1 insertion(+) diff --git a/danode/files.d b/danode/files.d index e4f72ec..2317846 100644 --- a/danode/files.d +++ b/danode/files.d @@ -86,6 +86,7 @@ class FilePayload : Payload { Updates the buffer time and status. */ final void buffer() { synchronized { + if (!needsupdate()) return; // re-check under lock if(buf is null) buf = new char[](fileSize()); buf.length = fileSize(); try { From 55718f6159b5b8837f3f33b870b9b7938aa3b61a Mon Sep 17 00:00:00 2001 From: DannyArends Date: Thu, 19 Mar 2026 10:50:23 +0000 Subject: [PATCH 15/22] No \n --- www/localhost/keepalive.d | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/localhost/keepalive.d b/www/localhost/keepalive.d index 5593227..f43ee21 100644 --- a/www/localhost/keepalive.d +++ b/www/localhost/keepalive.d @@ -35,7 +35,7 @@ void main(string[] args){ } htmlpage.put(" "); - htmlpage.put("\n"); + htmlpage.put(""); // Write headers writeln("HTTP/1.1 200 OK"); From 24a73f1e888f88802676a5e794912ecba950c104 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Thu, 19 Mar 2026 10:52:20 +0000 Subject: [PATCH 16/22] /dev/null is NUL under windows --- danode/process.d | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/danode/process.d b/danode/process.d index f63e920..a8eafb7 100644 --- a/danode/process.d +++ b/danode/process.d @@ -190,7 +190,10 @@ class Process : Thread { unittest { tag(Level.Always, "FILE", "%s", __FILE__); - auto p = new Process(["rdmd", "www/localhost/sse.d"], "/dev/null", null, false); + immutable string nulldev = "/dev/null"; + version(Windows) nulldev = "NUL"; + + auto p = new Process(["rdmd", "www/localhost/sse.d"], nulldev, null, false); p.start(); while(!p.finished){ Thread.sleep(msecs(5)); } assert(p.status() == 0, "process must exit 0"); From 166647aceb6cdbf3dd54c6c534fb078bc94af4e8 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Thu, 19 Mar 2026 11:04:18 +0000 Subject: [PATCH 17/22] Cannot modify immutable --- danode/process.d | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/danode/process.d b/danode/process.d index a8eafb7..de924ad 100644 --- a/danode/process.d +++ b/danode/process.d @@ -190,7 +190,7 @@ class Process : Thread { unittest { tag(Level.Always, "FILE", "%s", __FILE__); - immutable string nulldev = "/dev/null"; + string nulldev = "/dev/null"; version(Windows) nulldev = "NUL"; auto p = new Process(["rdmd", "www/localhost/sse.d"], nulldev, null, false); From d9a837a5b4556b195c9a133a49a15fd2fab5264a Mon Sep 17 00:00:00 2001 From: DannyArends Date: Thu, 19 Mar 2026 11:08:28 +0000 Subject: [PATCH 18/22] Fix: split(:) returns an empty --- danode/response.d | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/danode/response.d b/danode/response.d index be8d4d2..675e1ff 100644 --- a/danode/response.d +++ b/danode/response.d @@ -112,7 +112,9 @@ bool buildScriptHeader(ref Appender!(char[]) hdr, ref string connection, CGI scr hdr.put(format("%s %d %s\r\n", protocol, script.statuscode, script.statuscode.reason)); } foreach (line; scriptheader.split("\n")) { - if (line.length > 0 && icmp(strip(line).split(":")[0], "connection") != 0){ hdr.put(line ~ "\n"); } + auto stripped = strip(line); + auto parts = stripped.split(":"); + if (stripped.length > 0 && parts.length > 0 && icmp(parts[0], "connection") != 0) { hdr.put(line ~ "\n"); } } hdr.put(format("Connection: %s\r\n\r\n", connection)); return true; From dcd2fa2d0c10296ee28e91bb6e08f1e6c08c6a58 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Thu, 19 Mar 2026 11:12:20 +0000 Subject: [PATCH 19/22] adding synchronized {} to rebuffer() --- danode/filesystem.d | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/danode/filesystem.d b/danode/filesystem.d index df833ef..6c749f0 100644 --- a/danode/filesystem.d +++ b/danode/filesystem.d @@ -97,11 +97,9 @@ class FileSystem { /* Rebuffer all file domains from disk, By reusing domain keys so, we don't buffer new domains. This is ok since we would need to load SSL */ - final void rebuffer() { - foreach(ref d; domains.byKey){ foreach(ref f; domains[d].files.byKey){ - domains[d].files[f].buffer(); - } } - } + final void rebuffer() { synchronized { + foreach(ref d; domains.byValue) { foreach(ref f; d.files.byValue) { f.buffer(); } } + } } } /* Basic unit-tests should be extended */ From 5a22d0f697c5b7a77be59295ec03f5e7cd88c111 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Thu, 19 Mar 2026 11:20:48 +0000 Subject: [PATCH 20/22] Minor tweaks to buffering --- danode/files.d | 22 ++++++++++++---------- danode/filesystem.d | 5 +---- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/danode/files.d b/danode/files.d index 2317846..6c44af6 100644 --- a/danode/files.d +++ b/danode/files.d @@ -73,11 +73,12 @@ class FilePayload : Payload { /* Does the file require to be updated before sending ? */ final bool needsupdate() { if (!isStaticFile()) return false; // CGI files are never buffered, since they are executed - if (fileSize() > 0 && fileSize() < buffermaxsize) { // + ptrdiff_t sz = fileSize(); + if (sz > 0 && sz < buffermaxsize) { // if (!buffered) { log(Level.Trace, "File: '%s' needs buffering", path); return true; } if (mtime > btime) { log(Level.Trace, "File: '%s' stale record", path); return true; } }else{ - log(Level.Verbose, "File: '%s' exceeds buffer (%dkb > %dkb)", path, fileSize() / 1024, buffermaxsize / 1024); + log(Level.Verbose, "File: '%s' exceeds buffer (%.1fkb > %.1fkb)", path, sz / 1024f, buffermaxsize / 1024f); } return false; } @@ -85,23 +86,24 @@ class FilePayload : Payload { /* Reads the file into the internal buffer, and compress the buffer to the enc buffer Updates the buffer time and status. */ - final void buffer() { synchronized { - if (!needsupdate()) return; // re-check under lock - if(buf is null) buf = new char[](fileSize()); - buf.length = fileSize(); + final bool buffer() { synchronized { + if (!needsupdate()) return(false); // re-check under lock + ptrdiff_t sz = fileSize(); + if(buf is null) buf = new char[](sz); + buf.length = sz; try { auto f = File(path, "rb"); f.rawRead(buf); f.close(); - } catch (Exception e) { error("Exception during buffering '%s': %s", path, e.msg); return; } + } catch (Exception e) { error("Exception during buffering '%s': %s", path, e.msg); return(false); } try { auto c = new Compress(6, HeaderFormat.gzip); encbuf = cast(char[])(c.compress(buf)); encbuf ~= cast(char[])(c.flush()); - } catch (Exception e) { error("Exception during compressing '%s': %s", path, e.msg); } + } catch (Exception e) { error("Exception during compressing '%s': %s", path, e.msg); return(false); } btime = Clock.currTime(); - log(Level.Trace, "File: '%s' buffered %d|%d bytes", path, fileSize(), encbuf.length); - buffered = true; + log(Level.Trace, "File: '%s' buffered %.1fkb|%.1fkb", path, sz / 1024f, encbuf.length / 1024f); + return(buffered = true); } } /* Whole file content served via the bytes function */ diff --git a/danode/filesystem.d b/danode/filesystem.d index 6c749f0..b80a1a1 100644 --- a/danode/filesystem.d +++ b/danode/filesystem.d @@ -58,10 +58,7 @@ class FileSystem { if (!domain.files.has(shortname)) { domain.files[shortname] = new FilePayload(f.name, maxsize); domain.entries++; - if (domain.files[shortname].needsupdate()) { - domain.files[shortname].buffer(); - domain.buffered++; - } + if(domain.files[shortname].buffer()) { domain.buffered++; } } } } From 346bf6aba0867652637099793763ef786aa69fd5 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Thu, 19 Mar 2026 11:26:10 +0000 Subject: [PATCH 21/22] BN extraction biolerplate to extractRSAParams --- danode/acme.d | 35 +++++++++++++---------------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/danode/acme.d b/danode/acme.d index 5a8fa9c..440b454 100644 --- a/danode/acme.d +++ b/danode/acme.d @@ -204,21 +204,23 @@ version(SSL) { return JSONValue.init; } - // Compute SHA256 thumbprint of the public JWK - string jwkThumbprint(EVP_PKEY* pkey) { - // JWK thumbprint requires canonical JSON: sorted keys, no whitespace + // Extract RSA public key parameters as byte arrays + void extractRSAParams(EVP_PKEY* pkey, out ubyte[] nbuf, out ubyte[] ebuf) { BIGNUM* bn_n = BN_new(); BIGNUM* bn_e = BN_new(); + scope(exit) { BN_free(bn_n); BN_free(bn_e); } EVP_PKEY_get_bn_param(pkey, "n", &bn_n); EVP_PKEY_get_bn_param(pkey, "e", &bn_e); - int nlen = BN_num_bytes(bn_n); - int elen = BN_num_bytes(bn_e); - ubyte[] nbuf = new ubyte[](nlen); - ubyte[] ebuf = new ubyte[](elen); + nbuf = new ubyte[](BN_num_bytes(bn_n)); + ebuf = new ubyte[](BN_num_bytes(bn_e)); BN_bn2bin(bn_n, nbuf.ptr); BN_bn2bin(bn_e, ebuf.ptr); - BN_free(bn_n); - BN_free(bn_e); + } + + // Compute SHA256 thumbprint of the public JWK + string jwkThumbprint(EVP_PKEY* pkey) { + ubyte[] nbuf, ebuf; + pkey.extractRSAParams(nbuf, ebuf); // RFC 7638 canonical form - keys must be sorted alphabetically string canonical = `{"e":"` ~ b64url(ebuf) ~ `","kty":"RSA","n":"` ~ b64url(nbuf) ~ `"}`; @@ -253,19 +255,8 @@ version(SSL) { // Extract public key as JWK JSON (for newAccount header) string jwkPublic(EVP_PKEY* pkey) { - BIGNUM* bn_n = BN_new(); - BIGNUM* bn_e = BN_new(); - EVP_PKEY_get_bn_param(pkey, "n", &bn_n); - EVP_PKEY_get_bn_param(pkey, "e", &bn_e); - - int nlen = BN_num_bytes(bn_n); - int elen = BN_num_bytes(bn_e); - ubyte[] nbuf = new ubyte[](nlen); - ubyte[] ebuf = new ubyte[](elen); - BN_bn2bin(bn_n, nbuf.ptr); - BN_bn2bin(bn_e, ebuf.ptr); - BN_free(bn_n); - BN_free(bn_e); + ubyte[] nbuf, ebuf; + pkey.extractRSAParams(nbuf, ebuf); JSONValue jwk = ["kty": JSONValue("RSA"), "n": JSONValue(b64url(nbuf)), "e": JSONValue(b64url(ebuf))]; return toJSON(jwk); From f100f125dfbeb3708a89748b9cf61894c3bdae73 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Thu, 19 Mar 2026 11:29:43 +0000 Subject: [PATCH 22/22] More code minification --- danode/acme.d | 8 +++----- danode/log.d | 10 +++------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/danode/acme.d b/danode/acme.d index 440b454..e6f7b2d 100644 --- a/danode/acme.d +++ b/danode/acme.d @@ -55,6 +55,8 @@ version(SSL) { JSONValue order = newOrder(dir, pkey, kid, domain, orderURL); log(Level.Verbose, "ACME: order: %s, orderURL: %s", order.toString(), orderURL); string[] tokens; + scope(exit) synchronized(getAcmeMutex()) { foreach (t; tokens) acmeChallenges.remove(t); } + foreach (authURL; order["authorizations"].array) { JSONValue challenge = getHTTP01Challenge(authURL.str, dir, pkey, kid); if (challenge.type == JSONType.null_) return false; @@ -62,11 +64,7 @@ version(SSL) { triggerChallenge(challenge, dir, pkey, kid); } - if (!pollAllAuthorizations(order, dir, pkey, kid)) { - foreach (t; tokens) synchronized(getAcmeMutex()) { acmeChallenges.remove(t); } - return false; - } - foreach (t; tokens) synchronized(getAcmeMutex()) { acmeChallenges.remove(t); } + if (!pollAllAuthorizations(order, dir, pkey, kid)) { return(false); } JSONValue finalized = finalizeOrder(order, dir, pkey, kid, csrPath); log(Level.Verbose, "ACME: order status: %s, finalized: %s", finalized["status"].str, finalized.toString()); diff --git a/danode/log.d b/danode/log.d index 1ec3f08..8436795 100644 --- a/danode/log.d +++ b/danode/log.d @@ -15,13 +15,9 @@ private void logTo(A...)(ref File fp, string tag, const string fmt, auto ref A a synchronized(logM) { fp.writeln(format("[%s] %s", tag, format(fmt, args))); fp.flush(); } } -void log(A...)(Level lvl, const string fmt, auto ref A args) { - if(atomicLoad(cv) >= lvl) stdout.logTo("LOG", fmt, args); -} -void tag(A...)(Level lvl, const string tag, const string fmt, auto ref A args) { - if(atomicLoad(cv) >= lvl) stdout.logTo(tag, fmt, args); -} - +void log(A...)(Level lvl, const string fmt, auto ref A args) { tag(lvl, "LOG", fmt, args); } +void tag(A...)(Level lvl, const string tag, const string fmt, auto ref A args) { if(atomicLoad(cv) >= lvl) stdout.logTo(tag, fmt, args); } void error(A...)(const string fmt, auto ref A args) { stderr.logTo("ERR", fmt, args); } void abort(in string s, int exitcode = -1) { error(s); exit(exitcode); } void expect(A...)(bool cond, string msg, auto ref A args) { if (!cond) abort(format(msg, args)); } +