diff --git a/.github/workflows/d.yml b/.github/workflows/d.yml index e765db5..962a70a 100644 --- a/.github/workflows/d.yml +++ b/.github/workflows/d.yml @@ -4,19 +4,47 @@ name: D on: push: - branches: [ master ] + branches: [ development, master ] pull_request: branches: [ master ] jobs: build: - runs-on: ubuntu-slim - + runs-on: ubuntu-latest + timeout-minutes: 60 steps: - - uses: actions/checkout@v4 - - uses: dlang-community/setup-dlang@v1 + - uses: actions/checkout@v5 + with: + submodules: recursive + - uses: dlang-community/setup-dlang@v2 + + - name: 'Install OpenSSL build deps' + run: sudo apt-get update && sudo apt-get install -y build-essential perl + + - name: 'OpenSSL submodule SHA' + id: openssl-sha + run: echo "sha=$(git rev-parse HEAD:deps/openssl)" >> "$GITHUB_OUTPUT" + + - name: 'Cache OpenSSL build' + id: cache-openssl + uses: actions/cache@v5 + with: + path: deps/openssl + key: openssl-${{ runner.os }}-${{ steps.openssl-sha.outputs.sha }} - - name: 'Build & Test' + - name: 'Build OpenSSL submodule' + if: steps.cache-openssl.outputs.cache-hit != 'true' + run: | + cd deps/openssl + ./Configure linux-x86_64 no-tests + make -j$(nproc) + + - name: 'Build' run: | dub build --compiler=$DC - dub test --compiler=$DC \ No newline at end of file + dub build --compiler=$DC --config=ssl + + - name: 'Test' + run: | + dub test --compiler=$DC + dub test --compiler=$DC --config=ssl \ No newline at end of file diff --git a/danode/https.d b/danode/https.d index e2cd3a4..96ea242 100644 --- a/danode/https.d +++ b/danode/https.d @@ -83,7 +83,7 @@ version(SSL) { } override long receiveData(ref char[] buffer) { return(SSL_read(ssl, cast(void*) buffer, cast(int)buffer.length)); } - override ptrdiff_t bufferedBytes() { return(ssl !is null ? SSL_pending(ssl) : 0); } + override bool hasBuffered() const { return(ssl !is null && SSL_pending(cast(SSL*) ssl) > 0); } // Send upto maxsize bytes from the response to the client override void send(ref Response response, Socket socket, ptrdiff_t maxsize = 4096){ diff --git a/danode/interfaces.d b/danode/interfaces.d index f17c237..76e02eb 100644 --- a/danode/interfaces.d +++ b/danode/interfaces.d @@ -14,14 +14,16 @@ import danode.log : log, error, Level; /* Connection/Driver interface available to the client */ abstract class DriverInterface { public: - Appender!(char[]) inbuffer; /// Input appender buffer - Socket socket; /// Client socket for reading and writing - SocketSet set; /// SocketSet used for non-blocking select on this connection - 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 - SysTime modtime; /// Time in ms since this process was last modified - Address address; /// Private address field + Appender!(char[]) inbuffer; /// Input appender buffer + Socket socket; /// Client socket for reading and writing + SocketSet set; /// SocketSet used for non-blocking select on this connection + 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 + SysTime modtime; /// Time in ms since this process was last modified + private Address peer; /// Peer address, resolved once on assignment + private string peerIp = "0.0.0.0"; /// Cached peer IP + private long peerPort = -1; /// Cached peer port this(Socket s) { socket = s; @@ -29,11 +31,14 @@ abstract class DriverInterface { systime = Clock.currTime(); touch(); } + + @property void address(Address a) { peer = a; if (a !is null) { peerIp = a.toAddrString(); peerPort = to!long(a.toPortString()); } } + @property const(Address) address() const { return(peer); } bool socketReady() const { if (socket !is null) { return socket.isAlive(); } return false; }; /// Is the connection alive ? void touch() { modtime = Clock.currTime(); } private ptrdiff_t readSocket(ref char[] tmpbuffer) { if (!socketReady()) return 0; - if (bufferedBytes() <= 0 && sISelect(false, 25) <= 0) return 0; + if (!hasBuffered() && sISelect(false, 25) <= 0) return 0; // SSL may hold decrypted bytes select can't see ptrdiff_t received = receiveData(tmpbuffer); if (received > 0) { touch(); log(Level.Trace, "Received %d bytes of data", received); } return received; @@ -68,7 +73,7 @@ abstract class DriverInterface { } long receiveData(ref char[] buffer); - ptrdiff_t bufferedBytes() { return 0; } + bool hasBuffered() const { return false; } bool openConnection(bool blocking = false); void closeConnection(); @nogc bool isSecure() const nothrow; @@ -76,8 +81,8 @@ abstract class DriverInterface { // Send upto maxsize bytes from the response to the client void send(ref Response response, Socket conn, ptrdiff_t maxsize = 4096); - final @property long port() const { if (address !is null){ return(to!long(address.toPortString())); } return(-1); } - final @property string ip() const { if (address !is null){ return(address.toAddrString()); } return("0.0.0.0"); } + final @property long port() const { return(peerPort); } + final @property string ip() const { return(peerIp); } final @property long starttime() const { return(Msecs(systime)); } final @property long lastmodified() const { return(Msecs(modtime)); } final @property string header() const { return(fullheader(inbuffer.data)); } diff --git a/danode/post.d b/danode/post.d index 75cb4d9..871f0a1 100644 --- a/danode/post.d +++ b/danode/post.d @@ -80,6 +80,9 @@ final void parseXform(ref Request request, const string content) { return -1; } +/* Strip CR/LF so a field cannot forge extra lines in the CGI input protocol */ +pure string oneline(in string s) { return s.replace("\r", "").replace("\n", ""); } + /* The serverAPI functions prepares and writes out the input file for external process execution The inputfile contains the SERVER, COOKIES, POST, and FILES information that can be used by the external script This data is picked-up by the different CGI APIs, and presented to the client in the regular way */ @@ -108,8 +111,8 @@ final void serverAPI(in FileSystem filesystem, in WebConfig config, in Request r // Write the post information we received foreach (p; request.postinfo) { - if(p.type == PostType.Input) content.put(format("P=%s=%s\n", p.name, p.value)); - if(p.type == PostType.File) content.put(format("F=%s=%s=%s=%s\n", p.name, p.filename, p.mime, p.value)); + if(p.type == PostType.Input) content.put(format("P=%s=%s\n", oneline(p.name), oneline(p.value))); + if(p.type == PostType.File) content.put(format("F=%s=%s=%s=%s\n", oneline(p.name), oneline(p.filename), oneline(p.mime), p.value)); } string fIn = request.inputfile(filesystem); diff --git a/danode/request.d b/danode/request.d index 4c39489..ac6734e 100644 --- a/danode/request.d +++ b/danode/request.d @@ -195,7 +195,10 @@ struct Request { env["HTTPS"] = isSecure ? "on" : ""; env["REDIRECT_STATUS"] = "200"; env["PATH"] = environment.get("PATH", ""); - foreach (k, v; headers) { env["HTTP_" ~ k.toUpper().replace("-", "_")] = v; } + foreach (k, v; headers) { + if(icmp(k, "Proxy") == 0) continue; + env["HTTP_" ~ k.toUpper().replace("-", "_")] = v; + } return env; } diff --git a/danode/router.d b/danode/router.d index 0f6adca..cb609d8 100644 --- a/danode/router.d +++ b/danode/router.d @@ -58,7 +58,9 @@ class Router { string localroot = filesystem.localroot(request.shorthost()); log(Level.Trace, "Router: [T] %s:%s %s client (%s)", request.ip, request.port, (finalrewrite? "redirecting" : "routing"), request.id); log(Level.Trace, "Router: [T] shorthost '%s' -> localroot '%s'", request.shorthost(), localroot); - if (request.shorthost() == "" || !exists(localroot)) return(response.domainNotFound()); + if (request.shorthost() == "" || request.shorthost().canFind("..") || request.shorthost().canFind("\0") || !exists(localroot)) { + return(response.domainNotFound()); + } version(SSL) { if (serveACMEChallenge(request, response)) return; } diff --git a/danode/server.d b/danode/server.d index a458ddc..e6347fb 100644 --- a/danode/server.d +++ b/danode/server.d @@ -18,7 +18,7 @@ version(SSL) { import danode.acme : checkAndRenew; import danode.ssl : loadSSL, closeSSL; import danode.https : HTTPS; -} +} else { alias HTTPS = HTTP; /* No HTTPS driver without SSL */ } class Server { private: @@ -71,14 +71,11 @@ class Server { if (set.sISelect(socket, false, 5) <= 0) return; try { Socket accepted = socket.accept(); - string ip = accepted.remoteAddress().toAddrString(); - bool isLoopback = (ip == "127.0.0.1" || ip == "::1"); - DriverInterface driver = null; - if (!secure) driver = new HTTP(accepted); - version(SSL) { if (secure) driver = new HTTPS(accepted); } - if (driver is null) { accepted.close(); return; } - if (!pool.push(driver, ip, isLoopback)) { - log(Level.Always, "Rate limit or capacity exceeded [%s]", ip); + DriverInterface driver = secure ? new HTTPS(accepted) : new HTTP(accepted); + driver.address = accepted.remoteAddress(); + bool isLoopback = (driver.ip == "127.0.0.1" || driver.ip == "::1"); + if (!pool.push(driver, driver.ip, isLoopback)) { + log(Level.Always, "Rate limit or capacity exceeded [%s]", driver.ip); driver.closeConnection(); } } catch(Exception e) { error("Unable to accept connection, Exception: %s", e.msg); diff --git a/danode/ssl.d b/danode/ssl.d index b67285c..77d396c 100644 --- a/danode/ssl.d +++ b/danode/ssl.d @@ -164,5 +164,27 @@ version(SSL) { unittest { tag(Level.Always, "FILE", "%s", __FILE__); + + // SNI context matching - no handshake, context pointer left null (findContext reads only hostname) + auto saved = contexts; + scope(exit) contexts = saved; + static SSLcontext mk(string h) { SSLcontext c; c.hostname[0 .. h.length] = h[]; c.hostname[h.length] = '\0'; return c; } + contexts = [mk("example.com"), mk("test.org")]; + + assert(findContext("example.com") == 0, "exact host must match its context"); + assert(findContext("test.org") == 1, "second exact host must match"); + assert(findContext("absent.net") == -1, "unknown host must return -1"); + assert(hasCertificate("example.com"), "hasCertificate must see a loaded host"); + assert(!hasCertificate("absent.net"), "hasCertificate must reject unknown host"); + // Documents current behaviour: matching is endsWith(), so a look-alike also matches (no label boundary) + assert(findContext("notexample.com") == 0, "suffix match currently accepts look-alike host"); + + // generateKey - exercises libcrypto EVP RSA keygen + PEM write + string kpath = buildPath(tempDir, "danode_test_ssl.key"); + if (kpath.exists) remove(kpath); + generateKey(kpath, 2048); + assert(kpath.exists, "generateKey must write a key file"); + assert(getSize(kpath) > 0, "generated key must be non-empty"); + remove(kpath); } } diff --git a/danode/workerpool.d b/danode/workerpool.d index 1ae62a0..32bd2cb 100644 --- a/danode/workerpool.d +++ b/danode/workerpool.d @@ -40,6 +40,7 @@ class WorkerPool { if (!isLoopback && nAlivePerIP.get(ip, 0L) >= serverConfig.get("max_clients_per_ip", 32)) return(false); if (queue.length >= serverConfig.get("max_clients", 2048)) return(false); queue ~= driver; + nAlivePerIP[ip]++; } sem.notify(); return true; @@ -73,22 +74,19 @@ class WorkerPool { if (stopped) return; if (queue.length == 0) continue; // spurious notify from stop() driver = queue[0]; - queue = queue[1 .. $]; + queue = queue[1 .. $]; } - - string ip = driver.ip; - synchronized(mutex) { nAlivePerIP[ip]++; } try { auto client = new Client(router, driver); client.run(); client.destroy(); - } catch(Exception e) { error("WorkerPool: Client exception [%s]: %s", ip, e.msg); - } catch(Error e) { error("WorkerPool: Client error [%s]: %s", ip, e.msg); } + } catch(Exception e) { error("WorkerPool: Client exception [%s]: %s", driver.ip, e.msg); + } catch(Error e) { error("WorkerPool: Client error [%s]: %s", driver.ip, e.msg); } driver = null; if (GC.stats().usedSize > 32 * 1_048_576) { GC.collect(); GC.minimize(); } synchronized(mutex) { - if (ip in nAlivePerIP && nAlivePerIP[ip] > 0) nAlivePerIP[ip]--; - if (nAlivePerIP[ip] == 0) nAlivePerIP.remove(ip); + if (driver.ip in nAlivePerIP && nAlivePerIP[driver.ip] > 0) nAlivePerIP[driver.ip]--; + if (nAlivePerIP[driver.ip] == 0) nAlivePerIP.remove(driver.ip); } } }