Files
browser/src/server/http.zig
T
2026-09-22 07:47:06 +08:00

1226 lines
47 KiB
Zig

// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const App = @import("../App.zig");
const sys_net = @import("../sys/net.zig");
const header_parser = @import("../network/header_parser.zig");
const statusCategory = @import("../network/http.zig").statusCategory;
const Server = @import("Server.zig");
const Driver = @import("Driver.zig");
const bidi_session = @import("bidi/session.zig");
const http_command = @import("bidi/http_command.zig");
const uuidv4 = @import("../id.zig").uuidv4;
const log = lp.log;
const posix = std.posix;
const Allocator = std.mem.Allocator;
// A client connection in its http phase: loop-owned, pooled.
pub const Connection = struct {
state: State,
buffer: Buffer,
socket: posix.socket_t,
address: sys_net.IpAddress,
node: std.DoublyLinkedList.Node,
// When a keepalive (or just connected) connection should be closed
deadline: u64,
// Response that couldn't be sent without blocking. Socket will switch to
// "write-mode" until it's drained.
pending: ?Writing,
pub const Writing = struct {
pos: usize, // how ,uch of Data we've already written
data: Data,
keepalive: bool,
pub const Data = union(enum) {
// copied out of the server's scratch buffer; freed once written
owned: []const u8,
// lives as long as the server; referenced, never freed
static: []const u8,
// built by a worker in a pooled arena; released once written
pooled: Pooled,
};
pub const Pooled = struct {
arena: *lp.Arena,
bytes: []const u8,
};
pub fn remaining(self: *const Writing) []const u8 {
return switch (self.data) {
.owned, .static => |d| d[self.pos..],
.pooled => |p| p.bytes[self.pos..],
};
}
pub fn deinit(self: *const Writing, allocator: Allocator) void {
switch (self.data) {
.static => {},
.owned => |owned| allocator.free(owned),
.pooled => |p| p.arena.release(),
}
}
};
pub fn deinit(self: *Connection) void {
self.buffer.deinit();
}
// True if the request is in keepalive state and thus is a candidate to be
// closed if we need its slot for a new connection.
pub fn isIdle(self: *const Connection) bool {
if (self.pending != null or self.buffer.len != 0) {
// has a pending write, or has extra data to read
return false;
}
return self.state == .header;
}
pub const Request = struct {
method: Method,
// origin-form, query string stripped, always starts with '/'
path: []const u8,
keepalive: bool,
body: []const u8,
// The raw request head (request line + headers, through the final
// CRLF CRLF); a slice into the read buffer. Upgrade handlers re-parse
// it for the WebSocket headers.
head: []const u8,
// Filled in by the router for /session/{id}[/...] routes; points
// into the read buffer like path does.
session_id: ?*const [36]u8 = null,
// valid for handling a single request up to sending the response
arena: Allocator,
};
pub const Method = enum {
GET,
POST,
PUT,
DELETE,
};
pub const State = union(enum) {
header: void, // still parsing the header
request: Request,
// What the connection still needs before the request can be served.
const Parsed = union(enum) {
complete,
need: usize, // bytes the buffer needs will hold, 0 while parsing the header
};
fn parseHeader(self: *State, arena: Allocator, data: []u8) !Parsed {
const header_index = std.mem.indexOf(u8, data, "\r\n\r\n") orelse {
return .{ .need = 0 };
};
// include the last line's \r\n so every line, including the request
// line of a header-less request, is terminated
const header = data[0 .. header_index + 2];
const method, const path, const keepalive, const line_1_end = try parseRequestLine(header);
_ = line_1_end;
const body_start = header_index + 4;
// large content lenghts will saturate to max(usize) -> 413
const total = body_start +| try contentLength(header);
if (data.len < total) {
// the body is still arriving
return .{ .need = total };
}
// A WebSocket upgrade may be pipelined with its first frames, but every
// client we care about waits for the 101 first. Anything past the
// declared body is unsupported (and rejects pipelining).
if (data.len != total) {
return error.BodyNotSupported;
}
self.* = .{ .request = .{
.method = method,
.path = path,
.keepalive = keepalive,
.body = data[body_start..total],
.head = data[0..body_start],
.arena = arena,
} };
return .complete;
}
fn contentLength(header: []const u8) !usize {
const key = "\r\ncontent-length:";
const at = std.ascii.indexOfIgnoreCase(header, key) orelse return 0;
const start = at + key.len;
const end = std.mem.indexOfPos(u8, header, start, "\r\n") orelse return error.InvalidHeader;
const value = std.mem.trim(u8, header[start..end], " \t");
return std.fmt.parseInt(usize, value, 10) catch error.InvalidHeader;
}
fn parseRequestLine(header: []const u8) !struct { Method, []const u8, bool, usize } {
const l1 = std.mem.indexOfScalar(u8, header, '\r') orelse return error.InvalidHeader;
if (l1 == header.len) {
return error.InvalidHeader;
}
if (header[l1 + 1] != '\n') {
return error.InvalidHeader;
}
var it = std.mem.tokenizeScalar(u8, header[0..l1], ' ');
const method = std.meta.stringToEnum(Method, it.next() orelse return error.InvalidHeader) orelse return error.InvalidHTTPMethod;
// Only the origin-form request-target is accepted; nothing we serve
// reads the query string, so it's dropped here.
const target = it.next() orelse return error.InvalidHeader;
if (target[0] != '/') {
return error.InvalidHeader;
}
const path = target[0 .. std.mem.indexOfScalar(u8, target, '?') orelse target.len];
const protocol = it.next() orelse return error.InvalidHeader;
const keepalive = std.mem.indexOf(u8, protocol, "1.0") == null;
return .{ method, path, keepalive, l1 };
}
};
const Buffer = struct {
buf: []u8,
// position in buf up until where we have valid data
len: usize,
max: usize,
allocator: Allocator,
fn init(allocator: Allocator, max: usize) !Buffer {
const real_max = @max(max, INITIAL_BUFFER_SIZE);
return .{
.len = 0,
.max = real_max,
.buf = try allocator.alloc(u8, INITIAL_BUFFER_SIZE),
.allocator = allocator,
};
}
fn deinit(self: *const Buffer) void {
self.allocator.free(self.buf);
}
fn reset(self: *Buffer) void {
self.len = 0;
if (self.buf.len == INITIAL_BUFFER_SIZE) {
return;
}
// keeping the larger buffer is only wasteful, so failure is fine
self.buf = self.allocator.realloc(self.buf, INITIAL_BUFFER_SIZE) catch self.buf;
}
fn ensureCapacity(self: *Buffer, needed: usize) !void {
if (needed <= self.buf.len) {
return;
}
if (needed > self.max) {
return error.RequestTooLarge;
}
self.buf = try self.allocator.realloc(self.buf, needed);
}
pub fn read(self: *Buffer, socket: posix.socket_t) ![]u8 {
const len = self.len;
if (len == self.buf.len) {
if (self.buf.len == self.max) {
return error.RequestTooLarge;
}
// Only the header gets here: its length isn't declared, so we
// double until it fits. A body is sized from Content-Length.
try self.ensureCapacity(@min(self.buf.len * 2, self.max));
}
const n = try posix.read(socket, self.buf[len..]);
if (n == 0) {
return error.ConnectionClosed;
}
const total = len + n;
self.len = total;
return self.buf[0..total];
}
};
pub const Pool = struct {
allocator: Allocator,
free: std.DoublyLinkedList,
live: usize, // acquired and not yet released
retain: usize, // min # to keep
free_count: usize, // # of connections available in free
max_buffer_size: usize, // --cdp-max-http-message-size
pub fn init(app: *App) !Pool {
const retain = app.config.maxConnections();
var self = Pool{
.live = 0,
.free = .{},
.free_count = 0,
.retain = retain,
.allocator = app.allocator,
.max_buffer_size = app.config.cdpMaxHTTPMessageSize(),
};
errdefer self.deinit();
for (0..retain) |_| {
const conn = try self.create();
self.free.append(&conn.node);
self.free_count += 1;
}
return self;
}
// Every live connection must have been released (the server disconnects
// them all on deinit).
pub fn deinit(self: *Pool) void {
lp.assert(self.live == 0, "Connection.Pool.deinit live", .{ .live = self.live });
while (self.free.popFirst()) |node| {
const conn: *Connection = @fieldParentPtr("node", node);
self.destroy(conn);
}
}
pub fn acquire(self: *Pool) !*Connection {
const conn = blk: {
if (self.free.popFirst()) |node| {
self.free_count -= 1;
break :blk @as(*Connection, @fieldParentPtr("node", node));
}
break :blk try self.create();
};
self.live += 1;
return conn;
}
pub fn release(self: *Pool, conn: *Connection) void {
self.live -= 1;
if (self.free_count == self.retain) {
return self.destroy(conn);
}
conn.node = .{};
conn.socket = -1;
conn.address = .{ .ip4 = .unspecified(0) };
conn.deadline = 0;
conn.pending = null;
conn.buffer.reset();
conn.state = .header;
self.free.prepend(&conn.node);
self.free_count += 1;
}
fn create(self: *Pool) !*Connection {
const allocator = self.allocator;
const conn = try allocator.create(Connection);
errdefer allocator.destroy(conn);
conn.* = .{
.node = .{},
.socket = -1,
.address = .{ .ip4 = .unspecified(0) },
.deadline = 0,
.pending = null,
.state = .header,
.buffer = try .init(allocator, self.max_buffer_size),
};
return conn;
}
fn destroy(self: *Pool, conn: *Connection) void {
conn.deinit();
self.allocator.destroy(conn);
}
};
};
// How long a connection may sit without completing a request before we close it.
pub const IDLE_TIMEOUT_MS = 10_000;
// Default buffer size of a new connection. For CDP connections, this should be
// enough for the few HTTP requests that it makes. WebDriver can send larger
// bodies and the buffer will grow up to --cdp-max-http-message-size as needed
pub const INITIAL_BUFFER_SIZE = 4096;
const REQUEST_ARENA_RETAIN = 8192;
pub fn processEvent(server: *Server, conn: *Connection, rw: Server.IOEvent.ReadWrite, now: u64) void {
if (conn.pending != null) {
// registered for OUT only; a hangup shows up as a write error
if (rw.writable or rw.hangup) {
flush(server, conn, now);
}
return;
}
if (rw.readable) {
const keepalive = processHTTP(server, conn, now) catch |err| blk: {
writeError(conn, err);
break :blk false;
};
if (keepalive == false) {
disconnect(server, conn);
}
// else: the socket is level-triggered and stays registered; the
// deadline was refreshed by processHTTP when the response went out
} else if (rw.hangup) {
disconnect(server, conn);
}
}
// Continues a write that previously hit WouldBlock.
fn flush(server: *Server, conn: *Connection, now: u64) void {
const pending = &conn.pending.?;
const remaining = pending.remaining();
const n = write(conn.socket, remaining) catch |err| {
log.debug(.serve, "flush", .{ .err = err });
return disconnect(server, conn);
};
if (n < remaining.len) {
// hit a WouldBlock
pending.pos += n;
return;
}
// write is complete
const keepalive = pending.keepalive;
pending.deinit(server.app.allocator);
conn.pending = null;
if (keepalive == false) {
return disconnect(server, conn);
}
server.io_engine.waitReadable(conn) catch |err| {
log.err(.serve, "wait readable", .{ .err = err });
return disconnect(server, conn);
};
touch(server, conn, now);
}
fn processHTTP(server: *Server, conn: *Connection, now: u64) !bool {
const http = &conn.state;
const arena = server.request_arena.allocator();
while (true) {
switch (http.*) {
.header => {
const data = try conn.buffer.read(conn.socket);
switch (try http.parseHeader(arena, data)) {
.need => |needed| {
try conn.buffer.ensureCapacity(needed);
return true;
},
.complete => {},
}
if (comptime lp.IS_DEBUG) {
// we do have a complete header, the state must have transitioned
// to .request
std.debug.assert(http.* == .request);
}
},
.request => |*req| {
defer _ = server.request_arena.reset(.{ .retain_with_limit = REQUEST_ARENA_RETAIN });
const served = try serveHTTP(server, conn, req);
if (served == .upgraded) {
// The fd moved to a WebSocket (and out of server.http); all
// that's left of this Connection is to recycle it.
// upgradeConnection already took it out of http_connections.
recycle(server, conn);
return true;
}
// req lives in http.*; read what we need before resetting it
const keepalive = req.keepalive;
http.* = .header;
// safe to free the buffer, a parked command wil have copied
// what it needed from it.
conn.buffer.reset();
if (served == .parked) {
// off the loop until its worker answers (resumeParked)
return true;
}
if (conn.pending != null) {
// We got a WouldBlock and now have a pending write. The
// connection stays alive until we flush it. After the write
// if flushed, we'll apply the keepalive result.
return true;
}
if (keepalive == false) {
return false;
}
touch(server, conn, now);
return true;
},
}
}
}
// Error responses use a minimal, uniform shape: no reason phrase, an explicit
// Connection: Close, and no Content-Type. errorResponse builds it at comptime.
const invalid_request_response = errorResponse(400, "Invalid request");
const invalid_protocol_response = errorResponse(400, "Invalid HTTP protocol");
const missing_header_response = errorResponse(400, "Missing required header");
const forbidden_origin_response = errorResponse(403, "Origin not allowed");
const forbidden_host_response = errorResponse(403, "Host not allowed");
const request_too_large_response = errorResponse(413, "Request too large");
const not_found_response = errorResponse(404, "Not found");
const session_connected_response = errorResponse(409, "Session already connected");
const session_busy_response = errorResponse(429, "Session is releasing its previous connection");
const method_not_allowed_response = errorResponse(405, "Method not allowed");
const service_unavailable_response = errorResponse(503, "Too many connections");
const internal_error_response = errorResponse(500, "Internal server error");
const empty_json_list_response = staticResponse(.{ .status = "200 OK", .body = "[]", .content_type = "application/json; charset=UTF-8" });
// WebDriver's discovery endpoint; `ready` is whether a new session can be
// created, which the bootstrap never refuses.
const status_response = staticResponse(.{ .status = "200 OK", .body = "{\"value\":{\"ready\":true,\"message\":\"\"}}", .content_type = "application/json; charset=UTF-8" });
const delete_session_response = staticResponse(.{ .status = "200 OK", .body = "{\"value\":null}", .content_type = "application/json; charset=UTF-8" });
const protocol_response = staticResponse(.{ .status = "200 OK", .body = @embedFile("../data/protocol.json"), .content_type = "application/json; charset=UTF-8" });
// A parked command whose worker went away before answering.
pub const session_ended_response = staticResponse(.{
.status = "404 Not Found",
.body = "{\"value\":{\"error\":\"invalid session id\",\"message\":\"session ended\",\"stacktrace\":\"\"}}",
.content_type = "application/json; charset=UTF-8",
.close = true,
});
const Served = enum {
responded,
upgraded,
parked,
};
const Route = struct {
method: Connection.Method,
// exact match against the normalized path
path: []const u8,
gate: Gate = .none,
handler: *const fn (*Server, *Connection, *Connection.Request) anyerror!Served,
// A closed gate makes the route invisible (404), not forbidden.
const Gate = enum {
none,
cdp,
webdriver,
metrics,
};
};
const routes = [_]Route{
.{ .method = .GET, .path = "/", .gate = .cdp, .handler = upgradeCDP },
.{ .method = .GET, .path = "/metrics", .gate = .metrics, .handler = serveMetrics },
.{ .method = .GET, .path = "/json/version", .gate = .cdp, .handler = serveJSONVersion },
.{ .method = .GET, .path = "/json/list", .gate = .cdp, .handler = serveJSONList },
.{ .method = .GET, .path = "/json", .gate = .cdp, .handler = serveJSONList },
.{ .method = .GET, .path = "/json/protocol", .gate = .cdp, .handler = serveJSONProtocol },
// /session is the path Firefox advertises its BiDi endpoint on
.{ .method = .GET, .path = "/session", .gate = .webdriver, .handler = upgradeBiDi },
.{ .method = .POST, .path = "/session", .gate = .webdriver, .handler = newSession },
.{ .method = .GET, .path = "/status", .gate = .webdriver, .handler = serveStatus },
};
const session_routes = [_]Route{
.{ .method = .GET, .path = "", .handler = upgradeSession },
.{ .method = .DELETE, .path = "", .handler = deleteSession },
};
// /session/{id} itself is session_routes; everything under it is a command
// (http_command.parse).
const SESSION_PREFIX = "/session/";
const SESSION_ID_LEN = 36;
fn serveHTTP(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
var path = req.path;
if (path.len > 1 and path[path.len - 1] == '/') {
path = path[0 .. path.len - 1];
}
if (std.mem.startsWith(u8, path, SESSION_PREFIX) and path.len >= SESSION_PREFIX.len + SESSION_ID_LEN) {
if (!server.protocols.webdriver) {
return serveNotFound(server, conn, req);
}
const tail = path[SESSION_PREFIX.len + SESSION_ID_LEN ..];
if (tail.len != 0 and tail[0] != '/') {
return serveNotFound(server, conn, req);
}
req.session_id = path[SESSION_PREFIX.len..][0..SESSION_ID_LEN];
if (tail.len == 0) {
return dispatch(server, &session_routes, conn, req, tail);
}
return serveSessionCommand(server, conn, req, tail);
}
return dispatch(server, &routes, conn, req, path);
}
fn dispatch(server: *Server, comptime table: []const Route, conn: *Connection, req: *Connection.Request, path: []const u8) !Served {
var path_matched = false;
inline for (table) |route| {
if (std.mem.eql(u8, route.path, path) and gateOpen(server, route.gate)) {
if (route.method == req.method) {
return route.handler(server, conn, req);
}
path_matched = true;
}
}
if (path_matched) {
return serveMethodNotAllowed(server, conn, req);
}
return serveNotFound(server, conn, req);
}
// Best effort, connection is being closed. A partial write ends up as a partial
// write: no pending, no retry.
fn writeError(conn: *Connection, err: anyerror) void {
const response: []const u8 = switch (err) {
error.ConnectionClosed, error.ConnectionResetByPeer, error.BrokenPipe => return,
error.InvalidHeader, error.InvalidHTTPMethod, error.BodyNotSupported => invalid_request_response,
error.RequestTooLarge => request_too_large_response,
else => blk: {
log.warn(.serve, "serve error", .{ .err = err });
break :blk internal_error_response;
},
};
recordResponse(response);
_ = write(conn.socket, response) catch {};
}
// Every response starts with the status line our two builders emit, so the
// category is read straight off the bytes rather than threaded through.
fn recordResponse(response: []const u8) void {
const prefix = "HTTP/1.1 ";
lp.assert(std.mem.startsWith(u8, response, prefix), "Server.recordResponse status line", .{});
const status = std.fmt.parseInt(u16, response[prefix.len..][0..3], 10) catch 0;
lp.metrics.serve_http_requests.incr(statusCategory(status));
}
const Response = union(enum) {
// lives as long as the server; a queued remainder references it
static: []const u8,
// lives in server.scratch until the next response; a queued remainder is copied
dynamic: []const u8,
};
// Can do a partial write
fn write(socket: posix.socket_t, data: []const u8) !usize {
var pos: usize = 0;
while (pos < data.len) {
const n = sys_net.write(socket, data[pos..]) catch |err| switch (err) {
error.WouldBlock => break,
error.Interrupted => continue,
else => return err,
};
pos += n;
}
return pos;
}
// Dynamic responses are built in server.scratch with room for the header
// reserved up front; once the body length is known the header is written
// right-aligned against it (the same trick as WS.fillHeader).
const HEADER_RESERVE = 192;
fn beginBody(server: *Server) !*std.Io.Writer {
server.scratch.clearRetainingCapacity();
try server.scratch.writer.splatByteAll(0, HEADER_RESERVE);
return &server.scratch.writer;
}
fn serveDynamicHTTPResponse(server: *Server, conn: *Connection, req: *const Connection.Request, status: std.http.Status, comptime content_type: []const u8) !Served {
return serveHTTPResponse(server, conn, req, .{ .dynamic = fillHeader(server.scratch.written(), status, content_type) });
}
const JSON_CONTENT_TYPE = "application/json; charset=UTF-8";
// `buf` is HEADER_RESERVE bytes followed by the body; returns the response,
// its header right-aligned against the body.
fn fillHeader(buf: []u8, status: std.http.Status, comptime content_type: []const u8) []u8 {
const header_format = "HTTP/1.1 {d} {s}\r\n" ++
"Content-Length: {d}\r\n" ++
"Content-Type: " ++ content_type ++ "\r\n\r\n";
// 3 status digits, the longest std.http.Status phrase ("Network
// Authentication Required"), a usize's 20 digits
comptime std.debug.assert(header_format.len + 3 + 31 + 20 <= HEADER_RESERVE);
var header_buf: [HEADER_RESERVE]u8 = undefined;
const header = std.fmt.bufPrint(&header_buf, header_format, .{
@intFromEnum(status),
status.phrase() orelse "",
buf.len - HEADER_RESERVE,
}) catch unreachable;
const start = HEADER_RESERVE - header.len;
@memcpy(buf[start..HEADER_RESERVE], header);
return buf[start..];
}
fn errorResponse(comptime status: u16, comptime body: []const u8) []const u8 {
return std.fmt.comptimePrint(
"HTTP/1.1 {d} \r\nConnection: Close\r\nContent-Length: {d}\r\n\r\n{s}",
.{ status, body.len, body },
);
}
fn staticResponse(comptime opts: struct {
status: []const u8,
body: []const u8,
content_type: []const u8 = "text/plain",
close: bool = false,
}) []const u8 {
return std.fmt.comptimePrint("HTTP/1.1 " ++ opts.status ++ "\r\n" ++
"Content-Length: {d}\r\n" ++
(if (opts.close) "Connection: Close\r\n" else "") ++
"Content-Type: " ++ opts.content_type ++ "\r\n\r\n", .{opts.body.len}) ++ opts.body;
}
fn gateOpen(server: *const Server, gate: Route.Gate) bool {
return switch (gate) {
.none => true,
.cdp => server.protocols.cdp,
.webdriver => server.protocols.webdriver,
.metrics => server.app.config.metricsEndpointEnabled(),
};
}
// GET / (cdp)
fn upgradeCDP(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
return upgradeSpawn(server, conn, req, .cdp);
}
// GET /json/version (cdp)
fn serveJSONVersion(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
return serveHTTPResponse(server, conn, req, .{ .static = server.json_version_response });
}
// GET /json/list or GET /json (cdp)
fn serveJSONList(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
return serveHTTPResponse(server, conn, req, .{ .static = empty_json_list_response });
}
// GET /json/protocol (cdp)
fn serveJSONProtocol(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
return serveHTTPResponse(server, conn, req, .{ .static = protocol_response });
}
// GET /metrics (internal)
fn serveMetrics(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
const writer = try beginBody(server);
lp.metrics.write(writer);
return serveDynamicHTTPResponse(server, conn, req, .ok, "text/plain; version=0.0.4; charset=utf-8");
}
// GET /status (webdriver)
fn serveStatus(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
return serveHTTPResponse(server, conn, req, .{ .static = status_response });
}
// GET /session (webdriver (direct bidi))
fn upgradeBiDi(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
return upgradeSpawn(server, conn, req, .bidi);
}
// POST /session (webdriver)
fn newSession(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
const Capability = struct { webSocketUrl: ?bool = null };
const parsed = std.json.parseFromSliceLeaky(struct {
capabilities: ?struct {
alwaysMatch: ?Capability = null,
firstMatch: ?[]const Capability = null,
} = null,
}, req.arena, req.body, .{ .ignore_unknown_fields = true }) catch {
return serveWebDriverError(server, conn, req, "invalid argument", "invalid JSON body");
};
if (server.worker_pool.isFull()) {
lp.metrics.serve_connection_limit.incr();
return serveWebDriverError(server, conn, req, "session not created", "too many sessions");
}
var session_id: [36]u8 = undefined;
uuidv4(&session_id);
const worker = server.spawnWorker(.bidi, .{ .session = session_id }) catch |err| {
log.err(.serve, "worker spawn", .{ .err = err });
return serveWebDriverError(server, conn, req, "session not created", "failed to start the session");
};
// The client never learns the id if we fail to answer (e.g. it hung up),
// so nothing would ever DELETE this session.
errdefer server.quitSession(worker);
const is_requesting_websocket_url = blk: {
const caps = parsed.capabilities orelse break :blk false;
if (caps.alwaysMatch) |always| {
if (always.webSocketUrl == true) {
break :blk true;
}
}
for (caps.firstMatch orelse &.{}) |first| {
if (first.webSocketUrl == true) {
break :blk true;
}
}
break :blk false;
};
const url: ?[]const u8 = blk: {
if (is_requesting_websocket_url) {
break :blk try std.fmt.allocPrint(req.arena, "{s}{s}", .{ server.bidi_session_url, &session_id });
}
break :blk null;
};
return serveWebDriver(server, conn, req, .ok, .{
.sessionId = &session_id,
.capabilities = bidi_session.Capabilities{
.userAgent = server.app.config.http_headers.user_agent,
.webSocketUrl = url,
},
});
}
// GET /session/ID (webdriver (upgrade to bidi))
fn upgradeSession(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
const worker = server.findSession(req.session_id.?) orelse {
return serveNotFound(server, conn, req);
};
if (worker.linkDropping()) {
// The previous connection is gone but the worker hasn't given the
// link back yet. Dirver can retry.
return serveHTTPResponse(server, conn, req, .{ .static = session_busy_response });
}
if (worker.link != null) {
// already joined
return serveHTTPResponse(server, conn, req, .{ .static = session_connected_response });
}
return upgrade(server, conn, req, .{ .attach = worker });
}
// DELETE /session/ID (webdriver)
fn deleteSession(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
const worker = server.findSession(req.session_id.?) orelse {
return serveNoSuchSession(server, conn, req);
};
server.quitSession(worker);
return serveHTTPResponse(server, conn, req, .{ .static = delete_session_response });
}
// /session/ID/... (webdriver): parsed here, answered by the session's worker.
fn serveSessionCommand(server: *Server, conn: *Connection, req: *Connection.Request, path: []const u8) !Served {
const worker = server.findSession(req.session_id.?) orelse {
return serveNoSuchSession(server, conn, req);
};
if (worker.http_request != null) {
return serveCommandInProgress(server, conn, req);
}
const arena = try server.app.arena_pool.acquire(req.body.len, "http command");
const command = http_command.parse(arena.allocator(), req.method, path, req.body) catch |err| {
arena.release();
switch (err) {
error.OutOfMemory => return err,
error.UnknownCommand => return serveWebDriverError(server, conn, req, "unknown command", "unknown command"),
error.InvalidArgument => return serveWebDriverError(server, conn, req, "invalid argument", "invalid body"),
}
};
server.parkRequest(worker, conn, req.keepalive, arena, command);
return .parked;
}
fn serveCommandInProgress(server: *Server, conn: *Connection, req: *const Connection.Request) !Served {
return serveWebDriverError(server, conn, req, "unknown error", "a command is already in progress");
}
fn serveNoSuchSession(server: *Server, conn: *Connection, req: *const Connection.Request) !Served {
return serveWebDriverError(server, conn, req, "invalid session id", "no such session");
}
// CDP or Bidi directly creating a Worker from an websocket upgrade
fn upgradeSpawn(server: *Server, conn: *Connection, req: *Connection.Request, protocol: Driver.Protocol) !Served {
if (server.worker_pool.isFull()) {
lp.metrics.serve_connection_limit.incr();
return serveHTTPResponse(server, conn, req, .{ .static = service_unavailable_response });
}
return upgrade(server, conn, req, .{ .spawn = protocol });
}
// Answers a HTTP WebDriver request with {"value": value}.
fn serveWebDriver(server: *Server, conn: *Connection, req: *const Connection.Request, status: std.http.Status, value: anytype) !Served {
const writer = try beginBody(server);
try std.json.Stringify.value(.{ .value = value }, .{}, writer);
return serveDynamicHTTPResponse(server, conn, req, status, JSON_CONTENT_TYPE);
}
fn serveWebDriverError(server: *Server, conn: *Connection, req: *const Connection.Request, code: []const u8, message: []const u8) !Served {
return serveWebDriver(server, conn, req, webDriverErrorStatus(code), .{
.@"error" = code,
.message = message,
.stacktrace = "",
});
}
fn serveNotFound(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
return serveHTTPResponse(server, conn, req, .{ .static = not_found_response });
}
fn serveMethodNotAllowed(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
return serveHTTPResponse(server, conn, req, .{ .static = method_not_allowed_response });
}
// Writes what the socket will take now. Anything left is queued on the
// connection, which switches to waiting for writability.
fn serveHTTPResponse(server: *Server, conn: *Connection, req: *const Connection.Request, response: Response) !Served {
const data = switch (response) {
inline else => |d| d,
};
recordResponse(data);
const n = try write(conn.socket, data);
if (n == data.len) {
return .responded;
}
lp.assert(conn.pending == null, "Server.send pending", .{});
conn.pending = .{
.pos = 0,
.keepalive = req.keepalive,
.data = switch (response) {
.static => .{ .static = data[n..] },
.dynamic => .{ .owned = try server.app.allocator.dupe(u8, data[n..]) },
},
};
// on failure the caller disconnects, which frees pending
try server.io_engine.waitWritable(conn);
return .responded;
}
// A parked connection's response is ready, join the loop so that we can start
// writing the response.
pub fn resumeParked(server: *Server, conn: *Connection, req_keepalive: bool, response: Connection.Writing.Data, now: u64) void {
const allocator = server.app.allocator;
// beginShutdown closed the http connections, this one was off the loop then
const keepalive = req_keepalive and server.shutdown_begun == false;
var writing: Connection.Writing = .{ .pos = 0, .data = response, .keepalive = keepalive };
server.io_engine.monitorHTTP(conn) catch |err| {
log.err(.serve, "resume monitor", .{ .err = err });
writing.deinit(allocator);
sys_net.close(conn.socket);
return recycle(server, conn);
};
conn.deadline = now + IDLE_TIMEOUT_MS;
server.http_connections.append(&conn.node);
const data = writing.remaining();
recordResponse(data);
writing.pos = write(conn.socket, data) catch |err| {
log.debug(.serve, "resume write", .{ .err = err });
writing.deinit(allocator);
return disconnect(server, conn);
};
if (writing.pos < data.len) {
// disconnect frees it from here
conn.pending = writing;
server.io_engine.waitWritable(conn) catch |err| {
log.err(.serve, "wait writable", .{ .err = err });
return disconnect(server, conn);
};
return;
}
writing.deinit(allocator);
if (keepalive == false) {
disconnect(server, conn);
}
}
// A complete {"value": value} response, built by a worker for resumeParked,
// in an arena the loop releases once it's written.
pub fn webDriverResponse(arena: *lp.Arena, status: std.http.Status, value: anytype) ![]const u8 {
var aw: std.Io.Writer.Allocating = try .initCapacity(arena.allocator(), 512);
try aw.writer.splatByteAll(0, HEADER_RESERVE);
try std.json.Stringify.value(.{ .value = value }, .{}, &aw.writer);
return fillHeader(aw.written(), status, JSON_CONTENT_TYPE);
}
// W3C WebDriver's error table; every code not listed is a 500.
pub fn webDriverErrorStatus(code: []const u8) std.http.Status {
const statuses = std.StaticStringMap(std.http.Status).initComptime(.{
.{ "detached shadow root", .not_found },
.{ "element click intercepted", .bad_request },
.{ "element not interactable", .bad_request },
.{ "insecure certificate", .bad_request },
.{ "invalid argument", .bad_request },
.{ "invalid cookie domain", .bad_request },
.{ "invalid element state", .bad_request },
.{ "invalid selector", .bad_request },
.{ "invalid session id", .not_found },
.{ "no such alert", .not_found },
.{ "no such cookie", .not_found },
.{ "no such element", .not_found },
.{ "no such frame", .not_found },
.{ "no such shadow root", .not_found },
.{ "no such window", .not_found },
.{ "stale element reference", .not_found },
.{ "unknown command", .not_found },
.{ "unknown method", .method_not_allowed },
});
return statuses.get(code) orelse .internal_server_error;
}
// HTTP-phase teardown. Websockets tear down via releaseWorker.
pub fn disconnect(server: *Server, conn: *Connection) void {
server.io_engine.remove(conn.socket);
sys_net.close(conn.socket);
if (conn.pending) |*pending| {
pending.deinit(server.app.allocator);
conn.pending = null;
}
server.http_connections.remove(&conn.node);
recycle(server, conn);
}
// Return a connection to the pool; a slot in the fd budget is free.
fn recycle(server: *Server, conn: *Connection) void {
server.http_connection_pool.release(conn);
server.slotFreed();
}
fn touch(server: *Server, conn: *Connection, now: u64) void {
conn.deadline = now + IDLE_TIMEOUT_MS;
const node = &conn.node;
if (server.http_connections.last == node) {
return;
}
server.http_connections.remove(&conn.node);
server.http_connections.append(&conn.node);
}
pub fn buildJSONVersionResponse(app: *const App, port: u16) ![]const u8 {
const host = app.config.advertiseHost();
if (app.config.bindIsWildcard()) {
// Serve is bound to INADDR_ANY but no --advertise-host was given;
// advertiseHost() falls back to 127.0.0.1 so clients can still
// connect locally. Surface the trade-off so users running
// outside the same host know they have to opt in.
log.note(.cdp, "advertising loopback for wildcard bind", .{
.message = "--host is a wildcard (0.0.0.0 / ::) without --advertise-host; clients on other hosts will need --advertise-host to reach the CDP endpoint",
});
}
const body_format =
"{{" ++
"\"Browser\": \"Lightpanda/1.0\", " ++
"\"Protocol-Version\": \"1.3\", " ++
"\"User-Agent\": \"Lightpanda/1.0\", " ++
"\"Lightpanda-Version\": \"" ++ lp.build_config.version ++ "\", " ++
"\"webSocketDebuggerUrl\": \"ws://{s}:{d}/\"" ++
"}}";
const body_len = std.fmt.count(body_format, .{ host, port });
const response_format =
"HTTP/1.1 200 OK\r\n" ++
"Content-Length: {d}\r\n" ++
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
body_format;
return try std.fmt.allocPrint(app.allocator, response_format, .{ body_len, host, port });
}
// Where the upgraded socket goes: a new worker, or an existing session's.
const Upgrade = union(enum) {
spawn: Driver.Protocol,
attach: *Server.Worker,
};
// Shared upgrade path: validate the WebSocket headers, write the 101, and
// hand the fd to its worker (spawning one for a new connection).
fn upgrade(server: *Server, conn: *Connection, req: *Connection.Request, target: Upgrade) !Served {
var accept_buf: [28]u8 = undefined;
const accept_key = webSocketAccept(req.head, &accept_buf) catch |err| {
const response: []const u8 = switch (err) {
error.ForbiddenOrigin => forbidden_origin_response,
error.ForbiddenHost => forbidden_host_response,
error.InvalidProtocol => invalid_protocol_response,
error.MissingHeader => missing_header_response,
else => invalid_request_response,
};
return serveHTTPResponse(server, conn, req, .{ .static = response });
};
// The 101 is ~129 bytes into an empty send buffer, so a single write
// always completes; a partial write here means the peer is already gone.
var response_buf: [160]u8 = undefined;
const response = std.fmt.bufPrint(&response_buf, "HTTP/1.1 101 Switching Protocols\r\n" ++
"Upgrade: websocket\r\n" ++
"Connection: upgrade\r\n" ++
"Sec-Websocket-Accept: {s}\r\n\r\n", .{accept_key}) catch unreachable;
const n = write(conn.socket, response) catch return error.ConnectionClosed;
if (n != response.len) {
return error.ConnectionClosed;
}
switch (target) {
.spawn => |protocol| server.upgradeConnection(conn, protocol),
.attach => |worker| server.attachConnection(worker, conn),
}
return .upgraded;
}
// Validate an incoming WebSocket upgrade request head and, on success, write
// the Sec-WebSocket-Accept value into `out`. Mirrors the origin/host defenses
// from the old Handshake path.
fn webSocketAccept(head: []const u8, out: *[28]u8) ![]const u8 {
const FOUND_UPGRADE: u8 = 1 << 0;
const FOUND_VERSION: u8 = 1 << 1;
const FOUND_CONNECTION: u8 = 1 << 2;
const FOUND_KEY: u8 = 1 << 3;
const FOUND_ALL = FOUND_UPGRADE | FOUND_VERSION | FOUND_CONNECTION | FOUND_KEY;
const method, _, const version, var it = header_parser.parseRequest(head) catch return error.InvalidRequest;
if (method != .get or version != .@"1.1") {
return error.InvalidProtocol;
}
var found: u8 = 0;
var key: []const u8 = "";
while (it.next() catch return error.InvalidRequest) |h| {
if (std.ascii.eqlIgnoreCase(h.key, "upgrade")) {
if (!std.ascii.eqlIgnoreCase("websocket", h.value)) return error.MissingHeader;
found |= FOUND_UPGRADE;
} else if (std.ascii.eqlIgnoreCase(h.key, "sec-websocket-version")) {
if (h.value.len != 2 or h.value[0] != '1' or h.value[1] != '3') return error.MissingHeader;
found |= FOUND_VERSION;
} else if (std.ascii.eqlIgnoreCase(h.key, "connection")) {
if (std.ascii.indexOfIgnoreCase(h.value, "upgrade") == null) return error.MissingHeader;
found |= FOUND_CONNECTION;
} else if (std.ascii.eqlIgnoreCase(h.key, "sec-websocket-key")) {
key = h.value;
found |= FOUND_KEY;
} else if (std.ascii.eqlIgnoreCase(h.key, "origin")) {
// Only a browser sends Origin, and a browser has no business
// driving CDP/BiDi: it's cross-origin to us by definition.
log.warn(.serve, "rejected websocket origin", .{ .origin = h.value[0..@min(h.value.len, 64)] });
return error.ForbiddenOrigin;
} else if (std.ascii.eqlIgnoreCase(h.key, "host")) {
// Defense in depth against DNS rebinding: only an IP literal can
// legitimately reach us (no name resolution involved). The one
// name we accept is `localhost:<port>`, which browsers hardwire
// to loopback without a lookup.
if (!std.mem.startsWith(u8, h.value, "localhost:")) {
_ = std.Io.net.IpAddress.parseLiteral(h.value) catch {
log.warn(.serve, "rejected websocket host", .{ .host = h.value[0..@min(h.value.len, 64)] });
return error.ForbiddenHost;
};
}
}
}
if (found != FOUND_ALL) {
return error.MissingHeader;
}
var sha: [20]u8 = undefined;
var hasher = std.crypto.hash.Sha1.init(.{});
hasher.update(key);
hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
hasher.final(&sha);
_ = std.base64.standard.Encoder.encode(out, &sha);
return out;
}
const testing = @import("../testing.zig");
test "http: the read buffer grows with the request and gives the space back" {
var pair: [2]posix.socket_t = undefined;
if (std.c.socketpair(posix.AF.LOCAL, posix.SOCK.STREAM, 0, &pair) != 0) {
return error.SocketPairFailed;
}
defer sys_net.close(pair[0]);
defer sys_net.close(pair[1]);
const max = INITIAL_BUFFER_SIZE * 2;
var buffer = try Connection.Buffer.init(testing.allocator, max);
defer buffer.deinit();
// a connection commits the initial size, never the limit
try testing.expectEqual(INITIAL_BUFFER_SIZE, buffer.buf.len);
// a header declares no length, so the buffer doubles to take it
const filler = "a" ** max;
try sys_net.writeAll(pair[1], filler);
while (buffer.len < filler.len) {
_ = try buffer.read(pair[0]);
}
try testing.expectEqual(max, buffer.buf.len);
// and stops doubling at the limit
try sys_net.writeAll(pair[1], "a");
try testing.expectError(error.RequestTooLarge, buffer.read(pair[0]));
// the next request on this connection starts small again
buffer.reset();
try testing.expectEqual(0, buffer.len);
try testing.expectEqual(INITIAL_BUFFER_SIZE, buffer.buf.len);
}
test "http: a declared body is sized upfront" {
var pair: [2]posix.socket_t = undefined;
if (std.c.socketpair(posix.AF.LOCAL, posix.SOCK.STREAM, 0, &pair) != 0) {
return error.SocketPairFailed;
}
defer sys_net.close(pair[0]);
defer sys_net.close(pair[1]);
var buffer = try Connection.Buffer.init(testing.allocator, 1024 * 1024);
defer buffer.deinit();
var state: Connection.State = .header;
const body_len = INITIAL_BUFFER_SIZE * 4;
var head_buf: [64]u8 = undefined;
const head = try std.fmt.bufPrint(&head_buf, "POST /session HTTP/1.1\r\nContent-Length: {d}\r\n\r\n", .{body_len});
try sys_net.writeAll(pair[1], head);
// the header alone is enough to know how much room the body needs
const needed = switch (try state.parseHeader(testing.allocator, try buffer.read(pair[0]))) {
.complete => return error.UnexpectedlyComplete,
.need => |n| n,
};
try testing.expectEqual(head.len + body_len, needed);
try buffer.ensureCapacity(needed);
try testing.expectEqual(needed, buffer.buf.len);
// a body that can't fit is rejected without reading any of it
try testing.expectError(error.RequestTooLarge, buffer.ensureCapacity(buffer.max + 1));
}