Gates / frontend (push) Successful in 2m6s
Gates / test (push) Successful in 2m57s
Gates / test-aarch64 (push) Successful in 8m31s
Gates / package (push) Successful in 4m19s
Gates / container (push) Failing after 2s
CI / gates (push) Failing after 26m21s
1115 lines
48 KiB
Zig
1115 lines
48 KiB
Zig
//! The admin HTTP listener.
|
|
//!
|
|
//! One `std.http.Server` per connection over the shared `listener.Core` accept
|
|
//! loop (milestone-18 ruling 1): 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 slot pool and the shutdown split
|
|
//! come from the core, which documents both.
|
|
//!
|
|
//! 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 cert_store = @import("../server/cert_store.zig");
|
|
const client_names = @import("../server/client_names.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 doh_server = @import("../server/doh_server.zig");
|
|
const dot_server = @import("../server/dot_server.zig");
|
|
const events_mod = @import("../storage/events.zig");
|
|
const http_util = @import("http_util.zig");
|
|
const listener_core = @import("../server/listener.zig");
|
|
const local_tables_mod = @import("../server/local_tables.zig");
|
|
const logger_controller = @import("../storage/logger_controller.zig");
|
|
const manager_mod = @import("../filter/manager.zig");
|
|
const model = @import("../config/model.zig");
|
|
const pause_mod = @import("../server/pause.zig");
|
|
const upstream_owner = @import("../upstream/owner.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 tcp_server = @import("../server/tcp_server.zig");
|
|
const udp_server = @import("../server/udp_server.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 ~16 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;
|
|
|
|
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.
|
|
/// Which of the two sources governs this process's configuration (milestone-20
|
|
/// ruling 1). Per-process state, never persisted: authority lives in the
|
|
/// invocation, and the database carries no record of who wrote it.
|
|
///
|
|
/// The `managed_file` path is owned by `serve`'s arena, which outlives every
|
|
/// `WebState`, so nothing here copies it.
|
|
pub const Authority = union(enum) {
|
|
database,
|
|
managed_file: []const u8,
|
|
};
|
|
|
|
/// `web.trusted_proxies`, live. The boot text is borrowed from the loaded
|
|
/// configuration; every replacement is an owned, immutable generation that a
|
|
/// reader holds a shared lock on for as long as it borrows the text.
|
|
///
|
|
/// Taking the exclusive lock is what drains the readers: once `install`
|
|
/// returns, nothing holds the generation it hands back, so the caller can free
|
|
/// it. The free itself belongs to the caller and not to this type, because a
|
|
/// publish must not do work that a retire owns.
|
|
pub const LiveProxies = struct {
|
|
lock: std.Io.RwLock = .init,
|
|
text: []const u8 = "",
|
|
owned: bool = false,
|
|
|
|
pub fn init(boot_text: []const u8) LiveProxies {
|
|
return .{ .text = boot_text };
|
|
}
|
|
|
|
/// The reader's hold. Release it, and do not retain `text` afterwards.
|
|
pub const Handle = struct {
|
|
text: []const u8,
|
|
live: *LiveProxies,
|
|
|
|
pub fn release(self: Handle, io: std.Io) void {
|
|
self.live.lock.unlockShared(io);
|
|
}
|
|
};
|
|
|
|
pub fn acquire(self: *LiveProxies, io: std.Io) Handle {
|
|
self.lock.lockSharedUncancelable(io);
|
|
return .{ .text = self.text, .live = self };
|
|
}
|
|
|
|
/// Takes ownership of `prepared`, which must be a `gpa` allocation, and
|
|
/// returns the generation it replaced for the caller to free — or null
|
|
/// when what it replaced was the borrowed boot text.
|
|
pub fn install(self: *LiveProxies, io: std.Io, prepared: []const u8) ?[]const u8 {
|
|
self.lock.lockUncancelable(io);
|
|
defer self.lock.unlock(io);
|
|
|
|
const retired: ?[]const u8 = if (self.owned) self.text else null;
|
|
self.text = prepared;
|
|
self.owned = true;
|
|
return retired;
|
|
}
|
|
|
|
pub fn deinit(self: *LiveProxies, gpa: Allocator) void {
|
|
if (self.owned) gpa.free(self.text);
|
|
self.* = undefined;
|
|
}
|
|
};
|
|
|
|
/// The long-lived collaborators `upstream_owner.build` needs, which are the
|
|
/// composition root's and not the request's: the shared HTTP client every DoH
|
|
/// leaf borrows and the one certificate bundle every DoT leaf verifies against.
|
|
pub const UpstreamBuild = struct {
|
|
http: *std.http.Client,
|
|
bundle: *std.crypto.Certificate.Bundle,
|
|
bundle_lock: *std.Io.RwLock,
|
|
};
|
|
|
|
/// The Overview response cache: one already-serialized body per period.
|
|
///
|
|
/// A slot is valid for exactly one `(window.until, data_version)` pair, so it
|
|
/// expires both ways a stale Overview can arise — the window rolls onto the
|
|
/// next bucket, or another connection (the logger, retention) commits and moves
|
|
/// `PRAGMA data_version`. There is no time-to-live and no background refresh:
|
|
/// nothing here can serve bytes that describe a database state the reader could
|
|
/// not have seen.
|
|
///
|
|
/// Every field is read and written under `WebState.querylog_lock`, which is
|
|
/// also what makes the cache single-flight: a second request for the same key
|
|
/// waits for the first rebuild and then hits. The type carries no lock of its
|
|
/// own precisely so that nobody can touch it without the one that matters.
|
|
pub const OverviewCache = struct {
|
|
/// One per `overview.Period`, indexed by `@intFromEnum`. The handler asserts
|
|
/// the two counts agree.
|
|
pub const slot_count = 4;
|
|
|
|
const Slot = struct {
|
|
/// Empty until the first successful build; never a valid empty body,
|
|
/// since every response carries at least the period and the window.
|
|
body: []u8 = &.{},
|
|
until: i64 = 0,
|
|
data_version: i64 = 0,
|
|
};
|
|
|
|
slots: [slot_count]Slot = @splat(.{}),
|
|
|
|
/// The stored bytes for this key, or null. The caller copies them into its
|
|
/// request arena before releasing the lock: a later rebuild frees this
|
|
/// allocation.
|
|
pub fn get(self: *const OverviewCache, period_index: usize, until: i64, data_version: i64) ?[]const u8 {
|
|
const slot = &self.slots[period_index];
|
|
if (slot.body.len == 0) return null;
|
|
if (slot.until != until or slot.data_version != data_version) return null;
|
|
return slot.body;
|
|
}
|
|
|
|
/// Takes ownership of `body`, which must be a `gpa` allocation, and frees
|
|
/// whatever the slot held.
|
|
pub fn put(
|
|
self: *OverviewCache,
|
|
gpa: Allocator,
|
|
period_index: usize,
|
|
until: i64,
|
|
data_version: i64,
|
|
body: []u8,
|
|
) void {
|
|
const slot = &self.slots[period_index];
|
|
gpa.free(slot.body);
|
|
slot.* = .{ .body = body, .until = until, .data_version = data_version };
|
|
}
|
|
|
|
pub fn deinit(self: *OverviewCache, gpa: Allocator) void {
|
|
for (&self.slots) |*slot| {
|
|
gpa.free(slot.body);
|
|
slot.* = .{};
|
|
}
|
|
}
|
|
};
|
|
|
|
pub const WebState = struct {
|
|
gpa: Allocator,
|
|
web: model.Web = .{},
|
|
/// The live `web.trusted_proxies`. `web` above is the boot configuration
|
|
/// and goes stale the moment `PUT /api/settings` changes the list, exactly
|
|
/// as it does for the password: the request path reads this holder and
|
|
/// never `web.trusted_proxies`. The composition root seeds it from the boot
|
|
/// value, and whoever owns the `WebState` calls `proxies.deinit`.
|
|
proxies: LiveProxies = .{},
|
|
|
|
/// Defaults to `.database`: a `WebState` nobody told about a managed file
|
|
/// governs nothing declaratively, which is the safe reading — the mutation
|
|
/// routes stay live rather than a half-wired server refusing every write.
|
|
authority: Authority = .database,
|
|
/// When this process loaded the managed file, in epoch seconds. Null in
|
|
/// database mode, which never reconciles. It answers exactly "this process
|
|
/// loaded the file at T" and nothing more: a file whose mtime is newer has
|
|
/// not been loaded by the running process.
|
|
reconciled_at: ?i64 = null,
|
|
/// Whether a configuration change this process already committed waits for
|
|
/// a restart to take effect: the upstream pool and the scalar settings are
|
|
/// both built at startup. Per-process state, never persisted — nothing
|
|
/// clears it but process exit, which is exactly what applies the change.
|
|
/// Only database-mode mutations raise it; file mode never reaches them.
|
|
restart_pending: std.atomic.Value(bool) = .init(false),
|
|
|
|
handler: ?*dns_handler.Handler = null,
|
|
pause: ?*pause_mod.Pause = null,
|
|
tracker: ?*clients.Tracker = null,
|
|
/// The learned-name resolver, for `metrics.collect` (milestone-25 ruling 9).
|
|
client_names: ?*client_names.Resolver = null,
|
|
manager: ?*manager_mod.Manager = null,
|
|
/// The published upstream generation. A metrics or health scrape pins one
|
|
/// for the length of its read, so a `replace` cannot free the pool it is
|
|
/// copying out of.
|
|
upstreams: ?*upstream_owner.Owner = null,
|
|
/// What building a replacement upstream generation needs beyond the rows
|
|
/// themselves. Null in a state whose upstream owner is a test's borrowed
|
|
/// one: there is then nothing to build, and an upstream mutation applies to
|
|
/// the database alone.
|
|
upstream_build: ?UpstreamBuild = 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,
|
|
/// The query logger's controller, not a `Logger`: a resize replaces the
|
|
/// generation, and the counters a scrape reads are the controller's.
|
|
logger: ?*logger_controller.Controller = null,
|
|
retention: ?*retention_mod.Retention = null,
|
|
/// The one `logging.retention_days` cell both prune passes read. Separate
|
|
/// from `retention` above, which is one of the two readers: a settings
|
|
/// apply stores here and moves both of them together.
|
|
retention_days: ?*retention_mod.RetentionDays = 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 DoH/DoT certificate stores; null while an endpoint is disabled.
|
|
/// `POST /api/certs/reload` reloads through these, and `/metrics` reads
|
|
/// their counters (milestone-10 rulings 8 and 10).
|
|
doh_certs: ?*cert_store.CertStore = null,
|
|
dot_certs: ?*cert_store.CertStore = null,
|
|
/// The DoH/DoT listeners themselves; null while an endpoint is disabled
|
|
/// or its bind failed. `/metrics` reads their connection counters
|
|
/// (milestone-10 ruling 10).
|
|
doh_listener: ?*doh_server.DohServer = null,
|
|
dot_listener: ?*dot_server.DotServer = null,
|
|
/// The plain DNS listeners. The app binds one per family per protocol, so
|
|
/// `/metrics` sums each family across its slice (milestone-16 ruling 13).
|
|
udp_listeners: []const *udp_server.UdpServer = &.{},
|
|
tcp_listeners: []const *tcp_server.TcpServer = &.{},
|
|
|
|
/// 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,
|
|
/// Serializes the web layer's work on `querylog_db`, for the same reason
|
|
/// `config_lock` exists and one more: the read handlers wrap their several
|
|
/// statements in a transaction, and SQLite's serialized mode protects a
|
|
/// single call, not a transaction. Without this, two concurrent BEGINs on
|
|
/// the shared connection would fail and a third task's reads would land
|
|
/// inside someone else's snapshot.
|
|
querylog_lock: std.Io.Mutex = .init,
|
|
/// The Overview response cache, guarded by `querylog_lock` above. Whoever
|
|
/// owns the `WebState` calls `overview_cache.deinit`.
|
|
overview_cache: OverviewCache = .{},
|
|
/// The diagnostics event store, which owns a third connection of its own
|
|
/// and serializes every access — read and write — through its mutex. Null
|
|
/// when `Store.init` failed, which `/api/health` reports as `unavailable`
|
|
/// and treats as degraded.
|
|
events: ?*events_mod.Store = null,
|
|
|
|
version: []const u8 = "",
|
|
/// The `--admin-dev` asset directory, read by the dev-mode fallback. Empty
|
|
/// whenever that fallback is not wired.
|
|
dev_dir: []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,
|
|
};
|
|
|
|
/// One response's hold on the query log: `querylog_lock` plus one deferred read
|
|
/// transaction, opened and closed together so no reader can hold one without
|
|
/// the other.
|
|
///
|
|
/// Every web-layer read of `querylog_db` goes through this. The transaction is
|
|
/// what makes an aggregate and the coverage watermark beside it describe one
|
|
/// database state, and the lock is what makes the transaction meaningful on a
|
|
/// connection several tasks share.
|
|
///
|
|
/// `commit` is fallible and must be called before the response is written: a
|
|
/// connection still inside a transaction refuses the next `BEGIN`, so a handler
|
|
/// that answered 200 over a failed commit would leave every later query-log
|
|
/// request failing for a reason nothing on the wire ever named.
|
|
///
|
|
/// **The lock is always released, even when the transaction could not be
|
|
/// ended.** `lockUncancelable` cannot be interrupted, so holding it against a
|
|
/// connection that will not leave its transaction would park every later
|
|
/// query-log task forever, with no status and no way out but a kill. Releasing
|
|
/// it turns the same fault into a 500 per request: bounded, visible, and
|
|
/// recoverable by a restart.
|
|
/// **The lock is released exactly once, on every path.** The usage shape below
|
|
/// runs `abort` after a failed `commit` — an `errdefer` cannot know the error
|
|
/// came from the commit itself — so `release` is the single owner of the
|
|
/// unlock and `held` is what makes the second call a no-op. Unlocking an
|
|
/// already-unlocked `std.Io.Mutex` is `unreachable`, and under contention it
|
|
/// would hand away a hold another task had just taken, so the bounded 500 this
|
|
/// type promises would instead be a crash or a corrupted mutex.
|
|
///
|
|
/// ```zig
|
|
/// var scope = try QuerylogRead.open(state, io, database);
|
|
/// errdefer scope.abort();
|
|
/// ... // reads only
|
|
/// try scope.commit();
|
|
/// ```
|
|
pub const QuerylogRead = struct {
|
|
state: *WebState,
|
|
io: std.Io,
|
|
tx: db.ReadTx,
|
|
held: bool,
|
|
|
|
pub fn open(state: *WebState, io: std.Io, database: *db.Db) db.Error!QuerylogRead {
|
|
state.querylog_lock.lockUncancelable(io);
|
|
errdefer state.querylog_lock.unlock(io);
|
|
var scope = try openLocked(state, io, database);
|
|
scope.held = true;
|
|
return scope;
|
|
}
|
|
|
|
/// The transaction alone, for a caller that already holds `querylog_lock`
|
|
/// and keeps holding it past `commit` — the overview handler, which decides
|
|
/// its response cache under the same one hold. Calling `open` there would
|
|
/// deadlock on a mutex the task already owns.
|
|
///
|
|
/// The returned scope releases nothing: `commit` and `abort` end the
|
|
/// transaction and leave the lock to whoever took it.
|
|
pub fn openLocked(state: *WebState, io: std.Io, database: *db.Db) db.Error!QuerylogRead {
|
|
return .{
|
|
.state = state,
|
|
.io = io,
|
|
.tx = try db.ReadTx.begin(database),
|
|
.held = false,
|
|
};
|
|
}
|
|
|
|
pub fn commit(self: *QuerylogRead) db.Error!void {
|
|
defer self.release();
|
|
return self.tx.commit();
|
|
}
|
|
|
|
/// Safe in `errdefer`, and safe after `commit` however that ended: both the
|
|
/// rollback and the release are idempotent.
|
|
pub fn abort(self: *QuerylogRead) void {
|
|
self.tx.rollback();
|
|
self.release();
|
|
}
|
|
|
|
fn release(self: *QuerylogRead) void {
|
|
if (!self.held) return;
|
|
self.held = false;
|
|
self.state.querylog_lock.unlock(self.io);
|
|
}
|
|
};
|
|
|
|
/// 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.
|
|
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.
|
|
///
|
|
/// Keyed on `client_addr`, not on the socket peer: behind a trusted proxy every
|
|
/// peer is the proxy, and one bucket for every remote user is no limiter at all.
|
|
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.client_addr));
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
|
|
/// What the admin listener counts on top of `listener.CoreStats`. Nothing
|
|
/// exports these: there is no `nxdns_web_*` family, they exist for the
|
|
/// integration tests and for a future one.
|
|
pub const Stats = struct {
|
|
requests: std.atomic.Value(u64) = .init(0),
|
|
};
|
|
|
|
pub const Options = struct {
|
|
max_connections: u16 = default_max_connections,
|
|
};
|
|
|
|
pub const Server = struct {
|
|
core: listener_core.Core(Config),
|
|
state: *WebState,
|
|
stats: Stats,
|
|
|
|
/// 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). The
|
|
/// receive and send buffers belong to the core.
|
|
pub const Payload = struct {
|
|
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,
|
|
xff_buf: [http_util.max_xff_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,
|
|
|
|
fn init(payload: *Payload, gpa: Allocator) void {
|
|
payload.arena = .init(gpa);
|
|
}
|
|
|
|
fn deinit(payload: *Payload) void {
|
|
payload.arena.deinit();
|
|
}
|
|
};
|
|
|
|
const Config = struct {
|
|
pub const Owner = Server;
|
|
pub const ConnPayload = Payload;
|
|
pub const serveConn = serveOne;
|
|
pub const read_buffer_len = recv_buffer_len;
|
|
pub const write_buffer_len = send_buffer_len;
|
|
pub const log = std.log.scoped(.web_server);
|
|
pub const name = "web";
|
|
pub const refuse = refuseOverCapacity;
|
|
pub const initPayload = Payload.init;
|
|
pub const deinitPayload = Payload.deinit;
|
|
};
|
|
|
|
pub const Conn = listener_core.Core(Config).Conn;
|
|
pub const ListenError = listener_core.Core(Config).ListenError;
|
|
|
|
pub fn listen(
|
|
gpa: Allocator,
|
|
io: std.Io,
|
|
listen_address: net.IpAddress,
|
|
state: *WebState,
|
|
options: Options,
|
|
) ListenError!Server {
|
|
return .{
|
|
.core = try listener_core.Core(Config).listen(gpa, io, listen_address, options.max_connections),
|
|
.state = state,
|
|
.stats = .{},
|
|
};
|
|
}
|
|
|
|
/// The kernel-assigned address. A port of 0 in `listen` resolves here.
|
|
pub fn boundAddress(self: *const Server) net.IpAddress {
|
|
return self.core.boundAddress();
|
|
}
|
|
|
|
/// Accept loop. Returns when the task is canceled or `deinit` stops it.
|
|
pub fn serve(self: *Server, io: std.Io) void {
|
|
self.core.serve(io);
|
|
}
|
|
|
|
pub fn deinit(self: *Server, io: std.Io) void {
|
|
// Ruling 11 of milestone 16, before the core shuts the connections
|
|
// down: a live-query task parked in `Hub.wait` is waiting on an event,
|
|
// not on its socket, so shutting the connection down does not reach it.
|
|
// Without this the drain waits out one heartbeat interval per idle
|
|
// stream.
|
|
if (self.state.hub) |hub| hub.close(io);
|
|
|
|
self.core.deinit(io);
|
|
self.* = undefined;
|
|
}
|
|
|
|
/// 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 refuseOverCapacity(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);
|
|
}
|
|
|
|
/// One connection's keep-alive loop. The core closes the slot when this
|
|
/// returns.
|
|
fn serveOne(self: *Server, io: std.Io, index: usize) void {
|
|
const conn = &self.core.conns[index];
|
|
var reader = conn.stream.reader(io, &conn.read_buf);
|
|
var writer = conn.stream.writer(io, &conn.write_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 => {
|
|
listener_core.bump(&self.core.stats.connection_errors);
|
|
return;
|
|
},
|
|
error.HttpRequestTruncated, error.HttpHeadersInvalid => {
|
|
listener_core.bump(&self.core.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;
|
|
}
|
|
|
|
listener_core.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.payload.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 => {
|
|
listener_core.bump(&self.core.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.payload.arena.allocator();
|
|
|
|
const target = request.head.target;
|
|
if (target.len > conn.payload.target_buf.len) {
|
|
var view = bareRequest(request, conn, arena);
|
|
return http_util.respondError(&view, .uri_too_long, "target too long");
|
|
}
|
|
@memcpy(conn.payload.target_buf[0..target.len], target);
|
|
const copied = conn.payload.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 = copyCookie(request, &conn.payload.cookie_buf);
|
|
const accept_encoding = copyHeader(request, "accept-encoding", &conn.payload.accept_encoding_buf);
|
|
const if_none_match = copyHeader(request, "if-none-match", &conn.payload.if_none_match_buf);
|
|
|
|
const peer = address.NetAddress.fromIp(conn.peer);
|
|
const forwarded_for = copyHeaderSuffix(request, "x-forwarded-for", &conn.payload.xff_buf);
|
|
// The hold ends with the verdict, before dispatch: a settings PUT takes
|
|
// the exclusive lock from inside its own request, so a hold that lasted
|
|
// the request would be that request waiting on itself.
|
|
const verdict = blk: {
|
|
const proxies = self.state.proxies.acquire(io);
|
|
defer proxies.release(io);
|
|
break :blk clientAddr(proxies.text, peer, forwarded_for);
|
|
};
|
|
const client_addr = switch (verdict) {
|
|
.addr => |addr| addr,
|
|
.bad_forwarded_for => {
|
|
var view = bareRequest(request, conn, arena);
|
|
return http_util.respondError(&view, .bad_request, "malformed x-forwarded-for");
|
|
},
|
|
};
|
|
// A forwarded entry has no port of its own; the peer keeps the one it
|
|
// connected from.
|
|
const client_ip = if (client_addr.eql(peer)) conn.peer else client_addr.toIp(0);
|
|
|
|
// 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,
|
|
.client_addr = client_ip,
|
|
.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,
|
|
// These responses are decided before the forwarded-for header is
|
|
// read, or because reading it failed; the socket peer is all that
|
|
// is known.
|
|
.client_addr = conn.peer,
|
|
.arena = arena,
|
|
};
|
|
}
|
|
};
|
|
|
|
/// Copies one header value into `buf`. A value too long for its budget reads as
|
|
/// absent: the headers this applies to are an `accept-encoding` and an
|
|
/// `if-none-match`, and losing either degrades to uncompressed and
|
|
/// unconditional — never to a wrong answer. The cookie header has its own
|
|
/// copier, because losing it costs the session (ruling 7 of milestone 16).
|
|
fn copyHeader(request: *http.Server.Request, name: []const u8, buf: []u8) []const u8 {
|
|
const value = headerValue(request, name) orelse return "";
|
|
if (value.len > buf.len) return "";
|
|
@memcpy(buf[0..value.len], value);
|
|
return buf[0..value.len];
|
|
}
|
|
|
|
/// Copies the **last** `buf.len` bytes of one header value into `buf`. Null
|
|
/// means the header is absent, which is a different answer from an empty value.
|
|
///
|
|
/// The tail is what matters for `x-forwarded-for`, and `copyHeader`'s
|
|
/// empty-on-overflow rule would be a security hole here: an empty value reads as
|
|
/// "no header", the effective client falls back to the socket peer, and behind
|
|
/// the same-box proxy this feature exists for that peer is loopback — which
|
|
/// `web.api_localhost_exempt` exempts from the API limiter by default. A client
|
|
/// would regain the exemption by sending an oversized header. Keeping the tail
|
|
/// closes that: the proxy appends its entry last, so the entry that names the
|
|
/// real client is always in the final bytes.
|
|
fn copyHeaderSuffix(request: *http.Server.Request, name: []const u8, buf: []u8) ?[]const u8 {
|
|
const value = headerValue(request, name) orelse return null;
|
|
const tail = if (value.len > buf.len) value[value.len - buf.len ..] else value;
|
|
@memcpy(buf[0..tail.len], tail);
|
|
return buf[0..tail.len];
|
|
}
|
|
|
|
/// Who a request is from, or the one way deriving that can fail.
|
|
const ClientAddr = union(enum) {
|
|
addr: address.NetAddress,
|
|
bad_forwarded_for,
|
|
};
|
|
|
|
/// Milestone-17 ruling 4. The socket peer, unless it is a trusted proxy that
|
|
/// forwarded the request, in which case the last entry of `x-forwarded-for` —
|
|
/// the one that proxy appended, and the only entry in the chain a client cannot
|
|
/// write.
|
|
///
|
|
/// A missing header falls back to the peer: a trusted proxy that adds no header
|
|
/// is a proxy nobody asked to trust anything about, and the peer is still true.
|
|
/// A header that is present but whose last entry is not an IP literal fails
|
|
/// closed with a 400 instead. Only a misconfigured proxy can produce that — a
|
|
/// client's spoofed entries can never terminate the chain — and falling back
|
|
/// there would hand the request the loopback identity this ruling exists to
|
|
/// take away.
|
|
fn clientAddr(
|
|
trusted_proxies: []const u8,
|
|
peer: address.NetAddress,
|
|
forwarded_for: ?[]const u8,
|
|
) ClientAddr {
|
|
if (!trustsPeer(trusted_proxies, peer)) return .{ .addr = peer };
|
|
const chain = forwarded_for orelse return .{ .addr = peer };
|
|
|
|
const start = if (std.mem.lastIndexOfScalar(u8, chain, ',')) |comma| comma + 1 else 0;
|
|
const last = std.mem.trim(u8, chain[start..], " \t");
|
|
const addr = address.NetAddress.parse(last) catch return .bad_forwarded_for;
|
|
return .{ .addr = addr };
|
|
}
|
|
|
|
/// Whether `peer` is one of the configured trusted proxies. An element that is
|
|
/// not an IP literal matches nothing; `validate.zig` is what tells the operator
|
|
/// about it, and refusing to serve over a typo here would be a worse answer.
|
|
fn trustsPeer(trusted_proxies: []const u8, peer: address.NetAddress) bool {
|
|
var it = model.trustedProxies(trusted_proxies);
|
|
while (it.next()) |element| {
|
|
const trusted = address.NetAddress.parse(element) catch continue;
|
|
if (trusted.eql(peer)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// The first value sent under `name`, borrowed from the request head.
|
|
fn headerValue(request: *http.Server.Request, name: []const u8) ?[]const u8 {
|
|
var it = request.iterateHeaders();
|
|
while (it.next()) |header| {
|
|
if (std.ascii.eqlIgnoreCase(header.name, name)) return header.value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Milestone-16 ruling 7. The cookie header is the one budget a foreign party
|
|
/// can spend: behind a reverse proxy on a shared domain, every other cookie set
|
|
/// for the domain rides along. Treating the whole header as absent then logs the
|
|
/// operator out of a working session with nothing in the log to explain it, so
|
|
/// an oversized header keeps the session pair, drops the rest, and says so.
|
|
fn copyCookie(request: *http.Server.Request, buf: []u8) []const u8 {
|
|
const value = headerValue(request, "cookie") orelse return "";
|
|
if (value.len <= buf.len) {
|
|
@memcpy(buf[0..value.len], value);
|
|
return buf[0..value.len];
|
|
}
|
|
|
|
const kept = sessionPairOnly(value, buf);
|
|
// The session value is a random id and the name is a constant, so neither
|
|
// the size nor the outcome discloses anything the client did not send.
|
|
log.debug("cookie header of {d} bytes exceeds the {d} byte budget; {s}", .{
|
|
value.len,
|
|
buf.len,
|
|
if (kept.len == 0) "no session cookie kept" else "kept the session cookie alone",
|
|
});
|
|
return kept;
|
|
}
|
|
|
|
/// Rewrites an oversized cookie header as just its session pair. Empty when the
|
|
/// header carries no session cookie, or when even the pair is over budget.
|
|
fn sessionPairOnly(value: []const u8, buf: []u8) []const u8 {
|
|
const session = http_util.cookieValue(value, auth.cookie_name) orelse return "";
|
|
const len = auth.cookie_name.len + 1 + session.len;
|
|
if (len > buf.len) return "";
|
|
|
|
@memcpy(buf[0..auth.cookie_name.len], auth.cookie_name);
|
|
buf[auth.cookie_name.len] = '=';
|
|
@memcpy(buf[auth.cookie_name.len + 1 ..][0..session.len], session);
|
|
return buf[0..len];
|
|
}
|
|
|
|
/// 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(io);
|
|
|
|
log.info("web interface listening on {f}", .{server.boundAddress()});
|
|
server.serve(io);
|
|
}
|
|
|
|
const testing = std.testing;
|
|
|
|
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 oversized cookie header keeps its session pair and nothing else" {
|
|
var buf: [http_util.max_cookie_len]u8 = undefined;
|
|
var header: std.ArrayList(u8) = .empty;
|
|
defer header.deinit(testing.allocator);
|
|
|
|
try header.appendSlice(testing.allocator, "consent=yes; ");
|
|
try header.appendSlice(testing.allocator, auth.cookie_name ++ "=abc123; ");
|
|
while (header.items.len < 2048) try header.appendSlice(testing.allocator, "ad_id=0123456789; ");
|
|
|
|
const kept = sessionPairOnly(header.items, &buf);
|
|
try testing.expectEqualStrings(auth.cookie_name ++ "=abc123", kept);
|
|
try testing.expectEqualStrings("abc123", http_util.cookieValue(kept, auth.cookie_name).?);
|
|
}
|
|
|
|
test "an oversized cookie header with no session pair keeps nothing" {
|
|
var buf: [http_util.max_cookie_len]u8 = undefined;
|
|
var header: std.ArrayList(u8) = .empty;
|
|
defer header.deinit(testing.allocator);
|
|
|
|
while (header.items.len < 2048) try header.appendSlice(testing.allocator, "ad_id=0123456789; ");
|
|
|
|
try testing.expectEqualStrings("", sessionPairOnly(header.items, &buf));
|
|
}
|
|
|
|
test "a session pair too long for the buffer keeps nothing" {
|
|
var buf: [32]u8 = undefined;
|
|
const header = auth.cookie_name ++ "=" ++ ("v" ** 64);
|
|
try testing.expectEqualStrings("", sessionPairOnly(header, &buf));
|
|
}
|
|
|
|
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) },
|
|
.client_addr = .{ .ip4 = .loopback(0) },
|
|
.arena = testing.allocator,
|
|
};
|
|
}
|
|
|
|
fn ip(text: []const u8) address.NetAddress {
|
|
return address.NetAddress.parse(text) catch unreachable;
|
|
}
|
|
|
|
test "with no trusted proxy configured the socket peer is the client" {
|
|
const peer = ip("192.0.2.1");
|
|
try testing.expect(clientAddr("", peer, "203.0.113.9").addr.eql(peer));
|
|
}
|
|
|
|
test "a spoofed forwarded-for from an untrusted peer is ignored" {
|
|
const peer = ip("192.0.2.1");
|
|
try testing.expect(clientAddr("10.0.0.1", peer, "203.0.113.9").addr.eql(peer));
|
|
}
|
|
|
|
test "a trusted peer is identified by the last forwarded-for entry" {
|
|
const peer = ip("10.0.0.1");
|
|
const derived = clientAddr("10.0.0.1, 10.0.0.2", peer, "203.0.113.9, 198.51.100.7");
|
|
try testing.expect(derived.addr.eql(ip("198.51.100.7")));
|
|
}
|
|
|
|
test "a trusted peer that forwards nothing stays itself" {
|
|
const peer = ip("10.0.0.1");
|
|
try testing.expect(clientAddr("10.0.0.1", peer, null).addr.eql(peer));
|
|
}
|
|
|
|
test "an IPv6 proxy and an IPv6 forwarded entry work the same way" {
|
|
const peer = ip("fd00::1");
|
|
const derived = clientAddr("fd00::1", peer, "2001:db8::5, 2001:db8::7");
|
|
try testing.expect(derived.addr.eql(ip("2001:db8::7")));
|
|
}
|
|
|
|
test "an oversized forwarded-for keeps the entry the proxy appended" {
|
|
var header: std.ArrayList(u8) = .empty;
|
|
defer header.deinit(testing.allocator);
|
|
while (header.items.len < 4096) try header.appendSlice(testing.allocator, "203.0.113.9, ");
|
|
try header.appendSlice(testing.allocator, "198.51.100.7");
|
|
|
|
// What the connection would copy: the tail alone, the head thrown away.
|
|
const tail = header.items[header.items.len - http_util.max_xff_len ..];
|
|
const derived = clientAddr("10.0.0.1", ip("10.0.0.1"), tail);
|
|
try testing.expect(derived.addr.eql(ip("198.51.100.7")));
|
|
}
|
|
|
|
test "a trusted peer whose forwarded-for has no valid last entry is refused" {
|
|
const peer = ip("10.0.0.1");
|
|
try testing.expectEqual(
|
|
ClientAddr.bad_forwarded_for,
|
|
std.meta.activeTag(clientAddr("10.0.0.1", peer, "203.0.113.9, not-an-ip")),
|
|
);
|
|
try testing.expectEqual(
|
|
ClientAddr.bad_forwarded_for,
|
|
std.meta.activeTag(clientAddr("10.0.0.1", peer, "")),
|
|
);
|
|
// An oversized header whose tail cuts the last entry in half is the same
|
|
// failure, and must not degrade to the exemptible socket peer.
|
|
try testing.expectEqual(
|
|
ClientAddr.bad_forwarded_for,
|
|
std.meta.activeTag(clientAddr("10.0.0.1", peer, "8.51.100.7000")),
|
|
);
|
|
}
|
|
|
|
test "a trusted-proxy element that is not an IP literal trusts nobody" {
|
|
const peer = ip("10.0.0.1");
|
|
try testing.expect(!trustsPeer("proxy.example", peer));
|
|
try testing.expect(trustsPeer("proxy.example, 10.0.0.1", peer));
|
|
try testing.expect(!trustsPeer("", peer));
|
|
}
|
|
|
|
/// Every generation this test installs trusts `10.0.0.1` and nothing else, so
|
|
/// a reader that ever disagrees read a generation that was already freed.
|
|
const proxy_generations = [_][]const u8{
|
|
"10.0.0.1",
|
|
"10.0.0.1, 10.0.0.2",
|
|
"10.0.0.1,fd00::1,10.0.0.3",
|
|
" 10.0.0.1 ",
|
|
};
|
|
|
|
fn readProxiesRepeatedly(live: *LiveProxies, io: std.Io, rounds: usize, disagreed: *bool) void {
|
|
const peer = ip("10.0.0.1");
|
|
const stranger = ip("198.51.100.7");
|
|
for (0..rounds) |_| {
|
|
const held = live.acquire(io);
|
|
defer held.release(io);
|
|
if (!trustsPeer(held.text, peer)) disagreed.* = true;
|
|
if (trustsPeer(held.text, stranger)) disagreed.* = true;
|
|
}
|
|
}
|
|
|
|
test "trusted proxies are replaced under concurrent request-path reads" {
|
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
|
|
var live: LiveProxies = .init(proxy_generations[0]);
|
|
defer live.deinit(testing.allocator);
|
|
|
|
var disagreed = false;
|
|
var reader = try io.concurrent(readProxiesRepeatedly, .{ &live, io, 2_000, &disagreed });
|
|
|
|
for (0..2_000) |i| {
|
|
const prepared = try testing.allocator.dupe(u8, proxy_generations[i % proxy_generations.len]);
|
|
// Publish, then retire: `install` returns only once no reader holds
|
|
// what it replaced, which is what makes this free safe.
|
|
if (live.install(io, prepared)) |retired| testing.allocator.free(retired);
|
|
}
|
|
|
|
reader.await(io);
|
|
try testing.expect(!disagreed);
|
|
}
|
|
|
|
test "the boot text is borrowed and the first install is what starts owning" {
|
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
|
|
var boot_text: [8]u8 = "10.0.0.1".*;
|
|
var live: LiveProxies = .init(&boot_text);
|
|
defer live.deinit(testing.allocator);
|
|
|
|
// Nothing to retire: the boot text belongs to the loaded configuration.
|
|
const first = try testing.allocator.dupe(u8, "10.0.0.2");
|
|
try testing.expect(live.install(io, first) == null);
|
|
|
|
const second = try testing.allocator.dupe(u8, "10.0.0.3");
|
|
const retired = live.install(io, second).?;
|
|
try testing.expectEqualStrings("10.0.0.2", retired);
|
|
testing.allocator.free(retired);
|
|
|
|
const held = live.acquire(io);
|
|
defer held.release(io);
|
|
try testing.expectEqualStrings("10.0.0.3", held.text);
|
|
}
|