@stage-labs/metro 0.1.0-beta.197 → 0.1.0-beta.199
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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/files.ts +5 -3
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/job-env.ts +3 -9
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/schedules.ts +12 -5
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/unit-files.ts +61 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/memory.ts +10 -0
- package/runtime/node_modules/@metro-labs/daemon/src/metro-user/api.ts +43 -0
- package/runtime/node_modules/@metro-labs/daemon/src/metro-user/helper-script.ts +126 -0
- package/runtime/node_modules/@metro-labs/daemon/src/metro-user/move.ts +155 -0
- package/runtime/node_modules/@metro-labs/daemon/src/metro-user/privilege.ts +48 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/vault/firewall.ts +9 -0
- package/runtime/node_modules/@metro-labs/daemon/src/vault/trust.ts +10 -0
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.199",
|
|
4
4
|
"description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.199",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
3
|
import { join } from 'node:path';
|
|
5
4
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
6
5
|
import { asUser, type AgentUser } from './user.js';
|
|
@@ -113,5 +112,8 @@ function readLocal(root: string, path: string): FilesAnswer {
|
|
|
113
112
|
return folderAnswer(root, path, entries);
|
|
114
113
|
}
|
|
115
114
|
|
|
116
|
-
export
|
|
117
|
-
user
|
|
115
|
+
export function readAgentPath(user: AgentUser | null, path: string, localRoot?: string): FilesAnswer {
|
|
116
|
+
if (user !== null) return readAsAgent(user, path);
|
|
117
|
+
if (localRoot === undefined) throw new ApiError('Claude Code does not run as its own user on this machine, so there is no agent folder to show', 409);
|
|
118
|
+
return readLocal(localRoot, path);
|
|
119
|
+
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
1
|
import { join } from 'node:path';
|
|
3
2
|
import { log } from '@metro-labs/core/log';
|
|
4
3
|
import { writeHomeText } from './home-fs.js';
|
|
5
4
|
import { listSchedules, realRunner, showProps, type Runner } from './schedules.js';
|
|
6
5
|
import type { AgentUser } from './user.js';
|
|
6
|
+
import { removeDropIn, writeDropIn } from './unit-files.js';
|
|
7
7
|
|
|
8
|
-
const UNIT_DIR = '/etc/systemd/system';
|
|
9
8
|
const DROP_IN = '11-metro-vault.conf';
|
|
10
9
|
const BEGIN = '# metro vault: begin';
|
|
11
10
|
const END = '# metro vault: end';
|
|
@@ -35,15 +34,10 @@ function setTimerEnv(user: AgentUser, on: boolean, runner: Runner): void {
|
|
|
35
34
|
if (job.kind !== 'timer' || job.runsAs !== user.name) continue;
|
|
36
35
|
const unit = job.id.slice('timer:'.length);
|
|
37
36
|
const service = showProps(runner, unit, ['Unit']).Unit ?? unit.replace(/\.timer$/, '.service');
|
|
38
|
-
const file = join(UNIT_DIR, `${service}.d`, DROP_IN);
|
|
39
37
|
if (on) {
|
|
40
|
-
|
|
41
|
-
writeFileSync(file, `[Service]\nEnvironmentFile=-${jobEnvFile(user)}\n`, { mode: 0o644 });
|
|
38
|
+
writeDropIn(service, DROP_IN, `[Service]\nEnvironmentFile=-${jobEnvFile(user)}\n`);
|
|
42
39
|
changed = true;
|
|
43
|
-
} else if (
|
|
44
|
-
rmSync(file, { force: true });
|
|
45
|
-
changed = true;
|
|
46
|
-
}
|
|
40
|
+
} else if (removeDropIn(service, DROP_IN)) changed = true;
|
|
47
41
|
}
|
|
48
42
|
if (changed) runner.run('systemctl', ['daemon-reload']);
|
|
49
43
|
}
|
|
@@ -6,6 +6,7 @@ import { isRecord } from '@metro-labs/core/is-record';
|
|
|
6
6
|
import { copyIntoHome } from './home-fs.js';
|
|
7
7
|
import { AGENT_NAME, agentMarketplaceDir, agentUser, agentUserExpected, asUser, claudeBin, forgetAgentUser, type AgentUser } from './user.js';
|
|
8
8
|
import { watchAgentView } from './view.js';
|
|
9
|
+
import { mustHelper, runningAsMetro } from '../metro-user/privilege.js';
|
|
9
10
|
|
|
10
11
|
const INSTALL_MS = 10 * 60_000;
|
|
11
12
|
const INSTALLER = 'curl -fsSL https://claude.ai/install.sh | bash';
|
|
@@ -19,6 +20,11 @@ function run(file: string, args: string[]): void {
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
function ensureUser(): AgentUser | null {
|
|
23
|
+
if (runningAsMetro()) {
|
|
24
|
+
mustHelper(['ensure-agent']);
|
|
25
|
+
forgetAgentUser();
|
|
26
|
+
return agentUser();
|
|
27
|
+
}
|
|
22
28
|
if (agentUser() === null) {
|
|
23
29
|
run('useradd', ['--create-home', '--shell', '/bin/bash', AGENT_NAME]);
|
|
24
30
|
log.info({ user: AGENT_NAME }, 'agent-user: created the user Claude Code runs as');
|
|
@@ -6,7 +6,8 @@ import { join } from 'node:path';
|
|
|
6
6
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
7
7
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
8
8
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
9
|
-
import type
|
|
9
|
+
import { asUser, type AgentUser } from './user.js';
|
|
10
|
+
import { metroRun, writeDropIn } from './unit-files.js';
|
|
10
11
|
|
|
11
12
|
const UNIT_DIR = '/etc/systemd/system';
|
|
12
13
|
const defaultBackups = (): string => join(homedir(), '.metro', 'agents');
|
|
@@ -36,6 +37,8 @@ export interface Runner {
|
|
|
36
37
|
|
|
37
38
|
export const realRunner: Runner = {
|
|
38
39
|
run: (file, args, input) => {
|
|
40
|
+
const viaHelper = metroRun(file, args, input);
|
|
41
|
+
if (viaHelper !== null) return viaHelper;
|
|
39
42
|
const done = spawnSync(file, args, { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'ignore'], timeout: 20_000 });
|
|
40
43
|
return { status: done.error === undefined ? done.status : null, stdout: done.stdout ?? '' };
|
|
41
44
|
},
|
|
@@ -173,17 +176,21 @@ export function dropInText(user: AgentUser, argv: string[], environment: string)
|
|
|
173
176
|
].join('\n');
|
|
174
177
|
}
|
|
175
178
|
|
|
179
|
+
function agentSees(user: AgentUser, path: string): boolean {
|
|
180
|
+
if (existsSync(path)) return true;
|
|
181
|
+
const [file, args] = asUser(user, 'test', ['-e', path]);
|
|
182
|
+
return spawnSync(file, args, { stdio: 'ignore' }).status === 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
176
185
|
function convertTimer(job: ScheduledJob, user: AgentUser, runner: Runner): void {
|
|
177
186
|
const unit = job.id.slice('timer:'.length);
|
|
178
187
|
const service = showProps(runner, unit, ['Unit']).Unit ?? unit.replace(/\.timer$/, '.service');
|
|
179
188
|
const svc = showProps(runner, service, ['ExecStart', 'Environment']);
|
|
180
189
|
const argv = execArgv(svc.ExecStart ?? '');
|
|
181
190
|
if (argv.length === 0) throw new ApiError('that timer runs no command metro can read', 400);
|
|
182
|
-
const missing = argv.map((a) => rehome(a, user.home)).find((a) => a.startsWith(`${user.home}/`) && !
|
|
191
|
+
const missing = argv.map((a) => rehome(a, user.home)).find((a) => a.startsWith(`${user.home}/`) && !agentSees(user, a));
|
|
183
192
|
if (missing !== undefined) throw new ApiError(`${missing} does not exist; move it to the agent first`, 409);
|
|
184
|
-
|
|
185
|
-
mkdirSync(dir, { recursive: true });
|
|
186
|
-
writeFileSync(join(dir, DROP_IN), dropInText(user, argv, svc.Environment ?? ''), { mode: 0o644 });
|
|
193
|
+
writeDropIn(service, DROP_IN, dropInText(user, argv, svc.Environment ?? ''));
|
|
187
194
|
runner.run('systemctl', ['daemon-reload']);
|
|
188
195
|
log.info({ unit, service, user: user.name }, 'schedules: a timer now runs as the agent user');
|
|
189
196
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { mustHelper, rootHelper, runningAsMetro } from '../metro-user/privilege.js';
|
|
6
|
+
import { agentUser, asUser } from './user.js';
|
|
7
|
+
|
|
8
|
+
const UNIT_DIR = '/etc/systemd/system';
|
|
9
|
+
|
|
10
|
+
export function writeDropIn(service: string, name: string, text: string): void {
|
|
11
|
+
if (runningAsMetro()) {
|
|
12
|
+
mustHelper(['dropin-write', service, name], text);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
mkdirSync(join(UNIT_DIR, `${service}.d`), { recursive: true });
|
|
16
|
+
writeFileSync(join(UNIT_DIR, `${service}.d`, name), text, { mode: 0o644 });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function removeDropIn(service: string, name: string): boolean {
|
|
20
|
+
const file = join(UNIT_DIR, `${service}.d`, name);
|
|
21
|
+
if (!existsSync(file)) return false;
|
|
22
|
+
if (runningAsMetro()) mustHelper(['dropin-remove', service, name]);
|
|
23
|
+
else rmSync(file, { force: true });
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const lineHash = (line: string): string => createHash('sha256').update(line.trim()).digest('hex').slice(0, 16);
|
|
28
|
+
|
|
29
|
+
function asAgentRun(args: string[], input?: string): { status: number | null; stdout: string } {
|
|
30
|
+
const [file, argv] = asUser(agentUser(), 'crontab', args);
|
|
31
|
+
const run = spawnSync(file, argv, { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'ignore'], timeout: 20_000 });
|
|
32
|
+
return { status: run.error === undefined ? run.status : null, stdout: run.stdout ?? '' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function rootCrontabWrite(input: string): { status: number | null; stdout: string } {
|
|
36
|
+
const wanted = new Set(input.split('\n').filter((l) => l.trim() !== '').map(lineHash));
|
|
37
|
+
const current = rootHelper(['root-crontab']).stdout.split('\n').filter((l) => l.trim() !== '');
|
|
38
|
+
for (const line of current) if (!wanted.has(lineHash(line))) mustHelper(['root-crontab-drop', lineHash(line)]);
|
|
39
|
+
return { status: 0, stdout: '' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function crontab(args: string[], input?: string): { status: number | null; stdout: string } {
|
|
43
|
+
const at = args.indexOf('-u');
|
|
44
|
+
const who = at >= 0 ? args[at + 1] : undefined;
|
|
45
|
+
const rest = args.filter((_, i) => i !== at && i !== at + 1);
|
|
46
|
+
if (who === 'root') return rest[0] === '-l' ? { status: 0, stdout: rootHelper(['root-crontab']).stdout } : rootCrontabWrite(input ?? '');
|
|
47
|
+
return asAgentRun(rest, input);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function systemctl(args: string[]): { status: number | null; stdout: string } | null {
|
|
51
|
+
if (args[0] === 'daemon-reload') return { status: rootHelper(['daemon-reload']).status, stdout: '' };
|
|
52
|
+
if (args[0] === 'start') return { status: rootHelper(['start-job', args[args.length - 1] ?? '']).status, stdout: '' };
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function metroRun(file: string, args: string[], input?: string): { status: number | null; stdout: string } | null {
|
|
57
|
+
if (!runningAsMetro()) return null;
|
|
58
|
+
if (file === 'crontab') return crontab(args, input);
|
|
59
|
+
if (file === 'systemctl') return systemctl(args);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { runningAsMetro } from '../metro-user/privilege.js';
|
|
3
4
|
|
|
4
5
|
export const AGENT_NAME = 'agent';
|
|
5
6
|
const AGENT_PATH = ['.local/bin', '/usr/local/sbin', '/usr/local/bin', '/usr/sbin', '/usr/bin', '/sbin', '/bin'];
|
|
@@ -28,7 +29,7 @@ export function lookupUser(name: string): AgentUser | null {
|
|
|
28
29
|
|
|
29
30
|
const realHost: UserHost = { platform: process.platform, uid: process.getuid?.(), lookup: lookupUser };
|
|
30
31
|
|
|
31
|
-
export const agentUserExpected = (host: UserHost = realHost): boolean => host.platform === 'linux' && host.uid === 0;
|
|
32
|
+
export const agentUserExpected = (host: UserHost = realHost): boolean => host.platform === 'linux' && (host.uid === 0 || runningAsMetro());
|
|
32
33
|
|
|
33
34
|
let cached: AgentUser | null = null;
|
|
34
35
|
|
|
@@ -67,6 +68,7 @@ export function agentEnv(user: AgentUser, extra: Record<string, string> = {}): R
|
|
|
67
68
|
export function asUser(user: AgentUser | null, file: string, args: readonly string[], extra: Record<string, string> = {}): [string, string[]] {
|
|
68
69
|
if (user === null) return [file, [...args]];
|
|
69
70
|
const env = Object.entries(agentEnv(user, extra)).map(([k, v]) => `${k}=${v}`);
|
|
71
|
+
if (runningAsMetro()) return ['sudo', ['-n', '-u', user.name, '--', 'env', '-i', ...env, file, ...args]];
|
|
70
72
|
return ['setpriv', [`--reuid=${String(user.uid)}`, `--regid=${String(user.gid)}`, '--init-groups', 'env', '-i', ...env, file, ...args]];
|
|
71
73
|
}
|
|
72
74
|
|
|
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
import { totalmem } from 'node:os';
|
|
4
4
|
import { dirname } from 'node:path';
|
|
5
5
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
6
|
+
import { helperArgv, runningAsMetro } from '../metro-user/privilege.js';
|
|
6
7
|
|
|
7
8
|
const UNIT = '/etc/systemd/system/metro.service';
|
|
8
9
|
const DROP_IN = '/etc/systemd/system/metro.service.d/10-metro-memory.conf';
|
|
@@ -32,7 +33,16 @@ export function sessionMemoryLimit(total: number): number {
|
|
|
32
33
|
|
|
33
34
|
const usesScopes = (host: ScopeHost): boolean => host.platform === 'linux' && host.uid === 0 && host.systemd;
|
|
34
35
|
|
|
36
|
+
function metroScope(command: [string, string[]], total: number): [string, string[]] {
|
|
37
|
+
const [file, args] = command;
|
|
38
|
+
const asAgent = file === 'sudo' && args[0] === '-n' && args[1] === '-u' && args[3] === '--';
|
|
39
|
+
if (!asAgent) return command;
|
|
40
|
+
const limit = sessionMemoryLimit(total);
|
|
41
|
+
return helperArgv(['as-agent-scope', String(limit), String(Math.floor(limit * 0.9)), ...args.slice(4)]);
|
|
42
|
+
}
|
|
43
|
+
|
|
35
44
|
export function inSessionScope(command: [string, string[]], host: ScopeHost = realHost(), now = Date.now()): [string, string[]] {
|
|
45
|
+
if (host.systemd && runningAsMetro()) return metroScope(command, host.total);
|
|
36
46
|
if (!usesScopes(host)) return command;
|
|
37
47
|
const limit = sessionMemoryLimit(host.total);
|
|
38
48
|
const [file, args] = command;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { sessionRoute } from '@metro-labs/http/api-http';
|
|
4
|
+
import { MOVE_LOG, moveState, startMove } from './move.js';
|
|
5
|
+
import { rootHelper, runningAsMetro, runningAsRoot } from './privilege.js';
|
|
6
|
+
import { HELPER_VERSION } from './helper-script.js';
|
|
7
|
+
|
|
8
|
+
const PATH = '/api/server/metro-user';
|
|
9
|
+
const LOG_TAIL = 40;
|
|
10
|
+
|
|
11
|
+
function logTail(): string {
|
|
12
|
+
try {
|
|
13
|
+
return readFileSync(MOVE_LOG, 'utf8').split('\n').slice(-LOG_TAIL).join('\n');
|
|
14
|
+
} catch {
|
|
15
|
+
return '';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function helperVersion(): number | null {
|
|
20
|
+
if (!runningAsMetro()) return null;
|
|
21
|
+
const v = Number(rootHelper(['version']).stdout.trim());
|
|
22
|
+
return Number.isInteger(v) ? v : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function view(): unknown {
|
|
26
|
+
const runningAs = runningAsRoot() ? 'root' : runningAsMetro() ? 'metro' : 'other';
|
|
27
|
+
const helper = helperVersion();
|
|
28
|
+
return {
|
|
29
|
+
runningAs,
|
|
30
|
+
canMove: runningAs === 'root',
|
|
31
|
+
move: moveState(),
|
|
32
|
+
log: logTail(),
|
|
33
|
+
helper,
|
|
34
|
+
helperCurrent: runningAs !== 'metro' || helper === HELPER_VERSION,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function handleMetroUserRequest(req: IncomingMessage, res: ServerResponse): boolean {
|
|
39
|
+
return sessionRoute(req, res, { methods: { [PATH]: ['GET', 'POST'] }, admin: true, label: 'metro-user-api' }, () => {
|
|
40
|
+
if (req.method === 'POST') startMove();
|
|
41
|
+
return Promise.resolve(view());
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export const HELPER_PATH = '/usr/local/lib/metro/root-helper';
|
|
2
|
+
export const HELPER_VERSION = 1;
|
|
3
|
+
export const SUDOERS_PATH = '/etc/sudoers.d/metro';
|
|
4
|
+
export const METRO_USER = 'metro';
|
|
5
|
+
export const METRO_HOME = '/var/lib/metro';
|
|
6
|
+
|
|
7
|
+
export const sudoersText = (agent: string): string =>
|
|
8
|
+
[
|
|
9
|
+
`${METRO_USER} ALL=(${agent}) NOPASSWD: ALL`,
|
|
10
|
+
`${METRO_USER} ALL=(root) NOPASSWD: ${HELPER_PATH} *`,
|
|
11
|
+
`Defaults:${METRO_USER} !requiretty`,
|
|
12
|
+
'',
|
|
13
|
+
].join('\n');
|
|
14
|
+
|
|
15
|
+
const lines = (...parts: string[]): string => parts.join('\n');
|
|
16
|
+
|
|
17
|
+
const CHECKS = lines(
|
|
18
|
+
'AGENT=agent',
|
|
19
|
+
'UNITS=/etc/systemd/system',
|
|
20
|
+
'die() { echo "root-helper: $*" >&2; exit 2; }',
|
|
21
|
+
'agent_uid() { id -u "$AGENT" 2>/dev/null || die "no agent user"; }',
|
|
22
|
+
'service_ok() {',
|
|
23
|
+
' printf %s "$1" | grep -Eq "^[A-Za-z0-9@_.-]+\\.service$" || die "bad service name"',
|
|
24
|
+
' case "$1" in metro*) die "not a metro unit";; esac',
|
|
25
|
+
' frag=$(systemctl show "$1" -p FragmentPath --value)',
|
|
26
|
+
' case "$frag" in "$UNITS"/*) ;; *) die "not a unit under $UNITS";; esac',
|
|
27
|
+
' systemctl show "$1" -p TriggeredBy --value | grep -q "\\.timer" || die "not started by a timer"',
|
|
28
|
+
'}',
|
|
29
|
+
'dropin_ok() {',
|
|
30
|
+
' case "$1" in 10-metro-agent.conf|11-metro-vault.conf) ;; *) die "bad drop-in name";; esac',
|
|
31
|
+
' home=$(getent passwd "$AGENT" | cut -d: -f6)',
|
|
32
|
+
' saw_user=0',
|
|
33
|
+
' while IFS= read -r line; do',
|
|
34
|
+
' case "$line" in',
|
|
35
|
+
' "[Service]"|"") ;;',
|
|
36
|
+
' "User=$AGENT") saw_user=1 ;;',
|
|
37
|
+
' Group=*) printf %s "${line#Group=}" | grep -Eq "^[0-9]+$" || die "bad group" ;;',
|
|
38
|
+
' "WorkingDirectory=$home"|"WorkingDirectory=$home"/*) ;;',
|
|
39
|
+
' Environment=*) ;;',
|
|
40
|
+
' "EnvironmentFile=-$home/.metro/vault.env") ;;',
|
|
41
|
+
' "ExecStart=") ;;',
|
|
42
|
+
' ExecStart=[+!@]*) die "privileged ExecStart refused" ;;',
|
|
43
|
+
' ExecStart=*) ;;',
|
|
44
|
+
' *) die "line not allowed: $line" ;;',
|
|
45
|
+
' esac',
|
|
46
|
+
' done < "$2"',
|
|
47
|
+
' if [ "$1" = 10-metro-agent.conf ] && [ "$saw_user" != 1 ]; then die "the drop-in must set User=$AGENT"; fi',
|
|
48
|
+
'}',
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const FIREWALL = lines(
|
|
52
|
+
'firewall_on() {',
|
|
53
|
+
' uid=$(agent_uid)',
|
|
54
|
+
' for t in iptables ip6tables; do',
|
|
55
|
+
' $t -w -N METRO_AGENT 2>/dev/null || true',
|
|
56
|
+
' $t -w -F METRO_AGENT',
|
|
57
|
+
' $t -w -A METRO_AGENT -o lo -j ACCEPT',
|
|
58
|
+
' $t -w -A METRO_AGENT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT',
|
|
59
|
+
' $t -w -A METRO_AGENT -p udp --dport 53 -j ACCEPT',
|
|
60
|
+
' $t -w -A METRO_AGENT -p tcp --dport 53 -j ACCEPT',
|
|
61
|
+
' $t -w -A METRO_AGENT -p tcp -j REJECT --reject-with tcp-reset',
|
|
62
|
+
' $t -w -A METRO_AGENT -j REJECT',
|
|
63
|
+
' $t -w -C OUTPUT -m owner --uid-owner "$uid" -j METRO_AGENT 2>/dev/null || $t -w -I OUTPUT -m owner --uid-owner "$uid" -j METRO_AGENT',
|
|
64
|
+
' done',
|
|
65
|
+
'}',
|
|
66
|
+
'firewall_off() {',
|
|
67
|
+
' uid=$(agent_uid)',
|
|
68
|
+
' for t in iptables ip6tables; do',
|
|
69
|
+
' while $t -w -C OUTPUT -m owner --uid-owner "$uid" -j METRO_AGENT 2>/dev/null; do $t -w -D OUTPUT -m owner --uid-owner "$uid" -j METRO_AGENT; done',
|
|
70
|
+
' $t -w -F METRO_AGENT 2>/dev/null || true',
|
|
71
|
+
' $t -w -X METRO_AGENT 2>/dev/null || true',
|
|
72
|
+
' done',
|
|
73
|
+
'}',
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const ACTIONS = lines(
|
|
77
|
+
'action="${1:-}"; [ "$#" -gt 0 ] && shift',
|
|
78
|
+
'case "$action" in',
|
|
79
|
+
` version) echo ${String(HELPER_VERSION)} ;;`,
|
|
80
|
+
' ensure-agent)',
|
|
81
|
+
' getent passwd "$AGENT" >/dev/null || useradd --create-home --shell /bin/bash "$AGENT"',
|
|
82
|
+
' chmod 700 "$(getent passwd "$AGENT" | cut -d: -f6)" ;;',
|
|
83
|
+
' as-agent-scope)',
|
|
84
|
+
' [ "$#" -ge 3 ] || die "usage: as-agent-scope <max> <high> <command...>"',
|
|
85
|
+
' printf %s "$1$2" | grep -Eq "^[0-9]+$" || die "bad memory limits"',
|
|
86
|
+
' max="$1"; high="$2"; shift 2',
|
|
87
|
+
' uid=$(agent_uid); gid=$(id -g "$AGENT")',
|
|
88
|
+
' exec systemd-run --scope --quiet --collect --unit="metro-claude-$(date +%s%N)" -p MemoryMax="$max" -p MemoryHigh="$high" -p OOMPolicy=continue -- setpriv --reuid="$uid" --regid="$gid" --init-groups -- "$@" ;;',
|
|
89
|
+
' dropin-write)',
|
|
90
|
+
' service_ok "$1"; tmp=$(mktemp); trap \'rm -f "$tmp"\' EXIT; cat > "$tmp"; dropin_ok "$2" "$tmp"',
|
|
91
|
+
' mkdir -p "$UNITS/$1.d"; install -m 644 "$tmp" "$UNITS/$1.d/$2" ;;',
|
|
92
|
+
' dropin-remove)',
|
|
93
|
+
' service_ok "$1"; case "$2" in 10-metro-agent.conf|11-metro-vault.conf) ;; *) die "bad drop-in name";; esac',
|
|
94
|
+
' rm -f "$UNITS/$1.d/$2" ;;',
|
|
95
|
+
' daemon-reload) systemctl daemon-reload ;;',
|
|
96
|
+
' start-job)',
|
|
97
|
+
' service_ok "$1"; [ "$(systemctl show "$1" -p User --value)" = "$AGENT" ] || die "that job does not run as $AGENT"',
|
|
98
|
+
' systemctl start --no-block "$1" ;;',
|
|
99
|
+
' root-crontab) crontab -l -u root 2>/dev/null || true ;;',
|
|
100
|
+
' root-crontab-drop)',
|
|
101
|
+
' printf %s "$1" | grep -Eq "^[0-9a-f]{16}$" || die "bad line id"',
|
|
102
|
+
` backups="${METRO_HOME}/.metro/agents"`,
|
|
103
|
+
' cur=$(mktemp); crontab -l -u root > "$cur" 2>/dev/null || true',
|
|
104
|
+
' kept=$(mktemp); found=0',
|
|
105
|
+
' while IFS= read -r line; do',
|
|
106
|
+
' id=$(printf %s "$line" | sed "s/^[[:space:]]*//;s/[[:space:]]*$//" | tr -d "\\n" | sha256sum | cut -c1-16)',
|
|
107
|
+
' if [ "$id" = "$1" ] && [ "$found" = 0 ]; then found=1; elif [ -n "$line" ]; then printf "%s\\n" "$line" >> "$kept"; fi',
|
|
108
|
+
' done < "$cur"',
|
|
109
|
+
' [ "$found" = 1 ] || die "that cron line is gone"',
|
|
110
|
+
' install -m 600 "$cur" "$backups/crontab-root.$(date +%Y-%m-%dT%H-%M-%S).bak"',
|
|
111
|
+
' crontab -u root "$kept"; rm -f "$cur" "$kept" ;;',
|
|
112
|
+
' firewall-on) firewall_on ;;',
|
|
113
|
+
' firewall-off) firewall_off ;;',
|
|
114
|
+
' trust-ca)',
|
|
115
|
+
` src="${METRO_HOME}/.metro/agents/vault/ca/ca.crt"`,
|
|
116
|
+
' head -n1 "$src" | grep -q "BEGIN CERTIFICATE" || die "not a certificate"',
|
|
117
|
+
' if grep -q "PRIVATE KEY" "$src"; then die "a key is not a certificate"; fi',
|
|
118
|
+
' install -m 644 "$src" /usr/local/share/ca-certificates/metro-vault.crt && update-ca-certificates >/dev/null ;;',
|
|
119
|
+
' untrust-ca) rm -f /usr/local/share/ca-certificates/metro-vault.crt; update-ca-certificates --fresh >/dev/null ;;',
|
|
120
|
+
' install-nss-tools) DEBIAN_FRONTEND=noninteractive apt-get install -y -q libnss3-tools >/dev/null ;;',
|
|
121
|
+
' *) die "unknown action: $action" ;;',
|
|
122
|
+
'esac',
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
export const helperScript = (): string =>
|
|
126
|
+
['#!/bin/sh', 'set -eu', 'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', CHECKS, FIREWALL, ACTIONS, ''].join('\n');
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
6
|
+
import { METRO_VERSION } from '@metro-labs/core/version';
|
|
7
|
+
import { AGENT_NAME } from '../agent-user/user.js';
|
|
8
|
+
import { HELPER_PATH, helperScript, METRO_HOME, METRO_USER, SUDOERS_PATH, sudoersText } from './helper-script.js';
|
|
9
|
+
import { runningAsRoot } from './privilege.js';
|
|
10
|
+
|
|
11
|
+
export const UNIT_FILE = '/etc/systemd/system/metro.service';
|
|
12
|
+
export const MOVE_DIR = '/var/lib/metro-move';
|
|
13
|
+
export const MOVE_LOG = '/var/log/metro-move.log';
|
|
14
|
+
const PREFIX = `${METRO_HOME}/.npm-global`;
|
|
15
|
+
const CLI = `${PREFIX}/lib/node_modules/@stage-labs/metro/dist/cli.js`;
|
|
16
|
+
|
|
17
|
+
const which = (bin: string): string | null => {
|
|
18
|
+
const run = spawnSync('sh', ['-c', `command -v ${bin}`], { encoding: 'utf8' });
|
|
19
|
+
const path = run.status === 0 ? run.stdout.trim() : '';
|
|
20
|
+
if (path === '') return null;
|
|
21
|
+
try {
|
|
22
|
+
return realpathSync(path);
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export interface UnitFacts {
|
|
29
|
+
serveArgs: string[];
|
|
30
|
+
env: string[];
|
|
31
|
+
port: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function unitFacts(unit: string): UnitFacts {
|
|
35
|
+
const exec = /^ExecStart=(.*)$/m.exec(unit)?.[1] ?? '';
|
|
36
|
+
const words = [...exec.matchAll(/"((?:[^"\\]|\\.)*)"|(\S+)/g)].map((m) => (m[1] ?? m[2] ?? '').replace(/\\(.)/g, '$1'));
|
|
37
|
+
const at = words.indexOf('serve');
|
|
38
|
+
if (at < 0) throw new ApiError('the metro service does not run `metro serve`; move it by hand', 409);
|
|
39
|
+
const env = [...unit.matchAll(/^Environment=(.*)$/gm)].map((m) => m[1] ?? '').filter((e) => /^"?METRO_/.test(e) && !/^"?METRO_(AGENTS_DIR|RUNTIME_STORE)=/.test(e));
|
|
40
|
+
const portArg = words[words.indexOf('--port') + 1];
|
|
41
|
+
const envPort = /METRO_WEBHOOK_PORT=(\d+)/.exec(env.join(' '))?.[1];
|
|
42
|
+
return { serveArgs: words.slice(at + 1), env, port: (words.includes('--port') ? portArg : envPort) ?? '8420' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ESCAPED_QUOTE = '\'\\\'\'';
|
|
46
|
+
const shellWord = (w: string): string => `'${w.replace(/'/g, ESCAPED_QUOTE)}'`;
|
|
47
|
+
const unitWord = (w: string): string => (/^[A-Za-z0-9_/.:=+@,-]+$/.test(w) ? w : `"${w.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`);
|
|
48
|
+
|
|
49
|
+
export function metroUnit(node: string, facts: UnitFacts): string {
|
|
50
|
+
return [
|
|
51
|
+
'[Unit]',
|
|
52
|
+
'Description=Metro daemon (metro serve)',
|
|
53
|
+
'After=network-online.target tailscaled.service',
|
|
54
|
+
'Wants=network-online.target',
|
|
55
|
+
'',
|
|
56
|
+
'[Service]',
|
|
57
|
+
`User=${METRO_USER}`,
|
|
58
|
+
`Group=${METRO_USER}`,
|
|
59
|
+
`ExecStart=${[node, CLI, 'serve', ...facts.serveArgs].map(unitWord).join(' ')}`,
|
|
60
|
+
'Restart=always',
|
|
61
|
+
'RestartSec=2',
|
|
62
|
+
'OOMPolicy=continue',
|
|
63
|
+
'WorkingDirectory=/',
|
|
64
|
+
`Environment=HOME=${METRO_HOME}`,
|
|
65
|
+
`Environment=PATH=${PREFIX}/bin:/usr/local/bin:/usr/bin:/bin`,
|
|
66
|
+
`Environment=NPM_CONFIG_PREFIX=${PREFIX}`,
|
|
67
|
+
...facts.env.map((e) => `Environment=${e}`),
|
|
68
|
+
'',
|
|
69
|
+
'[Install]',
|
|
70
|
+
'WantedBy=multi-user.target',
|
|
71
|
+
'',
|
|
72
|
+
].join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function moveScript(port: string, spec: string): string {
|
|
76
|
+
return [
|
|
77
|
+
'#!/bin/sh',
|
|
78
|
+
'set -u',
|
|
79
|
+
`exec >> ${MOVE_LOG} 2>&1`,
|
|
80
|
+
'echo "--- metro move $(date -Is)"',
|
|
81
|
+
`OLD=/root/.metro; NEW=${METRO_HOME}/.metro; DIR=${MOVE_DIR}`,
|
|
82
|
+
'fail() { echo "FAILED: $*"; echo failed > "$DIR/state"; exit 1; }',
|
|
83
|
+
'healthy() {',
|
|
84
|
+
' i=0; while [ $i -lt 60 ]; do',
|
|
85
|
+
` if curl -fsS -m 3 http://127.0.0.1:${port}/health >/dev/null 2>&1 && [ "$(ps -o user= -p "$(systemctl show metro -p MainPID --value)")" = "$1" ]; then return 0; fi`,
|
|
86
|
+
' i=$((i+1)); sleep 2; done; return 1; }',
|
|
87
|
+
'echo moving > "$DIR/state"',
|
|
88
|
+
`id ${METRO_USER} >/dev/null 2>&1 || useradd --system --create-home --home-dir ${METRO_HOME} --shell /usr/sbin/nologin ${METRO_USER} || fail "useradd"`,
|
|
89
|
+
`usermod -aG systemd-journal ${METRO_USER} || true`,
|
|
90
|
+
`mkdir -p ${METRO_HOME} && chown ${METRO_USER}:${METRO_USER} ${METRO_HOME} && chmod 700 ${METRO_HOME}`,
|
|
91
|
+
'B=$(readlink -f "$(command -v bun)"); case "$B" in /root/*) install -m 755 "$B" /usr/local/bin/bun || fail "bun";; esac',
|
|
92
|
+
`su -s /bin/sh ${METRO_USER} -c ${shellWord(`cd / && npm install --global --prefix ${PREFIX} ${spec}`)} || fail "npm install for ${METRO_USER}"`,
|
|
93
|
+
`tailscale set --operator=${METRO_USER} || fail "tailscale operator"`,
|
|
94
|
+
`cp ${UNIT_FILE} "$DIR/metro.service.root" || fail "unit backup"`,
|
|
95
|
+
'systemctl stop metro',
|
|
96
|
+
'if [ -e "$NEW" ]; then mv "$NEW" "$NEW.old-$(date +%s)"; fi',
|
|
97
|
+
'mv "$OLD" "$NEW" || { systemctl start metro; fail "move"; }',
|
|
98
|
+
'rm -f "$NEW/runtime/runtime.json"',
|
|
99
|
+
`chown -R ${METRO_USER}:${METRO_USER} ${METRO_HOME}`,
|
|
100
|
+
`install -m 644 "$DIR/metro.service.new" ${UNIT_FILE}`,
|
|
101
|
+
'systemctl daemon-reload && systemctl start metro',
|
|
102
|
+
`if healthy ${METRO_USER}; then echo done > "$DIR/state"; echo "metro runs as ${METRO_USER}"; exit 0; fi`,
|
|
103
|
+
'echo "not healthy as metro, putting everything back"',
|
|
104
|
+
'systemctl stop metro',
|
|
105
|
+
'mv "$NEW" "$OLD" && chown -R root:root "$OLD"',
|
|
106
|
+
`install -m 644 "$DIR/metro.service.root" ${UNIT_FILE}`,
|
|
107
|
+
'systemctl daemon-reload && systemctl start metro',
|
|
108
|
+
'healthy root && fail "Metro did not start as metro; it runs as root again" || fail "Metro did not start as metro, and the way back did not answer either"',
|
|
109
|
+
'',
|
|
110
|
+
].join('\n');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function preflight(): { node: string; unit: string } {
|
|
114
|
+
if (!runningAsRoot()) throw new ApiError('only a Metro running as root can move itself', 409);
|
|
115
|
+
if (!existsSync(UNIT_FILE)) throw new ApiError('this box does not run Metro as the systemd service `metro`', 409);
|
|
116
|
+
if (process.env.METRO_AGENTS_DIR !== undefined && process.env.METRO_AGENTS_DIR !== '') throw new ApiError('METRO_AGENTS_DIR is set; move this box by hand', 409);
|
|
117
|
+
if (homedir() !== '/root') throw new ApiError(`Metro keeps its files in ${homedir()}, not /root; move this box by hand`, 409);
|
|
118
|
+
const node = which('node');
|
|
119
|
+
if (node === null || node.startsWith('/root/')) throw new ApiError('Node must be installed for every user (not under /root) before the move', 409);
|
|
120
|
+
for (const bin of ['sudo', 'visudo', 'tailscale', 'curl', 'npm', 'bun'])
|
|
121
|
+
if (which(bin) === null) throw new ApiError(`${bin} is missing on this box`, 409);
|
|
122
|
+
return { node, unit: readFileSync(UNIT_FILE, 'utf8') };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function installHelper(): void {
|
|
126
|
+
mkdirSync('/usr/local/lib/metro', { recursive: true, mode: 0o755 });
|
|
127
|
+
writeFileSync(HELPER_PATH, helperScript(), { mode: 0o755 });
|
|
128
|
+
chmodSync(HELPER_PATH, 0o755);
|
|
129
|
+
const draft = join(MOVE_DIR, 'sudoers');
|
|
130
|
+
writeFileSync(draft, sudoersText(AGENT_NAME), { mode: 0o440 });
|
|
131
|
+
const check = spawnSync('visudo', ['-cf', draft], { encoding: 'utf8' });
|
|
132
|
+
if (check.status !== 0) throw new ApiError(`the sudo rules did not validate: ${check.stdout}${check.stderr}`, 500);
|
|
133
|
+
spawnSync('install', ['-m', '440', draft, SUDOERS_PATH]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function startMove(spec = `@stage-labs/metro@${METRO_VERSION}`): { log: string } {
|
|
137
|
+
const { node, unit } = preflight();
|
|
138
|
+
const facts = unitFacts(unit);
|
|
139
|
+
mkdirSync(MOVE_DIR, { recursive: true, mode: 0o700 });
|
|
140
|
+
installHelper();
|
|
141
|
+
writeFileSync(join(MOVE_DIR, 'metro.service.new'), metroUnit(node, facts), { mode: 0o600 });
|
|
142
|
+
writeFileSync(join(MOVE_DIR, 'move.sh'), moveScript(facts.port, spec), { mode: 0o700 });
|
|
143
|
+
writeFileSync(join(MOVE_DIR, 'state'), 'starting\n');
|
|
144
|
+
const run = spawnSync('systemd-run', ['--unit=metro-move', '--collect', '--quiet', 'sh', join(MOVE_DIR, 'move.sh')], { encoding: 'utf8' });
|
|
145
|
+
if (run.status !== 0) throw new ApiError(`could not start the move: ${run.stderr.trim()}`, 500);
|
|
146
|
+
return { log: MOVE_LOG };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function moveState(): string | null {
|
|
150
|
+
try {
|
|
151
|
+
return readFileSync(join(MOVE_DIR, 'state'), 'utf8').trim();
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { HELPER_PATH, METRO_USER } from './helper-script.js';
|
|
3
|
+
|
|
4
|
+
export interface PrivilegeHost {
|
|
5
|
+
platform: string;
|
|
6
|
+
uid: number | undefined;
|
|
7
|
+
user: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let metroUid: number | null | undefined;
|
|
11
|
+
|
|
12
|
+
function uidOfMetro(): number | null {
|
|
13
|
+
if (metroUid !== undefined) return metroUid;
|
|
14
|
+
const run = spawnSync('getent', ['passwd', METRO_USER], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
15
|
+
const uid = Number(run.status === 0 ? run.stdout.split(':')[2] : NaN);
|
|
16
|
+
metroUid = Number.isInteger(uid) && uid > 0 ? uid : null;
|
|
17
|
+
return metroUid;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const realHost = (): PrivilegeHost => {
|
|
21
|
+
const uid = process.getuid?.();
|
|
22
|
+
const linux = process.platform === 'linux';
|
|
23
|
+
return { platform: process.platform, uid, user: linux && uid !== 0 && uid !== undefined && uid === uidOfMetro() ? METRO_USER : '' };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const runningAsRoot = (host: PrivilegeHost = realHost()): boolean => host.platform === 'linux' && host.uid === 0;
|
|
27
|
+
|
|
28
|
+
export const runningAsMetro = (host: PrivilegeHost = realHost()): boolean =>
|
|
29
|
+
host.platform === 'linux' && host.uid !== 0 && host.user === METRO_USER;
|
|
30
|
+
|
|
31
|
+
export interface HelperResult {
|
|
32
|
+
status: number | null;
|
|
33
|
+
stdout: string;
|
|
34
|
+
stderr: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function rootHelper(args: string[], input?: string): HelperResult {
|
|
38
|
+
const run = spawnSync('sudo', ['-n', HELPER_PATH, ...args], { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'pipe'], timeout: 180_000 });
|
|
39
|
+
return { status: run.error === undefined ? run.status : null, stdout: run.stdout ?? '', stderr: run.stderr ?? run.error?.message ?? '' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function mustHelper(args: string[], input?: string): string {
|
|
43
|
+
const run = rootHelper(args, input);
|
|
44
|
+
if (run.status !== 0) throw new Error(run.stderr.trim() || `root-helper ${args[0] ?? ''} failed`);
|
|
45
|
+
return run.stdout;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const helperArgv = (args: string[]): [string, string[]] => ['sudo', ['-n', HELPER_PATH, ...args]];
|
|
@@ -20,6 +20,7 @@ import { handleTerminalRequest, type TerminalApiDeps } from '../terminal/api.js'
|
|
|
20
20
|
import { handleSchedulesRequest } from '../agent-user/schedules-api.js';
|
|
21
21
|
import { handleFilesRequest } from '../agent-user/files-api.js';
|
|
22
22
|
import { handleVaultRequest } from '../vault/api.js';
|
|
23
|
+
import { handleMetroUserRequest } from '../metro-user/api.js';
|
|
23
24
|
|
|
24
25
|
export interface SessionApis {
|
|
25
26
|
agentApi?: AgentApiDeps;
|
|
@@ -58,6 +59,7 @@ export function handleSessionApis(
|
|
|
58
59
|
...when(apis.schedulesApi, () => handleSchedulesRequest(req, res)),
|
|
59
60
|
...when(apis.filesApi, () => handleFilesRequest(req, res)),
|
|
60
61
|
...when(apis.vaultApi, () => handleVaultRequest(req, res)),
|
|
62
|
+
...when(apis.vaultApi, () => handleMetroUserRequest(req, res)),
|
|
61
63
|
...when(apis.ownerApi, (d) => handleOwnerRequest(req, res, d)),
|
|
62
64
|
...when(apis.machineApi, (d) => handleMachineRequest(req, res, d)),
|
|
63
65
|
...when(apis.modelApi, (d) => handleModelRequest(req, res, d)),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { mustHelper, runningAsMetro } from '../metro-user/privilege.js';
|
|
2
3
|
|
|
3
4
|
const CHAIN = 'METRO_AGENT';
|
|
4
5
|
const TOOLS = ['iptables', 'ip6tables'] as const;
|
|
@@ -21,6 +22,10 @@ export const chainRules = (): string[][] => [
|
|
|
21
22
|
const jump = (uid: number): string[] => ['OUTPUT', '-m', 'owner', '--uid-owner', String(uid), '-j', CHAIN];
|
|
22
23
|
|
|
23
24
|
export function applyFirewall(uid: number, runner: FirewallRunner = realRunner): void {
|
|
25
|
+
if (runner === realRunner && runningAsMetro()) {
|
|
26
|
+
mustHelper(['firewall-on']);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
24
29
|
for (const tool of TOOLS) {
|
|
25
30
|
runner.run(tool, ['-N', CHAIN]);
|
|
26
31
|
if (runner.run(tool, ['-F', CHAIN]) !== 0) throw new Error(`${tool} refused to prepare the ${CHAIN} chain`);
|
|
@@ -31,6 +36,10 @@ export function applyFirewall(uid: number, runner: FirewallRunner = realRunner):
|
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
export function removeFirewall(uid: number, runner: FirewallRunner = realRunner): void {
|
|
39
|
+
if (runner === realRunner && runningAsMetro()) {
|
|
40
|
+
mustHelper(['firewall-off']);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
34
43
|
for (const tool of TOOLS) {
|
|
35
44
|
for (let i = 0; i < 10 && runner.run(tool, ['-C', ...jump(uid)]) === 0; i += 1) runner.run(tool, ['-D', ...jump(uid)]);
|
|
36
45
|
runner.run(tool, ['-F', CHAIN]);
|
|
@@ -3,12 +3,17 @@ import { copyFileSync, chmodSync, existsSync, readFileSync, rmSync } from 'node:
|
|
|
3
3
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
4
|
import { asUser, type AgentUser } from '../agent-user/user.js';
|
|
5
5
|
import { TRUSTED_CA } from './paths.js';
|
|
6
|
+
import { mustHelper, rootHelper, runningAsMetro } from '../metro-user/privilege.js';
|
|
6
7
|
|
|
7
8
|
const has = (bin: string): boolean => spawnSync('sh', ['-c', `command -v ${bin}`], { stdio: 'ignore' }).status === 0;
|
|
8
9
|
|
|
9
10
|
export function trustSystem(caFile: string): void {
|
|
10
11
|
const same = existsSync(TRUSTED_CA) && readFileSync(TRUSTED_CA, 'utf8') === readFileSync(caFile, 'utf8');
|
|
11
12
|
if (same) return;
|
|
13
|
+
if (runningAsMetro()) {
|
|
14
|
+
mustHelper(['trust-ca']);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
12
17
|
copyFileSync(caFile, TRUSTED_CA);
|
|
13
18
|
chmodSync(TRUSTED_CA, 0o644);
|
|
14
19
|
const run = spawnSync('update-ca-certificates', [], { encoding: 'utf8' });
|
|
@@ -17,6 +22,10 @@ export function trustSystem(caFile: string): void {
|
|
|
17
22
|
|
|
18
23
|
export function untrustSystem(): void {
|
|
19
24
|
if (!existsSync(TRUSTED_CA)) return;
|
|
25
|
+
if (runningAsMetro()) {
|
|
26
|
+
mustHelper(['untrust-ca']);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
20
29
|
rmSync(TRUSTED_CA, { force: true });
|
|
21
30
|
spawnSync('update-ca-certificates', ['--fresh'], { stdio: 'ignore' });
|
|
22
31
|
}
|
|
@@ -32,6 +41,7 @@ const NSS_SCRIPT = [
|
|
|
32
41
|
|
|
33
42
|
function ensureCertutil(): boolean {
|
|
34
43
|
if (has('certutil')) return true;
|
|
44
|
+
if (runningAsMetro()) return rootHelper(['install-nss-tools']).status === 0 && has('certutil');
|
|
35
45
|
if (!has('apt-get')) return false;
|
|
36
46
|
const run = spawnSync('apt-get', ['install', '-y', '-q', 'libnss3-tools'], { stdio: 'ignore', timeout: 180_000, env: { ...process.env, DEBIAN_FRONTEND: 'noninteractive' } });
|
|
37
47
|
return run.status === 0 && has('certutil');
|
package/runtime/runtime.json
CHANGED