@openape/proxy 0.2.14 → 0.2.15
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/index.cjs +711 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +710 -0
- package/dist/index.js.map +1 -0
- package/package.json +7 -2
- package/.nvmrc +0 -1
- package/CHANGELOG.md +0 -140
- package/PLAN.md +0 -261
- package/bun.lock +0 -229
- package/config.toml +0 -35
- package/src/audit.ts +0 -20
- package/src/auth.ts +0 -57
- package/src/config.ts +0 -84
- package/src/connect.ts +0 -111
- package/src/grants-client.ts +0 -129
- package/src/index.ts +0 -69
- package/src/matcher.ts +0 -80
- package/src/proxy.ts +0 -401
- package/src/ssrf.ts +0 -85
- package/src/types.ts +0 -65
- package/test/auth.test.ts +0 -57
- package/test/connect.test.ts +0 -131
- package/test/matcher.test.ts +0 -46
- package/test/multi-agent.test.ts +0 -122
- package/test/ssrf.test.ts +0 -73
- package/tsconfig.json +0 -21
- package/tsup.config.ts +0 -9
- package/vitest.config.ts +0 -18
package/src/grants-client.ts
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
import type { OpenApeGrant, GrantType } from '@openape/core'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Client for the IdP's grant management API.
|
|
5
|
-
* Creates grant requests and polls for approval.
|
|
6
|
-
*/
|
|
7
|
-
export class GrantsClient {
|
|
8
|
-
private idpUrl: string
|
|
9
|
-
private agentToken: string | undefined
|
|
10
|
-
|
|
11
|
-
constructor(idpUrl: string) {
|
|
12
|
-
this.idpUrl = idpUrl.replace(/\/$/, '')
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
setAgentToken(token: string): void {
|
|
16
|
-
this.agentToken = token
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
private headers(): Record<string, string> {
|
|
20
|
-
const h: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
21
|
-
if (this.agentToken) {
|
|
22
|
-
h.Authorization = `Bearer ${this.agentToken}`
|
|
23
|
-
}
|
|
24
|
-
return h
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Create a grant request on the IdP.
|
|
29
|
-
*/
|
|
30
|
-
async requestGrant(opts: {
|
|
31
|
-
requester: string
|
|
32
|
-
targetHost: string
|
|
33
|
-
audience: string
|
|
34
|
-
grantType: GrantType
|
|
35
|
-
permissions?: string[]
|
|
36
|
-
reason?: string
|
|
37
|
-
requestHash?: string
|
|
38
|
-
duration?: number
|
|
39
|
-
}): Promise<OpenApeGrant> {
|
|
40
|
-
const res = await fetch(`${this.idpUrl}/api/grants`, {
|
|
41
|
-
method: 'POST',
|
|
42
|
-
headers: this.headers(),
|
|
43
|
-
body: JSON.stringify({
|
|
44
|
-
requester: opts.requester,
|
|
45
|
-
target_host: opts.targetHost,
|
|
46
|
-
audience: opts.audience,
|
|
47
|
-
grant_type: opts.grantType,
|
|
48
|
-
permissions: opts.permissions,
|
|
49
|
-
reason: opts.reason,
|
|
50
|
-
request_hash: opts.requestHash,
|
|
51
|
-
duration: opts.duration,
|
|
52
|
-
}),
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
if (!res.ok) {
|
|
56
|
-
throw new Error(`Grant request failed: ${res.status} ${await res.text()}`)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return res.json() as Promise<OpenApeGrant>
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Poll a grant until it's approved, denied, or timeout.
|
|
64
|
-
*/
|
|
65
|
-
async waitForApproval(
|
|
66
|
-
grantId: string,
|
|
67
|
-
timeoutMs: number = 300_000,
|
|
68
|
-
pollIntervalMs: number = 2_000,
|
|
69
|
-
): Promise<OpenApeGrant> {
|
|
70
|
-
const deadline = Date.now() + timeoutMs
|
|
71
|
-
|
|
72
|
-
while (Date.now() < deadline) {
|
|
73
|
-
const res = await fetch(`${this.idpUrl}/api/grants/${grantId}`, {
|
|
74
|
-
headers: this.headers(),
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
if (!res.ok) {
|
|
78
|
-
throw new Error(`Grant poll failed: ${res.status}`)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const grant = await res.json() as OpenApeGrant
|
|
82
|
-
if (grant.status !== 'pending') {
|
|
83
|
-
return grant
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
await new Promise(r => setTimeout(r, pollIntervalMs))
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
throw new Error(`Grant approval timed out after ${timeoutMs}ms`)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Check if there's an existing approved grant for a host+audience+permissions combo.
|
|
94
|
-
* Ignores `once` grants (they're single-use).
|
|
95
|
-
*/
|
|
96
|
-
async findExistingGrant(
|
|
97
|
-
requester: string,
|
|
98
|
-
targetHost: string,
|
|
99
|
-
audience: string,
|
|
100
|
-
permissions?: string[],
|
|
101
|
-
): Promise<OpenApeGrant | null> {
|
|
102
|
-
const params = new URLSearchParams({
|
|
103
|
-
requester,
|
|
104
|
-
status: 'approved',
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
const res = await fetch(`${this.idpUrl}/api/grants?${params}`, {
|
|
108
|
-
headers: this.headers(),
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
if (!res.ok) return null
|
|
112
|
-
|
|
113
|
-
const grants = await res.json() as OpenApeGrant[]
|
|
114
|
-
const now = Math.floor(Date.now() / 1000)
|
|
115
|
-
|
|
116
|
-
return grants.find((g) => {
|
|
117
|
-
if (g.status !== 'approved') return false
|
|
118
|
-
if (g.expires_at && g.expires_at <= now) return false
|
|
119
|
-
if (g.request?.grant_type === 'once') return false
|
|
120
|
-
if (g.request?.target_host !== targetHost) return false
|
|
121
|
-
if (g.request?.audience !== audience) return false
|
|
122
|
-
if (permissions?.length && g.request?.permissions?.length) {
|
|
123
|
-
const grantedPerms = new Set(g.request.permissions)
|
|
124
|
-
if (!permissions.every(p => grantedPerms.has(p))) return false
|
|
125
|
-
}
|
|
126
|
-
return true
|
|
127
|
-
}) ?? null
|
|
128
|
-
}
|
|
129
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
import { createServer } from 'node:http'
|
|
3
|
-
import { parseArgs } from 'node:util'
|
|
4
|
-
import { loadMultiAgentConfig } from './config.js'
|
|
5
|
-
import { createNodeHandler } from './proxy.js'
|
|
6
|
-
import { initAudit } from './audit.js'
|
|
7
|
-
|
|
8
|
-
const { values } = parseArgs({
|
|
9
|
-
options: {
|
|
10
|
-
config: { type: 'string', short: 'c', default: 'config.toml' },
|
|
11
|
-
'dry-run': { type: 'boolean', default: false },
|
|
12
|
-
'mandatory-auth': { type: 'boolean', default: false },
|
|
13
|
-
},
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
const configPath = values.config!
|
|
17
|
-
|
|
18
|
-
console.log(`[openape-proxy] Loading config from ${configPath}`)
|
|
19
|
-
const config = loadMultiAgentConfig(configPath, {
|
|
20
|
-
mandatoryAuth: values['mandatory-auth'] || undefined,
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
// Init audit log
|
|
24
|
-
initAudit(config.proxy.audit_log)
|
|
25
|
-
|
|
26
|
-
if (values['dry-run']) {
|
|
27
|
-
console.log('[openape-proxy] DRY RUN mode — logging only, not blocking')
|
|
28
|
-
console.log('[openape-proxy] Config loaded:')
|
|
29
|
-
console.log(` Listen: ${config.proxy.listen}`)
|
|
30
|
-
console.log(` Default action: ${config.proxy.default_action}`)
|
|
31
|
-
console.log(` Mandatory auth: ${config.proxy.mandatory_auth ?? false}`)
|
|
32
|
-
console.log(` Agents: ${config.agents.length}`)
|
|
33
|
-
for (const agent of config.agents) {
|
|
34
|
-
const allowCount = agent.allow?.length ?? 0
|
|
35
|
-
const denyCount = agent.deny?.length ?? 0
|
|
36
|
-
const grantCount = agent.grant_required?.length ?? 0
|
|
37
|
-
console.log(` ${agent.email} (${agent.idp_url}) — ${allowCount} allow, ${denyCount} deny, ${grantCount} grant`)
|
|
38
|
-
}
|
|
39
|
-
process.exit(0)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const handler = createNodeHandler(config)
|
|
43
|
-
|
|
44
|
-
const port = Number.parseInt(config.proxy.listen.split(':')[1] || '9090')
|
|
45
|
-
const hostname = config.proxy.listen.split(':')[0] || '127.0.0.1'
|
|
46
|
-
|
|
47
|
-
const server = createServer(handler.handleRequest)
|
|
48
|
-
server.on('connect', handler.handleConnect)
|
|
49
|
-
|
|
50
|
-
server.listen(port, hostname, () => {
|
|
51
|
-
console.log(`[openape-proxy] Listening on http://${hostname}:${port}`)
|
|
52
|
-
console.log(`[openape-proxy] CONNECT tunneling enabled`)
|
|
53
|
-
console.log(`[openape-proxy] Mandatory auth: ${config.proxy.mandatory_auth ?? false}`)
|
|
54
|
-
console.log(`[openape-proxy] Agents: ${config.agents.map(a => a.email).join(', ')}`)
|
|
55
|
-
console.log(`[openape-proxy] Default action: ${config.proxy.default_action}`)
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
// Graceful shutdown
|
|
59
|
-
process.on('SIGINT', () => {
|
|
60
|
-
console.log('\n[openape-proxy] Shutting down...')
|
|
61
|
-
server.close()
|
|
62
|
-
process.exit(0)
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
process.on('SIGTERM', () => {
|
|
66
|
-
console.log('[openape-proxy] Shutting down...')
|
|
67
|
-
server.close()
|
|
68
|
-
process.exit(0)
|
|
69
|
-
})
|
package/src/matcher.ts
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import type { ProxyConfig, RuleAction, RuleEntry } from './types.js'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Match a glob pattern against a string.
|
|
5
|
-
* Supports * (any segment) and ** (any number of segments).
|
|
6
|
-
*/
|
|
7
|
-
function globMatch(pattern: string, value: string): boolean {
|
|
8
|
-
// Simple glob: convert * to regex
|
|
9
|
-
const regex = new RegExp(
|
|
10
|
-
`^${
|
|
11
|
-
pattern
|
|
12
|
-
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape regex chars except *
|
|
13
|
-
.replace(/\*\*/g, '<<<DOUBLESTAR>>>')
|
|
14
|
-
.replace(/\*/g, '[^/]*')
|
|
15
|
-
.replace(/<<<DOUBLESTAR>>>/g, '.*')
|
|
16
|
-
}$`,
|
|
17
|
-
)
|
|
18
|
-
return regex.test(value)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function matchesRule(rule: RuleEntry, domain: string, method: string, path: string): boolean {
|
|
22
|
-
// Domain match (supports wildcards like *.github.com)
|
|
23
|
-
if (!globMatch(rule.domain, domain)) return false
|
|
24
|
-
|
|
25
|
-
// Method match (if specified)
|
|
26
|
-
if (rule.methods && rule.methods.length > 0) {
|
|
27
|
-
if (!rule.methods.includes(method.toUpperCase())) return false
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Path match (if specified)
|
|
31
|
-
if (rule.path) {
|
|
32
|
-
if (!globMatch(rule.path, path)) return false
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return true
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Evaluate rules in order: deny → allow → grant_required → default_action
|
|
40
|
-
*/
|
|
41
|
-
export function evaluateRules(
|
|
42
|
-
config: ProxyConfig,
|
|
43
|
-
domain: string,
|
|
44
|
-
method: string,
|
|
45
|
-
path: string,
|
|
46
|
-
): RuleAction {
|
|
47
|
-
// 1. Check deny list first
|
|
48
|
-
for (const rule of config.deny) {
|
|
49
|
-
if (matchesRule(rule, domain, method, path)) {
|
|
50
|
-
return { type: 'deny', note: rule.note }
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// 2. Check allow list
|
|
55
|
-
for (const rule of config.allow) {
|
|
56
|
-
if (matchesRule(rule, domain, method, path)) {
|
|
57
|
-
return { type: 'allow' }
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// 3. Check grant_required rules (most specific first)
|
|
62
|
-
for (const rule of config.grant_required) {
|
|
63
|
-
if (matchesRule(rule, domain, method, path)) {
|
|
64
|
-
return { type: 'grant_required', rule }
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// 4. Default: treat as grant_required with 'once'
|
|
69
|
-
if (config.proxy.default_action === 'block') {
|
|
70
|
-
return { type: 'deny', note: 'No matching rule (default: block)' }
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return {
|
|
74
|
-
type: 'grant_required',
|
|
75
|
-
rule: {
|
|
76
|
-
domain: '*',
|
|
77
|
-
grant_type: 'once',
|
|
78
|
-
},
|
|
79
|
-
}
|
|
80
|
-
}
|
package/src/proxy.ts
DELETED
|
@@ -1,401 +0,0 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
-
import type { Socket } from 'node:net'
|
|
3
|
-
import { createHash } from 'node:crypto'
|
|
4
|
-
import type { AgentConfig, AuditEntry, MultiAgentProxyConfig, ProxyConfig } from './types.js'
|
|
5
|
-
import { evaluateRules } from './matcher.js'
|
|
6
|
-
import { AuthError, verifyAgentAuth } from './auth.js'
|
|
7
|
-
import { GrantsClient } from './grants-client.js'
|
|
8
|
-
import { writeAudit } from './audit.js'
|
|
9
|
-
import { isPrivateOrLoopback } from './ssrf.js'
|
|
10
|
-
import { handleConnect } from './connect.js'
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Compute a request hash that uniquely identifies the intent.
|
|
14
|
-
* hash = sha256(METHOD + " " + FULL_URL + "\n" + BODY)
|
|
15
|
-
* This binds the grant to the exact request — no bait-and-switch.
|
|
16
|
-
*/
|
|
17
|
-
async function computeRequestHash(method: string, targetUrl: string, body: ArrayBuffer | null): Promise<string> {
|
|
18
|
-
const hash = createHash('sha256')
|
|
19
|
-
hash.update(`${method} ${targetUrl}\n`)
|
|
20
|
-
if (body && body.byteLength > 0) {
|
|
21
|
-
hash.update(new Uint8Array(body))
|
|
22
|
-
}
|
|
23
|
-
return hash.digest('hex')
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Legacy single-agent proxy */
|
|
27
|
-
export function createProxy(config: ProxyConfig) {
|
|
28
|
-
const multiConfig: MultiAgentProxyConfig = {
|
|
29
|
-
proxy: {
|
|
30
|
-
listen: config.proxy.listen,
|
|
31
|
-
default_action: config.proxy.default_action,
|
|
32
|
-
audit_log: config.proxy.audit_log,
|
|
33
|
-
mandatory_auth: config.proxy.mandatory_auth,
|
|
34
|
-
},
|
|
35
|
-
agents: [{
|
|
36
|
-
email: config.proxy.agent_email,
|
|
37
|
-
idp_url: config.proxy.idp_url,
|
|
38
|
-
allow: config.allow,
|
|
39
|
-
deny: config.deny,
|
|
40
|
-
grant_required: config.grant_required,
|
|
41
|
-
}],
|
|
42
|
-
}
|
|
43
|
-
return createMultiAgentProxy(multiConfig)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Multi-agent proxy with SSRF protection and mandatory auth */
|
|
47
|
-
export function createMultiAgentProxy(config: MultiAgentProxyConfig) {
|
|
48
|
-
const grantsClients = new Map<string, GrantsClient>()
|
|
49
|
-
for (const agent of config.agents) {
|
|
50
|
-
grantsClients.set(agent.email, new GrantsClient(agent.idp_url))
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const mandatoryAuth = config.proxy.mandatory_auth ?? false
|
|
54
|
-
|
|
55
|
-
return {
|
|
56
|
-
port: Number.parseInt(config.proxy.listen.split(':')[1] || '9090'),
|
|
57
|
-
hostname: config.proxy.listen.split(':')[0] || '127.0.0.1',
|
|
58
|
-
|
|
59
|
-
async fetch(req: Request): Promise<Response> {
|
|
60
|
-
const url = new URL(req.url)
|
|
61
|
-
const startTime = Date.now()
|
|
62
|
-
|
|
63
|
-
// Health endpoint
|
|
64
|
-
if (url.pathname === '/healthz') {
|
|
65
|
-
return Response.json({
|
|
66
|
-
status: 'ok',
|
|
67
|
-
agents: config.agents.map(a => a.email),
|
|
68
|
-
})
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Parse target URL from the path
|
|
72
|
-
const targetUrl = url.pathname.slice(1) + url.search
|
|
73
|
-
let targetParsed: URL
|
|
74
|
-
try {
|
|
75
|
-
targetParsed = new URL(targetUrl)
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
return new Response(
|
|
79
|
-
'Invalid target URL. Send requests as: http://proxy:port/https://target.com/path',
|
|
80
|
-
{ status: 400 },
|
|
81
|
-
)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const domain = targetParsed.hostname
|
|
85
|
-
const method = req.method
|
|
86
|
-
const path = targetParsed.pathname
|
|
87
|
-
|
|
88
|
-
// SSRF protection — block private/loopback IPs before any rule evaluation
|
|
89
|
-
if (await isPrivateOrLoopback(domain)) {
|
|
90
|
-
return new Response('Blocked: private/loopback IP', { status: 403 })
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Read body once (needed for hash + forwarding)
|
|
94
|
-
const bodyBuffer = req.body ? await req.arrayBuffer() : null
|
|
95
|
-
|
|
96
|
-
// Verify agent identity — find IdP URL from first agent (for JWKS verification)
|
|
97
|
-
// In multi-agent mode, we need the JWT to identify the agent first.
|
|
98
|
-
// We try verification against each agent's IdP until one succeeds.
|
|
99
|
-
let agentIdentity: { email: string, act: 'agent' } | null = null
|
|
100
|
-
try {
|
|
101
|
-
for (const agentConf of config.agents) {
|
|
102
|
-
agentIdentity = await verifyAgentAuth(
|
|
103
|
-
req.headers.get('proxy-authorization'),
|
|
104
|
-
agentConf.idp_url,
|
|
105
|
-
mandatoryAuth && config.agents.length === 1,
|
|
106
|
-
)
|
|
107
|
-
if (agentIdentity) break
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// If mandatory auth and no identity found from any IdP
|
|
111
|
-
if (mandatoryAuth && !agentIdentity) {
|
|
112
|
-
throw new AuthError('JWT required')
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
catch (err) {
|
|
116
|
-
if (err instanceof AuthError) {
|
|
117
|
-
return new Response(`Unauthorized: ${err.message}`, { status: 401 })
|
|
118
|
-
}
|
|
119
|
-
throw err
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Find the matching agent config
|
|
123
|
-
const agentEmail = agentIdentity?.email
|
|
124
|
-
let agentConf: AgentConfig | undefined
|
|
125
|
-
|
|
126
|
-
if (agentEmail) {
|
|
127
|
-
agentConf = config.agents.find(a => a.email === agentEmail)
|
|
128
|
-
if (!agentConf) {
|
|
129
|
-
return new Response(`Forbidden: unknown agent ${agentEmail}`, { status: 403 })
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
else if (config.agents.length === 1) {
|
|
133
|
-
// Non-mandatory auth, single agent: use the only agent config
|
|
134
|
-
agentConf = config.agents[0]
|
|
135
|
-
}
|
|
136
|
-
else {
|
|
137
|
-
return new Response('Unauthorized: JWT required for multi-agent proxy', { status: 401 })
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const effectiveEmail = agentEmail ?? agentConf.email
|
|
141
|
-
const grantsClient = grantsClients.get(agentConf.email)!
|
|
142
|
-
|
|
143
|
-
// Build a ProxyConfig-shaped object for evaluateRules
|
|
144
|
-
const rulesConfig: ProxyConfig = {
|
|
145
|
-
proxy: {
|
|
146
|
-
listen: config.proxy.listen,
|
|
147
|
-
idp_url: agentConf.idp_url,
|
|
148
|
-
agent_email: agentConf.email,
|
|
149
|
-
default_action: config.proxy.default_action,
|
|
150
|
-
},
|
|
151
|
-
allow: agentConf.allow ?? [],
|
|
152
|
-
deny: agentConf.deny ?? [],
|
|
153
|
-
grant_required: agentConf.grant_required ?? [],
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Evaluate rules
|
|
157
|
-
const action = evaluateRules(rulesConfig, domain, method, path)
|
|
158
|
-
|
|
159
|
-
const baseAudit: Omit<AuditEntry, 'action' | 'rule'> = {
|
|
160
|
-
ts: new Date().toISOString(),
|
|
161
|
-
agent: effectiveEmail,
|
|
162
|
-
domain,
|
|
163
|
-
method,
|
|
164
|
-
path,
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// DENY
|
|
168
|
-
if (action.type === 'deny') {
|
|
169
|
-
writeAudit({ ...baseAudit, action: 'deny', rule: 'deny-list', grant_id: null })
|
|
170
|
-
return new Response(`Blocked: ${action.note || 'deny rule'}`, { status: 403 })
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// ALLOW (no grant needed)
|
|
174
|
-
if (action.type === 'allow') {
|
|
175
|
-
writeAudit({ ...baseAudit, action: 'allow', rule: 'allow-list', grant_id: null })
|
|
176
|
-
return forwardRequest(req, targetUrl, bodyBuffer)
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// GRANT REQUIRED
|
|
180
|
-
const rule = action.rule
|
|
181
|
-
const permissions = rule.permissions ?? [`${method.toLowerCase()}:${domain}`]
|
|
182
|
-
|
|
183
|
-
// Compute request hash — binds grant to exact method + URL + body
|
|
184
|
-
const requestHash = await computeRequestHash(method, targetUrl, bodyBuffer)
|
|
185
|
-
|
|
186
|
-
// Check for existing grant
|
|
187
|
-
const existing = await grantsClient.findExistingGrant(
|
|
188
|
-
effectiveEmail,
|
|
189
|
-
domain,
|
|
190
|
-
'proxy',
|
|
191
|
-
permissions,
|
|
192
|
-
).catch(() => null)
|
|
193
|
-
|
|
194
|
-
if (existing) {
|
|
195
|
-
writeAudit({
|
|
196
|
-
...baseAudit,
|
|
197
|
-
action: 'grant_approved',
|
|
198
|
-
rule: 'standing-grant',
|
|
199
|
-
grant_id: existing.id,
|
|
200
|
-
request_hash: requestHash,
|
|
201
|
-
})
|
|
202
|
-
return forwardRequest(req, targetUrl, bodyBuffer)
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// No existing grant — behavior depends on default_action
|
|
206
|
-
if (config.proxy.default_action === 'block') {
|
|
207
|
-
writeAudit({ ...baseAudit, action: 'deny', rule: 'no-grant (block mode)', grant_id: null })
|
|
208
|
-
return new Response('No grant — blocked', { status: 403 })
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (config.proxy.default_action === 'request-async') {
|
|
212
|
-
// Create grant request, return 407 immediately
|
|
213
|
-
const grant = await grantsClient.requestGrant({
|
|
214
|
-
requester: effectiveEmail,
|
|
215
|
-
targetHost: domain,
|
|
216
|
-
audience: 'proxy',
|
|
217
|
-
grantType: rule.grant_type,
|
|
218
|
-
permissions,
|
|
219
|
-
reason: `${method} ${targetUrl}`,
|
|
220
|
-
requestHash,
|
|
221
|
-
duration: rule.duration,
|
|
222
|
-
}).catch(() => null)
|
|
223
|
-
|
|
224
|
-
writeAudit({
|
|
225
|
-
...baseAudit,
|
|
226
|
-
action: 'grant_denied',
|
|
227
|
-
rule: 'grant_required (async)',
|
|
228
|
-
grant_id: grant?.id ?? null,
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
return new Response(
|
|
232
|
-
JSON.stringify({
|
|
233
|
-
error: 'Grant required',
|
|
234
|
-
grant_id: grant?.id,
|
|
235
|
-
message: 'Grant request created. Retry after approval.',
|
|
236
|
-
}),
|
|
237
|
-
{ status: 407, headers: { 'Content-Type': 'application/json' } },
|
|
238
|
-
)
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// BLOCKING mode: create grant request and wait
|
|
242
|
-
console.error(`[proxy] Requesting grant for ${method} ${domain}${path} — waiting for approval...`)
|
|
243
|
-
|
|
244
|
-
try {
|
|
245
|
-
const grant = await grantsClient.requestGrant({
|
|
246
|
-
requester: effectiveEmail,
|
|
247
|
-
targetHost: domain,
|
|
248
|
-
audience: 'proxy',
|
|
249
|
-
grantType: rule.grant_type,
|
|
250
|
-
permissions,
|
|
251
|
-
reason: `${method} ${targetUrl}`,
|
|
252
|
-
requestHash,
|
|
253
|
-
duration: rule.duration,
|
|
254
|
-
})
|
|
255
|
-
|
|
256
|
-
const approved = await grantsClient.waitForApproval(grant.id)
|
|
257
|
-
|
|
258
|
-
const waitedMs = Date.now() - startTime
|
|
259
|
-
|
|
260
|
-
if (approved.status === 'approved') {
|
|
261
|
-
writeAudit({
|
|
262
|
-
...baseAudit,
|
|
263
|
-
action: 'grant_approved',
|
|
264
|
-
rule: 'grant_required',
|
|
265
|
-
grant_id: approved.id,
|
|
266
|
-
request_hash: requestHash,
|
|
267
|
-
waited_ms: waitedMs,
|
|
268
|
-
})
|
|
269
|
-
return forwardRequest(req, targetUrl, bodyBuffer)
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
writeAudit({
|
|
273
|
-
...baseAudit,
|
|
274
|
-
action: 'grant_denied',
|
|
275
|
-
rule: 'grant_required',
|
|
276
|
-
grant_id: approved.id,
|
|
277
|
-
waited_ms: waitedMs,
|
|
278
|
-
})
|
|
279
|
-
return new Response(`Grant denied by ${approved.decided_by}`, { status: 403 })
|
|
280
|
-
}
|
|
281
|
-
catch (err) {
|
|
282
|
-
const msg = err instanceof Error ? err.message : 'Unknown error'
|
|
283
|
-
writeAudit({
|
|
284
|
-
...baseAudit,
|
|
285
|
-
action: 'grant_timeout',
|
|
286
|
-
rule: 'grant_required',
|
|
287
|
-
error: msg,
|
|
288
|
-
})
|
|
289
|
-
return new Response(`Grant request failed: ${msg}`, { status: 504 })
|
|
290
|
-
}
|
|
291
|
-
},
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Create a node:http compatible handler for use with http.createServer().
|
|
297
|
-
* Returns both a request handler and a CONNECT handler.
|
|
298
|
-
*/
|
|
299
|
-
export function createNodeHandler(config: MultiAgentProxyConfig): {
|
|
300
|
-
handleRequest: (req: IncomingMessage, res: ServerResponse) => void
|
|
301
|
-
handleConnect: (req: IncomingMessage, socket: Socket, head: Buffer) => void
|
|
302
|
-
} {
|
|
303
|
-
const proxy = createMultiAgentProxy(config)
|
|
304
|
-
|
|
305
|
-
return {
|
|
306
|
-
handleRequest(req: IncomingMessage, res: ServerResponse) {
|
|
307
|
-
// Convert IncomingMessage to Request and use existing fetch logic
|
|
308
|
-
const url = `http://${req.headers.host || 'localhost'}${req.url || '/'}`
|
|
309
|
-
const chunks: Buffer[] = []
|
|
310
|
-
|
|
311
|
-
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
312
|
-
req.on('end', async () => {
|
|
313
|
-
try {
|
|
314
|
-
const body = chunks.length > 0 ? Buffer.concat(chunks) : undefined
|
|
315
|
-
const headers = new Headers()
|
|
316
|
-
for (const [key, value] of Object.entries(req.headers)) {
|
|
317
|
-
if (value) {
|
|
318
|
-
headers.set(key, Array.isArray(value) ? value.join(', ') : value)
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const request = new Request(url, {
|
|
323
|
-
method: req.method,
|
|
324
|
-
headers,
|
|
325
|
-
body: body && req.method !== 'GET' && req.method !== 'HEAD' ? body : undefined,
|
|
326
|
-
duplex: 'half',
|
|
327
|
-
} as RequestInit)
|
|
328
|
-
|
|
329
|
-
const response = await proxy.fetch(request)
|
|
330
|
-
|
|
331
|
-
res.writeHead(response.status, response.statusText, Object.fromEntries(response.headers))
|
|
332
|
-
if (response.body) {
|
|
333
|
-
const reader = response.body.getReader()
|
|
334
|
-
const pump = async (): Promise<void> => {
|
|
335
|
-
const { done, value } = await reader.read()
|
|
336
|
-
if (done) {
|
|
337
|
-
res.end()
|
|
338
|
-
return
|
|
339
|
-
}
|
|
340
|
-
res.write(value)
|
|
341
|
-
return pump()
|
|
342
|
-
}
|
|
343
|
-
await pump()
|
|
344
|
-
}
|
|
345
|
-
else {
|
|
346
|
-
res.end()
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
catch {
|
|
350
|
-
res.writeHead(502)
|
|
351
|
-
res.end('Proxy error')
|
|
352
|
-
}
|
|
353
|
-
})
|
|
354
|
-
},
|
|
355
|
-
|
|
356
|
-
handleConnect(req: IncomingMessage, socket: Socket, head: Buffer) {
|
|
357
|
-
handleConnect(config, req, socket, head)
|
|
358
|
-
},
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* Forward a request to the target URL.
|
|
364
|
-
* Strips proxy-specific headers, preserves the rest.
|
|
365
|
-
*/
|
|
366
|
-
async function forwardRequest(originalReq: Request, targetUrl: string, cachedBody?: ArrayBuffer | null): Promise<Response> {
|
|
367
|
-
const headers = new Headers(originalReq.headers)
|
|
368
|
-
// Remove proxy-specific headers
|
|
369
|
-
headers.delete('proxy-authorization')
|
|
370
|
-
headers.delete('proxy-connection')
|
|
371
|
-
// Don't send host of the proxy
|
|
372
|
-
headers.delete('host')
|
|
373
|
-
|
|
374
|
-
const body = cachedBody && cachedBody.byteLength > 0 ? cachedBody : null
|
|
375
|
-
|
|
376
|
-
try {
|
|
377
|
-
const res = await fetch(targetUrl, {
|
|
378
|
-
method: originalReq.method,
|
|
379
|
-
headers,
|
|
380
|
-
body,
|
|
381
|
-
duplex: 'half',
|
|
382
|
-
redirect: 'manual',
|
|
383
|
-
})
|
|
384
|
-
|
|
385
|
-
// Stream the response back
|
|
386
|
-
const responseHeaders = new Headers(res.headers)
|
|
387
|
-
// Remove hop-by-hop headers
|
|
388
|
-
responseHeaders.delete('transfer-encoding')
|
|
389
|
-
responseHeaders.delete('connection')
|
|
390
|
-
|
|
391
|
-
return new Response(res.body, {
|
|
392
|
-
status: res.status,
|
|
393
|
-
statusText: res.statusText,
|
|
394
|
-
headers: responseHeaders,
|
|
395
|
-
})
|
|
396
|
-
}
|
|
397
|
-
catch (err) {
|
|
398
|
-
const msg = err instanceof Error ? err.message : 'Upstream error'
|
|
399
|
-
return new Response(`Proxy error: ${msg}`, { status: 502 })
|
|
400
|
-
}
|
|
401
|
-
}
|