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" { // }