@jr2/orchestrator 0.2.0 → 0.3.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/package.json +3 -4
- package/src/actor.ts +12 -7
- package/src/agent.ts +2 -2
- package/src/config.ts +119 -14
- package/src/custodian.lua +312 -0
- package/src/custodian.ts +899 -0
- package/src/harness-client.ts +30 -10
- package/src/held-secrets.ts +587 -0
- package/src/http.ts +25 -19
- package/src/images.ts +11 -10
- package/src/index.ts +6 -2
- package/src/instance.ts +13 -10
- package/src/model-keys.ts +92 -0
- package/src/names.ts +30 -6
- package/src/registration.ts +11 -3
- package/src/repo-fetch.ts +1 -1
- package/src/run-host.ts +34 -21
- package/src/sandbox-kubectl.ts +82 -110
- package/src/server.ts +5 -17
- package/src/tokens.ts +40 -9
- package/src/workspace.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jr2/orchestrator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The jr2 Orchestrator: xstate runtime, HTTP API and Console, and the kit pieces a Workflow imports.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"private": false,
|
|
@@ -35,18 +35,17 @@
|
|
|
35
35
|
"types": "./src/index.ts",
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@hono/node-server": "^2.0.6",
|
|
38
|
-
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
39
38
|
"elkjs": "^0.11.1",
|
|
40
39
|
"hono": "^4.12.27",
|
|
41
40
|
"preact": "^10.29.8",
|
|
42
41
|
"ts-blank-space": "^0.9.0",
|
|
43
42
|
"xstate": "^5.18.0",
|
|
44
43
|
"zod": "^4.4.3",
|
|
45
|
-
"@jr2/agent-protocol": "0.
|
|
44
|
+
"@jr2/agent-protocol": "0.3.0"
|
|
46
45
|
},
|
|
47
46
|
"devDependencies": {
|
|
48
47
|
"@types/node": "^26.0.1",
|
|
49
|
-
"@jr2/harness": "0.
|
|
48
|
+
"@jr2/harness": "0.3.0"
|
|
50
49
|
},
|
|
51
50
|
"files": [
|
|
52
51
|
"bin",
|
package/src/actor.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// 1. REGISTERS the invocation's event surface: `tools` names are resolved against the
|
|
5
5
|
// INVOKING MACHINE's vocabulary (per-Machine scoping — an unlisted name fails at invoke time)
|
|
6
6
|
// and registered in the host's table under the instance's agent address, with a deliver
|
|
7
|
-
// closure over THIS invocation's `sendBack`. The Agent's domain tool calls arrive
|
|
8
|
-
//
|
|
7
|
+
// closure over THIS invocation's `sendBack`. The Agent's domain tool calls arrive through its
|
|
8
|
+
// pod's Custodian (`/agents/<iid>/events` — ADR-0013), are validated by the table, and land on the
|
|
9
9
|
// state that invoked the agent — at any nesting depth, no routing, no `instanceId` on
|
|
10
10
|
// domain events (the closure IS the provenance). `/agents/<iid>/surface` serves exactly
|
|
11
11
|
// this registration, so menus are state-scoped by lifecycle (ADR-0006's dynamic
|
|
@@ -150,7 +150,7 @@ export type AgentTurnInput = {
|
|
|
150
150
|
export type AgentTurnPlacement = {
|
|
151
151
|
/** The Harness base URL this Turn is admitted over. */
|
|
152
152
|
endpoint?: string;
|
|
153
|
-
/** The Sandbox whose
|
|
153
|
+
/** The Sandbox whose token (held by that pod's Custodian) may deliver this Turn's picks (ADR-0013). */
|
|
154
154
|
sandbox?: string;
|
|
155
155
|
};
|
|
156
156
|
|
|
@@ -257,8 +257,10 @@ export interface AgentRunPort {
|
|
|
257
257
|
* Machine edit that changed it. */
|
|
258
258
|
export type AgentAdmitOptions = { definition: AgentDefinition; signal?: AbortSignal };
|
|
259
259
|
|
|
260
|
-
/** Build a port for one invocation from its serializable input (ADR-0011 static-import doctrine)
|
|
261
|
-
|
|
260
|
+
/** Build a port for one invocation from its serializable input (ADR-0011 static-import doctrine),
|
|
261
|
+
* plus the Harness bearer for the placement that input resolved to (ADR-0058) — absent where no
|
|
262
|
+
* placement is named (the stub path) or the host derives none. */
|
|
263
|
+
export type AgentRunPortFactory = (endpoint: string, bearer?: string) => AgentRunPort;
|
|
262
264
|
|
|
263
265
|
/** Absorbed-turn-mechanics knobs (ADR-0016): defaulted, never Machine context. */
|
|
264
266
|
export type AgentRunOptions = {
|
|
@@ -433,7 +435,7 @@ export function agentActorWith(
|
|
|
433
435
|
}
|
|
434
436
|
endpoint = binding.instanceHarness;
|
|
435
437
|
// The registration records the PLACEMENT's name as its delivery scope — ADR-0013's
|
|
436
|
-
// doctrine, extended to the second placement: the Instance Harness
|
|
438
|
+
// doctrine, extended to the second placement: the Instance Harness's Custodian holds a token
|
|
437
439
|
// signed for this name (deploy.ts), so it may speak for the Turns hosted there and for no
|
|
438
440
|
// Workspace's. The Instance token still may (it is the operator, tokens.ts).
|
|
439
441
|
sandbox = INSTANCE_HARNESS_SERVICE;
|
|
@@ -504,7 +506,10 @@ export function agentActorWith(
|
|
|
504
506
|
);
|
|
505
507
|
registerSurface(currentIid);
|
|
506
508
|
|
|
507
|
-
|
|
509
|
+
// The Harness bearer (ADR-0058) is derived from the SAME name the registration records as its
|
|
510
|
+
// delivery scope: the pod hosting this Turn. One name, both directions — the Custodian there may
|
|
511
|
+
// speak for these picks, and only this bearer may drive the conversation there.
|
|
512
|
+
const client = portFactory(endpoint, sandbox === undefined ? undefined : binding.harnessBearer?.(sandbox));
|
|
508
513
|
const controller = new AbortController();
|
|
509
514
|
// Shared per run, created on demand so the ordering guarantee holds for any binding.
|
|
510
515
|
const pendingAborts = (binding.pendingAborts ??= new Map<string, Promise<void>>());
|
package/src/agent.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The Agent definition (ADR-0018/0027): the part of an Agent a user genuinely owns — model +
|
|
2
|
-
// instructions + workspace access — as SERIALIZABLE DATA. Everything mechanical (the
|
|
3
|
-
//
|
|
2
|
+
// instructions + workspace access — as SERIALIZABLE DATA. Everything mechanical (the Menu,
|
|
3
|
+
// the Working-tool assembly, the wire) lives in the stock Harness image (`@jr2/harness`),
|
|
4
4
|
// which runs the definition it is handed and re-reads it per Submission.
|
|
5
5
|
//
|
|
6
6
|
// A definition is NOT an Instance roster entry: a Machine CARRIES it as an actor slot —
|
package/src/config.ts
CHANGED
|
@@ -105,6 +105,8 @@ export type HarnessEnvVar = {
|
|
|
105
105
|
/** A whole-Secret/ConfigMap env injection (corev1.EnvFromSource) for the Harness container —
|
|
106
106
|
* e.g. `{ secretRef: { name: "anthropic" } }` to hand a real Harness its model API key. */
|
|
107
107
|
export type HarnessEnvFromSource = {
|
|
108
|
+
/** Put before every key's name, as corev1 does. */
|
|
109
|
+
prefix?: string;
|
|
108
110
|
secretRef?: { name: string };
|
|
109
111
|
configMapRef?: { name: string };
|
|
110
112
|
};
|
|
@@ -136,8 +138,9 @@ export type HarnessProvider = {
|
|
|
136
138
|
/** The endpoint — reachable FROM PODS (`localhost` never is; a LAN address works on kind).
|
|
137
139
|
* Deployment-varying → resolve from env (`.env`), never hardcode (ADR-0019). */
|
|
138
140
|
baseUrl: string;
|
|
139
|
-
/** API key, when the endpoint wants one. May read `process.env
|
|
140
|
-
*
|
|
141
|
+
/** API key, when the endpoint wants one. May read `process.env`. It is a held secret
|
|
142
|
+
* (ADR-0059): `jr2 up` gives the value to the Custodian alone, bound to `baseUrl`'s host, and the
|
|
143
|
+
* Harness container holds only its Stand-in. So `baseUrl` must be `https://` when this is set.
|
|
141
144
|
* Genuinely optional: an unauthenticated endpoint needs none (the Harness sends the wire
|
|
142
145
|
* library's placeholder, which vLLM/Ollama ignore). */
|
|
143
146
|
apiKey?: string;
|
|
@@ -153,6 +156,41 @@ export type HarnessProvider = {
|
|
|
153
156
|
models?: Record<string, HarnessProviderModel>;
|
|
154
157
|
};
|
|
155
158
|
|
|
159
|
+
/** Where one of pi's catalog providers sends its calls (ADR-0059) — reach, not a model choice
|
|
160
|
+
* (ADR-0018). The catalog keeps every model fact (limits, cost, thinking levels); this changes only
|
|
161
|
+
* the endpoint, for a gateway that serves the provider's own API (LiteLLM, for example). */
|
|
162
|
+
export type CatalogReach = {
|
|
163
|
+
/** Replaces `model.baseUrl` for every model of this provider — the WHOLE base the provider's API
|
|
164
|
+
* appends to (`https://litellm.corp.example` for `anthropic`, which appends `/v1/messages`;
|
|
165
|
+
* `https://litellm.corp.example/v1` for `openai`). HTTPS only. Deployment-varying → `.env`. */
|
|
166
|
+
baseUrl: string;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** A credential the Harness uses and never holds (ADR-0059). The Harness container's env carries
|
|
170
|
+
* its Stand-in, `jr2-held-<name>`; the Custodian puts the value into requests toward `hosts` alone. */
|
|
171
|
+
export type HeldSecret = {
|
|
172
|
+
/** The env var the Harness container sees — `[A-Z_][A-Z0-9_]*`. It holds the Stand-in. */
|
|
173
|
+
name: string;
|
|
174
|
+
/** The value as a literal, usually read from `process.env`/`.env`. `jr2 up` writes it into the
|
|
175
|
+
* `jr2-held-secrets` Secret, which only the Custodian mounts. Exactly one of `value`/`valueFrom`. */
|
|
176
|
+
value?: string;
|
|
177
|
+
/** Or a key of a Secret you manage (Sealed Secrets, External Secrets). `jr2 up` checks that the
|
|
178
|
+
* Secret and key exist; the Custodian mounts that one key as a file, and no other container does. */
|
|
179
|
+
valueFrom?: { secretKeyRef: { name: string; key: string } };
|
|
180
|
+
/** Where the value may go. Each entry is `host`, `host:port`, or an `https://` URL (its host and
|
|
181
|
+
* port are taken; the path is ignored). Exact DNS names or IP literals; no wildcards. Port
|
|
182
|
+
* default 443. Optional ONLY for a known model-key name (`MODEL_KEYS`), where it defaults to the
|
|
183
|
+
* one host that provider's calls go to. */
|
|
184
|
+
hosts?: readonly string[];
|
|
185
|
+
/** Request headers the Stand-in is swapped in, lowercase. Default
|
|
186
|
+
* `["authorization", "x-api-key", "x-goog-api-key", "api-key"]`. */
|
|
187
|
+
headers?: readonly string[];
|
|
188
|
+
/** Optional path prefixes the value may be sent to (`/v1/messages`). Absent → every path. A
|
|
189
|
+
* gateway key that can reach admin routes (a LiteLLM key that can mint keys) should narrow this:
|
|
190
|
+
* a key minted through the Custodian is a key the Agent can take away. */
|
|
191
|
+
paths?: readonly string[];
|
|
192
|
+
};
|
|
193
|
+
|
|
156
194
|
/** The agent-runtime section (ADR-0018): what the stock Harness image consumes alongside the
|
|
157
195
|
* Agent definition each Turn hands it (ADR-0049). Moved out of `sandbox` deliberately — `sandbox`
|
|
158
196
|
* is pod transport (it still CARRIES this env to the Harness container), but model concerns are
|
|
@@ -164,20 +202,29 @@ export type HarnessProvider = {
|
|
|
164
202
|
* One definition value may still be carried by several Machines, so the variation that matters is
|
|
165
203
|
* per-definition and per-invocation, which one global default serves not at all. */
|
|
166
204
|
export type HarnessConfig = {
|
|
167
|
-
/** Custom model provider, preflighted from inside the cluster by `jr2 up` (ADR-0019).
|
|
205
|
+
/** Custom model provider, preflighted from inside the cluster by `jr2 up` (ADR-0019). Its
|
|
206
|
+
* `apiKey` is a held secret, `JR2_PROVIDER_API_KEY`, bound to `baseUrl`'s host (ADR-0059). */
|
|
168
207
|
provider?: HarnessProvider;
|
|
169
|
-
/**
|
|
170
|
-
* `
|
|
208
|
+
/** pi catalog providers whose calls go somewhere else, keyed by the catalog provider id
|
|
209
|
+
* (`anthropic`, `openai`, ...) — a gateway that serves the provider's own API (ADR-0059). */
|
|
210
|
+
catalog?: Readonly<Record<string, CatalogReach>>;
|
|
211
|
+
/** Credentials the Harness uses and never holds (ADR-0059): the Harness container sees a
|
|
212
|
+
* Stand-in, and the Custodian puts the value into requests toward the bound hosts alone. A model
|
|
213
|
+
* API key belongs here, never in `env`. */
|
|
214
|
+
heldSecrets?: readonly HeldSecret[];
|
|
215
|
+
/** Env vars for the Harness container. Values read from `process.env`/`.env` are materialized
|
|
216
|
+
* into the instance-owned Secret by `jr2 up`. The Agent can read every one of them, so a known
|
|
217
|
+
* model key here is refused — it goes in `heldSecrets` (ADR-0059). */
|
|
171
218
|
env?: readonly HarnessEnvVar[];
|
|
172
219
|
/** Whole-Secret/ConfigMap env for the Harness container — `envFrom` refs to Secrets YOU manage
|
|
173
|
-
* (Sealed Secrets etc.); `jr2 up` preflights that each referenced Secret exists (ADR-0019)
|
|
220
|
+
* (Sealed Secrets etc.); `jr2 up` preflights that each referenced Secret exists (ADR-0019), and
|
|
221
|
+
* refuses one that carries a known model key (ADR-0059). */
|
|
174
222
|
envFrom?: readonly HarnessEnvFromSource[];
|
|
175
223
|
/** Path to a PEM CA bundle, RELATIVE to the instance folder — commit the file (CA certs are
|
|
176
224
|
* public; e.g. an internal CA in front of a LAN vLLM). Only `jr2 up` reads it (host-side): it
|
|
177
225
|
* materializes the `jr2-ca` ConfigMap and runs the provider preflight with the same trust. The
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
* ADR-0013/0020). */
|
|
226
|
+
* Harness container trusts it, and so does the Custodian when it verifies a bound host
|
|
227
|
+
* (ADR-0020, ADR-0059). */
|
|
181
228
|
caBundle?: string;
|
|
182
229
|
};
|
|
183
230
|
|
|
@@ -231,7 +278,7 @@ export type JR2Config = {
|
|
|
231
278
|
*
|
|
232
279
|
* Separate from `registry` on purpose: `registry` addresses images THIS converge builds,
|
|
233
280
|
* `kitRegistry` addresses artifacts the kit already published. One key for both would make every
|
|
234
|
-
* private-registry user mirror
|
|
281
|
+
* private-registry user mirror the Kit images they could have pulled from the home. */
|
|
235
282
|
kitRegistry?: string;
|
|
236
283
|
/** What `jr2 up` builds its images FOR — docker platform strings, e.g. `["linux/arm64"]`
|
|
237
284
|
* (deployment-varying — resolve from env). Absent → derived from the cluster's schedulable nodes
|
|
@@ -260,10 +307,13 @@ export function defineConfig(c: JR2Config): JR2Config {
|
|
|
260
307
|
/**
|
|
261
308
|
* Load an instance's `jr2.config.ts` (default export). Absent file → undefined (an instance
|
|
262
309
|
* can boot configless); a file that fails to IMPORT throws — a broken config must be loud,
|
|
263
|
-
* never silently treated as "no config". The
|
|
264
|
-
*
|
|
265
|
-
* instance is zero-build and nothing
|
|
266
|
-
* imports it.
|
|
310
|
+
* never silently treated as "no config". The shape checks live here, on the path the host CLI
|
|
311
|
+
* and the in-cluster Orchestrator share: `git.credentials`, `harness.heldSecrets` and
|
|
312
|
+
* `harness.catalog` are checked at runtime, because an instance is zero-build and nothing
|
|
313
|
+
* typechecks this file before Node strips its types and imports it. SHAPE only: a held secret's
|
|
314
|
+
* `value` reads `.env`, which the in-cluster evaluation does not have, so an absent value is not
|
|
315
|
+
* this function's to refuse — `jr2 up` checks presence and every held-secret rule host-side
|
|
316
|
+
* (`heldSecretsOf`, ADR-0059).
|
|
267
317
|
*/
|
|
268
318
|
export async function loadConfig(dir: string): Promise<JR2Config | undefined> {
|
|
269
319
|
const file = join(dir, "jr2.config.ts");
|
|
@@ -271,6 +321,7 @@ export async function loadConfig(dir: string): Promise<JR2Config | undefined> {
|
|
|
271
321
|
const mod = (await import(pathToFileURL(file).href)) as { default?: JR2Config };
|
|
272
322
|
if (!mod.default) throw new Error(`${file} has no default export (use \`export default defineConfig({…})\`)`);
|
|
273
323
|
if (mod.default.git !== undefined) checkGit(mod.default.git, file);
|
|
324
|
+
if (mod.default.harness !== undefined) checkHeld(mod.default.harness, file);
|
|
274
325
|
return mod.default;
|
|
275
326
|
}
|
|
276
327
|
|
|
@@ -295,3 +346,57 @@ function checkGit(git: unknown, where: string): void {
|
|
|
295
346
|
}
|
|
296
347
|
|
|
297
348
|
const GIT_HINT = "an entry is { match, token?, sshKey? } (ADR-0051)";
|
|
349
|
+
|
|
350
|
+
const OptionalText = z.string().optional();
|
|
351
|
+
const HeldSecretShape = z
|
|
352
|
+
.object({
|
|
353
|
+
name: NonEmpty,
|
|
354
|
+
// `.env` values are absent in-cluster, so `undefined` must pass here — presence is `jr2 up`'s.
|
|
355
|
+
value: OptionalText,
|
|
356
|
+
valueFrom: z
|
|
357
|
+
.object({ secretKeyRef: z.object({ name: NonEmpty, key: NonEmpty }).strict() })
|
|
358
|
+
.strict()
|
|
359
|
+
.optional(),
|
|
360
|
+
hosts: z.array(z.string()).optional(),
|
|
361
|
+
headers: z.array(z.string()).optional(),
|
|
362
|
+
paths: z.array(z.string()).optional(),
|
|
363
|
+
})
|
|
364
|
+
.strict();
|
|
365
|
+
const CatalogReachShape = z.object({ baseUrl: z.string() }).strict();
|
|
366
|
+
|
|
367
|
+
/** `harness.heldSecrets` and `harness.catalog`, SHAPE only (ADR-0059) — by index and field, the
|
|
368
|
+
* `checkGit` style, so a bad entry names itself (`harness.heldSecrets[1].hosts`). */
|
|
369
|
+
function checkHeld(harness: unknown, where: string): void {
|
|
370
|
+
if (typeof harness !== "object" || harness === null || Array.isArray(harness)) {
|
|
371
|
+
throw new Error(`${where} at harness: expected an object`);
|
|
372
|
+
}
|
|
373
|
+
const { heldSecrets, catalog } = harness as { heldSecrets?: unknown; catalog?: unknown };
|
|
374
|
+
if (heldSecrets !== undefined) {
|
|
375
|
+
if (!Array.isArray(heldSecrets)) {
|
|
376
|
+
throw new Error(`${where} at harness.heldSecrets: expected an array — ${HELD_HINT}`);
|
|
377
|
+
}
|
|
378
|
+
for (const [i, entry] of heldSecrets.entries()) {
|
|
379
|
+
const parsed = HeldSecretShape.safeParse(entry);
|
|
380
|
+
if (parsed.success) continue;
|
|
381
|
+
const issue = parsed.error.issues[0];
|
|
382
|
+
const at = ["", ...(issue?.path ?? [])].map(String).join(".");
|
|
383
|
+
throw new Error(`${where} at harness.heldSecrets[${i}]${at}: ${issue?.message ?? "invalid"} — ${HELD_HINT}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (catalog !== undefined) {
|
|
387
|
+
if (typeof catalog !== "object" || catalog === null || Array.isArray(catalog)) {
|
|
388
|
+
throw new Error(`${where} at harness.catalog: expected an object keyed by catalog provider id — ${CATALOG_HINT}`);
|
|
389
|
+
}
|
|
390
|
+
for (const [id, reach] of Object.entries(catalog)) {
|
|
391
|
+
const parsed = CatalogReachShape.safeParse(reach);
|
|
392
|
+
if (parsed.success) continue;
|
|
393
|
+
const issue = parsed.error.issues[0];
|
|
394
|
+
const at = ["", ...(issue?.path ?? [])].map(String).join(".");
|
|
395
|
+
throw new Error(`${where} at harness.catalog.${id}${at}: ${issue?.message ?? "invalid"} — ${CATALOG_HINT}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const HELD_HINT =
|
|
401
|
+
"an entry is { name, value? | valueFrom?: { secretKeyRef: { name, key } }, hosts?, headers?, paths? } (ADR-0059)";
|
|
402
|
+
const CATALOG_HINT = "an entry is <provider id>: { baseUrl } (ADR-0059)";
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
-- The Custodian's decisions (ADR-0059), run by Envoy's Lua filter on every listener it owns.
|
|
2
|
+
--
|
|
3
|
+
-- Envoy carries the bytes: the CONNECT listener, TLS on both sides, the upstream pools, streaming.
|
|
4
|
+
-- This script makes every choice about a credential, and nothing else. `jr2 up` prepends a
|
|
5
|
+
-- generated `HELD` table (names, headers, paths, bound targets — never a value) and ships the
|
|
6
|
+
-- whole file in the `jr2-held` ConfigMap. Envoy loads it once per Lua state at start, so the
|
|
7
|
+
-- top-level code below runs before any listener serves: a value that is missing or cannot ride a
|
|
8
|
+
-- header stops the start-up with a line that names the secret, never the value.
|
|
9
|
+
--
|
|
10
|
+
-- Four roles, one per route family, read from the route's metadata:
|
|
11
|
+
--
|
|
12
|
+
-- control the Harness's Menu and the ask, plain HTTP on loopback, toward the Orchestrator
|
|
13
|
+
-- egress the CONNECT listener: dial guard, then tunnel or hand to an intercept listener
|
|
14
|
+
-- intercept one bound host, TLS already ended: swap the Stand-in, strip the rest
|
|
15
|
+
-- health the kubelet's probe
|
|
16
|
+
|
|
17
|
+
local VALUES = "/etc/jr2/custodian/values/"
|
|
18
|
+
|
|
19
|
+
-- The value of one held secret, read once. One trailing line end is taken off, because
|
|
20
|
+
-- `kubectl create secret --from-file` writes one; anything else a header cannot carry stops us.
|
|
21
|
+
local function load(name)
|
|
22
|
+
local f = io.open(VALUES .. name, "rb")
|
|
23
|
+
if f == nil then
|
|
24
|
+
error("jr2.custodian refuse secret=" .. name .. " reason=missing-value")
|
|
25
|
+
end
|
|
26
|
+
local v = f:read("*a")
|
|
27
|
+
f:close()
|
|
28
|
+
v = v:gsub("\r?\n$", "", 1)
|
|
29
|
+
if v == "" then
|
|
30
|
+
error("jr2.custodian refuse secret=" .. name .. " reason=empty-value")
|
|
31
|
+
end
|
|
32
|
+
if v:find("[^\32-\126]") then
|
|
33
|
+
error("jr2.custodian refuse secret=" .. name .. " reason=value-not-printable-ascii")
|
|
34
|
+
end
|
|
35
|
+
return v
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
local STAND_IN = {}
|
|
39
|
+
local VALUE = {}
|
|
40
|
+
for _, s in ipairs(HELD.secrets) do
|
|
41
|
+
STAND_IN[s.name] = "jr2-held-" .. s.name
|
|
42
|
+
VALUE[s.name] = load(s.name)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
-- The headers every bound target treats as a credential (`CREDENTIAL_HEADERS`, held-secrets.ts).
|
|
46
|
+
local CREDENTIAL = {}
|
|
47
|
+
for _, h in ipairs(HELD.credential) do CREDENTIAL[h] = true end
|
|
48
|
+
|
|
49
|
+
-- Everything a target's requests may carry a credential in: the fixed set, plus every header a
|
|
50
|
+
-- secret bound to it names.
|
|
51
|
+
local function credentialHeaders(bound)
|
|
52
|
+
local set = {}
|
|
53
|
+
for h in pairs(CREDENTIAL) do set[h] = true end
|
|
54
|
+
for _, s in ipairs(bound) do
|
|
55
|
+
for _, h in ipairs(s.headers) do set[h] = true end
|
|
56
|
+
end
|
|
57
|
+
return set
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
local function tag(handle, key, value)
|
|
61
|
+
handle:streamInfo():dynamicMetadata():set("jr2", key, value)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
local function refuse(handle, status, reason, body)
|
|
65
|
+
tag(handle, "action", "refuse")
|
|
66
|
+
tag(handle, "reason", reason)
|
|
67
|
+
tag(handle, "log", true)
|
|
68
|
+
handle:respond({ [":status"] = tostring(status), ["content-type"] = "text/plain" }, "jr2 custodian: " .. body .. "\n")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
-- The swap, in the two forms a client writes a key in: the Stand-in alone (`x-api-key`), or one
|
|
72
|
+
-- auth scheme then the Stand-in (`Authorization: Bearer`). Nothing else is ever rewritten.
|
|
73
|
+
local function swapped(value, standIn, secret)
|
|
74
|
+
if value == standIn then return secret end
|
|
75
|
+
local scheme, rest = value:match("^([A-Za-z][A-Za-z0-9._~+/-]*) (.+)$")
|
|
76
|
+
if scheme ~= nil and rest == standIn then return scheme .. " " .. secret end
|
|
77
|
+
return nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
-- One request toward a target whose secrets are `bound`: every credential header is either the
|
|
81
|
+
-- product of a swap or gone, and a request that swapped nothing never leaves.
|
|
82
|
+
local function hold(handle, headers, bound, target, path)
|
|
83
|
+
local creds = credentialHeaders(bound)
|
|
84
|
+
for h in pairs(creds) do
|
|
85
|
+
if headers:getNumValues(h) > 1 then
|
|
86
|
+
return refuse(handle, 400, "credential", target .. " got header " .. h .. " more than once")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
local done = {}
|
|
90
|
+
local names = {}
|
|
91
|
+
for _, s in ipairs(bound) do
|
|
92
|
+
for _, h in ipairs(s.headers) do
|
|
93
|
+
local v = headers:get(h)
|
|
94
|
+
if v ~= nil and not done[h] then
|
|
95
|
+
local out = swapped(v, STAND_IN[s.name], VALUE[s.name])
|
|
96
|
+
if out ~= nil then
|
|
97
|
+
if s.paths ~= nil then
|
|
98
|
+
local allowed = false
|
|
99
|
+
for _, p in ipairs(s.paths) do
|
|
100
|
+
if path == p or path:sub(1, #p) == p and (p:sub(-1) == "/" or path:sub(#p + 1, #p + 1) == "/") then
|
|
101
|
+
allowed = true
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
if not allowed then
|
|
105
|
+
return refuse(handle, 403, "path", s.name .. " may not be sent to this path of " .. target)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
headers:replace(h, out)
|
|
109
|
+
done[h] = true
|
|
110
|
+
names[#names + 1] = s.name
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
for h in pairs(creds) do
|
|
116
|
+
if not done[h] then headers:remove(h) end
|
|
117
|
+
end
|
|
118
|
+
if #names == 0 then
|
|
119
|
+
local wanted, where = {}, {}
|
|
120
|
+
for _, s in ipairs(bound) do
|
|
121
|
+
wanted[#wanted + 1] = s.name
|
|
122
|
+
for _, h in ipairs(s.headers) do where[#where + 1] = h end
|
|
123
|
+
end
|
|
124
|
+
return refuse(handle, 403, "stand-in", target .. " needs the stand-in of " .. table.concat(wanted, ", ") ..
|
|
125
|
+
" in " .. table.concat(where, ", "))
|
|
126
|
+
end
|
|
127
|
+
tag(handle, "action", "intercept")
|
|
128
|
+
tag(handle, "secret", table.concat(names, ","))
|
|
129
|
+
tag(handle, "log", true)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
-- A CONNECT target, one spelling: lowercase, no trailing dot on the name.
|
|
133
|
+
local function normal(authority)
|
|
134
|
+
local a = authority:lower()
|
|
135
|
+
local host, port = a:match("^(%[[^%]]+%]):(%d+)$")
|
|
136
|
+
if host == nil then host, port = a:match("^([^:]+):(%d+)$") end
|
|
137
|
+
if host == nil then return nil end
|
|
138
|
+
host = host:gsub("%.$", "")
|
|
139
|
+
return host, port
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
local function ipv4(host)
|
|
143
|
+
local a, b, c, d = host:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
|
|
144
|
+
if a == nil then return nil end
|
|
145
|
+
a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
|
|
146
|
+
if a > 255 or b > 255 or c > 255 or d > 255 then return nil end
|
|
147
|
+
return a, b, c, d
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
-- An IPv6 literal as eight numbers, or nil.
|
|
151
|
+
local function ipv6(host)
|
|
152
|
+
local s = host:match("^%[(.+)%]$")
|
|
153
|
+
if s == nil or not s:find(":") then return nil end
|
|
154
|
+
local v4 = s:match(":(%d+%.%d+%.%d+%.%d+)$")
|
|
155
|
+
if v4 ~= nil then
|
|
156
|
+
local a, b, c, d = ipv4(v4)
|
|
157
|
+
if a == nil then return nil end
|
|
158
|
+
s = s:sub(1, #s - #v4) .. string.format("%x:%x", a * 256 + b, c * 256 + d)
|
|
159
|
+
end
|
|
160
|
+
local head, rest = s:match("^(.-)::(.*)$")
|
|
161
|
+
local function groups(part)
|
|
162
|
+
local out = {}
|
|
163
|
+
if part == "" then return out end
|
|
164
|
+
for g in (part .. ":"):gmatch("([^:]*):") do
|
|
165
|
+
if not g:match("^%x%x?%x?%x?$") then return nil end
|
|
166
|
+
out[#out + 1] = tonumber(g, 16)
|
|
167
|
+
end
|
|
168
|
+
return out
|
|
169
|
+
end
|
|
170
|
+
local left, right
|
|
171
|
+
if head == nil then
|
|
172
|
+
left, right = groups(s), {}
|
|
173
|
+
if left == nil or #left ~= 8 then return nil end
|
|
174
|
+
else
|
|
175
|
+
left, right = groups(head), groups(rest)
|
|
176
|
+
if left == nil or right == nil or #left + #right > 7 then return nil end
|
|
177
|
+
end
|
|
178
|
+
local out = {}
|
|
179
|
+
for _, g in ipairs(left) do out[#out + 1] = g end
|
|
180
|
+
for _ = 1, 8 - #left - #right do out[#out + 1] = 0 end
|
|
181
|
+
for _, g in ipairs(right) do out[#out + 1] = g end
|
|
182
|
+
return out
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
-- The dial guard's first half: a tunnel never reaches loopback, link-local (the cloud metadata
|
|
186
|
+
-- address among it), or an unspecified address — nor an IPv4-mapped spelling of one. By NAME it
|
|
187
|
+
-- knows only `localhost`. The second half is Envoy's: an RBAC filter judges the address the
|
|
188
|
+
-- dynamic forward proxy resolved a name to, and `envoy_on_response` logs its refusal (ADR-0059).
|
|
189
|
+
local function guarded(host)
|
|
190
|
+
if host == "localhost" or host:sub(-10) == ".localhost" then return true end
|
|
191
|
+
local a, b = ipv4(host)
|
|
192
|
+
if a ~= nil then
|
|
193
|
+
return a == 127 or a == 0 or (a == 169 and b == 254)
|
|
194
|
+
end
|
|
195
|
+
local g = ipv6(host)
|
|
196
|
+
if g ~= nil then
|
|
197
|
+
local zero = true
|
|
198
|
+
for i = 1, 7 do if g[i] ~= 0 then zero = false end end
|
|
199
|
+
if zero and (g[8] == 0 or g[8] == 1) then return true end
|
|
200
|
+
if g[1] >= 0xfe80 and g[1] <= 0xfebf then return true end
|
|
201
|
+
if g[1] == 0xfd00 and g[2] == 0xec2 and g[3] == 0 and g[4] == 0 and g[5] == 0 and g[6] == 0 and g[7] == 0
|
|
202
|
+
and g[8] == 0x254 then return true end
|
|
203
|
+
local mapped = g[1] == 0 and g[2] == 0 and g[3] == 0 and g[4] == 0 and g[5] == 0 and g[6] == 0xffff
|
|
204
|
+
if mapped then
|
|
205
|
+
local x, y = math.floor(g[7] / 256), g[7] % 256
|
|
206
|
+
return x == 127 or x == 0 or (x == 169 and y == 254)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
return false
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
local function egress(handle, headers)
|
|
213
|
+
if headers:get(":method") ~= "CONNECT" then
|
|
214
|
+
return refuse(handle, 405, "method", "this listener speaks CONNECT alone")
|
|
215
|
+
end
|
|
216
|
+
local authority = headers:get(":authority") or ""
|
|
217
|
+
local host, port = normal(authority)
|
|
218
|
+
if host == nil then
|
|
219
|
+
return refuse(handle, 400, "target", "a CONNECT names host:port")
|
|
220
|
+
end
|
|
221
|
+
local target = host .. ":" .. port
|
|
222
|
+
if target ~= authority then
|
|
223
|
+
headers:replace(":authority", target)
|
|
224
|
+
handle:clearRouteCache()
|
|
225
|
+
end
|
|
226
|
+
if HELD.targets[target] ~= nil then
|
|
227
|
+
-- Handed to the intercept listener, which logs each request it carries.
|
|
228
|
+
return
|
|
229
|
+
end
|
|
230
|
+
if guarded(host) then
|
|
231
|
+
return refuse(handle, 403, "guard", target .. " is loopback, link-local or unspecified")
|
|
232
|
+
end
|
|
233
|
+
tag(handle, "action", "tunnel")
|
|
234
|
+
tag(handle, "log", true)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
-- What a bound host is sent. TRACE is not: a server that answers it echoes the request, the
|
|
238
|
+
-- swapped value among it, back to the Agent. Neither is a WebDAV or other extension method.
|
|
239
|
+
local METHODS = { GET = true, HEAD = true, POST = true, PUT = true, PATCH = true, DELETE = true, OPTIONS = true }
|
|
240
|
+
|
|
241
|
+
local function intercept(handle, headers, target)
|
|
242
|
+
if not METHODS[headers:get(":method")] then
|
|
243
|
+
return refuse(handle, 405, "method", target .. " takes GET, HEAD, POST, PUT, PATCH, DELETE or OPTIONS here")
|
|
244
|
+
end
|
|
245
|
+
if headers:get("upgrade") ~= nil then
|
|
246
|
+
return refuse(handle, 501, "upgrade", target .. " takes no protocol upgrade here")
|
|
247
|
+
end
|
|
248
|
+
local host = (headers:get(":authority") or ""):lower()
|
|
249
|
+
local want = target
|
|
250
|
+
if target:sub(-4) == ":443" then
|
|
251
|
+
if host == target:sub(1, -5) then host = target end
|
|
252
|
+
end
|
|
253
|
+
if host ~= want then
|
|
254
|
+
return refuse(handle, 421, "host", "Host does not name " .. target)
|
|
255
|
+
end
|
|
256
|
+
local path = (headers:get(":path") or "/"):match("^[^?]*")
|
|
257
|
+
if path:lower():find("%%2[ef]") then
|
|
258
|
+
return refuse(handle, 400, "path", "an encoded / or . in the path")
|
|
259
|
+
end
|
|
260
|
+
for _, h in ipairs({ "te", "trailer", "keep-alive", "proxy-connection" }) do headers:remove(h) end
|
|
261
|
+
return hold(handle, headers, HELD.targets[target], target, path)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
local function control(handle, headers, route)
|
|
265
|
+
if route == "fetch" then
|
|
266
|
+
local sandbox = os.getenv("JR2_SANDBOX")
|
|
267
|
+
if sandbox == nil or sandbox == "" then
|
|
268
|
+
tag(handle, "action", "refuse")
|
|
269
|
+
tag(handle, "reason", "no-sandbox")
|
|
270
|
+
tag(handle, "log", true)
|
|
271
|
+
return handle:respond({ [":status"] = "404", ["content-type"] = "application/json" },
|
|
272
|
+
'{"error":"this pod serves no Sandbox, so it mounts no Repo to fetch (ADR-0053)"}')
|
|
273
|
+
end
|
|
274
|
+
headers:replace(":path", "/sandboxes/" .. sandbox .. "/fetch")
|
|
275
|
+
end
|
|
276
|
+
return hold(handle, headers, HELD.control, "orchestrator", headers:get(":path"))
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
local READY = false
|
|
280
|
+
|
|
281
|
+
-- A refusal by resolved address comes from the RBAC filter after this one: the one 403 a request
|
|
282
|
+
-- this script let through as a tunnel can get. It is logged as a refusal by spelling is.
|
|
283
|
+
function envoy_on_response(handle)
|
|
284
|
+
if handle:metadata():get("role") ~= "egress" then return end
|
|
285
|
+
if handle:headers():get(":status") ~= "403" then return end
|
|
286
|
+
local jr2 = handle:streamInfo():dynamicMetadata():get("jr2")
|
|
287
|
+
if jr2 == nil or jr2.action ~= "tunnel" then return end
|
|
288
|
+
tag(handle, "action", "refuse")
|
|
289
|
+
tag(handle, "reason", "guard")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
function envoy_on_request(handle)
|
|
293
|
+
local meta = handle:metadata()
|
|
294
|
+
local role = meta:get("role")
|
|
295
|
+
local headers = handle:headers()
|
|
296
|
+
if role == "egress" then return egress(handle, headers) end
|
|
297
|
+
if role == "intercept" then return intercept(handle, headers, meta:get("target")) end
|
|
298
|
+
if role == "control" then return control(handle, headers, meta:get("route")) end
|
|
299
|
+
if role == "health" then
|
|
300
|
+
if not READY then
|
|
301
|
+
READY = true
|
|
302
|
+
local names = {}
|
|
303
|
+
for _, s in ipairs(HELD.secrets) do names[#names + 1] = s.name end
|
|
304
|
+
io.stdout:write("jr2.custodian ready secrets=" .. table.concat(names, ",") .. " hosts=" .. HELD.hosts .. "\n")
|
|
305
|
+
io.stdout:flush()
|
|
306
|
+
end
|
|
307
|
+
return handle:respond({ [":status"] = "200", ["content-type"] = "text/plain" }, "ok\n")
|
|
308
|
+
end
|
|
309
|
+
if role == "deny" then
|
|
310
|
+
return refuse(handle, 404, "route", "no such route")
|
|
311
|
+
end
|
|
312
|
+
end
|