main
py 711 lines 23 KB
Raw
1 # Copyright 2026 Google LLC
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 """Connect to a Colab runtime through ssh.
16
17 Modes:
18
19 * ``colab ssh`` use your only active session (or auto-create
20 one) and open an interactive shell in
21 ``/content``.
22 * ``colab ssh -s SESSION`` same, targeting SESSION explicitly.
23 * ``colab ssh --proxy-mode -s S`` act as an OpenSSH ProxyCommand-compatible
24 WebSocket-stdio bridge for ``~/.ssh/config``.
25
26 Every ``colab ssh`` flag also works in ``--proxy-mode``: with ``-s NAME`` the
27 session is created if it does not exist (so a config host works on first
28 connect), ``--gpu/--tpu`` pick the accelerator for that auto-created runtime,
29 and ``--rm`` stops the runtime when you disconnect.
30
31 ``--identity/-i`` overrides the default key order (``~/.ssh/id_ed25519`` ->
32 ``id_ecdsa``); the public key is derived via ``ssh-keygen -y -f`` and sent in
33 the ``X-Colab-Ssh-Pubkey`` header. RSA keys are rejected by the server, so
34 ``id_rsa`` is not auto-selected.
35 """
36
37 import contextlib
38 import os
39 from pathlib import Path
40 import select
41 import shlex
42 import signal
43 import subprocess
44 import sys
45 import threading
46 from typing import Callable, Optional
47 from urllib.parse import urlparse
48 import uuid
49
50 from colab_cli.state import SessionState
51 import typer
52 from typing_extensions import Annotated
53 import websocket
54
55 _SSH_PATH = "/colab/ssh"
56 # ssh-rsa keys are rejected server-side, so they are not auto-selected.
57 _KEY_TYPES = ["id_ed25519.pub", "id_ecdsa.pub"]
58 _PUBKEY_HEADER = "X-Colab-Ssh-Pubkey"
59 _SSH_HOST = "root@colab-runtime"
60 # Colab's standard working directory; land here instead of root's home.
61 _DEFAULT_REMOTE_DIR = "/content"
62
63
64 def _pubkey_from_identity(identity: str) -> str:
65 """Derives the public key from a private key via ``ssh-keygen -y -f``.
66
67 Args:
68 identity: Path to the private key (``~`` is expanded).
69
70 Returns:
71 The public key text.
72
73 Raises:
74 typer.Exit: If the file is missing, ssh-keygen fails, or it yields no key
75 (exit code 2).
76 """
77 identity = os.path.expanduser(identity)
78 if not os.path.exists(identity):
79 typer.echo(f"[colab] --identity {identity}: file not found.", err=True)
80 raise typer.Exit(code=2)
81 try:
82 res = subprocess.run(
83 ["ssh-keygen", "-y", "-f", identity],
84 check=True,
85 capture_output=True,
86 text=True,
87 )
88 except (subprocess.CalledProcessError, FileNotFoundError) as e:
89 typer.echo(
90 f"[colab] failed to derive public key from {identity}: {e}",
91 err=True,
92 )
93 raise typer.Exit(code=2)
94 pubkey = res.stdout.strip()
95 if not pubkey:
96 typer.echo(
97 f"[colab] ssh-keygen produced no key for {identity}.", err=True
98 )
99 raise typer.Exit(code=2)
100 return pubkey
101
102
103 def _resolve_pubkey(identity: Optional[str]) -> str:
104 """Returns the public key for the ``X-Colab-Ssh-Pubkey`` header.
105
106 With ``identity``, derives it from that private key; otherwise scans
107 ``~/.ssh`` for the first existing ``id_<type>.pub`` in preference order.
108
109 Args:
110 identity: Path to a private key, or None to scan ``~/.ssh``.
111
112 Returns:
113 The public key text to send verbatim in the header.
114
115 Raises:
116 typer.Exit: If no usable key is found (exit code 2).
117 """
118 if identity:
119 return _pubkey_from_identity(identity)
120
121 ssh_dir = Path(os.path.expanduser("~/.ssh"))
122 for name in _KEY_TYPES:
123 candidate = ssh_dir / name
124 if candidate.exists():
125 return candidate.read_text().strip()
126 typer.echo(
127 "[colab] no SSH public key found in ~/.ssh/. Run "
128 "`ssh-keygen -t ed25519` to generate one, or pass --identity.",
129 err=True,
130 )
131 raise typer.Exit(code=2)
132
133
134 def _resolve_session(name: Optional[str]) -> SessionState:
135 """Resolves the named session, or exits with an actionable message.
136
137 Args:
138 name: The session name, or None to use the single active session.
139
140 Returns:
141 The resolved ``SessionState``.
142
143 Raises:
144 typer.Exit: If the session cannot be resolved (exit code 2).
145 """
146 from colab_cli.common import state
147
148 resolved = state.resolve_session(name)
149 s = state.store.get(resolved)
150 if not s:
151 typer.echo(
152 f"[colab] session '{resolved}' not found. "
153 "Run `colab sessions` to list active sessions.",
154 err=True,
155 )
156 raise typer.Exit(code=2)
157 return s
158
159
160 def _session_exists(name: str) -> bool:
161 """True if a session with this exact name is in the local store."""
162 from colab_cli.common import state
163
164 return state.store.get(name) is not None
165
166
167 def _has_local_sessions() -> bool:
168 """True if the local store has any session.
169
170 Gates bare ``colab ssh`` auto-create: we create only when there are zero
171 sessions, matching ``state.resolve_session`` (which errors on an empty
172 store).
173 """
174 from colab_cli.common import state
175
176 return bool(state.store.list())
177
178
179 def _auto_create_session(
180 gpu: Optional[str],
181 tpu: Optional[str],
182 name: Optional[str] = None,
183 *,
184 high_mem: bool = False,
185 ) -> SessionState:
186 """Creates a runtime via ``colab new`` and returns its session.
187
188 Reuses ``colab new``'s creation path (assignment, keep-alive daemon, scope
189 pre-flight) verbatim so the two commands cannot drift.
190
191 Args:
192 gpu: GPU accelerator to request, or None for CPU.
193 tpu: TPU accelerator to request, or None for CPU.
194 name: Session name to pin; a random one is generated when omitted.
195
196 Returns:
197 The newly created ``SessionState``.
198 """
199 from colab_cli.commands import session as session_cmd
200
201 name = name or uuid.uuid4().hex[:6]
202 typer.echo(f"[colab] Creating runtime '{name}'...")
203 session_cmd.new(session=name, gpu=gpu, tpu=tpu, high_mem=high_mem)
204 return _resolve_session(name)
205
206
207 def _stop_session(name: str) -> None:
208 """Best-effort ``colab stop`` for a session (used by ``--rm``)."""
209 from colab_cli.commands import session as session_cmd
210
211 try:
212 session_cmd.stop(session=name)
213 except typer.Exit:
214 raise
215 except Exception as e: # Cleanup must not mask the shell's own exit.
216 typer.echo(f"[colab] --rm: failed to stop '{name}': {e}", err=True)
217
218
219 def _build_ws_url(session: SessionState) -> str:
220 """Builds the WebSocket URL for the session's SSH endpoint."""
221 parsed = urlparse(session.url)
222 scheme = "wss" if parsed.scheme == "https" else "ws"
223 return (
224 f"{scheme}://{parsed.netloc}{_SSH_PATH}"
225 f"?colab-runtime-proxy-token={session.token}"
226 )
227
228
229 def _explain_handshake_failure(status: Optional[int], body: bytes) -> str:
230 """Maps an upgrade-handshake status/body to an actionable message.
231
232 Args:
233 status: The HTTP status of the failed upgrade, or None if there was no
234 HTTP status (e.g. a network error).
235 body: The raw response body, used to refine the 400 message.
236
237 Returns:
238 A human-readable, actionable error message.
239 """
240 snippet = body.decode("utf-8", errors="replace").strip()[:200]
241 match status:
242 case 400 if "missing pubkey" in snippet:
243 message = (
244 "Server rejected request: missing pubkey header. This is "
245 "likely a CLI bug; please file a colab-cli issue."
246 )
247 case 400 if "unsupported key type" in snippet:
248 message = (
249 "Server rejected pubkey: unsupported key type. Accepted "
250 "key types: ssh-ed25519, ecdsa-sha2-nistp{256,384,521}. RSA "
251 "keys (ssh-rsa) are NOT accepted. Generate an Ed25519 key "
252 "with `ssh-keygen -t ed25519` and re-run (optionally with "
253 "--identity)."
254 )
255 case 400:
256 message = (
257 f"Server rejected pubkey (HTTP 400): {snippet}. "
258 "Re-check your key with `ssh-keygen -y -f <key>`."
259 )
260 case 401:
261 message = (
262 "Authentication failed (HTTP 401): the runtime-proxy token "
263 "is invalid. The session may have expired - try `colab new`."
264 )
265 case 403:
266 message = (
267 "Forbidden (HTTP 403): the server refused this request; the "
268 "runtime-proxy token may lack permission for this action. (A "
269 "runtime without SSH enabled returns 404, not 403.)"
270 )
271 case 404:
272 message = (
273 "Endpoint not found (HTTP 404): this runtime does not expose "
274 "the /colab/ssh endpoint. SSH is enabled at runtime creation, "
275 "so an older or non-SSH runtime will not have it - run "
276 "`colab new` for a fresh runtime with SSH."
277 )
278 case 429:
279 message = (
280 "Already-active SSH session (HTTP 429): another `colab ssh` "
281 "is connected to this runtime. Disconnect it and retry."
282 )
283 case 502:
284 message = (
285 "Bad gateway (HTTP 502): the runtime's local sshd is "
286 "unreachable. The runtime may be unhealthy; try `colab "
287 "status`, then `colab stop` + `colab new`."
288 )
289 case None:
290 message = (
291 "WebSocket upgrade failed without an HTTP status: "
292 f"{snippet}. Check your network."
293 )
294 case _:
295 message = f"WebSocket upgrade rejected (HTTP {status}): {snippet}"
296 return message
297
298
299 def _connect_websocket(url: str, pubkey: str) -> websocket.WebSocket:
300 """Opens the WebSocket, mapping handshake failures to messages.
301
302 Args:
303 url: The ``wss://.../colab/ssh`` URL to connect to.
304 pubkey: Public key to send in the ``X-Colab-Ssh-Pubkey`` header.
305
306 Returns:
307 A connected ``websocket.WebSocket``.
308
309 Raises:
310 typer.Exit: On any handshake or connection failure (exit code 1), after
311 printing an actionable message.
312 """
313 ws = websocket.WebSocket()
314 try:
315 ws.connect(url, header=[f"{_PUBKEY_HEADER}: {pubkey}"])
316 return ws
317 except websocket.WebSocketBadStatusException as e:
318 status = getattr(e, "status_code", None)
319 body = getattr(e, "resp_body", b"") or b""
320 if isinstance(body, str):
321 body = body.encode("utf-8", errors="replace")
322 msg = _explain_handshake_failure(status, body)
323 typer.echo(f"[colab] {msg}", err=True)
324 raise typer.Exit(code=1)
325 except (
326 websocket.WebSocketAddressException,
327 websocket.WebSocketTimeoutException,
328 ConnectionRefusedError,
329 OSError,
330 ) as e:
331 typer.echo(
332 f"[colab] WebSocket connection failed: {e}. Check your network "
333 "and that the runtime is healthy (`colab status`).",
334 err=True,
335 )
336 raise typer.Exit(code=1)
337
338
339 def _close_quietly(ws: websocket.WebSocket) -> None:
340 """Closes a WebSocket, ignoring any error (best-effort teardown)."""
341 try:
342 ws.close()
343 except Exception: # Best-effort close.
344 pass
345
346
347 _DATA_OPCODES = (websocket.ABNF.OPCODE_BINARY, websocket.ABNF.OPCODE_TEXT)
348
349
350 def _bridge_proxy_mode(ws: websocket.WebSocket) -> int:
351 """Bridges the WebSocket <-> stdin/stdout as an OpenSSH ProxyCommand.
352
353 Args:
354 ws: The connected WebSocket to bridge.
355
356 Returns:
357 0 when either side closes.
358 """
359 stdin_fd = sys.stdin.buffer.fileno()
360
361 def stdin_to_ws():
362 try:
363 while True:
364 ready, _, _ = select.select([stdin_fd], [], [], None)
365 if not ready:
366 continue
367 data = os.read(stdin_fd, 8192)
368 if not data:
369 break
370 ws.send_binary(data)
371 except (OSError, websocket.WebSocketException):
372 pass
373 finally:
374 _close_quietly(ws)
375
376 threading.Thread(target=stdin_to_ws, daemon=True).start()
377
378 try:
379 while True:
380 opcode, frame = ws.recv_data(control_frame=True)
381 if opcode == websocket.ABNF.OPCODE_CLOSE:
382 break
383 if opcode not in _DATA_OPCODES:
384 continue
385 if isinstance(frame, str):
386 frame = frame.encode("utf-8")
387 sys.stdout.buffer.write(frame)
388 sys.stdout.buffer.flush()
389 except (websocket.WebSocketException, OSError):
390 pass
391 finally:
392 _close_quietly(ws)
393 return 0
394
395
396 def _proxy_command(session: SessionState, identity: Optional[str]) -> str:
397 """Builds the OpenSSH ProxyCommand that bridges this session's WebSocket.
398
399 Re-invoked by the interactive shell in ``--proxy-mode``. The session
400 already exists by then, so only ``-s NAME`` (plus identity) is needed.
401 """
402 self_cmd = [
403 sys.executable,
404 "-m",
405 "colab_cli.cli",
406 "ssh",
407 "--proxy-mode",
408 "-s",
409 session.name,
410 ]
411 if identity:
412 self_cmd.extend(["--identity", identity])
413 return shlex.join(self_cmd)
414
415
416 def _ssh_base_args(proxy_command: str, identity: Optional[str]) -> list[str]:
417 """Builds the shared ``ssh`` invocation (ProxyCommand + hardening)."""
418 args = [
419 "ssh",
420 "-o",
421 f"ProxyCommand={proxy_command}",
422 "-o",
423 "StrictHostKeyChecking=no",
424 "-o",
425 "UserKnownHostsFile=/dev/null",
426 "-o",
427 "LogLevel=ERROR",
428 ]
429 if identity:
430 args.extend(["-i", os.path.expanduser(identity)])
431 return args
432
433
434 def _run_interactive_ssh(session: SessionState, identity: Optional[str]) -> int:
435 """Spawns an interactive ``ssh`` that uses this CLI as its ProxyCommand.
436
437 The subprocess connects to the abstract host ``colab-runtime``; its
438 ProxyCommand re-invokes ``colab ssh --proxy-mode`` to bridge the WebSocket.
439
440 A remote command ``cd``s into ``/content`` (Colab's working dir) and then
441 execs the login shell, so the user lands where their notebooks/uploads live
442 rather than in root's home. ``-t`` forces a PTY (required once a remote
443 command is present) so the exec'd shell is interactive; a missing
444 ``/content`` is tolerated (stderr suppressed, the shell still starts).
445
446 Args:
447 session: The session to connect to.
448 identity: Optional private key path forwarded to ``ssh``.
449
450 Returns:
451 The exit code of the ``ssh`` subprocess.
452 """
453 ssh_args = _ssh_base_args(_proxy_command(session, identity), identity)
454 ssh_args.append("-t")
455 ssh_args.append(_SSH_HOST)
456 ssh_args.append(
457 f"cd {_DEFAULT_REMOTE_DIR} 2>/dev/null; exec ${{SHELL:-/bin/bash}} -l"
458 )
459 return subprocess.call(ssh_args)
460
461
462 def _select_proxy_session(
463 session: Optional[str],
464 gpu: Optional[str],
465 tpu: Optional[str],
466 *,
467 high_mem: bool = False,
468 ) -> tuple[SessionState, bool]:
469 """Resolves (or creates) the session for ``--proxy-mode``.
470
471 With ``-s NAME`` and no such session, creates it -- routing creation
472 output to stderr so stdout stays the clean ssh byte stream -- so a
473 ``~/.ssh/config`` host works on first connect. Otherwise resolves the
474 named (or single active) session.
475
476 Args:
477 session: The requested session name, or None.
478 gpu: GPU accelerator for an auto-created runtime.
479 tpu: TPU accelerator for an auto-created runtime.
480
481 Returns:
482 A ``(session_state, created)`` pair.
483 """
484 if session and not _session_exists(session):
485 with contextlib.redirect_stdout(sys.stderr):
486 return _auto_create_session(
487 gpu, tpu, name=session, high_mem=high_mem
488 ), True
489 return _resolve_session(session), False
490
491
492 def _select_interactive_session(
493 session: Optional[str],
494 gpu: Optional[str],
495 tpu: Optional[str],
496 *,
497 high_mem: bool = False,
498 ) -> tuple[SessionState, bool]:
499 """Resolves (or auto-creates) the session for an interactive shell.
500
501 Bare ``colab ssh`` with an empty store auto-creates a runtime (like
502 ``colab new``); otherwise the named or single active session is resolved.
503
504 Args:
505 session: The requested session name, or None.
506 gpu: GPU accelerator for an auto-created runtime.
507 tpu: TPU accelerator for an auto-created runtime.
508
509 Returns:
510 A ``(session_state, created)`` pair.
511 """
512 if not session and not _has_local_sessions():
513 return _auto_create_session(gpu, tpu, high_mem=high_mem), True
514 return _resolve_session(session), False
515
516
517 def _warn_accelerator_ignored(
518 gpu: Optional[str],
519 tpu: Optional[str],
520 created: bool,
521 *,
522 high_mem: bool = False,
523 ) -> None:
524 """Warns that ``--gpu/--tpu/--high-mem`` are no-ops when no runtime was created."""
525 if (gpu or tpu or high_mem) and not created:
526 typer.echo(
527 "[colab] --gpu/--tpu/--high-mem ignored: only applies to a "
528 "created runtime.",
529 err=True,
530 )
531
532
533 def _install_rm_signal_handlers(do_rm: Callable[[], None]) -> None:
534 """Routes terminating signals to ``do_rm`` then a clean exit.
535
536 OpenSSH ends a ProxyCommand on disconnect by sending SIGHUP (not just
537 stdin EOF); Python's default SIGHUP action would terminate us WITHOUT
538 running teardown, leaking the runtime and its keep-alive daemon. Convert
539 SIGHUP/SIGTERM/SIGINT into ``do_rm`` + ``os._exit`` so ``--rm`` teardown
540 always runs.
541
542 Args:
543 do_rm: Idempotent teardown callback to run before exiting.
544 """
545
546 def _on_signal(signum, frame):
547 do_rm()
548 os._exit(0)
549
550 for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGINT):
551 try:
552 signal.signal(sig, _on_signal)
553 except (ValueError, OSError):
554 pass # e.g. not running in the main thread
555
556
557 def _run_proxy_bridge(
558 s: SessionState, identity: Optional[str], rm: bool
559 ) -> int:
560 """Runs the ``--proxy-mode`` WebSocket-stdio bridge, honoring ``--rm``.
561
562 Args:
563 s: The session to bridge.
564 identity: Optional private key path for the pubkey header.
565 rm: If True, stop the session when the bridge closes or a terminating
566 signal arrives.
567
568 Returns:
569 The bridge exit code.
570 """
571 # --rm teardown must be idempotent: it can fire from either a terminating
572 # signal (how OpenSSH ends a ProxyCommand) or the `finally` clean-close
573 # path. Output goes to stderr because our stdout is the ssh byte stream.
574 done = {"stopped": False}
575
576 def _do_rm() -> None:
577 if rm and not done["stopped"]:
578 done["stopped"] = True
579 with contextlib.redirect_stdout(sys.stderr):
580 _stop_session(s.name)
581
582 if rm:
583 _install_rm_signal_handlers(_do_rm)
584
585 pubkey = _resolve_pubkey(identity)
586 ws = _connect_websocket(_build_ws_url(s), pubkey)
587 try:
588 return _bridge_proxy_mode(ws)
589 finally:
590 _do_rm()
591
592
593 def _run_interactive_shell(
594 s: SessionState, identity: Optional[str], created: bool, rm: bool
595 ) -> int:
596 """Runs the interactive ssh shell, honoring ``--rm`` on exit.
597
598 Args:
599 s: The session to connect to.
600 identity: Optional private key path forwarded to ssh.
601 created: Whether this command auto-created the runtime.
602 rm: If True, stop an auto-created runtime on exit.
603
604 Returns:
605 The exit code of the ssh subprocess.
606 """
607 if rm and not created:
608 typer.echo(
609 "[colab] --rm ignored: only a runtime auto-created by `colab ssh` "
610 "is removed on exit.",
611 err=True,
612 )
613 try:
614 return _run_interactive_ssh(s, identity)
615 finally:
616 if created and rm:
617 _stop_session(s.name)
618
619
620 def ssh(
621 session: Annotated[
622 Optional[str], typer.Option("-s", "--session", help="Session name")
623 ] = None,
624 proxy_mode: Annotated[
625 bool,
626 typer.Option(
627 "--proxy-mode",
628 help=(
629 "Act as an OpenSSH ProxyCommand-compatible WebSocket-stdio "
630 "bridge (reads stdin, writes stdout). Use in ~/.ssh/config "
631 "as `ProxyCommand colab ssh --proxy-mode -s SESS`. All flags "
632 "below also apply here."
633 ),
634 ),
635 ] = False,
636 identity: Annotated[
637 Optional[str],
638 typer.Option(
639 "--identity",
640 "-i",
641 help=(
642 "SSH private key whose public key is sent in the "
643 "X-Colab-Ssh-Pubkey header (default: first of "
644 "~/.ssh/id_ed25519, id_ecdsa)."
645 ),
646 ),
647 ] = None,
648 gpu: Annotated[
649 Optional[str],
650 typer.Option(
651 "--gpu",
652 help=(
653 "GPU accelerator for a runtime created by this command "
654 "(T4, L4, G4, H100, A100). Used when the session is "
655 "auto-created."
656 ),
657 ),
658 ] = None,
659 tpu: Annotated[
660 Optional[str],
661 typer.Option(
662 "--tpu",
663 help=(
664 "TPU accelerator for a runtime created by this command "
665 "(v5e1, v6e1). Used when the session is auto-created."
666 ),
667 ),
668 ] = None,
669 high_mem: Annotated[
670 bool,
671 typer.Option(
672 "--high-mem",
673 help=(
674 "Request a high-RAM machine shape when this command "
675 "auto-creates a runtime."
676 ),
677 ),
678 ] = False,
679 rm: Annotated[
680 bool,
681 typer.Option(
682 "--rm",
683 help=(
684 "Stop the runtime when the session ends. In interactive "
685 "mode this applies only to a runtime `colab ssh` "
686 "auto-created; in --proxy-mode it stops the bridged session "
687 "on disconnect (ephemeral ~/.ssh/config host)."
688 ),
689 ),
690 ] = False,
691 ):
692 """Connect to a Colab runtime via SSH.
693
694 Bare ``colab ssh`` uses your only active session, or auto-creates one (like
695 ``colab new``) if you have none, and opens a shell in ``/content``. With
696 --proxy-mode it is a ProxyCommand-compatible WebSocket-stdio bridge; every
697 flag above still applies (``-s NAME`` creates the session if missing,
698 ``--gpu/--tpu`` set its accelerator, ``--rm`` stops it on disconnect).
699 """
700 if proxy_mode:
701 s, created = _select_proxy_session(session, gpu, tpu, high_mem=high_mem)
702 _warn_accelerator_ignored(gpu, tpu, created, high_mem=high_mem)
703 raise typer.Exit(code=_run_proxy_bridge(s, identity, rm))
704
705 s, created = _select_interactive_session(session, gpu, tpu, high_mem=high_mem)
706 _warn_accelerator_ignored(gpu, tpu, created, high_mem=high_mem)
707 raise typer.Exit(code=_run_interactive_shell(s, identity, created, rm))
708
709
710 def register(app: typer.Typer):
711 app.command()(ssh)