@kitman-sh/cli 0.10.54 → 0.10.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  // Runs alone inside the egress proxy container, so it imports nothing outside Node.
2
2
  import { once } from 'node:events'
3
- import { connect, createServer, type Server, type Socket } from 'node:net'
3
+ import { connect, createServer, type LookupFunction, type Server, type Socket } from 'node:net'
4
4
 
5
5
  /** Whether the allowlist takes `host:port`: an entry `host` takes any port, `host:port` one, `*.domain` subdomains. */
6
6
  export const egressAllowed = (hosts: readonly string[], host: string, port: number) => {
@@ -39,7 +39,93 @@ const httpTarget = (absolute: string) => {
39
39
 
40
40
  const answer = (socket: Socket, status: string) => socket.end(`HTTP/1.1 ${status}\r\nContent-Length: 0\r\n\r\n`)
41
41
 
42
- const handle = (hosts: readonly string[]) => (client: Socket) => {
42
+ /** Takes the next bytes a client sends: how many of them belong to the request body, and whether the body has ended. */
43
+ type BodyFraming = (chunk: Buffer) => { readonly used: number; readonly done: boolean } | 'malformed'
44
+
45
+ const countedBody = (length: number): BodyFraming => {
46
+ let remaining = length
47
+
48
+ return chunk => {
49
+ const used = Math.min(remaining, chunk.length)
50
+ remaining -= used
51
+
52
+ return { used, done: remaining === 0 }
53
+ }
54
+ }
55
+
56
+ const chunkedBody = (): BodyFraming => {
57
+ let state: 'size' | 'data' | 'trailer' | 'done' = 'size'
58
+ let line = ''
59
+ // The chunk's data and the CRLF after it.
60
+ let remaining = 0
61
+
62
+ return chunk => {
63
+ let index = 0
64
+
65
+ while (index < chunk.length && state !== 'done') {
66
+ if (state === 'data') {
67
+ const used = Math.min(remaining, chunk.length - index)
68
+ index += used
69
+ remaining -= used
70
+
71
+ if (remaining === 0) state = 'size'
72
+
73
+ continue
74
+ }
75
+
76
+ const byte = chunk[index++]
77
+
78
+ if (byte !== 0x0a) {
79
+ line += String.fromCharCode(byte ?? 0)
80
+
81
+ if (line.length > 4_096) return 'malformed'
82
+
83
+ continue
84
+ }
85
+
86
+ const content = line.trim()
87
+ line = ''
88
+
89
+ if (state === 'trailer') {
90
+ if (content === '') state = 'done'
91
+ continue
92
+ }
93
+
94
+ const hex = content.split(';')[0]?.trim() ?? ''
95
+
96
+ if (!/^[0-9a-f]+$/i.test(hex)) return 'malformed'
97
+
98
+ const size = Number.parseInt(hex, 16)
99
+
100
+ if (size === 0) state = 'trailer'
101
+ else {
102
+ remaining = size + 2
103
+ state = 'data'
104
+ }
105
+ }
106
+
107
+ return { used: index, done: state === 'done' }
108
+ }
109
+ }
110
+
111
+ const requestBody = (headers: readonly string[]): BodyFraming | 'malformed' => {
112
+ const value = (name: string) =>
113
+ headers.flatMap(line => (line.toLowerCase().startsWith(`${name}:`) ? [line.slice(name.length + 1).trim()] : []))
114
+
115
+ const transferEncoding = value('transfer-encoding').join(',').toLowerCase()
116
+
117
+ if (transferEncoding !== '') return /(^|,)\s*chunked\s*$/.test(transferEncoding) ? chunkedBody() : 'malformed'
118
+
119
+ const lengths = [...new Set(value('content-length'))]
120
+
121
+ if (lengths.length === 0) return countedBody(0)
122
+
123
+ const [length = ''] = lengths
124
+
125
+ return lengths.length === 1 && /^\d+$/.test(length) ? countedBody(Number(length)) : 'malformed'
126
+ }
127
+
128
+ const handle = (hosts: readonly string[], lookup: LookupFunction | undefined) => (client: Socket) => {
43
129
  let head = Buffer.alloc(0)
44
130
 
45
131
  const onData = (chunk: Buffer) => {
@@ -48,6 +134,8 @@ const handle = (hosts: readonly string[]) => (client: Socket) => {
48
134
 
49
135
  if (end === -1) return head.length > 16_384 ? answer(client, '431 Request Header Fields Too Large') : undefined
50
136
  client.off('data', onData)
137
+ // What a pipelining client sends next arrives while the proxy connects, and a flowing socket would drop it.
138
+ client.pause()
51
139
 
52
140
  const [requestLine = '', ...headers] = head.subarray(0, end).toString().split('\r\n')
53
141
  const [method = '', target = '', version = 'HTTP/1.1'] = requestLine.split(' ')
@@ -57,30 +145,57 @@ const handle = (hosts: readonly string[]) => (client: Socket) => {
57
145
 
58
146
  if (!egressAllowed(hosts, destination.host, destination.port)) return answer(client, '403 Forbidden')
59
147
 
60
- const upstream = connect(destination.port, destination.host, () => {
61
- if (!('path' in destination)) {
62
- client.write('HTTP/1.1 200 Connection Established\r\n\r\n')
63
- upstream.write(head.subarray(end + 4))
64
- } else {
65
- // One request per connection: a reused one could carry a request for another host to this one.
148
+ const framing = 'path' in destination ? requestBody(headers) : undefined
149
+
150
+ if (framing === 'malformed') return answer(client, '400 Bad Request')
151
+
152
+ const request = 'path' in destination && framing !== undefined ? { path: destination.path, framing } : undefined
153
+
154
+ const upstream = connect(
155
+ { port: destination.port, host: destination.host, ...(lookup === undefined ? undefined : { lookup }) },
156
+ () => {
157
+ if (request === undefined) {
158
+ client.write('HTTP/1.1 200 Connection Established\r\n\r\n')
159
+ upstream.write(head.subarray(end + 4))
160
+ client.pipe(upstream)
161
+ upstream.pipe(client)
162
+ client.resume()
163
+
164
+ return
165
+ }
166
+
167
+ // One request per connection, and the client's connection ends with its response: a reused one could
168
+ // carry a request for another host to this one, and a pipelined one would mismatch the answers.
66
169
  const kept = headers.filter(line => !/^(proxy-)?connection:/i.test(line))
170
+ upstream.write([[method, request.path, version].join(' '), ...kept, 'Connection: close', '', ''].join('\r\n'))
67
171
 
68
- upstream.write(
69
- Buffer.concat([
70
- Buffer.from(
71
- [[method, destination.path, version].join(' '), ...kept, 'Connection: close', '', ''].join('\r\n')
72
- ),
73
- head.subarray(end + 4)
74
- ])
75
- )
76
- }
172
+ let bodyDone = false
77
173
 
78
- client.pipe(upstream)
79
- upstream.pipe(client)
80
- })
174
+ const forward = (chunk: Buffer) => {
175
+ if (bodyDone) return
176
+
177
+ const framed = request.framing(chunk)
178
+
179
+ if (framed === 'malformed') return client.destroy()
180
+
181
+ bodyDone = framed.done
182
+
183
+ if (upstream.write(chunk.subarray(0, framed.used))) return
184
+ client.pause()
185
+ upstream.once('drain', () => client.resume())
186
+ }
187
+
188
+ // Pipelined requests after the body are dropped; the client sends them again on a new connection.
189
+ client.on('data', forward)
190
+ upstream.pipe(client)
191
+ client.resume()
192
+ forward(head.subarray(end + 4))
193
+ }
194
+ )
81
195
 
82
196
  upstream.on('error', () => (client.writable ? answer(client, '502 Bad Gateway') : client.destroy()))
83
197
  client.on('error', () => upstream.destroy())
198
+ client.on('close', () => upstream.destroy())
84
199
  }
85
200
 
86
201
  client.on('data', onData)
@@ -92,8 +207,10 @@ export const startEgressProxy = (input: {
92
207
  readonly hosts: readonly string[]
93
208
  readonly port: number
94
209
  readonly hostname: string
210
+ /** How it resolves a host, if not as the system does. */
211
+ readonly lookup?: LookupFunction
95
212
  }): Promise<Server> => {
96
- const server = createServer(handle(input.hosts)).listen(input.port, input.hostname)
213
+ const server = createServer(handle(input.hosts, input.lookup)).listen(input.port, input.hostname)
97
214
 
98
215
  return once(server, 'listening').then(() => server)
99
216
  }
@@ -0,0 +1,125 @@
1
+ // Runs alone inside a pipeline check's sandbox too, so it imports nothing outside Node.
2
+ import { once } from 'node:events'
3
+ import { connect, createServer, type Server, type Socket } from 'node:net'
4
+
5
+ /**
6
+ * srt's proxy closes a connection once its client stops sending, and a Docker client attached to a job stops before
7
+ * the job prints anything. So the encoder inside the sandbox never stops sending: it sends what its client sends as
8
+ * frames, each a 4-byte big-endian length and that many bytes, and the client's end as a frame of length 0. The
9
+ * decoder outside the sandbox turns that frame back into the end of what it sends on.
10
+ */
11
+ const frameHeader = 4
12
+
13
+ const frame = (chunk: Buffer) => {
14
+ const header = Buffer.alloc(frameHeader)
15
+
16
+ header.writeUInt32BE(chunk.length)
17
+
18
+ return Buffer.concat([header, chunk])
19
+ }
20
+
21
+ const endOfInput = Buffer.alloc(frameHeader)
22
+
23
+ /** Far more than the one socket chunk the encoder frames; a longer frame is not the encoder's. */
24
+ export const maxFrame = 1024 * 1024
25
+
26
+ /** Holds `from` until `to` has caught up, when a write to `to` was `accepted` only into its buffer. */
27
+ const keepUp = (from: Socket, to: Socket, accepted: boolean) => {
28
+ if (accepted) return
29
+ from.pause()
30
+ to.once('drain', () => from.resume())
31
+ }
32
+
33
+ /** Ends each of `sockets` once another has closed, after what it holds is sent, and destroys them all if one fails. */
34
+ const closeTogether = (...sockets: readonly Socket[]) =>
35
+ sockets.forEach(socket => {
36
+ socket.on('error', () => sockets.forEach(other => other.destroy()))
37
+ socket.on('close', () => sockets.forEach(other => other.end()))
38
+ })
39
+
40
+ const listening = (server: Server, port: number) =>
41
+ once(server.listen(port, '127.0.0.1'), 'listening').then(() => server)
42
+
43
+ /** Serves `port` outside the sandbox, sending each connection's frames on to `127.0.0.1:<targetPort>`. */
44
+ export const startHalfCloseDecoder = (input: { readonly port: number; readonly targetPort: number }) =>
45
+ listening(
46
+ createServer({ allowHalfOpen: true }, client => {
47
+ const upstream = connect({ port: input.targetPort, host: '127.0.0.1', allowHalfOpen: true })
48
+ let pending: Buffer = Buffer.alloc(0)
49
+
50
+ client.on('data', (chunk: Buffer) => {
51
+ pending = Buffer.concat([pending, chunk])
52
+
53
+ if (pending.length >= frameHeader && pending.readUInt32BE(0) > maxFrame) return void client.destroy()
54
+
55
+ while (pending.length >= frameHeader && pending.length >= frameHeader + pending.readUInt32BE(0)) {
56
+ const length = pending.readUInt32BE(0)
57
+
58
+ if (length === 0) upstream.end()
59
+ else keepUp(client, upstream, upstream.write(pending.subarray(frameHeader, frameHeader + length)))
60
+ pending = pending.subarray(frameHeader + length)
61
+ }
62
+ })
63
+ client.on('end', () => upstream.end())
64
+ upstream.pipe(client)
65
+ closeTogether(client, upstream)
66
+ }),
67
+ input.port
68
+ )
69
+
70
+ /** `host` as `net.connect` takes it: a URL writes an IPv6 address in brackets. */
71
+ const unbracketed = (host: string) => host.replace(/^\[(.*)\]$/, '$1')
72
+
73
+ /**
74
+ * Serves `port` inside the sandbox, sending each connection through `proxy` to the decoder on the Runner's
75
+ * `127.0.0.1:<targetPort>`, as frames.
76
+ */
77
+ export const startHalfCloseEncoder = (input: {
78
+ readonly port: number
79
+ readonly proxy: string
80
+ readonly targetPort: number
81
+ }) => {
82
+ const proxy = new URL(input.proxy)
83
+ const target = `127.0.0.1:${input.targetPort}`
84
+
85
+ const credential =
86
+ proxy.username === ''
87
+ ? ''
88
+ : `Proxy-Authorization: Basic ${Buffer.from(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`).toString('base64')}\r\n`
89
+
90
+ return listening(
91
+ createServer({ allowHalfOpen: true }, client => {
92
+ client.pause()
93
+ const upstream = connect({ port: Number(proxy.port === '' ? 80 : proxy.port), host: unbracketed(proxy.hostname) })
94
+ let head = Buffer.alloc(0)
95
+
96
+ const onHead = (chunk: Buffer) => {
97
+ head = Buffer.concat([head, chunk])
98
+ const end = head.indexOf('\r\n\r\n')
99
+
100
+ if (end === -1) return
101
+ upstream.off('data', onHead)
102
+
103
+ if (!/^HTTP\/1\.[01] 200/.test(head.toString('latin1', 0, end))) return void upstream.destroy()
104
+ client.write(head.subarray(end + 4))
105
+ upstream.pipe(client)
106
+ client.on('data', (chunk: Buffer) => keepUp(client, upstream, upstream.write(frame(chunk))))
107
+ client.on('end', () => upstream.write(endOfInput))
108
+ client.resume()
109
+ }
110
+
111
+ upstream.on('data', onHead)
112
+ upstream.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n${credential}\r\n`)
113
+ closeTogether(client, upstream)
114
+ }),
115
+ input.port
116
+ )
117
+ }
118
+
119
+ // `bun half-close-relay.ts <port> <proxy URL> <decoder port>`, inside srt.
120
+ if (import.meta.main)
121
+ await startHalfCloseEncoder({
122
+ port: Number(Bun.argv[2]),
123
+ proxy: Bun.argv[3] ?? '',
124
+ targetPort: Number(Bun.argv[4])
125
+ })
package/dist/index.js CHANGED
@@ -5,15 +5,15 @@
5
5
  var args = process.argv.slice(2);
