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.
| Path | Transport | Renders here | Needs |
|---|---|---|---|
proxxx ssh (CLI) | system ssh (PTY) | yes (vt100) | guest network up + SSH server in guest |
TUI c keypress | russh PTY embedded | yes (vt100) | same — but rendered inside a TUI pane |
proxxx serial | termproxy WebSocket | yes (raw) | serial console enabled in guest |
proxxx spice | SPICE over TLS | external | remote-viewer / virt-viewer on local machine |
proxxx novnc | HTML5 VNC | external | system 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.
proxxx ssh 100 # interactive shell
proxxx ssh 100 --cmd "uptime" # one-shot, exits with remote rcResolution order
- Explicit override: if
[ssh.guests."<vmid>"]exists inconfig.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. - 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: 1on the VM, and a full power cycle (warm reboot doesn't pick up the agent flag). - LXC:
/lxc/{vmid}/interfaces— PVE shellslxc-info/ip addrin the container's netns. The first routable IPv4 wins (skips loopback127.0.0.0/8, link-local169.254.0.0/16, and0.0.0.0/8). IPv6 is skipped — most operators SSH over v4; pin a v6 host explicitly if needed.
- QEMU:
- Friendly error: if both fail, proxxx prints paste-able TOML plus the diagnostic chain (agent off vs. only loopback vs. no
[ssh].key_pathset) 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
[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_pathInside 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
proxxx serial 100 --node pve1Auto-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
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 launchThe flow:
- POST
/nodes/{node}/qemu/{vmid}/spiceproxyto PVE. - Render the response as a
.vvvirt-viewer ConfigFile (INI format with[virt-viewer]section). - Write atomically with mode 0600 + O_EXCL to a randomly-named temp file (TOCTOU-safe, audit).
spawnthe first available of:remote-viewer,virt-viewer, system default.vvhandler.
.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
proxxx novnc 100 --node pve1Builds 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
// 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.