From 5ed483825a50cadb1d3d2dd55f9e4ebc52716660 Mon Sep 17 00:00:00 2001 From: Martin Ashby Date: Mon, 4 Mar 2024 15:55:07 +0000 Subject: Move web server to it's own subdir --- server/build.zig | 36 +++++ server/build.zig.zon | 9 ++ server/src/main.zig | 431 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 476 insertions(+) create mode 100644 server/build.zig create mode 100644 server/build.zig.zon create mode 100644 server/src/main.zig (limited to 'server') diff --git a/server/build.zig b/server/build.zig new file mode 100644 index 0000000..1f481c8 --- /dev/null +++ b/server/build.zig @@ -0,0 +1,36 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const exe = b.addExecutable(.{ + .name = "server", + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + + b.installArtifact(exe); + const run_cmd = b.addRunArtifact(exe); + + run_cmd.step.dependOn(b.getInstallStep()); + + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + const exe_unit_tests = b.addTest(.{ + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); + + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_exe_unit_tests.step); +} diff --git a/server/build.zig.zon b/server/build.zig.zon new file mode 100644 index 0000000..d68b536 --- /dev/null +++ b/server/build.zig.zon @@ -0,0 +1,9 @@ +.{ + .name = "server", + .version = "0.0.0", + .dependencies = .{ + }, + .paths = .{ + "", + }, +} diff --git a/server/src/main.zig b/server/src/main.zig new file mode 100644 index 0000000..f9f204b --- /dev/null +++ b/server/src/main.zig @@ -0,0 +1,431 @@ +const std = @import("std"); +const log = std.log.scoped(.server); + +// Web server for mfashby.net +// It does _not_ follow the unix philosophy (: +// Initially it replaces caddy, and it needs to be capable of these things +// to actually be usable +// - http(s) (otherwise it's not a web server...) ✅ https isn't supported because zig http server (in fact there's no TLS server implementation). For now I'll have to use haproxy +// - routing, including virtual host ✅ +// - serving static content from selected folders ✅ +// - executing CGI programs ✅ +// - reverse proxy ❌ + +// And I should probably test it thoroughly before exposing is to the 'net + +// Future possibilities: +// - subsume some CGI programs directly into the web server e.g. my comments program +// - and maybe even cgit (although I might scrap it in favour of stagit if I can figure out archive downloads +// - do something about efficiency :) it's thread-per-request right now +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{ .thread_safe = true }){}; + defer _ = gpa.deinit(); + const a = gpa.allocator(); + var pool: std.Thread.Pool = undefined; + try std.Thread.Pool.init(&pool, .{ .allocator = a, .n_jobs = 32 }); + defer pool.deinit(); + var svr = std.http.Server.init(.{ .reuse_address = true, .reuse_port = true }); + defer svr.deinit(); + const port = 8081; + const addr = std.net.Address.initIp4(.{0,0,0,0}, port); + try svr.listen(addr); + log.info("listening on {}", .{addr}); + try acceptLoop(a, &svr, &pool, struct{ + fn cancelled() bool { + return false; + } + }); +} + +fn acceptLoop(a: std.mem.Allocator, svr: *std.http.Server, pool: *std.Thread.Pool, cancel: anytype) !void { + while (!cancel.cancelled()) { + // Create the Response into the heap so that the ownership goes to the handling thread + const res = try a.create(std.http.Server.Response); + errdefer a.destroy(res); + res.* = try svr.accept(.{ .allocator = a }); + errdefer res.deinit(); + if (cancel.cancelled()) { + res.deinit(); + a.destroy(res); + break; + } + try pool.spawn(handle, .{res}); + } +} + +fn handle(res: *std.http.Server.Response) void { + defer res.allocator.destroy(res); + defer res.deinit(); + handleRoute(res) catch |e| { + log.info("error {}", .{e}); + if (@errorReturnTrace()) |trace| { + std.debug.dumpStackTrace(trace.*); + } + sendError(res, e) catch |e2| { + log.info("error sending error {}", .{e2}); + if (@errorReturnTrace()) |trace| { + std.debug.dumpStackTrace(trace.*); + } + }; + }; +} + +fn handleRoute(res: *std.http.Server.Response) !void { + try res.wait(); + + // Route, virtual host first + var host: []const u8 = ""; + if (res.request.headers.getFirstValue("Host")) |host_header| { + var spl = std.mem.splitScalar(u8, host_header, ':'); + host = spl.first(); + } + + const uri = std.Uri.parseWithoutScheme(res.request.target) catch { + res.status = .bad_request; + const msg = "bad request target"; + res.transfer_encoding = .{ .content_length = msg.len }; + try res.send(); + try res.writeAll(msg); + try res.finish(); + return; + }; + + if (std.mem.eql(u8, host, "localhost")) { + if (std.mem.startsWith(u8, uri.path, "/api")) { + try serveCgi(res, "/home/martin/dev/mfashby.net/comments/zig-out/bin/comments", + &.{"DATABASE_URL","SMTP_USERNAME","SMTP_PASSWORD","NOTIFICATION_ADDRESS","SMTP_SERVER"}); + } else { + try serveStatic(res, "public"); + } + } else { + const ans = "You have reached mfashby.net ... but did you mean to?"; + res.status = .ok; + res.transfer_encoding = .{ .content_length = ans.len }; + try res.send(); + try res.writeAll(ans); + try res.finish(); + } +} + +fn serveCgi(res: *std.http.Server.Response, executable: []const u8, + comptime env_copy: []const []const u8) !void { + var child = std.ChildProcess.init(&.{executable}, res.allocator); + child.stdin_behavior = .Pipe; + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Pipe; + + var env = std.process.EnvMap.init(res.allocator); + try env.put("REQUEST_METHOD", @tagName(res.request.method)); + try env.put("REQUEST_URI", res.request.target); + inline for (env_copy) |key| { + try env.put(key, std.os.getenv(key) orelse ""); + } + var clb = [_]u8{0}**30; + if (res.request.method == .POST) { + if (res.request.content_length) |cl| { + try env.put("CONTENT_LENGTH", try std.fmt.bufPrint(&clb, "{}", .{cl})); + } + } + child.env_map = &env; + try child.spawn(); + + if (res.request.method == .POST) { + if (res.request.content_length) |cl| { + log.info("sending {} data as CGI body", .{cl}); + try pump(res.reader(), child.stdin.?.writer(), cl); + } else { + _ = try pumpUnknown(res.reader(), child.stdin.?.writer()); + } + } + var stdout = std.ArrayList(u8).init(res.allocator); + var stderr = std.ArrayList(u8).init(res.allocator); + defer stdout.deinit(); + defer stderr.deinit(); + defer { + if (stderr.items.len>0) { + log.err("CGI error: {s}", .{stderr.items}); + } + } + try child.collectOutput(&stdout, &stderr, 1_000_000); + const term = try child.wait(); + if (term.Exited != 0) { + return error.ProcessError; + } + + var fbs = std.io.fixedBufferStream(stdout.items); + var reader = fbs.reader(); + var headerLine = std.ArrayList(u8).init(res.allocator); + defer headerLine.deinit(); + while (true) { + headerLine.clearRetainingCapacity(); + try reader.streamUntilDelimiter(headerLine.writer(), '\r', 8192); + _ = try reader.skipBytes(1, .{}); // \n + if (headerLine.items.len == 0) { + break; + } + var spl = std.mem.splitScalar(u8, headerLine.items, ':'); + const key = try std.ascii.allocLowerString(res.allocator, spl.first()); + defer res.allocator.free(key); + if (std.mem.eql(u8, key, "status")) { + const value = spl.rest(); + var spl2 = std.mem.splitScalar(u8, std.mem.trim(u8, value, " "), ' '); + res.status = @enumFromInt(try std.fmt.parseInt(u16, spl2.first(), 10)); + log.info("status from CGI {}", .{res.status}); + } else if (std.mem.eql(u8, key, "content-length")) { + const value = spl.rest(); + res.transfer_encoding = .{.content_length = try std.fmt.parseInt(usize, value, 10)}; + log.info("transfer_encoding from CGI {}", .{res.transfer_encoding}); + } else { + const value = spl.rest(); + try res.headers.append(key, std.mem.trim(u8, value, " ")); + log.info("header from CGI {s}: {s}", .{key, value}); + } + } + + if (res.transfer_encoding == .content_length) { + try res.send(); + try pump(reader, res.writer(), res.transfer_encoding.content_length); + } else { + res.transfer_encoding = .chunked; + try res.send(); + _ = try pumpUnknown(reader, res.writer()); + } + try res.finish(); +} + +fn serveStatic(res: *std.http.Server.Response, dirname: []const u8) !void { + const dirpath = try std.fs.realpathAlloc(res.allocator, dirname); + defer res.allocator.free(dirpath); + + // Path massaging + const uri = std.Uri.parseWithoutScheme(res.request.target) catch { + res.status = .bad_request; + const msg = "bad request target"; + res.transfer_encoding = .{ .content_length = msg.len }; + try res.send(); + try res.writeAll(msg); + try res.finish(); + return; + }; + var requested_path = uri.path; + requested_path = try std.fs.path.join(res.allocator, &.{ dirpath, requested_path }); + defer res.allocator.free(requested_path); + + + const path = std.fs.realpathAlloc(res.allocator, requested_path) catch |e| { + res.status = switch (e) { + error.FileNotFound => .not_found, + error.AccessDenied => .forbidden, + error.BadPathName => .bad_request, + else => .internal_server_error, + }; + const msg = try std.fmt.allocPrint(res.allocator, "error: {}", .{e}); + defer res.allocator.free(msg); + res.transfer_encoding = .{ .content_length = msg.len }; + try res.send(); + try res.writeAll(msg); + try res.finish(); + return; + }; + + defer res.allocator.free(path); + if (!std.mem.startsWith(u8, path, dirpath)) { + res.status = .bad_request; + const msg = try std.fmt.allocPrint(res.allocator, "Trying to escape the root directory {s}", .{path}); + defer res.allocator.free(msg); + res.transfer_encoding = .{ .content_length = msg.len }; + try res.send(); + try res.writeAll(msg); + try res.finish(); + return; + } + const f = std.fs.openFileAbsolute(path, .{}) catch |e| { + res.status = switch (e) { + error.FileNotFound => .not_found, + error.AccessDenied => .forbidden, + error.BadPathName, error.NameTooLong => .bad_request, + else => .internal_server_error, + }; + const msg = try std.fmt.allocPrint(res.allocator, "error: {}", .{e}); + defer res.allocator.free(msg); + res.transfer_encoding = .{ .content_length = msg.len }; + try res.send(); + try res.writeAll(msg); + try res.finish(); + return; + }; + defer f.close(); + const stat = try f.stat(); + switch (stat.kind) { + .file => { + res.transfer_encoding = .{ .content_length = stat.size }; + try res.send(); + try pump(f.reader(), res.writer(), stat.size); + try res.finish(); + }, + .directory => { + const index_path = try std.fs.path.join(res.allocator, &.{path, "index.html"}); + defer res.allocator.free(index_path); + const index_f = std.fs.openFileAbsolute(index_path, .{}) catch |e| { + res.status = switch (e) { + error.FileNotFound => .not_found, + error.AccessDenied => .forbidden, + error.BadPathName, error.NameTooLong => .bad_request, + else => .internal_server_error, + }; + const msg = try std.fmt.allocPrint(res.allocator, "error: {}", .{e}); + defer res.allocator.free(msg); + res.transfer_encoding = .{ .content_length = msg.len }; + try res.send(); + try res.writeAll(msg); + try res.finish(); + return; + }; + defer index_f.close(); + const index_stat = try index_f.stat(); + res.transfer_encoding = .{ .content_length = index_stat.size }; + try res.send(); + try pump(index_f.reader(), res.writer(), index_stat.size); + try res.finish(); + }, + else => { + const msg = "unable to serve unsupported file kind"; + res.status = .unavailable_for_legal_reasons; + res.transfer_encoding = .{.content_length = msg.len}; + try res.send(); + try res.writeAll(msg); + try res.finish(); + } + } + +} + +fn pumpUnknown(reader: anytype, writer: anytype) !usize { + var read: usize = 0; + var buf: [1024]u8 = undefined; + while (true) { + const sz = try reader.read(&buf); + if (sz == 0) break; + read += sz; + try writer.writeAll(buf[0..sz]); + } + return read; +} + +fn pump(reader: anytype, writer: anytype, expected: usize) !void { + var read: usize = 0; + var buf: [1024]u8 = undefined; + while (true) { + const sz = try reader.read(&buf); + if (sz == 0) break; + read += sz; + if (read > expected) return error.TooMuchData; + try writer.writeAll(buf[0..sz]); + } + if (read != expected) { + return error.NotEnoughData; + } +} + + +fn sendError(res: *std.http.Server.Response, e: anyerror) !void { + switch (res.state) { + .first, .start, .waited => { + if (res.state != .waited) { + try res.wait(); + } + const errmsg = try std.fmt.allocPrint(res.allocator, "Error: {}", .{e}); + defer res.allocator.free(errmsg); + // Now send an error + res.status = .internal_server_error; + res.transfer_encoding = .{ .content_length = errmsg.len }; + try res.send(); + try res.writeAll(errmsg); + try res.finish(); + }, + .responded, .finished => { + // Too late! + log.err("can't send an error, response already sent, state {}", .{res.state}); + }, + } +} + +const Fixture = struct { + a: std.mem.Allocator, + port: u16, + t: std.Thread, + cancel: std.atomic.Value(bool), + base_url: []const u8, + thread_pool: std.Thread.Pool, + server: std.http.Server, + client: std.http.Client, + + fn init(a: std.mem.Allocator) !*Fixture { + var res: *Fixture = try a.create(Fixture); + errdefer a.destroy(res); + res.a = a; + try std.Thread.Pool.init(&res.thread_pool, .{ .allocator = a, .n_jobs = 32 }); + errdefer res.thread_pool.deinit(); + res.server = std.http.Server.init(.{ .reuse_address = true, .reuse_port = true }); + errdefer res.server.deinit(); + const addr = std.net.Address.initIp4(.{127,0,0,1}, 0); + try res.server.listen(addr); + res.port = res.server.socket.listen_address.in.getPort(); + res.base_url = try std.fmt.allocPrint(a, "http://localhost:{}", .{res.port}); + errdefer a.free(res.base_url); + res.cancel = std.atomic.Value(bool).init(false); + res.t = try std.Thread.spawn(.{}, acceptLoop, .{a, &res.server, &res.thread_pool, res}); + res.client = .{.allocator = a}; + return res; + } + + fn deinit(self: *Fixture) void { + self.client.deinit(); + self.a.free(self.base_url); + self.cancel.store(true, .Unordered); + // Trigger the server's accept() method to wake it up + if (std.net.tcpConnectToAddress(std.net.Address.initIp4(.{127,0,0,1}, self.port))) |stream| { + stream.close(); + } else |_| {} + self.t.join(); + self.thread_pool.deinit(); + self.server.deinit(); + self.a.destroy(self); + } + + fn cancelled(self: *Fixture) bool { + return self.cancel.load(.Unordered); + } +}; + + +test "static pages" { + const a = std.testing.allocator; + var f = try Fixture.init(a); + defer f.deinit(); + const TestCase = struct { + path: []const u8, + file: []const u8, + }; + const test_cases = [_]TestCase{ + .{ .path = "/", .file = "public/index.html"}, + .{ .path = "/posts/2018-05-31-new-site/", .file = "public/posts/2018-05-31-new-site/index.html"}, + }; + + for (test_cases) |test_case| { + const url = try std.fmt.allocPrint(a, "{s}{s}", .{f.base_url, test_case.path}); + defer a.free(url); + var fr = try f.client.fetch(a, .{ + .location = .{.url = url} + }); + defer fr.deinit(); + try std.testing.expectEqual(std.http.Status.ok, fr.status); + const expected = try std.fs.cwd().readFileAlloc(a, test_case.file, 1_000_000); + defer a.free(expected); + try std.testing.expectEqualStrings(expected, fr.body.?); + } +} + +// test "404" { + +// } \ No newline at end of file -- cgit v1.2.3-ZIG