milestone 8: web server, rest api, sse, auth, metrics and static assets
This commit is contained in:
@@ -0,0 +1,733 @@
|
||||
//! The admin HTTP listener.
|
||||
//!
|
||||
//! One `std.http.Server` per connection over our own accept loop: a listener
|
||||
//! task in the app's group, an inner `Io.Group` of connection tasks, and a
|
||||
//! keep-alive loop per connection that ends on `error.HttpConnectionClosing`.
|
||||
//! The shape is lib/std/Build/WebServer.zig:152-185; the shutdown split is
|
||||
//! tcp_server.zig's, for the same reason.
|
||||
//!
|
||||
//! Shutdown takes one of two paths:
|
||||
//!
|
||||
//! - `deinit` shuts the listening socket down (which unblocks `accept` with
|
||||
//! `error.SocketNotListening`) and then shuts every live connection down, so
|
||||
//! each one unblocks and finishes its response. `serve` drains them.
|
||||
//! - A canceled `serve` cannot drain: HTTP keep-alive lets a browser hold a
|
||||
//! connection open indefinitely with no request on it, so waiting would let
|
||||
//! one idle tab stall the whole process's shutdown. The connection group is
|
||||
//! canceled instead.
|
||||
//!
|
||||
//! Connection slots are fixed and pre-allocated, and each one owns every buffer
|
||||
//! a request needs, so serving allocates only what a handler asks the
|
||||
//! per-request arena for. Over capacity the listener answers 503 and closes
|
||||
//! (ruling 7) rather than queueing: refusing is honest, a queue would hide it.
|
||||
//!
|
||||
//! There is no per-request timeout this milestone. The port is LAN-facing and
|
||||
//! behind the operator's own network; the cancel path, not a timer, is what
|
||||
//! bounds shutdown. A slow client costs one of 64 slots and nothing else.
|
||||
|
||||
const std = @import("std");
|
||||
const net = std.Io.net;
|
||||
const http = std.http;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const address = @import("../platform/address.zig");
|
||||
const api_limiter = @import("api_limiter.zig");
|
||||
const auth = @import("auth.zig");
|
||||
const clients = @import("../server/clients.zig");
|
||||
const db = @import("../storage/db.zig");
|
||||
const disk_monitor = @import("../storage/disk_monitor.zig");
|
||||
const dns_handler = @import("../server/handler.zig");
|
||||
const http_util = @import("http_util.zig");
|
||||
const local_tables_mod = @import("../server/local_tables.zig");
|
||||
const logger_mod = @import("../storage/logger.zig");
|
||||
const manager_mod = @import("../filter/manager.zig");
|
||||
const model = @import("../config/model.zig");
|
||||
const pause_mod = @import("../server/pause.zig");
|
||||
const pool_mod = @import("../upstream/pool.zig");
|
||||
const query_sink = @import("../server/query_sink.zig");
|
||||
const retention_mod = @import("../storage/retention.zig");
|
||||
const router = @import("router.zig");
|
||||
const sse = @import("sse.zig");
|
||||
|
||||
const log = std.log.scoped(.web_server);
|
||||
|
||||
/// Ruling 7. The receive buffer is also the maximum request head
|
||||
/// (http/Server.zig:32 sets `max_head_len` from it).
|
||||
const recv_buffer_len = 8 * 1024;
|
||||
const send_buffer_len = 4 * 1024;
|
||||
|
||||
/// Ruling 7. 64 slots at ~15.7 KiB each is ~1 MiB of fixed connection state.
|
||||
pub const default_max_connections: u16 = 64;
|
||||
|
||||
/// How much per-request arena a connection keeps between requests. Enough that
|
||||
/// a normal API response allocates nothing new, small enough that 64 idle
|
||||
/// connections cost 4 MiB rather than 64.
|
||||
const arena_retain_bytes = 64 * 1024;
|
||||
|
||||
/// How long the accept loop waits after an unexpected accept failure, so a
|
||||
/// persistent one cannot turn the loop into a spin.
|
||||
const retry_delay: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake };
|
||||
|
||||
const over_capacity_body = "{\"error\":\"too many connections\"}";
|
||||
const over_capacity_response = std.fmt.comptimePrint(
|
||||
"HTTP/1.1 503 Service Unavailable\r\n" ++
|
||||
"content-type: " ++ http_util.content_type_json ++ "\r\n" ++
|
||||
"connection: close\r\n" ++
|
||||
"content-length: {d}\r\n\r\n{s}",
|
||||
.{ over_capacity_body.len, over_capacity_body },
|
||||
);
|
||||
|
||||
/// The verdict of an API rate-limit check. The limiter's own result type, not a
|
||||
/// copy of it: two structurally identical verdicts would only drift.
|
||||
pub const LimitVerdict = api_limiter.Result;
|
||||
|
||||
pub const AuthCheckFn = *const fn (
|
||||
state: *WebState,
|
||||
io: std.Io,
|
||||
request: *const http_util.Request,
|
||||
) bool;
|
||||
|
||||
pub const LimitCheckFn = *const fn (
|
||||
state: *WebState,
|
||||
io: std.Io,
|
||||
request: *const http_util.Request,
|
||||
) LimitVerdict;
|
||||
|
||||
/// Applies a configuration change to the running server (ruling 12: rules,
|
||||
/// blocklists, groups, clients and prefixes take effect live). Mutation
|
||||
/// handlers call it through this pointer so their tests can count the calls
|
||||
/// without a real `Manager`.
|
||||
pub const ReloadFn = *const fn (state: *WebState, io: std.Io) anyerror!void;
|
||||
|
||||
/// Everything the web layer borrows, assembled by the composition root. Every
|
||||
/// pointer here outlives the listener task: `app.serve` declares the
|
||||
/// collaborators above the task group and cancels the group before releasing
|
||||
/// any of them.
|
||||
///
|
||||
/// The collaborator pointers are optional because the web layer must build and
|
||||
/// be testable without a whole running server, and because `web.enabled =
|
||||
/// false` means several of them are never opened at all (ruling 6). A handler
|
||||
/// that finds the collaborator it needs missing answers 503, the same way it
|
||||
/// answers a missing snapshot.
|
||||
pub const WebState = struct {
|
||||
gpa: Allocator,
|
||||
web: model.Web = .{},
|
||||
|
||||
handler: ?*dns_handler.Handler = null,
|
||||
pause: ?*pause_mod.Pause = null,
|
||||
tracker: ?*clients.Tracker = null,
|
||||
manager: ?*manager_mod.Manager = null,
|
||||
pool: ?*pool_mod.Pool = null,
|
||||
monitor: ?*disk_monitor.Monitor = null,
|
||||
/// The local records and forward zones the DNS path reads. The
|
||||
/// local-records and forward-zones handlers rebuild and swap them
|
||||
/// (ruling 12).
|
||||
local_tables: ?*local_tables_mod.LocalTables = null,
|
||||
logger: ?*logger_mod.Logger = null,
|
||||
retention: ?*retention_mod.Retention = null,
|
||||
sessions: ?*auth.Sessions = null,
|
||||
/// The password hash every auth decision reads. `web` above is the boot
|
||||
/// configuration and goes stale the moment `PUT /api/settings` changes the
|
||||
/// password; this holder is what makes the revoked credential stop working
|
||||
/// without a restart. The composition root seeds it from the boot hash,
|
||||
/// the settings handler installs replacements, and whoever owns the
|
||||
/// `WebState` calls `live_hash.deinit`.
|
||||
live_hash: auth.LiveHash = .{},
|
||||
limiter: ?*api_limiter.ApiLimiter = null,
|
||||
/// The SSE fanout. The sink publishes into it on the DNS hot path; the
|
||||
/// live-query handler subscribes.
|
||||
hub: ?*sse.Hub = null,
|
||||
sink: ?*query_sink.QuerySink = null,
|
||||
|
||||
/// The web task's own connections (m7 ruling 21) — never the DNS path's.
|
||||
config_db: ?*db.Db = null,
|
||||
/// Serializes the mutation handlers' work on `config_db`. Connection tasks
|
||||
/// share the one connection, and `changes()` and `lastInsertRowid()` are
|
||||
/// connection state that the repositories read after a write, so two
|
||||
/// concurrent writes would misread each other's row counts.
|
||||
config_lock: std.Io.Mutex = .init,
|
||||
querylog_db: ?*db.Db = null,
|
||||
|
||||
version: []const u8 = "",
|
||||
/// Unix seconds at process start, for uptime.
|
||||
started_unix: i64 = 0,
|
||||
|
||||
/// The table `dispatch` matches against. Defaults to the shipped one;
|
||||
/// tests point it at their own.
|
||||
routes: []const router.RouteInfo = router.routes,
|
||||
|
||||
/// Answers a path no route claimed and that is not under `/api` — the
|
||||
/// static assets and the SPA fallback (ruling 24). Null means every miss is
|
||||
/// a JSON 404.
|
||||
fallback: ?router.HandlerFn = null,
|
||||
|
||||
/// The three policy seams. They are function pointers so that the tests in
|
||||
/// this layer can drive authentication, rate limiting and reload with
|
||||
/// doubles instead of a real session store, a real clock and a real
|
||||
/// `Manager`. The defaults are the production implementations, so the
|
||||
/// composition root wires collaborators rather than behaviour, and a
|
||||
/// forgotten wire fails closed rather than open. This is the only
|
||||
/// indirection of its kind in the web layer; everything else is a direct
|
||||
/// call.
|
||||
check_auth: AuthCheckFn = sessionAuth,
|
||||
check_limit: LimitCheckFn = bucketLimit,
|
||||
reload_fn: ?ReloadFn = null,
|
||||
};
|
||||
|
||||
/// Ruling 17. Authentication is enabled iff a password hash is set — the live
|
||||
/// one, so a password set through the API locks the routes without a restart.
|
||||
/// With it set but no session store wired, every session route is refused: the
|
||||
/// failure mode of a half-wired server must be locked, not open.
|
||||
pub fn sessionAuth(state: *WebState, io: std.Io, request: *const http_util.Request) bool {
|
||||
if (!state.live_hash.enabled(io)) return true;
|
||||
const sessions = state.sessions orelse return false;
|
||||
const cookie = http_util.cookieValue(request.cookie, auth.cookie_name) orelse return false;
|
||||
return sessions.validate(io, cookie);
|
||||
}
|
||||
|
||||
/// Ruling 19. No limiter wired means no limit: the limiter is a defence the
|
||||
/// operator configures, and its absence must not refuse traffic.
|
||||
pub fn bucketLimit(state: *WebState, io: std.Io, request: *const http_util.Request) LimitVerdict {
|
||||
const limiter = state.limiter orelse return .ok;
|
||||
const now = std.Io.Clock.awake.now(io);
|
||||
return limiter.check(io, now, address.NetAddress.fromIp(request.peer));
|
||||
}
|
||||
|
||||
/// Seam double: refuses nothing. For tests and for a server with no admin
|
||||
/// password, where `sessionAuth` already answers the same way.
|
||||
pub fn allowAll(state: *WebState, io: std.Io, request: *const http_util.Request) bool {
|
||||
_ = state;
|
||||
_ = io;
|
||||
_ = request;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Seam double: throttles nothing.
|
||||
pub fn neverLimit(state: *WebState, io: std.Io, request: *const http_util.Request) LimitVerdict {
|
||||
_ = state;
|
||||
_ = io;
|
||||
_ = request;
|
||||
return .ok;
|
||||
}
|
||||
|
||||
pub const Stats = struct {
|
||||
accepted: std.atomic.Value(u64) = .init(0),
|
||||
rejected_at_capacity: std.atomic.Value(u64) = .init(0),
|
||||
rejected_at_shutdown: std.atomic.Value(u64) = .init(0),
|
||||
accept_errors: std.atomic.Value(u64) = .init(0),
|
||||
connection_errors: std.atomic.Value(u64) = .init(0),
|
||||
requests: std.atomic.Value(u64) = .init(0),
|
||||
};
|
||||
|
||||
pub const Options = struct {
|
||||
max_connections: u16 = default_max_connections,
|
||||
};
|
||||
|
||||
/// Lifecycle of the accept loop, mirroring tcp_server: `serve` claims
|
||||
/// `.serving`, `deinit` publishes `.closing`, and the two meet at `stopped`.
|
||||
const State = enum(u32) { idle, serving, closing };
|
||||
|
||||
/// `.closing` exists so `deinit` never shuts down a descriptor its own task is
|
||||
/// about to close.
|
||||
const ConnState = enum { free, active, closing };
|
||||
|
||||
/// Why the accept loop stopped, which decides what happens to the connections
|
||||
/// still in flight.
|
||||
const Stop = enum { closing, canceled };
|
||||
|
||||
const Claim = union(enum) {
|
||||
slot: usize,
|
||||
at_capacity,
|
||||
shutting_down,
|
||||
};
|
||||
|
||||
pub const Server = struct {
|
||||
state: *WebState,
|
||||
listener: net.Server,
|
||||
conns: []Conn,
|
||||
mutex: std.Io.Mutex,
|
||||
/// Guarded by `mutex`, set in the same critical section that shuts the live
|
||||
/// connections down.
|
||||
shutdown_begun: bool,
|
||||
stats: Stats,
|
||||
run_state: std.atomic.Value(State),
|
||||
stopped: std.Io.Event,
|
||||
|
||||
/// One slot's fixed cost. The head copies exist because every string in
|
||||
/// `request.head` dies on the first body read (http/Server.zig:594).
|
||||
pub const Conn = struct {
|
||||
recv_buf: [recv_buffer_len]u8,
|
||||
send_buf: [send_buffer_len]u8,
|
||||
target_buf: [http_util.max_target_len]u8,
|
||||
cookie_buf: [http_util.max_cookie_len]u8,
|
||||
accept_encoding_buf: [http_util.max_header_value_len]u8,
|
||||
if_none_match_buf: [http_util.max_header_value_len]u8,
|
||||
/// Per-request working memory, reset between requests on the same
|
||||
/// connection so a keep-alive client cannot grow it without bound.
|
||||
arena: std.heap.ArenaAllocator,
|
||||
stream: net.Stream,
|
||||
peer: net.IpAddress,
|
||||
/// Guarded by `Server.mutex`.
|
||||
conn_state: ConnState,
|
||||
};
|
||||
|
||||
pub const ListenError = net.IpAddress.ListenError || error{OutOfMemory};
|
||||
|
||||
pub fn listen(
|
||||
gpa: Allocator,
|
||||
io: std.Io,
|
||||
listen_address: net.IpAddress,
|
||||
state: *WebState,
|
||||
options: Options,
|
||||
) ListenError!Server {
|
||||
std.debug.assert(options.max_connections > 0);
|
||||
|
||||
const conns = try gpa.alloc(Conn, options.max_connections);
|
||||
errdefer gpa.free(conns);
|
||||
for (conns) |*conn| {
|
||||
conn.conn_state = .free;
|
||||
conn.arena = .init(gpa);
|
||||
}
|
||||
|
||||
const listener = try listen_address.listen(io, .{ .reuse_address = true });
|
||||
|
||||
return .{
|
||||
.state = state,
|
||||
.listener = listener,
|
||||
.conns = conns,
|
||||
.mutex = .init,
|
||||
.shutdown_begun = false,
|
||||
.stats = .{},
|
||||
.run_state = .init(.idle),
|
||||
.stopped = .unset,
|
||||
};
|
||||
}
|
||||
|
||||
/// The kernel-assigned address. A port of 0 in `listen` resolves here.
|
||||
pub fn boundAddress(self: *const Server) net.IpAddress {
|
||||
return self.listener.socket.address;
|
||||
}
|
||||
|
||||
/// Accept loop. Returns when the task is canceled or `deinit` stops it.
|
||||
pub fn serve(self: *Server, io: std.Io) void {
|
||||
if (self.run_state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return;
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
switch (self.acceptLoop(io, &group)) {
|
||||
// `deinit` shut every live connection down before it published
|
||||
// `.closing`, so each one is unblocked and finishing on its own.
|
||||
// Awaiting them means a half-written response still goes out whole.
|
||||
.closing => {
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
_ = io.swapCancelProtection(prev);
|
||||
},
|
||||
// Nothing has shut these connections down, and an idle keep-alive
|
||||
// connection has no deadline of its own, so draining could wait
|
||||
// forever. Cancel joins, so the slots are quiet by the time `serve`
|
||||
// returns; the price is the one response that was mid-write.
|
||||
.canceled => group.cancel(io),
|
||||
}
|
||||
|
||||
self.stopped.set(io);
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Server, gpa: Allocator, io: std.Io) void {
|
||||
const was_serving = self.run_state.swap(.closing, .acq_rel) == .serving;
|
||||
|
||||
// Shutting the listening socket down is the documented way to unblock a
|
||||
// pending `accept`: it fails with `error.SocketNotListening`.
|
||||
const listener: net.Stream = .{ .socket = self.listener.socket };
|
||||
listener.shutdown(io, .both) catch |err| {
|
||||
log.debug("web listener shutdown failed: {t}", .{err});
|
||||
};
|
||||
|
||||
self.beginShutdown(io);
|
||||
|
||||
if (was_serving) self.stopped.waitUncancelable(io);
|
||||
|
||||
self.listener.deinit(io);
|
||||
for (self.conns) |*conn| conn.arena.deinit();
|
||||
gpa.free(self.conns);
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
fn acceptLoop(self: *Server, io: std.Io, group: *std.Io.Group) Stop {
|
||||
while (self.run_state.load(.acquire) == .serving) {
|
||||
const stream = self.listener.accept(io) catch |err| switch (err) {
|
||||
error.Canceled => return .canceled,
|
||||
error.SocketNotListening => return .closing,
|
||||
else => {
|
||||
bump(&self.stats.accept_errors);
|
||||
log.debug("web accept failed: {t}", .{err});
|
||||
retry_delay.sleep(io) catch return .canceled;
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
const index = switch (self.claim(io, stream)) {
|
||||
.slot => |index| index,
|
||||
.at_capacity => {
|
||||
bump(&self.stats.rejected_at_capacity);
|
||||
refuse(io, stream);
|
||||
continue;
|
||||
},
|
||||
.shutting_down => {
|
||||
bump(&self.stats.rejected_at_shutdown);
|
||||
stream.close(io);
|
||||
return .closing;
|
||||
},
|
||||
};
|
||||
|
||||
group.concurrent(io, serveConn, .{ self, io, index }) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => {
|
||||
bump(&self.stats.rejected_at_capacity);
|
||||
self.finish(io, index);
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
bump(&self.stats.accepted);
|
||||
}
|
||||
|
||||
// The loop condition failed, which only `deinit` can cause.
|
||||
return .closing;
|
||||
}
|
||||
|
||||
/// Ruling 7: over capacity the client is told so, never silently dropped.
|
||||
///
|
||||
/// The response is written from the accept loop, because refusing must not
|
||||
/// consume the slot that is missing. It is ~130 bytes — one socket buffer —
|
||||
/// so a peer that never reads still cannot stall the loop.
|
||||
///
|
||||
/// The close that follows does not drain the client's request first, so
|
||||
/// Linux may follow the response with an RST and a client that had already
|
||||
/// sent its request can lose the 503 and see a reset instead. Draining
|
||||
/// would mean a blocking read on the accept loop with no bound but the
|
||||
/// client's goodwill, which is a worse failure than a lost error page on a
|
||||
/// server that is already at capacity.
|
||||
fn refuse(io: std.Io, stream: net.Stream) void {
|
||||
var buf: [over_capacity_response.len]u8 = undefined;
|
||||
var writer = stream.writer(io, &buf);
|
||||
writer.interface.writeAll(over_capacity_response) catch {};
|
||||
writer.interface.flush() catch {};
|
||||
stream.close(io);
|
||||
}
|
||||
|
||||
fn serveConn(self: *Server, io: std.Io, index: usize) void {
|
||||
defer self.finish(io, index);
|
||||
|
||||
const conn = &self.conns[index];
|
||||
var reader = conn.stream.reader(io, &conn.recv_buf);
|
||||
var writer = conn.stream.writer(io, &conn.send_buf);
|
||||
var connection: http.Server = .init(&reader.interface, &writer.interface);
|
||||
|
||||
while (connection.reader.state == .ready) {
|
||||
var request = connection.receiveHead() catch |err| switch (err) {
|
||||
// The normal end of a keep-alive connection.
|
||||
error.HttpConnectionClosing => return,
|
||||
// Cancellation and a vanished client both land here; neither is
|
||||
// worth a counter.
|
||||
error.ReadFailed => return,
|
||||
error.HttpHeadersOversize => {
|
||||
bump(&self.stats.connection_errors);
|
||||
return;
|
||||
},
|
||||
error.HttpRequestTruncated, error.HttpHeadersInvalid => {
|
||||
bump(&self.stats.connection_errors);
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
// RFC 9110 §8.6: a request with neither content-length nor
|
||||
// transfer-encoding has an empty body, but std leaves the head
|
||||
// saying "unknown" and `discardBody` asserts on it inside every
|
||||
// `respond` (http/Server.zig:631) — `curl -X POST` panics the
|
||||
// process. A zero length is what the head means, and it satisfies
|
||||
// every downstream reader: `bodyReader` (http.zig:445) goes
|
||||
// straight to `.ready` on a zero content-length.
|
||||
if (request.head.method.requestHasBody() and
|
||||
request.head.transfer_encoding == .none and
|
||||
request.head.content_length == null)
|
||||
{
|
||||
request.head.content_length = 0;
|
||||
}
|
||||
|
||||
bump(&self.stats.requests);
|
||||
// Retained with a limit, not wholesale: a single 1 MiB body would
|
||||
// otherwise keep a megabyte per slot alive for as long as the
|
||||
// browser holds the connection.
|
||||
_ = conn.arena.reset(.{ .retain_with_limit = arena_retain_bytes });
|
||||
|
||||
self.handleRequest(io, conn, &request) catch |err| switch (err) {
|
||||
// Ruling 28: the peer went away mid-response. Normal.
|
||||
error.WriteFailed => return,
|
||||
error.HttpExpectationFailed, error.OutOfMemory => {
|
||||
bump(&self.stats.connection_errors);
|
||||
return;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the request view and dispatches it. Every string a handler may
|
||||
/// touch after a body read is copied here first (ruling 25).
|
||||
fn handleRequest(
|
||||
self: *Server,
|
||||
io: std.Io,
|
||||
conn: *Conn,
|
||||
request: *http.Server.Request,
|
||||
) http_util.HandlerError!void {
|
||||
const arena = conn.arena.allocator();
|
||||
|
||||
const target = request.head.target;
|
||||
if (target.len > conn.target_buf.len) {
|
||||
var view = bareRequest(request, conn, arena);
|
||||
return http_util.respondError(&view, .uri_too_long, "target too long");
|
||||
}
|
||||
@memcpy(conn.target_buf[0..target.len], target);
|
||||
const copied = conn.target_buf[0..target.len];
|
||||
|
||||
const split = std.mem.findScalar(u8, copied, '?') orelse copied.len;
|
||||
const raw_path = copied[0..split];
|
||||
const query = if (split == copied.len) copied[split..] else copied[split + 1 ..];
|
||||
|
||||
const cookie = copyHeader(request, "cookie", &conn.cookie_buf);
|
||||
const accept_encoding = copyHeader(request, "accept-encoding", &conn.accept_encoding_buf);
|
||||
const if_none_match = copyHeader(request, "if-none-match", &conn.if_none_match_buf);
|
||||
|
||||
// Decoding is destructive, so it runs on a copy: W8's asset lookup needs
|
||||
// the raw path to match embedded file names byte for byte.
|
||||
const decodable = arena.dupe(u8, raw_path) catch return error.OutOfMemory;
|
||||
const path = http_util.parsePath(decodable) catch {
|
||||
var view = bareRequest(request, conn, arena);
|
||||
return http_util.respondError(&view, .bad_request, "malformed path");
|
||||
};
|
||||
|
||||
var view: http_util.Request = .{
|
||||
.http = request,
|
||||
.method = request.head.method,
|
||||
.path = path,
|
||||
.raw_path = raw_path,
|
||||
.query = query,
|
||||
.id = null,
|
||||
.cookie = cookie,
|
||||
.accept_encoding = accept_encoding,
|
||||
.if_none_match = if_none_match,
|
||||
.peer = conn.peer,
|
||||
.arena = arena,
|
||||
};
|
||||
return router.dispatch(self.state, io, &view);
|
||||
}
|
||||
|
||||
/// A request view for the errors that are decided before parsing finishes.
|
||||
fn bareRequest(request: *http.Server.Request, conn: *Conn, arena: Allocator) http_util.Request {
|
||||
return .{
|
||||
.http = request,
|
||||
.method = request.head.method,
|
||||
.path = .empty,
|
||||
.raw_path = "",
|
||||
.query = "",
|
||||
.id = null,
|
||||
.cookie = "",
|
||||
.accept_encoding = "",
|
||||
.if_none_match = "",
|
||||
.peer = conn.peer,
|
||||
.arena = arena,
|
||||
};
|
||||
}
|
||||
|
||||
fn claim(self: *Server, io: std.Io, stream: net.Stream) Claim {
|
||||
// Uncancelable: this section takes no Io and never blocks on a peer, so
|
||||
// losing the lock mid-update would leak a slot for nothing.
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
const outcome = decideClaim(self.conns, self.shutdown_begun);
|
||||
switch (outcome) {
|
||||
.slot => |index| {
|
||||
self.conns[index].stream = stream;
|
||||
self.conns[index].peer = stream.socket.address;
|
||||
self.conns[index].conn_state = .active;
|
||||
},
|
||||
.at_capacity, .shutting_down => {},
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
fn finish(self: *Server, io: std.Io, index: usize) void {
|
||||
const conn = &self.conns[index];
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
conn.conn_state = .closing;
|
||||
self.mutex.unlock(io);
|
||||
|
||||
// The socket is released even when this task is being torn down: the
|
||||
// next cancelable call would otherwise skip the close.
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
conn.stream.close(io);
|
||||
_ = io.swapCancelProtection(prev);
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
conn.conn_state = .free;
|
||||
self.mutex.unlock(io);
|
||||
}
|
||||
|
||||
/// Closes the door on new connections and unblocks the live ones under one
|
||||
/// hold of the mutex, so no `claim` can slip between the two.
|
||||
fn beginShutdown(self: *Server, io: std.Io) void {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
self.shutdown_begun = true;
|
||||
|
||||
for (self.conns) |*conn| {
|
||||
if (conn.conn_state != .active) continue;
|
||||
conn.stream.shutdown(io, .both) catch |err| {
|
||||
log.debug("web connection shutdown failed: {t}", .{err});
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Copies one header value into `buf`. A value too long for its budget reads as
|
||||
/// absent: the three headers this applies to are a session cookie, an
|
||||
/// `accept-encoding` and an `if-none-match`, and losing any of them degrades to
|
||||
/// unauthenticated, uncompressed and unconditional — never to a wrong answer.
|
||||
fn copyHeader(request: *http.Server.Request, name: []const u8, buf: []u8) []const u8 {
|
||||
var it = request.iterateHeaders();
|
||||
while (it.next()) |header| {
|
||||
if (!std.ascii.eqlIgnoreCase(header.name, name)) continue;
|
||||
if (header.value.len > buf.len) return "";
|
||||
@memcpy(buf[0..header.value.len], header.value);
|
||||
return buf[0..header.value.len];
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/// The whole claim rule, without the mutex, so it is testable without a backend.
|
||||
fn decideClaim(conns: []const Server.Conn, shutdown_begun: bool) Claim {
|
||||
if (shutdown_begun) return .shutting_down;
|
||||
for (conns, 0..) |*conn, index| {
|
||||
if (conn.conn_state == .free) return .{ .slot = index };
|
||||
}
|
||||
return .at_capacity;
|
||||
}
|
||||
|
||||
fn bump(counter: *std.atomic.Value(u64)) void {
|
||||
_ = counter.fetchAdd(1, .monotonic);
|
||||
}
|
||||
|
||||
/// The composition root's entry point: bind, serve, release.
|
||||
///
|
||||
/// A bind failure is warned and swallowed. The admin UI failing to come up must
|
||||
/// not stop nxdns answering DNS, which is what the box is for; the operator
|
||||
/// sees the warning and the DNS side keeps serving.
|
||||
pub fn serve(state: *WebState, io: std.Io) void {
|
||||
const bind_address = net.IpAddress.parse(state.web.bind, state.web.port) catch {
|
||||
log.warn("web.bind '{s}' is not an IP address; the web interface is disabled", .{state.web.bind});
|
||||
return;
|
||||
};
|
||||
|
||||
var server: Server = Server.listen(state.gpa, io, bind_address, state, .{}) catch |err| {
|
||||
log.warn("web interface cannot listen on {s}:{d}: {t}", .{ state.web.bind, state.web.port, err });
|
||||
return;
|
||||
};
|
||||
defer server.deinit(state.gpa, io);
|
||||
|
||||
log.info("web interface listening on {f}", .{server.boundAddress()});
|
||||
server.serve(io);
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn testConns(count: usize) ![]Server.Conn {
|
||||
const conns = try testing.allocator.alloc(Server.Conn, count);
|
||||
for (conns) |*conn| conn.conn_state = .free;
|
||||
return conns;
|
||||
}
|
||||
|
||||
test "the connection pool hands out every slot once, then refuses" {
|
||||
const conns = try testConns(2);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot);
|
||||
conns[0].conn_state = .active;
|
||||
try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot);
|
||||
conns[1].conn_state = .active;
|
||||
try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false)));
|
||||
}
|
||||
|
||||
test "a closing slot is not reused until it is free" {
|
||||
const conns = try testConns(1);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
conns[0].conn_state = .closing;
|
||||
try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false)));
|
||||
conns[0].conn_state = .free;
|
||||
try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot);
|
||||
}
|
||||
|
||||
test "shutdown outranks capacity and does not consume the slot" {
|
||||
const conns = try testConns(1);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true)));
|
||||
try testing.expectEqual(@as(usize, 0), decideClaim(conns, false).slot);
|
||||
}
|
||||
|
||||
test "the over-capacity response is a well formed 503" {
|
||||
try testing.expect(std.mem.startsWith(u8, over_capacity_response, "HTTP/1.1 503 "));
|
||||
const split = std.mem.findPosLinear(u8, over_capacity_response, 0, "\r\n\r\n").?;
|
||||
try testing.expectEqualStrings(over_capacity_body, over_capacity_response[split + 4 ..]);
|
||||
}
|
||||
|
||||
test "an unconfigured password leaves every route open" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
var state: WebState = .{ .gpa = testing.allocator };
|
||||
const request = testRequest();
|
||||
try testing.expect(sessionAuth(&state, threaded.io(), &request));
|
||||
}
|
||||
|
||||
test "a configured password with no session store refuses rather than opens" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
var state: WebState = .{ .gpa = testing.allocator, .live_hash = .init("$argon2id$...") };
|
||||
const request = testRequest();
|
||||
try testing.expect(!sessionAuth(&state, threaded.io(), &request));
|
||||
}
|
||||
|
||||
test "an unwired limiter throttles nothing" {
|
||||
var state: WebState = .{ .gpa = testing.allocator };
|
||||
const request = testRequest();
|
||||
try testing.expect(bucketLimit(&state, undefined, &request).allowed);
|
||||
}
|
||||
|
||||
test "the seam doubles are usable in place of the production checks" {
|
||||
var state: WebState = .{ .gpa = testing.allocator, .check_auth = allowAll, .check_limit = neverLimit };
|
||||
const request = testRequest();
|
||||
try testing.expect(state.check_auth(&state, undefined, &request));
|
||||
try testing.expect(state.check_limit(&state, undefined, &request).allowed);
|
||||
}
|
||||
|
||||
/// `io` is never reached on these paths, so the tests above pass `undefined`.
|
||||
fn testRequest() http_util.Request {
|
||||
return .{
|
||||
.http = undefined,
|
||||
.method = .GET,
|
||||
.path = .empty,
|
||||
.raw_path = "/api/groups",
|
||||
.query = "",
|
||||
.id = null,
|
||||
.cookie = "",
|
||||
.accept_encoding = "",
|
||||
.if_none_match = "",
|
||||
.peer = .{ .ip4 = .loopback(0) },
|
||||
.arena = testing.allocator,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user