// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // 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 . 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:`, 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)); }