anon-pi 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/src/cli.ts CHANGED
@@ -55,6 +55,15 @@ import {
55
55
  resolveDeleteProject,
56
56
  parseConfigJson,
57
57
  parseLaunchArgs,
58
+ parseForwardArgs,
59
+ parsePortsArgs,
60
+ parsePortArg,
61
+ parseKeptKey,
62
+ keyProject,
63
+ resolveManagedMatches,
64
+ parseNetcagePortsJson,
65
+ forwardablePorts,
66
+ formatPortsHint,
58
67
  isHeadlessPiArgs,
59
68
  resumeSessionId,
60
69
  sessionHeaderCwd,
@@ -102,6 +111,8 @@ import {
102
111
  type ModelCandidate,
103
112
  type ModelSelection,
104
113
  type KeptContainer,
114
+ type ManagedContainer,
115
+ type NetcageListener,
105
116
  type LaunchIntent,
106
117
  type Machine,
107
118
  type MachineConfig,
@@ -132,7 +143,7 @@ function main(argv: string[]): number {
132
143
  // and `anon-pi machine --help` show THEIR help, not the global one). Those
133
144
  // subcommands route to runInit / runMachine, which print INIT_HELP /
134
145
  // MACHINE_HELP respectively.
135
- const OWN_HELP_SUBCOMMANDS = new Set(['init', 'machine']);
146
+ const OWN_HELP_SUBCOMMANDS = new Set(['init', 'machine', 'forward', 'ports']);
136
147
  if (
137
148
  (args.includes('--help') || args.includes('-h')) &&
138
149
  !OWN_HELP_SUBCOMMANDS.has(args[0] ?? '')
@@ -166,6 +177,16 @@ function main(argv: string[]): number {
166
177
  return runInit(args.slice(1));
167
178
  }
168
179
 
180
+ // Host-access verbs (netcage >= 0.9.0). `forward` opens a host->jail port; the
181
+ // `ports` sibling lists a jail's open listeners. Dispatched BEFORE the launch
182
+ // grammar so `forward`/`ports` are never parsed as a project name.
183
+ if (args[0] === 'forward') {
184
+ return runForward(args.slice(1));
185
+ }
186
+ if (args[0] === 'ports') {
187
+ return runPorts(args.slice(1));
188
+ }
189
+
169
190
  let parsed: ParsedLaunch;
170
191
  try {
171
192
  parsed = parseLaunchArgs(args);
@@ -383,6 +404,13 @@ function executeLaunchPlan(
383
404
  mkdirSync(intent.mountParent ?? intent.projectsRoot, {recursive: true});
384
405
  }
385
406
 
407
+ // The anon-pi identity key, stamped on EVERY launch (not just --keep) as an
408
+ // additive netcage label. Under --keep it lets a re-entry find + `netcage
409
+ // start` the kept container; on a throwaway --rm run it lets `anon-pi forward`
410
+ // find the RUNNING container while it is up (the label goes away with the
411
+ // container on exit). It touches NO egress flag (the RunPlan owns those).
412
+ const keyed = withKeyLabel(plan.netcageArgs, keptContainerKey(intent));
413
+
386
414
  // Run-vs-start: under --keep, ask netcage for its kept managed containers and
387
415
  // resume a matching one via `netcage start`; else run the composed argv. A
388
416
  // throwaway (`--rm`) launch is always a fresh run (the pure rule never
@@ -392,14 +420,11 @@ function executeLaunchPlan(
392
420
  if (decision.action === 'start') {
393
421
  return spawnNetcage(['start', '-a', '-i', decision.ref]);
394
422
  }
395
- // A fresh `--keep` run: stamp the identity key so a later re-entry can
396
- // find this container. The RunPlan already omits --rm under --keep.
397
- return spawnNetcage(
398
- withKeyLabel(plan.netcageArgs, keptContainerKey(intent)),
399
- );
423
+ // A fresh `--keep` run: the RunPlan already omits --rm so it is left kept.
424
+ return spawnNetcage(keyed);
400
425
  }
401
426
 
402
- return spawnNetcage(plan.netcageArgs);
427
+ return spawnNetcage(keyed);
403
428
  }
404
429
 
405
430
  // --- the interactive host-side menu (the ONLY untested I/O) -------------------
@@ -1981,6 +2006,36 @@ function promptLine(prompt: string): string | undefined {
1981
2006
  return line === '' ? undefined : line;
1982
2007
  }
1983
2008
 
2009
+ /** The `forward` subcommand help. */
2010
+ const FORWARD_HELP = `anon-pi forward - open a host port onto a running anon-pi container's in-jail server
2011
+
2012
+ USAGE
2013
+ anon-pi forward [<project>] [--port <[hostPort:]jailPort>] [--bind <addr>] [-m <machine>]
2014
+
2015
+ <project> filter to a running container for THIS project (a numeric name is a
2016
+ project, never a port). Omitted => all running anon-pi containers.
2017
+ --port,-p the port to forward, host-first like docker/kubectl: 3001 (host
2018
+ 3001 -> jail 3001) or 8080:3001 (host 8080 -> jail 3001). Omit to be
2019
+ shown the container's open ports and prompted (incl. a different
2020
+ host port). An explicit port may be one not open yet.
2021
+ --bind passed through to netcage (127.0.0.1 default, or 0.0.0.0 for LAN).
2022
+ -m the machine the container runs on (else the default machine).
2023
+
2024
+ Wraps \`netcage forward\` (netcage >= 0.9.0). If several containers match, you pick
2025
+ one (each row shows its open in-jail ports). The forward runs until Ctrl-C.
2026
+ `;
2027
+
2028
+ /** The `ports` subcommand help. */
2029
+ const PORTS_HELP = `anon-pi ports - list a running anon-pi container's open in-jail TCP listeners
2030
+
2031
+ USAGE
2032
+ anon-pi ports [<project>] [-m <machine>]
2033
+
2034
+ Wraps \`netcage ports --json\` (netcage >= 0.9.0), which reads the jail's
2035
+ /proc/net/tcp* image-independently (works even with no ss/netstat in the image).
2036
+ Use it to find which port to \`anon-pi forward\`.
2037
+ `;
2038
+
1984
2039
  /** The `machine` subcommand help. */
1985
2040
  const MACHINE_HELP = `anon-pi machine - manage machines (an image + a persistent host home)
1986
2041
 
@@ -2068,6 +2123,259 @@ function queryKeptContainers(): KeptContainer[] {
2068
2123
  return out;
2069
2124
  }
2070
2125
 
2126
+ // --- `forward` / `ports`: reach an in-jail server from the host --------------
2127
+
2128
+ /**
2129
+ * Query netcage for its RUNNING managed containers (no `-a`, so a stopped kept
2130
+ * container is excluded: forward/ports can only reach a live jail), surfacing
2131
+ * each one's stamped anon-pi key + a display name. Best-effort: [] on any
2132
+ * failure, so the caller reports "nothing running" cleanly.
2133
+ */
2134
+ function queryRunningContainers(): ManagedContainer[] {
2135
+ const res = spawnSync(
2136
+ 'netcage',
2137
+ [
2138
+ 'ps',
2139
+ '--filter',
2140
+ 'label=netcage.managed',
2141
+ '--format',
2142
+ '{{.ID}}\t{{.Names}}\t{{.Labels}}',
2143
+ ],
2144
+ {encoding: 'utf8'},
2145
+ );
2146
+ if (res.error || res.status !== 0 || !res.stdout) return [];
2147
+ const out: ManagedContainer[] = [];
2148
+ for (const line of res.stdout.split('\n')) {
2149
+ const trimmed = line.trim();
2150
+ if (trimmed === '') continue;
2151
+ const cols = trimmed.split('\t');
2152
+ if (cols.length < 3) continue;
2153
+ const ref = cols[0].trim();
2154
+ const name = cols[1].trim();
2155
+ const key = extractKeyLabel(cols.slice(2).join('\t'));
2156
+ if (ref !== '' && key !== undefined) {
2157
+ out.push({key, ref, name: name || ref});
2158
+ }
2159
+ }
2160
+ return out;
2161
+ }
2162
+
2163
+ /**
2164
+ * Best-effort: the in-jail TCP listeners of a container via `netcage ports
2165
+ * <ref> --json` (netcage >= 0.9.0). [] on any failure (older netcage without
2166
+ * the verb, a parse miss), so the port hint is purely additive and never blocks
2167
+ * a forward.
2168
+ */
2169
+ function queryNetcagePorts(ref: string): NetcageListener[] {
2170
+ const res = spawnSync('netcage', ['ports', ref, '--json'], {
2171
+ encoding: 'utf8',
2172
+ });
2173
+ if (res.error || res.status !== 0 || !res.stdout) return [];
2174
+ return parseNetcagePortsJson(res.stdout);
2175
+ }
2176
+
2177
+ /**
2178
+ * Resolve the ONE running anon-pi container a forward/ports should act on:
2179
+ * filter the running managed containers by machine (+ project if given), then
2180
+ * 0 => error (nothing running), 1 => it, many => an arrow-key picker annotated
2181
+ * with each container's open in-jail ports. Returns undefined on no-match or a
2182
+ * cancelled pick (with the reason printed). TTY is required only for the picker.
2183
+ */
2184
+ function resolveForwardTarget(
2185
+ machine: string,
2186
+ project: string | undefined,
2187
+ verb: string,
2188
+ ): ManagedContainer | undefined {
2189
+ const running = queryRunningContainers();
2190
+ const matches = resolveManagedMatches({
2191
+ containers: running,
2192
+ machine,
2193
+ project,
2194
+ });
2195
+ if (matches.length === 0) {
2196
+ const scope =
2197
+ project !== undefined
2198
+ ? `project ${JSON.stringify(project)} on machine ${JSON.stringify(machine)}`
2199
+ : `machine ${JSON.stringify(machine)}`;
2200
+ process.stderr.write(
2201
+ `anon-pi: no running anon-pi container for ${scope}. ` +
2202
+ `Start one first (e.g. \`anon-pi ${project ?? '<project>'}\`) and run its ` +
2203
+ `server, then \`anon-pi ${verb}\` again.\n`,
2204
+ );
2205
+ return undefined;
2206
+ }
2207
+ if (matches.length === 1) return matches[0];
2208
+
2209
+ // Many: pick one. Each row is annotated with the container's open ports (a
2210
+ // best-effort hint), so the user can tell the sessions apart.
2211
+ if (!process.stdin.isTTY) {
2212
+ process.stderr.write(
2213
+ `anon-pi: ${matches.length} running containers match; a terminal is needed to ` +
2214
+ `pick one. Narrow with a project (\`anon-pi ${verb} <project>\`) or -m <machine>.\n`,
2215
+ );
2216
+ return undefined;
2217
+ }
2218
+ const entries: MenuEntry[] = matches.map((c) => {
2219
+ const proj = keyProject(parseKeptKey(c.key));
2220
+ const label = proj === '' ? '(shell)' : proj;
2221
+ const hint = formatPortsHint(queryNetcagePorts(c.ref));
2222
+ return {
2223
+ kind: 'project',
2224
+ project: c.ref,
2225
+ label: `${label} [${c.name}] ${hint}`,
2226
+ };
2227
+ });
2228
+ const picked = select(entries, {
2229
+ header: `anon-pi: pick a container to ${verb} (\u2191/\u2193 move, Enter select, Ctrl-C quit)`,
2230
+ });
2231
+ if (picked === undefined) {
2232
+ process.stderr.write('anon-pi: cancelled; nothing forwarded.\n');
2233
+ return undefined;
2234
+ }
2235
+ return matches.find((c) => c.ref === picked.project);
2236
+ }
2237
+
2238
+ /**
2239
+ * `anon-pi forward [<project>] [--port <[hostPort:]jailPort>] [--bind <addr>]
2240
+ * [-m <machine>]`: open a host->jail port on a running anon-pi container. Wraps
2241
+ * `netcage forward`. When --port is omitted, it lists the container's open
2242
+ * in-jail ports and prompts for the jail port + an optional different host port.
2243
+ */
2244
+ function runForward(forwardArgs: string[]): number {
2245
+ if (forwardArgs.includes('--help') || forwardArgs.includes('-h')) {
2246
+ process.stdout.write(FORWARD_HELP);
2247
+ return 0;
2248
+ }
2249
+ if (!hasNetcage()) return netcageMissing();
2250
+
2251
+ let cmd;
2252
+ try {
2253
+ cmd = parseForwardArgs(forwardArgs);
2254
+ } catch (e) {
2255
+ return reportAnonPiError(e);
2256
+ }
2257
+
2258
+ const machine = resolveVerbMachine(cmd.machine, cmd.machineExplicit);
2259
+ const target = resolveForwardTarget(machine, cmd.project, 'forward');
2260
+ if (target === undefined) return 1;
2261
+
2262
+ // Resolve the port: --port wins; else prompt from the container's listeners.
2263
+ let portRaw: string;
2264
+ if (cmd.port !== undefined) {
2265
+ portRaw = cmd.port.raw;
2266
+ } else {
2267
+ const prompted = promptForwardPort(target.ref);
2268
+ if (prompted === undefined) return 1;
2269
+ portRaw = prompted;
2270
+ }
2271
+
2272
+ const argv = ['forward'];
2273
+ if (cmd.bind !== undefined) argv.push('--bind', cmd.bind);
2274
+ argv.push(target.ref, portRaw);
2275
+ process.stderr.write(
2276
+ `anon-pi: forwarding to ${target.name} (${portRaw}); Ctrl-C to stop\u2026\n`,
2277
+ );
2278
+ return spawnNetcage(argv);
2279
+ }
2280
+
2281
+ /**
2282
+ * Interactive port resolution when `--port` is omitted: show the container's
2283
+ * open in-jail listeners, prompt for the jail port (defaulting to the sole
2284
+ * obvious one), then ask whether to bind it on a DIFFERENT host port and let the
2285
+ * user type it. Returns the netcage port token (`<jail>` or `<host>:<jail>`), or
2286
+ * undefined on cancel / a bad entry (reported). Requires a TTY.
2287
+ */
2288
+ function promptForwardPort(ref: string): string | undefined {
2289
+ if (!process.stdin.isTTY) {
2290
+ process.stderr.write(
2291
+ 'anon-pi: no TTY. Pass the port explicitly, e.g. `anon-pi forward --port 3001`.\n',
2292
+ );
2293
+ return undefined;
2294
+ }
2295
+ const listeners = queryNetcagePorts(ref);
2296
+ const open = forwardablePorts(listeners);
2297
+ process.stderr.write(` in-jail listeners: ${formatPortsHint(listeners)}\n`);
2298
+
2299
+ const def = open.length === 1 ? String(open[0]) : undefined;
2300
+ const jailAns = promptLine(
2301
+ ` jail port to forward${def ? ` [${def}]` : ''}: `,
2302
+ );
2303
+ const jailStr =
2304
+ jailAns === undefined || jailAns.trim() === '' ? def : jailAns.trim();
2305
+ if (jailStr === undefined) {
2306
+ process.stderr.write('anon-pi: no port given; nothing forwarded.\n');
2307
+ return undefined;
2308
+ }
2309
+
2310
+ const hostAns = promptLine(
2311
+ ` host port (Enter for same as jail, or type a different one): `,
2312
+ );
2313
+ const hostStr = hostAns === undefined ? '' : hostAns.trim();
2314
+ const token = hostStr === '' ? jailStr : `${hostStr}:${jailStr}`;
2315
+ try {
2316
+ return parsePortArg(token).raw; // validate 1..65535 + shape
2317
+ } catch (e) {
2318
+ reportAnonPiError(e);
2319
+ return undefined;
2320
+ }
2321
+ }
2322
+
2323
+ /**
2324
+ * `anon-pi ports [<project>] [-m <machine>]`: list a running anon-pi container's
2325
+ * open in-jail TCP listeners (via `netcage ports --json`), image-independent.
2326
+ * Disambiguates the same way as forward.
2327
+ */
2328
+ function runPorts(portsArgs: string[]): number {
2329
+ if (portsArgs.includes('--help') || portsArgs.includes('-h')) {
2330
+ process.stdout.write(PORTS_HELP);
2331
+ return 0;
2332
+ }
2333
+ if (!hasNetcage()) return netcageMissing();
2334
+
2335
+ let cmd;
2336
+ try {
2337
+ cmd = parsePortsArgs(portsArgs);
2338
+ } catch (e) {
2339
+ return reportAnonPiError(e);
2340
+ }
2341
+
2342
+ const machine = resolveVerbMachine(cmd.machine, cmd.machineExplicit);
2343
+ const target = resolveForwardTarget(machine, cmd.project, 'ports');
2344
+ if (target === undefined) return 1;
2345
+
2346
+ const listeners = queryNetcagePorts(target.ref);
2347
+ if (listeners.length === 0) {
2348
+ process.stdout.write(
2349
+ `${target.name}: no in-jail TCP listeners detected ` +
2350
+ `(the server may not be up yet, or netcage < 0.9.0).\n`,
2351
+ );
2352
+ return 0;
2353
+ }
2354
+ process.stdout.write(`${target.name}: in-jail TCP listeners\n`);
2355
+ for (const l of listeners) {
2356
+ const scope = l.loopbackOnly ? 'loopback' : 'all-interfaces';
2357
+ const note = l.port === 53 && l.loopbackOnly ? ' (netcage DNS)' : '';
2358
+ process.stdout.write(` ${l.address}:${l.port}\t${scope}${note}\n`);
2359
+ }
2360
+ return 0;
2361
+ }
2362
+
2363
+ /** Resolve the machine a verb targets: explicit -m wins, else config.defaultMachine. */
2364
+ function resolveVerbMachine(machine: string, explicit: boolean): string {
2365
+ if (explicit) return machine;
2366
+ const config = readJsonConfig(envFromProcess(process.env));
2367
+ return config.defaultMachine ?? DEFAULT_MACHINE;
2368
+ }
2369
+
2370
+ /** The shared "netcage not on PATH" error (exit 1). */
2371
+ function netcageMissing(): number {
2372
+ process.stderr.write(
2373
+ 'anon-pi: `netcage` not found on PATH. anon-pi is a launcher for netcage; install it first\n' +
2374
+ '(https://github.com/wighawag/netcage). Linux only.\n',
2375
+ );
2376
+ return 1;
2377
+ }
2378
+
2071
2379
  /**
2072
2380
  * Pull the anon-pi key out of a podman `{{.Labels}}` rendering (a
2073
2381
  * comma-separated `k=v` list). The key is stamped as `anon-pi.key=<opaque>`;