Skip to content

Latest commit

 

History

History
236 lines (199 loc) · 13.4 KB

ssh.md

File metadata and controls

236 lines (199 loc) · 13.4 KB

Guest SSH control

SSH starts disabled. The Rust runtime exposes LiteBox::ssh() to configure, query, or disable it, for local boxes and for REST-backed boxes served by boxlite serve (HTTP control) or the hosted cloud API. Only configure() follows the implicit-start policy of exec, metrics, and file copying. A Configured or Stopped box using the image's default command can start implicitly. If BoxOptions.cmd or entrypoint explicitly sets the main command, configuration returns InvalidState in these states; call start() first. On a REST server, a stopped box with auto_resume=false is refused instead of resumed.

status() and disable() never start the VM or container main command. Valid Configured or Stopped handles return disabled status with generation zero and empty address and host identity fields, regardless of the configured command. Running boxes send requests to the existing guest, including after attach() before start() and after runtime recovery. Other lifecycle states return InvalidState. Results describe the state observed by the call; a concurrent configure() can subsequently enable SSH.

Creating the handle alone does not start anything. Deployments without SSH control answer 404 or reject with Unsupported; there is no capability negotiation. See the SDK API references for language-specific control APIs.

SSH does not publish a host port; forward one with boxlite ssh forward or other network forwarding. For component diagrams and implementation details, see Guest SSH architecture.

use boxlite::{SshAccount, SshConfig};

let ssh = sandbox.ssh();
let status = ssh.configure(SshConfig {
    listen_address: "0.0.0.0:2222".into(),
    host_private_key: std::fs::read_to_string("host_key")?,
    accounts: vec![SshAccount {
        login: "alice".into(),
        authorized_keys: vec![std::fs::read_to_string("alice.pub")?],
        ca: None,
    }],
}).await?;
println!("{} {}", status.host_public_key, status.host_key_fingerprint);
let current = ssh.status().await?;
ssh.disable().await?;

SshHandle owns its backend reference and can outlive the LiteBox borrow. A fresh handle to a running VM can query SSH without calling start() again. For a local box, after startup each operation has a total 15-second budget: the guest's 10-second cleanup limit plus 5 seconds for communication and scheduling. Obtaining the SSH interface, connection setup, queueing, and the RPC all consume this budget; VM and container startup time is excluded. Connection or queueing delays can leave less than 10 seconds for guest cleanup. Local runtime shutdown rejects new SSH operations and cancels interface acquisition and RPCs. In-progress VM initialization finishes before cancellation is returned, so detached boxes remain recoverable; container startup retains its own cancellation. Operations are not automatically retried. Timeout or cancellation does not undo changes the guest may already have applied. Invalidated handles return Stopped; drop all references to the old box and obtain a fresh handle with runtime.get(). That handle can query or disable SSH without restarting the box. A stopped box with an explicit main command still requires start() before configure().

CLI

boxlite ssh wraps the same handle for local and REST boxes; the CLI reference lists every flag.

boxlite ssh configure dev --ssh-config ssh.json          # complete SshConfig; prints SshStatus
boxlite ssh forward dev                                  # forwards 127.0.0.1:<random> to guest SSH
boxlite ssh disable dev

Configure SSH first, then use forward to open a local listener until Ctrl-C. For one account, configuration can instead read key files with boxlite ssh configure dev --host-key host_key --login alice --authorized-key alice.pub.

  • configure replaces the whole configuration once, which disconnects every SSH client, including those connected through another tunnel.
  • forward reads status and targets the port SSH actually bound, so a guest listener on port 0 works. It leaves configuration and generation unchanged. Ctrl-C ends only local forwarding; guest SSH stays enabled.
  • SSH lives in guest memory. A local CLI exit stops boxes that are not detached (plain boxlite run, SDK boxes with detach=false); a box such a command started loses SSH with its VM. Boxes from boxlite create and run -d keep running.
  • Over REST, forwarding opens the box's public tunnel for the SSH port and leaves it open, so anyone who reaches it can attempt a login and wake a stopped box; SSH keys remain the access control. Close it with boxlite network tunnel BOX PORT --revoke.

HTTP control with boxlite serve

The local server exposes the same handle at /v1/boxes/{box_id}/ssh. Against another compatible server the Rust client uses the existing path_prefix convention, /{prefix}/boxes/{box_id}/ssh:

Method Behavior
POST Replace the complete SshConfig body; returns the resulting SshStatus. Every accepted call fully restarts the guest listener, so never retry it automatically.
GET Read SshStatus. Never starts the VM or its main command.
DELETE Disable SSH idempotently; returns SshStatus. Never starts the VM or its main command.

The lifecycle and resume rules above also apply over HTTP. Authenticated POST requests count as AutoStop activity, including rejected configurations; GET, HEAD, and DELETE do not.

Send JSON with the fields defined in the OpenAPI contract. Unknown fields are rejected in the configuration, accounts, and CA objects. Malformed bodies return 400 invalid_argument with invalid SSH configuration, without echoing request values. Invalid configurations leave the running listener untouched. Other errors include 404 not_found, 409 invalid_state for lifecycle conflicts (409 conflict for AutoResume refusal), and 503 upstream_unavailable for guest RPC failures.

The Rust client's 300-second request timeout includes startup; a timeout does not undo configuration or stop work still running on the server. See the Rust SSH reference for types and API details.

Build the request body from local key files; do not commit it or paste a real private key into a document:

# ssh-config.json carries the SshConfig shape, built locally from your keys.
curl -fsS -X POST "$BOXLITE_URL/v1/boxes/$BOX_ID/ssh" \
  -H "Authorization: Bearer $BOXLITE_API_KEY" \
  -H 'Content-Type: application/json' \
  --data-binary @ssh-config.json