6
6
  if (args[0] === "srt") {
7
7
  process.argv.splice(2, 1);
8
- const { runSrtCli } = await import("./chunk-wczs5vrq.js");
8
+ const { runSrtCli } = await import("./chunk-st9mym1j.js");
9
9
  await runSrtCli();
10
10
  } else if (args[0] === "srt-step") {
11
- const { runSrtStep } = await import("./chunk-19aqk325.js");
11
+ const { runSrtStep } = await import("./chunk-99ver3za.js");
12
12
  await runSrtStep(args.slice(1));
13
13
  } else if (args[0] === "opencode-read") {
14
- const { runOpencodeReader } = await import("./chunk-rjfxnk05.js");
14
+ const { runOpencodeReader } = await import("./chunk-yfdktxzg.js");
15
15
  runOpencodeReader();
16
16
  } else {
17
- const { runCli } = await import("./chunk-y48wz1sa.js");
17
+ const { runCli } = await import("./chunk-w9kg1784.js");
18
18
  runCli(args);
19
19
  }
@@ -0,0 +1,29 @@
1
+ // Runs alone inside a check Docker daemon's API forwarder container, so it imports nothing outside Node.
2
+ import { once } from 'node:events'
3
+ import { connect, createServer, type Server } from 'node:net'
4
+
5
+ /** Forwards each connection to `port` on to `target:targetPort`, and only there. */
6
+ export const startTcpForwarder = (input: {
7
+ readonly port: number
8
+ readonly target: string
9
+ readonly targetPort: number
10
+ }): Promise<Server> => {
11
+ // A Docker client attached to a container stops sending long before the container's output ends.
12
+ const server = createServer({ allowHalfOpen: true }, client => {
13
+ const upstream = connect({ port: input.targetPort, host: input.target, allowHalfOpen: true })
14
+
15
+ client.pipe(upstream)
16
+ upstream.pipe(client)
17
+ client.on('error', () => upstream.destroy())
18
+ upstream.on('error', () => client.destroy())
19
+ }).listen(input.port, '0.0.0.0')
20
+
21
+ return once(server, 'listening').then(() => server)
22
+ }
23
+
24
+ if (import.meta.main)
25
+ await startTcpForwarder({
26
+ port: Number(Bun.argv[2]),
27
+ target: Bun.argv[3] ?? '',
28
+ targetPort: Number(Bun.argv[4])
29
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitman-sh/cli",
3
- "version": "0.10.54",
3
+ "version": "0.10.56",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
File without changes