@celilo/cli 0.9.1 → 0.11.0-alpha.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/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +16 -0
- package/drizzle/0014_api_principals.sql +10 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +14 -6
- package/src/api/protocol.test.ts +76 -0
- package/src/api/remote-client.test.ts +91 -0
- package/src/api/serve.ts +159 -0
- package/src/cli/command-tree-parser.ts +3 -1
- package/src/cli/commands/api.ts +194 -0
- package/src/cli/commands/apt-upgrade.test.ts +33 -0
- package/src/cli/commands/apt-upgrade.ts +63 -0
- package/src/cli/commands/commands-json.ts +29 -0
- package/src/cli/commands/completion.ts +1 -1
- package/src/cli/commands/module-list.ts +16 -2
- package/src/cli/commands/publish/helpers.ts +18 -0
- package/src/cli/commands/publish/types.ts +6 -8
- package/src/cli/commands/publish/workspace.test.ts +44 -7
- package/src/cli/commands/publish/workspace.ts +40 -164
- package/src/cli/commands/service-list.ts +15 -2
- package/src/cli/completion.ts +24 -0
- package/src/cli/generate-zsh-completion.test.ts +22 -4
- package/src/cli/generate-zsh-completion.ts +7 -3
- package/src/cli/index.ts +109 -2
- package/src/cli/parser.test.ts +13 -0
- package/src/cli/parser.ts +12 -3
- package/src/db/schema.ts +30 -0
- package/src/hooks/capability-loader.test.ts +77 -0
- package/src/hooks/capability-loader.ts +56 -0
- package/src/services/api-access.test.ts +138 -0
- package/src/services/api-access.ts +154 -0
- package/src/services/remote-responder.test.ts +78 -0
- package/src/services/remote-responder.ts +89 -0
- package/src/cli/command-registry.ts +0 -1443
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote API access control (see v2/API_COMMUNICATION.md, Slice 2a).
|
|
3
|
+
*
|
|
4
|
+
* Stores API principals (name + SSH public key + grants), answers authz
|
|
5
|
+
* queries (deny-by-default), and renders the API account's `authorized_keys`
|
|
6
|
+
* with one forced-command line per principal. No SSH plumbing lives here —
|
|
7
|
+
* `renderAuthorizedKeys()` returns the file content; installing it is a
|
|
8
|
+
* separate operational step (Slice 2b).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { eq } from 'drizzle-orm';
|
|
13
|
+
import { getDb } from '../db/client';
|
|
14
|
+
import { type ApiPrincipal, apiPrincipals } from '../db/schema';
|
|
15
|
+
|
|
16
|
+
const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
17
|
+
const GRANT_PATTERN = /^(\*|[a-z0-9]+(-[a-z0-9]+)*(:(\*|[a-z0-9]+(-[a-z0-9]+)*))?)$/;
|
|
18
|
+
const PUBKEY_TYPES = [
|
|
19
|
+
'ssh-ed25519',
|
|
20
|
+
'ssh-rsa',
|
|
21
|
+
'ssh-dss',
|
|
22
|
+
'ecdsa-sha2-nistp256',
|
|
23
|
+
'ecdsa-sha2-nistp384',
|
|
24
|
+
'ecdsa-sha2-nistp521',
|
|
25
|
+
'sk-ssh-ed25519@openssh.com',
|
|
26
|
+
'sk-ecdsa-sha2-nistp256@openssh.com',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const FORCED_COMMAND_OPTS = 'no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding';
|
|
30
|
+
|
|
31
|
+
export function validatePrincipalName(name: string): void {
|
|
32
|
+
if (!NAME_PATTERN.test(name)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Invalid principal name "${name}": use kebab-case (e.g. "alice", "ci-deployer").`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Basic SSH public-key shape check: `<type> <base64> [comment]`. */
|
|
40
|
+
export function validatePublicKey(key: string): void {
|
|
41
|
+
const parts = key.trim().split(/\s+/);
|
|
42
|
+
if (
|
|
43
|
+
parts.length < 2 ||
|
|
44
|
+
!PUBKEY_TYPES.includes(parts[0]) ||
|
|
45
|
+
!/^[A-Za-z0-9+/]+={0,3}$/.test(parts[1])
|
|
46
|
+
) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
'Invalid SSH public key. Expected "<type> <base64> [comment]" (e.g. contents of ~/.ssh/id_ed25519.pub).',
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function validateGrant(grant: string): void {
|
|
54
|
+
if (!GRANT_PATTERN.test(grant)) {
|
|
55
|
+
throw new Error(`Invalid grant "${grant}": use "command:subcommand", "command:*", or "*".`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Does this grant set permit `command`/`subcommand`? Pure — deny-by-default.
|
|
61
|
+
* Matches an exact `command:subcommand`, a `command:*` wildcard, `*`, or a
|
|
62
|
+
* bare `command` grant for a subcommand-less command.
|
|
63
|
+
*/
|
|
64
|
+
export function grantsAllow(grants: string[], command: string, subcommand?: string): boolean {
|
|
65
|
+
const op = subcommand ? `${command}:${subcommand}` : command;
|
|
66
|
+
for (const grant of grants) {
|
|
67
|
+
if (grant === '*') return true;
|
|
68
|
+
if (grant === op) return true;
|
|
69
|
+
if (grant === `${command}:*`) return true;
|
|
70
|
+
if (!subcommand && grant === command) return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function listPrincipals(): Promise<ApiPrincipal[]> {
|
|
76
|
+
return getDb().select().from(apiPrincipals).all();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function getPrincipalByName(name: string): Promise<ApiPrincipal | null> {
|
|
80
|
+
const rows = getDb().select().from(apiPrincipals).where(eq(apiPrincipals.name, name)).all();
|
|
81
|
+
return rows[0] ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create or replace a principal's key + grants (upsert by name). Validates all
|
|
86
|
+
* inputs; throws on the first invalid one.
|
|
87
|
+
*/
|
|
88
|
+
export async function grantPrincipal(params: {
|
|
89
|
+
name: string;
|
|
90
|
+
publicKey: string;
|
|
91
|
+
grants: string[];
|
|
92
|
+
}): Promise<{ principal: ApiPrincipal; created: boolean }> {
|
|
93
|
+
const { name, publicKey, grants } = params;
|
|
94
|
+
validatePrincipalName(name);
|
|
95
|
+
validatePublicKey(publicKey);
|
|
96
|
+
for (const grant of grants) validateGrant(grant);
|
|
97
|
+
|
|
98
|
+
const db = getDb();
|
|
99
|
+
const existing = await getPrincipalByName(name);
|
|
100
|
+
|
|
101
|
+
if (existing) {
|
|
102
|
+
db.update(apiPrincipals).set({ publicKey, grants }).where(eq(apiPrincipals.name, name)).run();
|
|
103
|
+
const updated = await getPrincipalByName(name);
|
|
104
|
+
if (!updated) throw new Error(`Principal "${name}" vanished after update`);
|
|
105
|
+
return { principal: updated, created: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const row: ApiPrincipal = {
|
|
109
|
+
id: randomUUID(),
|
|
110
|
+
name,
|
|
111
|
+
publicKey,
|
|
112
|
+
grants,
|
|
113
|
+
createdAt: new Date(),
|
|
114
|
+
updatedAt: new Date(),
|
|
115
|
+
};
|
|
116
|
+
db.insert(apiPrincipals).values(row).run();
|
|
117
|
+
return { principal: row, created: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Remove a principal. Returns false if it didn't exist. */
|
|
121
|
+
export async function revokePrincipal(name: string): Promise<boolean> {
|
|
122
|
+
const existing = await getPrincipalByName(name);
|
|
123
|
+
if (!existing) return false;
|
|
124
|
+
getDb().delete(apiPrincipals).where(eq(apiPrincipals.name, name)).run();
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Authz entry point: is `principalName` allowed to run `command`/`subcommand`? */
|
|
129
|
+
export async function isAuthorized(
|
|
130
|
+
principalName: string,
|
|
131
|
+
command: string,
|
|
132
|
+
subcommand?: string,
|
|
133
|
+
): Promise<boolean> {
|
|
134
|
+
const principal = await getPrincipalByName(principalName);
|
|
135
|
+
if (!principal) return false;
|
|
136
|
+
return grantsAllow(principal.grants, command, subcommand);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Render the API account's `authorized_keys` — one forced-command line per
|
|
141
|
+
* principal. The principal name is baked into the command so sshd hands the
|
|
142
|
+
* identity to `api-serve` (see the design doc). Names are kebab-case-validated,
|
|
143
|
+
* so no shell-escaping is required inside the `command=` string.
|
|
144
|
+
*/
|
|
145
|
+
export async function renderAuthorizedKeys(): Promise<string> {
|
|
146
|
+
const principals = await listPrincipals();
|
|
147
|
+
if (principals.length === 0) return '';
|
|
148
|
+
return `${principals
|
|
149
|
+
.map(
|
|
150
|
+
(p) =>
|
|
151
|
+
`command="celilo api-serve --principal=${p.name}",${FORCED_COMMAND_OPTS} ${p.publicKey}`,
|
|
152
|
+
)
|
|
153
|
+
.join('\n')}\n`;
|
|
154
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { type BusEvent, defineEvents, openBus } from '@celilo/event-bus';
|
|
6
|
+
import { type WireInterview, startRemoteResponder } from './remote-responder';
|
|
7
|
+
|
|
8
|
+
const NO_SCHEMAS = defineEvents({});
|
|
9
|
+
|
|
10
|
+
let dir: string;
|
|
11
|
+
let busDbPath: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-remresp-'));
|
|
15
|
+
busDbPath = join(dir, 'events.db');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('forwards an interview.required question to ask and replies with the answer', async () => {
|
|
23
|
+
const asked: WireInterview[] = [];
|
|
24
|
+
const responder = startRemoteResponder({
|
|
25
|
+
busDbPath,
|
|
26
|
+
ask: async (iv) => {
|
|
27
|
+
asked.push(iv);
|
|
28
|
+
return 'acme.example.com';
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
|
|
33
|
+
try {
|
|
34
|
+
const replies = (await bus.query(
|
|
35
|
+
'interview.required.site.hostname' as never,
|
|
36
|
+
{
|
|
37
|
+
scope: 'site',
|
|
38
|
+
key: 'hostname',
|
|
39
|
+
kind: 'text',
|
|
40
|
+
message: 'Hostname for the site?',
|
|
41
|
+
required: true,
|
|
42
|
+
} as never,
|
|
43
|
+
{ timeoutMs: 8000, pollIntervalMs: 100, expect: 'first' } as never,
|
|
44
|
+
)) as BusEvent[];
|
|
45
|
+
|
|
46
|
+
expect(replies).toHaveLength(1);
|
|
47
|
+
expect((replies[0].payload as { value: unknown }).value).toBe('acme.example.com');
|
|
48
|
+
expect(asked).toHaveLength(1);
|
|
49
|
+
expect(asked[0].message).toBe('Hostname for the site?');
|
|
50
|
+
expect(asked[0].kind).toBe('text');
|
|
51
|
+
} finally {
|
|
52
|
+
bus.close();
|
|
53
|
+
responder.close();
|
|
54
|
+
}
|
|
55
|
+
}, 15_000);
|
|
56
|
+
|
|
57
|
+
test('answers responder.probe with kind daemon', async () => {
|
|
58
|
+
const responder = startRemoteResponder({ busDbPath, ask: async () => 'unused' });
|
|
59
|
+
|
|
60
|
+
const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
|
|
61
|
+
try {
|
|
62
|
+
const replies = (await bus.query(
|
|
63
|
+
'responder.probe' as never,
|
|
64
|
+
{} as never,
|
|
65
|
+
{
|
|
66
|
+
timeoutMs: 8000,
|
|
67
|
+
pollIntervalMs: 100,
|
|
68
|
+
expect: 'first',
|
|
69
|
+
} as never,
|
|
70
|
+
)) as BusEvent[];
|
|
71
|
+
|
|
72
|
+
expect(replies).toHaveLength(1);
|
|
73
|
+
expect((replies[0].payload as { kind: string }).kind).toBe('daemon');
|
|
74
|
+
} finally {
|
|
75
|
+
bus.close();
|
|
76
|
+
responder.close();
|
|
77
|
+
}
|
|
78
|
+
}, 15_000);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote bus responder (`kind: 'daemon'`) — Slice 3.
|
|
3
|
+
*
|
|
4
|
+
* Runs in the `api-serve` parent and bridges the event bus to the wire: watches
|
|
5
|
+
* the generic `interview.required.*` family, forwards each question to the
|
|
6
|
+
* remote client via an injected `ask` round-trip, and replies on the bus with
|
|
7
|
+
* the client's answer. Also answers `responder.probe` so the (non-TTY) command
|
|
8
|
+
* child doesn't fail-fast for lack of a responder.
|
|
9
|
+
*
|
|
10
|
+
* The deploy-specific families (config / secret / ensure / aspect) reuse this
|
|
11
|
+
* same plumbing with per-family adapters — tracked as a follow-up.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
|
|
15
|
+
import type { InterviewRequiredPayload } from './bus-interview';
|
|
16
|
+
import { RESPONDER_PROBE_EVENT } from './responder-probe';
|
|
17
|
+
|
|
18
|
+
const NO_SCHEMAS = defineEvents({});
|
|
19
|
+
|
|
20
|
+
/** The normalized question handed to the wire (mirrors InterviewMessage). */
|
|
21
|
+
export interface WireInterview {
|
|
22
|
+
id: string;
|
|
23
|
+
kind: InterviewRequiredPayload['kind'];
|
|
24
|
+
message: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
defaultValue?: string;
|
|
27
|
+
placeholder?: string;
|
|
28
|
+
options?: Array<{ value: string; label: string; hint?: string }>;
|
|
29
|
+
required?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RemoteResponderOptions {
|
|
33
|
+
/** Path to the shared bus sqlite db (command child + responder share it). */
|
|
34
|
+
busDbPath: string;
|
|
35
|
+
/** Forward a question to the client and resolve with its answer. */
|
|
36
|
+
ask: (interview: WireInterview) => Promise<unknown>;
|
|
37
|
+
/** `emittedBy` audit label on replies. Defaults to `daemon`. */
|
|
38
|
+
emittedBy?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RemoteResponderHandle {
|
|
42
|
+
close(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function startRemoteResponder(opts: RemoteResponderOptions): RemoteResponderHandle {
|
|
46
|
+
const me = opts.emittedBy ?? 'daemon';
|
|
47
|
+
const bus: Bus = openBus({ dbPath: opts.busDbPath, events: NO_SCHEMAS });
|
|
48
|
+
|
|
49
|
+
const interviewWatch = bus.watch('interview.required.*.*', async (event) => {
|
|
50
|
+
if (event.replyFor !== null) return;
|
|
51
|
+
|
|
52
|
+
const payload = event.payload as InterviewRequiredPayload;
|
|
53
|
+
if (!payload || typeof payload.scope !== 'string' || typeof payload.key !== 'string') {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const value = await opts.ask({
|
|
58
|
+
id: String(event.id),
|
|
59
|
+
kind: payload.kind,
|
|
60
|
+
message: payload.message,
|
|
61
|
+
description: payload.description,
|
|
62
|
+
defaultValue: payload.defaultValue,
|
|
63
|
+
placeholder: payload.placeholder,
|
|
64
|
+
options: payload.options,
|
|
65
|
+
required: payload.required,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
bus.emitRaw(`${event.type}.reply`, { value }, { replyFor: event.id, emittedBy: me });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Liveness probe: the non-TTY command child emits `responder.probe` before
|
|
72
|
+
// asking, to confirm someone is listening. Answer with our kind.
|
|
73
|
+
const probeWatch = bus.watch(RESPONDER_PROBE_EVENT, async (event) => {
|
|
74
|
+
if (event.replyFor !== null) return;
|
|
75
|
+
bus.emitRaw(
|
|
76
|
+
`${event.type}.reply`,
|
|
77
|
+
{ kind: 'daemon', emittedBy: me },
|
|
78
|
+
{ replyFor: event.id, emittedBy: me },
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
close: () => {
|
|
84
|
+
interviewWatch.close();
|
|
85
|
+
probeWatch.close();
|
|
86
|
+
bus.close();
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|