Hosted cloud API

The hosted API serves the same three operations at /v1/boxes/{box_id}/ssh and /v1/{prefix}/boxes/{box_id}/ssh, by box ID or name, within the caller's organization. The API checks policy and lifecycle; the box's runner validates the body and calls the guest once.

Method Permission Activity Lifecycle
POST write:boxes Counts after authorization and box lookup, including lifecycle refusals Running box: configured. stopped box: auto-resumed on its default command. 409 when the box is not ready and auto_resume=false, when it must start a box with an explicit cmd/entrypoint, or when it cannot resume (error, archived, …).
GET membership None started or stopped only; a stopped box reports disabled status with generation 0. Other states answer 409.
DELETE write:boxes None Same as GET; idempotent.

With auto-resume enabled, configuring SSH waits for a box already starting (creating, starting, restoring), even with an explicit cmd or entrypoint. You can request start, then configure SSH while that start completes.

Errors follow the HTTP control rules, with two differences:

  • The API parses JSON before proxying. Malformed JSON returns 400 Invalid JSON in request body. Repeated keys collapse to their last value before the runner sees them; case-variant and unknown fields, and null values where the schema requires a string or array, are still rejected. The optional ca may be null.
  • Lifecycle refusals from the API are a plain 409 with a message naming the reason; SDKs read them, like the runner's 409 invalid_state, as InvalidState.

SSH lives in guest memory, so every stop, restart, or auto-stop disables it. After the box runs again, run boxlite ssh configure to restore its configuration, then boxlite ssh forward to reconnect; forwarding fails on a disabled listener.

Forwarding uses the box's public tunnel, which needs inbound access enabled on the box. Ending the local boxlite ssh forward leaves that tunnel open; revoke it with boxlite network tunnel BOX PORT --revoke.

Guest protocol and lifecycle

The internal host-only boxlite.v1.Ssh gRPC service remains available on sockets/box.sock after Guest.Init succeeds. It exposes Configure, Status, and Disable; its schema is in src/shared/proto/boxlite/v1/service.proto. Raw protocol callers must regenerate their bindings and send SshConfigureRequest.config using field 4. Legacy string fields listen_address, ca_public_key, and principal (fields 1–3) are reserved and ignored. A legacy-only request returns InvalidArgument because config is missing, without changing the current service. There is no legacy request conversion or protocol version negotiation.

Configure accepts an unencrypted OpenSSH host private key and a non-empty accounts list. Each account has a unique login and at least one public key or CA configuration. Logins are 1–128 ASCII letters, digits, dots, underscores, or hyphens. Authentication selects only that login's credentials. A CA contains its public key and the required certificate principal; the principal need not equal the login. Certificate validity and permissions are checked as before; plain authorized keys allow PTY and forwarding. Each authorized_keys entry is one OpenSSH public key with an optional comment, without authorized_keys options.

The former global SshConfig.ca and SshConfig.authorized_keys fields (3 and 4) are reserved, including their names. Accounts use field 5. Regenerate bindings and migrate credentials into explicitly named accounts. An old configuration decodes without accounts and returns InvalidArgument, leaving the existing service running. There is no implicit root account.

Authentication accounts do not need entries in the container's /etc/passwd. For example, alice and bob can both execute as the container's default app user. Shell, exec, PTY, SFTP, and both directions of Unix socket forwarding inherit the UID/GID selected at container creation from the image USER or box user override. Accounts share that identity and its file permissions; they do not provide operating-system isolation from one another.

After entering the container, SSH resolves the actual execution UID and sets USER, LOGNAME, HOME, and SHELL. Client environment requests cannot override these values or select an execution identity. A numeric UID without a passwd entry uses its numeric name, / as home, and /bin/sh as shell. An empty passwd shell also uses /bin/sh; a configured shell that is missing fails explicitly. Shell and SFTP start in the user's home, falling back to / if it cannot be entered. Non-root SFTP and Unix socket helpers clear effective, permitted, inheritable, and ambient capabilities before serving; they retain the bounding set. TCP forwarding remains guest-side and uses the session's authorization.

Every valid Configure fully restarts SSH, even if the configuration is identical: validate → stop listener → disconnect all clients → wait for SSH execution and forwarding cleanup → bind new listener. Invalid configuration returns a sanitized InvalidArgument error and leaves the old service running. If stopping takes longer than ten seconds, the request returns DeadlineExceeded, which the Rust runtime preserves as BoxliteError::Rpc with the original gRPC status; cleanup remains tracked and a later Configure must finish draining it before starting another listener. A bind failure returns Unavailable and leaves SSH disabled, without restoring the old configuration. Disable performs the same drain and is idempotent.

Status contains only enabled, the actual bound listen_address, generation, a comment-free host_public_key, and its SHA-256 host_key_fingerprint. Generation increments on each successful listener start. Disabled status has empty address and host identity fields. No authentication configuration or private key is returned.

Configuration and status live only in guest memory. Reconnecting to the same VM preserves them. A VM restart starts disabled with generation zero and requires Configure again. Create, clone, export, and import do not carry SSH configuration or state. Old persisted SSH fields are ignored; existing databases and archives are not migrated or rewritten by this change.

SSH restart terminates only SSH work. The container's main process and non-SSH executions continue. Guest shutdown calls Disable before cleaning up executions and containers, and continues cleanup even if Disable fails. SSH does not track guest shutdown: concurrent Configure may briefly restart it, with VM shutdown reclaiming any remaining resources.