Skip to content

Console handoff

Four ways to get into a guest, from text-only to graphical. The right one depends on whether the network is up, whether the agent is up, whether you want graphics, and whether you trust the local machine with a graphical client.

PathTransportRenders hereNeeds
proxxx ssh (CLI)system ssh (PTY)yes (vt100)guest network up + SSH server in guest
TUI c keypressrussh PTY embeddedyes (vt100)same — but rendered inside a TUI pane
proxxx serialtermproxy WebSocketyes (raw)serial console enabled in guest
proxxx spiceSPICE over TLSexternalremote-viewer / virt-viewer on local machine
proxxx novncHTML5 VNCexternalsystem browser + active web UI session

proxxx ssh <vmid> — the main daily driver

CLI-side spawns the system ssh so your existing keys, agent, ~/.ssh/known_hosts, and ~/.ssh/config (Host stanzas, ProxyJump, ControlMaster) all apply transparently. Re-implementing those in russh would be incomplete and invisible to muscle memory.

sh
proxxx ssh 100                              # interactive shell
proxxx ssh 100 --cmd "uptime"               # one-shot, exits with remote rc

Resolution order

  1. Explicit override: if [ssh.guests."<vmid>"] exists in config.toml, the wizard / hand-edited host wins. Use this when you want a stable DNS name (web1.lab) over a churning DHCP IP, or when QGA is off.
  2. Auto-discovery: otherwise, proxxx asks PVE for the live IPs:
    • QEMU: qemu_agent_network_get_interfaces (QGA) — needs the agent installed in the guest, agent: 1 on the VM, and a full power cycle (warm reboot doesn't pick up the agent flag).
    • LXC: /lxc/{vmid}/interfaces — PVE shells lxc-info / ip addr in the container's netns. The first routable IPv4 wins (skips loopback 127.0.0.0/8, link-local 169.254.0.0/16, and 0.0.0.0/8). IPv6 is skipped — most operators SSH over v4; pin a v6 host explicitly if needed.
  3. Friendly error: if both fail, proxxx prints paste-able TOML plus the diagnostic chain (agent off vs. only loopback vs. no [ssh].key_path set) so the message tells you what to fix, not just "guest not found".

The resolved source is echoed before exec — [ssh] resolved root@10.0.0.42:22 (source: QGA / lxc-interfaces auto-discovery) — so you know which path won.

Optional explicit pin

toml
[ssh]
user     = "root"
key_path = "~/.ssh/proxxx_homelab"

# Optional per-guest overrides (only when QGA is unavailable or
# you want a stable DNS name). Generated by the wizard's step 4
# sub-prompt, or hand-edited.
[ssh.guests."100"]
host = "10.10.10.100"
# user = "fab"                  # optional, falls back to [ssh].user
# port = 22                     # optional, default 22
# key_path = "~/.ssh/per-guest" # optional, falls back to [ssh].key_path

Inside the TUI, press c on a selected guest, or run :ssh 100 in the command palette. The TUI flow uses an embedded russh PTY inside a TUI widget (so the session lives alongside other panes); the CLI flow above uses the system ssh. Both reach the same guest; pick whichever fits your workflow.

TUI-side TOFU host keys (embedded russh path only)

The TUI's russh-backed PTY maintains a dedicated known_hosts at $XDG_CONFIG_HOME/proxxx/known_hosts — separate from your ~/.ssh/known_hosts. First connect logs the fingerprint with a warning and accepts; subsequent connects refuse on mismatch. The CLI proxxx ssh path delegates to system ssh and uses your regular ~/.ssh/known_hosts.

Encrypted private keys (TUI russh path)

If your SSH key is passphrase-protected and you use the TUI c flow, set PROXXX_SSH_KEY_PASSPHRASE. proxxx never prompts interactively for a passphrase — it would block the TUI's event loop. The CLI path inherits the system ssh agent and prompts as ssh normally would.

proxxx serial <vmid> — recovery console

Raw termproxy over WebSocket, useful when:

  • The guest's network is down
  • The guest agent is dead
  • The guest is stuck at the bootloader
  • You're recovering a misconfigured /etc/network/interfaces
sh
proxxx serial 100 --node pve1

Auto-detects QEMU vs LXC if --kind is omitted. Puts the local terminal in raw mode + alternate screen. Exit: Ctrl+] then q (telnet-style chord; any other key after Ctrl+] forwards Ctrl+] to the remote).

The verify_tls = false flag in your profile mirrors into the WebSocket TLS verifier — wsterm::tls::dangerous_no_verify_config respects the same homelab self-signed allowance as the REST client.

proxxx spice <vmid> — graphical, QEMU only

sh
proxxx spice 100 --node pve1                   # writes .vv, launches remote-viewer
proxxx spice 100 --node pve1 --write-vv /tmp   # write but don't launch
proxxx spice 100 --node pve1 --no-launch       # write to default temp path, don't launch

The flow:

  1. POST /nodes/{node}/qemu/{vmid}/spiceproxy to PVE.
  2. Render the response as a .vv virt-viewer ConfigFile (INI format with [virt-viewer] section).
  3. Write atomically with mode 0600 + O_EXCL to a randomly-named temp file (TOCTOU-safe, audit).
  4. spawn the first available of: remote-viewer, virt-viewer, system default .vv handler.

.vv files contain the SPICE password in plaintext. Mode 0600 restricts read to the owning user. virt-viewer respects PVE's delete-this-file=1 directive and removes the file after connecting.

proxxx novnc <vmid> — graphical, browser

sh
proxxx novnc 100 --node pve1

Builds a deep-link URL to PVE's web UI noVNC console:

https://pve1.lan:8006/?console=kvm&novnc=1&vmid=100&node=pve1&resize=scale

(or console=lxc for containers) and opens it via xdg-open, open, or cmd /C start.

WARNING

The user must already be logged into the Proxmox web UI in the browser. proxxx does not inject a session ticket into the URL — that pattern leaks tokens via browser history, screen capture, and shell history. If you want unattended browser-side access, look at PVE's own ticket flow, not proxxx.

Why no in-TUI graphics

ratatui renders text. Proper SPICE / VNC needs pixel buffers, audio sync, USB redirection, clipboard sharing. Even text-mode VNC clients (vncviewer's text mode) are a fraction of what remote-viewer provides. The handoff approach is correct: proxxx coordinates, the graphical client renders.

Per-platform launcher

rust
// src/handoff/launcher.rs
fn open_with_default(url: &str) {
    let cmd = if cfg!(target_os = "macos") { "open" }
              else if cfg!(target_os = "windows") { "cmd /C start \"\"" }
              else { "xdg-open" };
    Command::new(cmd).arg(url).spawn();
}

No opener crate dependency — three lines, two cfg! arms, audit-friendly.

See also

Released under the MIT License.