@blogic-cz/agent-tools 1.6.19 → 1.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/README.md +8 -2
- package/dist/api-tool/client.d.ts +8 -0
- package/dist/api-tool/client.d.ts.map +1 -0
- package/dist/api-tool/errors.d.ts +14 -0
- package/dist/api-tool/errors.d.ts.map +1 -0
- package/dist/api-tool/index.d.ts +3 -0
- package/dist/api-tool/index.d.ts.map +1 -0
- package/dist/api-tool/main.d.ts +2 -0
- package/dist/api-tool/main.d.ts.map +1 -0
- package/dist/api-tool/proxy-entry.d.ts +3 -0
- package/dist/api-tool/proxy-entry.d.ts.map +1 -0
- package/dist/api-tool/proxy.d.ts +15 -0
- package/dist/api-tool/proxy.d.ts.map +1 -0
- package/dist/api-tool/retention.d.ts +9 -0
- package/dist/api-tool/retention.d.ts.map +1 -0
- package/dist/api-tool/server.d.ts +6 -0
- package/dist/api-tool/server.d.ts.map +1 -0
- package/dist/api-tool/store.d.ts +5 -0
- package/dist/api-tool/store.d.ts.map +1 -0
- package/dist/api-tool/transport.d.ts +26 -0
- package/dist/api-tool/transport.d.ts.map +1 -0
- package/dist/api-tool/types.d.ts +136 -0
- package/dist/api-tool/types.d.ts.map +1 -0
- package/dist/credential-guard/index.d.ts.map +1 -1
- package/dist/gh-tool/issue/core.d.ts.map +1 -1
- package/dist/gh-tool/pr/review.d.ts.map +1 -1
- package/docs/rest-api-proxy.md +136 -0
- package/package.json +16 -2
- package/src/api-tool/client.ts +56 -0
- package/src/api-tool/errors.ts +41 -0
- package/src/api-tool/index.ts +4 -0
- package/src/api-tool/main.ts +58 -0
- package/src/api-tool/proxy-entry.ts +19 -0
- package/src/api-tool/proxy.ts +298 -0
- package/src/api-tool/retention.ts +58 -0
- package/src/api-tool/server.ts +114 -0
- package/src/api-tool/store.ts +93 -0
- package/src/api-tool/transport.ts +167 -0
- package/src/api-tool/types.ts +202 -0
- package/src/credential-guard/index.ts +58 -0
- package/src/gh-tool/issue/core.ts +2 -3
- package/src/gh-tool/pr/review.ts +4 -6
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
export const ErrorCode = Schema.Literals([
|
|
4
|
+
"CONFIG_INVALID",
|
|
5
|
+
"CREDENTIAL_UNAVAILABLE",
|
|
6
|
+
"UNAUTHORIZED",
|
|
7
|
+
"POLICY_DENIED",
|
|
8
|
+
"BUDGET_EXHAUSTED",
|
|
9
|
+
"DESTINATION_DENIED",
|
|
10
|
+
"UPSTREAM_FAILED",
|
|
11
|
+
"LIMIT_EXCEEDED",
|
|
12
|
+
"DEADLINE_EXCEEDED",
|
|
13
|
+
"DISCLOSURE_DENIED",
|
|
14
|
+
"GRANT_ALREADY_USED",
|
|
15
|
+
"GRANT_STORE_UNAVAILABLE",
|
|
16
|
+
]);
|
|
17
|
+
export class ApiError extends Schema.TaggedError<ApiError>()("ApiError", {
|
|
18
|
+
code: ErrorCode,
|
|
19
|
+
message: Schema.String,
|
|
20
|
+
hint: Schema.String,
|
|
21
|
+
}) {}
|
|
22
|
+
export type ErrorCode = typeof ErrorCode.Type;
|
|
23
|
+
const hints: Record<ErrorCode, string> = {
|
|
24
|
+
CONFIG_INVALID: "Ask the operator to validate configuration and protected file permissions.",
|
|
25
|
+
CREDENTIAL_UNAVAILABLE: "Ask the operator to provision the profile credential.",
|
|
26
|
+
UNAUTHORIZED: "Obtain a valid unexpired caller grant from the operator.",
|
|
27
|
+
POLICY_DENIED: "Use an operator-approved profile, method and relative path.",
|
|
28
|
+
BUDGET_EXHAUSTED: "Ask the operator for a new grant with an appropriate budget.",
|
|
29
|
+
DESTINATION_DENIED: "Ask the operator to approve the exact HTTPS destination and addresses.",
|
|
30
|
+
UPSTREAM_FAILED: "Check the approved service with the operator. No upstream details are exposed.",
|
|
31
|
+
LIMIT_EXCEEDED: "Reduce request or response size within the configured limits.",
|
|
32
|
+
DEADLINE_EXCEEDED: "The bounded request deadline expired.",
|
|
33
|
+
DISCLOSURE_DENIED: "The response cannot be disclosed under this profile.",
|
|
34
|
+
GRANT_STORE_UNAVAILABLE:
|
|
35
|
+
"Restore protected durable grant storage. Retain existing markers and issue fresh grants after a failed startup.",
|
|
36
|
+
GRANT_ALREADY_USED: "Generate fresh caller grants before restarting. Retain grant-use markers.",
|
|
37
|
+
};
|
|
38
|
+
export const apiError = (code: ErrorCode): ApiError =>
|
|
39
|
+
new ApiError({ code, message: code, hint: hints[code] });
|
|
40
|
+
export const safeError = (error: unknown): ApiError =>
|
|
41
|
+
error instanceof ApiError ? apiError(error.code) : apiError("UPSTREAM_FAILED");
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Command, Flag } from "effect/unstable/cli";
|
|
2
|
+
import { BunRuntime, BunServices } from "@effect/platform-bun";
|
|
3
|
+
import { Console, Effect } from "effect";
|
|
4
|
+
import { createApiClient } from "./client";
|
|
5
|
+
import { decode, ClientSchema, RequestSchema } from "./types";
|
|
6
|
+
import { ApiError, apiError, safeError } from "./errors";
|
|
7
|
+
import type { ApiRequest, ClientConfig } from "./types";
|
|
8
|
+
import { VERSION } from "#shared";
|
|
9
|
+
|
|
10
|
+
const requestCommand = Command.make(
|
|
11
|
+
"request",
|
|
12
|
+
{
|
|
13
|
+
config: Flag.String("config"),
|
|
14
|
+
profile: Flag.String("profile"),
|
|
15
|
+
path: Flag.String("path"),
|
|
16
|
+
method: Flag.String("method").pipe(Flag.withDefault("GET")),
|
|
17
|
+
query: Flag.String("query").pipe(Flag.withDefault("{}")),
|
|
18
|
+
body: Flag.String("body").pipe(Flag.withDefault("")),
|
|
19
|
+
},
|
|
20
|
+
(args) =>
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
const result = yield* Effect.tryPromise({
|
|
23
|
+
try: async () => {
|
|
24
|
+
let config: ClientConfig;
|
|
25
|
+
let request: ApiRequest;
|
|
26
|
+
try {
|
|
27
|
+
config = decode(ClientSchema, JSON.parse(await Bun.file(args.config).text()));
|
|
28
|
+
request = decode(RequestSchema, {
|
|
29
|
+
profile: args.profile,
|
|
30
|
+
method: args.method,
|
|
31
|
+
path: args.path,
|
|
32
|
+
query: JSON.parse(args.query),
|
|
33
|
+
...(args.body ? { body: JSON.parse(args.body) } : {}),
|
|
34
|
+
});
|
|
35
|
+
} catch {
|
|
36
|
+
throw apiError("CONFIG_INVALID");
|
|
37
|
+
}
|
|
38
|
+
return createApiClient(config).request(request);
|
|
39
|
+
},
|
|
40
|
+
catch: safeError,
|
|
41
|
+
});
|
|
42
|
+
yield* Console.log(JSON.stringify(result));
|
|
43
|
+
}),
|
|
44
|
+
).pipe(
|
|
45
|
+
Command.withDescription("Send a JSON REST request through an operator-owned credential proxy"),
|
|
46
|
+
);
|
|
47
|
+
const command = Command.make("api-tool", {}).pipe(Command.withSubcommands([requestCommand]));
|
|
48
|
+
BunRuntime.runMain(
|
|
49
|
+
Command.run(command, { version: VERSION, renderErrors: false }).pipe(
|
|
50
|
+
Effect.provide(BunServices.layer),
|
|
51
|
+
Effect.tapError((error) =>
|
|
52
|
+
Console.error(
|
|
53
|
+
JSON.stringify(error instanceof ApiError ? safeError(error) : apiError("CONFIG_INVALID")),
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
{ disableErrorReporting: true },
|
|
58
|
+
);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { startProxy } from "./server";
|
|
3
|
+
import { readPrivateFile } from "./store";
|
|
4
|
+
import { safeError } from "./errors";
|
|
5
|
+
|
|
6
|
+
if (Bun.argv.length !== 4 || Bun.argv[2] !== "--config") {
|
|
7
|
+
process.stderr.write("Usage: api-proxy --config /operator/private/operator.json\n");
|
|
8
|
+
process.exitCode = 1;
|
|
9
|
+
} else {
|
|
10
|
+
try {
|
|
11
|
+
await startProxy(JSON.parse(await readPrivateFile(Bun.argv[3])));
|
|
12
|
+
process.stderr.write(
|
|
13
|
+
"Credential proxy listening. Verify operator identity separation before production use.\n",
|
|
14
|
+
);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
process.stderr.write(`${JSON.stringify(safeError(error))}\n`);
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
import { apiError, safeError } from "./errors";
|
|
4
|
+
import {
|
|
5
|
+
decode,
|
|
6
|
+
matchesPrefix,
|
|
7
|
+
relativePath,
|
|
8
|
+
RequestSchema,
|
|
9
|
+
validateOperator,
|
|
10
|
+
MAX_PATH_BYTES,
|
|
11
|
+
MAX_RESULT_ENVELOPE_BYTES,
|
|
12
|
+
} from "./types";
|
|
13
|
+
import type { ApiResult, Grant, Profile } from "./types";
|
|
14
|
+
import { createCredentialRetention } from "./retention";
|
|
15
|
+
import { beforeAbort } from "./transport";
|
|
16
|
+
import type { Transport } from "./transport";
|
|
17
|
+
|
|
18
|
+
export const capabilityDigest = (capability: string): string =>
|
|
19
|
+
createHash("sha256").update(capability).digest("hex");
|
|
20
|
+
type Budget = {
|
|
21
|
+
grant: Grant;
|
|
22
|
+
requests: number;
|
|
23
|
+
bytes: number;
|
|
24
|
+
active: number;
|
|
25
|
+
startedAt: number;
|
|
26
|
+
revoked: boolean;
|
|
27
|
+
};
|
|
28
|
+
export function createProxy(options: {
|
|
29
|
+
config: unknown;
|
|
30
|
+
secrets: Readonly<Record<string, string>>;
|
|
31
|
+
transport: Transport;
|
|
32
|
+
now?: () => number;
|
|
33
|
+
}) {
|
|
34
|
+
const config = validateOperator(options.config);
|
|
35
|
+
const now = options.now ?? Date.now;
|
|
36
|
+
const budgets = new Map(
|
|
37
|
+
config.grants.map((grant) => [
|
|
38
|
+
grant.digest,
|
|
39
|
+
{ grant, requests: 0, bytes: 0, active: 0, startedAt: now(), revoked: grant.revoked },
|
|
40
|
+
]),
|
|
41
|
+
);
|
|
42
|
+
let active = 0;
|
|
43
|
+
const secret = (ref: string): string => {
|
|
44
|
+
const value = Object.hasOwn(options.secrets, ref) ? options.secrets[ref] : undefined;
|
|
45
|
+
if (
|
|
46
|
+
!value ||
|
|
47
|
+
value.length > 16384 ||
|
|
48
|
+
/[\uD800-\uDFFF]/u.test(value) ||
|
|
49
|
+
value.includes("\r") ||
|
|
50
|
+
value.includes("\n") ||
|
|
51
|
+
value.includes(String.fromCharCode(0))
|
|
52
|
+
)
|
|
53
|
+
throw apiError("CREDENTIAL_UNAVAILABLE");
|
|
54
|
+
return value;
|
|
55
|
+
};
|
|
56
|
+
const references = (profile: Profile): readonly string[] => {
|
|
57
|
+
switch (profile.auth.kind) {
|
|
58
|
+
case "bearer":
|
|
59
|
+
case "apiKey":
|
|
60
|
+
return [profile.auth.secretRef];
|
|
61
|
+
case "basic":
|
|
62
|
+
return [profile.auth.usernameRef, profile.auth.passwordRef];
|
|
63
|
+
case "login":
|
|
64
|
+
return Object.values(profile.auth.fields);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
if (Object.keys(options.secrets).length > 128) throw apiError("CONFIG_INVALID");
|
|
68
|
+
const initial = Object.keys(options.secrets).map(secret);
|
|
69
|
+
const basicCredentials = new Map<Profile, string>();
|
|
70
|
+
const loginBodies = new Map<Profile, string>();
|
|
71
|
+
for (const profile of Object.values(config.profiles)) {
|
|
72
|
+
for (const ref of references(profile)) secret(ref);
|
|
73
|
+
if (profile.auth.kind === "basic") {
|
|
74
|
+
const encoded = Buffer.from(
|
|
75
|
+
`${secret(profile.auth.usernameRef)}:${secret(profile.auth.passwordRef)}`,
|
|
76
|
+
).toString("base64");
|
|
77
|
+
basicCredentials.set(profile, encoded);
|
|
78
|
+
initial.push(encoded);
|
|
79
|
+
}
|
|
80
|
+
if (profile.auth.kind === "login") {
|
|
81
|
+
const body = JSON.stringify(
|
|
82
|
+
Object.fromEntries(
|
|
83
|
+
Object.entries(profile.auth.fields).map(([field, ref]) => [field, secret(ref)]),
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
if (Buffer.byteLength(profile.auth.path) + Buffer.byteLength(body) > profile.maxRequestBytes)
|
|
87
|
+
throw apiError("CONFIG_INVALID");
|
|
88
|
+
loginBodies.set(profile, body);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const retention = createCredentialRetention(initial);
|
|
92
|
+
const protectedOutput = (result: ApiResult): string => {
|
|
93
|
+
const serialized = JSON.stringify(result);
|
|
94
|
+
if (Buffer.byteLength(serialized) > MAX_RESULT_ENVELOPE_BYTES) throw apiError("LIMIT_EXCEEDED");
|
|
95
|
+
if (retention.protects(serialized)) throw apiError("DISCLOSURE_DENIED");
|
|
96
|
+
return serialized;
|
|
97
|
+
};
|
|
98
|
+
const admit = (budget: Budget, profile: Profile): void => {
|
|
99
|
+
const reserve = profile.maxRequestBytes + profile.maxResponseBytes;
|
|
100
|
+
const total = profile.auth.kind === "login" ? reserve * 2 : reserve;
|
|
101
|
+
if (budget.revoked || now() >= budget.grant.expiresAt) throw apiError("UNAUTHORIZED");
|
|
102
|
+
if (
|
|
103
|
+
now() - budget.startedAt >= budget.grant.lifetimeMs ||
|
|
104
|
+
budget.requests >= budget.grant.maxRequests ||
|
|
105
|
+
budget.bytes + total > budget.grant.maxBytes ||
|
|
106
|
+
budget.active >= budget.grant.maxConcurrent ||
|
|
107
|
+
active >= config.maxConcurrent
|
|
108
|
+
)
|
|
109
|
+
throw apiError("BUDGET_EXHAUSTED");
|
|
110
|
+
budget.requests++;
|
|
111
|
+
budget.bytes += total;
|
|
112
|
+
budget.active++;
|
|
113
|
+
active++;
|
|
114
|
+
};
|
|
115
|
+
const authorize = (capability: string): Budget => {
|
|
116
|
+
const budget = budgets.get(capabilityDigest(capability));
|
|
117
|
+
if (!budget || budget.revoked || now() >= budget.grant.expiresAt)
|
|
118
|
+
throw apiError("UNAUTHORIZED");
|
|
119
|
+
if (now() - budget.startedAt >= budget.grant.lifetimeMs) throw apiError("BUDGET_EXHAUSTED");
|
|
120
|
+
return budget;
|
|
121
|
+
};
|
|
122
|
+
const execute = async (
|
|
123
|
+
capability: string,
|
|
124
|
+
input: unknown,
|
|
125
|
+
): Promise<{ result: ApiResult; serialized: string }> => {
|
|
126
|
+
const budget = authorize(capability);
|
|
127
|
+
if (
|
|
128
|
+
typeof input === "object" &&
|
|
129
|
+
input !== null &&
|
|
130
|
+
"path" in input &&
|
|
131
|
+
typeof input.path === "string" &&
|
|
132
|
+
input.path.length > MAX_PATH_BYTES
|
|
133
|
+
)
|
|
134
|
+
throw apiError("POLICY_DENIED");
|
|
135
|
+
const request = decode(RequestSchema, input);
|
|
136
|
+
const profile = Object.hasOwn(config.profiles, request.profile)
|
|
137
|
+
? config.profiles[request.profile]
|
|
138
|
+
: undefined;
|
|
139
|
+
if (!profile || !budget.grant.profiles.includes(request.profile))
|
|
140
|
+
throw apiError("POLICY_DENIED");
|
|
141
|
+
relativePath(request.path);
|
|
142
|
+
if (
|
|
143
|
+
!(profile.methods ?? ["GET"]).includes(request.method) ||
|
|
144
|
+
!profile.pathPrefixes.some((prefix) => matchesPrefix(request.path, prefix)) ||
|
|
145
|
+
profile.deniedPathPrefixes.some((prefix) =>
|
|
146
|
+
matchesPrefix(request.path.toLowerCase(), prefix.toLowerCase()),
|
|
147
|
+
) ||
|
|
148
|
+
(profile.auth.kind === "login" &&
|
|
149
|
+
matchesPrefix(request.path.toLowerCase(), profile.auth.path.toLowerCase())) ||
|
|
150
|
+
(request.method === "GET" && request.body !== undefined)
|
|
151
|
+
)
|
|
152
|
+
throw apiError("POLICY_DENIED");
|
|
153
|
+
const body = request.body === undefined ? "" : JSON.stringify(request.body);
|
|
154
|
+
const url = new URL(request.path, profile.origin);
|
|
155
|
+
for (const [key, value] of Object.entries(request.query ?? {}))
|
|
156
|
+
url.searchParams.append(key, value);
|
|
157
|
+
if (
|
|
158
|
+
Buffer.byteLength(body) + Buffer.byteLength(url.pathname + url.search) >
|
|
159
|
+
profile.maxRequestBytes
|
|
160
|
+
)
|
|
161
|
+
throw apiError("LIMIT_EXCEEDED");
|
|
162
|
+
admit(budget, profile);
|
|
163
|
+
const controller = new AbortController();
|
|
164
|
+
const timer = setTimeout(
|
|
165
|
+
() => controller.abort(),
|
|
166
|
+
Math.min(
|
|
167
|
+
profile.timeoutMs,
|
|
168
|
+
budget.grant.expiresAt - now(),
|
|
169
|
+
budget.grant.lifetimeMs - (now() - budget.startedAt),
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
try {
|
|
173
|
+
const checkGrant = () => {
|
|
174
|
+
if (budget.revoked || now() >= budget.grant.expiresAt) throw apiError("UNAUTHORIZED");
|
|
175
|
+
if (now() - budget.startedAt >= budget.grant.lifetimeMs) throw apiError("BUDGET_EXHAUSTED");
|
|
176
|
+
};
|
|
177
|
+
const send = (target: URL, method: string, headers: Record<string, string>, data: string) => {
|
|
178
|
+
checkGrant();
|
|
179
|
+
return options.transport({
|
|
180
|
+
url: target,
|
|
181
|
+
method,
|
|
182
|
+
headers: {
|
|
183
|
+
"content-type": "application/json",
|
|
184
|
+
accept: "application/json",
|
|
185
|
+
...headers,
|
|
186
|
+
},
|
|
187
|
+
body: data,
|
|
188
|
+
maxBytes: profile.maxResponseBytes,
|
|
189
|
+
signal: controller.signal,
|
|
190
|
+
allowedPrivateAddresses: profile.allowedPrivateAddresses,
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
const headers: Record<string, string> = {};
|
|
194
|
+
switch (profile.auth.kind) {
|
|
195
|
+
case "bearer":
|
|
196
|
+
headers.authorization = `Bearer ${secret(profile.auth.secretRef)}`;
|
|
197
|
+
break;
|
|
198
|
+
case "apiKey":
|
|
199
|
+
headers[profile.auth.header.toLowerCase()] = secret(profile.auth.secretRef);
|
|
200
|
+
break;
|
|
201
|
+
case "basic": {
|
|
202
|
+
const encoded = basicCredentials.get(profile);
|
|
203
|
+
if (encoded === undefined) throw apiError("CREDENTIAL_UNAVAILABLE");
|
|
204
|
+
headers.authorization = `Basic ${encoded}`;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
case "login": {
|
|
208
|
+
const auth = profile.auth;
|
|
209
|
+
const loginBody = loginBodies.get(profile);
|
|
210
|
+
if (loginBody === undefined) throw apiError("CREDENTIAL_UNAVAILABLE");
|
|
211
|
+
const reservation = retention.reserve();
|
|
212
|
+
let operation: Promise<string>;
|
|
213
|
+
try {
|
|
214
|
+
operation = send(new URL(profile.auth.path, profile.origin), "POST", {}, loginBody)
|
|
215
|
+
.then((result) => {
|
|
216
|
+
if (
|
|
217
|
+
result.status < 200 ||
|
|
218
|
+
result.status >= 300 ||
|
|
219
|
+
Buffer.byteLength(result.body) > profile.maxResponseBytes
|
|
220
|
+
)
|
|
221
|
+
throw apiError("UPSTREAM_FAILED");
|
|
222
|
+
const parsed = decode(
|
|
223
|
+
Schema.Record(Schema.String, Schema.Json),
|
|
224
|
+
JSON.parse(result.body),
|
|
225
|
+
);
|
|
226
|
+
const token = Object.hasOwn(parsed, auth.tokenField)
|
|
227
|
+
? parsed[auth.tokenField]
|
|
228
|
+
: undefined;
|
|
229
|
+
if (
|
|
230
|
+
typeof token !== "string" ||
|
|
231
|
+
!token ||
|
|
232
|
+
token.length > 16384 ||
|
|
233
|
+
/[\uD800-\uDFFF]/u.test(token) ||
|
|
234
|
+
token.includes("\r") ||
|
|
235
|
+
token.includes("\n") ||
|
|
236
|
+
token.includes(String.fromCharCode(0))
|
|
237
|
+
)
|
|
238
|
+
throw apiError("UPSTREAM_FAILED");
|
|
239
|
+
reservation.retain(token);
|
|
240
|
+
return token;
|
|
241
|
+
})
|
|
242
|
+
.finally(reservation.release);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
reservation.release();
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
headers.authorization = `Bearer ${await beforeAbort(operation, controller.signal)}`;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const response = await beforeAbort(
|
|
252
|
+
send(url, request.method, headers, body),
|
|
253
|
+
controller.signal,
|
|
254
|
+
);
|
|
255
|
+
if (response.status < 200 || response.status >= 300) throw apiError("UPSTREAM_FAILED");
|
|
256
|
+
if (Buffer.byteLength(response.body) > profile.maxResponseBytes)
|
|
257
|
+
throw apiError("LIMIT_EXCEEDED");
|
|
258
|
+
if (controller.signal.aborted) throw apiError("DEADLINE_EXCEEDED");
|
|
259
|
+
let data = response.body === "" ? null : decode(Schema.Json, JSON.parse(response.body));
|
|
260
|
+
if (profile.disclosure.kind === "fields" && response.body !== "") {
|
|
261
|
+
let object: Readonly<Record<string, typeof Schema.Json.Type>>;
|
|
262
|
+
try {
|
|
263
|
+
object = decode(Schema.Record(Schema.String, Schema.Json), data);
|
|
264
|
+
} catch {
|
|
265
|
+
throw apiError("DISCLOSURE_DENIED");
|
|
266
|
+
}
|
|
267
|
+
data = Object.fromEntries(
|
|
268
|
+
profile.disclosure.fields
|
|
269
|
+
.filter((field) => Object.hasOwn(object, field))
|
|
270
|
+
.map((field) => [field, object[field]]),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
const result = { status: response.status, data };
|
|
274
|
+
const serialized = protectedOutput(result);
|
|
275
|
+
checkGrant();
|
|
276
|
+
return { result, serialized };
|
|
277
|
+
} catch (error) {
|
|
278
|
+
throw safeError(error);
|
|
279
|
+
} finally {
|
|
280
|
+
clearTimeout(timer);
|
|
281
|
+
budget.active--;
|
|
282
|
+
active--;
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
return {
|
|
286
|
+
authorize: (capability: string): void => {
|
|
287
|
+
authorize(capability);
|
|
288
|
+
},
|
|
289
|
+
revoke: (digest: string): void => {
|
|
290
|
+
const budget = budgets.get(digest);
|
|
291
|
+
if (budget) budget.revoked = true;
|
|
292
|
+
},
|
|
293
|
+
request: async (capability: string, input: unknown): Promise<ApiResult> =>
|
|
294
|
+
(await execute(capability, input)).result,
|
|
295
|
+
requestSerialized: async (capability: string, input: unknown): Promise<string> =>
|
|
296
|
+
(await execute(capability, input)).serialized,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { apiError } from "./errors";
|
|
2
|
+
|
|
3
|
+
export const MAX_RETAINED_CREDENTIALS = 128;
|
|
4
|
+
type RetainedCredential = {
|
|
5
|
+
readonly exact: readonly string[];
|
|
6
|
+
readonly urlEncoded: readonly string[];
|
|
7
|
+
};
|
|
8
|
+
export function createCredentialRetention(initial: readonly string[]) {
|
|
9
|
+
const values = new Map<string, RetainedCredential>();
|
|
10
|
+
let reserved = 0;
|
|
11
|
+
const add = (value: string): void => {
|
|
12
|
+
if (values.has(value)) return;
|
|
13
|
+
if (!value || value.length > 131_072 || values.size >= MAX_RETAINED_CREDENTIALS)
|
|
14
|
+
throw apiError("CONFIG_INVALID");
|
|
15
|
+
if (/[\uD800-\uDFFF]/u.test(value)) throw apiError("CONFIG_INVALID");
|
|
16
|
+
const base64 = Buffer.from(value).toString("base64").replace(/=+$/, "");
|
|
17
|
+
const variants = [value, base64, Buffer.from(value).toString("base64url")];
|
|
18
|
+
values.set(value, {
|
|
19
|
+
exact: variants.flatMap((encoded) => [encoded, JSON.stringify(encoded).slice(1, -1)]),
|
|
20
|
+
urlEncoded: [
|
|
21
|
+
encodeURIComponent(value),
|
|
22
|
+
new URLSearchParams({ "": value }).toString().slice(1),
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
for (const value of initial) add(value);
|
|
27
|
+
return {
|
|
28
|
+
protects: (serialized: string): boolean => {
|
|
29
|
+
for (const { exact } of values.values()) {
|
|
30
|
+
if (exact.some((value) => serialized.includes(value))) return true;
|
|
31
|
+
}
|
|
32
|
+
const canonical = serialized.replace(/%[0-9a-f]{2}/gi, (escape) => escape.toUpperCase());
|
|
33
|
+
for (const { urlEncoded } of values.values()) {
|
|
34
|
+
if (urlEncoded.some((value) => canonical.includes(value))) return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
},
|
|
38
|
+
reserve: () => {
|
|
39
|
+
if (values.size + reserved >= MAX_RETAINED_CREDENTIALS) throw apiError("BUDGET_EXHAUSTED");
|
|
40
|
+
reserved++;
|
|
41
|
+
let pending = true;
|
|
42
|
+
const release = (): void => {
|
|
43
|
+
if (pending) {
|
|
44
|
+
pending = false;
|
|
45
|
+
reserved--;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
release,
|
|
50
|
+
retain: (value: string): void => {
|
|
51
|
+
if (!pending) throw apiError("BUDGET_EXHAUSTED");
|
|
52
|
+
add(value);
|
|
53
|
+
release();
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createServer as createHttpServer } from "node:http";
|
|
2
|
+
import { createServer as createHttpsServer } from "node:https";
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
4
|
+
import { createProxy } from "./proxy";
|
|
5
|
+
import { pinnedTransport } from "./transport";
|
|
6
|
+
import type { Transport } from "./transport";
|
|
7
|
+
import { apiError, safeError } from "./errors";
|
|
8
|
+
import { claimGrants, readPrivateFile, readSecretStore } from "./store";
|
|
9
|
+
import { validateOperator } from "./types";
|
|
10
|
+
import type { OperatorConfig } from "./types";
|
|
11
|
+
|
|
12
|
+
export function createRequestHandler(proxy: ReturnType<typeof createProxy>, maxConcurrent: number) {
|
|
13
|
+
let active = 0;
|
|
14
|
+
return (request: IncomingMessage, response: ServerResponse): void => {
|
|
15
|
+
const failure = (error: unknown): void => {
|
|
16
|
+
if (!response.destroyed) {
|
|
17
|
+
response.writeHead(400, {
|
|
18
|
+
"content-type": "application/json",
|
|
19
|
+
"cache-control": "no-store",
|
|
20
|
+
});
|
|
21
|
+
response.end(JSON.stringify({ code: safeError(error).code }));
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
if (
|
|
25
|
+
request.method !== "POST" ||
|
|
26
|
+
request.url !== "/request" ||
|
|
27
|
+
request.headers["content-type"] !== "application/json" ||
|
|
28
|
+
request.headers["content-encoding"] !== undefined ||
|
|
29
|
+
active >= maxConcurrent
|
|
30
|
+
) {
|
|
31
|
+
request.resume();
|
|
32
|
+
failure(apiError("POLICY_DENIED"));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const auth = request.headers.authorization;
|
|
36
|
+
if (!auth || !/^Bearer [A-Za-z0-9_-]{32,256}$/.test(auth)) {
|
|
37
|
+
request.resume();
|
|
38
|
+
failure(apiError("UNAUTHORIZED"));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
proxy.authorize(auth.slice(7));
|
|
43
|
+
} catch (error) {
|
|
44
|
+
request.resume();
|
|
45
|
+
failure(error);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
active++;
|
|
49
|
+
const run = async (): Promise<void> => {
|
|
50
|
+
try {
|
|
51
|
+
const chunks: Buffer[] = [];
|
|
52
|
+
let bytes = 0;
|
|
53
|
+
for await (const chunk of request) {
|
|
54
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
55
|
+
bytes += buffer.length;
|
|
56
|
+
if (bytes > 1_000_000) throw apiError("LIMIT_EXCEEDED");
|
|
57
|
+
chunks.push(buffer);
|
|
58
|
+
}
|
|
59
|
+
let input: unknown;
|
|
60
|
+
try {
|
|
61
|
+
input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
62
|
+
} catch {
|
|
63
|
+
throw apiError("CONFIG_INVALID");
|
|
64
|
+
}
|
|
65
|
+
const serialized = await proxy.requestSerialized(auth.slice(7), input);
|
|
66
|
+
response.writeHead(200, {
|
|
67
|
+
"content-type": "application/json",
|
|
68
|
+
"cache-control": "no-store",
|
|
69
|
+
});
|
|
70
|
+
response.end(serialized);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
failure(error);
|
|
73
|
+
} finally {
|
|
74
|
+
active--;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
void run();
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function startProxy(configValue: unknown, transport: Transport = pinnedTransport) {
|
|
82
|
+
const config: OperatorConfig = validateOperator(configValue);
|
|
83
|
+
const secrets = await readSecretStore(config.secretsFile);
|
|
84
|
+
const proxy = createProxy({ config, secrets, transport });
|
|
85
|
+
const handler = createRequestHandler(proxy, config.maxConcurrent);
|
|
86
|
+
const server =
|
|
87
|
+
config.listen.kind === "tls"
|
|
88
|
+
? createHttpsServer(
|
|
89
|
+
{
|
|
90
|
+
cert: await readPrivateFile(config.listen.certFile),
|
|
91
|
+
key: await readPrivateFile(config.listen.keyFile),
|
|
92
|
+
minVersion: "TLSv1.2",
|
|
93
|
+
},
|
|
94
|
+
handler,
|
|
95
|
+
)
|
|
96
|
+
: createHttpServer(handler);
|
|
97
|
+
server.requestTimeout = 10000;
|
|
98
|
+
server.headersTimeout = 5000;
|
|
99
|
+
server.timeout = 70000;
|
|
100
|
+
server.maxConnections = config.maxConcurrent * 2;
|
|
101
|
+
await claimGrants(config);
|
|
102
|
+
try {
|
|
103
|
+
await new Promise<void>((resolve, reject) => {
|
|
104
|
+
server.once("error", reject);
|
|
105
|
+
server.listen(config.listen.port, config.listen.host, () => {
|
|
106
|
+
server.removeListener("error", reject);
|
|
107
|
+
resolve();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
} catch {
|
|
111
|
+
throw apiError("CONFIG_INVALID");
|
|
112
|
+
}
|
|
113
|
+
return server;
|
|
114
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open, lstat } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { Schema } from "effect";
|
|
5
|
+
import { apiError } from "./errors";
|
|
6
|
+
import { decode } from "./types";
|
|
7
|
+
import type { OperatorConfig } from "./types";
|
|
8
|
+
|
|
9
|
+
async function privateDirectory(path: string): Promise<void> {
|
|
10
|
+
const uid = process.getuid?.();
|
|
11
|
+
if (uid === undefined || !isAbsolute(path)) throw apiError("CONFIG_INVALID");
|
|
12
|
+
const stat = await lstat(path);
|
|
13
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== uid || (stat.mode & 0o077) !== 0)
|
|
14
|
+
throw apiError("CONFIG_INVALID");
|
|
15
|
+
}
|
|
16
|
+
export async function readPrivateFile(path: string): Promise<string> {
|
|
17
|
+
try {
|
|
18
|
+
await privateDirectory(dirname(path));
|
|
19
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
20
|
+
try {
|
|
21
|
+
const stat = await file.stat();
|
|
22
|
+
if (
|
|
23
|
+
!stat.isFile() ||
|
|
24
|
+
stat.uid !== process.getuid?.() ||
|
|
25
|
+
(stat.mode & 0o077) !== 0 ||
|
|
26
|
+
stat.size > 1_000_000
|
|
27
|
+
)
|
|
28
|
+
throw apiError("CONFIG_INVALID");
|
|
29
|
+
return await file.readFile("utf8");
|
|
30
|
+
} finally {
|
|
31
|
+
await file.close();
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
throw apiError("CONFIG_INVALID");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function readSecretStore(path: string): Promise<Readonly<Record<string, string>>> {
|
|
38
|
+
try {
|
|
39
|
+
const secrets = decode(
|
|
40
|
+
Schema.Record(Schema.String, Schema.String),
|
|
41
|
+
JSON.parse(await readPrivateFile(path)),
|
|
42
|
+
);
|
|
43
|
+
if (
|
|
44
|
+
Object.values(secrets).some(
|
|
45
|
+
(value) =>
|
|
46
|
+
!value ||
|
|
47
|
+
value.length > 16384 ||
|
|
48
|
+
value.includes("\r") ||
|
|
49
|
+
value.includes("\n") ||
|
|
50
|
+
value.includes(String.fromCharCode(0)),
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
throw apiError("CREDENTIAL_UNAVAILABLE");
|
|
54
|
+
return secrets;
|
|
55
|
+
} catch {
|
|
56
|
+
throw apiError("CREDENTIAL_UNAVAILABLE");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export async function claimGrants(config: OperatorConfig): Promise<void> {
|
|
60
|
+
try {
|
|
61
|
+
await privateDirectory(config.grantUseDirectory);
|
|
62
|
+
await Promise.all(
|
|
63
|
+
config.grants.map(async (grant) => {
|
|
64
|
+
const file = await open(
|
|
65
|
+
join(config.grantUseDirectory, grant.digest),
|
|
66
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
67
|
+
0o600,
|
|
68
|
+
);
|
|
69
|
+
try {
|
|
70
|
+
await file.writeFile("used\n");
|
|
71
|
+
await file.sync();
|
|
72
|
+
} finally {
|
|
73
|
+
await file.close();
|
|
74
|
+
}
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
const directory = await open(
|
|
78
|
+
config.grantUseDirectory,
|
|
79
|
+
constants.O_RDONLY | constants.O_DIRECTORY,
|
|
80
|
+
);
|
|
81
|
+
try {
|
|
82
|
+
await directory.sync();
|
|
83
|
+
} finally {
|
|
84
|
+
await directory.close();
|
|
85
|
+
}
|
|
86
|
+
} catch (error) {
|
|
87
|
+
throw apiError(
|
|
88
|
+
typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST"
|
|
89
|
+
? "GRANT_ALREADY_USED"
|
|
90
|
+
: "GRANT_STORE_UNAVAILABLE",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|