Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 131 additions & 15 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6375,15 +6375,18 @@ def _ssh_target_for_node(node: str) -> tuple[str | None, bool]:
return meta.get("host"), meta.get("verify_ssl", True)


def _ssh_connect_root(node: str, timeout: int = 20):
def _ssh_connect_root(node: str, timeout: int = 20, host: str | None = None):
"""Open a paramiko SSHClient to `node` as root using ProxUI's key.

Returns the connected client, or raises. Caller must close it.
`host` overrides the address to connect to — needed for node-specific
commands (e.g. `pct exec`) since connection_metadata maps discovered nodes
to the configured API endpoint, not their own address. Caller must close it.
"""
paramiko = _get_paramiko()
if not paramiko:
raise SnippetWriteError("paramiko is not installed (pip install paramiko).")
host, _ = _ssh_target_for_node(node)
if host is None:
host, _ = _ssh_target_for_node(node)
if not host:
raise SnippetWriteError(f"No connection host known for node '{node}'.")
if not os.path.exists(_SSH_KEY_PATH):
Expand Down Expand Up @@ -6468,11 +6471,29 @@ def _sftp_read_file(node: str, remote_path: str) -> str:
client.close()


def _ssh_run_root(node: str, command: str, timeout: int = 30) -> tuple[int, str, str]:
"""Run a command on `node` as root via SSH. Returns (exit_code, stdout, stderr)."""
client = _ssh_connect_root(node, timeout=timeout)
def _ssh_run_root(
node: str,
command: str,
timeout: int = 30,
stdin_data: str | None = None,
host: str | None = None,
) -> tuple[int, str, str]:
"""Run a command on `node` as root via SSH. Returns (exit_code, stdout, stderr).

If stdin_data is given it is written to the command's stdin (e.g. to feed
`chpasswd`) so secrets never appear on the command line. `host` overrides the
address (needed for node-specific commands — see _ssh_connect_root).
"""
client = _ssh_connect_root(node, timeout=timeout, host=host)
try:
_stdin, stdout, stderr = client.exec_command(command, timeout=timeout)
stdin, stdout, stderr = client.exec_command(command, timeout=timeout)
if stdin_data is not None:
stdin.write(stdin_data)
stdin.flush()
try:
stdin.channel.shutdown_write()
except Exception:
pass
rc = stdout.channel.recv_exit_status()
out = stdout.read().decode("utf-8", errors="replace")
err = stderr.read().decode("utf-8", errors="replace")
Expand All @@ -6481,9 +6502,10 @@ def _ssh_run_root(node: str, command: str, timeout: int = 30) -> tuple[int, str,
client.close()


def _ssh_port_open(node: str, timeout: float = 4.0) -> bool:
"""Quick TCP check for SSH (port 22) reachability."""
host, _ = _ssh_target_for_node(node)
def _ssh_port_open(node: str, timeout: float = 4.0, host: str | None = None) -> bool:
"""Quick TCP check for SSH (port 22) reachability. `host` overrides the address."""
if host is None:
host, _ = _ssh_target_for_node(node)
if not host:
return False
try:
Expand Down Expand Up @@ -7636,6 +7658,97 @@ def api_vm_agent(node, vmid):
return _proxmox_error_response(e)


def _node_address(node: str) -> str | None:
"""Real management address of a specific cluster node, for node-local commands.

connection_metadata maps discovered nodes to the configured API endpoint, so
it can't reach a *specific* node (e.g. to run `pct exec`, which must run on
the node that owns the container). Look the node's IP up via cluster status.
"""
proxmox = get_proxmox_connection(node, auto_renew=True)
if not proxmox:
return None
try:
for entry in proxmox.cluster.status.get():
if (
entry.get("type") == "node"
and entry.get("name") == node
and entry.get("ip")
):
return entry["ip"]
except Exception:
return None
return None


def _reset_lxc_root_password(node: str, vmid: str, password: str) -> str:
"""Set an LXC container's root password by running chpasswd via `pct exec`.

Proxmox has no REST API to exec inside a container (unlike QEMU's guest
agent), so we run `pct exec <vmid> -- chpasswd` on the node over the same
channels the snippet writer uses, feeding `root:<password>` on stdin so the
secret never lands in the process list or shell history:
1. SSH as root — ProxUI's key installed and port 22 open (stdin pipe)
2. node Shell — root@pam console, via a short-lived temp file
Returns the channel used, or raises SnippetWriteError with what to fix.
"""
payload = f"root:{password}\n"
qvmid = shlex.quote(str(vmid))
errors = []

# `pct exec` must run ON the node that owns the container. connection_metadata
# points discovered nodes at the configured API endpoint, so resolve the
# owning node's real address for the SSH tier. (The termproxy tier already
# reaches the right node — /nodes/<node>/termproxy is proxied there.)
host = _node_address(node)

# Tier 1: SSH as root with ProxUI's key — password stays on the stdin pipe.
if os.path.exists(_SSH_KEY_PATH) and host and _ssh_port_open(node, host=host):
try:
rc, out, err = _ssh_run_root(
node,
f"pct exec {qvmid} -- chpasswd",
stdin_data=payload,
host=host,
)
if rc == 0:
return "ssh"
errors.append(f"SSH: {(err or out)[-200:].strip()}")
except Exception as e:
errors.append(f"SSH: {e}")

# Tier 2: node Shell (root@pam) — write the payload to a temp file, feed it to
# chpasswd, then remove it.
if _is_root_pam(node):
try:
sess = _termproxy_open(node)
try:
tmp = f"/tmp/proxui-pw-{uuid.uuid4().hex}"
qtmp = shlex.quote(tmp)
sess.write_file(tmp, payload)
rc, out = sess.run(
f"pct exec {qvmid} -- chpasswd < {qtmp}; "
f"rc=$?; rm -f {qtmp}; exit $rc"
)
if rc == 0:
return "termproxy"
errors.append(f"node Shell: {out[-200:].strip()}")
finally:
sess.close()
except Exception as e:
errors.append(f"node Shell: {e}")

detail = (
"; ".join(errors)
if errors
else (
"no usable channel — set up ProxUI SSH access to the node "
"(Cloud-Init → Custom Snippet → Set up access), or connect as root@pam."
)
)
raise SnippetWriteError(f"Could not reset the container password: {detail}")


@app.route("/api/vm/<node>/<vmid>/reset-password", methods=["POST"])
def api_vm_reset_password(node, vmid):
"""Reset root password: LXC via pct exec, QEMU via guest agent with cipassword fallback."""
Expand All @@ -7658,12 +7771,15 @@ def api_vm_reset_password(node, vmid):
jsonify({"error": "Container must be running to reset password"}),
400,
)
proxmox.nodes(node).lxc(vmid).exec.post(
command=["chpasswd"],
stdin=f"root:{password}\n",
)
try:
channel = _reset_lxc_root_password(node, vmid, password)
except SnippetWriteError as e:
return jsonify({"error": str(e)}), 400
return jsonify(
{"success": True, "message": "Root password updated successfully"}
{
"success": True,
"message": f"Root password updated successfully (via {channel}).",
}
)
else:
# Try guest agent first (immediate effect), fall back to cipassword (next reboot)
Expand Down
Loading