@homeflare/alchemy 0.47.0 → 0.48.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 +6 -32
- package/dist/talos/bao-env.d.ts +7 -0
- package/dist/talos/bao-env.d.ts.map +1 -0
- package/dist/talos/cluster-adapter-errors.d.ts +134 -0
- package/dist/talos/cluster-adapter-errors.d.ts.map +1 -0
- package/dist/talos/cluster-adapter.d.ts +115 -0
- package/dist/talos/cluster-adapter.d.ts.map +1 -0
- package/dist/talos/cluster-identity.d.ts +28 -0
- package/dist/talos/cluster-identity.d.ts.map +1 -0
- package/dist/talos/cluster-transport.d.ts +64 -0
- package/dist/talos/cluster-transport.d.ts.map +1 -0
- package/dist/talos/credentials-write.d.ts.map +1 -1
- package/dist/talos/credentials.d.ts +10 -19
- package/dist/talos/credentials.d.ts.map +1 -1
- package/dist/talos/fake-apiserver.d.ts +9 -0
- package/dist/talos/fake-apiserver.d.ts.map +1 -0
- package/dist/talos/index.d.ts +7 -0
- package/dist/talos/index.d.ts.map +1 -1
- package/dist/talos/index.js +669 -167
- package/dist/talos/index.js.map +22 -10
- package/dist/talos/kubeconfig-attrs.d.ts +14 -0
- package/dist/talos/kubeconfig-attrs.d.ts.map +1 -1
- package/dist/talos/kubeconfig-doc.d.ts +23 -0
- package/dist/talos/kubeconfig-doc.d.ts.map +1 -0
- package/dist/talos/kubeconfig.d.ts +0 -12
- package/dist/talos/kubeconfig.d.ts.map +1 -1
- package/dist/talos/literal-connection.d.ts +16 -0
- package/dist/talos/literal-connection.d.ts.map +1 -0
- package/dist/talos/machine-config-read.d.ts +1 -1
- package/dist/talos/machine-config-read.d.ts.map +1 -1
- package/dist/talos/mint-temp-file.d.ts +20 -0
- package/dist/talos/mint-temp-file.d.ts.map +1 -0
- package/dist/talos/node-temp.harness.d.ts +2 -0
- package/dist/talos/node-temp.harness.d.ts.map +1 -0
- package/dist/talos/talos-bootstrap-etcd.d.ts +1 -1
- package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -1
- package/dist/talos/talos-cluster-health.d.ts +15 -15
- package/dist/talos/talos-cluster-health.d.ts.map +1 -1
- package/dist/talos/talos-cluster-identity.d.ts +65 -0
- package/dist/talos/talos-cluster-identity.d.ts.map +1 -0
- package/dist/talos/talosctl-binary.d.ts +43 -0
- package/dist/talos/talosctl-binary.d.ts.map +1 -0
- package/dist/talos/talosctl.d.ts +12 -15
- package/dist/talos/talosctl.d.ts.map +1 -1
- package/dist/talos/trust-boundary.d.ts +8 -0
- package/dist/talos/trust-boundary.d.ts.map +1 -0
- package/dist/talos/values.d.ts.map +1 -1
- package/dist/talos/yaml-parse.d.ts +3 -0
- package/dist/talos/yaml-parse.d.ts.map +1 -0
- package/dist/verify/fake-engine.d.ts +2 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/peers.md +32 -5
- package/docs/talos-openbao-adapter.md +44 -0
- package/docs/upstream-conformance-bun.md +61 -24
- package/package.json +116 -2
- package/src/talos/bao-env.ts +16 -0
- package/src/talos/bao-vetting.test.ts +97 -0
- package/src/talos/cluster-adapter-deadline.test.ts +110 -0
- package/src/talos/cluster-adapter-errors.ts +192 -0
- package/src/talos/cluster-adapter-move.test.ts +129 -0
- package/src/talos/cluster-adapter-reconcile.test.ts +165 -0
- package/src/talos/cluster-adapter-refusals.test.ts +102 -0
- package/src/talos/cluster-adapter-rename.test.ts +87 -0
- package/src/talos/cluster-adapter-secrecy.test.ts +243 -0
- package/src/talos/cluster-adapter.fixtures.ts +125 -0
- package/src/talos/cluster-adapter.test.ts +240 -0
- package/src/talos/cluster-adapter.ts +249 -0
- package/src/talos/cluster-identity.ts +63 -0
- package/src/talos/cluster-transport.ts +110 -0
- package/src/talos/credentials-write.ts +27 -5
- package/src/talos/credentials.ts +51 -45
- package/src/talos/fake-apiserver.ts +55 -0
- package/src/talos/fake-process.ts +47 -1
- package/src/talos/index.ts +32 -0
- package/src/talos/kubeconfig-adapter.test.ts +37 -0
- package/src/talos/kubeconfig-attrs.ts +12 -28
- package/src/talos/kubeconfig-doc.ts +74 -0
- package/src/talos/kubeconfig.fixtures.ts +52 -0
- package/src/talos/kubeconfig.test.ts +6 -54
- package/src/talos/kubeconfig.ts +18 -34
- package/src/talos/literal-connection.ts +59 -0
- package/src/talos/mint-temp-file.ts +56 -0
- package/src/talos/node-connect.harness.ts +78 -0
- package/src/talos/node-connect.test.ts +27 -0
- package/src/talos/node-temp.harness.ts +40 -0
- package/src/talos/node-temp.test.ts +26 -0
- package/src/talos/talos-cluster-health-declare.test.ts +54 -0
- package/src/talos/talos-cluster-health-literal.test.ts +69 -0
- package/src/talos/talos-cluster-health.test.ts +104 -18
- package/src/talos/talos-cluster-health.ts +46 -14
- package/src/talos/talos-cluster-identity.test.ts +96 -0
- package/src/talos/talos-cluster-identity.ts +130 -0
- package/src/talos/talosctl-binary.ts +150 -0
- package/src/talos/talosctl-vetting.test.ts +212 -0
- package/src/talos/talosctl.test.ts +110 -0
- package/src/talos/talosctl.ts +101 -15
- package/src/talos/trust-boundary.test.ts +17 -0
- package/src/talos/trust-boundary.ts +45 -0
- package/src/talos/values.ts +5 -12
- package/src/talos/yaml-parse.test.ts +41 -0
- package/src/talos/yaml-parse.ts +28 -0
- package/src/verify/fake-engine.ts +10 -0
- package/dist/talos/fake-process.d.ts +0 -56
- package/dist/talos/fake-process.d.ts.map +0 -1
- package/dist/talos/machine-config-fixtures.d.ts +0 -60
- package/dist/talos/machine-config-fixtures.d.ts.map +0 -1
- package/src/postgres/fixtures/pg-REL_18_6-acl.h.txt +0 -147
- package/src/postgres/fixtures/pg-REL_18_6-aclexplode-func.txt +0 -31
- package/src/postgres/fixtures/pg-REL_18_6-alter-default-privileges-synopsis.txt +0 -82
- package/src/postgres/fixtures/pg-REL_18_6-alter_role.sgml.txt +0 -44
- package/src/postgres/fixtures/pg-REL_18_6-create-database-synopsis.txt +0 -26
- package/src/postgres/fixtures/pg-REL_18_6-create_role.sgml.txt +0 -26
- package/src/postgres/fixtures/pg-REL_18_6-grant-synopsis.txt +0 -95
- package/src/postgres/fixtures/pg-REL_18_6-pg_attribute.h.txt +0 -24
- package/src/postgres/fixtures/pg-REL_18_6-pg_auth_members.h.txt +0 -62
- package/src/postgres/fixtures/pg-REL_18_6-pg_authid.h.txt +0 -70
- package/src/postgres/fixtures/pg-REL_18_6-pg_class.h.txt +0 -33
- package/src/postgres/fixtures/pg-REL_18_6-pg_database.h.txt +0 -67
- package/src/postgres/fixtures/pg-REL_18_6-pg_default_acl.h.txt +0 -81
- package/src/postgres/fixtures/pg-REL_18_6-pg_namespace.h.txt +0 -28
- package/src/postgres/fixtures/pg-REL_18_6-revoke-synopsis.txt +0 -124
- package/src/release/fixtures/openbao/checksums.txt +0 -83
- package/src/release/fixtures/openbao/checksums.txt.gpgsig +0 -0
- package/src/release/fixtures/openbao/darwin-assets.txt +0 -12
- package/src/release/fixtures/openbao/openbao-gpg-pub-20240618.asc +0 -109
- package/src/release/fixtures/victoria/darwin-arm64-assets.txt +0 -22
- package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +0 -2
- package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +0 -2
- package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +0 -2
- package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +0 -8
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Talos.ClusterIdentity` — publishes the physical cluster's uid for the operator to pin. Fake `bao` and fake apiserver only; nothing is written.
|
|
3
|
+
*/
|
|
4
|
+
import { expect, test } from 'bun:test';
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
|
|
7
|
+
import { fakeApiServer } from './fake-apiserver.ts';
|
|
8
|
+
import { fakeSpawner } from './fake-process.ts';
|
|
9
|
+
import {
|
|
10
|
+
diffClusterIdentity,
|
|
11
|
+
readClusterIdentity,
|
|
12
|
+
reconcileClusterIdentity,
|
|
13
|
+
} from './talos-cluster-identity.ts';
|
|
14
|
+
|
|
15
|
+
const b64 = (text: string) => Buffer.from(text).toString('base64');
|
|
16
|
+
const pem = (label: string) => `-----${'BEGIN'} ${label}-----\n${b64(label)}\n`;
|
|
17
|
+
const kubeconfig = `apiVersion: v1
|
|
18
|
+
kind: Config
|
|
19
|
+
clusters:
|
|
20
|
+
- name: c1
|
|
21
|
+
cluster: { server: 'https://c1.cluster.invalid:6443', certificate-authority-data: ${b64(pem('CERTIFICATE'))} }
|
|
22
|
+
contexts:
|
|
23
|
+
- name: admin@c1
|
|
24
|
+
context: { cluster: c1, user: admin@c1 }
|
|
25
|
+
users:
|
|
26
|
+
- name: admin@c1
|
|
27
|
+
user: { client-certificate-data: ${b64(pem('CERTIFICATE'))}, client-key-data: ${b64(pem('PRIVATE KEY'))} }
|
|
28
|
+
`;
|
|
29
|
+
const props = { context: 'admin@c1', target: { cluster: 'c1', mount: 'talos-c1' } };
|
|
30
|
+
const run = <A, E>(effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>) =>
|
|
31
|
+
Effect.runPromise(
|
|
32
|
+
Effect.provideService(
|
|
33
|
+
effect,
|
|
34
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
35
|
+
fakeSpawner(() => ({ stdout: JSON.stringify({ data: { data: { kubeconfig } } }) })),
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
test('reads the kube-system uid and publishes { uid }', async () => {
|
|
40
|
+
const api = fakeApiServer({ 'c1.cluster.invalid': 'uid-c1' });
|
|
41
|
+
try {
|
|
42
|
+
const out = await run(readClusterIdentity(props));
|
|
43
|
+
expect(out).toEqual({ uid: 'uid-c1' });
|
|
44
|
+
expect(api.seen).toEqual(['GET c1.cluster.invalid/api/v1/namespaces/kube-system']);
|
|
45
|
+
} finally {
|
|
46
|
+
api.restore();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const prior = { uid: 'uid-c1' } as const;
|
|
51
|
+
|
|
52
|
+
test('an unchanged uid is a noop', async () => {
|
|
53
|
+
const api = fakeApiServer({ 'c1.cluster.invalid': 'uid-c1' });
|
|
54
|
+
try {
|
|
55
|
+
expect((await run(diffClusterIdentity(props, prior)))?.action).toBe('noop');
|
|
56
|
+
} finally {
|
|
57
|
+
api.restore();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ⛔ A changed uid must never be an `update`: downstream workloads would be force-applied onto the
|
|
62
|
+
// new cluster (see the header of talos-cluster-identity.ts).
|
|
63
|
+
test('a changed uid is refused in diff AND reconcile, never an update', async () => {
|
|
64
|
+
const moved = fakeApiServer({ 'c1.cluster.invalid': 'uid-c2' });
|
|
65
|
+
try {
|
|
66
|
+
const inDiff = await run(diffClusterIdentity(props, prior)).catch((e: unknown) => String(e));
|
|
67
|
+
expect(inDiff).toContain('TalosClusterMoved');
|
|
68
|
+
expect(inDiff).toContain('uid-c2');
|
|
69
|
+
const inReconcile = await run(reconcileClusterIdentity(props, prior)).catch((e: unknown) =>
|
|
70
|
+
String(e),
|
|
71
|
+
);
|
|
72
|
+
expect(inReconcile).toContain('TalosClusterMoved');
|
|
73
|
+
} finally {
|
|
74
|
+
moved.restore();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('reconcile with no saved output publishes the live uid', async () => {
|
|
79
|
+
const api = fakeApiServer({ 'c1.cluster.invalid': 'uid-c2' });
|
|
80
|
+
try {
|
|
81
|
+
const out = await run(reconcileClusterIdentity(props, undefined));
|
|
82
|
+
expect(out.uid).toBe('uid-c2');
|
|
83
|
+
} finally {
|
|
84
|
+
api.restore();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('an apiserver answering without a uid fails closed', async () => {
|
|
89
|
+
const api = fakeApiServer({});
|
|
90
|
+
try {
|
|
91
|
+
const failure = await run(readClusterIdentity(props)).catch((e: unknown) => String(e));
|
|
92
|
+
expect(failure).toContain('TalosClusterIdentityUnreadable');
|
|
93
|
+
} finally {
|
|
94
|
+
api.restore();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Talos.ClusterIdentity` — a read-only resource that reports WHICH physical cluster a name means.
|
|
3
|
+
*
|
|
4
|
+
* ★ It reads the vault kubeconfig and GETs the kube-system Namespace once (`cluster-identity.ts`),
|
|
5
|
+
* and publishes `{ uid }`. That is the BOOTSTRAP-THEN-PIN step (cluster-adapter.ts header): the
|
|
6
|
+
* first deploy creates the cluster and this row with no workloads, the operator copies the
|
|
7
|
+
* printed uid into the adapter config, and workloads take `talosOpenBaoConnection(<literal>)`.
|
|
8
|
+
* ⛔ It publishes NO `connection`: an Output uid in a workload's auth is unresolved at plan time
|
|
9
|
+
* and hides a cluster change from the plan (`talosOpenBaoConnection` explains the DELETE on B).
|
|
10
|
+
* ⛔ DRIFT REPAIR CANNOT LAUNDER A MOVE: `read` receives the persisted output and compares it, so
|
|
11
|
+
* `Drift.repair` fails with `TalosClusterMoved` instead of reconciling B against B.
|
|
12
|
+
* ⛔ IT MUTATES NOTHING: no vault write, no cluster write, `delete` is a no-op. Its only effect is
|
|
13
|
+
* the value it reports. `after` must reach past Cilium/CNI only if the apiserver needs it; the
|
|
14
|
+
* kube-system namespace exists as soon as the apiserver answers.
|
|
15
|
+
* ⛔ A CHANGED UID IS REFUSED, NEVER AN UPDATE (`TalosClusterMoved`, in diff AND reconcile). This
|
|
16
|
+
* resource declares no `stables`, so downstream the engine sees an unresolved Output, every
|
|
17
|
+
* workload `diff` returns undefined and the engine plans an UPDATE, not a replace (Plan.ts
|
|
18
|
+
* resourceExpr, `isResolved(news)` in Manifest.ts and HelmChart.ts). Reconcile would then
|
|
19
|
+
* connect with the NEW uid and force-apply onto the new cluster while the old cluster's objects
|
|
20
|
+
* are orphaned (an earlier comment here claimed a replace and a refusal at cleanup: it cannot
|
|
21
|
+
* happen). A real move is a NEW identity resource per physical cluster.
|
|
22
|
+
*/
|
|
23
|
+
import { Resource } from 'alchemy';
|
|
24
|
+
import { isResolved } from 'alchemy/Diff';
|
|
25
|
+
import type { Input } from 'alchemy/Input';
|
|
26
|
+
import * as Provider from 'alchemy/Provider';
|
|
27
|
+
import * as Effect from 'effect/Effect';
|
|
28
|
+
import { TalosClusterMoved } from './cluster-adapter-errors.ts';
|
|
29
|
+
import { readClusterUid } from './cluster-identity.ts';
|
|
30
|
+
import { openTransport, talosVaultLocation, withConnectDeadline } from './cluster-transport.ts';
|
|
31
|
+
import type { KubeconfigProps } from './kubeconfig.ts';
|
|
32
|
+
import type { TalosRequirements } from './resource.ts';
|
|
33
|
+
|
|
34
|
+
type KubeconfigSource = Pick<KubeconfigProps, 'context' | 'kubeconfigKey' | 'target'>;
|
|
35
|
+
|
|
36
|
+
export interface ClusterIdentityProps extends KubeconfigSource {
|
|
37
|
+
/** Ordering edge: after `Talos.Kubeconfig` (the vault key must exist) and the apiserver is up. */
|
|
38
|
+
after?: readonly unknown[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ClusterIdentityAttributes {
|
|
42
|
+
/**
|
|
43
|
+
* The cluster's kube-system `metadata.uid`. Public. ⛔ NOT A `connection`: these attributes are
|
|
44
|
+
* deliberately not `ClusterLike`. Pin this value as a literal in `TalosOpenBaoAdapter` and build
|
|
45
|
+
* workloads' connection from that literal (bootstrap-then-pin, cluster-adapter.ts header).
|
|
46
|
+
*/
|
|
47
|
+
uid: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TalosClusterIdentity extends Resource<
|
|
51
|
+
'Talos.ClusterIdentity',
|
|
52
|
+
ClusterIdentityProps,
|
|
53
|
+
ClusterIdentityAttributes,
|
|
54
|
+
never,
|
|
55
|
+
TalosRequirements
|
|
56
|
+
> {}
|
|
57
|
+
|
|
58
|
+
export const TalosClusterIdentity = Resource<TalosClusterIdentity>('Talos.ClusterIdentity');
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* ★ EXPORTED for talos-cluster-identity.test.ts — see talos-bootstrap.ts's note on the pattern.
|
|
62
|
+
* ⛔ A PERSISTED OUTPUT IS COMPARED HERE TOO, not only in reconcile: `Drift.repair` hands `read`
|
|
63
|
+
* the persisted attributes, then reconciles `news: old.props, output: <what read returned>`.
|
|
64
|
+
* A read that ignored the output returned the NEW uid, so the reconcile guard compared B with B
|
|
65
|
+
* and saved it. Failing in read stops that path with `TalosClusterMoved`.
|
|
66
|
+
*/
|
|
67
|
+
export const readClusterIdentity = (
|
|
68
|
+
props: ClusterIdentityProps,
|
|
69
|
+
output?: ClusterIdentityAttributes,
|
|
70
|
+
) =>
|
|
71
|
+
Effect.gen(function* () {
|
|
72
|
+
const cluster = props.target.cluster;
|
|
73
|
+
// ⛔ Same single deadline as connect: a hung `bao` must not hang the plan.
|
|
74
|
+
const uid = yield* withConnectDeadline(
|
|
75
|
+
cluster,
|
|
76
|
+
Effect.gen(function* () {
|
|
77
|
+
const transport = yield* openTransport(talosVaultLocation(props));
|
|
78
|
+
return yield* readClusterUid(cluster, transport);
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
if (output !== undefined && uid !== output.uid) {
|
|
82
|
+
return yield* Effect.fail(new TalosClusterMoved({ cluster, live: uid, saved: output.uid }));
|
|
83
|
+
}
|
|
84
|
+
return { uid };
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/** ⛔ Fails closed when a saved uid exists and the live one differs. See the header. */
|
|
88
|
+
export const reconcileClusterIdentity = (
|
|
89
|
+
props: ClusterIdentityProps,
|
|
90
|
+
output: ClusterIdentityAttributes | undefined,
|
|
91
|
+
) => readClusterIdentity(props, output);
|
|
92
|
+
|
|
93
|
+
export const diffClusterIdentity = (
|
|
94
|
+
news: Input<ClusterIdentityProps>,
|
|
95
|
+
output: ClusterIdentityAttributes | undefined,
|
|
96
|
+
) =>
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
99
|
+
yield* reconcileClusterIdentity(news, output);
|
|
100
|
+
return { action: 'noop' } as const;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const handlers = {
|
|
104
|
+
delete: () => Effect.void,
|
|
105
|
+
diff: ({
|
|
106
|
+
news,
|
|
107
|
+
output,
|
|
108
|
+
}: {
|
|
109
|
+
news: Input<ClusterIdentityProps>;
|
|
110
|
+
output: ClusterIdentityAttributes | undefined;
|
|
111
|
+
}) => diffClusterIdentity(news, output),
|
|
112
|
+
list: () => Effect.succeed([]),
|
|
113
|
+
read: ({
|
|
114
|
+
olds,
|
|
115
|
+
output,
|
|
116
|
+
}: {
|
|
117
|
+
olds: ClusterIdentityProps;
|
|
118
|
+
output: ClusterIdentityAttributes | undefined;
|
|
119
|
+
}) => readClusterIdentity(olds, output),
|
|
120
|
+
reconcile: ({
|
|
121
|
+
news,
|
|
122
|
+
output,
|
|
123
|
+
}: {
|
|
124
|
+
news: ClusterIdentityProps;
|
|
125
|
+
output: ClusterIdentityAttributes | undefined;
|
|
126
|
+
}) => reconcileClusterIdentity(news, output),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export const TalosClusterIdentityProvider = () =>
|
|
130
|
+
Provider.effect(TalosClusterIdentity, Effect.succeed(TalosClusterIdentity.Provider.of(handlers)));
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which `talosctl` runs, and whether that file may be trusted with the vault-minted talosconfig.
|
|
3
|
+
* Extracted from talosctl.ts (250-line cap).
|
|
4
|
+
*
|
|
5
|
+
* ⛔ A VETTING REFUSAL IS NOT A TalosError. `TalosError` means a `talosctl` run completed and
|
|
6
|
+
* exited non-zero — the one outcome `Talos.ClusterHealth` may call "not healthy", and the one
|
|
7
|
+
* `talosctlOrAlready` pattern-matches. A refused binary never ran, so it is its own class and
|
|
8
|
+
* propagates through both untouched (hunt round 6: it read as `healthy: false`).
|
|
9
|
+
*/
|
|
10
|
+
import { accessSync, constants, lstatSync, realpathSync } from 'node:fs';
|
|
11
|
+
import { basename, delimiter, dirname, isAbsolute, join } from 'node:path';
|
|
12
|
+
import * as Effect from 'effect/Effect';
|
|
13
|
+
import { isTrustedBoundary } from './trust-boundary.ts';
|
|
14
|
+
|
|
15
|
+
/** `talosctl` on `PATH`. A lane pins another build with {@link TALOSCTL_BINARY_ENV} or `binary`. */
|
|
16
|
+
export const DEFAULT_TALOSCTL_BINARY = 'talosctl';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Executable override, read at call time. The Mac PATH measured 2026-10-05 has v1.13.8; the
|
|
20
|
+
* cluster is v1.14.2. Set this to the v1.14.2 binary. A per-call `binary` option wins.
|
|
21
|
+
*/
|
|
22
|
+
export const TALOSCTL_BINARY_ENV = 'HF_TALOSCTL';
|
|
23
|
+
|
|
24
|
+
/** `bao` on `PATH` — the OpenBao CLI that fetches the cluster credentials at connect time. */
|
|
25
|
+
export const DEFAULT_BAO_BINARY = 'bao';
|
|
26
|
+
|
|
27
|
+
/** `bao` executable override, read at call time and vetted exactly like {@link TALOSCTL_BINARY_ENV}. */
|
|
28
|
+
export const BAO_BINARY_ENV = 'HF_BAO';
|
|
29
|
+
|
|
30
|
+
export class TalosBinaryRefused extends Error {
|
|
31
|
+
readonly command: string;
|
|
32
|
+
readonly binary: string;
|
|
33
|
+
|
|
34
|
+
constructor(command: string, detail: string, binary = DEFAULT_TALOSCTL_BINARY) {
|
|
35
|
+
super(`${binary} ${command} refused: ${detail}`);
|
|
36
|
+
this.name = 'TalosBinaryRefused';
|
|
37
|
+
this.command = command;
|
|
38
|
+
this.binary = binary;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* ⛔ THE DEFAULT IS VETTED TOO (hunt round 5): a bare `talosctl` is resolved against `PATH` here
|
|
44
|
+
* (not `Bun.which` — the published dist runs on Node, where that global is missing), symlinks are
|
|
45
|
+
* followed, and the REAL file is vetted, version-checked and executed by absolute path.
|
|
46
|
+
*/
|
|
47
|
+
export const resolveDefault = (binary: string) =>
|
|
48
|
+
Effect.try({
|
|
49
|
+
try: () => {
|
|
50
|
+
for (const entry of (process.env['PATH'] ?? '').split(delimiter)) {
|
|
51
|
+
if (!isAbsolute(entry)) continue;
|
|
52
|
+
try {
|
|
53
|
+
const candidate = join(entry, binary);
|
|
54
|
+
accessSync(candidate, constants.X_OK);
|
|
55
|
+
return realpathSync(candidate);
|
|
56
|
+
} catch {
|
|
57
|
+
// not in this PATH entry — try the next
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
throw new Error('not found');
|
|
61
|
+
},
|
|
62
|
+
catch: () => new TalosBinaryRefused('binary check', `${binary} was not found on PATH`, binary),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* ⛔ EVERY DIRECTORY ON THE CANONICAL PATH must belong to the current user or root and must not be
|
|
67
|
+
* group/world-writable (unless sticky: a sticky directory's entries cannot be renamed by others).
|
|
68
|
+
* Round 5 checked only the immediate parent's mode, so an attacker-owned 0755 parent, or a
|
|
69
|
+
* writable ancestor, could swap the file after the check. Returns the refusal reason, or
|
|
70
|
+
* undefined when the whole chain is trusted.
|
|
71
|
+
*/
|
|
72
|
+
const untrustedAncestor = (file: string, uid: number | undefined): string | undefined => {
|
|
73
|
+
let dir = realpathSync(dirname(file));
|
|
74
|
+
for (;;) {
|
|
75
|
+
const stat = lstatSync(dir);
|
|
76
|
+
// The boundary directory itself is still vetted; only what lies ABOVE it is not (test seam,
|
|
77
|
+
// trust-boundary.seam.ts, which the published tarball does not carry).
|
|
78
|
+
const isBoundary = isTrustedBoundary(dir);
|
|
79
|
+
if (uid !== undefined && stat.uid !== uid && stat.uid !== 0) {
|
|
80
|
+
return `lives under a directory owned by another user (${dir})`;
|
|
81
|
+
}
|
|
82
|
+
const sticky = (stat.mode & 0o1000) !== 0;
|
|
83
|
+
if ((stat.mode & 0o022) !== 0 && !sticky) {
|
|
84
|
+
return `lives in a group- or world-writable directory (${dir})`;
|
|
85
|
+
}
|
|
86
|
+
const up = dirname(dir);
|
|
87
|
+
if (up === dir || isBoundary) return undefined;
|
|
88
|
+
dir = up;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* ⛔ THE BINARY GETS THE VAULT-MINTED TALOSCONFIG: absolute, owned, not writable, executable, trusted
|
|
94
|
+
* path. ⛔ RETURNS THE CANONICAL PATH (round 7): the override's directory is resolved ONCE
|
|
95
|
+
* (realpath), that path and every ancestor of it are vetted, and the caller must launch exactly
|
|
96
|
+
* the returned path for the version probe AND the credential-bearing call. Vetting the given path
|
|
97
|
+
* and executing it again let a symlinked ancestor be repointed between the two launches. The leaf
|
|
98
|
+
* itself is still lstat'd, so a symlink leaf is refused rather than followed.
|
|
99
|
+
*/
|
|
100
|
+
export const checkBinaryPath = (binary: string, source: string) => {
|
|
101
|
+
const refuse = (why: string) =>
|
|
102
|
+
Effect.fail(new TalosBinaryRefused('binary check', `${source} ${why}`, binary));
|
|
103
|
+
if (!isAbsolute(binary)) return refuse('must be an absolute path');
|
|
104
|
+
return Effect.try({
|
|
105
|
+
try: () => {
|
|
106
|
+
const canonical = join(realpathSync(dirname(binary)), basename(binary));
|
|
107
|
+
const file = lstatSync(canonical);
|
|
108
|
+
const uid = process.getuid?.();
|
|
109
|
+
return { canonical, file, uid, ancestor: untrustedAncestor(canonical, uid) };
|
|
110
|
+
},
|
|
111
|
+
catch: () => new TalosBinaryRefused('binary check', `${source} is not a readable file`, binary),
|
|
112
|
+
}).pipe(
|
|
113
|
+
Effect.flatMap(({ canonical, file, uid, ancestor }) => {
|
|
114
|
+
// ★ lstat, not stat: a symlink can be re-pointed by whoever owns the link, so none is accepted.
|
|
115
|
+
if (file.isSymbolicLink()) return refuse('must not be a symlink');
|
|
116
|
+
if (!file.isFile()) return refuse('is not a regular file');
|
|
117
|
+
// ⛔ FAIL CLOSED: with no process uid (a platform lacking getuid) ownership cannot be checked.
|
|
118
|
+
if (uid === undefined) return refuse('cannot be vetted: no process uid to check ownership');
|
|
119
|
+
if (ancestor !== undefined) return refuse(ancestor);
|
|
120
|
+
if (uid !== undefined && file.uid !== uid && file.uid !== 0) {
|
|
121
|
+
return refuse('must be owned by the current user or root');
|
|
122
|
+
}
|
|
123
|
+
if ((file.mode & 0o022) !== 0) return refuse('is group- or world-writable');
|
|
124
|
+
try {
|
|
125
|
+
accessSync(canonical, constants.X_OK);
|
|
126
|
+
} catch {
|
|
127
|
+
return refuse('is not executable');
|
|
128
|
+
}
|
|
129
|
+
return Effect.succeed(canonical);
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resolve and vet the `bao` binary: {@link BAO_BINARY_ENV} wins over `bao` on `PATH`, and either is
|
|
136
|
+
* checked exactly like talosctl (absolute, owned, not writable, executable, not a symlink, every
|
|
137
|
+
* ancestor trusted). Returns the canonical path to launch.
|
|
138
|
+
*
|
|
139
|
+
* ⛔ `bao` FETCHES THE CLUSTER CREDENTIALS (`readKvValue` / `writeKvValue`), so an un-vetted `bao`
|
|
140
|
+
* on a group/world-writable PATH folder (umask 002, the round-10 incident) could answer with an
|
|
141
|
+
* attacker's kubeconfig. The same walk that vets `talosctl` must gate every `bao` launch.
|
|
142
|
+
*/
|
|
143
|
+
export const resolveBao = (): Effect.Effect<string, TalosBinaryRefused> => {
|
|
144
|
+
const override = process.env[BAO_BINARY_ENV]?.trim();
|
|
145
|
+
const requested = override !== undefined && override !== '' ? override : DEFAULT_BAO_BINARY;
|
|
146
|
+
const source = requested === DEFAULT_BAO_BINARY ? 'bao on PATH' : BAO_BINARY_ENV;
|
|
147
|
+
const resolved =
|
|
148
|
+
requested === DEFAULT_BAO_BINARY ? resolveDefault(requested) : Effect.succeed(requested);
|
|
149
|
+
return Effect.flatMap(resolved, (path) => checkBinaryPath(path, source));
|
|
150
|
+
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HF_TALOSCTL` vetting: path (absolute, lstat, owner, parent directory, mode), exact version, and
|
|
3
|
+
* the minimal child env. Fake spawner only — the named file is never executed.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
chmodSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
mkdtempSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
symlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { afterAll, expect, test } from 'bun:test';
|
|
17
|
+
import * as Effect from 'effect/Effect';
|
|
18
|
+
import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
|
|
19
|
+
import { type FakeHandler, fakeSpawner } from './fake-process.ts';
|
|
20
|
+
import { trustBoundaryForTests } from './trust-boundary.seam.ts';
|
|
21
|
+
import { TalosBinaryRefused, TalosError, talosctl } from './talosctl.ts';
|
|
22
|
+
|
|
23
|
+
const run = (binary: string, handler: FakeHandler = () => ({}), envs: unknown[] = []) =>
|
|
24
|
+
Effect.runPromise(
|
|
25
|
+
Effect.provideService(
|
|
26
|
+
talosctl(['version'], { binary, talosconfigPath: 'unused.yaml' }),
|
|
27
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
28
|
+
ChildProcessSpawner.make((cmd) => {
|
|
29
|
+
if (cmd._tag === 'StandardCommand') envs.push(cmd.options);
|
|
30
|
+
return fakeSpawner(handler).spawn(cmd);
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
).catch((e: unknown) => String(e));
|
|
34
|
+
|
|
35
|
+
const dir = mkdtempSync(join(tmpdir(), 'hf-talosctl-vet-'));
|
|
36
|
+
chmodSync(dir, 0o755);
|
|
37
|
+
trustBoundaryForTests(dir);
|
|
38
|
+
afterAll(() => rmSync(dir, { force: true, recursive: true }));
|
|
39
|
+
const trusted = (name: string) => {
|
|
40
|
+
const path = join(dir, name);
|
|
41
|
+
writeFileSync(path, '#!/bin/sh\n', { mode: 0o755 });
|
|
42
|
+
chmodSync(path, 0o755);
|
|
43
|
+
return path;
|
|
44
|
+
};
|
|
45
|
+
const pinned =
|
|
46
|
+
(tag: string): FakeHandler =>
|
|
47
|
+
() => ({ stdout: `Client: Tag: ${tag}` });
|
|
48
|
+
|
|
49
|
+
test('relative, missing, group/world-writable and symlinked overrides are refused', async () => {
|
|
50
|
+
expect(await run('relative/talosctl')).toContain('absolute');
|
|
51
|
+
expect(await run(join(dir, 'missing'))).toContain('not a readable file');
|
|
52
|
+
const bin = trusted('writable');
|
|
53
|
+
chmodSync(bin, 0o777);
|
|
54
|
+
expect(await run(bin)).toContain('group- or world-writable');
|
|
55
|
+
const link = join(dir, 'link');
|
|
56
|
+
symlinkSync(trusted('target'), link);
|
|
57
|
+
expect(await run(link)).toContain('symlink');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('an override in a group- or world-writable directory is refused', async () => {
|
|
61
|
+
const open = join(dir, 'open');
|
|
62
|
+
mkdirSync(open);
|
|
63
|
+
const bin = join(open, 'talosctl');
|
|
64
|
+
writeFileSync(bin, '#!/bin/sh\n', { mode: 0o755 });
|
|
65
|
+
chmodSync(open, 0o777);
|
|
66
|
+
expect(await run(bin)).toContain('writable directory');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a writable ancestor of the override is refused (fails on 37d835a: only the parent was checked)', async () => {
|
|
70
|
+
const outer = join(dir, 'outer');
|
|
71
|
+
mkdirSync(join(outer, 'inner'), { recursive: true });
|
|
72
|
+
const bin = join(outer, 'inner', 'talosctl');
|
|
73
|
+
writeFileSync(bin, '#!/bin/sh\n', { mode: 0o755 });
|
|
74
|
+
chmodSync(join(outer, 'inner'), 0o755);
|
|
75
|
+
chmodSync(outer, 0o777);
|
|
76
|
+
expect(await run(bin, pinned('v1.14.2'))).toContain('writable directory');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('the walk stops at a registered boundary but not above an unregistered one (guards the trust-boundary.seam wiring: if the seam is missing or the pull fails, the jailed shape is refused too)', async () => {
|
|
80
|
+
// Two identical shapes: `jailed/inner` is registered as a boundary, `free/inner` is not. The
|
|
81
|
+
// wrapper above each is group/world-writable, so only the registered one can pass: the
|
|
82
|
+
// boundary directory itself is still vetted (755), and its wrapper is never reached.
|
|
83
|
+
const jailed = join(dir, 'jailed');
|
|
84
|
+
const free = join(dir, 'free');
|
|
85
|
+
for (const wrapper of [jailed, free]) {
|
|
86
|
+
mkdirSync(join(wrapper, 'inner', 'bin'), { recursive: true });
|
|
87
|
+
// ⚠️ The BOUNDARY DIRECTORY is vetted too, so it must be 755 — not the umask default. Measured
|
|
88
|
+
// 2026-10-06 at umask 002 (CT100's default): `mkdirSync(…, { recursive: true })` creates the
|
|
89
|
+
// intermediate `inner` group-writable (775), and the walk rightly refused the boundary itself
|
|
90
|
+
// before ever reaching the 777 wrapper the test means to probe.
|
|
91
|
+
chmodSync(join(wrapper, 'inner'), 0o755);
|
|
92
|
+
chmodSync(join(wrapper, 'inner', 'bin'), 0o755);
|
|
93
|
+
writeFileSync(join(wrapper, 'inner', 'bin', 'talosctl'), '#!/bin/sh\n', { mode: 0o755 });
|
|
94
|
+
chmodSync(join(wrapper, 'inner', 'bin', 'talosctl'), 0o755);
|
|
95
|
+
}
|
|
96
|
+
trustBoundaryForTests(join(jailed, 'inner'));
|
|
97
|
+
chmodSync(jailed, 0o777);
|
|
98
|
+
chmodSync(free, 0o777);
|
|
99
|
+
expect(await run(join(jailed, 'inner', 'bin', 'talosctl'), pinned('v1.14.2'))).toBe(
|
|
100
|
+
'Client: Tag: v1.14.2',
|
|
101
|
+
);
|
|
102
|
+
expect(await run(join(free, 'inner', 'bin', 'talosctl'), pinned('v1.14.2'))).toContain(
|
|
103
|
+
'writable directory',
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('an ancestor owned by another user is refused (fails on 37d835a: parent owner never read)', async () => {
|
|
108
|
+
const owner = join(dir, 'foreign');
|
|
109
|
+
mkdirSync(owner);
|
|
110
|
+
const bin = join(owner, 'talosctl');
|
|
111
|
+
writeFileSync(bin, '#!/bin/sh\n', { mode: 0o755 });
|
|
112
|
+
chmodSync(owner, 0o755);
|
|
113
|
+
expect(await run(bin, pinned('v1.14.2'))).toBe('Client: Tag: v1.14.2');
|
|
114
|
+
const real = process.getuid;
|
|
115
|
+
// Everything here is ours; pretending to be another uid makes every directory foreign.
|
|
116
|
+
Object.defineProperty(process, 'getuid', { configurable: true, value: () => 4242 });
|
|
117
|
+
try {
|
|
118
|
+
expect(await run(bin, pinned('v1.14.2'))).toContain('directory owned by another user');
|
|
119
|
+
} finally {
|
|
120
|
+
Object.defineProperty(process, 'getuid', { configurable: true, value: real });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('a non-executable override is refused', async () => {
|
|
125
|
+
const bin = join(dir, 'noexec');
|
|
126
|
+
writeFileSync(bin, '#!/bin/sh\n', { mode: 0o644 });
|
|
127
|
+
chmodSync(bin, 0o644);
|
|
128
|
+
expect(await run(bin, pinned('v1.14.2'))).toContain('not executable');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('both launches use the canonical path even if an ancestor symlink is swapped (fails on ede1f6f)', async () => {
|
|
132
|
+
const real = join(dir, 'real-bin');
|
|
133
|
+
const evil = join(dir, 'evil-bin');
|
|
134
|
+
for (const d of [real, evil]) {
|
|
135
|
+
mkdirSync(d);
|
|
136
|
+
chmodSync(d, 0o755);
|
|
137
|
+
writeFileSync(join(d, 'talosctl'), '#!/bin/sh\n', { mode: 0o755 });
|
|
138
|
+
chmodSync(join(d, 'talosctl'), 0o755);
|
|
139
|
+
}
|
|
140
|
+
const via = join(dir, 'via');
|
|
141
|
+
symlinkSync(real, via);
|
|
142
|
+
const launched: string[] = [];
|
|
143
|
+
const out = await Effect.runPromise(
|
|
144
|
+
Effect.provideService(
|
|
145
|
+
talosctl(['version'], { binary: join(via, 'talosctl'), talosconfigPath: 'unused.yaml' }),
|
|
146
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
147
|
+
ChildProcessSpawner.make((cmd) => {
|
|
148
|
+
if (cmd._tag === 'StandardCommand') launched.push(cmd.command);
|
|
149
|
+
// The attacker repoints the link right after the version probe.
|
|
150
|
+
rmSync(via);
|
|
151
|
+
symlinkSync(evil, via);
|
|
152
|
+
return fakeSpawner(pinned('v1.14.2')).spawn(cmd);
|
|
153
|
+
}),
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
expect(out).toBe('Client: Tag: v1.14.2');
|
|
157
|
+
expect(launched.length).toBe(2);
|
|
158
|
+
expect(launched[0]).toBe(join(realpathSync(real), 'talosctl'));
|
|
159
|
+
expect(launched[1]).toBe(launched[0]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('a refusal is a TalosBinaryRefused naming its source, never a TalosError', async () => {
|
|
163
|
+
const outcome = await Effect.runPromise(
|
|
164
|
+
Effect.flip(
|
|
165
|
+
Effect.provideService(
|
|
166
|
+
talosctl(['version'], { binary: 'relative/talosctl', talosconfigPath: 'x' }),
|
|
167
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
168
|
+
fakeSpawner(() => ({})),
|
|
169
|
+
),
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
expect(outcome).toBeInstanceOf(TalosBinaryRefused);
|
|
173
|
+
expect(outcome).not.toBeInstanceOf(TalosError);
|
|
174
|
+
expect(String(outcome)).toContain('the `binary` option');
|
|
175
|
+
expect(String(outcome)).not.toContain('HF_TALOSCTL');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('no process uid means ownership cannot be checked: refused (fails on 19502c6: passed)', async () => {
|
|
179
|
+
const bin = trusted('no-getuid');
|
|
180
|
+
const real = process.getuid;
|
|
181
|
+
Object.defineProperty(process, 'getuid', { configurable: true, value: undefined });
|
|
182
|
+
try {
|
|
183
|
+
expect(await run(bin, pinned('v1.14.2'))).toContain('no process uid');
|
|
184
|
+
} finally {
|
|
185
|
+
Object.defineProperty(process, 'getuid', { configurable: true, value: real });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('the version must match exactly: v1.14.20 and v1.13.8 fail, v1.14.2 passes', async () => {
|
|
190
|
+
const bin = trusted('versions');
|
|
191
|
+
expect(await run(bin, pinned('v1.14.20'))).toContain('v1.14.2');
|
|
192
|
+
expect(await run(bin, pinned('v1.13.8'))).toContain('v1.14.2');
|
|
193
|
+
expect(await run(bin, pinned('v1.14.2'))).toBe('Client: Tag: v1.14.2');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('the child gets a minimal env: BAO_TOKEN never reaches the binary, even the version probe', async () => {
|
|
197
|
+
const saved = process.env['BAO_TOKEN'];
|
|
198
|
+
process.env['BAO_TOKEN'] = 'fake-test-token';
|
|
199
|
+
const options: unknown[] = [];
|
|
200
|
+
try {
|
|
201
|
+
await run(trusted('env'), pinned('v1.14.2'), options);
|
|
202
|
+
} finally {
|
|
203
|
+
if (saved === undefined) delete process.env['BAO_TOKEN'];
|
|
204
|
+
else process.env['BAO_TOKEN'] = saved;
|
|
205
|
+
}
|
|
206
|
+
expect(options.length).toBe(2);
|
|
207
|
+
for (const option of options as { env?: Record<string, string>; extendEnv?: boolean }[]) {
|
|
208
|
+
expect(option.extendEnv).toBe(false);
|
|
209
|
+
expect(Object.keys(option.env ?? {})).not.toContain('BAO_TOKEN');
|
|
210
|
+
expect(Object.keys(option.env ?? {})).not.toContain('BAO_ADDR');
|
|
211
|
+
}
|
|
212
|
+
});
|