calog/tests/testHttpdLua.c

560 lines
21 KiB
C

// testHttpdLua.c -- the httpd-as-a-script (examples/httpd.lua). A Lua context loads the script (which
// implements HTTP/1.1 + routing + keep-alive + WebSocket purely in Lua over calog's tcp* transport and
// crypto natives) and serves on a port; this test drives real requests over a loopback socket: routing,
// a 404, a response-map custom status, HTTP/1.1 keep-alive (two requests on one connection), and a full
// WebSocket handshake + echo. There is no C HTTP code under test -- the point is that the protocol
// lives in the script.
#define _GNU_SOURCE // strcasestr
#include "calog.h"
#include "calogCrypto.h"
#include "calogNet.h"
#include <openssl/ssl.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>
#define PORT 38910
#define HTTPS_PORT 38911
#define PUMP_LIMIT 4000
static CalogT *calog = NULL;
static _Atomic bool serving = true;
static _Atomic bool isReady = false;
static int32_t testsRun = 0;
static int32_t testsFailed = 0;
#define CHECK(cond, msg) checkImpl((cond), (msg), __LINE__)
static void checkImpl(bool condition, const char *message, int32_t line);
static bool genCert(const char *certPath, const char *keyPath);
static int clientConnect(int port);
static bool httpsGet(int port, const char *path, int *statusOut, char *body, size_t bodyCap);
static char *loadScript(const char *path);
static int32_t nativeKeepServing(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
static int32_t nativeReady(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
static bool readResponse(int fd, int *statusOut, char *body, size_t bodyCap);
static void checkImpl(bool condition, const char *message, int32_t line) {
testsRun++;
if (!condition) {
testsFailed++;
printf("FAIL testHttpdLua.c:%d %s\n", line, message);
}
}
static int clientConnect(int port) {
struct sockaddr_in addr;
int fd;
fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
return -1;
}
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons((uint16_t)port);
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) {
close(fd);
return -1;
}
return fd;
}
// Read up to one full HTTP response (headers + Content-Length body) from fd. Parses the status and
// copies the body out. Returns false on a closed/short read. Consumes EXACTLY one response (headers
// byte-by-byte to the CRLFCRLF, then precisely Content-Length body bytes) so a following pipelined
// response is left in the socket for the next call -- a bulk recv could coalesce two responses and
// make the second read block forever.
static bool readResponse(int fd, int *statusOut, char *body, size_t bodyCap) {
char response[8192];
char *clHeader;
int total;
int headerLen;
int contentLen;
int bodyRead;
total = 0;
headerLen = -1;
while (total < (int)sizeof(response) - 1) {
ssize_t got;
got = recv(fd, response + total, 1, 0);
if (got <= 0) {
break;
}
total += (int)got;
if (total >= 4 && memcmp(response + total - 4, "\r\n\r\n", 4) == 0) {
headerLen = total;
break;
}
}
if (headerLen < 0) {
return false;
}
response[total] = '\0';
contentLen = 0;
clHeader = strcasestr(response, "Content-Length:");
if (clHeader != NULL) {
contentLen = atoi(clHeader + 15);
}
bodyRead = 0;
while (bodyRead < contentLen && headerLen + bodyRead < (int)sizeof(response) - 1) {
ssize_t got;
got = recv(fd, response + headerLen + bodyRead, 1, 0);
if (got <= 0) {
break;
}
bodyRead += (int)got;
}
*statusOut = 0;
if (strncmp(response, "HTTP/1.1 ", 9) == 0) {
*statusOut = atoi(response + 9);
}
response[headerLen + bodyRead] = '\0';
snprintf(body, bodyCap, "%s", response + headerLen);
return true;
}
static char *loadScript(const char *path) {
FILE *f;
char *buffer;
long size;
size_t got;
f = fopen(path, "rb");
if (f == NULL) {
return NULL;
}
fseek(f, 0, SEEK_END);
size = ftell(f);
fseek(f, 0, SEEK_SET);
if (size < 0) {
fclose(f);
return NULL;
}
buffer = (char *)malloc((size_t)size + 1);
if (buffer == NULL) {
fclose(f);
return NULL;
}
got = fread(buffer, 1, (size_t)size, f);
buffer[got] = '\0';
fclose(f);
return buffer;
}
// Generate a throwaway self-signed cert + key via the system openssl CLI, for the HTTPS test.
static bool genCert(const char *certPath, const char *keyPath) {
char cmd[512];
snprintf(cmd, sizeof(cmd),
"openssl req -x509 -newkey rsa:2048 -keyout %s -out %s -days 1 -nodes -subj /CN=localhost >/dev/null 2>&1",
keyPath, certPath);
return system(cmd) == 0;
}
// Minimal HTTPS client: connect, TLS handshake (no cert verification -- self-signed test cert), GET
// the path, parse the status + body. Exercises the tcp transport's tls option end to end.
static bool httpsGet(int port, const char *path, int *statusOut, char *body, size_t bodyCap) {
SSL_CTX *ctx;
SSL *ssl;
char request[256];
char response[8192];
char *bodyStart;
int fd;
int total;
int requestLen;
bool ok;
ctx = SSL_CTX_new(TLS_client_method());
if (ctx == NULL) {
return false;
}
fd = clientConnect(port);
if (fd < 0) {
SSL_CTX_free(ctx);
return false;
}
ssl = SSL_new(ctx);
if (ssl == NULL) {
close(fd);
SSL_CTX_free(ctx);
return false;
}
SSL_set_fd(ssl, fd);
ok = SSL_connect(ssl) == 1;
if (ok) {
requestLen = snprintf(request, sizeof(request), "GET %s HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n", path);
SSL_write(ssl, request, requestLen);
total = 0;
for (;;) {
int got;
got = SSL_read(ssl, response + total, (int)sizeof(response) - 1 - total);
if (got <= 0) {
break;
}
total += got;
if (total >= (int)sizeof(response) - 1) {
break;
}
}
response[total > 0 ? total : 0] = '\0';
*statusOut = 0;
if (strncmp(response, "HTTP/1.1 ", 9) == 0) {
*statusOut = atoi(response + 9);
}
bodyStart = strstr(response, "\r\n\r\n");
body[0] = '\0';
if (bodyStart != NULL) {
snprintf(body, bodyCap, "%s", bodyStart + 4);
}
ok = total > 0;
}
if (ssl != NULL) {
SSL_shutdown(ssl);
SSL_free(ssl);
}
close(fd);
SSL_CTX_free(ctx);
return ok;
}
static int32_t nativeKeepServing(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
(void)args;
(void)argCount;
(void)userData;
calogValueBool(result, atomic_load(&serving));
return calogOkE;
}
static int32_t nativeReady(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
(void)args;
(void)argCount;
(void)userData;
atomic_store(&isReady, true);
calogValueNil(result);
return calogOkE;
}
int main(void) {
CalogContextT *lua;
CalogContextT *tls;
char *script;
char *source;
char body[8192];
const char *setup;
int i;
int status;
int fd;
calog = calogCreate();
if (calog == NULL) {
printf("calog create failed\n");
return 1;
}
calogNetRegister(calog);
calogCryptoRegister(calog);
calogRegisterInline(calog, "keepServing", nativeKeepServing, NULL);
calogRegisterInline(calog, "ready", nativeReady, NULL);
script = loadScript("examples/httpd.lua");
if (script == NULL) {
printf("could not load examples/httpd.lua\n");
return 1;
}
// Wrap the module (it ends in `return httpd`) so it binds to a global, then register routes and
// serve. keepServing lets this test stop the accept loop; ready fires once the socket is bound.
setup =
"\nlocal s = httpd.new()\n"
"s:route('GET', '/hi', function(req) return 'hello ' .. req.path end)\n"
"s:route('GET', '/made', function(req) return { status = 201, body = 'created' } end)\n"
"s:route('GET', '/boom', function(req) return nil .. 'x' end)\n" /* runtime error -> 500 */
"s:websocket('/ws', function(msg) return 'echo: ' .. msg.message end)\n"
"s:serve(38910, { keep = keepServing, onReady = ready })\n";
source = (char *)malloc(strlen(script) + strlen(setup) + 64);
if (source == NULL) {
return 1;
}
sprintf(source, "httpd = (function()\n%s\nend)()\n%s", script, setup);
free(script);
lua = calogContextOpen(calog, &calogLuaEngine);
calogContextEval(lua, source);
free(source);
// Wait until the script reports the socket is listening (it runs on its own context thread).
for (i = 0; i < PUMP_LIMIT && !atomic_load(&isReady); i++) {
struct timespec ts = { 0, 500000 };
calogPump(calog);
nanosleep(&ts, NULL);
}
CHECK(atomic_load(&isReady), "the script httpd bound and is listening");
// --- plain HTTP routing (Connection: close) ---
fd = clientConnect(PORT);
if (fd >= 0) {
const char *req = "GET /hi HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n";
send(fd, req, strlen(req), 0);
CHECK(readResponse(fd, &status, body, sizeof(body)) && status == 200 && strcmp(body, "hello /hi") == 0, "GET route serves and sees the path");
close(fd);
} else {
CHECK(false, "connect for GET /hi");
}
fd = clientConnect(PORT);
if (fd >= 0) {
const char *req = "GET /made HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n";
send(fd, req, strlen(req), 0);
CHECK(readResponse(fd, &status, body, sizeof(body)) && status == 201 && strcmp(body, "created") == 0, "response map sets a custom status");
close(fd);
}
fd = clientConnect(PORT);
if (fd >= 0) {
const char *req = "GET /nope HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n";
send(fd, req, strlen(req), 0);
CHECK(readResponse(fd, &status, body, sizeof(body)) && status == 404, "an unmatched route is 404");
close(fd);
}
// --- HTTP/1.1 keep-alive: two requests on ONE connection ---
fd = clientConnect(PORT);
if (fd >= 0) {
const char *req = "GET /hi HTTP/1.1\r\nHost: x\r\nConnection: keep-alive\r\n\r\n";
bool first;
bool second;
send(fd, req, strlen(req), 0);
first = readResponse(fd, &status, body, sizeof(body)) && status == 200 && strcmp(body, "hello /hi") == 0;
send(fd, req, strlen(req), 0);
second = readResponse(fd, &status, body, sizeof(body)) && status == 200 && strcmp(body, "hello /hi") == 0;
CHECK(first && second, "keep-alive serves two requests on one connection");
close(fd);
}
// --- WebSocket: handshake (RFC 6455 sample key -> known accept) + a masked echo round-trip ---
fd = clientConnect(PORT);
if (fd >= 0) {
const char *req = "GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n";
unsigned char frame[10];
unsigned char reply[64];
char handshake[1024];
ssize_t got;
ssize_t rgot;
send(fd, req, strlen(req), 0);
got = recv(fd, handshake, sizeof(handshake) - 1, 0);
handshake[got > 0 ? got : 0] = '\0';
CHECK(got > 0 && strstr(handshake, "101 Switching Protocols") != NULL &&
strstr(handshake, "Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=") != NULL,
"WebSocket handshake returns the RFC 6455 accept value");
// One masked text frame carrying "ping": FIN|text, MASK|len=4, mask 01020304, "ping" XOR mask.
frame[0] = 0x81;
frame[1] = 0x80 | 4;
frame[2] = 0x01; frame[3] = 0x02; frame[4] = 0x03; frame[5] = 0x04;
frame[6] = (unsigned char)('p' ^ 0x01);
frame[7] = (unsigned char)('i' ^ 0x02);
frame[8] = (unsigned char)('n' ^ 0x03);
frame[9] = (unsigned char)('g' ^ 0x04);
send(fd, frame, sizeof(frame), 0);
rgot = recv(fd, reply, sizeof(reply), 0);
// Server frame: 0x81, len=10 (unmasked), "echo: ping".
CHECK(rgot >= 12 && reply[0] == 0x81 && reply[1] == 10 && memcmp(reply + 2, "echo: ping", 10) == 0,
"WebSocket echoes a masked text frame back through the Lua handler");
close(fd);
}
// --- WebSocket FRAGMENTATION: "hello" split across a text frame (FIN=0) + a continuation (FIN=1) ---
fd = clientConnect(PORT);
if (fd >= 0) {
const char *req = "GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n";
unsigned char frames[16];
unsigned char reply[64];
char handshake[512];
ssize_t rgot;
send(fd, req, strlen(req), 0);
recv(fd, handshake, sizeof(handshake) - 1, 0); // consume the 101
// Frame 1: opcode 0x1 (text) FIN=0, masked, len 3 "hel" ^ {1,2,3,4}
frames[0] = 0x01; frames[1] = 0x83;
frames[2] = 1; frames[3] = 2; frames[4] = 3; frames[5] = 4;
frames[6] = (unsigned char)('h' ^ 1); frames[7] = (unsigned char)('e' ^ 2); frames[8] = (unsigned char)('l' ^ 3);
// Frame 2: opcode 0x0 (continuation) FIN=1, masked, len 2 "lo" ^ {1,2,3,4}
frames[9] = 0x80; frames[10] = 0x82;
frames[11] = 1; frames[12] = 2; frames[13] = 3; frames[14] = 4;
frames[15] = (unsigned char)('l' ^ 1);
send(fd, frames, sizeof(frames), 0);
send(fd, (const unsigned char[]){ (unsigned char)('o' ^ 2) }, 1, 0); // last masked byte
rgot = recv(fd, reply, sizeof(reply), 0);
// Server reply frame: 0x81, len 11, "echo: hello" -- the two fragments were reassembled.
CHECK(rgot >= 13 && reply[0] == 0x81 && reply[1] == 11 && memcmp(reply + 2, "echo: hello", 11) == 0,
"WebSocket reassembles a fragmented text message");
close(fd);
}
// --- A handler that ERRORS returns 500, and the server keeps serving afterward (pcall isolation) ---
fd = clientConnect(PORT);
if (fd >= 0) {
const char *req = "GET /boom HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n";
send(fd, req, strlen(req), 0);
CHECK(readResponse(fd, &status, body, sizeof(body)) && status == 500, "a handler runtime error becomes a 500");
close(fd);
}
// --- An abrupt RST mid-request must NOT kill the server (native I/O raises; handle's pcall catches) ---
fd = clientConnect(PORT);
if (fd >= 0) {
struct linger sl;
const char *partial = "GET /hi HTTP/1.1\r\nHost: x\r\n"; // no terminating CRLF -> server waits, then sees RST
sl.l_onoff = 1;
sl.l_linger = 0; // close() sends RST, not FIN
setsockopt(fd, SOL_SOCKET, SO_LINGER, &sl, sizeof(sl));
send(fd, partial, strlen(partial), 0);
close(fd);
}
{
struct timespec ts = { 0, 20000000 };
nanosleep(&ts, NULL); // let the server process the RST
}
fd = clientConnect(PORT);
if (fd >= 0) {
const char *req = "GET /hi HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n";
send(fd, req, strlen(req), 0);
CHECK(readResponse(fd, &status, body, sizeof(body)) && status == 200 && strcmp(body, "hello /hi") == 0,
"server survives a client RST mid-request and keeps serving");
close(fd);
}
// --- HTTP/1.1 PIPELINING: two requests in one write; the leftover buffer must carry the second ---
fd = clientConnect(PORT);
if (fd >= 0) {
const char *reqs = "GET /hi HTTP/1.1\r\nHost: x\r\nConnection: keep-alive\r\n\r\n"
"GET /made HTTP/1.1\r\nHost: x\r\nConnection: keep-alive\r\n\r\n";
bool one;
bool two;
send(fd, reqs, strlen(reqs), 0);
one = readResponse(fd, &status, body, sizeof(body)) && status == 200 && strcmp(body, "hello /hi") == 0;
two = readResponse(fd, &status, body, sizeof(body)) && status == 201 && strcmp(body, "created") == 0;
CHECK(one && two, "pipelined second request (buffered past the first) is served, not lost");
close(fd);
}
// --- SMUGGLING: a Transfer-Encoding request must be REJECTED (its trailing bytes must not be
// re-parsed as a smuggled pipelined request via the leftover buffer) ---
fd = clientConnect(PORT);
if (fd >= 0) {
const char *req = "POST /made HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n"
"0\r\n\r\nGET /made HTTP/1.1\r\nHost: x\r\n\r\n";
bool served;
send(fd, req, strlen(req), 0);
served = readResponse(fd, &status, body, sizeof(body));
CHECK(!served || status != 201, "Transfer-Encoding request rejected; the smuggled GET /made is not served");
close(fd);
}
// --- SMUGGLING: conflicting duplicate Content-Length must be REJECTED (CL.CL desync) ---
fd = clientConnect(PORT);
if (fd >= 0) {
const char *req = "GET /hi HTTP/1.1\r\nHost: x\r\nContent-Length: 0\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello";
bool served;
send(fd, req, strlen(req), 0);
served = readResponse(fd, &status, body, sizeof(body));
CHECK(!served || status != 200, "conflicting duplicate Content-Length is rejected");
close(fd);
}
// --- HTTPS: a second context serves TLS with a generated self-signed cert (tcp tls option) ---
tls = NULL;
{
const char *certPath = "/tmp/calogHttpdTest-cert.pem";
const char *keyPath = "/tmp/calogHttpdTest-key.pem";
if (genCert(certPath, keyPath)) {
char *tlsScript;
char *tlsSource;
char tlsSetup[512];
atomic_store(&isReady, false);
tlsScript = loadScript("examples/httpd.lua");
snprintf(tlsSetup, sizeof(tlsSetup),
"\nlocal s = httpd.new()\n"
"s:route('GET', '/hi', function(req) return 'secure ' .. req.path end)\n"
"s:serve(38911, { tls = true, cert = '%s', key = '%s', keep = keepServing, onReady = ready })\n",
certPath, keyPath);
tlsSource = tlsScript != NULL ? (char *)malloc(strlen(tlsScript) + strlen(tlsSetup) + 64) : NULL;
if (tlsSource != NULL) {
sprintf(tlsSource, "httpd = (function()\n%s\nend)()\n%s", tlsScript, tlsSetup);
tls = calogContextOpen(calog, &calogLuaEngine);
calogContextEval(tls, tlsSource);
for (i = 0; i < PUMP_LIMIT && !atomic_load(&isReady); i++) {
struct timespec ts = { 0, 500000 };
calogPump(calog);
nanosleep(&ts, NULL);
}
CHECK(httpsGet(HTTPS_PORT, "/hi", &status, body, sizeof(body)) && status == 200 && strcmp(body, "secure /hi") == 0,
"HTTPS: the tcp transport's tls option serves an encrypted request");
// A bad handshake (plain HTTP to the TLS port) makes tcpAccept raise; the accept loop
// must survive it. Fire one, then confirm the TLS server still serves.
{
int bad;
bad = clientConnect(HTTPS_PORT);
if (bad >= 0) {
const char *probe = "GET / HTTP/1.1\r\nHost: x\r\n\r\n"; // not a TLS ClientHello
send(bad, probe, strlen(probe), 0);
close(bad);
}
{
struct timespec ts = { 0, 20000000 };
nanosleep(&ts, NULL);
}
CHECK(httpsGet(HTTPS_PORT, "/hi", &status, body, sizeof(body)) && status == 200,
"TLS server survives a bad/plain-HTTP handshake probe and keeps serving");
}
}
free(tlsScript);
free(tlsSource);
unlink(certPath);
unlink(keyPath);
} else {
printf("SKIP: openssl CLI could not generate a test cert (HTTPS check skipped)\n");
}
}
// Stop the accept loops and let the context threads unwind before teardown.
atomic_store(&serving, false);
for (i = 0; i < PUMP_LIMIT; i++) {
struct timespec ts = { 0, 500000 };
calogPump(calog);
nanosleep(&ts, NULL);
}
calogContextClose(lua);
if (tls != NULL) {
calogContextClose(tls);
}
calogDestroy(calog);
printf("\n%d checks, %d failed\n", testsRun, testsFailed);
fflush(stdout);
return testsFailed == 0 ? 0 : 1;
}