@homeflare/alchemy 0.48.0 → 0.49.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 +11 -0
- package/dist/cloudflare/cloudflared-tunnel-api.d.ts +87 -0
- package/dist/cloudflare/cloudflared-tunnel-api.d.ts.map +1 -0
- package/dist/cloudflare/cloudflared-tunnel-form.d.ts +49 -0
- package/dist/cloudflare/cloudflared-tunnel-form.d.ts.map +1 -0
- package/dist/cloudflare/cloudflared-tunnel-harness.d.ts +45 -0
- package/dist/cloudflare/cloudflared-tunnel-harness.d.ts.map +1 -0
- package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts +21 -0
- package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/cloudflared-tunnel.d.ts +39 -0
- package/dist/cloudflare/cloudflared-tunnel.d.ts.map +1 -0
- package/dist/cloudflare/fake-access.d.ts +40 -0
- package/dist/cloudflare/fake-access.d.ts.map +1 -0
- package/dist/cloudflare/fake-mesh.d.ts +7 -2
- package/dist/cloudflare/fake-mesh.d.ts.map +1 -1
- package/dist/cloudflare/fake-tunnel.d.ts +48 -0
- package/dist/cloudflare/fake-tunnel.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +15 -4
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +518 -7
- package/dist/cloudflare/index.js.map +15 -4
- package/dist/cloudflare/providers.d.ts +1 -1
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/cloudflare/saas-oidc-api.d.ts +88 -0
- package/dist/cloudflare/saas-oidc-api.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-consumer.d.ts +22 -0
- package/dist/cloudflare/saas-oidc-consumer.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-error.d.ts +22 -0
- package/dist/cloudflare/saas-oidc-error.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-form.d.ts +48 -0
- package/dist/cloudflare/saas-oidc-form.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-harness.d.ts +50 -0
- package/dist/cloudflare/saas-oidc-harness.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-lifecycle.d.ts +14 -0
- package/dist/cloudflare/saas-oidc-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-props.d.ts +139 -0
- package/dist/cloudflare/saas-oidc-props.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-team.d.ts +9 -0
- package/dist/cloudflare/saas-oidc-team.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-wire.d.ts +58 -0
- package/dist/cloudflare/saas-oidc-wire.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc.d.ts +36 -0
- package/dist/cloudflare/saas-oidc.d.ts.map +1 -0
- package/dist/talos/cluster-adapter-errors.d.ts.map +1 -1
- package/dist/talos/cluster-adapter.d.ts +2 -1
- package/dist/talos/cluster-adapter.d.ts.map +1 -1
- package/dist/talos/index.js +19 -8
- package/dist/talos/index.js.map +6 -6
- package/dist/talos/mint-temp-file.d.ts.map +1 -1
- package/dist/talos/talosctl.d.ts +29 -0
- package/dist/talos/talosctl.d.ts.map +1 -1
- package/docs/cloudflared-tunnel.md +136 -0
- package/docs/credentials.md +1 -1
- package/docs/ownership.md +3 -2
- package/docs/saas-oidc.md +185 -0
- package/docs/talos-openbao-adapter.md +3 -2
- package/docs/upstream-conformance-families.md +1 -1
- package/docs/upstream-conformance.md +3 -0
- package/package.json +1 -1
- package/src/cloudflare/cloudflared-tunnel-api.ts +150 -0
- package/src/cloudflare/cloudflared-tunnel-edges.test.ts +179 -0
- package/src/cloudflare/cloudflared-tunnel-form.ts +78 -0
- package/src/cloudflare/cloudflared-tunnel-harness.ts +126 -0
- package/src/cloudflare/cloudflared-tunnel-lifecycle.ts +146 -0
- package/src/cloudflare/cloudflared-tunnel-state.test.ts +104 -0
- package/src/cloudflare/cloudflared-tunnel.test.ts +164 -0
- package/src/cloudflare/cloudflared-tunnel.ts +116 -0
- package/src/cloudflare/fake-access.ts +196 -0
- package/src/cloudflare/fake-mesh.ts +6 -3
- package/src/cloudflare/fake-tunnel.ts +148 -0
- package/src/cloudflare/index.ts +27 -4
- package/src/cloudflare/providers.ts +16 -3
- package/src/cloudflare/saas-oidc-api.ts +224 -0
- package/src/cloudflare/saas-oidc-consumer.ts +35 -0
- package/src/cloudflare/saas-oidc-error.ts +52 -0
- package/src/cloudflare/saas-oidc-form.test.ts +227 -0
- package/src/cloudflare/saas-oidc-form.ts +190 -0
- package/src/cloudflare/saas-oidc-harness.ts +140 -0
- package/src/cloudflare/saas-oidc-lifecycle.ts +150 -0
- package/src/cloudflare/saas-oidc-parse-error.test.ts +180 -0
- package/src/cloudflare/saas-oidc-props.ts +147 -0
- package/src/cloudflare/saas-oidc-read.test.ts +85 -0
- package/src/cloudflare/saas-oidc-stable.test.ts +38 -0
- package/src/cloudflare/saas-oidc-state.test.ts +128 -0
- package/src/cloudflare/saas-oidc-team.test.ts +88 -0
- package/src/cloudflare/saas-oidc-team.ts +70 -0
- package/src/cloudflare/saas-oidc-wire.ts +116 -0
- package/src/cloudflare/saas-oidc.test.ts +201 -0
- package/src/cloudflare/saas-oidc.ts +107 -0
- package/src/talos/cluster-adapter-errors.ts +2 -1
- package/src/talos/cluster-adapter-move.test.ts +2 -0
- package/src/talos/cluster-adapter.ts +2 -1
- package/src/talos/mint-temp-file.test.ts +44 -0
- package/src/talos/mint-temp-file.ts +16 -4
- package/src/talos/talosctl-probe-kill.test.ts +56 -0
- package/src/talos/talosctl.test.ts +54 -1
- package/src/talos/talosctl.ts +34 -2
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SaasOidcApplicationProvider against a fake Access API (fake-access.ts), its handlers called the
|
|
3
|
+
* way the engine calls them. Read, delete and adoption: saas-oidc-read.test.ts.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ THE ASSERTIONS THAT MATTER MOST: a public PKCE client is created with
|
|
6
|
+
* `allow_pkce_without_client_secret` and the `authorization_code_with_pkce` grant, and the client
|
|
7
|
+
* secret — which the fake's create response DOES hand out, as the API does on POST — appears in
|
|
8
|
+
* no attribute, no log line and no request body.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, describe, expect, spyOn, test } from 'bun:test';
|
|
11
|
+
import type { StackServices } from 'alchemy/Stack';
|
|
12
|
+
import * as Effect from 'effect/Effect';
|
|
13
|
+
import type * as Layer from 'effect/Layer';
|
|
14
|
+
import { FAKE_CLIENT_SECRET, TEAM, fakeAccess } from './fake-access.ts';
|
|
15
|
+
import { FAKE_ACCOUNT } from './fake-mesh.ts';
|
|
16
|
+
import { providers } from './providers.ts';
|
|
17
|
+
import { LIVE_OIDC, publicClient, reconcile, run, writes } from './saas-oidc-harness.ts';
|
|
18
|
+
import { SaasOidcApplication } from './saas-oidc.ts';
|
|
19
|
+
|
|
20
|
+
// ⛔ Compile-time: a stack's `providers` must be `Layer<…, never, StackServices>`. A provider with
|
|
21
|
+
// an open error channel leaks it here, and `bun run types` catches that.
|
|
22
|
+
type Built = Layer.Success<ReturnType<typeof providers>>;
|
|
23
|
+
const _stackShaped: Layer.Layer<Built, never, StackServices> = providers();
|
|
24
|
+
void _stackShaped;
|
|
25
|
+
|
|
26
|
+
const postBody = (fake: ReturnType<typeof fakeAccess>) =>
|
|
27
|
+
fake.seen.find((s) => s.method === 'POST')?.body as Record<string, unknown>;
|
|
28
|
+
|
|
29
|
+
describe('SaasOidcApplication identity', () => {
|
|
30
|
+
test('the type id is the one the openbao state rows are keyed by', () => {
|
|
31
|
+
expect(SaasOidcApplication.Type).toBe('HomeFlare.Access.SaasOidcApplication');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('create', () => {
|
|
36
|
+
const debug = process.env['DISTILLED_DEBUG_HTTP'];
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
if (debug === undefined) delete process.env['DISTILLED_DEBUG_HTTP'];
|
|
39
|
+
else process.env['DISTILLED_DEBUG_HTTP'] = debug;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a public client is sent as PKCE with no secret, and read back for its client id', async () => {
|
|
43
|
+
const fake = fakeAccess();
|
|
44
|
+
const spies = [spyOn(console, 'log'), spyOn(console, 'error'), spyOn(console, 'warn')];
|
|
45
|
+
const out = await run(fake, (p) => reconcile(p, publicClient()));
|
|
46
|
+
const logged = JSON.stringify(spies.map((spy) => spy.mock.calls));
|
|
47
|
+
for (const spy of spies) spy.mockRestore();
|
|
48
|
+
expect(postBody(fake)).toMatchObject({
|
|
49
|
+
type: 'saas',
|
|
50
|
+
name: 'Headlamp',
|
|
51
|
+
policies: ['policy-admin'],
|
|
52
|
+
saas_app: {
|
|
53
|
+
auth_type: 'oidc',
|
|
54
|
+
grant_types: ['authorization_code_with_pkce'],
|
|
55
|
+
allow_pkce_without_client_secret: true,
|
|
56
|
+
access_token_lifetime: '15m',
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
expect(writes(fake)).toEqual(['POST']);
|
|
60
|
+
expect(out).toMatchObject({ accountId: FAKE_ACCOUNT, teamDomain: TEAM, name: 'Headlamp' });
|
|
61
|
+
expect(out.issuer).toBe(`https://${TEAM}/cdn-cgi/access/sso/oidc/${out.clientId}`);
|
|
62
|
+
expect(out.jwksEndpoint).toBe(`${out.issuer}/jwks`);
|
|
63
|
+
// ⛔ The secret was in the create response. It is nowhere we control.
|
|
64
|
+
expect(JSON.stringify(out)).not.toContain(FAKE_CLIENT_SECRET);
|
|
65
|
+
expect(JSON.stringify(fake.seen)).not.toContain(FAKE_CLIENT_SECRET);
|
|
66
|
+
expect(logged).not.toContain(FAKE_CLIENT_SECRET);
|
|
67
|
+
expect(Object.keys(out)).not.toContain('clientSecret');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('refuses while DISTILLED_DEBUG_HTTP is set, before any write', async () => {
|
|
71
|
+
process.env['DISTILLED_DEBUG_HTTP'] = '1';
|
|
72
|
+
const fake = fakeAccess();
|
|
73
|
+
// The SDK prints the name lookup's (secret-free) response while the variable is set.
|
|
74
|
+
const quiet = spyOn(console, 'error').mockImplementation(() => {});
|
|
75
|
+
const failure = await run(fake, (p) => Effect.flip(reconcile(p, publicClient())));
|
|
76
|
+
quiet.mockRestore();
|
|
77
|
+
expect(String(failure)).toContain('DISTILLED_DEBUG_HTTP');
|
|
78
|
+
expect(writes(fake)).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('an invalid declaration fails before any request', async () => {
|
|
82
|
+
const fake = fakeAccess();
|
|
83
|
+
const failure = await run(fake, (p) =>
|
|
84
|
+
Effect.flip(reconcile(p, publicClient({ teamDomain: 'https://x.test' }))),
|
|
85
|
+
);
|
|
86
|
+
expect(String(failure)).toContain('teamDomain');
|
|
87
|
+
expect(fake.seen).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('reconcile against a live app', () => {
|
|
92
|
+
test('makes zero writes when nothing drifted', async () => {
|
|
93
|
+
const fake = fakeAccess();
|
|
94
|
+
const [first, second] = await run(fake, (p) =>
|
|
95
|
+
Effect.gen(function* () {
|
|
96
|
+
const created = yield* reconcile(p, publicClient());
|
|
97
|
+
return [created, yield* reconcile(p, publicClient(), created)] as const;
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
expect(second).toEqual(first);
|
|
101
|
+
expect(writes(fake)).toEqual(['POST']);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a changed redirect URI is one PUT, and the client id survives', async () => {
|
|
105
|
+
const fake = fakeAccess();
|
|
106
|
+
const base = publicClient();
|
|
107
|
+
const changed = {
|
|
108
|
+
...base,
|
|
109
|
+
saasApp: { ...base.saasApp, redirectUris: ['https://headlamp.example.test/new'] },
|
|
110
|
+
};
|
|
111
|
+
const [first, second] = await run(fake, (p) =>
|
|
112
|
+
Effect.gen(function* () {
|
|
113
|
+
const created = yield* reconcile(p, base);
|
|
114
|
+
return [created, yield* reconcile(p, changed, created)] as const;
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
expect(writes(fake)).toEqual(['POST', 'PUT']);
|
|
118
|
+
expect(second.redirectUris).toEqual(['https://headlamp.example.test/new']);
|
|
119
|
+
expect(second.clientId).toBe(first.clientId);
|
|
120
|
+
expect(second.applicationId).toBe(first.applicationId);
|
|
121
|
+
expect(JSON.stringify(fake.seen)).not.toContain(FAKE_CLIENT_SECRET);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('a reordered policy list is one PUT that carries the new order', async () => {
|
|
125
|
+
const fake = fakeAccess();
|
|
126
|
+
const base = publicClient({ policies: ['policy-a', 'policy-b'] });
|
|
127
|
+
const swapped = { ...base, policies: ['policy-b', 'policy-a'] };
|
|
128
|
+
const second = await run(fake, (p) =>
|
|
129
|
+
Effect.gen(function* () {
|
|
130
|
+
const created = yield* reconcile(p, base);
|
|
131
|
+
return yield* reconcile(p, swapped, created);
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
expect(second.applicationId).toBeDefined();
|
|
135
|
+
expect(writes(fake)).toEqual(['POST', 'PUT']);
|
|
136
|
+
expect(fake.seen.filter((s) => s.method === 'PUT')[0]?.body).toMatchObject({
|
|
137
|
+
policies: ['policy-b', 'policy-a'],
|
|
138
|
+
});
|
|
139
|
+
// The fake holds the new order, so the next read sees it in sync.
|
|
140
|
+
expect(fake.apps.get(second.applicationId)?.policies).toEqual(['policy-b', 'policy-a']);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("a declared team that is not the app's team is refused before any write", async () => {
|
|
144
|
+
const fake = fakeAccess({ team: 'real.cloudflareaccess.com' });
|
|
145
|
+
const live = fake.seed({ name: 'Headlamp' });
|
|
146
|
+
const failure = await run(fake, (p) =>
|
|
147
|
+
Effect.flip(
|
|
148
|
+
reconcile(
|
|
149
|
+
p,
|
|
150
|
+
publicClient({ applicationId: live.id, teamDomain: 'other.cloudflareaccess.com' }),
|
|
151
|
+
),
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
expect(String(failure)).toContain('real.cloudflareaccess.com');
|
|
155
|
+
expect(writes(fake)).toEqual([]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('an owned row whose app was deleted out of band never adopts a same-named app', async () => {
|
|
159
|
+
const fake = fakeAccess();
|
|
160
|
+
const made = await run(fake, (p) => reconcile(p, publicClient()));
|
|
161
|
+
fake.apps.delete(made.applicationId);
|
|
162
|
+
const impostor = fake.seed({
|
|
163
|
+
name: 'Headlamp',
|
|
164
|
+
policies: ['policy-admin'],
|
|
165
|
+
saas_app: LIVE_OIDC,
|
|
166
|
+
});
|
|
167
|
+
const before = fake.seen.length;
|
|
168
|
+
const out = await run(fake, (p) => reconcile(p, publicClient(), made));
|
|
169
|
+
// A fresh app is created; the same-named one is neither adopted nor written.
|
|
170
|
+
expect(out.applicationId).not.toBe(impostor.id);
|
|
171
|
+
expect(out.applicationId).not.toBe(made.applicationId);
|
|
172
|
+
expect(
|
|
173
|
+
fake.seen
|
|
174
|
+
.slice(before)
|
|
175
|
+
.filter((r) => r.method !== 'GET')
|
|
176
|
+
.map((r) => r.method),
|
|
177
|
+
).toEqual(['POST']);
|
|
178
|
+
expect(fake.apps.get(impostor.id)?.saas_app?.['client_id']).toBe(
|
|
179
|
+
impostor.saas_app?.['client_id'],
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('an id that is not a saas app is refused, never rewritten', async () => {
|
|
184
|
+
const fake = fakeAccess();
|
|
185
|
+
const selfHosted = fake.seed({ name: 'Headlamp', type: 'self_hosted' });
|
|
186
|
+
const failure = await run(fake, (p) =>
|
|
187
|
+
Effect.flip(reconcile(p, publicClient({ applicationId: selfHosted.id }))),
|
|
188
|
+
);
|
|
189
|
+
expect(String(failure)).toContain('not "saas"');
|
|
190
|
+
expect(writes(fake)).toEqual([]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('two saas apps with the declared name are refused, not guessed between', async () => {
|
|
194
|
+
const fake = fakeAccess();
|
|
195
|
+
fake.seed({ name: 'Headlamp' });
|
|
196
|
+
fake.seed({ name: 'Headlamp' });
|
|
197
|
+
const failure = await run(fake, (p) => Effect.flip(reconcile(p, publicClient())));
|
|
198
|
+
expect(String(failure)).toContain('matches 2 live apps');
|
|
199
|
+
expect(writes(fake)).toEqual([]);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HomeFlare.Access.SaasOidcApplication` — a Cloudflare Access SaaS application that makes Access an
|
|
3
|
+
* OIDC identity provider for one relying party (OpenBao's JWT/OIDC auth, Headlamp, a Kubernetes
|
|
4
|
+
* API server). Usage and the secret rules: docs/saas-oidc.md.
|
|
5
|
+
*
|
|
6
|
+
* 🔴 WHY THIS EXISTS ALONGSIDE `Cloudflare.Access.Application`. Alchemy beta.81's Application has
|
|
7
|
+
* no `saas_app` field (its source mentions `saas` only as a type and in two comments), and
|
|
8
|
+
* `@distilled.cloud/cloudflare` rc.13 decodes a saas app's GET and create response WITHOUT it, so
|
|
9
|
+
* the client id cannot be read back through either. See saas-oidc-api.ts.
|
|
10
|
+
*
|
|
11
|
+
* ⛔ THE TYPE ID IS A STATE KEY, AND THIS ONE WAS MOVED, NOT RENAMED. It is the string the
|
|
12
|
+
* homeflare-openbao stack's live `OpenBaoOidcSaas` row was written under. A different string would
|
|
13
|
+
* make the next plan see a new resource and an orphaned one: a second OIDC app, with a new
|
|
14
|
+
* client id and `aud`, and a delete of the live one unless it were retained. `tests` pin it.
|
|
15
|
+
*
|
|
16
|
+
* ★ `retain` BY DEFAULT, like every kit resource whose deletion breaks its consumers: deleting the
|
|
17
|
+
* app deletes the client id every relying party is configured with. The openbao declaration
|
|
18
|
+
* already pipes `RemovalPolicy.retain()`; `delete` stays fully implemented for a declaration
|
|
19
|
+
* that opts in with `.pipe(RemovalPolicy.destroy())`.
|
|
20
|
+
*/
|
|
21
|
+
import { Credentials } from '@distilled.cloud/cloudflare/Credentials';
|
|
22
|
+
import * as Cloudflare from 'alchemy/Cloudflare';
|
|
23
|
+
import * as Provider from 'alchemy/Provider';
|
|
24
|
+
import { Resource, type ResourceClass } from 'alchemy/Resource';
|
|
25
|
+
import * as Context from 'effect/Context';
|
|
26
|
+
import * as Effect from 'effect/Effect';
|
|
27
|
+
import * as HttpClient from 'effect/http/HttpClient';
|
|
28
|
+
import type { Providers } from './providers.ts';
|
|
29
|
+
import { deleteSaasOidc, readSaasOidc, reconcileSaasOidc } from './saas-oidc-lifecycle.ts';
|
|
30
|
+
import type { SaasOidcApplicationAttributes, SaasOidcApplicationProps } from './saas-oidc-form.ts';
|
|
31
|
+
|
|
32
|
+
export interface SaasOidcApplication extends Resource<
|
|
33
|
+
'HomeFlare.Access.SaasOidcApplication',
|
|
34
|
+
SaasOidcApplicationProps,
|
|
35
|
+
SaasOidcApplicationAttributes,
|
|
36
|
+
never,
|
|
37
|
+
Providers
|
|
38
|
+
> {}
|
|
39
|
+
|
|
40
|
+
export const SaasOidcApplication: ResourceClass<SaasOidcApplication> =
|
|
41
|
+
Resource<SaasOidcApplication>('HomeFlare.Access.SaasOidcApplication', {
|
|
42
|
+
defaultRemovalPolicy: 'retain',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const accountId = Effect.gen(function* () {
|
|
46
|
+
const resolved = yield* yield* Cloudflare.CloudflareEnvironment;
|
|
47
|
+
return resolved.accountId;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* ★ `Provider.effect`, CAPTURING THE THREE SERVICES AT BUILD, as MeshNodeProvider does
|
|
52
|
+
* (mesh-node.ts): correct whether or not the stack also merges `Cloudflare.providers()`.
|
|
53
|
+
*/
|
|
54
|
+
export const SaasOidcApplicationProvider = () =>
|
|
55
|
+
Provider.effect(
|
|
56
|
+
SaasOidcApplication,
|
|
57
|
+
Effect.gen(function* () {
|
|
58
|
+
const services = Context.make(
|
|
59
|
+
Cloudflare.CloudflareEnvironment,
|
|
60
|
+
yield* Cloudflare.CloudflareEnvironment,
|
|
61
|
+
).pipe(
|
|
62
|
+
Context.add(Credentials, yield* Credentials),
|
|
63
|
+
Context.add(HttpClient.HttpClient, yield* HttpClient.HttpClient),
|
|
64
|
+
);
|
|
65
|
+
// ★ Only the three captured services are provided; `createPhysicalName` still takes
|
|
66
|
+
// InstanceId, Stack and Stage from the engine at call time, as in every Alchemy provider.
|
|
67
|
+
const run = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
68
|
+
Effect.provideContext(effect, services);
|
|
69
|
+
|
|
70
|
+
return SaasOidcApplication.Provider.of({
|
|
71
|
+
/**
|
|
72
|
+
* ★ THE ISSUER AND EVERY ENDPOINT ARE STABLE. They depend only on the team domain and the
|
|
73
|
+
* client id, and `checkTeam` refuses any team other than the app's own host, so no update
|
|
74
|
+
* of this app can move them. Alchemy's plan (Plan.ts `withStables`, beta.81) keeps ONLY
|
|
75
|
+
* the listed attributes resolved for consumers of an updated resource; without them, a
|
|
76
|
+
* redirect-URI change would leave `app.issuer` unresolved and re-plan every consumer
|
|
77
|
+
* (the Talos KubeAuthenticationConfig, so a kube-apiserver restart on each node).
|
|
78
|
+
* ⚠️ `teamDomain` is NOT listed: state written by the openbao copy has no such attribute,
|
|
79
|
+
* so a consumer would read `undefined` on the first plan after the move.
|
|
80
|
+
*/
|
|
81
|
+
stables: [
|
|
82
|
+
'applicationId',
|
|
83
|
+
'aud',
|
|
84
|
+
'clientId',
|
|
85
|
+
'accountId',
|
|
86
|
+
'issuer',
|
|
87
|
+
'authorizationEndpoint',
|
|
88
|
+
'tokenEndpoint',
|
|
89
|
+
'jwksEndpoint',
|
|
90
|
+
'userinfoEndpoint',
|
|
91
|
+
'configurationEndpoint',
|
|
92
|
+
],
|
|
93
|
+
/**
|
|
94
|
+
* ⛔ EMPTY, AND `nuke.skip`. Alchemy's own `Cloudflare.Access.Application` already lists
|
|
95
|
+
* every Access application in the account for `alchemy unsafe nuke` (its `list`, saas
|
|
96
|
+
* apps included); a second listing here would make nuke delete each app twice.
|
|
97
|
+
*/
|
|
98
|
+
list: () => Effect.succeed([]),
|
|
99
|
+
nuke: { skip: true },
|
|
100
|
+
read: ({ id, olds, output }) =>
|
|
101
|
+
run(Effect.flatMap(accountId, (account) => readSaasOidc(account, id, olds, output))),
|
|
102
|
+
reconcile: ({ id, news, output }) =>
|
|
103
|
+
run(Effect.flatMap(accountId, (account) => reconcileSaasOidc(account, id, news, output))),
|
|
104
|
+
delete: ({ output }) => run(deleteSaasOidc(output)),
|
|
105
|
+
});
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
@@ -155,7 +155,8 @@ export class TalosClusterIdentityTimeout extends Data.TaggedError('TalosClusterI
|
|
|
155
155
|
override get message(): string {
|
|
156
156
|
return (
|
|
157
157
|
`talos-openbao cluster '${this.cluster}': no answer to the kube-system uid read within ` +
|
|
158
|
-
`${this.seconds}s
|
|
158
|
+
`${this.seconds}s (the identity read's own deadline, inside the 10 s connect deadline). ` +
|
|
159
|
+
'Refused (failed closed) rather than waiting on a silent apiserver.'
|
|
159
160
|
);
|
|
160
161
|
}
|
|
161
162
|
}
|
|
@@ -97,6 +97,8 @@ test('a silent apiserver fails closed with TalosClusterIdentityTimeout', async (
|
|
|
97
97
|
Effect.runPromise(Effect.provideService(read, spawn, fakeSpawner(vault))),
|
|
98
98
|
);
|
|
99
99
|
expect(message).toContain('TalosClusterIdentityTimeout');
|
|
100
|
+
// The text names the identity read's own deadline inside the 10 s connect deadline.
|
|
101
|
+
expect(message).toContain("the identity read's own deadline, inside the 10 s connect deadline");
|
|
100
102
|
} finally {
|
|
101
103
|
api.restore();
|
|
102
104
|
}
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
* (`TalosClusterMoved`); a move is a NEW identity resource per physical cluster, so the old
|
|
34
34
|
* rows keep their old uid and their cleanup reaches only the old cluster.
|
|
35
35
|
* ⛔ ONE 10 s DEADLINE covers the vault read AND the uid GET (`withConnectDeadline`): a hung `bao`
|
|
36
|
-
*
|
|
36
|
+
* fails with `TalosOpenBaoConnectTimeout` and the `bao` child is killed. The uid GET has its own
|
|
37
|
+
* 5 s deadline inside it, so a silent apiserver fails first with `TalosClusterIdentityTimeout`.
|
|
37
38
|
* ⚠️ The in-flight HTTPS GET is NOT aborted: upstream `readObject` takes no signal
|
|
38
39
|
* (`internal/client.ts:75`), so the socket lives until the OS gives up. Left for the upstream ask.
|
|
39
40
|
*
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mintKvTempFile` when the write fails AFTER the exclusive 0600 create (disk full): the release
|
|
3
|
+
* is registered only once the acquire step succeeds, so the file must be removed inside the step.
|
|
4
|
+
* Fails on 0.48.0, which left the partial file behind.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { expect, spyOn, test } from 'bun:test';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import { mintKvTempFile } from './mint-temp-file.ts';
|
|
11
|
+
|
|
12
|
+
const leftovers = (label: string) =>
|
|
13
|
+
fs.readdirSync(tmpdir()).filter((name) => name.startsWith(`hf-talos-${label}-`));
|
|
14
|
+
|
|
15
|
+
test('a write that fails after the exclusive create leaves no temp file behind', async () => {
|
|
16
|
+
const label = `write-fail-${process.pid}`;
|
|
17
|
+
// A non-string body makes writeFileSync throw after openSync already created the file: a real
|
|
18
|
+
// failure on the real code path, with no stub of node:fs.
|
|
19
|
+
const failure = await Effect.runPromise(
|
|
20
|
+
Effect.flip(Effect.scoped(mintKvTempFile(42 as unknown as string, label))),
|
|
21
|
+
);
|
|
22
|
+
expect(failure.message).toContain(`writing ${label} temp file`);
|
|
23
|
+
expect(leftovers(label)).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('a close that fails after a good write leaves no temp file behind', async () => {
|
|
27
|
+
const label = `close-fail-${process.pid}`;
|
|
28
|
+
const realClose = fs.closeSync;
|
|
29
|
+
// EIO/EDQUOT surface at close(2): close for real (no leaked fd), then report the failure.
|
|
30
|
+
const spy = spyOn(fs, 'closeSync').mockImplementation((fd: number) => {
|
|
31
|
+
realClose(fd);
|
|
32
|
+
throw new Error('EIO: simulated close failure');
|
|
33
|
+
});
|
|
34
|
+
try {
|
|
35
|
+
const failure = await Effect.runPromise(
|
|
36
|
+
Effect.flip(Effect.scoped(mintKvTempFile('credential\n', label))),
|
|
37
|
+
);
|
|
38
|
+
expect(failure.message).toContain('EIO: simulated close failure');
|
|
39
|
+
expect(spy).toHaveBeenCalled();
|
|
40
|
+
} finally {
|
|
41
|
+
spy.mockRestore();
|
|
42
|
+
}
|
|
43
|
+
expect(leftovers(label)).toEqual([]);
|
|
44
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* credentials.ts (2026-10-06, red team PR 355) once the Node-runtime fix pushed that file past the
|
|
4
4
|
* 250-line cap; the split mirrors credentials-write.ts, which was split out for its own header.
|
|
5
5
|
*/
|
|
6
|
-
import { closeSync, openSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { closeSync, openSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { unlink } from 'node:fs/promises';
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
@@ -39,10 +39,22 @@ export const mintKvTempFile = (
|
|
|
39
39
|
Effect.try({
|
|
40
40
|
try: () => {
|
|
41
41
|
const fd = openSync(path, 'wx', 0o600);
|
|
42
|
+
// ⛔ The release below registers only after this step succeeds, so ANY failure here (a
|
|
43
|
+
// disk-full write, or a close that errors: EIO/EDQUOT surface at close on a quota'd
|
|
44
|
+
// tmpdir) would leave the 0600 file, possibly holding part of the credential, behind.
|
|
42
45
|
try {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
try {
|
|
47
|
+
writeFileSync(fd, raw);
|
|
48
|
+
} finally {
|
|
49
|
+
closeSync(fd);
|
|
50
|
+
}
|
|
51
|
+
} catch (cause) {
|
|
52
|
+
try {
|
|
53
|
+
unlinkSync(path);
|
|
54
|
+
} catch {
|
|
55
|
+
// nothing more to remove
|
|
56
|
+
}
|
|
57
|
+
throw cause;
|
|
46
58
|
}
|
|
47
59
|
},
|
|
48
60
|
catch: (cause) => new Error(`writing ${label} temp file: ${String(cause)}`),
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The version probe's deadline against a REAL hanging child: the claim in talosctl.ts that a timed-out
|
|
3
|
+
* probe kills the child (and its process group) is proved here with `process.kill(pid, 0)`, not with a
|
|
4
|
+
* spawner that never starts anything. The script lives in a private os.tmpdir() directory removed on
|
|
5
|
+
* every exit path, and sleeps 60 s so a surviving child is unmistakable.
|
|
6
|
+
*/
|
|
7
|
+
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { afterAll, expect, test } from 'bun:test';
|
|
11
|
+
import * as Effect from 'effect/Effect';
|
|
12
|
+
import * as NodeServices from '@effect/platform-node/NodeServices';
|
|
13
|
+
import { trustBoundaryForTests } from './trust-boundary.seam.ts';
|
|
14
|
+
import { TalosBinaryRefused, talosctl } from './talosctl.ts';
|
|
15
|
+
|
|
16
|
+
const dir = mkdtempSync(join(tmpdir(), 'hf-probe-kill-'));
|
|
17
|
+
chmodSync(dir, 0o755);
|
|
18
|
+
trustBoundaryForTests(dir);
|
|
19
|
+
afterAll(() => rmSync(dir, { force: true, recursive: true }));
|
|
20
|
+
|
|
21
|
+
const pidFile = join(dir, 'pids');
|
|
22
|
+
const script = join(dir, 'talosctl');
|
|
23
|
+
// The shell and its `sleep` grandchild both record their pid: the grandchild only dies if the whole
|
|
24
|
+
// process group is killed, not just the direct child.
|
|
25
|
+
writeFileSync(script, `#!/bin/sh\nsleep 60 &\necho "$$ $!" > '${pidFile}'\nwait\n`, {
|
|
26
|
+
mode: 0o755,
|
|
27
|
+
});
|
|
28
|
+
chmodSync(script, 0o755);
|
|
29
|
+
|
|
30
|
+
const alive = (pid: number) => {
|
|
31
|
+
try {
|
|
32
|
+
process.kill(pid, 0);
|
|
33
|
+
return true;
|
|
34
|
+
} catch (cause) {
|
|
35
|
+
return (cause as NodeJS.ErrnoException).code !== 'ESRCH';
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
test('a probe that hangs is refused and its real child and process group are gone', async () => {
|
|
40
|
+
const outcome = await Effect.runPromise(
|
|
41
|
+
Effect.flip(
|
|
42
|
+
talosctl(['version'], {
|
|
43
|
+
binary: script,
|
|
44
|
+
talosconfigPath: 'unused.yaml',
|
|
45
|
+
versionProbeTimeout: '1 seconds',
|
|
46
|
+
}).pipe(Effect.provide(NodeServices.layer)),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
expect(outcome).toBeInstanceOf(TalosBinaryRefused);
|
|
50
|
+
const pids = readFileSync(pidFile, 'utf8').trim().split(' ').map(Number);
|
|
51
|
+
expect(pids).toHaveLength(2);
|
|
52
|
+
// SIGKILL follows SIGTERM after `forceKillAfter` (1 s), so allow it to land.
|
|
53
|
+
const deadline = Date.now() + 5000;
|
|
54
|
+
while (pids.some(alive) && Date.now() < deadline) await Bun.sleep(100);
|
|
55
|
+
expect(pids.map(alive)).toEqual([false, false]);
|
|
56
|
+
});
|
|
@@ -6,11 +6,17 @@ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { afterAll, expect, test } from 'bun:test';
|
|
9
|
+
import * as Duration from 'effect/Duration';
|
|
9
10
|
import * as Effect from 'effect/Effect';
|
|
10
11
|
import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
|
|
11
12
|
import { type FakeCall, fakeSpawner } from './fake-process.ts';
|
|
12
13
|
import { trustBoundaryForTests } from './trust-boundary.seam.ts';
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
TALOSCTL_BINARY_ENV,
|
|
16
|
+
TalosBinaryRefused,
|
|
17
|
+
VERSION_PROBE_TIMEOUT,
|
|
18
|
+
talosctl,
|
|
19
|
+
} from './talosctl.ts';
|
|
14
20
|
|
|
15
21
|
// ⚠️ A real, vetted file in a private directory: the system `sh` or `bun` may be a symlink or live
|
|
16
22
|
// in a writable directory, which the vetting (correctly) refuses.
|
|
@@ -108,3 +114,50 @@ test('a default talosctl that is not on PATH is refused, never run by bare name'
|
|
|
108
114
|
}),
|
|
109
115
|
);
|
|
110
116
|
});
|
|
117
|
+
|
|
118
|
+
test('a version probe that never answers is refused with a typed error, not awaited forever', async () => {
|
|
119
|
+
await withEnv(fixture, async () => {
|
|
120
|
+
const started = Date.now();
|
|
121
|
+
const outcome = await Effect.runPromise(
|
|
122
|
+
Effect.flip(
|
|
123
|
+
Effect.provideService(
|
|
124
|
+
// The default is 10 s (asserted below); a short override keeps the suite fast.
|
|
125
|
+
talosctl(['version'], {
|
|
126
|
+
talosconfigPath: 'unused.yaml',
|
|
127
|
+
versionProbeTimeout: '2 seconds',
|
|
128
|
+
}),
|
|
129
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
130
|
+
ChildProcessSpawner.make(() => Effect.never),
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
expect(outcome).toBeInstanceOf(TalosBinaryRefused);
|
|
135
|
+
expect(outcome.message).toContain('did not answer within');
|
|
136
|
+
expect(Date.now() - started).toBeLessThan(4000);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('the probe deadline is overridable per call and still fails closed', async () => {
|
|
141
|
+
await withEnv(fixture, async () => {
|
|
142
|
+
const started = Date.now();
|
|
143
|
+
const outcome = await Effect.runPromise(
|
|
144
|
+
Effect.flip(
|
|
145
|
+
Effect.provideService(
|
|
146
|
+
talosctl(['version'], {
|
|
147
|
+
talosconfigPath: 'unused.yaml',
|
|
148
|
+
versionProbeTimeout: '1 seconds',
|
|
149
|
+
}),
|
|
150
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
151
|
+
ChildProcessSpawner.make(() => Effect.never),
|
|
152
|
+
),
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
expect(outcome).toBeInstanceOf(TalosBinaryRefused);
|
|
156
|
+
expect(outcome.message).toContain('did not answer within 1s');
|
|
157
|
+
expect(Date.now() - started).toBeLessThan(5000);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('the default probe deadline is 10 s', () => {
|
|
162
|
+
expect(Duration.toSeconds(VERSION_PROBE_TIMEOUT)).toBe(10);
|
|
163
|
+
});
|
package/src/talos/talosctl.ts
CHANGED
|
@@ -78,6 +78,13 @@ export type TalosRunOptions = {
|
|
|
78
78
|
* since insecure mode ignores the file's certs rather than requiring their absence.
|
|
79
79
|
*/
|
|
80
80
|
readonly insecure?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Deadline for the `version --client` probe of the binary; past it the binary is refused (fails
|
|
83
|
+
* closed, never skipped). A cold first run of the Go binary under CI load can exceed a tight
|
|
84
|
+
* limit, so a caller may raise it.
|
|
85
|
+
* @default {@link VERSION_PROBE_TIMEOUT} (10 s)
|
|
86
|
+
*/
|
|
87
|
+
readonly versionProbeTimeout?: Duration.Input;
|
|
81
88
|
};
|
|
82
89
|
|
|
83
90
|
const capture = (binary: string, argv: readonly string[]) =>
|
|
@@ -117,6 +124,16 @@ const capture = (binary: string, argv: readonly string[]) =>
|
|
|
117
124
|
);
|
|
118
125
|
});
|
|
119
126
|
|
|
127
|
+
/**
|
|
128
|
+
* ⛔ THE VERSION PROBE IS BOUNDED. `talosctl version --client` talks to no cluster and answers in
|
|
129
|
+
* milliseconds, so a probe silent for this long is a hung or hostile binary: it is refused
|
|
130
|
+
* (failed closed, typed) instead of parking the caller forever. Interrupting the scope kills the
|
|
131
|
+
* child with its process group (proved against a real hanging child by talosctl-probe-kill.test.ts).
|
|
132
|
+
* ⚠️ 10 s, not 3 s: a cold first run of the Go binary under CI load can take seconds, and a
|
|
133
|
+
* refusal there is a false alarm. Override per call with `TalosRunOptions.versionProbeTimeout`.
|
|
134
|
+
*/
|
|
135
|
+
export const VERSION_PROBE_TIMEOUT = Duration.seconds(10);
|
|
136
|
+
|
|
120
137
|
/** ⚠️ EXACT TOKEN MATCH: `includes` would accept v1.14.20 for v1.14.2. */
|
|
121
138
|
const reportsPinnedVersion = (stdout: string) =>
|
|
122
139
|
stdout.split(/[^\w.+-]+/).includes(TALOSCTL_PINNED_VERSION);
|
|
@@ -125,8 +142,23 @@ const reportsPinnedVersion = (stdout: string) =>
|
|
|
125
142
|
* ⛔ AN OVERRIDE MUST REPORT THE PINNED CLIENT VERSION (`version --client`, no talosconfig, so
|
|
126
143
|
* nothing secret reaches an untrusted binary before it is vetted).
|
|
127
144
|
*/
|
|
128
|
-
const checkOverrideVersion = (
|
|
145
|
+
const checkOverrideVersion = (
|
|
146
|
+
binary: string,
|
|
147
|
+
source: string,
|
|
148
|
+
timeout: Duration.Input = VERSION_PROBE_TIMEOUT,
|
|
149
|
+
) =>
|
|
129
150
|
capture(binary, ['version', '--client']).pipe(
|
|
151
|
+
Effect.timeoutOrElse({
|
|
152
|
+
duration: timeout,
|
|
153
|
+
orElse: () =>
|
|
154
|
+
Effect.fail(
|
|
155
|
+
new TalosBinaryRefused(
|
|
156
|
+
'version --client',
|
|
157
|
+
`${source} did not answer within ${String(Duration.toSeconds(timeout))}s`,
|
|
158
|
+
binary,
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
}),
|
|
130
162
|
Effect.flatMap((out) =>
|
|
131
163
|
out.exitCode === 0 && reportsPinnedVersion(out.stdout)
|
|
132
164
|
? Effect.void
|
|
@@ -155,7 +187,7 @@ export const talosctl = (args: readonly string[], options: TalosRunOptions) =>
|
|
|
155
187
|
: TALOSCTL_BINARY_ENV;
|
|
156
188
|
// ⛔ Launch exactly the vetted canonical path, for the probe and the credentialed call alike.
|
|
157
189
|
const binary = yield* checkBinaryPath(resolved, source);
|
|
158
|
-
yield* checkOverrideVersion(binary, source);
|
|
190
|
+
yield* checkOverrideVersion(binary, source, options.versionProbeTimeout);
|
|
159
191
|
const argv = [
|
|
160
192
|
...args,
|
|
161
193
|
'--talosconfig',
|