Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions .github/workflows/d.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
dub build --compiler=$DC --config=ssl

- name: 'Test'
run: |
dub test --compiler=$DC
dub test --compiler=$DC --config=ssl
2 changes: 1 addition & 1 deletion danode/https.d
Original file line number Diff line number Diff line change
Expand Up @@ -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){
Expand Down
29 changes: 17 additions & 12 deletions danode/interfaces.d
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,31 @@ 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;
set = new SocketSet();
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;
Expand Down Expand Up @@ -68,16 +73,16 @@ 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;

// 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)); }
Expand Down
7 changes: 5 additions & 2 deletions danode/post.d
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion danode/request.d
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
4 changes: 3 additions & 1 deletion danode/router.d
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down
15 changes: 6 additions & 9 deletions danode/server.d
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions danode/ssl.d
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
14 changes: 6 additions & 8 deletions danode/workerpool.d
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
}
Expand Down