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.

235 lines · 10.6 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 let buf: Buffer = Buffer.alloc(0);
102 let last: Awaited<ReturnType<typeof send>> | null = null;
103 const flush = async (data: Buffer) => {
104 for (let attempt = 1; ; attempt++) {
105 try {
106 const r = await send("PATCH", location, { ...headers, "content-type": "application/octet-stream", "content-range": `${offset}-${offset + data.length - 1}` }, data);
107 if (r.status === 202) {
108 location = new URL(String(r.headers.location ?? location), "http://relay").pathname;
109 offset += data.length;
110 last = r;
111 return true;
112 }
113 if (r.status < 500 || attempt >= 3) {
114 last = r;
115 return false;
116 }
117 } catch (e) {
118 if (attempt >= 3) throw e;
119 }
120 // Retry from wherever the registry got to.
121 const s = await send("GET", location, { authorization: req.headers.authorization ?? "" });
122 if (s.status === 204 && uploadSize(s.headers) === offset + data.length) {
123 offset += data.length;
124 last = s;
125 return true;
126 }
127 await new Promise((r) => setTimeout(r, 1000 * attempt));
128 }
129 };
130 for await (const piece of req as AsyncIterable<Buffer>) {
131 buf = buf.length ? Buffer.concat([buf, piece]) : piece;
132 while (buf.length >= chunkSize) {
133 const chunk = buf.subarray(0, chunkSize);
134 buf = Buffer.from(buf.subarray(chunkSize));
135 if (!(await flush(chunk))) return reply(res, last!);
136 }
137 }
138 if (req.method === "PUT") {
139 const r = await send("PUT", `${location}${url.search}`, { ...headers, ...(buf.length ? { "content-type": "application/octet-stream", "content-range": `${offset}-${offset + buf.length - 1}` } : {}) }, buf);
140 return reply(res, r);
141 }
142 if (buf.length && !(await flush(buf))) return reply(res, last!);
143 if (!last) last = await send("GET", location, { authorization: req.headers.authorization ?? "" });
144 // The client sent one PATCH; answer it like the registry would, covering everything so far.
145 return reply(res, { ...last, status: 202, headers: { ...last.headers, location, range: `0-${Math.max(0, offset - 1)}` } }, 202);
146 }
147
148 function reply(res: http.ServerResponse, r: { status: number; headers: http.IncomingHttpHeaders; body: Buffer }, status = r.status): void {
149 const out: http.OutgoingHttpHeaders = {};
150 for (const [k, v] of Object.entries(r.headers)) if (!HOP_BY_HOP.has(k)) out[k] = v;
151 out["content-length"] = String(r.body.length);
152 res.writeHead(status, out);
153 res.end(r.body);
154 }
155
156 // Intercepted HTTPS traffic for the Diffinery host.
157 const inner = http.createServer((req, res) => {
158 const big = (req.method === "PATCH" || req.method === "PUT") && UPLOAD_PATH.test(req.url ?? "");
159 if (!big) return passThrough(req, res);
160 chunkedUpload(req, res).catch((e: Error) => {
161 log(`relay: upload ${req.url} failed: ${e.message}`);
162 if (!res.headersSent) res.writeHead(502, { "content-type": "application/json" });
163 res.end(JSON.stringify({ errors: [{ code: "BLOB_UPLOAD_INVALID", message: `registry relay: ${e.message}` }] }));
164 });
165 });
166 inner.requestTimeout = 0;
167 inner.headersTimeout = 120_000;
168 const secure = { cert: o.cert, key: o.key };
169
170 // The proxy itself: CONNECT tunnels, plus plain-HTTP proxying for completeness.
171 const proxy = http.createServer((req, res) => {
172 let target: URL;
173 try {
174 target = new URL(req.url ?? "");
175 } catch {
176 res.writeHead(400).end("absolute URL required");
177 return;
178 }
179 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) => {
180 res.writeHead(r.statusCode ?? 502, r.headers);
181 r.pipe(res);
182 });
183 up.on("error", () => res.writeHead(502).end());
184 req.pipe(up);
185 });
186 proxy.requestTimeout = 0;
187 proxy.on("connect", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => {
188 const [host, portStr] = (req.url ?? "").split(":");
189 const port = Number(portStr || 443);
190 socket.on("error", () => socket.destroy());
191 if (host?.toLowerCase() === o.host.toLowerCase() && port === 443) {
192 socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
193 if (head.length) socket.unshift(head);
194 const t = new tls.TLSSocket(socket, { isServer: true, ...secure, ALPNProtocols: ["http/1.1"] });
195 t.on("error", () => t.destroy());
196 inner.emit("connection", t);
197 return;
198 }
199 const out = net.connect(port, host!, () => {
200 socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
201 if (head.length) out.write(head);
202 out.pipe(socket);
203 socket.pipe(out);
204 });
205 out.on("error", () => {
206 if (socket.writable) socket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
207 socket.destroy();
208 });
209 });
210
211 await new Promise<void>((resolve, reject) => {
212 proxy.once("error", reject);
213 proxy.listen(o.port, "127.0.0.1", () => resolve());
214 });
215 const port = (proxy.address() as net.AddressInfo).port;
216 log(`registry relay for ${o.host} -> ${o.upstream} listening on 127.0.0.1:${port}`);
217 return {
218 port,
219 close: () =>
220 new Promise<void>((resolve) => {
221 agent.destroy();
222 inner.close();
223 proxy.closeAllConnections();
224 proxy.close(() => resolve());
225 }),
226 };
227}
228
229/** Bytes the registry holds for an upload. */
230function uploadSize(h: http.IncomingHttpHeaders): number {
231 const exact = Number(h["diffinery-upload-size"]);
232 if (Number.isInteger(exact) && exact >= 0) return exact;
233 const m = /^(?:bytes=)?(\d+)-(\d+)$/.exec(String(h.range ?? ""));
234 return m && Number(m[2]) > 0 ? Number(m[2]) + 1 : 0;
235}