@stage-labs/metro 0.1.0-beta.196 → 0.1.0-beta.197
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/job-env.ts +57 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +9 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/vault/api.ts +45 -0
- package/runtime/node_modules/@metro-labs/daemon/src/vault/config.ts +51 -0
- package/runtime/node_modules/@metro-labs/daemon/src/vault/firewall.ts +39 -0
- package/runtime/node_modules/@metro-labs/daemon/src/vault/index.ts +96 -0
- package/runtime/node_modules/@metro-labs/daemon/src/vault/install.ts +56 -0
- package/runtime/node_modules/@metro-labs/daemon/src/vault/paths.ts +26 -0
- package/runtime/node_modules/@metro-labs/daemon/src/vault/proxy.ts +148 -0
- package/runtime/node_modules/@metro-labs/daemon/src/vault/store.ts +130 -0
- package/runtime/node_modules/@metro-labs/daemon/src/vault/trust.ts +51 -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.197",
|
|
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.197",
|
|
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",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { log } from '@metro-labs/core/log';
|
|
4
|
+
import { writeHomeText } from './home-fs.js';
|
|
5
|
+
import { listSchedules, realRunner, showProps, type Runner } from './schedules.js';
|
|
6
|
+
import type { AgentUser } from './user.js';
|
|
7
|
+
|
|
8
|
+
const UNIT_DIR = '/etc/systemd/system';
|
|
9
|
+
const DROP_IN = '11-metro-vault.conf';
|
|
10
|
+
const BEGIN = '# metro vault: begin';
|
|
11
|
+
const END = '# metro vault: end';
|
|
12
|
+
|
|
13
|
+
export const jobEnvFile = (user: AgentUser): string => join(user.home, '.metro', 'vault.env');
|
|
14
|
+
|
|
15
|
+
export function withCronEnv(lines: string[], env: Record<string, string>): string[] {
|
|
16
|
+
const start = lines.indexOf(BEGIN);
|
|
17
|
+
const stop = lines.indexOf(END);
|
|
18
|
+
const rest = start >= 0 && stop > start ? [...lines.slice(0, start), ...lines.slice(stop + 1)] : lines;
|
|
19
|
+
const kept = rest.filter((l) => l !== '');
|
|
20
|
+
const block = Object.entries(env).map(([k, v]) => `${k}=${v}`);
|
|
21
|
+
return block.length === 0 ? kept : [BEGIN, ...block, END, ...kept];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function setCronEnv(user: AgentUser, env: Record<string, string>, runner: Runner): void {
|
|
25
|
+
const out = runner.run('crontab', ['-l', '-u', user.name]);
|
|
26
|
+
const lines = out.status === 0 ? out.stdout.split('\n') : [];
|
|
27
|
+
const next = withCronEnv(lines, env);
|
|
28
|
+
if (next.join('\n') === lines.filter((l) => l !== '').join('\n')) return;
|
|
29
|
+
runner.run('crontab', ['-u', user.name, '-'], next.length === 0 ? '' : `${next.join('\n')}\n`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function setTimerEnv(user: AgentUser, on: boolean, runner: Runner): void {
|
|
33
|
+
let changed = false;
|
|
34
|
+
for (const job of listSchedules(user, runner)) {
|
|
35
|
+
if (job.kind !== 'timer' || job.runsAs !== user.name) continue;
|
|
36
|
+
const unit = job.id.slice('timer:'.length);
|
|
37
|
+
const service = showProps(runner, unit, ['Unit']).Unit ?? unit.replace(/\.timer$/, '.service');
|
|
38
|
+
const file = join(UNIT_DIR, `${service}.d`, DROP_IN);
|
|
39
|
+
if (on) {
|
|
40
|
+
mkdirSync(join(UNIT_DIR, `${service}.d`), { recursive: true });
|
|
41
|
+
writeFileSync(file, `[Service]\nEnvironmentFile=-${jobEnvFile(user)}\n`, { mode: 0o644 });
|
|
42
|
+
changed = true;
|
|
43
|
+
} else if (existsSync(file)) {
|
|
44
|
+
rmSync(file, { force: true });
|
|
45
|
+
changed = true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (changed) runner.run('systemctl', ['daemon-reload']);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function exportJobEnv(user: AgentUser, env: Record<string, string>, runner: Runner = realRunner): void {
|
|
52
|
+
const on = Object.keys(env).length > 0;
|
|
53
|
+
writeHomeText(jobEnvFile(user), Object.entries(env).map(([k, v]) => `${k}=${v}\n`).join(''), 0o644);
|
|
54
|
+
setCronEnv(user, env, runner);
|
|
55
|
+
setTimerEnv(user, on, runner);
|
|
56
|
+
log.info({ on, keys: Object.keys(env).length }, "vault: the agent's scheduled jobs use the vault settings");
|
|
57
|
+
}
|
|
@@ -42,6 +42,14 @@ export function forgetAgentUser(): void {
|
|
|
42
42
|
cached = null;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
let extraEnv: Record<string, string> = {};
|
|
46
|
+
|
|
47
|
+
export function setAgentExtraEnv(env: Record<string, string>): void {
|
|
48
|
+
extraEnv = { ...env };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const agentExtraEnv = (): Record<string, string> => ({ ...extraEnv });
|
|
52
|
+
|
|
45
53
|
export function agentEnv(user: AgentUser, extra: Record<string, string> = {}): Record<string, string> {
|
|
46
54
|
return {
|
|
47
55
|
HOME: user.home,
|
|
@@ -51,6 +59,7 @@ export function agentEnv(user: AgentUser, extra: Record<string, string> = {}): R
|
|
|
51
59
|
LANG: process.env.LANG ?? 'C.UTF-8',
|
|
52
60
|
TERM: process.env.TERM ?? 'xterm-256color',
|
|
53
61
|
PATH: AGENT_PATH.map((p) => (p.startsWith('/') ? p : join(user.home, p))).join(':'),
|
|
62
|
+
...extraEnv,
|
|
54
63
|
...extra,
|
|
55
64
|
};
|
|
56
65
|
}
|
|
@@ -25,6 +25,7 @@ import { loadConnectorPolicies, readLocalConnectors } from '../connectors/store.
|
|
|
25
25
|
import { ensureMetroPlugin } from '../claude/plugin-install.js';
|
|
26
26
|
import { provisionAgentUser } from '../agent-user/provision.js';
|
|
27
27
|
import { convertRootJobs } from '../agent-user/schedules.js';
|
|
28
|
+
import { applyVault } from '../vault/index.js';
|
|
28
29
|
import { agentUser as currentAgentUser } from '../agent-user/user.js';
|
|
29
30
|
import { ensureServiceOomPolicy } from '../claude/memory.js';
|
|
30
31
|
import { unwatchSession, watchSession } from '../claude/session.js';
|
|
@@ -164,6 +165,8 @@ async function startClaude(): Promise<void> {
|
|
|
164
165
|
if (agentUser === 'ready') {
|
|
165
166
|
const switched = convertRootJobs(currentAgentUser());
|
|
166
167
|
if (switched > 0) log.info({ switched }, "schedules: jobs that pointed into root's home now run as the agent user");
|
|
168
|
+
const vault = (await applyVault()).status;
|
|
169
|
+
if (vault.enabled) log.info({ running: vault.running, problem: vault.problem }, 'vault: the agent reaches the internet through the vault');
|
|
167
170
|
}
|
|
168
171
|
ensureMetroPlugin()
|
|
169
172
|
.then((outcome) => {
|
|
@@ -12,13 +12,13 @@ import { claudeDir, listClaudeProjects } from './files.js';
|
|
|
12
12
|
import { claudeAccount, claudeInstalled } from './login.js';
|
|
13
13
|
import { trustFolder } from './onboarding.js';
|
|
14
14
|
import { inSessionScope } from './memory.js';
|
|
15
|
-
import { agentUser, agentUserExpected, agentViewDir, asAgent, claudeHome } from '../agent-user/user.js';
|
|
15
|
+
import { agentExtraEnv, agentUser, agentUserExpected, agentViewDir, asAgent, claudeHome } from '../agent-user/user.js';
|
|
16
16
|
|
|
17
17
|
function sessionEnv(): Record<string, string> {
|
|
18
18
|
const user = agentUser();
|
|
19
19
|
if (user === null) return {};
|
|
20
20
|
const port = process.env.METRO_WEBHOOK_PORT?.trim() ?? '';
|
|
21
|
-
return { METRO_AGENTS_DIR: agentViewDir(user), ...(port === '' ? {} : { METRO_WEBHOOK_PORT: port }) };
|
|
21
|
+
return { ...agentExtraEnv(), METRO_AGENTS_DIR: agentViewDir(user), ...(port === '' ? {} : { METRO_WEBHOOK_PORT: port }) };
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export const SESSION_NAME = 'metro';
|
|
@@ -197,6 +197,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
197
197
|
controlApi: { restart: deps.restart, stop: deps.stop },
|
|
198
198
|
schedulesApi: true,
|
|
199
199
|
filesApi: true,
|
|
200
|
+
vaultApi: true,
|
|
200
201
|
ownerApi: { setOwner: (owner) => setLocalOwner(owner) },
|
|
201
202
|
machineApi: {},
|
|
202
203
|
modelApi: {},
|
|
@@ -19,6 +19,7 @@ import type { GatewayDeps } from '../gateway/gateway.js';
|
|
|
19
19
|
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
|
+
import { handleVaultRequest } from '../vault/api.js';
|
|
22
23
|
|
|
23
24
|
export interface SessionApis {
|
|
24
25
|
agentApi?: AgentApiDeps;
|
|
@@ -27,6 +28,7 @@ export interface SessionApis {
|
|
|
27
28
|
controlApi?: ControlApiDeps;
|
|
28
29
|
schedulesApi?: true;
|
|
29
30
|
filesApi?: true;
|
|
31
|
+
vaultApi?: true;
|
|
30
32
|
ownerApi?: OwnerApiDeps;
|
|
31
33
|
machineApi?: MachineApiDeps;
|
|
32
34
|
modelApi?: ModelApiDeps;
|
|
@@ -55,6 +57,7 @@ export function handleSessionApis(
|
|
|
55
57
|
...when(apis.controlApi, (d) => handleControlRequest(req, res, d)),
|
|
56
58
|
...when(apis.schedulesApi, () => handleSchedulesRequest(req, res)),
|
|
57
59
|
...when(apis.filesApi, () => handleFilesRequest(req, res)),
|
|
60
|
+
...when(apis.vaultApi, () => handleVaultRequest(req, res)),
|
|
58
61
|
...when(apis.ownerApi, (d) => handleOwnerRequest(req, res, d)),
|
|
59
62
|
...when(apis.machineApi, (d) => handleMachineRequest(req, res, d)),
|
|
60
63
|
...when(apis.modelApi, (d) => handleModelRequest(req, res, d)),
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
3
|
+
import { bodyField, readJsonBody, sessionRoute } from '@metro-labs/http/api-http';
|
|
4
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
+
import { log } from '@metro-labs/core/log';
|
|
6
|
+
import { sessionRunning, stopSession } from '../claude/session.js';
|
|
7
|
+
import { agentUser } from '../agent-user/user.js';
|
|
8
|
+
import { applyVault, vaultStatus } from './index.js';
|
|
9
|
+
import { recentRequests } from './proxy.js';
|
|
10
|
+
import { addSecret, readVault, removeSecret, setVaultEnabled, updateSecret, type SecretInput } from './store.js';
|
|
11
|
+
|
|
12
|
+
const PATH = '/api/vault';
|
|
13
|
+
|
|
14
|
+
function view(): unknown {
|
|
15
|
+
return { ...vaultStatus(), secrets: readVault().secrets, recent: recentRequests().slice(0, 100) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const inputOf = (body: unknown): SecretInput => (isRecord(body) ? { name: body.name, env: body.env, hosts: body.hosts, value: body.value } : {});
|
|
19
|
+
|
|
20
|
+
function change(body: unknown): void {
|
|
21
|
+
const action = bodyField(body, 'action');
|
|
22
|
+
const id = bodyField(body, 'id');
|
|
23
|
+
if (action === 'enable' || action === 'disable') {
|
|
24
|
+
if (agentUser() === null) throw new ApiError('the vault needs Claude Code running as its own user (a Linux box)', 409);
|
|
25
|
+
setVaultEnabled(action === 'enable');
|
|
26
|
+
} else if (action === 'add') addSecret(inputOf(body));
|
|
27
|
+
else if (action === 'update' && typeof id === 'string') updateSecret(id, inputOf(body));
|
|
28
|
+
else if (action === 'remove' && typeof id === 'string') removeSecret(id);
|
|
29
|
+
else throw new ApiError('action must be enable, disable, add, update or remove', 400);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function answer(req: IncomingMessage): Promise<unknown> {
|
|
33
|
+
if (req.method === 'GET') return view();
|
|
34
|
+
change(await readJsonBody(req));
|
|
35
|
+
const { envChanged } = await applyVault();
|
|
36
|
+
if (envChanged && sessionRunning()) {
|
|
37
|
+
stopSession();
|
|
38
|
+
log.info('vault: the Claude session restarts to pick up the new environment');
|
|
39
|
+
}
|
|
40
|
+
return view();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function handleVaultRequest(req: IncomingMessage, res: ServerResponse): boolean {
|
|
44
|
+
return sessionRoute(req, res, { methods: { [PATH]: ['GET', 'POST'] }, admin: true, label: 'vault-api' }, () => answer(req));
|
|
45
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { caDir, ports, valueFile, vaultDir } from './paths.js';
|
|
2
|
+
import type { VaultSecret } from './store.js';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const MANAGEMENT_KEY_ENV = 'IRON_MANAGEMENT_API_KEY';
|
|
6
|
+
|
|
7
|
+
function secretEntry(secret: VaultSecret, dir: string): Record<string, unknown> {
|
|
8
|
+
return {
|
|
9
|
+
source: { type: 'file', path: valueFile(secret.id, dir) },
|
|
10
|
+
replace: { proxy_value: secret.env, match_headers: [], match_query: true },
|
|
11
|
+
rules: secret.hosts.map((host) => ({ host })),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function proxyConfig(secrets: VaultSecret[], dir = vaultDir()): Record<string, unknown> {
|
|
16
|
+
const p = ports();
|
|
17
|
+
const local = (port: number): string => `127.0.0.1:${String(port)}`;
|
|
18
|
+
return {
|
|
19
|
+
dns: { enabled: false },
|
|
20
|
+
proxy: {
|
|
21
|
+
tunnel_listen: local(p.tunnel),
|
|
22
|
+
http_listen: local(p.http),
|
|
23
|
+
https_listen: local(p.https),
|
|
24
|
+
upstream_response_header_timeout: '10m',
|
|
25
|
+
},
|
|
26
|
+
tls: { mode: 'mitm', ca_cert: join(caDir(dir), 'ca.crt'), ca_key: join(caDir(dir), 'ca.key') },
|
|
27
|
+
transforms: secrets.length === 0 ? [] : [{ name: 'secrets', config: { secrets: secrets.map((s) => secretEntry(s, dir)) } }],
|
|
28
|
+
metrics: { listen: local(p.metrics) },
|
|
29
|
+
management: { listen: local(p.management), api_key_env: MANAGEMENT_KEY_ENV },
|
|
30
|
+
log: { level: 'info' },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function proxyEnvFor(caFile: string, bundle: string, secrets: VaultSecret[]): Record<string, string> {
|
|
35
|
+
const url = `http://127.0.0.1:${String(ports().tunnel)}`;
|
|
36
|
+
const skip = 'localhost,127.0.0.1,::1';
|
|
37
|
+
return {
|
|
38
|
+
HTTP_PROXY: url,
|
|
39
|
+
HTTPS_PROXY: url,
|
|
40
|
+
http_proxy: url,
|
|
41
|
+
https_proxy: url,
|
|
42
|
+
NO_PROXY: skip,
|
|
43
|
+
no_proxy: skip,
|
|
44
|
+
NODE_EXTRA_CA_CERTS: caFile,
|
|
45
|
+
SSL_CERT_FILE: bundle,
|
|
46
|
+
REQUESTS_CA_BUNDLE: bundle,
|
|
47
|
+
CURL_CA_BUNDLE: bundle,
|
|
48
|
+
GIT_SSL_CAINFO: bundle,
|
|
49
|
+
...Object.fromEntries(secrets.map((s) => [s.env, s.env])),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const CHAIN = 'METRO_AGENT';
|
|
4
|
+
const TOOLS = ['iptables', 'ip6tables'] as const;
|
|
5
|
+
|
|
6
|
+
export interface FirewallRunner {
|
|
7
|
+
run: (file: string, args: string[]) => number | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const realRunner: FirewallRunner = { run: (file, args) => spawnSync(file, ['-w', ...args], { stdio: 'ignore' }).status };
|
|
11
|
+
|
|
12
|
+
export const chainRules = (): string[][] => [
|
|
13
|
+
['-A', CHAIN, '-o', 'lo', '-j', 'ACCEPT'],
|
|
14
|
+
['-A', CHAIN, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT'],
|
|
15
|
+
['-A', CHAIN, '-p', 'udp', '--dport', '53', '-j', 'ACCEPT'],
|
|
16
|
+
['-A', CHAIN, '-p', 'tcp', '--dport', '53', '-j', 'ACCEPT'],
|
|
17
|
+
['-A', CHAIN, '-p', 'tcp', '-j', 'REJECT', '--reject-with', 'tcp-reset'],
|
|
18
|
+
['-A', CHAIN, '-j', 'REJECT'],
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const jump = (uid: number): string[] => ['OUTPUT', '-m', 'owner', '--uid-owner', String(uid), '-j', CHAIN];
|
|
22
|
+
|
|
23
|
+
export function applyFirewall(uid: number, runner: FirewallRunner = realRunner): void {
|
|
24
|
+
for (const tool of TOOLS) {
|
|
25
|
+
runner.run(tool, ['-N', CHAIN]);
|
|
26
|
+
if (runner.run(tool, ['-F', CHAIN]) !== 0) throw new Error(`${tool} refused to prepare the ${CHAIN} chain`);
|
|
27
|
+
for (const rule of chainRules()) if (runner.run(tool, rule) !== 0) throw new Error(`${tool} refused a rule: ${rule.join(' ')}`);
|
|
28
|
+
if (runner.run(tool, ['-C', ...jump(uid)]) !== 0 && runner.run(tool, ['-I', ...jump(uid)]) !== 0)
|
|
29
|
+
throw new Error(`${tool} refused to send the agent's traffic to ${CHAIN}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function removeFirewall(uid: number, runner: FirewallRunner = realRunner): void {
|
|
34
|
+
for (const tool of TOOLS) {
|
|
35
|
+
for (let i = 0; i < 10 && runner.run(tool, ['-C', ...jump(uid)]) === 0; i += 1) runner.run(tool, ['-D', ...jump(uid)]);
|
|
36
|
+
runner.run(tool, ['-F', CHAIN]);
|
|
37
|
+
runner.run(tool, ['-X', CHAIN]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import { ensureSecureDir } from '@metro-labs/core/secure-fs';
|
|
4
|
+
import { agentExtraEnv, agentUser, asAgent, setAgentExtraEnv, type AgentUser } from '../agent-user/user.js';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { exportJobEnv } from '../agent-user/job-env.js';
|
|
7
|
+
import { proxyConfig, proxyEnvFor } from './config.js';
|
|
8
|
+
import { applyFirewall, removeFirewall } from './firewall.js';
|
|
9
|
+
import { ensureAuthority, ensureProxyBinary } from './install.js';
|
|
10
|
+
import { configFile, PROXY_VERSION, SYSTEM_BUNDLE, TRUSTED_CA, vaultDir } from './paths.js';
|
|
11
|
+
import { proxyError, proxyRunning, runProxy, stopProxy } from './proxy.js';
|
|
12
|
+
import { hasValue, readVault } from './store.js';
|
|
13
|
+
import { trustBrowsers, trustSystem, untrustSystem } from './trust.js';
|
|
14
|
+
|
|
15
|
+
export interface VaultStatus {
|
|
16
|
+
available: boolean;
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
running: boolean;
|
|
19
|
+
version: string;
|
|
20
|
+
problem: string | null;
|
|
21
|
+
browsers: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const memory: { problem: string | null; browsers: string | null } = { problem: null, browsers: null };
|
|
25
|
+
|
|
26
|
+
function pushTmuxEnv(before: Record<string, string>, after: Record<string, string>): void {
|
|
27
|
+
const has = spawnSync(...asAgent('tmux', ['list-sessions']), { stdio: 'ignore' }).status === 0;
|
|
28
|
+
if (!has) return;
|
|
29
|
+
for (const key of Object.keys(before)) if (!(key in after)) spawnSync(...asAgent('tmux', ['set-environment', '-gu', key]), { stdio: 'ignore' });
|
|
30
|
+
for (const [key, value] of Object.entries(after)) spawnSync(...asAgent('tmux', ['set-environment', '-g', key, value]), { stdio: 'ignore' });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const sameEnv = (a: Record<string, string>, b: Record<string, string>): boolean =>
|
|
34
|
+
JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());
|
|
35
|
+
|
|
36
|
+
function setEnv(next: Record<string, string>): boolean {
|
|
37
|
+
const before = agentExtraEnv();
|
|
38
|
+
if (sameEnv(before, next)) return false;
|
|
39
|
+
setAgentExtraEnv(next);
|
|
40
|
+
pushTmuxEnv(before, next);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function exportForJobs(user: AgentUser, env: Record<string, string>): void {
|
|
45
|
+
try {
|
|
46
|
+
exportJobEnv(user, env);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
log.warn({ err: errMsg(err) }, 'vault: could not give the scheduled jobs the vault settings');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function turnOn(user: AgentUser, dir: string): Promise<Record<string, string>> {
|
|
53
|
+
const bin = await ensureProxyBinary(dir);
|
|
54
|
+
const ca = ensureAuthority(bin, dir);
|
|
55
|
+
const secrets = readVault(dir).secrets.filter((s) => hasValue(s.id, dir));
|
|
56
|
+
ensureSecureDir(dir);
|
|
57
|
+
writeFileSync(configFile(dir), JSON.stringify(proxyConfig(secrets, dir), null, 2), { mode: 0o600 });
|
|
58
|
+
trustSystem(ca);
|
|
59
|
+
memory.browsers = trustBrowsers(user);
|
|
60
|
+
await runProxy(bin, configFile(dir));
|
|
61
|
+
applyFirewall(user.uid);
|
|
62
|
+
return proxyEnvFor(TRUSTED_CA, SYSTEM_BUNDLE, secrets);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function turnOff(user: AgentUser): void {
|
|
66
|
+
removeFirewall(user.uid);
|
|
67
|
+
stopProxy();
|
|
68
|
+
untrustSystem();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function applyVault(user: AgentUser | null = agentUser(), dir = vaultDir()): Promise<{ status: VaultStatus; envChanged: boolean }> {
|
|
72
|
+
if (user === null) return { status: vaultStatus(null, dir), envChanged: false };
|
|
73
|
+
const enabled = readVault(dir).enabled;
|
|
74
|
+
try {
|
|
75
|
+
const env = enabled ? await turnOn(user, dir) : (turnOff(user), {});
|
|
76
|
+
memory.problem = null;
|
|
77
|
+
const envChanged = setEnv(env);
|
|
78
|
+
if (envChanged || enabled) exportForJobs(user, env);
|
|
79
|
+
return { status: vaultStatus(user, dir), envChanged };
|
|
80
|
+
} catch (err) {
|
|
81
|
+
memory.problem = errMsg(err);
|
|
82
|
+
log.warn({ err: memory.problem }, 'vault: could not apply the vault');
|
|
83
|
+
return { status: vaultStatus(user, dir), envChanged: false };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function vaultStatus(user: AgentUser | null = agentUser(), dir = vaultDir()): VaultStatus {
|
|
88
|
+
return {
|
|
89
|
+
available: user !== null,
|
|
90
|
+
enabled: user !== null && readVault(dir).enabled,
|
|
91
|
+
running: proxyRunning(),
|
|
92
|
+
version: PROXY_VERSION,
|
|
93
|
+
problem: memory.problem ?? (readVault(dir).enabled ? proxyError() : null),
|
|
94
|
+
browsers: memory.browsers,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { chmodSync, copyFileSync, existsSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { ensureSecureDir } from '@metro-labs/core/secure-fs';
|
|
7
|
+
import { binDir, caDir, PROXY_VERSION, proxyBin, vaultDir } from './paths.js';
|
|
8
|
+
|
|
9
|
+
const RELEASES = `https://github.com/ironsh/iron-proxy/releases/download/v${PROXY_VERSION}`;
|
|
10
|
+
|
|
11
|
+
export const archiveName = (arch = process.arch): string => `iron-proxy_${PROXY_VERSION}_linux_${arch === 'arm64' ? 'arm64' : 'amd64'}.tar.gz`;
|
|
12
|
+
|
|
13
|
+
export function expectedSum(checksums: string, file: string): string | null {
|
|
14
|
+
const line = checksums.split('\n').find((l) => l.trim().endsWith(` ${file}`));
|
|
15
|
+
const sum = line?.trim().split(/\s+/)[0];
|
|
16
|
+
return sum !== undefined && /^[0-9a-f]{64}$/.test(sum) ? sum : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function download(url: string): Promise<Buffer> {
|
|
20
|
+
const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(120_000) });
|
|
21
|
+
if (!res.ok) throw new Error(`${url} answered ${String(res.status)}`);
|
|
22
|
+
return Buffer.from(await res.arrayBuffer());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function ensureProxyBinary(dir = vaultDir()): Promise<string> {
|
|
26
|
+
const bin = proxyBin(dir);
|
|
27
|
+
if (existsSync(bin)) return bin;
|
|
28
|
+
const file = archiveName();
|
|
29
|
+
const [archive, checksums] = await Promise.all([download(`${RELEASES}/${file}`), download(`${RELEASES}/checksums.txt`)]);
|
|
30
|
+
const want = expectedSum(checksums.toString('utf8'), file);
|
|
31
|
+
const got = createHash('sha256').update(archive).digest('hex');
|
|
32
|
+
if (want === null || want !== got) throw new Error(`the iron-proxy download does not match its checksum (${file})`);
|
|
33
|
+
const work = mkdtempSync(join(tmpdir(), 'metro-iron-'));
|
|
34
|
+
try {
|
|
35
|
+
writeFileSync(join(work, file), archive);
|
|
36
|
+
const untar = spawnSync('tar', ['-xzf', file, 'iron-proxy'], { cwd: work, encoding: 'utf8' });
|
|
37
|
+
if (untar.status !== 0) throw new Error(`could not unpack iron-proxy: ${untar.stderr.trim()}`);
|
|
38
|
+
ensureSecureDir(binDir(dir));
|
|
39
|
+
copyFileSync(join(work, 'iron-proxy'), `${bin}.part`);
|
|
40
|
+
chmodSync(`${bin}.part`, 0o755);
|
|
41
|
+
renameSync(`${bin}.part`, bin);
|
|
42
|
+
} finally {
|
|
43
|
+
rmSync(work, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
return bin;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function ensureAuthority(bin: string, dir = vaultDir()): string {
|
|
49
|
+
const cert = join(caDir(dir), 'ca.crt');
|
|
50
|
+
if (existsSync(cert) && existsSync(join(caDir(dir), 'ca.key'))) return cert;
|
|
51
|
+
ensureSecureDir(caDir(dir));
|
|
52
|
+
const made = spawnSync(bin, ['generate-ca', '-outdir', caDir(dir), '-name', 'Metro vault', '-expiry-hours', '87600', '-alg', 'rsa4096'], { encoding: 'utf8' });
|
|
53
|
+
if (made.status !== 0) throw new Error(`could not create the vault certificate: ${made.stderr.trim()}`);
|
|
54
|
+
chmodSync(join(caDir(dir), 'ca.key'), 0o600);
|
|
55
|
+
return cert;
|
|
56
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { agentsDir } from '../agents/files.js';
|
|
3
|
+
|
|
4
|
+
export const PROXY_VERSION = '0.50.0';
|
|
5
|
+
|
|
6
|
+
const basePort = (): number => {
|
|
7
|
+
const raw = Number(process.env.METRO_VAULT_PORT);
|
|
8
|
+
return Number.isInteger(raw) && raw > 1024 && raw < 65000 ? raw : 8421;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const ports = (): { tunnel: number; http: number; https: number; management: number; metrics: number } => {
|
|
12
|
+
const base = basePort();
|
|
13
|
+
return { tunnel: base, http: base + 1, https: base + 2, management: base + 3, metrics: base + 4 };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const vaultDir = (dir = agentsDir()): string => join(dir, 'vault');
|
|
17
|
+
export const stateFile = (dir = vaultDir()): string => join(dir, 'vault.json');
|
|
18
|
+
export const valuesDir = (dir = vaultDir()): string => join(dir, 'values');
|
|
19
|
+
export const valueFile = (id: string, dir = vaultDir()): string => join(valuesDir(dir), id);
|
|
20
|
+
export const caDir = (dir = vaultDir()): string => join(dir, 'ca');
|
|
21
|
+
export const binDir = (dir = vaultDir()): string => join(dir, 'bin');
|
|
22
|
+
export const proxyBin = (dir = vaultDir()): string => join(binDir(dir), `iron-proxy-${PROXY_VERSION}`);
|
|
23
|
+
export const configFile = (dir = vaultDir()): string => join(dir, 'iron-proxy.json');
|
|
24
|
+
|
|
25
|
+
export const TRUSTED_CA = '/usr/local/share/ca-certificates/metro-vault.crt';
|
|
26
|
+
export const SYSTEM_BUNDLE = '/etc/ssl/certs/ca-certificates.crt';
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
5
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
7
|
+
import { MANAGEMENT_KEY_ENV } from './config.js';
|
|
8
|
+
import { ports } from './paths.js';
|
|
9
|
+
|
|
10
|
+
export interface ProxyRequest {
|
|
11
|
+
at: string;
|
|
12
|
+
method: string;
|
|
13
|
+
host: string;
|
|
14
|
+
path: string;
|
|
15
|
+
status: number | null;
|
|
16
|
+
action: string;
|
|
17
|
+
swapped: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const RECENT_MAX = 200;
|
|
21
|
+
const BACKOFF_MAX_MS = 30_000;
|
|
22
|
+
|
|
23
|
+
const recent: ProxyRequest[] = [];
|
|
24
|
+
const state: { child: ChildProcess | null; wanted: { bin: string; config: string } | null; key: string; backoff: number; lastError: string | null; startedAt: number } = {
|
|
25
|
+
child: null,
|
|
26
|
+
wanted: null,
|
|
27
|
+
key: randomBytes(24).toString('hex'),
|
|
28
|
+
backoff: 1000,
|
|
29
|
+
lastError: null,
|
|
30
|
+
startedAt: 0,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const text = (v: unknown): string => (typeof v === 'string' ? v : '');
|
|
34
|
+
|
|
35
|
+
function swappedIds(transforms: unknown): string[] {
|
|
36
|
+
if (!Array.isArray(transforms)) return [];
|
|
37
|
+
return transforms.flatMap((t) => {
|
|
38
|
+
const swapped = isRecord(t) && isRecord(t.annotations) && Array.isArray(t.annotations.swapped) ? t.annotations.swapped : [];
|
|
39
|
+
return swapped.flatMap((s) => (isRecord(s) && typeof s.secret === 'string' ? [basename(s.secret)] : []));
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function requestOf(line: string): ProxyRequest | null {
|
|
44
|
+
let parsed: unknown;
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(line);
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (!isRecord(parsed) || parsed.msg !== 'request' || !isRecord(parsed.audit)) return null;
|
|
51
|
+
const audit = parsed.audit;
|
|
52
|
+
if (audit.method === 'CONNECT') return null;
|
|
53
|
+
return {
|
|
54
|
+
at: text(parsed.time),
|
|
55
|
+
method: text(audit.method),
|
|
56
|
+
host: text(audit.host),
|
|
57
|
+
path: text(audit.path),
|
|
58
|
+
status: typeof audit.status_code === 'number' ? audit.status_code : null,
|
|
59
|
+
action: text(audit.action),
|
|
60
|
+
swapped: swappedIds(parsed.request_transforms),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function noteLine(line: string): void {
|
|
65
|
+
const request = requestOf(line);
|
|
66
|
+
if (request !== null) {
|
|
67
|
+
recent.push(request);
|
|
68
|
+
if (recent.length > RECENT_MAX) recent.shift();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (/"level":"(WARN|ERROR)"/.test(line)) log.warn({ line: line.slice(0, 500) }, 'vault: iron-proxy');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function launch(): void {
|
|
75
|
+
const wanted = state.wanted;
|
|
76
|
+
if (wanted === null || state.child !== null) return;
|
|
77
|
+
const child = spawn(wanted.bin, ['-config', wanted.config], { stdio: ['ignore', 'pipe', 'pipe'], env: { PATH: process.env.PATH ?? '/usr/bin:/bin', [MANAGEMENT_KEY_ENV]: state.key } });
|
|
78
|
+
state.child = child;
|
|
79
|
+
state.startedAt = Date.now();
|
|
80
|
+
for (const stream of [child.stdout, child.stderr]) if (stream !== null) createInterface({ input: stream }).on('line', noteLine);
|
|
81
|
+
child.on('error', (err) => {
|
|
82
|
+
state.lastError = errMsg(err);
|
|
83
|
+
});
|
|
84
|
+
child.on('exit', (code) => {
|
|
85
|
+
state.child = null;
|
|
86
|
+
if (state.wanted === null) return;
|
|
87
|
+
state.lastError = `iron-proxy stopped (exit ${String(code)})`;
|
|
88
|
+
log.warn({ code, retryInMs: state.backoff }, 'vault: iron-proxy stopped, starting it again');
|
|
89
|
+
const wait = state.backoff;
|
|
90
|
+
state.backoff = Date.now() - state.startedAt > 60_000 ? 1000 : Math.min(state.backoff * 2, BACKOFF_MAX_MS);
|
|
91
|
+
setTimeout(launch, wait).unref();
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function reload(): Promise<boolean> {
|
|
96
|
+
try {
|
|
97
|
+
const res = await fetch(`http://127.0.0.1:${String(ports().management)}/v1/reload`, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { authorization: `Bearer ${state.key}` },
|
|
100
|
+
signal: AbortSignal.timeout(10_000),
|
|
101
|
+
});
|
|
102
|
+
return res.ok;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function listening(): Promise<boolean> {
|
|
109
|
+
try {
|
|
110
|
+
await fetch(`http://127.0.0.1:${String(ports().management)}/v1/reload`, { method: 'GET', signal: AbortSignal.timeout(2_000) });
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function waitListening(ms = 10_000): Promise<boolean> {
|
|
118
|
+
const until = Date.now() + ms;
|
|
119
|
+
while (Date.now() < until) {
|
|
120
|
+
if (await listening()) return true;
|
|
121
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function runProxy(bin: string, config: string): Promise<void> {
|
|
127
|
+
const fresh = state.child === null;
|
|
128
|
+
state.wanted = { bin, config };
|
|
129
|
+
if (fresh) launch();
|
|
130
|
+
else if (await reload()) {
|
|
131
|
+
state.lastError = null;
|
|
132
|
+
return;
|
|
133
|
+
} else {
|
|
134
|
+
state.child?.kill('SIGTERM');
|
|
135
|
+
await new Promise((r) => setTimeout(r, state.backoff + 500));
|
|
136
|
+
}
|
|
137
|
+
if (!(await waitListening())) throw new Error(state.lastError ?? 'iron-proxy did not start');
|
|
138
|
+
state.lastError = null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function stopProxy(): void {
|
|
142
|
+
state.wanted = null;
|
|
143
|
+
state.child?.kill('SIGTERM');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const proxyRunning = (): boolean => state.child !== null;
|
|
147
|
+
export const proxyError = (): string | null => state.lastError;
|
|
148
|
+
export const recentRequests = (): ProxyRequest[] => [...recent].reverse();
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { existsSync, rmSync } from 'node:fs';
|
|
2
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
3
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
+
import { newId } from '@metro-labs/core/ids';
|
|
5
|
+
import { ensureSecureDir, readJson, writeSecure } from '@metro-labs/core/secure-fs';
|
|
6
|
+
import { stateFile, valueFile, valuesDir, vaultDir } from './paths.js';
|
|
7
|
+
|
|
8
|
+
export interface VaultSecret {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
env: string;
|
|
12
|
+
hosts: string[];
|
|
13
|
+
createdAt: string;
|
|
14
|
+
updatedAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface VaultState {
|
|
18
|
+
version: 1;
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
secrets: VaultSecret[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SecretInput {
|
|
24
|
+
name?: unknown;
|
|
25
|
+
env?: unknown;
|
|
26
|
+
hosts?: unknown;
|
|
27
|
+
value?: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ENV_RE = /^[A-Z][A-Z0-9_]{2,63}$/;
|
|
31
|
+
const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
32
|
+
const RESERVED_ENV = new Set(['HOME', 'PATH', 'USER', 'SHELL', 'LANG', 'TERM', 'LOGNAME', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'ALL_PROXY']);
|
|
33
|
+
const MAX_SECRETS = 100;
|
|
34
|
+
const MAX_HOSTS = 20;
|
|
35
|
+
const MAX_VALUE = 16 * 1024;
|
|
36
|
+
|
|
37
|
+
const str = (v: unknown): string => (typeof v === 'string' ? v : '');
|
|
38
|
+
|
|
39
|
+
function secretOf(raw: unknown): VaultSecret | null {
|
|
40
|
+
if (!isRecord(raw) || typeof raw.id !== 'string' || !ENV_RE.test(str(raw.env))) return null;
|
|
41
|
+
const hosts = Array.isArray(raw.hosts) ? raw.hosts.filter((h): h is string => typeof h === 'string' && HOST_RE.test(h)) : [];
|
|
42
|
+
return { id: raw.id, name: str(raw.name) || str(raw.env), env: str(raw.env), hosts, createdAt: str(raw.createdAt), updatedAt: str(raw.updatedAt) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readVault(dir = vaultDir()): VaultState {
|
|
46
|
+
const raw = readJson<unknown>(stateFile(dir), null);
|
|
47
|
+
if (!isRecord(raw)) return { version: 1, enabled: false, secrets: [] };
|
|
48
|
+
const secrets = Array.isArray(raw.secrets) ? raw.secrets.map(secretOf).filter((s): s is VaultSecret => s !== null) : [];
|
|
49
|
+
return { version: 1, enabled: raw.enabled === true, secrets };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function writeVault(state: VaultState, dir = vaultDir()): void {
|
|
53
|
+
ensureSecureDir(dir);
|
|
54
|
+
writeSecure(stateFile(dir), `${JSON.stringify(state, null, 2)}\n`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function normalizeName(raw: unknown): string {
|
|
58
|
+
const name = str(raw).trim();
|
|
59
|
+
if (name === '' || name.length > 80) throw new ApiError('a name of 1 to 80 characters is required', 400);
|
|
60
|
+
return name;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function normalizeEnv(raw: unknown): string {
|
|
64
|
+
const env = str(raw).trim();
|
|
65
|
+
if (!ENV_RE.test(env) || RESERVED_ENV.has(env))
|
|
66
|
+
throw new ApiError('the variable name must be capital letters, digits and _, 3 to 64 long, like OPENAI_API_KEY', 400);
|
|
67
|
+
return env;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function normalizeHosts(raw: unknown): string[] {
|
|
71
|
+
if (!Array.isArray(raw)) throw new ApiError('hosts must be a list of websites, like api.openai.com', 400);
|
|
72
|
+
const hosts = [...new Set(raw.map((h) => str(h).trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '')).filter((h) => h !== ''))];
|
|
73
|
+
if (hosts.length === 0 || hosts.length > MAX_HOSTS) throw new ApiError(`a secret needs 1 to ${String(MAX_HOSTS)} websites`, 400);
|
|
74
|
+
const bad = hosts.find((h) => !HOST_RE.test(h));
|
|
75
|
+
if (bad !== undefined) throw new ApiError(`'${bad}' is not a website name; write it as api.example.com or *.example.com`, 400);
|
|
76
|
+
return hosts;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function normalizeValue(raw: unknown): string {
|
|
80
|
+
const value = str(raw).trim();
|
|
81
|
+
if (value === '' || value.length > MAX_VALUE) throw new ApiError('a value of 1 to 16384 characters is required', 400);
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function saveValue(id: string, value: string, dir: string): void {
|
|
86
|
+
ensureSecureDir(valuesDir(dir));
|
|
87
|
+
writeSecure(valueFile(id, dir), value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function addSecret(input: SecretInput, dir = vaultDir(), now = new Date()): VaultSecret {
|
|
91
|
+
const state = readVault(dir);
|
|
92
|
+
if (state.secrets.length >= MAX_SECRETS) throw new ApiError(`at most ${String(MAX_SECRETS)} secrets`, 400);
|
|
93
|
+
const env = normalizeEnv(input.env);
|
|
94
|
+
if (state.secrets.some((s) => s.env === env)) throw new ApiError(`${env} is already a secret`, 409);
|
|
95
|
+
const at = now.toISOString();
|
|
96
|
+
const secret: VaultSecret = { id: newId(), name: normalizeName(input.name ?? env), env, hosts: normalizeHosts(input.hosts), createdAt: at, updatedAt: at };
|
|
97
|
+
saveValue(secret.id, normalizeValue(input.value), dir);
|
|
98
|
+
writeVault({ ...state, secrets: [...state.secrets, secret] }, dir);
|
|
99
|
+
return secret;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function updateSecret(id: string, input: SecretInput, dir = vaultDir(), now = new Date()): VaultSecret {
|
|
103
|
+
const state = readVault(dir);
|
|
104
|
+
const found = state.secrets.find((s) => s.id === id);
|
|
105
|
+
if (found === undefined) throw new ApiError('no such secret', 404);
|
|
106
|
+
const next: VaultSecret = {
|
|
107
|
+
...found,
|
|
108
|
+
...(input.name === undefined ? {} : { name: normalizeName(input.name) }),
|
|
109
|
+
...(input.hosts === undefined ? {} : { hosts: normalizeHosts(input.hosts) }),
|
|
110
|
+
updatedAt: now.toISOString(),
|
|
111
|
+
};
|
|
112
|
+
if (input.value !== undefined) saveValue(id, normalizeValue(input.value), dir);
|
|
113
|
+
writeVault({ ...state, secrets: state.secrets.map((s) => (s.id === id ? next : s)) }, dir);
|
|
114
|
+
return next;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function removeSecret(id: string, dir = vaultDir()): void {
|
|
118
|
+
const state = readVault(dir);
|
|
119
|
+
if (!state.secrets.some((s) => s.id === id)) throw new ApiError('no such secret', 404);
|
|
120
|
+
writeVault({ ...state, secrets: state.secrets.filter((s) => s.id !== id) }, dir);
|
|
121
|
+
rmSync(valueFile(id, dir), { force: true });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function setVaultEnabled(enabled: boolean, dir = vaultDir()): VaultState {
|
|
125
|
+
const next = { ...readVault(dir), enabled };
|
|
126
|
+
writeVault(next, dir);
|
|
127
|
+
return next;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const hasValue = (id: string, dir = vaultDir()): boolean => existsSync(valueFile(id, dir));
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { copyFileSync, chmodSync, existsSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
|
+
import { asUser, type AgentUser } from '../agent-user/user.js';
|
|
5
|
+
import { TRUSTED_CA } from './paths.js';
|
|
6
|
+
|
|
7
|
+
const has = (bin: string): boolean => spawnSync('sh', ['-c', `command -v ${bin}`], { stdio: 'ignore' }).status === 0;
|
|
8
|
+
|
|
9
|
+
export function trustSystem(caFile: string): void {
|
|
10
|
+
const same = existsSync(TRUSTED_CA) && readFileSync(TRUSTED_CA, 'utf8') === readFileSync(caFile, 'utf8');
|
|
11
|
+
if (same) return;
|
|
12
|
+
copyFileSync(caFile, TRUSTED_CA);
|
|
13
|
+
chmodSync(TRUSTED_CA, 0o644);
|
|
14
|
+
const run = spawnSync('update-ca-certificates', [], { encoding: 'utf8' });
|
|
15
|
+
if (run.status !== 0) throw new Error(`update-ca-certificates failed: ${run.stderr.trim()}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function untrustSystem(): void {
|
|
19
|
+
if (!existsSync(TRUSTED_CA)) return;
|
|
20
|
+
rmSync(TRUSTED_CA, { force: true });
|
|
21
|
+
spawnSync('update-ca-certificates', ['--fresh'], { stdio: 'ignore' });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const NSS_SCRIPT = [
|
|
25
|
+
'set -e',
|
|
26
|
+
'db="$HOME/.pki/nssdb"',
|
|
27
|
+
'mkdir -p "$db"',
|
|
28
|
+
'[ -f "$db/cert9.db" ] || certutil -d "sql:$db" -N --empty-password',
|
|
29
|
+
'certutil -d "sql:$db" -D -n "Metro vault" >/dev/null 2>&1 || true',
|
|
30
|
+
'certutil -d "sql:$db" -A -t "C,," -n "Metro vault" -i "$1"',
|
|
31
|
+
].join('\n');
|
|
32
|
+
|
|
33
|
+
function ensureCertutil(): boolean {
|
|
34
|
+
if (has('certutil')) return true;
|
|
35
|
+
if (!has('apt-get')) return false;
|
|
36
|
+
const run = spawnSync('apt-get', ['install', '-y', '-q', 'libnss3-tools'], { stdio: 'ignore', timeout: 180_000, env: { ...process.env, DEBIAN_FRONTEND: 'noninteractive' } });
|
|
37
|
+
return run.status === 0 && has('certutil');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function trustBrowsers(user: AgentUser): string | null {
|
|
41
|
+
try {
|
|
42
|
+
if (!ensureCertutil()) return 'certutil is missing, so browsers the agent starts will not trust the vault (apt-get install libnss3-tools)';
|
|
43
|
+
const [file, argv] = asUser(user, 'sh', ['-c', NSS_SCRIPT, 'metro', TRUSTED_CA]);
|
|
44
|
+
const run = spawnSync(file, argv, { encoding: 'utf8', timeout: 30_000 });
|
|
45
|
+
if (run.status !== 0) return `browsers will not trust the vault: ${run.stderr.trim()}`;
|
|
46
|
+
return null;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
log.warn({ err: errMsg(err) }, 'vault: could not add the certificate for browsers');
|
|
49
|
+
return errMsg(err);
|
|
50
|
+
}
|
|
51
|
+
}
|
package/runtime/runtime.json
CHANGED