//! HTTP application; no web or namespace policy is added to the cloud9 library. const std = @import("std"); const c9 = @import("cloud9"); const Io = std.Io; const max_frame = 65536; const max_connections = 32; var serial_busy: std.atomic.Value(bool) = .init(false); const Upstream = union(enum) { network: c9.transport.Address, file: []const u8 }; var connections: std.atomic.Value(u32) = .init(0); const Config = struct { upstream: Upstream, host: []const u8, origin: []const u8, public_host: []const u8, timeout_ms: u32, browser_config: []const u8, }; pub fn main(init: std.process.Init) !void { const allocator = init.arena.allocator(); const args = try init.minimal.args.toSlice(allocator); var listen_text: []const u8 = "127.0.0.1:8080"; var upstream_text: []const u8 = "tcp:127.0.0.1:564"; var timeout_ms: u32 = 300000; var user: []const u8 = "user"; var tree: []const u8 = ""; var public_origin: ?[]const u8 = null; var i: usize = 1; while (i < args.len) : (i += 1) { if (std.mem.eql(u8, args[i], "--help")) { std.debug.print("Usage: 9web [--listen IP:PORT] [--upstream tcp:IP:PORT|unix:PATH|file:PATH] [--origin https://HOST:PORT] [--timeout-ms 300000] [--user NAME] [--tree NAME]\nDefault: http://127.0.0.1:8080 -> tcp:127.0.0.1:564\n", .{}); return; } if (i + 1 == args.len) return error.MissingArgument; if (std.mem.eql(u8, args[i], "--listen")) { i += 1; listen_text = args[i]; } else if (std.mem.eql(u8, args[i], "--upstream")) { i += 1; upstream_text = args[i]; } else if (std.mem.eql(u8, args[i], "--origin")) { i += 1; public_origin = args[i]; } else if (std.mem.eql(u8, args[i], "--timeout-ms")) { i += 1; timeout_ms = try std.fmt.parseInt(u32, args[i], 10); } else if (std.mem.eql(u8, args[i], "--user")) { i += 1; user = args[i]; } else if (std.mem.eql(u8, args[i], "--tree")) { i += 1; tree = args[i]; } else return error.UnknownArgument; } const address = try Io.net.IpAddress.parseLiteral(listen_text); const upstream: Upstream = if (std.mem.startsWith(u8, upstream_text, "tcp:")) .{ .network = .{ .tcp = try Io.net.IpAddress.parseLiteral(upstream_text[4..]) } } else if (std.mem.startsWith(u8, upstream_text, "unix:")) .{ .network = .{ .unix = try allocator.dupeZ(u8, upstream_text[5..]) } } else if (std.mem.startsWith(u8, upstream_text, "file:")) .{ .file = upstream_text[5..] } else return error.InvalidUpstream; const io = init.io; var listener = c9.transport.listen(io, .{ .tcp = address }, max_connections) catch |err| { if (err == error.AddressInUse) { std.debug.print("9web: cannot listen on {s}: address already in use.\nUse --listen 127.0.0.1:0 to select a free port; the URL is printed at startup.\n", .{listen_text}); } else { std.debug.print("9web: cannot listen on {s}: {s}\n", .{ listen_text, @errorName(err) }); } return err; }; defer listener.deinit(io); const host = try std.fmt.allocPrint(allocator, "{f}", .{listener.socket.address}); const origin_text = public_origin orelse try std.fmt.allocPrint(allocator, "http://{s}", .{host}); const origin_uri = try std.Uri.parse(origin_text); if ((!std.mem.eql(u8, origin_uri.scheme, "https") and !std.mem.eql(u8, origin_uri.scheme, "http")) or origin_uri.host == null or origin_uri.user != null or origin_uri.password != null or origin_uri.path.percent_encoded.len != 0 or origin_uri.query != null or origin_uri.fragment != null) return error.InvalidOrigin; if (user.len > 256 or tree.len > 256) return error.AttachNameTooLong; const browser_config = try std.json.Stringify.valueAlloc(allocator, .{ .user = user, .tree = tree }, .{}); const config: Config = .{ .upstream = upstream, .host = host, .origin = origin_text, .public_host = origin_text[origin_uri.scheme.len + 3 ..], .timeout_ms = timeout_ms, .browser_config = browser_config }; std.debug.print("9web {s} -> {s}\n", .{ config.origin, upstream_text }); var group: Io.Group = .init; defer group.cancel(io); while (true) { const stream = try listener.accept(io); if (connections.fetchAdd(1, .monotonic) >= max_connections) { _ = connections.fetchSub(1, .monotonic); stream.close(io); continue; } group.concurrent(io, handle, .{ io, stream, config }) catch |err| { _ = connections.fetchSub(1, .monotonic); stream.close(io); return err; }; } } fn handle(io: Io, stream: Io.net.Stream, config: Config) void { defer _ = connections.fetchSub(1, .monotonic); defer stream.close(io); var work: Connection = .{ .io = io, .stream = stream, .config = config }; var group: Io.Group = .init; defer group.cancel(io); group.concurrent(io, Connection.run, .{&work}) catch return; const timeout: Io.Timeout = if (config.timeout_ms == 0) .none else .{ .duration = .{ .raw = .fromMilliseconds(config.timeout_ms), .clock = .awake } }; work.done.waitTimeout(io, timeout) catch {}; } const Connection = struct { io: Io, stream: Io.net.Stream, config: Config, done: Io.Event = .unset, fn run(connection: *Connection) void { defer connection.done.set(connection.io); serve(connection.io, connection.stream, connection.config) catch {}; } }; fn respond(request: *std.http.Server.Request, content: []const u8, mime: []const u8, status: std.http.Status) !void { try request.respond(content, .{ .status = status, .keep_alive = false, .extra_headers = &.{ .{ .name = "content-type", .value = mime }, .{ .name = "cache-control", .value = "no-store" }, .{ .name = "x-content-type-options", .value = "nosniff" }, .{ .name = "content-security-policy", .value = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'" }, } }); } fn serve(io: Io, stream: Io.net.Stream, config: Config) !void { var input: [max_frame + 14]u8 = undefined; var output: [8192]u8 = undefined; var reader = stream.reader(io, &input); var writer = stream.writer(io, &output); var http: std.http.Server = .init(&reader.interface, &writer.interface); http.reader.max_head_len = 8192; var request = try http.receiveHead(); var host: ?[]const u8 = null; var origin: ?[]const u8 = null; var version: ?[]const u8 = null; var headers = request.iterateHeaders(); while (headers.next()) |header| { if (std.ascii.eqlIgnoreCase(header.name, "host")) host = header.value; if (std.ascii.eqlIgnoreCase(header.name, "origin")) origin = header.value; if (std.ascii.eqlIgnoreCase(header.name, "sec-websocket-version")) version = header.value; } if (!std.mem.eql(u8, host orelse "", config.host) and !std.mem.eql(u8, host orelse "", config.public_host)) return respond(&request, "Unexpected Host\n", "text/plain", .forbidden); if (request.head.method != .GET) return respond(&request, "Use GET\n", "text/plain", .method_not_allowed); const path = std.mem.sliceTo(request.head.target, '?'); if (std.mem.eql(u8, path, "/_cloud9/config.json")) return respond(&request, config.browser_config, "application/json", .ok); if (std.mem.eql(u8, path, "/_cloud9/app.mjs")) return respond(&request, @embedFile("static/app.mjs"), "text/javascript; charset=utf-8", .ok); if (std.mem.eql(u8, path, "/_cloud9/client.mjs")) return respond(&request, @embedFile("static/client.mjs"), "text/javascript; charset=utf-8", .ok); if (std.mem.eql(u8, path, "/_cloud9/style.css")) return respond(&request, @embedFile("static/style.css"), "text/css; charset=utf-8", .ok); if (std.mem.eql(u8, path, "/_cloud9/cloud9.wasm")) return respond(&request, @embedFile("client.wasm"), "application/wasm", .ok); if (!std.mem.eql(u8, path, "/_cloud9/9p")) { if (std.mem.eql(u8, path, "/_cloud9") or std.mem.startsWith(u8, path, "/_cloud9/")) return respond(&request, "Not found\n", "text/plain", .not_found); // File URLs boot the same browser client. The client resolves the path // in the upstream 9P namespace, never in this machine's filesystem. return respond(&request, @embedFile("static/index.html"), "text/html; charset=utf-8", .ok); } if (!std.mem.eql(u8, origin orelse "", config.origin)) return respond(&request, "Unexpected Origin\n", "text/plain", .forbidden); if (!std.mem.eql(u8, version orelse "", "13")) return respond(&request, "WebSocket version 13 required\n", "text/plain", .bad_request); switch (config.upstream) { .network => |address| { const upstream = c9.transport.connect(io, address) catch return respond(&request, "9P upstream unavailable\n", "text/plain", .bad_gateway); defer upstream.close(io); var in_buffer: [8192]u8 = undefined; var out_buffer: [8192]u8 = undefined; var upstream_reader = upstream.reader(io, &in_buffer); var upstream_writer = upstream.writer(io, &out_buffer); try bridge(io, &request, &upstream_reader.interface, &upstream_writer.interface); }, .file => |path_name| { if (serial_busy.swap(true, .acquire)) return respond(&request, "Device already in use\n", "text/plain", .service_unavailable); defer serial_busy.store(false, .release); const file = Io.Dir.cwd().openFile(io, path_name, .{ .mode = .read_write }) catch return respond(&request, "9P device unavailable\n", "text/plain", .bad_gateway); defer file.close(io); var in_buffer: [8192]u8 = undefined; var out_buffer: [8192]u8 = undefined; var upstream_reader = file.readerStreaming(io, &in_buffer); var upstream_writer = file.writerStreaming(io, &out_buffer); try bridge(io, &request, &upstream_reader.interface, &upstream_writer.interface); }, } } fn bridge(io: Io, request: *std.http.Server.Request, reader: *Io.Reader, writer: *Io.Writer) !void { var ws = try c9.http.accept(request); var requests: [max_frame]u8 = undefined; var replies: [max_frame]u8 = undefined; var relay: c9.http.Bridge = .{ .socket = &ws, .upstream_reader = reader, .upstream_writer = writer, .request_buffer = &requests, .reply_buffer = &replies, .frame_limit = max_frame, }; try relay.run(io, .none); }