@homeflare/alchemy 0.54.0 → 0.55.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/dist/ceph/ceph-argv-config.d.ts +6 -3
- package/dist/ceph/ceph-argv-config.d.ts.map +1 -1
- package/dist/ceph/ceph-argv.d.ts +8 -0
- package/dist/ceph/ceph-argv.d.ts.map +1 -1
- package/dist/ceph/ceph-config-option-form.d.ts +48 -0
- package/dist/ceph/ceph-config-option-form.d.ts.map +1 -0
- package/dist/ceph/ceph-config-option-reconcile.d.ts +22 -0
- package/dist/ceph/ceph-config-option-reconcile.d.ts.map +1 -0
- package/dist/ceph/ceph-config-options.d.ts +15 -6
- package/dist/ceph/ceph-config-options.d.ts.map +1 -1
- package/dist/ceph/config-option.d.ts +10 -0
- package/dist/ceph/config-option.d.ts.map +1 -0
- package/dist/ceph/index.d.ts +7 -3
- package/dist/ceph/index.d.ts.map +1 -1
- package/dist/ceph/index.js +363 -8
- package/dist/ceph/index.js.map +17 -7
- package/dist/linux/index.js +33 -2
- package/dist/linux/index.js.map +3 -3
- package/dist/linux/sudo-runner.d.ts +12 -0
- package/dist/linux/sudo-runner.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +1 -0
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +101 -1
- package/dist/proxmox/index.js.map +5 -4
- package/dist/proxmox/scoped-api.d.ts +49 -0
- package/dist/proxmox/scoped-api.d.ts.map +1 -0
- package/docs/pve-scoped-api.md +23 -0
- package/package.json +1 -1
- package/src/ceph/ceph-argv-config.ts +27 -19
- package/src/ceph/ceph-argv.test.ts +37 -1
- package/src/ceph/ceph-argv.ts +25 -0
- package/src/ceph/ceph-config-option-form.ts +73 -0
- package/src/ceph/ceph-config-option-reconcile.ts +88 -0
- package/src/ceph/ceph-config-option.test.ts +213 -0
- package/src/ceph/ceph-config-options.test.ts +28 -11
- package/src/ceph/ceph-config-options.ts +29 -8
- package/src/ceph/config-option.ts +112 -0
- package/src/ceph/index.ts +7 -3
- package/src/linux/sudo-runner-privileged-read.test.ts +92 -0
- package/src/linux/sudo-runner.ts +54 -1
- package/src/proxmox/index.ts +13 -0
- package/src/proxmox/scoped-api.test.ts +52 -0
- package/src/proxmox/scoped-api.ts +148 -0
|
@@ -1,19 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Structural guard on the named config-option list itself (mon-transport doc acceptance test #3):
|
|
3
|
-
* the list contains none of the lockout classes, and
|
|
3
|
+
* the list contains none of the lockout classes, and each entry is one reviewed line (D-C1).
|
|
4
4
|
*/
|
|
5
5
|
import { describe, expect, test } from 'bun:test';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
NAMED_CONFIG_OPTIONS,
|
|
8
|
+
allowedWhoFor,
|
|
9
|
+
isLockoutConfigOption,
|
|
10
|
+
} from './ceph-config-options.ts';
|
|
7
11
|
|
|
8
12
|
describe('NAMED_CONFIG_OPTIONS', () => {
|
|
9
|
-
test('
|
|
10
|
-
expect(NAMED_CONFIG_OPTIONS.
|
|
13
|
+
test('the first reviewed entry is the mgr prometheus perf-counter switch, for mgr only', () => {
|
|
14
|
+
expect(NAMED_CONFIG_OPTIONS.get('mgr/prometheus/exclude_perf_counters')).toEqual(['mgr']);
|
|
15
|
+
expect(allowedWhoFor('mgr/prometheus/exclude_perf_counters')).toEqual(['mgr']);
|
|
16
|
+
expect(allowedWhoFor('mon_allow_pool_delete')).toEqual([]);
|
|
11
17
|
});
|
|
12
18
|
|
|
13
19
|
test('contains none of the lockout classes, whatever gets added later', () => {
|
|
14
|
-
const bad = [...NAMED_CONFIG_OPTIONS].filter((name) => isLockoutConfigOption(name));
|
|
20
|
+
const bad = [...NAMED_CONFIG_OPTIONS.keys()].filter((name) => isLockoutConfigOption(name));
|
|
15
21
|
expect(bad).toEqual([]);
|
|
16
22
|
});
|
|
23
|
+
|
|
24
|
+
test('every entry names at least one bounded <who>', () => {
|
|
25
|
+
for (const [name, whos] of NAMED_CONFIG_OPTIONS) {
|
|
26
|
+
expect([name, whos.length > 0]).toEqual([name, true]);
|
|
27
|
+
for (const who of whos) expect(who).toMatch(/^(global|mon|osd|mgr|mds|client)(\.[\w-]+)?$/);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
17
30
|
});
|
|
18
31
|
|
|
19
32
|
describe('isLockoutConfigOption', () => {
|
|
@@ -31,14 +44,18 @@ describe('isLockoutConfigOption', () => {
|
|
|
31
44
|
'keyring_path',
|
|
32
45
|
'key',
|
|
33
46
|
'mon_data_key',
|
|
47
|
+
'mgr/dashboard/key',
|
|
48
|
+
'mgr/restful/keyring',
|
|
34
49
|
])('flags %s', (name) => {
|
|
35
50
|
expect(isLockoutConfigOption(name)).toBe(true);
|
|
36
51
|
});
|
|
37
52
|
|
|
38
|
-
test.each([
|
|
39
|
-
'
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
)
|
|
53
|
+
test.each([
|
|
54
|
+
'mgr/prometheus/exclude_perf_counters',
|
|
55
|
+
'mon_allow_pool_delete',
|
|
56
|
+
'osd_pool_default_size',
|
|
57
|
+
'mon_max_pg_per_osd',
|
|
58
|
+
])('does not flag an ordinary tuning option like %s', (name) => {
|
|
59
|
+
expect(isLockoutConfigOption(name)).toBe(false);
|
|
60
|
+
});
|
|
44
61
|
});
|
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
* through the mon-command transport (ceph-argv.ts's `configProblem`). Design:
|
|
4
4
|
* docs/plans/2026-09-26-ceph-config-options.md, D-C1.
|
|
5
5
|
*
|
|
6
|
-
* ⛔ THE LIST
|
|
7
|
-
*
|
|
8
|
-
* transport's allowlist
|
|
9
|
-
*
|
|
6
|
+
* ⛔ THE LIST STARTED EMPTY, AND EACH ADDITION IS ITS OWN REVIEWED PR LINE. `Ceph.ConfigOption`
|
|
7
|
+
* (K-D, config-option.ts) is the only declarer; it refuses any `{who, name}` this map does not
|
|
8
|
+
* carry before planning, and the transport's allowlist (ceph-argv-config.ts) refuses it again
|
|
9
|
+
* before anything dials. One entry per line, each with a comment saying what it does and why it
|
|
10
|
+
* is managed — never a batch.
|
|
11
|
+
* ★ EACH ENTRY ALSO NAMES THE `<who>` IT MAY BE SET FOR. This is the config-options design's
|
|
12
|
+
* acceptance test #1 ("`<who>` bounded to the declared daemons"): an option listed for `mgr`
|
|
13
|
+
* can never be written to `global`, `client.admin` or any other section through this transport.
|
|
10
14
|
* ⛔ NEVER ADD ONE OF THE LOCKOUT CLASSES BELOW. `mon_host`, `public_network`, `cluster_network`,
|
|
11
15
|
* `public_addr`, `cluster_addr`, every `auth_*_required` and every `key`/`keyring`-bearing
|
|
12
16
|
* option re-address the mons or de-authenticate the whole cluster — cutting every VM disk — and
|
|
@@ -17,8 +21,22 @@
|
|
|
17
21
|
* never a `ceph config set` argv, which lands in the runner's log and the node's sudo journal.
|
|
18
22
|
*/
|
|
19
23
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
24
|
+
/**
|
|
25
|
+
* option name -> the `<who>` sections it may be set for. Add one reviewed line per PR, never a
|
|
26
|
+
* batch; the structural test (ceph-config-options.test.ts) runs the lockout guard over every key.
|
|
27
|
+
*/
|
|
28
|
+
export const NAMED_CONFIG_OPTIONS: ReadonlyMap<string, readonly string[]> = new Map<
|
|
29
|
+
string,
|
|
30
|
+
readonly string[]
|
|
31
|
+
>([
|
|
32
|
+
// mgr prometheus module: `false` makes it export per-daemon perf counters (ceph_osd_op_*,
|
|
33
|
+
// ceph_rgw_* …) instead of omitting them. Set live 2026-10, codified here so plan reconciles it.
|
|
34
|
+
['mgr/prometheus/exclude_perf_counters', ['mgr']],
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/** The `<who>` sections `name` may be set for — empty when the option is not on the list. */
|
|
38
|
+
export const allowedWhoFor = (name: string): readonly string[] =>
|
|
39
|
+
NAMED_CONFIG_OPTIONS.get(name) ?? [];
|
|
22
40
|
|
|
23
41
|
const LOCKOUT_EXACT: ReadonlySet<string> = new Set([
|
|
24
42
|
'mon_host',
|
|
@@ -28,8 +46,11 @@ const LOCKOUT_EXACT: ReadonlySet<string> = new Set([
|
|
|
28
46
|
'cluster_addr',
|
|
29
47
|
]);
|
|
30
48
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
49
|
+
/**
|
|
50
|
+
* `auth_*_required` (cephx enforcement) and anything key/keyring-shaped, bounded by `_` or by the
|
|
51
|
+
* `/` that separates an mgr module option (`mgr/<module>/<option>`) — so `mgr/x/key` is caught too.
|
|
52
|
+
*/
|
|
53
|
+
const LOCKOUT_PATTERN = /^auth_\w*_required$|(^|[_/])key(ring)?($|[_/])/;
|
|
33
54
|
|
|
34
55
|
/** Whether `name` is a class this transport must never read a value for, adopt, or write. */
|
|
35
56
|
export const isLockoutConfigOption = (name: string): boolean =>
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Ceph.ConfigOption` (K-D) — one `{who, name} = value` row of the Ceph mon config db, declared.
|
|
3
|
+
* Design: docs/plans/2026-09-26-ceph-config-options.md. Pure helpers: ceph-config-option-form.ts;
|
|
4
|
+
* the loop: ceph-config-option-reconcile.ts; the allowlist: ceph-argv-config.ts.
|
|
5
|
+
*
|
|
6
|
+
* ★ ONE RESOURCE PER OPTION, AND ONLY OPTIONS ON THE NAMED LIST. `NAMED_CONFIG_OPTIONS`
|
|
7
|
+
* (ceph-config-options.ts) is the gate: each entry is its own reviewed PR line and names the
|
|
8
|
+
* `<who>` it may be set for. Anything else is refused at `diff` (so the plan fails loudly) and
|
|
9
|
+
* again by the transport before anything dials.
|
|
10
|
+
* ★ PLAN READS OVER THE PVE API, NEVER SSH. `read`/`diff` call `GET /nodes/{node}/ceph/cfg/db`
|
|
11
|
+
* (read lease only — "a plan never elevates"). Only `reconcile`/`delete` reach ssh, through
|
|
12
|
+
* ceph-transport.ts — the existing mon-command transport `Ceph.AuthEntity` uses, not a new one.
|
|
13
|
+
* ★ ADOPTION OF AN ALREADY-SET OPTION IS A ZERO-WRITE NO-OP. A cold `read` of a live row answers
|
|
14
|
+
* `Unowned(attrs)` (the house rule for a marker-less API, H1), so a live option needs
|
|
15
|
+
* `adopt(true)` on its row; `reconcile` then observes the matching value and writes nothing.
|
|
16
|
+
* ⛔ Fix-live-then-codify is fine for this family exactly because of that: the plan proves it.
|
|
17
|
+
* ⚠️ PRECONDITION BEFORE DECLARING THE WHOLE CONFIG DB: the dead `public_addr` entries
|
|
18
|
+
* (homeflare-proxmox docs/completeness-tb4-sdn-ceph.md, T3) must be removed by hand with
|
|
19
|
+
* `ceph config rm` first, so fossils are never adopted. `public_addr` is a lockout class and can
|
|
20
|
+
* never be declared here; a single named option (like the first one) is not blocked by it.
|
|
21
|
+
* ⚠️ THE COMPARED VALUE IS THE MON DB's, not the effective one: a ceph.conf override or a compiled
|
|
22
|
+
* default is out of scope (CephConf is its own family).
|
|
23
|
+
* ⚠️ `defaultRemovalPolicy: 'retain'`: removing a row stops managing it; `config rm` runs only when
|
|
24
|
+
* a removal policy of `destroy` is chosen deliberately for that row.
|
|
25
|
+
*/
|
|
26
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
27
|
+
import { Resource } from 'alchemy';
|
|
28
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
29
|
+
import { isResolved } from 'alchemy/Diff';
|
|
30
|
+
import * as Provider from 'alchemy/Provider';
|
|
31
|
+
import * as Effect from 'effect/Effect';
|
|
32
|
+
import { mintTier } from '../proxmox/credentials.ts';
|
|
33
|
+
import { runPve } from '../proxmox/distilled-pve.ts';
|
|
34
|
+
import type { PveRequirements } from '../proxmox/resource-spec.ts';
|
|
35
|
+
import { UNREADABLE, readOrUnreadable, unreadableWarning } from '../proxmox/unreadable-read.ts';
|
|
36
|
+
import {
|
|
37
|
+
type CephConfigOptionAttributes,
|
|
38
|
+
type CephConfigOptionProps,
|
|
39
|
+
configOptionProblem,
|
|
40
|
+
findLive,
|
|
41
|
+
matches,
|
|
42
|
+
} from './ceph-config-option-form.ts';
|
|
43
|
+
import {
|
|
44
|
+
deleteCephConfigOption,
|
|
45
|
+
reconcileCephConfigOption,
|
|
46
|
+
} from './ceph-config-option-reconcile.ts';
|
|
47
|
+
|
|
48
|
+
export type {
|
|
49
|
+
CephConfigOptionAttributes,
|
|
50
|
+
CephConfigOptionProps,
|
|
51
|
+
} from './ceph-config-option-form.ts';
|
|
52
|
+
|
|
53
|
+
export interface CephConfigOption extends Resource<
|
|
54
|
+
'Ceph.ConfigOption',
|
|
55
|
+
CephConfigOptionProps,
|
|
56
|
+
CephConfigOptionAttributes,
|
|
57
|
+
never,
|
|
58
|
+
PveRequirements
|
|
59
|
+
> {}
|
|
60
|
+
|
|
61
|
+
export const CephConfigOption = Resource<CephConfigOption>('Ceph.ConfigOption', {
|
|
62
|
+
defaultRemovalPolicy: 'retain',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** The whole mon config db, over the PVE API, on a read lease. */
|
|
66
|
+
const readRows = (props: Pick<CephConfigOptionProps, 'target' | 'node'>) =>
|
|
67
|
+
runPve(props.target, 'read', false, nodes.listNodeCephCfgDb({ node: props.node }));
|
|
68
|
+
|
|
69
|
+
const refuse = (props: CephConfigOptionProps) => {
|
|
70
|
+
const problem = configOptionProblem(props);
|
|
71
|
+
return problem === undefined ? Effect.void : Effect.fail(new Error(problem));
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const CephConfigOptionProvider = () =>
|
|
75
|
+
Provider.effect(
|
|
76
|
+
CephConfigOption,
|
|
77
|
+
Effect.succeed(
|
|
78
|
+
CephConfigOption.Provider.of({
|
|
79
|
+
list: () => Effect.succeed([]),
|
|
80
|
+
|
|
81
|
+
read: Effect.fn(function* ({ olds, output }) {
|
|
82
|
+
const live = findLive(yield* readRows(olds), olds.who, olds.name);
|
|
83
|
+
if (live === undefined) return undefined;
|
|
84
|
+
return output !== undefined ? live : Unowned(live);
|
|
85
|
+
}),
|
|
86
|
+
|
|
87
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
88
|
+
if (!isResolved(news)) return undefined;
|
|
89
|
+
yield* refuse(news);
|
|
90
|
+
if (output === undefined) return undefined;
|
|
91
|
+
if (output.who !== news.who || output.name !== news.name)
|
|
92
|
+
return { action: 'replace' } as const;
|
|
93
|
+
const rows = yield* readOrUnreadable(readRows(news));
|
|
94
|
+
if (rows === UNREADABLE) {
|
|
95
|
+
yield* unreadableWarning(
|
|
96
|
+
'Ceph.ConfigOption',
|
|
97
|
+
`${news.who}/${news.name}`,
|
|
98
|
+
news.target.mount,
|
|
99
|
+
mintTier(news.target, 'read'),
|
|
100
|
+
);
|
|
101
|
+
return { action: 'noop' } as const;
|
|
102
|
+
}
|
|
103
|
+
const live = findLive(rows, news.who, news.name);
|
|
104
|
+
return { action: matches(live, news) ? 'noop' : 'update' } as const;
|
|
105
|
+
}),
|
|
106
|
+
|
|
107
|
+
reconcile: ({ news }) => reconcileCephConfigOption(news, readRows(news), {}),
|
|
108
|
+
|
|
109
|
+
delete: ({ olds }) => deleteCephConfigOption(olds, readRows(olds), {}),
|
|
110
|
+
}),
|
|
111
|
+
),
|
|
112
|
+
);
|
package/src/ceph/index.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Ceph providers for Alchemy — the mon-command transport
|
|
2
|
+
* Ceph providers for Alchemy — the mon-command transport, `Ceph.AuthEntity` (K-A4) and
|
|
3
|
+
* `Ceph.ConfigOption` (K-D).
|
|
3
4
|
*
|
|
4
5
|
* ⛔ THIS BARREL IS THE PUBLIC API, DELIBERATELY SMALLER THAN THE DIRECTORY — see
|
|
5
6
|
* `../openbao/index.ts`'s header for why. The allowlist internals (ceph-argv.ts), the parsing
|
|
6
|
-
* (ceph-auth-parse.ts) and the reconcile
|
|
7
|
-
*
|
|
7
|
+
* (ceph-auth-parse.ts) and the reconcile loops (ceph-auth-reconcile.ts,
|
|
8
|
+
* ceph-config-option-reconcile.ts) are reached by path from tests, not published here.
|
|
8
9
|
*/
|
|
9
10
|
export type { CephAuthEntityAttributes, CephAuthEntityProps, CephCaps } from './auth-entity.ts';
|
|
10
11
|
export { CephAuthEntity, CephAuthEntityProvider } from './auth-entity.ts';
|
|
11
12
|
export type { CephDial, CephExecResult, CephTransportOptions } from './ceph-transport.ts';
|
|
12
13
|
export { assertFreshQuorum, runCephCommand, sshCephDial } from './ceph-transport.ts';
|
|
14
|
+
export type { CephConfigOptionAttributes, CephConfigOptionProps } from './config-option.ts';
|
|
15
|
+
export { CephConfigOption, CephConfigOptionProvider } from './config-option.ts';
|
|
16
|
+
export { NAMED_CONFIG_OPTIONS } from './ceph-config-options.ts';
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sshSudoRunner `privilegedReads`: an exact-path, root-owned-regular-file-only sudo read, so a
|
|
3
|
+
* plan can diff a declared 0600 file without a general root-read primitive.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import type { FileStat, HostRunner } from '../launchd/runner.ts';
|
|
7
|
+
import { SUDO } from './sudo-allowlist.ts';
|
|
8
|
+
import {
|
|
9
|
+
BASE64,
|
|
10
|
+
SudoRefusedError,
|
|
11
|
+
checkPrivilegedReads,
|
|
12
|
+
makeSshSudoRunner,
|
|
13
|
+
} from './sudo-runner.ts';
|
|
14
|
+
|
|
15
|
+
const SECRETISH = '/opt/app/config.yaml';
|
|
16
|
+
const file = (uid: number, kind: FileStat['kind'] = 'file'): FileStat =>
|
|
17
|
+
({ kind, mode: 0o600, uid, gid: 0, size: 5 }) as unknown as FileStat;
|
|
18
|
+
|
|
19
|
+
const host = (stat: FileStat | undefined, exitCode = 0) => {
|
|
20
|
+
const execs: string[][] = [];
|
|
21
|
+
const operatorReads: string[] = [];
|
|
22
|
+
const base = {
|
|
23
|
+
platform: 'linux',
|
|
24
|
+
privileged: false,
|
|
25
|
+
effectiveUid: () => 1001,
|
|
26
|
+
stat: async () => stat,
|
|
27
|
+
readFile: async (path: string) => {
|
|
28
|
+
operatorReads.push(path);
|
|
29
|
+
return new TextEncoder().encode('operator');
|
|
30
|
+
},
|
|
31
|
+
exec: async (argv: string[]) => {
|
|
32
|
+
execs.push(argv);
|
|
33
|
+
return {
|
|
34
|
+
exitCode,
|
|
35
|
+
stdout: `${Buffer.from('hello').toString('base64')}\n`,
|
|
36
|
+
stderr: exitCode === 0 ? '' : 'boom',
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
} as unknown as HostRunner;
|
|
40
|
+
const runner = makeSshSudoRunner(['/opt/app/data'], {
|
|
41
|
+
base,
|
|
42
|
+
log: () => undefined,
|
|
43
|
+
stage: async () => {
|
|
44
|
+
throw new Error('unused');
|
|
45
|
+
},
|
|
46
|
+
privilegedReads: [SECRETISH],
|
|
47
|
+
});
|
|
48
|
+
return { execs, operatorReads, runner };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe('privilegedReads', () => {
|
|
52
|
+
test('a listed root-owned file is read with exactly sudo -n -- base64 -- <path>', async () => {
|
|
53
|
+
const { execs, runner } = host(file(0));
|
|
54
|
+
expect(new TextDecoder().decode(await runner.readFile(SECRETISH))).toBe('hello');
|
|
55
|
+
expect(execs).toEqual([[SUDO, '-n', '--', BASE64, '--', SECRETISH]]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('an unlisted path is read as the operator, never elevated', async () => {
|
|
59
|
+
const { execs, operatorReads, runner } = host(file(0));
|
|
60
|
+
await runner.readFile('/etc/caddy/cf-token.env');
|
|
61
|
+
await runner.readFile(`${SECRETISH}.bak`);
|
|
62
|
+
await runner.readFile('/opt/app');
|
|
63
|
+
expect(execs).toEqual([]);
|
|
64
|
+
expect(operatorReads).toEqual(['/etc/caddy/cf-token.env', `${SECRETISH}.bak`, '/opt/app']);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a symlink, a directory or a non-root file at the listed path is refused', async () => {
|
|
68
|
+
for (const stat of [file(0, 'symlink'), file(0, 'directory'), file(1001)]) {
|
|
69
|
+
const { execs, runner } = host(stat);
|
|
70
|
+
await expect(runner.readFile(SECRETISH)).rejects.toBeInstanceOf(SudoRefusedError);
|
|
71
|
+
expect(execs).toEqual([]);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('an absent listed file reads as absent', async () => {
|
|
76
|
+
const { execs, runner } = host(undefined);
|
|
77
|
+
expect(await runner.readFile(SECRETISH)).toBeUndefined();
|
|
78
|
+
expect(execs).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a failed sudo read throws', async () => {
|
|
82
|
+
const { runner } = host(file(0), 1);
|
|
83
|
+
await expect(runner.readFile(SECRETISH)).rejects.toThrow('-> 1');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('only normalized absolute paths may be listed', () => {
|
|
87
|
+
for (const bad of ['relative', '/opt/../etc/shadow', '/opt//x', '/opt/x/', '/opt/./x', '/']) {
|
|
88
|
+
expect(() => checkPrivilegedReads([bad])).toThrow(SudoRefusedError);
|
|
89
|
+
}
|
|
90
|
+
expect(checkPrivilegedReads([SECRETISH]).has(SECRETISH)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
package/src/linux/sudo-runner.ts
CHANGED
|
@@ -44,6 +44,14 @@ export type SshSudoRunnerOptions = SshRunnerOptions & {
|
|
|
44
44
|
readonly prefixes: readonly string[];
|
|
45
45
|
/** One line per privileged argv, before it runs. @default a line on stderr */
|
|
46
46
|
readonly log?: (line: string) => void;
|
|
47
|
+
/**
|
|
48
|
+
* ★ Exact absolute paths of root-owned files the operator cannot read but a plan must diff
|
|
49
|
+
* (a declared 0600 file). Each is read with `sudo -n -- /usr/bin/base64 -- <path>`, and ONLY
|
|
50
|
+
* when it is the exact string listed here, a regular file (lstat: never a symlink) and owned
|
|
51
|
+
* by uid 0. ⛔ Not a prefix and not a pattern: a general sudo read would be a root-read
|
|
52
|
+
* primitive reaching every credential on the host, so each file is named one by one.
|
|
53
|
+
*/
|
|
54
|
+
readonly privilegedReads?: readonly string[];
|
|
47
55
|
};
|
|
48
56
|
|
|
49
57
|
/** What a sudo runner is built from. ★ Split out so the tests swap the host, never the logic. */
|
|
@@ -52,8 +60,31 @@ export type SshSudoDeps = {
|
|
|
52
60
|
readonly base: HostRunner;
|
|
53
61
|
readonly stage: (bytes: Uint8Array) => Promise<Staged>;
|
|
54
62
|
readonly log: (line: string) => void;
|
|
63
|
+
readonly privilegedReads?: readonly string[];
|
|
55
64
|
};
|
|
56
65
|
|
|
66
|
+
export const BASE64 = '/usr/bin/base64';
|
|
67
|
+
|
|
68
|
+
/** Refuses anything but a normalized absolute path: no `.`/`..` segment, no `//`, no trailing `/`. */
|
|
69
|
+
export const checkPrivilegedReads = (paths: readonly string[] = []): ReadonlySet<string> => {
|
|
70
|
+
for (const path of paths) {
|
|
71
|
+
const segments = path.split('/').slice(1);
|
|
72
|
+
if (
|
|
73
|
+
!path.startsWith('/') ||
|
|
74
|
+
path.endsWith('/') ||
|
|
75
|
+
segments.some((segment) => segment === '' || segment === '.' || segment === '..') ||
|
|
76
|
+
/[\0\n]/.test(path)
|
|
77
|
+
)
|
|
78
|
+
throw new SudoRefusedError(
|
|
79
|
+
`sshSudoRunner: privilegedReads entry ${JSON.stringify(path)} is not a normalized absolute file path`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return new Set(paths);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const decodeBase64 = (text: string): Uint8Array =>
|
|
86
|
+
new Uint8Array(Buffer.from(text.replaceAll('\n', ''), 'base64'));
|
|
87
|
+
|
|
57
88
|
const outsidePrefixHint =
|
|
58
89
|
(path: string, prefixes: readonly string[]) =>
|
|
59
90
|
(cause: unknown): never => {
|
|
@@ -70,6 +101,7 @@ const outsidePrefixHint =
|
|
|
70
101
|
export const makeSshSudoRunner = (prefixList: readonly string[], deps: SshSudoDeps): HostRunner => {
|
|
71
102
|
const prefixes = checkPrefixes(prefixList);
|
|
72
103
|
const { base } = deps;
|
|
104
|
+
const readable = checkPrivilegedReads(deps.privilegedReads);
|
|
73
105
|
|
|
74
106
|
/** ⛔ THE ONE PLACE `sudo` IS SPAWNED: allowlist, then any host check (`before`), then the log
|
|
75
107
|
* line, then `sudo -n -- argv`. Shared by exec()'s root branch and by writeFileAtomic. */
|
|
@@ -103,7 +135,27 @@ export const makeSshSudoRunner = (prefixList: readonly string[], deps: SshSudoDe
|
|
|
103
135
|
// this runner elevates on the SAME target `base` (sshRunner) already declared.
|
|
104
136
|
platform: base.platform,
|
|
105
137
|
privileged: true,
|
|
106
|
-
readFile: (path) =>
|
|
138
|
+
readFile: async (path) => {
|
|
139
|
+
if (!readable.has(path)) return base.readFile(path);
|
|
140
|
+
const stat = await base.stat(path);
|
|
141
|
+
if (stat === undefined) return undefined;
|
|
142
|
+
if (stat.kind !== 'file' || stat.uid !== 0)
|
|
143
|
+
throw new SudoRefusedError(
|
|
144
|
+
`sshSudoRunner refused a privileged read of ${path}: it is a ${stat.kind} owned by uid ` +
|
|
145
|
+
`${String(stat.uid)}, and only a root-owned regular file is read as root`,
|
|
146
|
+
);
|
|
147
|
+
const argv = [BASE64, '--', path];
|
|
148
|
+
const shown = JSON.stringify(argv);
|
|
149
|
+
deps.log(`homeflare/linux sudo -n ${shown}`);
|
|
150
|
+
const result = await base.exec([SUDO, '-n', '--', ...argv]);
|
|
151
|
+
const refused = sudoRefusal(shown, result);
|
|
152
|
+
if (refused !== undefined) throw refused;
|
|
153
|
+
if (result.exitCode !== 0)
|
|
154
|
+
throw new Error(
|
|
155
|
+
`sudo -n ${shown} -> ${String(result.exitCode)}: ${result.stderr.trim().slice(0, 300)}`,
|
|
156
|
+
);
|
|
157
|
+
return decodeBase64(result.stdout);
|
|
158
|
+
},
|
|
107
159
|
removeFile: async (path) => {
|
|
108
160
|
const prefix = prefixOf(path, prefixes);
|
|
109
161
|
if (prefix === undefined)
|
|
@@ -157,6 +209,7 @@ export const sshSudoRunner = async (options: SshSudoRunnerOptions): Promise<Host
|
|
|
157
209
|
await checkProgramsExist(base, options.host);
|
|
158
210
|
return makeSshSudoRunner(options.prefixes, {
|
|
159
211
|
base,
|
|
212
|
+
...(options.privilegedReads === undefined ? {} : { privilegedReads: options.privilegedReads }),
|
|
160
213
|
log:
|
|
161
214
|
options.log ??
|
|
162
215
|
((line) => {
|
package/src/proxmox/index.ts
CHANGED
|
@@ -130,3 +130,16 @@ export { ProxmoxStorage, ProxmoxStorageProvider } from './storage.ts';
|
|
|
130
130
|
export { ProxmoxUser, ProxmoxUserProvider } from './user.ts';
|
|
131
131
|
export { type FromEnv } from '../secrets/write-only.ts';
|
|
132
132
|
export { ProxmoxZfsPool, ProxmoxZfsPoolProvider } from './zfs-pool.ts';
|
|
133
|
+
|
|
134
|
+
// ★ 2026-10-14: scoped PVE API helper for Ceph / CT ops without SSH (scoped-api.ts).
|
|
135
|
+
export {
|
|
136
|
+
CEPH_FLAGS,
|
|
137
|
+
CT_CONFIG_KEYS,
|
|
138
|
+
type CephFlag,
|
|
139
|
+
type CtConfigPatch,
|
|
140
|
+
type ScopedCall,
|
|
141
|
+
type ScopedTier,
|
|
142
|
+
pveScoped,
|
|
143
|
+
scopedCalls,
|
|
144
|
+
scopedTarget,
|
|
145
|
+
} from './scoped-api.ts';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import { type PveTarget, mintTier } from './credentials.ts';
|
|
4
|
+
import { CT_CONFIG_KEYS, scopedCalls, scopedTarget } from './scoped-api.ts';
|
|
5
|
+
|
|
6
|
+
const target: PveTarget = { mount: 'proxmox-tb4', members: ['n2.mgmt.example'], scheme: 'pve' };
|
|
7
|
+
|
|
8
|
+
describe('pveScoped', () => {
|
|
9
|
+
it('pins BOTH roles to the scoped tier, so neither read nor provision can be minted', () => {
|
|
10
|
+
for (const tier of ['ceph-ops', 'ct-config'] as const) {
|
|
11
|
+
const t = scopedTarget(target, tier);
|
|
12
|
+
assert.equal(mintTier(t, 'read'), tier);
|
|
13
|
+
assert.equal(mintTier(t, 'provision'), tier);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
it('ceph calls are fixed paths on the ceph-ops tier', () => {
|
|
17
|
+
assert.deepEqual(scopedCalls.cephStatus(), {
|
|
18
|
+
tier: 'ceph-ops',
|
|
19
|
+
role: 'read',
|
|
20
|
+
method: 'GET',
|
|
21
|
+
path: '/cluster/ceph/status',
|
|
22
|
+
});
|
|
23
|
+
assert.deepEqual(scopedCalls.cephSetFlag('noout', false), {
|
|
24
|
+
tier: 'ceph-ops',
|
|
25
|
+
role: 'provision',
|
|
26
|
+
method: 'PUT',
|
|
27
|
+
path: '/cluster/ceph/flags/noout',
|
|
28
|
+
form: { value: '0' },
|
|
29
|
+
});
|
|
30
|
+
assert.equal(scopedCalls.cephOsd('n3', 13, 'in').path, '/nodes/n3/ceph/osd/13/in');
|
|
31
|
+
});
|
|
32
|
+
it('refuses flags, nodes, ids and actions outside the closed set', () => {
|
|
33
|
+
assert.throws(() => scopedCalls.cephSetFlag('pause' as never, true));
|
|
34
|
+
assert.throws(() => scopedCalls.cephOsd('../n2', 1, 'in'));
|
|
35
|
+
assert.throws(() => scopedCalls.cephOsd('n2', -1, 'out'));
|
|
36
|
+
assert.throws(() => scopedCalls.ctConfig('n2', 99));
|
|
37
|
+
assert.throws(() => scopedCalls.ctPower('n2', 100, 'destroy' as never));
|
|
38
|
+
});
|
|
39
|
+
it('ct config writes only the keys HFCTConfig covers, on the ct-config tier', () => {
|
|
40
|
+
const c = scopedCalls.ctSetConfig('n2', 100, { cores: 8, memory: 16384, onboot: true });
|
|
41
|
+
assert.deepEqual(c, {
|
|
42
|
+
tier: 'ct-config',
|
|
43
|
+
role: 'provision',
|
|
44
|
+
method: 'PUT',
|
|
45
|
+
path: '/nodes/n2/lxc/100/config',
|
|
46
|
+
form: { cores: '8', memory: '16384', onboot: '1' },
|
|
47
|
+
});
|
|
48
|
+
assert.throws(() => scopedCalls.ctSetConfig('n2', 100, { rootfs: 'x' } as never));
|
|
49
|
+
assert.throws(() => scopedCalls.ctSetConfig('n2', 100, {}));
|
|
50
|
+
assert.ok(!(CT_CONFIG_KEYS as readonly string[]).includes('rootfs'));
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pveScoped`: the agent helper for Ceph and container operations over the PVE API when SSH to a
|
|
3
|
+
* node is not available (2026-10-14, "PVE API path").
|
|
4
|
+
*
|
|
5
|
+
* ★ EVERY CALL MINTS A SCOPED TIER, NEVER `provision`. A call names its tier (`ceph-ops` or
|
|
6
|
+
* `ct-config`); `scopedTarget` pins BOTH of the target's roles to that tier (credentials.ts's
|
|
7
|
+
* "set both roles or neither"), so no read or write in it can fall back to the estate-wide tier.
|
|
8
|
+
* In the estate those tiers are `proxmox-tb4/creds/{ceph-ops,ct-config}` → hf-ceph-ops@pve
|
|
9
|
+
* (HFCephOps on `/`) and hf-ct-config@pve (HFCTConfig on `/vms`); the PVE ACL is the boundary.
|
|
10
|
+
* ★ A CLOSED SET OF OPERATIONS. Each builder returns a fixed method + path, with every path
|
|
11
|
+
* segment validated, and `ctSetConfig` accepts only the keys HFCTConfig can change anyway.
|
|
12
|
+
* No free-form path, so an agent cannot turn this into a general PVE client.
|
|
13
|
+
* ⛔ The minted secret never leaves `mint`/`pve()` (mint.ts's ⛔). Results are PVE's `data`.
|
|
14
|
+
* ⛔ Not a substitute for Alchemy: a change that should persist (a declared CT's cores, a Ceph
|
|
15
|
+
* flag that a stack owns) still lands as a reviewed PR. This is the break-glass read/act path.
|
|
16
|
+
*/
|
|
17
|
+
import type * as Effect from 'effect/Effect';
|
|
18
|
+
import type * as HttpClient from 'effect/http/HttpClient';
|
|
19
|
+
import type { PveCredentialDenied } from './credential-errors.ts';
|
|
20
|
+
import { type PveForm, pve } from './client.ts';
|
|
21
|
+
import type { PveRole, PveTarget } from './credentials.ts';
|
|
22
|
+
|
|
23
|
+
export type ScopedTier = 'ceph-ops' | 'ct-config';
|
|
24
|
+
|
|
25
|
+
export type ScopedCall = {
|
|
26
|
+
readonly tier: ScopedTier;
|
|
27
|
+
readonly role: PveRole;
|
|
28
|
+
readonly method: 'GET' | 'POST' | 'PUT';
|
|
29
|
+
readonly path: string;
|
|
30
|
+
readonly form?: PveForm;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** The target with BOTH roles pinned to `tier`: nothing here can mint `read`/`provision`. */
|
|
34
|
+
export const scopedTarget = (target: PveTarget, tier: ScopedTier): PveTarget => ({
|
|
35
|
+
...target,
|
|
36
|
+
roles: { read: tier, provision: tier },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const NODE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
40
|
+
const need = (ok: boolean, what: string) => {
|
|
41
|
+
if (!ok) throw new Error(`pveScoped: invalid ${what}`);
|
|
42
|
+
};
|
|
43
|
+
const node = (n: string) => (need(NODE.test(n), 'node name'), n);
|
|
44
|
+
const vmid = (v: number) => (
|
|
45
|
+
need(Number.isInteger(v) && v >= 100 && v <= 999_999_999, 'vmid'),
|
|
46
|
+
String(v)
|
|
47
|
+
);
|
|
48
|
+
const osd = (o: number) => (
|
|
49
|
+
need(Number.isInteger(o) && o >= 0 && o < 100_000, 'osd id'),
|
|
50
|
+
String(o)
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
/** Flags an operator sets around maintenance. `pause`, `full` and friends are deliberately absent. */
|
|
54
|
+
export const CEPH_FLAGS = [
|
|
55
|
+
'noout',
|
|
56
|
+
'norebalance',
|
|
57
|
+
'norecover',
|
|
58
|
+
'nobackfill',
|
|
59
|
+
'noscrub',
|
|
60
|
+
'nodeep-scrub',
|
|
61
|
+
] as const;
|
|
62
|
+
export type CephFlag = (typeof CEPH_FLAGS)[number];
|
|
63
|
+
|
|
64
|
+
/** The CT config keys `ctSetConfig` may write: exactly what HFCTConfig's VM.Config.* privs cover. */
|
|
65
|
+
export const CT_CONFIG_KEYS = [
|
|
66
|
+
'cores',
|
|
67
|
+
'cpulimit',
|
|
68
|
+
'memory',
|
|
69
|
+
'swap',
|
|
70
|
+
'onboot',
|
|
71
|
+
'description',
|
|
72
|
+
'tags',
|
|
73
|
+
] as const;
|
|
74
|
+
export type CtConfigPatch = Partial<
|
|
75
|
+
Record<(typeof CT_CONFIG_KEYS)[number], string | number | boolean>
|
|
76
|
+
>;
|
|
77
|
+
|
|
78
|
+
export const scopedCalls = {
|
|
79
|
+
cephStatus: (): ScopedCall => ({
|
|
80
|
+
tier: 'ceph-ops',
|
|
81
|
+
role: 'read',
|
|
82
|
+
method: 'GET',
|
|
83
|
+
path: '/cluster/ceph/status',
|
|
84
|
+
}),
|
|
85
|
+
cephFlags: (): ScopedCall => ({
|
|
86
|
+
tier: 'ceph-ops',
|
|
87
|
+
role: 'read',
|
|
88
|
+
method: 'GET',
|
|
89
|
+
path: '/cluster/ceph/flags',
|
|
90
|
+
}),
|
|
91
|
+
cephSetFlag: (flag: CephFlag, on: boolean): ScopedCall => {
|
|
92
|
+
need((CEPH_FLAGS as readonly string[]).includes(flag), 'ceph flag');
|
|
93
|
+
return {
|
|
94
|
+
tier: 'ceph-ops',
|
|
95
|
+
role: 'provision',
|
|
96
|
+
method: 'PUT',
|
|
97
|
+
path: `/cluster/ceph/flags/${flag}`,
|
|
98
|
+
form: { value: on ? '1' : '0' },
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
cephOsd: (n: string, id: number, to: 'in' | 'out'): ScopedCall => {
|
|
102
|
+
need(to === 'in' || to === 'out', 'osd direction');
|
|
103
|
+
return {
|
|
104
|
+
tier: 'ceph-ops',
|
|
105
|
+
role: 'provision',
|
|
106
|
+
method: 'POST',
|
|
107
|
+
path: `/nodes/${node(n)}/ceph/osd/${osd(id)}/${to}`,
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
ctConfig: (n: string, id: number): ScopedCall => ({
|
|
111
|
+
tier: 'ct-config',
|
|
112
|
+
role: 'read',
|
|
113
|
+
method: 'GET',
|
|
114
|
+
path: `/nodes/${node(n)}/lxc/${vmid(id)}/config`,
|
|
115
|
+
}),
|
|
116
|
+
ctSetConfig: (n: string, id: number, patch: CtConfigPatch): ScopedCall => {
|
|
117
|
+
const keys = Object.keys(patch);
|
|
118
|
+
need(keys.length > 0, 'empty config patch');
|
|
119
|
+
for (const k of keys)
|
|
120
|
+
need((CT_CONFIG_KEYS as readonly string[]).includes(k), `config key ${k}`);
|
|
121
|
+
const form: Record<string, string> = {};
|
|
122
|
+
for (const [k, v] of Object.entries(patch))
|
|
123
|
+
form[k] = typeof v === 'boolean' ? (v ? '1' : '0') : String(v);
|
|
124
|
+
return {
|
|
125
|
+
tier: 'ct-config',
|
|
126
|
+
role: 'provision',
|
|
127
|
+
method: 'PUT',
|
|
128
|
+
path: `/nodes/${node(n)}/lxc/${vmid(id)}/config`,
|
|
129
|
+
form,
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
ctPower: (n: string, id: number, action: 'start' | 'shutdown' | 'reboot'): ScopedCall => {
|
|
133
|
+
need(['start', 'shutdown', 'reboot'].includes(action), 'power action');
|
|
134
|
+
return {
|
|
135
|
+
tier: 'ct-config',
|
|
136
|
+
role: 'provision',
|
|
137
|
+
method: 'POST',
|
|
138
|
+
path: `/nodes/${node(n)}/lxc/${vmid(id)}/status/${action}`,
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
} as const;
|
|
142
|
+
|
|
143
|
+
/** Run one scoped call: mint its tier (only), call PVE, return `data`. */
|
|
144
|
+
export const pveScoped = <T = unknown>(
|
|
145
|
+
target: PveTarget,
|
|
146
|
+
call: ScopedCall,
|
|
147
|
+
): Effect.Effect<T | undefined, Error | PveCredentialDenied, HttpClient.HttpClient> =>
|
|
148
|
+
pve<T>(scopedTarget(target, call.tier), call.role, call.method, call.path, call.form);
|