From 2bb396c96739a8c72a8878b52d54ddc7a4db2a7e Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 28 May 2026 08:50:08 +0100 Subject: [PATCH] feat(zig-ffi): HTTP server primitives for OikosBot webhook receiver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a synchronous, single-threaded HTTP/1.1 server FFI surface for sitting behind a TLS reverse proxy (Caddy / nginx). Intended use: the OikosBot links libgateway.so alongside libhpm_crypto.so and composes them — no router, no TLS, no concurrency primitives here. Why this repo: the user-elected home for HTTP server primitives, matching the repo's HTTP-domain identity (verb governance is the Elixir layer; this is the protocol-primitive layer alongside the existing gRPC / GraphQL parsers). New Zig exports (libgateway.so): - hpm_http_server_listen / _port / _free / _accept - hpm_http_request_method / _path / _header / _body / _respond / _free Implementation notes: - Heap-allocated RequestCtx holds the connection + IO buffers + the std.http.Server + parsed std.http.Server.Request, so inner Reader / Writer pointer addresses stay stable for the lifetime of the request. - Headers parsed via std.http.Server's HeaderIterator with case-insensitive name match. - Method enum ordinal matches std.http.Method directly (GET=0..PATCH=8). - Body capped at 1 MiB; content-length-driven read; idempotent. - Connection always closes after respond (keep_alive=false) — keeps the surface narrow for v1. Idris2 ABI: - New src/abi/HttpServer.idr with %foreign declarations and safe wrappers mirroring the Zig exports. gateway.ipkg updated. Tests: - 15/15 main module tests pass (zig test src/main.zig -lc): GET / POST + body / header lookup / header-absent / path size-query / extra response headers / arbitrary status / method ordinals / null req safety / listen failure paths / port introspection. Pre-existing build hygiene fixes (latent under Zig 0.15.2): - Handle: opaque → struct (Zig 0.15 forbids fields on opaque {}). - Callback callconv: .C → .c (rename in Zig 0.15). Unrelated red test: ffi/zig/grpc/parser.zig::"parse gRPC frame header" fails because the test fixture isn't a valid HTTP/2 HEADERS frame. Pre-existing on main; not addressed here. Co-Authored-By: Claude Opus 4.7 (1M context) --- ffi/zig/README.md | 25 ++ ffi/zig/build.zig | 10 +- ffi/zig/src/main.zig | 561 ++++++++++++++++++++++++++++++++++++++++- gateway.ipkg | 2 +- src/abi/HttpServer.idr | 165 ++++++++++++ 5 files changed, 755 insertions(+), 8 deletions(-) create mode 100644 src/abi/HttpServer.idr diff --git a/ffi/zig/README.md b/ffi/zig/README.md index d7c7cf5..0c70ce8 100644 --- a/ffi/zig/README.md +++ b/ffi/zig/README.md @@ -29,6 +29,31 @@ zig build test All exported functions use C calling convention for Idris2 FFI compatibility: +### HTTP server (`hpm_http_*`) + +Synchronous, single-threaded HTTP/1.1 server primitives intended to sit +behind a TLS reverse proxy. Used by the OikosBot for webhook reception. + +- `hpm_http_server_listen(host, host_len, port) -> server*` — bind TCP +- `hpm_http_server_port(server) -> port` — query bound port (e.g. when + `port=0` was passed) +- `hpm_http_server_accept(server) -> request*` — block until next request, + return parsed head +- `hpm_http_request_method(request) -> method_ordinal` — matches + `std.http.Method` (GET=0 HEAD=1 POST=2 PUT=3 DELETE=4 CONNECT=5 + OPTIONS=6 TRACE=7 PATCH=8) +- `hpm_http_request_path(request, out, cap) -> bytes` — copy URI target +- `hpm_http_request_header(request, name, name_len, out, cap) -> bytes` — + case-insensitive lookup; returns 0 if absent +- `hpm_http_request_body(request, out, cap) -> bytes` — read body (max 1 + MiB, content-length-driven); idempotent +- `hpm_http_request_respond(request, status, headers, headers_len, body, + body_len) -> 0/-1` — send full response; connection closes after +- `hpm_http_request_free(request)` — close + free +- `hpm_http_server_free(server)` — close listener + free + +Idris2 wrappers live in `../../src/abi/HttpServer.idr`. + ### gRPC - `parse_grpc_request` - Parse HTTP/2 gRPC frame - Validates frame headers and extracts service/method diff --git a/ffi/zig/build.zig b/ffi/zig/build.zig index 19e5053..2667079 100644 --- a/ffi/zig/build.zig +++ b/ffi/zig/build.zig @@ -45,8 +45,14 @@ pub fn build(b: *std.Build) void { const grpc_tests = b.addTest(.{ .root_module = grpc_module, }); + const run_grpc_tests = b.addRunArtifact(grpc_tests); + + const main_tests = b.addTest(.{ + .root_module = lib_module, + }); + const run_main_tests = b.addRunArtifact(main_tests); - const run_tests = b.addRunArtifact(grpc_tests); const test_step = b.step("test", "Run all tests"); - test_step.dependOn(&run_tests.step); + test_step.dependOn(&run_grpc_tests.step); + test_step.dependOn(&run_main_tests.step); } diff --git a/ffi/zig/src/main.zig b/ffi/zig/src/main.zig index 026a31a..866078e 100644 --- a/ffi/zig/src/main.zig +++ b/ffi/zig/src/main.zig @@ -6,6 +6,8 @@ // SPDX-License-Identifier: PMPL-1.0-or-later const std = @import("std"); +const net = std.net; +const http = std.http; // Version information (keep in sync with project) const VERSION = "0.1.0"; @@ -37,12 +39,12 @@ pub const Result = enum(c_int) { null_pointer = 4, }; -/// Library handle (opaque to prevent direct access) -pub const Handle = opaque { - // Internal state hidden from C +/// Library handle. Internals are hidden from C callers behind `?*Handle`; +/// the struct itself must be a regular Zig struct (not `opaque`) because +/// Zig 0.15 forbids fields on `opaque {}`. +pub const Handle = struct { allocator: std.mem.Allocator, initialized: bool, - // Add your fields here }; //============================================================================== @@ -209,7 +211,7 @@ export fn http_capability_gateway_build_info() [*:0]const u8 { //============================================================================== /// Callback function type (C ABI) -pub const Callback = *const fn (u64, u32) callconv(.C) u32; +pub const Callback = *const fn (u64, u32) callconv(.c) u32; /// Register a callback export fn http_capability_gateway_register_callback( @@ -248,6 +250,286 @@ export fn http_capability_gateway_is_initialized(handle: ?*Handle) u32 { return if (h.initialized) 1 else 0; } +//============================================================================== +// HTTP server primitives (hpm_http_server_*) +// +// Synchronous, single-threaded HTTP/1.1 server intended to sit behind a TLS +// reverse proxy (Caddy / nginx). Designed for the OikosBot's webhook +// receiver path: bind once, accept in a loop, inspect the request, reply, +// free. No keep-alive — every response closes the connection. +// +// The OikosBot links libhpm_crypto.so (for HMAC + RS256) alongside this +// library. Both surfaces are intentionally narrow: no router, no TLS, no +// concurrency primitives. Caller composes. +//============================================================================== + +const HTTP_RECV_BUF = 16 * 1024; +const HTTP_SEND_BUF = 16 * 1024; +const HTTP_BODY_SCRATCH = 4 * 1024; +const HTTP_MAX_BODY_BYTES = 1 * 1024 * 1024; + +/// One per call to `hpm_http_server_listen`. Owns the TCP listener. +pub const HpmHttpServer = struct { + listener: net.Server, + allocator: std.mem.Allocator, +}; + +/// One per call to `hpm_http_server_accept`. Owns the connection + IO +/// buffers + parsed request. Must be freed exactly once with +/// `hpm_http_request_free`. +pub const HpmHttpRequest = struct { + allocator: std.mem.Allocator, + connection: net.Server.Connection, + recv_buf: [HTTP_RECV_BUF]u8, + send_buf: [HTTP_SEND_BUF]u8, + conn_reader: net.Stream.Reader, + conn_writer: net.Stream.Writer, + http_server: http.Server, + request: http.Server.Request, + body_consumed: bool, + responded: bool, +}; + +/// Bind a TCP listener on `host:port`. Host is an IPv4/IPv6 string +/// (e.g. "0.0.0.0", "127.0.0.1", "::1"). Returns an opaque server handle +/// or NULL on error. Free with `hpm_http_server_free`. +export fn hpm_http_server_listen( + host_ptr: ?[*]const u8, + host_len: usize, + port: u16, +) ?*HpmHttpServer { + const allocator = std.heap.c_allocator; + const hp = host_ptr orelse return null; + if (host_len == 0) return null; + const host = hp[0..host_len]; + + const addr = net.Address.parseIp(host, port) catch return null; + var listener = addr.listen(.{ .reuse_address = true }) catch return null; + + const ctx = allocator.create(HpmHttpServer) catch { + listener.deinit(); + return null; + }; + ctx.* = .{ + .listener = listener, + .allocator = allocator, + }; + return ctx; +} + +/// Returns the actual port the listener is bound to. Useful when +/// `port` was passed as 0 to `listen` (kernel-picked). Returns 0 on +/// null pointer. +export fn hpm_http_server_port(server: ?*HpmHttpServer) u16 { + const s = server orelse return 0; + return s.listener.listen_address.getPort(); +} + +/// Close the listener and free the handle. Does not affect requests +/// already returned by `accept` — those must be freed independently. +export fn hpm_http_server_free(server: ?*HpmHttpServer) void { + const s = server orelse return; + s.listener.deinit(); + s.allocator.destroy(s); +} + +/// Block until a request arrives, parse its head, return a request +/// handle. Returns NULL if accept failed, the client sent a malformed +/// head, or the allocator failed. The TCP connection is closed +/// automatically on failure. +export fn hpm_http_server_accept(server: ?*HpmHttpServer) ?*HpmHttpRequest { + const s = server orelse return null; + const allocator = s.allocator; + + const conn = s.listener.accept() catch return null; + + const ctx = allocator.create(HpmHttpRequest) catch { + conn.stream.close(); + return null; + }; + + ctx.* = .{ + .allocator = allocator, + .connection = conn, + .recv_buf = undefined, + .send_buf = undefined, + .conn_reader = undefined, + .conn_writer = undefined, + .http_server = undefined, + .request = undefined, + .body_consumed = false, + .responded = false, + }; + + // Wire up reader/writer/http server. All inner pointers (Io.Reader, + // Io.Writer, http.Server) reference fields inside `ctx`, which is + // heap-allocated and therefore has a stable address. + ctx.conn_reader = conn.stream.reader(&ctx.recv_buf); + ctx.conn_writer = conn.stream.writer(&ctx.send_buf); + ctx.http_server = http.Server.init(ctx.conn_reader.interface(), &ctx.conn_writer.interface); + + ctx.request = ctx.http_server.receiveHead() catch { + conn.stream.close(); + allocator.destroy(ctx); + return null; + }; + // After receiveHead, `request.server` was set during the call but to a + // value relative to the http.Server inside ctx — that's already stable + // because ctx is heap-allocated. + + return ctx; +} + +/// Returns the request method's ordinal, matching `std.http.Method`: +/// 0=GET 1=HEAD 2=POST 3=PUT 4=DELETE 5=CONNECT 6=OPTIONS 7=TRACE 8=PATCH. +/// Returns -1 on null pointer. +export fn hpm_http_request_method(req: ?*HpmHttpRequest) c_int { + const r = req orelse return -1; + return @intCast(@intFromEnum(r.request.head.method)); +} + +/// Copy the request target (URI path + query) into `out_ptr`. Returns +/// bytes written, or the required size if `out_ptr` is NULL or `cap` is +/// 0 (size-query). Returns -1 if `cap < required` or `req` is NULL. +export fn hpm_http_request_path( + req: ?*HpmHttpRequest, + out_ptr: ?[*]u8, + cap: usize, +) isize { + const r = req orelse return -1; + const target = r.request.head.target; + if (out_ptr == null or cap == 0) return @intCast(target.len); + if (cap < target.len) return -1; + @memcpy(out_ptr.?[0..target.len], target); + return @intCast(target.len); +} + +/// Look up a request header by case-insensitive name. Writes value into +/// `out_ptr`. Returns bytes written, 0 if header absent (out untouched), +/// the required size if `out_ptr` is NULL or `cap` is 0 (size-query, 0 +/// still means absent), or -1 on `cap < required` / null req / null +/// name. +export fn hpm_http_request_header( + req: ?*HpmHttpRequest, + name_ptr: ?[*]const u8, + name_len: usize, + out_ptr: ?[*]u8, + cap: usize, +) isize { + const r = req orelse return -1; + const np = name_ptr orelse return -1; + if (name_len == 0) return -1; + const name = np[0..name_len]; + + var it = r.request.iterateHeaders(); + while (it.next()) |h| { + if (std.ascii.eqlIgnoreCase(h.name, name)) { + const v = h.value; + if (out_ptr == null or cap == 0) return @intCast(v.len); + if (cap < v.len) return -1; + @memcpy(out_ptr.?[0..v.len], v); + return @intCast(v.len); + } + } + return 0; +} + +/// Read the entire request body into `out_ptr`. Returns bytes read. +/// If `out_ptr` is NULL or `cap` is 0, returns the body size from +/// `Content-Length` (size-query) without consuming. Returns -1 on +/// over-cap, over-`HTTP_MAX_BODY_BYTES`, IO error, or null req. May +/// only be called once per request — subsequent calls return 0. +export fn hpm_http_request_body( + req: ?*HpmHttpRequest, + out_ptr: ?[*]u8, + cap: usize, +) isize { + const r = req orelse return -1; + if (r.body_consumed) return 0; + + const cl_opt = r.request.head.content_length; + const cl: usize = if (cl_opt) |v| @intCast(v) else 0; + + if (cl > HTTP_MAX_BODY_BYTES) return -1; + if (out_ptr == null or cap == 0) return @intCast(cl); + if (cap < cl) return -1; + if (cl == 0) { + r.body_consumed = true; + return 0; + } + + var scratch: [HTTP_BODY_SCRATCH]u8 = undefined; + const reader = r.request.readerExpectNone(&scratch); + + var total: usize = 0; + while (total < cl) { + const n = reader.readSliceShort(out_ptr.?[total..cl]) catch return -1; + if (n == 0) break; + total += n; + } + r.body_consumed = true; + return @intCast(total); +} + +/// Send a complete HTTP response. `status` is the numeric status code +/// (e.g. 200, 404, 500). `headers_ptr` / `headers_len` is an optional +/// buffer of extra headers in "Name:Value\r\nName:Value\r\n" format +/// (max 16 entries). `body_ptr` / `body_len` is the response body. +/// Connection is always closed after (no keep-alive). Returns 0 on +/// success, -1 on error. +export fn hpm_http_request_respond( + req: ?*HpmHttpRequest, + status: u16, + headers_ptr: ?[*]const u8, + headers_len: usize, + body_ptr: ?[*]const u8, + body_len: usize, +) c_int { + const r = req orelse return -1; + if (r.responded) return -1; + + var extra_storage: [16]http.Header = undefined; + var n_extra: usize = 0; + + if (headers_ptr) |hp| { + if (headers_len > 0) { + const hdrs = hp[0..headers_len]; + var lines = std.mem.splitSequence(u8, hdrs, "\r\n"); + while (lines.next()) |line| { + if (line.len == 0) continue; + const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue; + if (colon == 0) continue; + if (n_extra >= extra_storage.len) return -1; + extra_storage[n_extra] = .{ + .name = line[0..colon], + .value = std.mem.trim(u8, line[colon + 1 ..], " \t"), + }; + n_extra += 1; + } + } + } + + const body = if (body_ptr) |bp| bp[0..body_len] else ""; + const st: http.Status = @enumFromInt(@as(u10, @truncate(status))); + + r.request.respond(body, .{ + .status = st, + .extra_headers = extra_storage[0..n_extra], + .keep_alive = false, + }) catch return -1; + + r.responded = true; + return 0; +} + +/// Close the TCP connection and free the request handle. Must be +/// called exactly once for every request returned by `accept`. +export fn hpm_http_request_free(req: ?*HpmHttpRequest) void { + const r = req orelse return; + r.connection.stream.close(); + r.allocator.destroy(r); +} + //============================================================================== // Tests //============================================================================== @@ -272,3 +554,272 @@ test "version" { const ver_str = std.mem.span(ver); try std.testing.expectEqualStrings(VERSION, ver_str); } + +//------------------------------------------------------------------------------ +// HTTP server tests +//------------------------------------------------------------------------------ +// +// Each test spawns a single-accept server on 127.0.0.1:0 (kernel-picked +// port), then drives a raw TCP client on the main thread. +//------------------------------------------------------------------------------ + +const ServerThreadCtx = struct { + server: *HpmHttpServer, + /// Filled in by the server thread for the test thread to inspect. + method_out: c_int = -1, + path_out: [256]u8 = undefined, + path_len: isize = -1, + body_out: [256]u8 = undefined, + body_len: isize = -1, + header_out: [256]u8 = undefined, + header_len: isize = -1, + respond_rc: c_int = -1, + /// What status to respond with. + reply_status: u16 = 200, + reply_body: []const u8 = "ok", + reply_extra_headers: []const u8 = "", + /// Tell server to call `body()` before responding. + consume_body: bool = false, + /// Tell server to look up this header (case-insensitive). + lookup_header_name: []const u8 = "", +}; + +fn testServerThreadFn(ctx: *ServerThreadCtx) void { + const req = hpm_http_server_accept(ctx.server) orelse return; + defer hpm_http_request_free(req); + + ctx.method_out = hpm_http_request_method(req); + ctx.path_len = hpm_http_request_path(req, &ctx.path_out, ctx.path_out.len); + + if (ctx.lookup_header_name.len > 0) { + ctx.header_len = hpm_http_request_header( + req, + ctx.lookup_header_name.ptr, + ctx.lookup_header_name.len, + &ctx.header_out, + ctx.header_out.len, + ); + } + + if (ctx.consume_body) { + ctx.body_len = hpm_http_request_body(req, &ctx.body_out, ctx.body_out.len); + } + + ctx.respond_rc = hpm_http_request_respond( + req, + ctx.reply_status, + if (ctx.reply_extra_headers.len > 0) ctx.reply_extra_headers.ptr else null, + ctx.reply_extra_headers.len, + ctx.reply_body.ptr, + ctx.reply_body.len, + ); +} + +fn sendAndReceive(port: u16, request_bytes: []const u8, response_out: []u8) !usize { + const addr = try net.Address.parseIp("127.0.0.1", port); + const stream = try net.tcpConnectToAddress(addr); + defer stream.close(); + + var write_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 = undefined; + var w = stream.writer(&write_buf); + var r = stream.reader(&read_buf); + try w.interface.writeAll(request_bytes); + try w.interface.flush(); + + var total: usize = 0; + while (total < response_out.len) { + const n = r.interface().readSliceShort(response_out[total..]) catch break; + if (n == 0) break; + total += n; + } + return total; +} + +test "http server: listen on invalid host returns null" { + const bogus = "not-a-valid-ip"; + const s = hpm_http_server_listen(bogus.ptr, bogus.len, 0); + try std.testing.expect(s == null); +} + +test "http server: listen with null host returns null" { + const s = hpm_http_server_listen(null, 0, 0); + try std.testing.expect(s == null); +} + +test "http server: port reflects bound port" { + const host = "127.0.0.1"; + const s = hpm_http_server_listen(host.ptr, host.len, 0) orelse return error.ListenFailed; + defer hpm_http_server_free(s); + + const p = hpm_http_server_port(s); + try std.testing.expect(p != 0); +} + +test "http server: GET round trip" { + const host = "127.0.0.1"; + const s = hpm_http_server_listen(host.ptr, host.len, 0) orelse return error.ListenFailed; + defer hpm_http_server_free(s); + const port = hpm_http_server_port(s); + + var ctx: ServerThreadCtx = .{ .server = s }; + const t = try std.Thread.spawn(.{}, testServerThreadFn, .{&ctx}); + + var resp_buf: [1024]u8 = undefined; + const req = "GET /hello HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + const n = try sendAndReceive(port, req, &resp_buf); + t.join(); + + try std.testing.expectEqual(@as(c_int, @intCast(@intFromEnum(http.Method.GET))), ctx.method_out); + try std.testing.expectEqualStrings("/hello", ctx.path_out[0..@intCast(ctx.path_len)]); + try std.testing.expectEqual(@as(c_int, 0), ctx.respond_rc); + try std.testing.expect(std.mem.indexOf(u8, resp_buf[0..n], "200") != null); + try std.testing.expect(std.mem.indexOf(u8, resp_buf[0..n], "ok") != null); +} + +test "http server: POST body is read" { + const host = "127.0.0.1"; + const s = hpm_http_server_listen(host.ptr, host.len, 0) orelse return error.ListenFailed; + defer hpm_http_server_free(s); + const port = hpm_http_server_port(s); + + var ctx: ServerThreadCtx = .{ .server = s, .consume_body = true }; + const t = try std.Thread.spawn(.{}, testServerThreadFn, .{&ctx}); + + var resp_buf: [1024]u8 = undefined; + const req = "POST /webhook HTTP/1.1\r\nHost: localhost\r\nContent-Length: 11\r\nConnection: close\r\n\r\nhello world"; + _ = try sendAndReceive(port, req, &resp_buf); + t.join(); + + try std.testing.expectEqual(@as(c_int, @intCast(@intFromEnum(http.Method.POST))), ctx.method_out); + try std.testing.expectEqualStrings("/webhook", ctx.path_out[0..@intCast(ctx.path_len)]); + try std.testing.expectEqualStrings("hello world", ctx.body_out[0..@intCast(ctx.body_len)]); +} + +test "http server: header lookup case-insensitive" { + const host = "127.0.0.1"; + const s = hpm_http_server_listen(host.ptr, host.len, 0) orelse return error.ListenFailed; + defer hpm_http_server_free(s); + const port = hpm_http_server_port(s); + + var ctx: ServerThreadCtx = .{ .server = s, .lookup_header_name = "x-hub-signature-256" }; + const t = try std.Thread.spawn(.{}, testServerThreadFn, .{&ctx}); + + var resp_buf: [1024]u8 = undefined; + const req = "GET / HTTP/1.1\r\nHost: localhost\r\nX-Hub-Signature-256: sha256=abcdef\r\nConnection: close\r\n\r\n"; + _ = try sendAndReceive(port, req, &resp_buf); + t.join(); + + try std.testing.expectEqualStrings("sha256=abcdef", ctx.header_out[0..@intCast(ctx.header_len)]); +} + +test "http server: header absent returns zero" { + const host = "127.0.0.1"; + const s = hpm_http_server_listen(host.ptr, host.len, 0) orelse return error.ListenFailed; + defer hpm_http_server_free(s); + const port = hpm_http_server_port(s); + + var ctx: ServerThreadCtx = .{ .server = s, .lookup_header_name = "x-not-present" }; + const t = try std.Thread.spawn(.{}, testServerThreadFn, .{&ctx}); + + var resp_buf: [1024]u8 = undefined; + const req = "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + _ = try sendAndReceive(port, req, &resp_buf); + t.join(); + + try std.testing.expectEqual(@as(isize, 0), ctx.header_len); +} + +test "http server: path size-query returns required size" { + const host = "127.0.0.1"; + const s = hpm_http_server_listen(host.ptr, host.len, 0) orelse return error.ListenFailed; + defer hpm_http_server_free(s); + const port = hpm_http_server_port(s); + + const PathQueryCtx = struct { + server: *HpmHttpServer, + size_query_result: isize = -2, + size_write_result: isize = -2, + }; + + var ctx: PathQueryCtx = .{ .server = s }; + const t = try std.Thread.spawn(.{}, struct { + fn run(c: *PathQueryCtx) void { + const req = hpm_http_server_accept(c.server) orelse return; + defer hpm_http_request_free(req); + c.size_query_result = hpm_http_request_path(req, null, 0); + var buf: [64]u8 = undefined; + c.size_write_result = hpm_http_request_path(req, &buf, buf.len); + _ = hpm_http_request_respond(req, 200, null, 0, "x".ptr, 1); + } + }.run, .{&ctx}); + + var resp_buf: [512]u8 = undefined; + const req = "GET /the-target-path HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + _ = try sendAndReceive(port, req, &resp_buf); + t.join(); + + try std.testing.expectEqual(@as(isize, @intCast("/the-target-path".len)), ctx.size_query_result); + try std.testing.expectEqual(@as(isize, @intCast("/the-target-path".len)), ctx.size_write_result); +} + +test "http server: extra response headers" { + const host = "127.0.0.1"; + const s = hpm_http_server_listen(host.ptr, host.len, 0) orelse return error.ListenFailed; + defer hpm_http_server_free(s); + const port = hpm_http_server_port(s); + + var ctx: ServerThreadCtx = .{ + .server = s, + .reply_extra_headers = "x-test:value-1\r\ncontent-type:text/plain", + }; + const t = try std.Thread.spawn(.{}, testServerThreadFn, .{&ctx}); + + var resp_buf: [1024]u8 = undefined; + const req = "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + const n = try sendAndReceive(port, req, &resp_buf); + t.join(); + + const resp = resp_buf[0..n]; + try std.testing.expect(std.mem.indexOf(u8, resp, "x-test: value-1") != null); + try std.testing.expect(std.mem.indexOf(u8, resp, "content-type: text/plain") != null); +} + +test "http server: arbitrary status code" { + const host = "127.0.0.1"; + const s = hpm_http_server_listen(host.ptr, host.len, 0) orelse return error.ListenFailed; + defer hpm_http_server_free(s); + const port = hpm_http_server_port(s); + + var ctx: ServerThreadCtx = .{ .server = s, .reply_status = 404, .reply_body = "not found" }; + const t = try std.Thread.spawn(.{}, testServerThreadFn, .{&ctx}); + + var resp_buf: [1024]u8 = undefined; + const req = "GET /missing HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + const n = try sendAndReceive(port, req, &resp_buf); + t.join(); + + try std.testing.expect(std.mem.indexOf(u8, resp_buf[0..n], "404") != null); + try std.testing.expect(std.mem.indexOf(u8, resp_buf[0..n], "not found") != null); +} + +test "http server: method ordinals match std.http.Method" { + try std.testing.expectEqual(@as(c_int, 0), @as(c_int, @intCast(@intFromEnum(http.Method.GET)))); + try std.testing.expectEqual(@as(c_int, 1), @as(c_int, @intCast(@intFromEnum(http.Method.HEAD)))); + try std.testing.expectEqual(@as(c_int, 2), @as(c_int, @intCast(@intFromEnum(http.Method.POST)))); + try std.testing.expectEqual(@as(c_int, 3), @as(c_int, @intCast(@intFromEnum(http.Method.PUT)))); + try std.testing.expectEqual(@as(c_int, 4), @as(c_int, @intCast(@intFromEnum(http.Method.DELETE)))); + try std.testing.expectEqual(@as(c_int, 8), @as(c_int, @intCast(@intFromEnum(http.Method.PATCH)))); +} + +test "http server: null req returns -1 from accessors" { + try std.testing.expectEqual(@as(c_int, -1), hpm_http_request_method(null)); + try std.testing.expectEqual(@as(isize, -1), hpm_http_request_path(null, null, 0)); + const name = "x"; + try std.testing.expectEqual(@as(isize, -1), hpm_http_request_header(null, name.ptr, name.len, null, 0)); + try std.testing.expectEqual(@as(isize, -1), hpm_http_request_body(null, null, 0)); + try std.testing.expectEqual(@as(c_int, -1), hpm_http_request_respond(null, 200, null, 0, null, 0)); + // free is safe on null + hpm_http_request_free(null); + hpm_http_server_free(null); +} diff --git a/gateway.ipkg b/gateway.ipkg index 2ea82bc..3faae20 100644 --- a/gateway.ipkg +++ b/gateway.ipkg @@ -7,7 +7,7 @@ package gateway sourcedir = "src/abi" -- Modules to build -modules = Protocol, Types +modules = HttpServer, Protocol, Types -- Dependencies depends = base >= 0.7.0 diff --git a/src/abi/HttpServer.idr b/src/abi/HttpServer.idr new file mode 100644 index 0000000..859c44c --- /dev/null +++ b/src/abi/HttpServer.idr @@ -0,0 +1,165 @@ +||| HTTP server FFI — %foreign declarations binding into libgateway.so. +||| +||| Provides a thin, synchronous HTTP/1.1 server suitable for sitting +||| behind a TLS reverse proxy (Caddy / nginx). Each call to `listen` +||| returns a server handle; `accept` blocks until a request arrives, +||| returning a request handle. Method / path / headers / body can be +||| inspected, then `respond` sends the full response and the request +||| handle must be freed. +||| +||| Designed for the OikosBot's webhook receiver path. No keep-alive — +||| every response closes the connection. +||| +||| Null-pointer discipline: callers MUST treat any AnyPtr returned by +||| `listen` / `accept` as potentially NULL. Idris2's foreign AnyPtr is +||| opaque; the canonical pattern is to check the C handle on the C +||| side. Until a portable Idris2 null-pointer predicate lands, callers +||| should pair these wrappers with a thin C-side wrapper that returns +||| `0` for failure. + +module HttpServer + +import Data.Buffer + +%default total + +-------------------------------------------------------------------------------- +-- HTTP method ordinal — matches `std.http.Method` in Zig. +-------------------------------------------------------------------------------- + +public export +data HttpMethod + = MGet + | MHead + | MPost + | MPut + | MDelete + | MConnect + | MOptions + | MTrace + | MPatch + | MUnknown + +export +methodFromInt : Int -> HttpMethod +methodFromInt 0 = MGet +methodFromInt 1 = MHead +methodFromInt 2 = MPost +methodFromInt 3 = MPut +methodFromInt 4 = MDelete +methodFromInt 5 = MConnect +methodFromInt 6 = MOptions +methodFromInt 7 = MTrace +methodFromInt 8 = MPatch +methodFromInt _ = MUnknown + +-------------------------------------------------------------------------------- +-- Server lifecycle +-------------------------------------------------------------------------------- + +||| Raw C call. +||| +||| HpmHttpServer* hpm_http_server_listen( +||| const uint8_t* host_ptr, size_t host_len, uint16_t port); +||| +||| Returns the raw `AnyPtr`. NULL on bind / parse / OOM failure. +%foreign "C:hpm_http_server_listen, libgateway" +prim__listen : Buffer -> Int -> Int -> PrimIO AnyPtr + +export +listen : (host : Buffer) -> (hostLen : Int) -> (port : Int) -> IO AnyPtr +listen host hostLen port = primIO $ prim__listen host hostLen port + +||| Returns the port the listener is bound to (useful when `port` was +||| passed as 0). Returns 0 if the server pointer is NULL. +%foreign "C:hpm_http_server_port, libgateway" +prim__serverPort : AnyPtr -> PrimIO Int + +export +serverPort : AnyPtr -> IO Int +serverPort s = primIO $ prim__serverPort s + +||| Close the listener and free the handle. Safe on NULL. +%foreign "C:hpm_http_server_free, libgateway" +prim__serverFree : AnyPtr -> PrimIO () + +export +serverFree : AnyPtr -> IO () +serverFree s = primIO $ prim__serverFree s + +-------------------------------------------------------------------------------- +-- Request lifecycle +-------------------------------------------------------------------------------- + +||| Block until a request arrives. NULL on IO / parse error. +%foreign "C:hpm_http_server_accept, libgateway" +prim__accept : AnyPtr -> PrimIO AnyPtr + +export +accept : AnyPtr -> IO AnyPtr +accept s = primIO $ prim__accept s + +||| Return the request method ordinal (matches `std.http.Method` in +||| Zig). Returns -1 on null request. +%foreign "C:hpm_http_request_method, libgateway" +prim__method : AnyPtr -> PrimIO Int + +export +requestMethod : AnyPtr -> IO HttpMethod +requestMethod r = do + rc <- primIO $ prim__method r + pure (methodFromInt rc) + +||| Copy the request target into `out`. Returns bytes written (or +||| required size when `outCap == 0`). -1 on error. +%foreign "C:hpm_http_request_path, libgateway" +prim__path : AnyPtr -> Buffer -> Int -> PrimIO Int + +export +requestPath : (req : AnyPtr) -> (out : Buffer) -> (outCap : Int) -> IO Int +requestPath req out cap = primIO $ prim__path req out cap + +||| Look up a header by case-insensitive name. Returns bytes written +||| (or required size when `outCap == 0`), 0 if absent, -1 on error. +%foreign "C:hpm_http_request_header, libgateway" +prim__header : AnyPtr -> Buffer -> Int -> Buffer -> Int -> PrimIO Int + +export +requestHeader : (req : AnyPtr) + -> (name : Buffer) -> (nameLen : Int) + -> (out : Buffer) -> (outCap : Int) + -> IO Int +requestHeader req name nameLen out cap = + primIO $ prim__header req name nameLen out cap + +||| Read the entire request body into `out`. Idempotent: subsequent +||| calls return 0. -1 on error / over-cap. Max body = 1 MiB. +%foreign "C:hpm_http_request_body, libgateway" +prim__body : AnyPtr -> Buffer -> Int -> PrimIO Int + +export +requestBody : (req : AnyPtr) -> (out : Buffer) -> (outCap : Int) -> IO Int +requestBody req out cap = primIO $ prim__body req out cap + +||| Send a complete HTTP response. `extraHeaders` is a buffer of +||| "Name:Value\r\n…" lines (or empty). Returns 0 on success, -1 on error. +%foreign "C:hpm_http_request_respond, libgateway" +prim__respond : AnyPtr -> Int -> Buffer -> Int -> Buffer -> Int -> PrimIO Int + +export +requestRespond : (req : AnyPtr) + -> (status : Int) + -> (extraHeaders : Buffer) -> (extraHeadersLen : Int) + -> (body : Buffer) -> (bodyLen : Int) + -> IO Int +requestRespond req status hdrs hdrsLen body bodyLen = + primIO $ prim__respond req status hdrs hdrsLen body bodyLen + +||| Close the connection and free the handle. Must be called exactly +||| once per successful `accept`. Safe on NULL. +%foreign "C:hpm_http_request_free, libgateway" +prim__requestFree : AnyPtr -> PrimIO () + +export +requestFree : AnyPtr -> IO () +requestFree r = primIO $ prim__requestFree r