summaryrefslogtreecommitdiff
path: root/app/main.zig
blob: 720503ff1ff62908592c52f8ec583027a1e41d34 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
//! 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: cloud9-http [--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("cloud9-http: 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("cloud9-http: 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("cloud9-http {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("web/app.mjs"), "text/javascript; charset=utf-8", .ok);
    if (std.mem.eql(u8, path, "/_cloud9/client.mjs")) return respond(&request, @embedFile("web/client.mjs"), "text/javascript; charset=utf-8", .ok);
    if (std.mem.eql(u8, path, "/_cloud9/style.css")) return respond(&request, @embedFile("web/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("web/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);
}