@feasibleone/blong-gogo 1.39.0 → 1.40.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/CHANGELOG.md +13 -0
- package/package.json +2 -2
- package/src/GatewayCodec.ts +60 -1
- package/src/Remote.ts +63 -5
- package/src/RpcClient.ts +11 -1
- package/src/busGateway.ts +41 -5
- package/src/codec/adapter/mle/ready.ts +22 -1
- package/src/frameworkRealms.test.ts +2 -2
- package/src/lib.ts +33 -10
- package/src/load.ts +1 -1
- package/src/oidc.ts +39 -5
- package/src/remoteCanSkip.test.ts +76 -0
- package/src/skipGateway.test.ts +112 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.40.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.39.0...blong-gogo-v1.40.0) (2026-10-13)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* blong-kustomize improvements ([0a07e50](https://github.com/feasibleone/blong/commit/0a07e50275208a3fd8a23476e0abe9334ec3822d))
|
|
9
|
+
* blong-kustomize operator UI ([2599dab](https://github.com/feasibleone/blong/commit/2599dab9c5ac734c9949f9af9b11fcb181eb8c7f))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* flaky tests ([226d4a6](https://github.com/feasibleone/blong/commit/226d4a6c1bb912e2fff0919bda975ba487d3e060))
|
|
15
|
+
|
|
3
16
|
## [1.39.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.38.1...blong-gogo-v1.39.0) (2026-10-11)
|
|
4
17
|
|
|
5
18
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feasibleone/blong-gogo",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.40.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"url": "git+https://github.com/feasibleone/blong.git"
|
|
6
6
|
},
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"yaml": "^2.8.3"
|
|
84
84
|
},
|
|
85
85
|
"devDependencies": {
|
|
86
|
-
"@feasibleone/blong-dev": "1.
|
|
86
|
+
"@feasibleone/blong-dev": "1.10.0",
|
|
87
87
|
"@rushstack/heft": "^1.2.6",
|
|
88
88
|
"@rushstack/heft-lint-plugin": "^1.2.6",
|
|
89
89
|
"@rushstack/heft-typescript-plugin": "^1.3.1",
|
package/src/GatewayCodec.ts
CHANGED
|
@@ -39,6 +39,28 @@ export interface IGatewayCodec {
|
|
|
39
39
|
export interface IConfig {
|
|
40
40
|
client?: {tls?: {ca?: string | string[]; key?: string; cert?: string; crl?: string}};
|
|
41
41
|
gateway?: object;
|
|
42
|
+
/**
|
|
43
|
+
* Whether a method must *not* be carried by the gateway this config names: `true` for every method,
|
|
44
|
+
* `false` for none, or a regular-expression *source* matched against the method name for the ones
|
|
45
|
+
* that belong here.
|
|
46
|
+
*
|
|
47
|
+
* The routing decision is made here — a gateway is where an *outside* caller reaches a process, and
|
|
48
|
+
* `gateway()` is what says whether a call is handed to one — so this is where a method that has to
|
|
49
|
+
* be answered at home is kept out of it. The name the framework needs it for is its own session
|
|
50
|
+
* check: `identity.checkInternal` is asked by the process that verified the token, from inside that
|
|
51
|
+
* verification, and a realm that answers its own login is one process. Routed through a gateway the
|
|
52
|
+
* call reached the gateway itself, was refused for a missing bearer and came back as a null the
|
|
53
|
+
* session step destructured (F-497) — a hardcoded name here until this setting replaced it, because
|
|
54
|
+
* a deployment that routes namespaces through a gateway is the one that knows which of its own
|
|
55
|
+
* methods have to stay home.
|
|
56
|
+
*
|
|
57
|
+
* A skipped method falls back to the ordinary path, and what that resolves to is the resolver's
|
|
58
|
+
* answer: `remote.canSkipSocket` decides whether the process answers it in-process or sends it to
|
|
59
|
+
* the namespace. The two settings compose rather than overlap — this one says "not through the
|
|
60
|
+
* gateway", that one says "answered here" — which is why the resolver asks its question only after
|
|
61
|
+
* this one has let the call through.
|
|
62
|
+
*/
|
|
63
|
+
skipGateway?: boolean | string;
|
|
42
64
|
protocol?: Protocol;
|
|
43
65
|
host?: string;
|
|
44
66
|
port?: string;
|
|
@@ -56,6 +78,14 @@ export default class GatewayCodecImpl implements IGatewayCodec {
|
|
|
56
78
|
#config: IConfig;
|
|
57
79
|
#tlsClient: object;
|
|
58
80
|
#resolution: IResolution;
|
|
81
|
+
/**
|
|
82
|
+
* The `skipGateway` source as it was compiled, and the pattern — `null` for a source that did not
|
|
83
|
+
* compile.
|
|
84
|
+
*
|
|
85
|
+
* Kept beside the source for the reason the resolver keeps its own: `gateway()` runs for every
|
|
86
|
+
* outgoing call, and compiling the same expression per call is work that only the first call needs.
|
|
87
|
+
*/
|
|
88
|
+
#skipGateway: {source: boolean | string; pattern: RegExp | null} | undefined;
|
|
59
89
|
|
|
60
90
|
public verify!: IGatewayCodec['verify'];
|
|
61
91
|
|
|
@@ -170,8 +200,37 @@ export default class GatewayCodecImpl implements IGatewayCodec {
|
|
|
170
200
|
this.verify = verify as IGatewayCodec['verify'];
|
|
171
201
|
}
|
|
172
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Whether this call is one the gateway must not carry ({@link IConfig.skipGateway}).
|
|
205
|
+
*
|
|
206
|
+
* A source that does not compile is treated as nothing matching rather than as every method: a typo
|
|
207
|
+
* in a config must not decide that no call is ever routed, and the alternative — refusing to start —
|
|
208
|
+
* would take a deployment down over a string. Nothing is logged here because this class has no
|
|
209
|
+
* logger: it is handed one by the caller that owns the logs, and the silent treatment is the same
|
|
210
|
+
* answer `Remote.canAnswerLocally` reports through its own.
|
|
211
|
+
*/
|
|
212
|
+
private _skipsGateway(methodName: string): boolean {
|
|
213
|
+
const {skipGateway} = this.#config;
|
|
214
|
+
if (skipGateway === true) return true;
|
|
215
|
+
if (typeof skipGateway !== 'string') return false;
|
|
216
|
+
if (this.#skipGateway?.source !== skipGateway) {
|
|
217
|
+
let pattern: RegExp | null = null;
|
|
218
|
+
try {
|
|
219
|
+
pattern = new RegExp(skipGateway);
|
|
220
|
+
} catch {
|
|
221
|
+
pattern = null;
|
|
222
|
+
}
|
|
223
|
+
this.#skipGateway = {source: skipGateway, pattern};
|
|
224
|
+
}
|
|
225
|
+
return this.#skipGateway.pattern?.test(methodName) ?? false;
|
|
226
|
+
}
|
|
227
|
+
|
|
173
228
|
public gateway($meta: IMeta, methodName: string = $meta.method!): object | void {
|
|
174
|
-
|
|
229
|
+
// A skipped method does not reach a gateway at all, not even one the caller asked for in
|
|
230
|
+
// `$meta.gateway`: the setting says this process answers the call, and what answers it is the
|
|
231
|
+
// resolver's business (see `IConfig.skipGateway`).
|
|
232
|
+
if (this._skipsGateway(methodName)) return undefined;
|
|
233
|
+
if (this.#config.gateway) {
|
|
175
234
|
const gw = this.#config.gateway as Record<string, unknown>;
|
|
176
235
|
const [prefix, method] = methodName.split('/');
|
|
177
236
|
if (method) {
|
package/src/Remote.ts
CHANGED
|
@@ -24,7 +24,18 @@ const errorMap: IErrorMap = {
|
|
|
24
24
|
|
|
25
25
|
export default class Remote extends Internal implements IRemote {
|
|
26
26
|
#config: {
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Whether a call may be answered in this process instead of through the transport: `true` for
|
|
29
|
+
* every method, `false` for none, or a regular-expression *source* matched against the method
|
|
30
|
+
* name for the ones that belong here ({@link canAnswerLocally}).
|
|
31
|
+
*
|
|
32
|
+
* A configured gateway still wins, and deliberately: a method a gateway is declared for is not
|
|
33
|
+
* this process's to answer, and a deployment that wants an exception says so on the other side
|
|
34
|
+
* — `GatewayCodec`'s `skipGateway` keeps the method out of the gateway, and this is what the
|
|
35
|
+
* fallback then resolves by. Asking the same question in two places would make the two settings
|
|
36
|
+
* disagreeable; asking it here only when nothing else claims the call keeps them composable.
|
|
37
|
+
*/
|
|
38
|
+
canSkipSocket: boolean | string;
|
|
28
39
|
requireMeta: 'trace' | 'debug' | 'warn' | 'error' | 'fatal' | 'info' | true;
|
|
29
40
|
} = {
|
|
30
41
|
canSkipSocket: false,
|
|
@@ -36,12 +47,21 @@ export default class Remote extends Internal implements IRemote {
|
|
|
36
47
|
#errors: Errors<typeof errorMap>;
|
|
37
48
|
#local: ILocal;
|
|
38
49
|
#platform: IPlatformApi;
|
|
50
|
+
/**
|
|
51
|
+
* The compiled pattern of each `canSkipSocket` source seen, `null` for one that does not compile.
|
|
52
|
+
*
|
|
53
|
+
* Per source rather than one slot, because a reload replaces the config and a process may see more
|
|
54
|
+
* than one value in its life; the map is small enough that keeping the earlier ones costs nothing.
|
|
55
|
+
*/
|
|
56
|
+
#skipPatterns = new Map<string, RegExp | null>();
|
|
39
57
|
|
|
40
58
|
#brokerRequest: RemoteMethod;
|
|
41
59
|
#brokerPublish: RemoteMethod;
|
|
42
60
|
|
|
43
61
|
public constructor(
|
|
44
|
-
|
|
62
|
+
// Declared rather than read off the caller's own interface, because a type that names only
|
|
63
|
+
// `logLevel` refuses the value the loader actually passes — `canSkipSocket` among them.
|
|
64
|
+
config: {logLevel?: Parameters<ILog['logger']>[0]; canSkipSocket?: boolean | string},
|
|
45
65
|
{
|
|
46
66
|
log,
|
|
47
67
|
error,
|
|
@@ -202,9 +222,10 @@ export default class Remote extends Internal implements IRemote {
|
|
|
202
222
|
if (!fn) {
|
|
203
223
|
// don't try skipping socket if there is a gateway configured
|
|
204
224
|
if (methodName && !this.gateway?.($applyMeta, methodName)) {
|
|
205
|
-
if (this
|
|
225
|
+
if (this.canAnswerLocally(methodName))
|
|
226
|
+
fn = this._findMethod(methodName, methodType);
|
|
206
227
|
if (fn) unpack = true;
|
|
207
|
-
else if (
|
|
228
|
+
else if (this.canAnswerLocally(methodName)) {
|
|
208
229
|
// Visibility: under canSkipSocket every call is expected
|
|
209
230
|
// to resolve locally. A missing method (e.g. a derived
|
|
210
231
|
// handler name that doesn't match a registered file —
|
|
@@ -368,7 +389,7 @@ export default class Remote extends Internal implements IRemote {
|
|
|
368
389
|
mtid = $meta.mtid;
|
|
369
390
|
if (mtid === 'discard') return true;
|
|
370
391
|
const handler =
|
|
371
|
-
this
|
|
392
|
+
this.canAnswerLocally($meta.method) &&
|
|
372
393
|
this._findMethod($meta.method!, mtid === 'request' ? 'request' : 'publish');
|
|
373
394
|
if (handler) {
|
|
374
395
|
return Promise.resolve(handler(...params));
|
|
@@ -392,6 +413,43 @@ export default class Remote extends Internal implements IRemote {
|
|
|
392
413
|
return this._getPath(name).split('.').pop()!;
|
|
393
414
|
}
|
|
394
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Whether a method may be answered in this process rather than through the transport.
|
|
418
|
+
*
|
|
419
|
+
* `true` and `false` are the two ends — a process that carries everything answers everything, one
|
|
420
|
+
* that carries only its own realm answers nothing — and the string is the middle: a
|
|
421
|
+
* regular-expression *source*, read against the method name, for a process that serves some of what
|
|
422
|
+
* it can reach and has to send the rest out. The pattern is compiled once per value, because a
|
|
423
|
+
* reload replaces the config.
|
|
424
|
+
*
|
|
425
|
+
* A source that does not compile is not a reason to refuse every call: it is reported once and
|
|
426
|
+
* treated as nothing matching, which leaves the process dialling as it would have with `false`.
|
|
427
|
+
*
|
|
428
|
+
* The caller decides *when* to ask ({@link _getMethod} asks only where no gateway claims the call),
|
|
429
|
+
* so nothing here knows about gateways: this is the local half of the question and the routing half
|
|
430
|
+
* lives in the codec that actually routes.
|
|
431
|
+
*/
|
|
432
|
+
protected canAnswerLocally(methodName: string | undefined): boolean {
|
|
433
|
+
const {canSkipSocket} = this.#config;
|
|
434
|
+
if (canSkipSocket === true) return true;
|
|
435
|
+
if (typeof canSkipSocket !== 'string' || !methodName) return false;
|
|
436
|
+
const cached = this.#skipPatterns.get(canSkipSocket);
|
|
437
|
+
if (cached !== undefined) return cached ? cached.test(methodName) : false;
|
|
438
|
+
try {
|
|
439
|
+
const pattern = new RegExp(canSkipSocket);
|
|
440
|
+
this.#skipPatterns.set(canSkipSocket, pattern);
|
|
441
|
+
return pattern.test(methodName);
|
|
442
|
+
} catch (error) {
|
|
443
|
+
this.#skipPatterns.set(canSkipSocket, null);
|
|
444
|
+
this.log?.warn?.({
|
|
445
|
+
$meta: {mtid: 'event', method: 'remote.canSkipSocket'},
|
|
446
|
+
message: `canSkipSocket is not a regular expression: ${canSkipSocket}`,
|
|
447
|
+
error,
|
|
448
|
+
});
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
395
453
|
private _findMethod(methodName: string, type: 'request' | 'publish'): RemoteMethod {
|
|
396
454
|
methodName = this._getPath(methodName);
|
|
397
455
|
const key = ['ports', methodName, type].join('.');
|
package/src/RpcClient.ts
CHANGED
|
@@ -90,7 +90,17 @@ export default class RpcClientImpl extends RemoteImpl implements IRpcClient {
|
|
|
90
90
|
'http',
|
|
91
91
|
'8091',
|
|
92
92
|
this.#errors,
|
|
93
|
-
|
|
93
|
+
// The session step's own call — the one the codec makes while it verifies a caller's token
|
|
94
|
+
// (`identity.checkInternal`) — is asked through the *resolver* like every other call this
|
|
95
|
+
// process makes, which is what lets a process answer it itself. Handed the raw transport it
|
|
96
|
+
// could only ever dial: the routing question would be answered (the codec keeps the method
|
|
97
|
+
// out of a gateway, `skipGateway`) and then the call left for an `identity` namespace that a
|
|
98
|
+
// deployment answering its own login does not publish — refused for a missing bearer when it
|
|
99
|
+
// reached a gateway, and `ENOTFOUND` when it reached nothing (F-494, F-497). Asked through
|
|
100
|
+
// the resolver, `remote.canSkipSocket` decides: the process that verified the token answers
|
|
101
|
+
// the question, and a deployment that runs an identity service still reaches it.
|
|
102
|
+
(msg: unknown, $meta: unknown) =>
|
|
103
|
+
this.remote(($meta as IMeta)?.method ?? 'identity.checkInternal')(msg, $meta),
|
|
94
104
|
this.#resolution,
|
|
95
105
|
);
|
|
96
106
|
}
|
package/src/busGateway.ts
CHANGED
|
@@ -35,6 +35,37 @@ export default ({
|
|
|
35
35
|
encrypt: string;
|
|
36
36
|
}>;
|
|
37
37
|
}) => {
|
|
38
|
+
/**
|
|
39
|
+
* What could not be reached, in the message rather than in a cause nobody prints.
|
|
40
|
+
*
|
|
41
|
+
* `fetch failed` is all a `TypeError` from the transport says, and the method and the
|
|
42
|
+
* address it was fetching are not in it: diagnosing an unreachable namespace came down
|
|
43
|
+
* to reading the process's DNS names out of a log line that did not carry them (F-494).
|
|
44
|
+
* Every transport failure in this file therefore leaves through here, with the method,
|
|
45
|
+
* the address and the code the resolver or the socket returned.
|
|
46
|
+
*/
|
|
47
|
+
const unreachable = async <T>(
|
|
48
|
+
target: string,
|
|
49
|
+
method: string,
|
|
50
|
+
call: () => Promise<T>,
|
|
51
|
+
): Promise<T> => {
|
|
52
|
+
try {
|
|
53
|
+
return await call();
|
|
54
|
+
} catch (error) {
|
|
55
|
+
const cause = (error as {cause?: {code?: string; hostname?: string; syscall?: string}})
|
|
56
|
+
?.cause;
|
|
57
|
+
const detail = cause
|
|
58
|
+
? [cause.code, cause.syscall, cause.hostname].filter(Boolean).join(' ')
|
|
59
|
+
: '';
|
|
60
|
+
throw new Error(
|
|
61
|
+
`gateway codec could not reach ${target} for '${method}': ` +
|
|
62
|
+
`${(error as Error)?.message ?? String(error)}` +
|
|
63
|
+
`${detail ? ` (${detail})` : ''}`,
|
|
64
|
+
{cause: error as Error},
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
38
69
|
const localCache: Record<
|
|
39
70
|
string,
|
|
40
71
|
{
|
|
@@ -193,10 +224,12 @@ export default ({
|
|
|
193
224
|
const cache = (localCache[url] = localCache[url] || {});
|
|
194
225
|
|
|
195
226
|
if (localKeys && !cache.remoteKeys) {
|
|
196
|
-
const body = await
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
227
|
+
const body = await unreachable(`${url}/rpc/login/.well-known/mle`, method, () =>
|
|
228
|
+
get(
|
|
229
|
+
`${url}/rpc/login/.well-known/mle`,
|
|
230
|
+
errors['bus.jsonRpcHttp'],
|
|
231
|
+
errors['bus.jsonRpcEmpty'],
|
|
232
|
+
),
|
|
200
233
|
);
|
|
201
234
|
if (body.sign && body.encrypt) cache.remoteKeys = body;
|
|
202
235
|
}
|
|
@@ -230,7 +263,10 @@ export default ({
|
|
|
230
263
|
return codec;
|
|
231
264
|
}
|
|
232
265
|
|
|
233
|
-
if (!cache.auth)
|
|
266
|
+
if (!cache.auth)
|
|
267
|
+
await unreachable(`${url}/rpc/login/identity`, method, () =>
|
|
268
|
+
login(cache, url, username, password, channel),
|
|
269
|
+
);
|
|
234
270
|
|
|
235
271
|
const exp = Date.now();
|
|
236
272
|
|
|
@@ -176,7 +176,28 @@ export default handler<{
|
|
|
176
176
|
|
|
177
177
|
return {
|
|
178
178
|
async ready() {
|
|
179
|
-
|
|
179
|
+
/**
|
|
180
|
+
* The pair is kept across a reload, which is what the write below has always assumed.
|
|
181
|
+
*
|
|
182
|
+
* A caller's session is opened with the pair it presented when it logged in — the realm
|
|
183
|
+
* that answers the session hands those two public keys back — and every later call is
|
|
184
|
+
* verified against the signing half of it. A reload that minted a new pair therefore
|
|
185
|
+
* signed with a key the session had never seen, and the gateway answered
|
|
186
|
+
* `Decryption failed` with `ERR_JWS_SIGNATURE_VERIFICATION_FAILED` underneath, on the
|
|
187
|
+
* first call of every page that logs in by reloading (F-497). The value is written below
|
|
188
|
+
* and was never read back, which is the shape of a half-finished feature rather than a
|
|
189
|
+
* decision: keeping it is now deliberate — a private JWK in `localStorage` is readable by
|
|
190
|
+
* any script on this origin, and the alternative is a session re-opened after every
|
|
191
|
+
* reload, which needs the caller's keys to travel with the call instead (D-530).
|
|
192
|
+
*/
|
|
193
|
+
let mleKey = null;
|
|
194
|
+
if (isBrowser) {
|
|
195
|
+
try {
|
|
196
|
+
mleKey = JSON.parse(window.localStorage.getItem('mle-jose') || 'null');
|
|
197
|
+
} catch {
|
|
198
|
+
mleKey = null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
180
201
|
if (!mleKey) {
|
|
181
202
|
const {body: {sign, encrypt} = {}}: {body?: {sign?: unknown; encrypt?: unknown}} =
|
|
182
203
|
(await (this as {exec?(...params: unknown[]): Promise<unknown>}).exec!(
|
|
@@ -143,7 +143,7 @@ test('a realm the suite declares is loaded', async t => {
|
|
|
143
143
|
try {
|
|
144
144
|
await withAmbientResolution(dirname(fileURLToPath(url)), async () => {
|
|
145
145
|
const registry = await load(suite(url) as never, 'declared', {}, ['cli']);
|
|
146
|
-
t.ok(registry.describe?.().realms.includes('
|
|
146
|
+
t.ok(registry.describe?.().realms.includes('srv'), 'the declared realm is loaded');
|
|
147
147
|
});
|
|
148
148
|
} finally {
|
|
149
149
|
dispose();
|
|
@@ -164,7 +164,7 @@ test('a trimmed manifest does not un-declare a realm', async t => {
|
|
|
164
164
|
['cli'],
|
|
165
165
|
);
|
|
166
166
|
t.ok(
|
|
167
|
-
registry.describe?.().realms.includes('
|
|
167
|
+
registry.describe?.().realms.includes('srv'),
|
|
168
168
|
'the file beside the suite still counts',
|
|
169
169
|
);
|
|
170
170
|
});
|
package/src/lib.ts
CHANGED
|
@@ -240,16 +240,39 @@ export async function requestGet(
|
|
|
240
240
|
protocol: string,
|
|
241
241
|
tls: Record<string, unknown> | undefined,
|
|
242
242
|
): Promise<unknown> {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
243
|
+
let response;
|
|
244
|
+
try {
|
|
245
|
+
response = await ky.get(url, {
|
|
246
|
+
...tls,
|
|
247
|
+
...(headers && {
|
|
248
|
+
headers: {
|
|
249
|
+
// Every header the caller named, not only the two forwarded-for ones. Dropping
|
|
250
|
+
// the rest silently dropped the `authorization` an issuer's discovery is read
|
|
251
|
+
// with, and the API server answered 401 for a request that presented nothing —
|
|
252
|
+
// a failure with no trace of the cause it was given (F-494).
|
|
253
|
+
...(Object.fromEntries(
|
|
254
|
+
Object.entries(headers).filter(([, value]) => value !== undefined),
|
|
255
|
+
) as Record<string, string>),
|
|
256
|
+
'x-forwarded-proto': headers['x-forwarded-proto'] || protocol,
|
|
257
|
+
'x-forwarded-host': headers['x-forwarded-host'] || headers.host,
|
|
258
|
+
},
|
|
259
|
+
}),
|
|
260
|
+
throwHttpErrors: false,
|
|
261
|
+
});
|
|
262
|
+
} catch (error) {
|
|
263
|
+
// Name what could not be fetched. A transport failure reaches the log as `fetch failed`
|
|
264
|
+
// and nothing else, so an unreachable dependency was diagnosed by reading DNS names out of
|
|
265
|
+
// the process the hard way (F-494); the address and the resolver's code belong in the
|
|
266
|
+
// message, where whoever reads the failure is already looking.
|
|
267
|
+
const cause = (error as {cause?: {code?: string; hostname?: string; syscall?: string}})
|
|
268
|
+
?.cause;
|
|
269
|
+
const detail = cause ? [cause.code, cause.syscall, cause.hostname].filter(Boolean).join(' ') : '';
|
|
270
|
+
throw new Error(
|
|
271
|
+
`could not reach ${url}: ${(error as Error)?.message ?? String(error)}` +
|
|
272
|
+
`${detail ? ` (${detail})` : ''}`,
|
|
273
|
+
{cause: error as Error},
|
|
274
|
+
);
|
|
275
|
+
}
|
|
253
276
|
|
|
254
277
|
const responseText = await response.text();
|
|
255
278
|
let body: {[key: string]: unknown} | undefined;
|
package/src/load.ts
CHANGED
|
@@ -1360,7 +1360,7 @@ export default async function loadRealm<T extends TSchema>(
|
|
|
1360
1360
|
*/
|
|
1361
1361
|
intents?: readonly string[];
|
|
1362
1362
|
}> = [
|
|
1363
|
-
{name: '
|
|
1363
|
+
{name: 'srv', specifier: '@feasibleone/blong-server/server.ts'},
|
|
1364
1364
|
{name: 'login', specifier: '@feasibleone/blong-login/server.ts'},
|
|
1365
1365
|
{name: 'core', specifier: '@feasibleone/blong-core/server.ts'},
|
|
1366
1366
|
{name: 'access', specifier: '@feasibleone/blong-access/server.ts'},
|
package/src/oidc.ts
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import type {Errors} from '@feasibleone/blong';
|
|
2
2
|
import {createLocalJWKSet, decodeJwt, decodeProtectedHeader, jwtVerify} from 'jose';
|
|
3
|
+
import {readFileSync} from 'node:fs';
|
|
3
4
|
import {requestGet as get, loginService} from './lib.ts';
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The headers an issuer's own endpoints are read with.
|
|
8
|
+
*
|
|
9
|
+
* A cluster's discovery document and key set are not anonymous: the API server answers 401 unless
|
|
10
|
+
* the caller presents an identity it knows, so an issuer that names its credentials by file is read
|
|
11
|
+
* with them (`rpc.oidcHttp: OpenID returned HTTP error 401` is what an uncredentialed read looks
|
|
12
|
+
* like). The file is read per fetch rather than cached, because what lives in a projected
|
|
13
|
+
* service-account volume is rotated by the kubelet and the process is meant to pick the new one up.
|
|
14
|
+
*/
|
|
15
|
+
const issuerHeaders = (config: {tokenFile?: string} | undefined): Record<string, string> => {
|
|
16
|
+
if (!config?.tokenFile) return {};
|
|
17
|
+
return {authorization: `Bearer ${readFileSync(config.tokenFile, 'utf8').trim()}`};
|
|
18
|
+
};
|
|
19
|
+
|
|
5
20
|
export default ({
|
|
6
21
|
issuers,
|
|
7
22
|
tls = {},
|
|
@@ -55,9 +70,12 @@ export default ({
|
|
|
55
70
|
) => Promise<{protocol: string; hostname: string; port: number}>,
|
|
56
71
|
);
|
|
57
72
|
issuer = `${loginProtocol}://${hostname}:${port}/rpc/login/.well-known/openid-configuration`;
|
|
58
|
-
} else {
|
|
59
|
-
headers = undefined;
|
|
60
73
|
}
|
|
74
|
+
// The headers are kept for every issuer, not only for `blong-login`. A configured issuer
|
|
75
|
+
// is read with whatever the caller of this function brought, and for a cluster issuer that
|
|
76
|
+
// is the process's own credential: discarding it here turned the API server's answer into
|
|
77
|
+
// `rpc.oidcHttp: 401` for a request that presented nothing, with nothing in the message to
|
|
78
|
+
// say the credential had been dropped on the way (F-494).
|
|
61
79
|
return await get(issuer, errorOidcHttp, errorOidcEmpty, headers, protocol ?? '', tls);
|
|
62
80
|
}
|
|
63
81
|
|
|
@@ -155,6 +173,7 @@ export default ({
|
|
|
155
173
|
{
|
|
156
174
|
...((await openIdConfig(
|
|
157
175
|
configuration || issuerUrl(issuerId, url),
|
|
176
|
+
issuerHeaders(rest as {tokenFile?: string}),
|
|
158
177
|
)) as object),
|
|
159
178
|
audience,
|
|
160
179
|
issuerId,
|
|
@@ -220,11 +239,12 @@ export default ({
|
|
|
220
239
|
}
|
|
221
240
|
|
|
222
241
|
async function jwks(issuerId: string) {
|
|
242
|
+
const config = await issuerConfig(issuerId);
|
|
223
243
|
return get(
|
|
224
|
-
|
|
244
|
+
config.jwks_uri,
|
|
225
245
|
errorOidcHttp,
|
|
226
246
|
errorOidcEmpty,
|
|
227
|
-
{},
|
|
247
|
+
issuerHeaders(config as {tokenFile?: string}),
|
|
228
248
|
'',
|
|
229
249
|
tls,
|
|
230
250
|
);
|
|
@@ -295,7 +315,21 @@ export default ({
|
|
|
295
315
|
} catch (error) {
|
|
296
316
|
throw errorInvalid({params: {message: (error as Error).message}, cause: error});
|
|
297
317
|
}
|
|
298
|
-
|
|
318
|
+
/**
|
|
319
|
+
* The session step, for the issuers that ask for one.
|
|
320
|
+
*
|
|
321
|
+
* It is what hands a caller its session — an actor, a session id and the MLE key pair its
|
|
322
|
+
* later calls are decrypted with — by asking the `identity` namespace, and the framework's
|
|
323
|
+
* own issuer asks for it by name. An issuer may ask too (`session: true`), which is how a
|
|
324
|
+
* realm that answers its own login keeps the framework's session contract without running
|
|
325
|
+
* the framework's login realm: it answers `identity.checkInternal` itself. An issuer that
|
|
326
|
+
* asks for nothing gets nothing — a cluster issuer answers for the caller by its signature
|
|
327
|
+
* alone — and asking an `identity` service that does not exist is what made a verified
|
|
328
|
+
* token fail with `ENOTFOUND identity.<namespace>.svc.cluster.local` (F-494).
|
|
329
|
+
*/
|
|
330
|
+
const wantsSession =
|
|
331
|
+
(config as {session?: boolean})?.session ?? decoded.iss === 'blong-login';
|
|
332
|
+
if (session && wantsSession && !decoded.ses) await session(decoded);
|
|
299
333
|
return {
|
|
300
334
|
...decoded,
|
|
301
335
|
config,
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type IErrorFactory,
|
|
3
|
+
type ILocal,
|
|
4
|
+
type ILog,
|
|
5
|
+
type IPlatformApi,
|
|
6
|
+
} from '@feasibleone/blong/types';
|
|
7
|
+
import tap from 'tap';
|
|
8
|
+
|
|
9
|
+
import RemoteImpl from './Remote.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `canSkipSocket` in its three shapes each: all, none, and the pattern in between.
|
|
13
|
+
*
|
|
14
|
+
* A process that carries a whole suite answers most of what it can reach, and one that carries a single
|
|
15
|
+
* realm has no reason to answer anything — but a process that serves some of what it can reach and has
|
|
16
|
+
* to send the rest out is neither: the pattern is the middle, read against the method name, and as a
|
|
17
|
+
* *source* rather than a `RegExp` because a config is data.
|
|
18
|
+
*
|
|
19
|
+
* The check is `protected` so this can pin it: the alternative is a transport, and a test that dials
|
|
20
|
+
* localhost asserts on a machine's state rather than on the code. What it does *not* pin is when the
|
|
21
|
+
* question is asked — a configured gateway claims the call first, and a method kept out of the gateway
|
|
22
|
+
* is the codec's business (`skipGateway.test.ts`) — which is why nothing here is about gateways.
|
|
23
|
+
*/
|
|
24
|
+
class Probe extends RemoteImpl {
|
|
25
|
+
public answers(methodName: string | undefined): boolean {
|
|
26
|
+
return this.canAnswerLocally(methodName);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const build = (canSkipSocket: boolean | string): Probe =>
|
|
31
|
+
new Probe(
|
|
32
|
+
{canSkipSocket},
|
|
33
|
+
{
|
|
34
|
+
log: {logger: () => ({warn: () => undefined})} as unknown as ILog,
|
|
35
|
+
error: {register: () => ({})} as unknown as IErrorFactory,
|
|
36
|
+
local: {get: () => undefined, register: () => undefined} as unknown as ILocal,
|
|
37
|
+
platform: {} as unknown as IPlatformApi,
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
tap.test('canSkipSocket=true answers any method', async t => {
|
|
42
|
+
const probe = build(true);
|
|
43
|
+
t.equal(probe.answers('kustomize.deployment.find'), true, 'a realm method');
|
|
44
|
+
t.equal(probe.answers('subject.object.schema'), true, 'another namespace');
|
|
45
|
+
t.equal(probe.answers(undefined), true, 'and the end of the scale is not a pattern');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
tap.test('canSkipSocket=false answers none', async t => {
|
|
49
|
+
const probe = build(false);
|
|
50
|
+
t.equal(probe.answers('kustomize.deployment.find'), false, 'a realm method travels');
|
|
51
|
+
t.equal(probe.answers(undefined), false, 'and a nameless call travels');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
tap.test('canSkipSocket as a pattern answers what it matches', async t => {
|
|
55
|
+
const probe = build('^(kustomize|srv)\\.');
|
|
56
|
+
t.equal(probe.answers('kustomize.deployment.find'), true, 'the realm serves this one');
|
|
57
|
+
t.equal(probe.answers('srv.db.queryBuilder'), true, 'and this one');
|
|
58
|
+
t.equal(
|
|
59
|
+
probe.answers('access.user.find'),
|
|
60
|
+
false,
|
|
61
|
+
'while another namespace is sent out, which is what the pattern is for',
|
|
62
|
+
);
|
|
63
|
+
t.equal(probe.answers(undefined), false, 'and a nameless call travels');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
tap.test('canSkipSocket that is not a regular expression answers none', async t => {
|
|
67
|
+
// `(` on its own throws when it is compiled. Reported, and treated as nothing matching: a typo in
|
|
68
|
+
// a config must not decide where every call goes.
|
|
69
|
+
const probe = build('(');
|
|
70
|
+
t.equal(probe.answers('kustomize.deployment.find'), false, 'the call travels');
|
|
71
|
+
t.equal(
|
|
72
|
+
probe.answers('kustomize.deployment.find'),
|
|
73
|
+
false,
|
|
74
|
+
'and keeps travelling, not throwing',
|
|
75
|
+
);
|
|
76
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type {IMeta} from '@feasibleone/blong/types';
|
|
2
|
+
import tap from 'tap';
|
|
3
|
+
|
|
4
|
+
import GatewayCodecImpl from './GatewayCodec.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `skipGateway` in its three shapes, pinned where the routing decision is made.
|
|
8
|
+
*
|
|
9
|
+
* A gateway is where an *outside* caller reaches a process, and `gateway()` is what answers whether a
|
|
10
|
+
* call is handed to one. A method that has to be answered at home therefore has to be kept out *here*:
|
|
11
|
+
* the resolver asks its own question (`remote.canSkipSocket`, `remoteCanSkip.test.ts`) only after this
|
|
12
|
+
* one has let the call through, which is the order that lets a deployment route a namespace through a
|
|
13
|
+
* gateway and still keep one of its own methods in-process. The framework's own session check is that
|
|
14
|
+
* method — `identity.checkInternal`, asked by the process that verified the token, from inside the
|
|
15
|
+
* verification — and it was a hardcoded name here until this setting replaced it (F-497).
|
|
16
|
+
*
|
|
17
|
+
* The codec is built for real rather than mocked: what is pinned is the answer of the public method, and
|
|
18
|
+
* a stub of the class would let the two disagree. Its OIDC client discovers nothing until a token is
|
|
19
|
+
* verified, so building one costs no network.
|
|
20
|
+
*/
|
|
21
|
+
const build = (config: {skipGateway?: boolean | string; gateway?: object}) =>
|
|
22
|
+
new GatewayCodecImpl(
|
|
23
|
+
config,
|
|
24
|
+
'http',
|
|
25
|
+
'8091',
|
|
26
|
+
{register: () => ({})} as never,
|
|
27
|
+
async () => undefined,
|
|
28
|
+
undefined as never,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
/** The address the configured gateway answers at, as `gateway()` hands it back. */
|
|
32
|
+
const GATEWAY = {
|
|
33
|
+
deploy: {protocol: 'http', hostname: 'the-gateway', port: '8080'},
|
|
34
|
+
identity: {protocol: 'http', hostname: 'the-gateway', port: '8080'},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
tap.test('nothing is skipped unless it is asked for', async t => {
|
|
38
|
+
const codec = build({gateway: GATEWAY});
|
|
39
|
+
t.same(
|
|
40
|
+
codec.gateway({method: 'identity.checkInternal'} as IMeta),
|
|
41
|
+
{...GATEWAY.identity, method: 'identity.checkInternal'},
|
|
42
|
+
'a namespace routed through the gateway still is',
|
|
43
|
+
);
|
|
44
|
+
t.equal(
|
|
45
|
+
codec.gateway({method: 'remote.unknown.nothing'} as IMeta),
|
|
46
|
+
undefined,
|
|
47
|
+
'and one the gateway does not name is not routed at all',
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
tap.test('skipGateway=true keeps every call at home', async t => {
|
|
52
|
+
const codec = build({gateway: GATEWAY, skipGateway: true});
|
|
53
|
+
t.equal(
|
|
54
|
+
codec.gateway({method: 'kustomize.deployment.find'} as IMeta),
|
|
55
|
+
undefined,
|
|
56
|
+
'even where the gateway names the namespace',
|
|
57
|
+
);
|
|
58
|
+
t.equal(
|
|
59
|
+
codec.gateway({method: 'identity.checkInternal'} as IMeta),
|
|
60
|
+
undefined,
|
|
61
|
+
'and the session',
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
tap.test('skipGateway as a pattern skips what it matches', async t => {
|
|
66
|
+
const codec = build({gateway: GATEWAY, skipGateway: '^identity\\.'});
|
|
67
|
+
t.equal(
|
|
68
|
+
codec.gateway({method: 'identity.checkInternal'} as IMeta),
|
|
69
|
+
undefined,
|
|
70
|
+
'the method the framework asks of the process that verified the token',
|
|
71
|
+
);
|
|
72
|
+
t.same(
|
|
73
|
+
codec.gateway({method: 'deploy.kustomize.deployment.find'} as IMeta),
|
|
74
|
+
{...GATEWAY.deploy, method: 'deploy.kustomize.deployment.find'},
|
|
75
|
+
'while the rest still goes through the gateway it is configured for',
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
tap.test('the setting beats a gateway the caller asked for', async t => {
|
|
80
|
+
// `$meta.gateway` is how a caller routes a call itself. A method named in `skipGateway` does not
|
|
81
|
+
// take that route either: the setting says this process answers it, and a caller's preference is
|
|
82
|
+
// not a reason for the session check to leave the process that holds the handshake.
|
|
83
|
+
const codec = build({skipGateway: 'identity.checkInternal'});
|
|
84
|
+
t.equal(
|
|
85
|
+
codec.gateway({
|
|
86
|
+
method: 'identity.checkInternal',
|
|
87
|
+
gateway: {hostname: 'somewhere-else'},
|
|
88
|
+
} as IMeta),
|
|
89
|
+
undefined,
|
|
90
|
+
'the skip wins',
|
|
91
|
+
);
|
|
92
|
+
t.same(
|
|
93
|
+
codec.gateway({method: 'other.method', gateway: {hostname: 'somewhere-else'}} as IMeta),
|
|
94
|
+
{hostname: 'somewhere-else', method: 'other.method'},
|
|
95
|
+
'and a call it does not name keeps the route it was given',
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
tap.test('a skip that is not a regular expression skips nothing', async t => {
|
|
100
|
+
// `(` throws when it is compiled, and a broken pattern must not decide that no call is ever routed:
|
|
101
|
+
// treated as nothing matching, which leaves the gateway doing what it did.
|
|
102
|
+
const codec = build({gateway: GATEWAY, skipGateway: '('});
|
|
103
|
+
t.same(
|
|
104
|
+
codec.gateway({method: 'identity.checkInternal'} as IMeta),
|
|
105
|
+
{...GATEWAY.identity, method: 'identity.checkInternal'},
|
|
106
|
+
'the call is routed as it would have been',
|
|
107
|
+
);
|
|
108
|
+
t.ok(
|
|
109
|
+
codec.gateway({method: 'deploy.kustomize.deployment.find'} as IMeta),
|
|
110
|
+
'and keeps routing, not throwing',
|
|
111
|
+
);
|
|
112
|
+
});
|