@celilo/cli 0.10.0 → 0.11.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_SUBSYSTEMS.md +11 -0
- package/drizzle/0014_api_principals.sql +10 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +3 -9
- package/src/api/protocol.test.ts +76 -0
- package/src/api/protocol.ts +159 -0
- package/src/api/remote-client.test.ts +91 -0
- package/src/api/remote-client.ts +218 -0
- package/src/api/serve.ts +159 -0
- package/src/cli/command-registry.ts +45 -0
- package/src/cli/commands/api.ts +194 -0
- package/src/cli/completion.ts +22 -0
- package/src/cli/index.ts +95 -0
- package/src/cli/parser.test.ts +13 -0
- package/src/cli/parser.ts +11 -2
- 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/api/serve.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote API server — `celilo api-serve --principal=<id>` (Slices 1–3).
|
|
3
|
+
*
|
|
4
|
+
* Invoked as the sshd forced command for an enrolled key, so it starts already
|
|
5
|
+
* authenticated as `principal`. Reads `command`/`answer` messages as NDJSON on
|
|
6
|
+
* stdin/stdout, authorizes each command against the principal's grants
|
|
7
|
+
* (deny-by-default), and — if allowed — runs it as a child `celilo` process,
|
|
8
|
+
* translating the child's protocol-mode output into `progress`/`log` and a
|
|
9
|
+
* terminal `result`. While a command runs, a remote responder bridges the event
|
|
10
|
+
* bus to the wire so mid-run `interview`s are answered by the client. Every
|
|
11
|
+
* attempt is audited to stderr.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createInterface } from 'node:readline';
|
|
15
|
+
import { parseArguments } from '../cli/parser';
|
|
16
|
+
import { getEventBusPath } from '../config/paths';
|
|
17
|
+
import { isAuthorized } from '../services/api-access';
|
|
18
|
+
import { type WireInterview, startRemoteResponder } from '../services/remote-responder';
|
|
19
|
+
import {
|
|
20
|
+
API_PROTOCOL_VERSION,
|
|
21
|
+
ClientMessageSchema,
|
|
22
|
+
type ServerMessage,
|
|
23
|
+
translateOutputLine,
|
|
24
|
+
} from './protocol';
|
|
25
|
+
|
|
26
|
+
/** Exit code returned to the client when authz denies a command. */
|
|
27
|
+
const EXIT_PERMISSION_DENIED = 126;
|
|
28
|
+
|
|
29
|
+
function send(msg: ServerMessage): void {
|
|
30
|
+
process.stdout.write(`${JSON.stringify(msg)}\n`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function audit(principal: string, op: string, decision: string, exitCode?: number): void {
|
|
34
|
+
const suffix = exitCode === undefined ? '' : ` exit=${exitCode}`;
|
|
35
|
+
process.stderr.write(
|
|
36
|
+
`[api-audit] principal=${principal} op=${op} decision=${decision}${suffix}\n`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Derive the `command`/`subcommand` an argv would run, via the real parser. */
|
|
41
|
+
function opOf(argv: string[]): { command: string; subcommand?: string; label: string } {
|
|
42
|
+
const parsed = parseArguments(['bun', 'celilo', ...argv]);
|
|
43
|
+
const label = parsed.subcommand ? `${parsed.command}:${parsed.subcommand}` : parsed.command;
|
|
44
|
+
return { command: parsed.command, subcommand: parsed.subcommand, label };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Drain a child stream line-by-line, forwarding each line as a message. */
|
|
48
|
+
async function pumpLines(stream: ReadableStream<Uint8Array>): Promise<void> {
|
|
49
|
+
const decoder = new TextDecoder();
|
|
50
|
+
let buffer = '';
|
|
51
|
+
for await (const chunk of stream) {
|
|
52
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
53
|
+
let nl = buffer.indexOf('\n');
|
|
54
|
+
while (nl >= 0) {
|
|
55
|
+
const line = buffer.slice(0, nl);
|
|
56
|
+
buffer = buffer.slice(nl + 1);
|
|
57
|
+
send(translateOutputLine(line));
|
|
58
|
+
nl = buffer.indexOf('\n');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (buffer.length > 0) {
|
|
62
|
+
send(translateOutputLine(buffer));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Run an authorized command as a child; returns its exit code. */
|
|
67
|
+
async function runCommand(argv: string[]): Promise<number> {
|
|
68
|
+
// Re-invoke this same CLI as a child. The child is non-TTY (piped stdout) so
|
|
69
|
+
// ProgressDisplay resolves to protocol mode and emits `[progress:*]` markers.
|
|
70
|
+
const child = Bun.spawn([process.execPath, Bun.main, ...argv], {
|
|
71
|
+
stdin: 'ignore',
|
|
72
|
+
stdout: 'pipe',
|
|
73
|
+
stderr: 'pipe',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await Promise.all([pumpLines(child.stdout), pumpLines(child.stderr)]);
|
|
77
|
+
const exitCode = await child.exited;
|
|
78
|
+
send({ type: 'result', success: exitCode === 0, exitCode });
|
|
79
|
+
return exitCode;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function handleCommand(
|
|
83
|
+
principal: string,
|
|
84
|
+
argv: string[],
|
|
85
|
+
busDbPath: string,
|
|
86
|
+
ask: (interview: WireInterview) => Promise<unknown>,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const { command, subcommand, label } = opOf(argv);
|
|
89
|
+
|
|
90
|
+
if (!(await isAuthorized(principal, command, subcommand))) {
|
|
91
|
+
send({ type: 'error', error: `permission denied: "${principal}" is not granted "${label}"` });
|
|
92
|
+
send({ type: 'result', success: false, exitCode: EXIT_PERMISSION_DENIED });
|
|
93
|
+
audit(principal, label, 'deny');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Bridge the bus to the wire so mid-run interviews reach the client.
|
|
98
|
+
const responder = startRemoteResponder({ busDbPath, ask, emittedBy: `api:${principal}` });
|
|
99
|
+
try {
|
|
100
|
+
const exitCode = await runCommand(argv);
|
|
101
|
+
audit(principal, label, 'allow', exitCode);
|
|
102
|
+
} finally {
|
|
103
|
+
responder.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function apiServeMode(principal: string): Promise<void> {
|
|
108
|
+
send({ type: 'ready', protocolVersion: API_PROTOCOL_VERSION });
|
|
109
|
+
|
|
110
|
+
const busDbPath = getEventBusPath();
|
|
111
|
+
const pendingAnswers = new Map<string, (value: unknown) => void>();
|
|
112
|
+
|
|
113
|
+
const ask = (interview: WireInterview): Promise<unknown> =>
|
|
114
|
+
new Promise((resolve) => {
|
|
115
|
+
pendingAnswers.set(interview.id, resolve);
|
|
116
|
+
send({
|
|
117
|
+
type: 'interview',
|
|
118
|
+
id: interview.id,
|
|
119
|
+
kind: interview.kind,
|
|
120
|
+
message: interview.message,
|
|
121
|
+
description: interview.description,
|
|
122
|
+
defaultValue: interview.defaultValue,
|
|
123
|
+
placeholder: interview.placeholder,
|
|
124
|
+
options: interview.options,
|
|
125
|
+
required: interview.required,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
130
|
+
|
|
131
|
+
for await (const line of rl) {
|
|
132
|
+
if (!line.trim()) continue;
|
|
133
|
+
|
|
134
|
+
let msg: ReturnType<typeof ClientMessageSchema.parse>;
|
|
135
|
+
try {
|
|
136
|
+
msg = ClientMessageSchema.parse(JSON.parse(line));
|
|
137
|
+
} catch (error) {
|
|
138
|
+
send({ type: 'error', error: error instanceof Error ? error.message : String(error) });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (msg.type === 'answer') {
|
|
143
|
+
const resolve = pendingAnswers.get(msg.id);
|
|
144
|
+
if (resolve) {
|
|
145
|
+
pendingAnswers.delete(msg.id);
|
|
146
|
+
resolve(msg.value);
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (msg.type === 'command') {
|
|
152
|
+
// Fire-and-forget so the read loop keeps consuming `answer` messages while
|
|
153
|
+
// the command runs — mid-run interviews are answered in-flight.
|
|
154
|
+
void handleCommand(principal, msg.argv, busDbPath, ask);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
process.exit(0);
|
|
159
|
+
}
|
|
@@ -92,6 +92,51 @@ export const COMMANDS: CommandDef[] = [
|
|
|
92
92
|
},
|
|
93
93
|
],
|
|
94
94
|
},
|
|
95
|
+
{
|
|
96
|
+
name: 'api',
|
|
97
|
+
description: 'Manage remote-API access (principals, grants, authorized_keys)',
|
|
98
|
+
subcommands: [
|
|
99
|
+
{
|
|
100
|
+
name: 'grant',
|
|
101
|
+
description: 'Grant or replace API access for a principal',
|
|
102
|
+
args: [{ name: 'principal', description: 'Principal name (kebab-case)' }],
|
|
103
|
+
flags: [
|
|
104
|
+
{
|
|
105
|
+
name: 'key',
|
|
106
|
+
description: 'SSH public key, or path to a .pub file',
|
|
107
|
+
takesValue: true,
|
|
108
|
+
valueHint: '_files',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'can',
|
|
112
|
+
description: 'Comma-separated grants (e.g. module:deploy,service:*)',
|
|
113
|
+
takesValue: true,
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
{ name: 'list', description: 'List API principals and their grants' },
|
|
118
|
+
{
|
|
119
|
+
name: 'revoke',
|
|
120
|
+
description: 'Revoke API access for a principal',
|
|
121
|
+
args: [{ name: 'principal', description: 'Principal name' }],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'authorized-keys',
|
|
125
|
+
description: "Print the API account's authorized_keys (forced-command lines)",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'key',
|
|
129
|
+
description: 'Client-side key management',
|
|
130
|
+
subcommands: [
|
|
131
|
+
{
|
|
132
|
+
name: 'new',
|
|
133
|
+
description: 'Generate a new API keypair and print enrollment steps',
|
|
134
|
+
args: [{ name: 'name', description: 'Principal name for the key' }],
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
},
|
|
95
140
|
{
|
|
96
141
|
name: 'events',
|
|
97
142
|
description: 'SQLite event-bus operations (status, tail, run dispatcher, etc.)',
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo api ...` — manage remote-API principals (Slice 2a).
|
|
3
|
+
*
|
|
4
|
+
* grant / list / revoke operate on the api_principals table; authorized-keys
|
|
5
|
+
* renders the forced-command file for the API account. See v2/API_COMMUNICATION.md.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
grantPrincipal,
|
|
13
|
+
listPrincipals,
|
|
14
|
+
renderAuthorizedKeys,
|
|
15
|
+
revokePrincipal,
|
|
16
|
+
validatePrincipalName,
|
|
17
|
+
} from '../../services/api-access';
|
|
18
|
+
import { celiloIntro } from '../prompts';
|
|
19
|
+
import type { CommandResult } from '../types';
|
|
20
|
+
|
|
21
|
+
function errMsg(error: unknown): string {
|
|
22
|
+
return error instanceof Error ? error.message : String(error);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `celilo api grant <principal> --key <pubkey|path> --can <grant[,grant...]>` */
|
|
26
|
+
export async function handleApiGrant(
|
|
27
|
+
args: string[],
|
|
28
|
+
flags: Record<string, boolean | string> = {},
|
|
29
|
+
): Promise<CommandResult> {
|
|
30
|
+
try {
|
|
31
|
+
const name = args[0];
|
|
32
|
+
if (!name) {
|
|
33
|
+
return {
|
|
34
|
+
success: false,
|
|
35
|
+
error: 'Usage: celilo api grant <principal> --key <pubkey|path> --can <grant[,grant...]>',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const keyArg = typeof flags.key === 'string' ? flags.key : '';
|
|
40
|
+
if (!keyArg) {
|
|
41
|
+
return {
|
|
42
|
+
success: false,
|
|
43
|
+
error: '--key <public-key-or-path> is required (e.g. ~/.ssh/id_ed25519.pub)',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const canArg = typeof flags.can === 'string' ? flags.can : '';
|
|
48
|
+
if (!canArg) {
|
|
49
|
+
return {
|
|
50
|
+
success: false,
|
|
51
|
+
error: '--can <grant[,grant...]> is required (e.g. --can module:deploy,service:*)',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const publicKey = existsSync(keyArg) ? readFileSync(keyArg, 'utf8').trim() : keyArg.trim();
|
|
56
|
+
const grants = canArg
|
|
57
|
+
.split(',')
|
|
58
|
+
.map((g) => g.trim())
|
|
59
|
+
.filter(Boolean);
|
|
60
|
+
|
|
61
|
+
const { principal, created } = await grantPrincipal({ name, publicKey, grants });
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
success: true,
|
|
65
|
+
message: `${created ? 'Granted' : 'Updated'} API access for "${principal.name}" → ${grants.join(', ')}\n\nInstall the forced-command line on celilo-mgr with:\n celilo api authorized-keys >> ~celilo-api/.ssh/authorized_keys`,
|
|
66
|
+
};
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return { success: false, error: `Failed to grant API access: ${errMsg(error)}` };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `celilo api list` */
|
|
73
|
+
export async function handleApiList(): Promise<CommandResult> {
|
|
74
|
+
try {
|
|
75
|
+
celiloIntro('API Principals');
|
|
76
|
+
const principals = await listPrincipals();
|
|
77
|
+
|
|
78
|
+
if (principals.length === 0) {
|
|
79
|
+
console.log('No API principals.\n');
|
|
80
|
+
console.log('Grant access:');
|
|
81
|
+
console.log(' celilo api grant <name> --key ~/.ssh/id_ed25519.pub --can module:deploy');
|
|
82
|
+
return { success: true, message: 'No API principals found' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.log('');
|
|
86
|
+
for (const p of principals) {
|
|
87
|
+
const parts = p.publicKey.split(/\s+/);
|
|
88
|
+
const keyType = parts[0] ?? '';
|
|
89
|
+
const comment = parts.length > 2 ? parts.slice(2).join(' ') : '';
|
|
90
|
+
console.log(`${p.name}`);
|
|
91
|
+
console.log(` Grants: ${p.grants.join(', ') || '(none)'}`);
|
|
92
|
+
console.log(` Key: ${keyType}${comment ? ` (${comment})` : ''}`);
|
|
93
|
+
console.log('');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.log(`Total: ${principals.length} principal${principals.length === 1 ? '' : 's'}\n`);
|
|
97
|
+
return { success: true, message: `Found ${principals.length} principal(s)` };
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return { success: false, error: `Failed to list API principals: ${errMsg(error)}` };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `celilo api revoke <principal>` */
|
|
104
|
+
export async function handleApiRevoke(args: string[]): Promise<CommandResult> {
|
|
105
|
+
try {
|
|
106
|
+
const name = args[0];
|
|
107
|
+
if (!name) {
|
|
108
|
+
return { success: false, error: 'Usage: celilo api revoke <principal>' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const removed = await revokePrincipal(name);
|
|
112
|
+
if (!removed) {
|
|
113
|
+
return { success: false, error: `No API principal named "${name}".` };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
success: true,
|
|
118
|
+
message: `Revoked API access for "${name}".\n\nRe-render celilo-mgr's authorized_keys to drop the line:\n celilo api authorized-keys > ~celilo-api/.ssh/authorized_keys`,
|
|
119
|
+
};
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return { success: false, error: `Failed to revoke API access: ${errMsg(error)}` };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `celilo api authorized-keys` — print the forced-command file for the API account. */
|
|
126
|
+
export async function handleApiAuthorizedKeys(): Promise<CommandResult> {
|
|
127
|
+
try {
|
|
128
|
+
const content = await renderAuthorizedKeys();
|
|
129
|
+
return { success: true, message: content, rawOutput: true };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return { success: false, error: `Failed to render authorized_keys: ${errMsg(error)}` };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `celilo api key new <name>` — generate a client-side keypair for API access. */
|
|
136
|
+
export async function handleApiKeyNew(args: string[]): Promise<CommandResult> {
|
|
137
|
+
try {
|
|
138
|
+
const name = args[0];
|
|
139
|
+
if (!name) {
|
|
140
|
+
return { success: false, error: 'Usage: celilo api key new <name>' };
|
|
141
|
+
}
|
|
142
|
+
validatePrincipalName(name);
|
|
143
|
+
|
|
144
|
+
// Prefer $HOME (operator shell always sets it, and it's test-controllable);
|
|
145
|
+
// homedir() is the fallback for the rare unset case.
|
|
146
|
+
const keyPath = join(process.env.HOME || homedir(), '.ssh', `celilo-api-${name}`);
|
|
147
|
+
if (existsSync(keyPath) || existsSync(`${keyPath}.pub`)) {
|
|
148
|
+
return {
|
|
149
|
+
success: false,
|
|
150
|
+
error: `A key already exists at ${keyPath}. Remove it or choose another name.`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
mkdirSync(dirname(keyPath), { recursive: true, mode: 0o700 });
|
|
155
|
+
|
|
156
|
+
const gen = Bun.spawnSync([
|
|
157
|
+
'ssh-keygen',
|
|
158
|
+
'-t',
|
|
159
|
+
'ed25519',
|
|
160
|
+
'-N',
|
|
161
|
+
'',
|
|
162
|
+
'-C',
|
|
163
|
+
`celilo-api-${name}`,
|
|
164
|
+
'-f',
|
|
165
|
+
keyPath,
|
|
166
|
+
]);
|
|
167
|
+
if (gen.exitCode !== 0) {
|
|
168
|
+
return {
|
|
169
|
+
success: false,
|
|
170
|
+
error: `ssh-keygen failed: ${gen.stderr.toString().trim() || `exit ${gen.exitCode}`}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const pubkey = readFileSync(`${keyPath}.pub`, 'utf8').trim();
|
|
175
|
+
return {
|
|
176
|
+
success: true,
|
|
177
|
+
message: [
|
|
178
|
+
`Generated API keypair for "${name}":`,
|
|
179
|
+
` private: ${keyPath} (keep secret — never share)`,
|
|
180
|
+
` public: ${keyPath}.pub`,
|
|
181
|
+
'',
|
|
182
|
+
`Public key: ${pubkey}`,
|
|
183
|
+
'',
|
|
184
|
+
'Enroll the public key on celilo-mgr:',
|
|
185
|
+
` celilo api grant ${name} --key ${keyPath}.pub --can module:deploy`,
|
|
186
|
+
'',
|
|
187
|
+
'Then run commands remotely (configure a Host alias in ~/.ssh/config as needed):',
|
|
188
|
+
' celilo --remote celilo-api@celilo-mgr <command>',
|
|
189
|
+
].join('\n'),
|
|
190
|
+
};
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return { success: false, error: `Failed to generate API key: ${errMsg(error)}` };
|
|
193
|
+
}
|
|
194
|
+
}
|
package/src/cli/completion.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { eq } from 'drizzle-orm';
|
|
|
7
7
|
import { getDb } from '../db/client';
|
|
8
8
|
import { capabilities, modules } from '../db/schema';
|
|
9
9
|
import type { ModuleManifest } from '../manifest/schema';
|
|
10
|
+
import { listPrincipals } from '../services/api-access';
|
|
10
11
|
import { listBackups } from '../services/backup-metadata';
|
|
11
12
|
import { listBackupStorages } from '../services/backup-storage';
|
|
12
13
|
import { listContainerServices } from '../services/container-service';
|
|
@@ -28,6 +29,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
28
29
|
// currentIndex === 0 means we're completing the first word (the command)
|
|
29
30
|
if (currentIndex === 0) {
|
|
30
31
|
const commands = [
|
|
32
|
+
'api',
|
|
31
33
|
'audit',
|
|
32
34
|
'backup',
|
|
33
35
|
'capability',
|
|
@@ -332,6 +334,26 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
332
334
|
return filterSuggestions(configKeys, args[4] || '');
|
|
333
335
|
}
|
|
334
336
|
|
|
337
|
+
// API subcommands
|
|
338
|
+
if (command === 'api' && currentIndex === 1) {
|
|
339
|
+
const subcommands = ['grant', 'list', 'revoke', 'authorized-keys', 'key'];
|
|
340
|
+
return filterSuggestions(subcommands, args[1] || '');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// API revoke - complete with principal names
|
|
344
|
+
if (command === 'api' && args[1] === 'revoke' && currentIndex === 2) {
|
|
345
|
+
const principals = await listPrincipals();
|
|
346
|
+
return filterSuggestions(
|
|
347
|
+
principals.map((p) => p.name),
|
|
348
|
+
args[2] || '',
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// API key subcommands
|
|
353
|
+
if (command === 'api' && args[1] === 'key' && currentIndex === 2) {
|
|
354
|
+
return filterSuggestions(['new'], args[2] || '');
|
|
355
|
+
}
|
|
356
|
+
|
|
335
357
|
// Machine subcommands
|
|
336
358
|
if (command === 'machine' && currentIndex === 1) {
|
|
337
359
|
const subcommands = ['add', 'list', 'status', 'remove', 'earmark', 'detect'];
|
package/src/cli/index.ts
CHANGED
|
@@ -5,8 +5,16 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import * as p from '@clack/prompts';
|
|
8
|
+
import { resolveRemote, runRemoteClient } from '../api/remote-client';
|
|
8
9
|
import { CLIServerRequestSchema, parseJsonWithValidation } from '../validation/schemas';
|
|
9
10
|
import { COMMANDS, type CommandDef } from './command-registry';
|
|
11
|
+
import {
|
|
12
|
+
handleApiAuthorizedKeys,
|
|
13
|
+
handleApiGrant,
|
|
14
|
+
handleApiKeyNew,
|
|
15
|
+
handleApiList,
|
|
16
|
+
handleApiRevoke,
|
|
17
|
+
} from './commands/api';
|
|
10
18
|
import { handleCapabilityInfo } from './commands/capability-info';
|
|
11
19
|
import { handleCapabilityList } from './commands/capability-list';
|
|
12
20
|
import { handleCompletion } from './commands/completion';
|
|
@@ -184,10 +192,14 @@ Commands:
|
|
|
184
192
|
proxmox Proxmox cluster introspection (proxmox node list)
|
|
185
193
|
publish Publish workspace packages to npm and modules to celilo.computer
|
|
186
194
|
subscribers Manage build-bus subscribers (cross-machine publish-event delivery)
|
|
195
|
+
api Manage remote-API access (principals, grants, authorized_keys)
|
|
187
196
|
completion Generate shell completion scripts (bash/zsh)
|
|
188
197
|
|
|
189
198
|
help, --help, -h Show this help message
|
|
190
199
|
|
|
200
|
+
Run any command on a remote celilo-mgr over SSH:
|
|
201
|
+
celilo --remote <ssh-dest> <command> (or set CELILO_REMOTE=<ssh-dest>)
|
|
202
|
+
|
|
191
203
|
For command-specific help:
|
|
192
204
|
celilo package --help
|
|
193
205
|
celilo module --help
|
|
@@ -1015,6 +1027,24 @@ Using Vault Password:
|
|
|
1015
1027
|
export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
1016
1028
|
const parsed = parseArguments(argv);
|
|
1017
1029
|
|
|
1030
|
+
// Remote API server: the sshd forced-command entry point
|
|
1031
|
+
// (`celilo api-serve --principal=<id>`). Not an operator command — kept out
|
|
1032
|
+
// of the registry/completion on purpose. Runs a persistent NDJSON protocol
|
|
1033
|
+
// loop until stdin closes, then exits the process; never returns here.
|
|
1034
|
+
if (parsed.command === 'api-serve') {
|
|
1035
|
+
const principal = typeof parsed.flags.principal === 'string' ? parsed.flags.principal : '';
|
|
1036
|
+
if (!principal) {
|
|
1037
|
+
return {
|
|
1038
|
+
success: false,
|
|
1039
|
+
error:
|
|
1040
|
+
'api-serve requires --principal <name> (normally supplied by the forced command in authorized_keys)',
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
const { apiServeMode } = await import('../api/serve');
|
|
1044
|
+
await apiServeMode(principal);
|
|
1045
|
+
return { success: true, message: '' };
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1018
1048
|
// Handle --get-completions for shell completion (must be before other processing)
|
|
1019
1049
|
if (parsed.flags['get-completions']) {
|
|
1020
1050
|
try {
|
|
@@ -1673,6 +1703,64 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1673
1703
|
return handleRestore(restoreArgs, parsed.flags);
|
|
1674
1704
|
}
|
|
1675
1705
|
|
|
1706
|
+
if (parsed.command === 'api') {
|
|
1707
|
+
if (parsed.flags.help || parsed.flags.h) {
|
|
1708
|
+
return {
|
|
1709
|
+
success: true,
|
|
1710
|
+
message: [
|
|
1711
|
+
'celilo api — manage remote-API access',
|
|
1712
|
+
'',
|
|
1713
|
+
'Usage:',
|
|
1714
|
+
' celilo api grant <principal> --key <pubkey|path> --can <grant[,grant...]>',
|
|
1715
|
+
' celilo api list',
|
|
1716
|
+
' celilo api revoke <principal>',
|
|
1717
|
+
' celilo api authorized-keys',
|
|
1718
|
+
' celilo api key new <name>',
|
|
1719
|
+
'',
|
|
1720
|
+
'Grants are command:subcommand (module:deploy), command:* (service:*), or * (all).',
|
|
1721
|
+
].join('\n'),
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
if (!parsed.subcommand) {
|
|
1726
|
+
return {
|
|
1727
|
+
success: false,
|
|
1728
|
+
error: 'API subcommand required\n\nRun "celilo api --help" for usage',
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
const apiFlagError = checkFlags('api', parsed.subcommand, parsed.flags, parsed.args);
|
|
1733
|
+
if (apiFlagError) return apiFlagError;
|
|
1734
|
+
|
|
1735
|
+
if (parsed.subcommand === 'grant') {
|
|
1736
|
+
return handleApiGrant(parsed.args, parsed.flags);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
if (parsed.subcommand === 'list') {
|
|
1740
|
+
return handleApiList();
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
if (parsed.subcommand === 'revoke') {
|
|
1744
|
+
return handleApiRevoke(parsed.args);
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
if (parsed.subcommand === 'authorized-keys') {
|
|
1748
|
+
return handleApiAuthorizedKeys();
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
if (parsed.subcommand === 'key') {
|
|
1752
|
+
if (parsed.args[0] === 'new') {
|
|
1753
|
+
return handleApiKeyNew(parsed.args.slice(1));
|
|
1754
|
+
}
|
|
1755
|
+
return { success: false, error: 'Usage: celilo api key new <name>' };
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
return {
|
|
1759
|
+
success: false,
|
|
1760
|
+
error: `Unknown api subcommand: ${parsed.subcommand}\n\nRun "celilo api --help" for usage`,
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1676
1764
|
if (parsed.command === 'machine') {
|
|
1677
1765
|
// Handle machine --help
|
|
1678
1766
|
if (parsed.flags.help || parsed.flags.h) {
|
|
@@ -2084,6 +2172,13 @@ export async function main(): Promise<void> {
|
|
|
2084
2172
|
return;
|
|
2085
2173
|
}
|
|
2086
2174
|
|
|
2175
|
+
// Remote execution: `celilo --remote <dest> <cmd>` or CELILO_REMOTE=<dest>.
|
|
2176
|
+
// SSH to the remote celilo-mgr and drive its api-serve over the wire.
|
|
2177
|
+
const remote = resolveRemote(process.argv);
|
|
2178
|
+
if (remote) {
|
|
2179
|
+
process.exit(await runRemoteClient(remote.dest, remote.commandArgv));
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2087
2182
|
// Normal single-command execution
|
|
2088
2183
|
try {
|
|
2089
2184
|
const result = await runCli(process.argv);
|
package/src/cli/parser.test.ts
CHANGED
|
@@ -107,6 +107,19 @@ describe('CLI Parser', () => {
|
|
|
107
107
|
expect(result.flags).toEqual({ json: true, verbose: true });
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
+
test('should parse --flag=value inline form', () => {
|
|
111
|
+
const result = parseArguments([
|
|
112
|
+
'node',
|
|
113
|
+
'celilo',
|
|
114
|
+
'api-serve',
|
|
115
|
+
'--principal=alice',
|
|
116
|
+
'--empty=',
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
expect(result.command).toBe('api-serve');
|
|
120
|
+
expect(result.flags).toEqual({ principal: 'alice', empty: '' });
|
|
121
|
+
});
|
|
122
|
+
|
|
110
123
|
test('should not consume key=value args as flag values', () => {
|
|
111
124
|
const result = parseArguments([
|
|
112
125
|
'node',
|
package/src/cli/parser.ts
CHANGED
|
@@ -82,8 +82,17 @@ export function parseArguments(argv: string[]): ParsedCommand {
|
|
|
82
82
|
if (!arg) continue;
|
|
83
83
|
|
|
84
84
|
if (arg.startsWith('--')) {
|
|
85
|
-
|
|
86
|
-
const
|
|
85
|
+
const body = arg.slice(2);
|
|
86
|
+
const eqIndex = body.indexOf('=');
|
|
87
|
+
|
|
88
|
+
// Inline-value form: --flag=value (everything after the first `=`).
|
|
89
|
+
if (eqIndex >= 0) {
|
|
90
|
+
flags[body.slice(0, eqIndex)] = body.slice(eqIndex + 1);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Space-separated form: --flag value
|
|
95
|
+
const flagName = body;
|
|
87
96
|
const nextArg = restArgs[i + 1];
|
|
88
97
|
|
|
89
98
|
// Check if next arg is a value (not another flag, not a key=value pair)
|
package/src/db/schema.ts
CHANGED
|
@@ -654,6 +654,34 @@ export const aspectApprovals = sqliteTable(
|
|
|
654
654
|
}),
|
|
655
655
|
);
|
|
656
656
|
|
|
657
|
+
/**
|
|
658
|
+
* Remote API principals (see v2/API_COMMUNICATION.md).
|
|
659
|
+
*
|
|
660
|
+
* Each row is one API identity: a name, one SSH public key, and the set of
|
|
661
|
+
* operations it may run. celilo renders these into the API account's
|
|
662
|
+
* `authorized_keys` (one forced-command line per row) and authz keys off the
|
|
663
|
+
* principal → `grants`.
|
|
664
|
+
*
|
|
665
|
+
* ponytail: one key per principal (a person wanting a second device makes a
|
|
666
|
+
* second principal, e.g. `alice-laptop`). If multiple keys per identity is ever
|
|
667
|
+
* needed, split into an `api_keys` child table — not worth it yet.
|
|
668
|
+
*/
|
|
669
|
+
export const apiPrincipals = sqliteTable('api_principals', {
|
|
670
|
+
id: text('id').primaryKey(), // UUID
|
|
671
|
+
/** Human-readable principal name, kebab-case (e.g. "alice", "ci-deployer"). */
|
|
672
|
+
name: text('name').notNull().unique(),
|
|
673
|
+
/** SSH public key line: `<type> <base64> [comment]`. */
|
|
674
|
+
publicKey: text('public_key').notNull(),
|
|
675
|
+
/**
|
|
676
|
+
* Operations this principal may run, as `command:subcommand` grants
|
|
677
|
+
* (`module:deploy`), `command:*` wildcards (`service:*`), or `*` (all).
|
|
678
|
+
* Deny-by-default: an operation not matched by any grant is refused.
|
|
679
|
+
*/
|
|
680
|
+
grants: text('grants', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
|
|
681
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
682
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
683
|
+
});
|
|
684
|
+
|
|
657
685
|
/**
|
|
658
686
|
* Type exports for use in application code
|
|
659
687
|
*/
|
|
@@ -695,3 +723,5 @@ export type ModuleOperation = typeof moduleOperations.$inferSelect;
|
|
|
695
723
|
export type NewModuleOperation = typeof moduleOperations.$inferInsert;
|
|
696
724
|
export type AspectApproval = typeof aspectApprovals.$inferSelect;
|
|
697
725
|
export type NewAspectApproval = typeof aspectApprovals.$inferInsert;
|
|
726
|
+
export type ApiPrincipal = typeof apiPrincipals.$inferSelect;
|
|
727
|
+
export type NewApiPrincipal = typeof apiPrincipals.$inferInsert;
|