@ornncompute/cli 1.0.133 → 1.0.135

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/README.md CHANGED
@@ -478,5 +478,6 @@ see [the service contract](../../services/orchestrator/README.md#file-upload-rep
478
478
  Staff can use `ornn admin ssh <node-id>` for certificate access on nodes that trust
479
479
  the environment's SSH CA. It requires an interactive staff login, creates a temporary
480
480
  local key, obtains a 15-minute certificate through the gateway and launches
481
- OpenSSH with normal host-key verification. Temporary files are removed when
482
- the command exits. Shared and per-node key access remain available.
481
+ OpenSSH with normal host-key verification. Nodes configured for the staff SSH
482
+ relay connect through Orchestrator automatically. Temporary files are removed
483
+ when the command exits.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ornncompute/cli",
3
- "version": "1.0.133",
3
+ "version": "1.0.135",
4
4
  "description": "Command-line interface for Ornn compute access workflows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,7 +44,8 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "picocolors": "^1.1.1",
47
- "zod": "^4.6.7",
48
- "update-notifier": "7.3.1"
47
+ "update-notifier": "7.3.1",
48
+ "ws": "8.22.1",
49
+ "zod": "^4.6.7"
49
50
  }
50
51
  }
@@ -58,5 +58,3 @@ export function preflightAdminCommand(catalog, argv, runtime) {
58
58
  }
59
59
  return { capabilityId: capability.id, command: command.join(" "), parsed };
60
60
  }
61
-
62
- export const adminCommandHandlers = handlers;
@@ -0,0 +1,49 @@
1
+ import { once } from "node:events";
2
+ import { createServer } from "node:net";
3
+ import WebSocket, { createWebSocketStream } from "ws";
4
+ import { loadRequiredSession, resolveApiBaseUrl, userCredentialHeaders } from "./api-client.mjs";
5
+
6
+ export async function openSshRelay(nodeId, { env, signal }) {
7
+ const session = await loadRequiredSession({ env });
8
+ const url = new URL(
9
+ `/v1/orchestrator/nodes/${encodeURIComponent(nodeId)}/ssh-relay`,
10
+ resolveApiBaseUrl({ env, session })
11
+ );
12
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
13
+ signal.throwIfAborted();
14
+ const ws = new WebSocket(url, {
15
+ headers: userCredentialHeaders(session),
16
+ handshakeTimeout: 12_000,
17
+ maxPayload: 128 * 1024,
18
+ });
19
+ const stream = createWebSocketStream(ws);
20
+ let client;
21
+ const server = createServer((socket) => {
22
+ if (client || stream.destroyed) {
23
+ socket.destroy();
24
+ return;
25
+ }
26
+ client = socket;
27
+ server.close();
28
+ socket.on("error", close);
29
+ socket.on("close", close);
30
+ socket.pipe(stream).pipe(socket);
31
+ });
32
+ function close() {
33
+ if (server.listening) server.close();
34
+ client?.destroy();
35
+ stream.destroy();
36
+ }
37
+ stream.on("error", close);
38
+ stream.on("close", close);
39
+ try {
40
+ await once(ws, "open", { signal });
41
+ server.listen({ port: 0, host: "127.0.0.1", signal });
42
+ await once(server, "listening", { signal });
43
+ if (stream.destroyed) throw new Error("The SSH relay closed before connecting.");
44
+ return { port: server.address().port, close };
45
+ } catch (error) {
46
+ close();
47
+ throw new Error(`Could not open the SSH relay. ${error.message}`, { cause: error });
48
+ }
49
+ }
package/src/admin-ssh.mjs CHANGED
@@ -4,6 +4,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { constants, tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { ADMIN_MUTATION_SAFETY } from "./admin-mutations.mjs";
7
+ import { openSshRelay } from "./admin-ssh-relay.mjs";
7
8
  import { forwardSignals } from "./signals.mjs";
8
9
 
9
10
  function run(program, args, signal) {
@@ -20,7 +21,10 @@ function run(program, args, signal) {
20
21
  });
21
22
  }
22
23
 
23
- export async function openAdminSsh({ input, fetch, execute, runtime }, { runProcess = run } = {}) {
24
+ export async function openAdminSsh(
25
+ { input, fetch, execute, runtime },
26
+ { runProcess = run, openRelay = openSshRelay } = {}
27
+ ) {
24
28
  // Read the existing node route; issuance returns only its own certificate.
25
29
  const node = await orchestrator.getNode(fetch, input.node_id);
26
30
  const host = node.ip;
@@ -41,6 +45,7 @@ export async function openAdminSsh({ input, fetch, execute, runtime }, { runProc
41
45
  : controller.signal;
42
46
  let interrupted;
43
47
  let directory;
48
+ let relay;
44
49
  const removeSignals = forwardSignals(process, null, (received) => {
45
50
  interrupted ??= received;
46
51
  controller.abort();
@@ -66,6 +71,21 @@ export async function openAdminSsh({ input, fetch, execute, runtime }, { runProc
66
71
  )
67
72
  throw new Error("The issuer returned an invalid SSH certificate.");
68
73
  await writeFile(`${key}-cert.pub`, `${issued.certificate}\n`, { mode: 0o600 });
74
+ if (node.ssh_relay_configured === true) {
75
+ relay = await openRelay(input.node_id, { env: runtime.env, signal });
76
+ }
77
+ const relayOptions = relay
78
+ ? [
79
+ "-o",
80
+ "HostName=127.0.0.1",
81
+ "-o",
82
+ `HostKeyAlias=${port === 22 ? host : `[${host}]:${port}`}`,
83
+ "-o",
84
+ "ProxyCommand=none",
85
+ "-o",
86
+ "ControlPath=none",
87
+ ]
88
+ : [];
69
89
  const exitCode = await runProcess(
70
90
  "ssh",
71
91
  [
@@ -76,7 +96,8 @@ export async function openAdminSsh({ input, fetch, execute, runtime }, { runProc
76
96
  "-i",
77
97
  key,
78
98
  "-p",
79
- String(port),
99
+ String(relay?.port ?? port),
100
+ ...relayOptions,
80
101
  "-l",
81
102
  "admin",
82
103
  host,
@@ -90,6 +111,7 @@ export async function openAdminSsh({ input, fetch, execute, runtime }, { runProc
90
111
  return { suppressResult: true, exitCode: 128 + constants.signals[interrupted] };
91
112
  } finally {
92
113
  try {
114
+ relay?.close();
93
115
  if (directory) await rm(directory, { recursive: true, force: true });
94
116
  } finally {
95
117
  removeSignals();
@@ -291,11 +291,7 @@ export async function operatorRequest({
291
291
  });
292
292
  }
293
293
 
294
- export function computeEndpoint(path) {
295
- return `/v1/workflows/compute${normalizeComputePath(path)}`;
296
- }
297
-
298
- export function normalizeComputePath(path) {
294
+ function normalizeComputePath(path) {
299
295
  if (!path || typeof path !== "string") {
300
296
  throw new CliApiError("API path is required.");
301
297
  }
@@ -277,8 +277,6 @@ export function createSlurmConnectModeHandler({ spawnProcess = spawnProcessDefau
277
277
  };
278
278
  }
279
279
 
280
- export const slurmConnectModeHandler = createSlurmConnectModeHandler();
281
-
282
280
  export const slurmLaunchModeHandler = {
283
281
  mode: "slurm",
284
282
  launch: (state) => launchClusterMode(state, "slurm"),