Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

243 lines · 11.0 KBRaw History
1import http from "node:http";
2import https from "node:https";
3import net from "node:net";
4import tls from "node:tls";
5
6/**
7 * Registry relay for hosted runners: an HTTP(S) proxy for the Docker daemon.
8 *
9 * Cloudflare refuses request bodies over 100 MB, and Docker sends each image layer in a
10 * single request, so a big layer could never reach the Diffinery registry directly. The
11 * Docker daemon on a hosted runner uses this relay as its HTTPS proxy. Connections to the
12 * Diffinery host are terminated here (the daemon trusts this relay's certificate for that
13 * host only, through /etc/docker/certs.d), and blob uploads are re-sent as a series of
14 * chunked PATCH requests with Content-Range, which the registry API supports. Everything
15 * else is passed through unchanged, and other hosts are tunnelled untouched.
16 */
17export interface RelayOptions {
18 /** Port to listen on (127.0.0.1). */
19 port: number;
20 /** Host name the relay intercepts, e.g. diffinery.com. */
21 host: string;
22 /** Where intercepted requests really go, e.g. https://diffinery.com. */
23 upstream: string;
24 cert: string;
25 key: string;
26 /** Upload chunk size; must stay under Cloudflare's 100 MB limit. */
27 chunkSize?: number;
28 log?: (line: string) => void;
29}
30
31const HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "proxy-connection", "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length"]);
32const UPLOAD_PATH = /^\/v2\/.+\/blobs\/uploads\/[^/?]+(\?.*)?$/;
33
34export interface Relay {
35 port: number;
36 close(): Promise<void>;
37}
38
39export async function startRelay(o: RelayOptions): Promise<Relay> {
40 const log = o.log ?? ((l: string) => process.stdout.write(`${l}\n`));
41 const upstream = new URL(o.upstream);
42 const client = upstream.protocol === "https:" ? https : http;
43 const agent = new client.Agent({ keepAlive: true, maxSockets: 32 });
44 const chunkSize = o.chunkSize ?? 64 * 1024 * 1024;
45
46 /** One upstream request with a fully buffered (or empty) body. */
47 function send(method: string, path: string, headers: http.OutgoingHttpHeaders, body?: Buffer): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: Buffer }> {
48 return new Promise((resolve, reject) => {
49 const req = client.request({ protocol: upstream.protocol, hostname: upstream.hostname, port: upstream.port || undefined, method, path, headers: { ...headers, host: upstream.host, "content-length": String(body?.length ?? 0) }, agent }, (res) => {
50 const parts: Buffer[] = [];
51 res.on("data", (d: Buffer) => parts.push(d));
52 res.on("end", () => resolve({ status: res.statusCode ?? 502, headers: res.headers, body: Buffer.concat(parts) }));
53 res.on("error", reject);
54 });
55 req.on("error", reject);
56 req.end(body);
57 });
58 }
59
60 function forwardHeaders(req: http.IncomingMessage): http.OutgoingHttpHeaders {
61 const h: http.OutgoingHttpHeaders = {};
62 for (const [k, v] of Object.entries(req.headers)) if (!HOP_BY_HOP.has(k) && v !== undefined) h[k] = v;
63 return h;
64 }
65
66 /** Streams a request and its response straight through. */
67 function passThrough(req: http.IncomingMessage, res: http.ServerResponse): void {
68 const headers = forwardHeaders(req);
69 if (req.headers["content-length"]) headers["content-length"] = req.headers["content-length"];
70 const up = client.request({ protocol: upstream.protocol, hostname: upstream.hostname, port: upstream.port || undefined, method: req.method, path: req.url, headers: { ...headers, host: upstream.host }, agent }, (r) => {
71 const out: http.OutgoingHttpHeaders = {};
72 for (const [k, v] of Object.entries(r.headers)) if (!HOP_BY_HOP.has(k) || k === "content-length") out[k] = v;
73 res.writeHead(r.statusCode ?? 502, out);
74 r.pipe(res);
75 });
76 up.on("error", (e) => {
77 log(`relay: ${req.method} ${req.url} failed: ${e.message}`);
78 if (!res.headersSent) res.writeHead(502, { "content-type": "application/json" });
79 res.end(JSON.stringify({ errors: [{ code: "UNKNOWN", message: `registry relay: ${e.message}` }] }));
80 });
81 req.pipe(up);
82 }
83
84 /**
85 * A blob upload (PATCH or PUT to an upload URL): sent on in chunks. Each full chunk
86 * goes as a PATCH with Content-Range; a PUT's remainder goes with the final PUT.
87 */
88 async function chunkedUpload(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
89 const url = new URL(req.url!, "http://relay");
90 const headers = forwardHeaders(req);
91 delete headers["content-range"];
92 delete headers["content-type"];
93 let location = url.pathname;
94 // Where the upload stands: earlier chunks may have come through another connection.
95 const status = await send("GET", location, { authorization: req.headers.authorization ?? "" });
96 if (status.status !== 204) return reply(res, status);
97 let offset = uploadSize(status.headers);
98 const declaredStart = /^(?:bytes[ =])?(\d+)-/.exec(String(req.headers["content-range"] ?? ""));
99 if (declaredStart && Number(declaredStart[1]) !== offset) return reply(res, status, 416);
100
101 // Pieces received since the last chunk went out. Joining them only when a chunk is full
102 // copies each byte once; growing one buffer per piece copied whole chunks thousands of times.
103 let pieces: Buffer[] = [];
104 let held = 0;
105 const take = (n: number): Buffer => {
106 const all = pieces.length === 1 ? pieces[0]! : Buffer.concat(pieces, held);
107 const rest = all.subarray(n);
108 pieces = rest.length ? [rest] : [];
109 held = rest.length;
110 return all.subarray(0, n);
111 };
112 let last: Awaited<ReturnType<typeof send>> | null = null;
113 const flush = async (data: Buffer) => {
114 for (let attempt = 1; ; attempt++) {
115 try {
116 const r = await send("PATCH", location, { ...headers, "content-type": "application/octet-stream", "content-range": `${offset}-${offset + data.length - 1}` }, data);
117 if (r.status === 202) {
118 location = new URL(String(r.headers.location ?? location), "http://relay").pathname;
119 offset += data.length;
120 last = r;
121 return true;
122 }
123 if (r.status < 500 || attempt >= 3) {
124 last = r;
125 return false;
126 }
127 } catch (e) {
128 if (attempt >= 3) throw e;
129 }
130 // Retry from wherever the registry got to.
131 const s = await send("GET", location, { authorization: req.headers.authorization ?? "" });
132 if (s.status === 204 && uploadSize(s.headers) === offset + data.length) {
133 offset += data.length;
134 last = s;
135 return true;
136 }
137 await new Promise((r) => setTimeout(r, 1000 * attempt));
138 }
139 };
140 for await (const piece of req as AsyncIterable<Buffer>) {
141 pieces.push(piece);
142 held += piece.length;
143 while (held >= chunkSize) if (!(await flush(take(chunkSize)))) return reply(res, last!);
144 }
145 const buf = take(held);
146 if (req.method === "PUT") {
147 const r = await send("PUT", `${location}${url.search}`, { ...headers, ...(buf.length ? { "content-type": "application/octet-stream", "content-range": `${offset}-${offset + buf.length - 1}` } : {}) }, buf);
148 return reply(res, r);
149 }
150 if (buf.length && !(await flush(buf))) return reply(res, last!);
151 if (!last) last = await send("GET", location, { authorization: req.headers.authorization ?? "" });
152 // The client sent one PATCH; answer it like the registry would, covering everything so far.
153 return reply(res, { ...last, status: 202, headers: { ...last.headers, location, range: `0-${Math.max(0, offset - 1)}` } }, 202);
154 }
155
156 function reply(res: http.ServerResponse, r: { status: number; headers: http.IncomingHttpHeaders; body: Buffer }, status = r.status): void {
157 const out: http.OutgoingHttpHeaders = {};
158 for (const [k, v] of Object.entries(r.headers)) if (!HOP_BY_HOP.has(k)) out[k] = v;
159 out["content-length"] = String(r.body.length);
160 res.writeHead(status, out);
161 res.end(r.body);
162 }
163
164 // Intercepted HTTPS traffic for the Diffinery host.
165 const inner = http.createServer((req, res) => {
166 const big = (req.method === "PATCH" || req.method === "PUT") && UPLOAD_PATH.test(req.url ?? "");
167 if (!big) return passThrough(req, res);
168 chunkedUpload(req, res).catch((e: Error) => {
169 log(`relay: upload ${req.url} failed: ${e.message}`);
170 if (!res.headersSent) res.writeHead(502, { "content-type": "application/json" });
171 res.end(JSON.stringify({ errors: [{ code: "BLOB_UPLOAD_INVALID", message: `registry relay: ${e.message}` }] }));
172 });
173 });
174 inner.requestTimeout = 0;
175 inner.headersTimeout = 120_000;
176 const secure = { cert: o.cert, key: o.key };
177
178 // The proxy itself: CONNECT tunnels, plus plain-HTTP proxying for completeness.
179 const proxy = http.createServer((req, res) => {
180 let target: URL;
181 try {
182 target = new URL(req.url ?? "");
183 } catch {
184 res.writeHead(400).end("absolute URL required");
185 return;
186 }
187 const up = http.request(target, { method: req.method, headers: { ...forwardHeaders(req), host: target.host, ...(req.headers["content-length"] ? { "content-length": req.headers["content-length"] } : {}) } }, (r) => {
188 res.writeHead(r.statusCode ?? 502, r.headers);
189 r.pipe(res);
190 });
191 up.on("error", () => res.writeHead(502).end());
192 req.pipe(up);
193 });
194 proxy.requestTimeout = 0;
195 proxy.on("connect", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => {
196 const [host, portStr] = (req.url ?? "").split(":");
197 const port = Number(portStr || 443);
198 socket.on("error", () => socket.destroy());
199 if (host?.toLowerCase() === o.host.toLowerCase() && port === 443) {
200 socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
201 if (head.length) socket.unshift(head);
202 const t = new tls.TLSSocket(socket, { isServer: true, ...secure, ALPNProtocols: ["http/1.1"] });
203 t.on("error", () => t.destroy());
204 inner.emit("connection", t);
205 return;
206 }
207 const out = net.connect(port, host!, () => {
208 socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
209 if (head.length) out.write(head);
210 out.pipe(socket);
211 socket.pipe(out);
212 });
213 out.on("error", () => {
214 if (socket.writable) socket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
215 socket.destroy();
216 });
217 });
218
219 await new Promise<void>((resolve, reject) => {
220 proxy.once("error", reject);
221 proxy.listen(o.port, "127.0.0.1", () => resolve());
222 });
223 const port = (proxy.address() as net.AddressInfo).port;
224 log(`registry relay for ${o.host} -> ${o.upstream} listening on 127.0.0.1:${port}`);
225 return {
226 port,
227 close: () =>
228 new Promise<void>((resolve) => {
229 agent.destroy();
230 inner.close();
231 proxy.closeAllConnections();
232 proxy.close(() => resolve());
233 }),
234 };
235}
236
237/** Bytes the registry holds for an upload. */
238function uploadSize(h: http.IncomingHttpHeaders): number {
239 const exact = Number(h["diffinery-upload-size"]);
240 if (Number.isInteger(exact) && exact >= 0) return exact;
241 const m = /^(?:bytes=)?(\d+)-(\d+)$/.exec(String(h.range ?? ""));
242 return m && Number(m[2]) > 0 ? Number(m[2]) + 1 : 0;
243}