@kitman-sh/cli 0.0.0-stage → 0.7.0

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.
@@ -0,0 +1,107 @@
1
+ // Runs alone inside the egress proxy container, 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
+ /** Whether the allowlist takes `host:port`: an entry `host` takes any port, `host:port` one, `*.domain` subdomains. */
6
+ export const egressAllowed = (hosts: readonly string[], host: string, port: number) => {
7
+ const target = host.toLowerCase()
8
+
9
+ return hosts.some(entry => {
10
+ const [name = '', entryPort] = entry.toLowerCase().split(':')
11
+
12
+ if (entryPort !== undefined && Number(entryPort) !== port) return false
13
+
14
+ return name.startsWith('*.') ? target.endsWith(name.slice(1)) : target === name
15
+ })
16
+ }
17
+
18
+ const connectTarget = (authority: string) => {
19
+ const separator = authority.lastIndexOf(':')
20
+ const port = Number(authority.slice(separator + 1))
21
+
22
+ return separator === -1 || !Number.isInteger(port)
23
+ ? undefined
24
+ : { host: authority.slice(0, separator).replace(/^\[|\]$/g, ''), port }
25
+ }
26
+
27
+ const httpTarget = (absolute: string) => {
28
+ if (!URL.canParse(absolute)) return undefined
29
+ const url = new URL(absolute)
30
+
31
+ return url.protocol !== 'http:'
32
+ ? undefined
33
+ : {
34
+ host: url.hostname.replace(/^\[|\]$/g, ''),
35
+ port: url.port === '' ? 80 : Number(url.port),
36
+ path: `${url.pathname}${url.search}`
37
+ }
38
+ }
39
+
40
+ const answer = (socket: Socket, status: string) => socket.end(`HTTP/1.1 ${status}\r\nContent-Length: 0\r\n\r\n`)
41
+
42
+ const handle = (hosts: readonly string[]) => (client: Socket) => {
43
+ let head = Buffer.alloc(0)
44
+
45
+ const onData = (chunk: Buffer) => {
46
+ head = Buffer.concat([head, chunk])
47
+ const end = head.indexOf('\r\n\r\n')
48
+
49
+ if (end === -1) return head.length > 16_384 ? answer(client, '431 Request Header Fields Too Large') : undefined
50
+ client.off('data', onData)
51
+
52
+ const [requestLine = '', ...headers] = head.subarray(0, end).toString().split('\r\n')
53
+ const [method = '', target = '', version = 'HTTP/1.1'] = requestLine.split(' ')
54
+ const destination = method === 'CONNECT' ? connectTarget(target) : httpTarget(target)
55
+
56
+ if (destination === undefined) return answer(client, '405 Method Not Allowed')
57
+
58
+ if (!egressAllowed(hosts, destination.host, destination.port)) return answer(client, '403 Forbidden')
59
+
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.
66
+ const kept = headers.filter(line => !/^(proxy-)?connection:/i.test(line))
67
+
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
+ }
77
+
78
+ client.pipe(upstream)
79
+ upstream.pipe(client)
80
+ })
81
+
82
+ upstream.on('error', () => (client.writable ? answer(client, '502 Bad Gateway') : client.destroy()))
83
+ client.on('error', () => upstream.destroy())
84
+ }
85
+
86
+ client.on('data', onData)
87
+ client.on('error', () => client.destroy())
88
+ }
89
+
90
+ /** An HTTP proxy that tunnels CONNECT and forwards plain HTTP only to the allowlist. */
91
+ export const startEgressProxy = (input: {
92
+ readonly hosts: readonly string[]
93
+ readonly port: number
94
+ readonly hostname: string
95
+ }): Promise<Server> => {
96
+ const server = createServer(handle(input.hosts)).listen(input.port, input.hostname)
97
+
98
+ return once(server, 'listening').then(() => server)
99
+ }
100
+
101
+ // `bun egress-proxy.ts <comma-separated allowlist>`
102
+ if (import.meta.main)
103
+ void startEgressProxy({
104
+ hosts: (Bun.argv[2] ?? '').split(',').filter(host => host.length > 0),
105
+ port: 3128,
106
+ hostname: '0.0.0.0'
107
+ })