aboutsummaryrefslogtreecommitdiff
path: root/server
diff options
context:
space:
mode:
authorMartin Ashby <martin@ashbysoft.com>2024-03-04 15:55:07 +0000
committerMartin Ashby <martin@ashbysoft.com>2024-03-04 15:55:07 +0000
commit5ed483825a50cadb1d3d2dd55f9e4ebc52716660 (patch)
treebdd78e0854f83dd021e2c954ee09c9cc9d3fec48 /server
parentbf0e3b2eae00e7cd86723299e28ccdaeed35b8ed (diff)
downloadmfashby.net-5ed483825a50cadb1d3d2dd55f9e4ebc52716660.tar.gz
mfashby.net-5ed483825a50cadb1d3d2dd55f9e4ebc52716660.tar.bz2
mfashby.net-5ed483825a50cadb1d3d2dd55f9e4ebc52716660.tar.xz
mfashby.net-5ed483825a50cadb1d3d2dd55f9e4ebc52716660.zip
Move web server to it's own subdir
Diffstat (limited to 'server')
-rw-r--r--server/build.zig36
-rw-r--r--server/build.zig.zon9
-rw-r--r--server/src/main.zig431
3 files changed, 476 insertions, 0 deletions
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