From bafb13e7f76074f2eee15a0dd2f8622833cac977 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 15 Jul 2026 10:00:05 -0500 Subject: [PATCH 1/2] fix: reset LXC password via pct exec, not a nonexistent API endpoint Resetting an LXC root password called POST /nodes/{node}/lxc/{vmid}/exec, which Proxmox does not implement (LXC has no REST exec, unlike QEMU's guest agent), so it returned 501 Not Implemented. Run 'pct exec -- chpasswd' on the node over the existing SSH / node-Shell channels instead, feeding root: on stdin so the secret never appears on the command line. Requires ProxUI SSH access to the node or a root@pam connection; the container must be running. --- app.py | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 83 insertions(+), 8 deletions(-) diff --git a/app.py b/app.py index c0a211f..b2129fb 100644 --- a/app.py +++ b/app.py @@ -6468,11 +6468,24 @@ 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).""" +def _ssh_run_root( + node: str, command: str, timeout: int = 30, stdin_data: 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. + """ client = _ssh_connect_root(node, timeout=timeout) 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") @@ -7636,6 +7649,65 @@ def api_vm_agent(node, vmid): return _proxmox_error_response(e) +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 -- chpasswd` on the node over the same + channels the snippet writer uses, feeding `root:` 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 = [] + + # Tier 1: SSH as root with ProxUI's key — password stays on the stdin pipe. + if os.path.exists(_SSH_KEY_PATH) and _ssh_port_open(node): + try: + rc, out, err = _ssh_run_root( + node, f"pct exec {qvmid} -- chpasswd", stdin_data=payload + ) + 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///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.""" @@ -7658,12 +7730,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) From bedf3a75b7ab189e9db9a0312f663d72f176fbd9 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 15 Jul 2026 10:09:43 -0500 Subject: [PATCH 2/2] fix: run pct exec on the node that owns the container The SSH tier connected to the configured API endpoint for every node (connection_metadata maps discovered nodes to that endpoint), so resetting a container on a non-endpoint node ran pct exec on the wrong node: 'Configuration file nodes//lxc/.conf does not exist'. Resolve the owning node's real address via cluster status and target it for the SSH tier (new host override on the SSH helpers). The termproxy tier already reaches the right node, since /nodes//termproxy is proxied there. --- app.py | 63 ++++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/app.py b/app.py index b2129fb..7ac71fd 100644 --- a/app.py +++ b/app.py @@ -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): @@ -6469,14 +6472,19 @@ def _sftp_read_file(node: str, remote_path: str) -> str: def _ssh_run_root( - node: str, command: str, timeout: int = 30, stdin_data: str | None = None + 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. + `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) + client = _ssh_connect_root(node, timeout=timeout, host=host) try: stdin, stdout, stderr = client.exec_command(command, timeout=timeout) if stdin_data is not None: @@ -6494,9 +6502,10 @@ def _ssh_run_root( 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: @@ -7649,6 +7658,29 @@ 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`. @@ -7664,11 +7696,20 @@ def _reset_lxc_root_password(node: str, vmid: str, password: str) -> str: 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//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 _ssh_port_open(node): + 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 + node, + f"pct exec {qvmid} -- chpasswd", + stdin_data=payload, + host=host, ) if rc == 0: return "ssh"