@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.
- package/dist/chunk-226sychh.js +7169 -0
- package/dist/chunk-6f4anyec.js +13 -0
- package/dist/chunk-6kdj8psf.js +74 -0
- package/dist/chunk-704ws2q7.js +26325 -0
- package/dist/chunk-ae7gx971.js +78 -0
- package/dist/chunk-dwgta18y.js +1178 -0
- package/dist/chunk-epdajq63.js +1084 -0
- package/dist/chunk-gzhxsb25.js +13 -0
- package/dist/chunk-hrd7xmax.js +238 -0
- package/dist/chunk-vjnae06n.js +4785 -0
- package/dist/chunk-ztmwmk8t.js +962 -0
- package/dist/egress-proxy.ts +107 -0
- package/dist/index.js +5984 -0
- package/package.json +16 -4
- package/README.md +0 -3
|
@@ -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
|
+
})
|