Container console on an external site (via API)

elone

New Member
Jan 13, 2025
3
0
1
Hello!

I’ve done several searches, and I must admit I’m completely lost. I would like to do the following:

  • When I’m on a page of an external website (site.fr), API requests should be sent to my Proxmox.
  • The requests sent should allow me to retrieve the console of a container (or a virtual machine) on site.fr.
I’ve already tried to:

  • Use /vncproxy to get a ticket, and then use /vncwebsockets to obtain a wss link.
  • Attempt to use the root@pam password and simulate a connection to the console URL of a VM (it works, but only if the browser is already logged into Proxmox). As you can imagine, I’m not going to give my users the Proxmox credentials so they can access their machine.
So, my problem is that I can’t understand how the API works, because no matter how many wss links I generate, nothing seems to work.

I’m providing a Python code and an HTML code that you can test on your side, and I would be happy to get your feedback on them.

Thanks in advance!


Python:
sudo apt update && apt upgrade -y
sudo apt install python3-pip

mkdir /var/www
mkdir /var/www/console
mkdir /var/www/console/templates

# On a LXC machine ?
sudo apt install python3-venv
python3 -m venv /var/www/console/venv
source /var/www/console/venv/bin/activate

cd /var/www/console/
pip3 install flask requests


Python:
# server.py
from flask import Flask, render_template, request, jsonify
import requests

app = Flask(__name__)

CONFIG = {
    "base_url": "proxmoxUrl:8006/api2/json",
    "token_id": "username@auth!tokenId",
    "token_secret": "tokenSecret",
    "node": "nodeName"
}

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/get_vnc_ticket', methods=['POST'])
def get_vnc_ticket():
    # Retrieve container ID from the request
    container_id = request.json.get("container_id")
    if not container_id:
        return jsonify({"error": "Container ID is required"}), 400

    # Build the Proxmox API endpoint URL
    url = f"{CONFIG['base_url']}/nodes/{CONFIG['node']}/lxc/{container_id}/vncproxy"

    # Authenticate with Proxmox API and get the VNC ticket
    headers = {
        "Authorization": f"PVEAPIToken={CONFIG['token_id']}={CONFIG['token_secret']}"
    }

    try:
        response = requests.post(url, headers=headers, verify=False)
        response.raise_for_status()
        data = response.json()["data"]
        return jsonify({
            "vncticket": data["ticket"],
            "port": data["port"]
        })
    except requests.exceptions.RequestException as e:
        return jsonify({"error": str(e)}), 500

@app.route('/get_vnc_websocket', methods=['GET'])
def get_vnc_websocket():
    # Retrieve container ID, vncticket, port, and protocol
    container_id = request.args.get("container_id")
    vncticket = request.args.get("vncticket")
    port = request.args.get("port")
    protocol = request.args.get("protocol", "wss")

    print(f"Received parameters - container_id: {container_id}, vncticket: {vncticket}, port: {port}, protocol: {protocol}")

    if not container_id or not vncticket or not port:
        return jsonify({"error": "Container ID, vncticket, and port are required"}), 400

    # Ensure that the protocol is either "wss" or "https" (it was just for try the https://)
    if protocol not in ["wss", "https"]:
        return jsonify({"error": 'Protocol must be either "wss" or "https"'}), 400

    # Build the WebSocket URL (with port first and vncticket second)
    websocket_url = f"{protocol}://{CONFIG['base_url'].split('//')[1].split(':')[0]}:8006/api2/json/nodes/{CONFIG['node']}/lxc/{container_id}/vncwebsocket?port={port}&vncticket={vncticket}"
 
    return jsonify({"websocket_url": websocket_url})

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000, debug=True)
    # The server will be accessible with the IPv4 given on your machine
    # For example, you can access the server with : 123.123.123.123:5000
    # You can change the host to 127.0.0.1 if you want to try it on your local machine (if you don't have IPv4 or if you want to run it on your computer for example)


HTML:
<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Proxmox LXC Console</title>
</head>
<body>
    <h1>Access LXC Console</h1>
    <form id="vnc-form">
        <label for="container_id">Container ID:</label>
        <input type="text" id="container_id" name="container_id" required>
     
        <label for="protocol">Protocol:</label>
        <select id="protocol" name="protocol">
            <option value="wss" selected>WSS</option>
            <option value="https">HTTPS</option>
        </select>
     
        <button type="submit">Get Console</button>
    </form>

    <div id="output"></div>

    <script>
        document.getElementById('vnc-form').addEventListener('submit', async (e) => {
            e.preventDefault();
            const containerId = document.getElementById('container_id').value;
            const protocol = document.getElementById('protocol').value;
         
            try {
                // Request VNC Ticket
                const ticketResponse = await fetch('/get_vnc_ticket', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ container_id: containerId })
                });
                const ticketData = await ticketResponse.json();

                if (ticketResponse.ok) {
                    const wsResponse = await fetch(`/get_vnc_websocket?container_id=${containerId}&vncticket=${encodeURIComponent(ticketData.vncticket)}&port=${ticketData.port}&protocol=${protocol}`);
                    const wsData = await wsResponse.json();

                    if (wsResponse.ok) {
                        document.getElementById('output').innerHTML = `
                            <p>WebSocket URL: <a href="${wsData.websocket_url}" target="_blank">${wsData.websocket_url}</a></p>
                        `;
                    } else {
                        document.getElementById('output').textContent = wsData.error || 'Failed to retrieve WebSocket URL.';
                    }
                } else {
                    document.getElementById('output').textContent = ticketData.error || 'Failed to retrieve VNC Ticket.';
                }
            } catch (error) {
                document.getElementById('output').textContent = error.message;
            }
        });
    </script>
</body>
</html>

PS : The protocol wss or https does not impact the URL. So don't need to search the issue here :)
 
Last edited:
First, what i have found is this:
1. the proxmox API connection shouldn't be made with a token, only username/password ( with a token it just doesn't work )
2. i am using a JS library called rfb.js but noVNC can't work directly with the wss_url towards the proxmox due to CORS issues (proxmox domain isn't in the same domain as your webapp)
2.1 for that, i again send the wss url -> back to the backend again, and from there - open a python websocket to communicate with the proxmox.
3. You must request a /vncticket from the nodename that the VM is located at. (meaning. lets say you have 4 servers in a DC, you can communicate and get details for VM on server-3 through an API that you connected via server-1 as the DC is all shared, but requesting a ticket from server-1 will result a 401 when you access server-3 VM)

So it's like this:
user -> webapp
webapp -> request a ticket from /vncticket
webapp -> parse the response
webapp -> construct wss_url with ticket & port -> /vncwebsocket via RFB.js (local backend, noVNC lib)
local backend of /vncwebsocket -> open a WebSocket(not GET) -> proxmox node <-> communicate with RFB.js

I'm using Proxmox version 9.1.15
The HTML/JS is:
JavaScript:
 <!-- noVNC console -->
<div id="vnc-container"></div>

<script type="module">
async function startVNC() {
    try {
        // some route for fetching the ticket
        const response = await fetch('/your/server/api/v1/vm/{vm_id}/console/ticket', {
            method: 'POST'
        });
        const vnc_data = await response.json();
      
        // the URL is towards the webapp ( not remote proxmox directly )
        // some route that will handle WebSocket connection ( not GET )
        const ws_url = '/ws/console' +
              '?ticket=' + encodeURIComponent(vnc_data.ticket) +
              '&port=' + vnc_data.port +
              '&vm_id=' + vnc_data.vm_id +
              '&wss_ip=' + vnc_data.wss_ip +
              '&wss_port=' + vnc_data.wss_port +
              '&wss_node=' + vnc_data.wss_node +
              '&pve_auth_ticket=' + vnc_data.pve_auth_ticket;

        rfb_connection = new RFB(container, ws_url, {
            credentials: {
                username: vnc_data.user,
                password: vnc_data.ticket
            }
        });

        // Configure how the screen scales
        rfb_connection.scaleViewport = true;
        rfb_connection.resizeSession = true;

        // 4. Handle connection events
        rfb_connection.addEventListener("connect", () => {
            console.log("Connected!");
        });

        rfb_connection.addEventListener("disconnect", (e) => {
            let msg = e.detail.clean ? "Disconnected gracefully" : "Connection dropped";
            console.log("Generic msg:", msg);
        });

        rfb_connection.addEventListener("securityfailure", () => {
            console.log("Auth failure");
        });

    } catch (err) {
        console.error(err);     
    }
}
startVNC()
</script>

The console/ticket route is:
Python:
# FastAPI
@router.post('/your/server/api/v1/vm/{vm_id}/console/ticket')
async def console(request : Request, vm_id:int);
    # Get these details from your resources somewhere ...
    ip =  ...
    proxmox_port =  ...
    username = ...
    password = ...
    node_name = ...

    proxmox = ProxmoxAPI(
        ip,
        port=proxmox_port,
        user=username,
        password=password,
        verify_ssl=False
    )

    access_ticket  = proxmox.nodes(node_name).qemu(vm_id).vncproxy().post()

    auth_obj = proxmox._store["session"].auth
    if hasattr(auth_obj, "pve_auth_ticket"):
        pve_auth_ticket = f"PVEAuthCookie={auth_obj.pve_auth_ticket}"

    return {
        'ticket': access_ticket['ticket'],
        'port'  : access_ticket['port'],
        'user'  : access_ticket['user'],
        'vm_id' : vm_id,
        'wss_port': proxmox_port,
        'wss_ip'  : ip,
        'wss_node': node_name,
        'pve_auth_ticket': f"PVEAuthCookie={auth_obj.pve_auth_ticket}"
    }

The /ws/console?ticket=... route is:

Python:
# FastAPI
import websockets
import asyncio
import ssl

from fastapi import WebSocket

...

@wss_router.websocket("/ws/console")
async def ws_console(websocket: WebSocket,
                     group_id: int,
                     vm_id: int,    # VM remote ID in proxmox
                     ticket: str,   # vnc ticket
                     port: str,     # vnc port (from the ticket)
                     wss_ip: str,
                     wss_port: str,
                     wss_nodename: str,
                     pve_auth_ticket: str):

    await websocket.accept()

    # Disable SSL verification for the self-signed Proxmox cert
    ssl_context = ssl._create_unverified_context()

    # Construct the WS URL towards remote proxmox
    eticket = urlparse.quote(ticket, safe="")
    wss_url f"wss://{wss_ip}:{wss_port}/api2/json/nodes/{wss_nodename}/qemu/{vm_id}/vncwebsocket?port={port}&vncticket={eticket}"
    headers = [('Cookie', pve_auth_ticket)]

    try:
        async with websockets.connect(wss_url,
                                     additional_headers=headers,
                                     ssl=ssl_context,
                                     subprotocols=["binary"]) as proxmox_ws:
            print("WebSocket bridged successfully!")

            async def client_to_proxmox():
                try:
                    while True:
                        # Use receive() instead of receive_bytes() to handle any frame type
                        message = await websocket.receive()

                        if "bytes" in message and message["bytes"]:
                            await proxmox_ws.send(message["bytes"])

                        elif "text" in message and message["text"]:
                            await proxmox_ws.send(message["text"])

                        elif message["type"] == "websocket.disconnect":
                            break
                except Exception as e:
                    print(f"[ERROR] Client->Proxmox: {e}")

            async def proxmox_to_client():
                try:
                    while aiter(proxmox_ws):
                        data = await proxmox_ws.recv()
                        if isinstance(data, bytes):
                            await websocket.send_bytes(data)
                        else:
                            await websocket.send_text(data)
                except websockets.exceptions.ConnectionClosed:
                    print("[DEBUG] Proxmox closed the connection.")
                except Exception as e:
                    print(f"[ERROR] Proxmox->Client: {e}")

            # Run both bridging tasks concurrently
            await asyncio.gather(
                client_to_proxmox(),
                proxmox_to_client()
            )

    except Exception as e:
        print(f"Proxy connection failed: {e}")
    finally:
        try:
            await websocket.close()
        except RuntimeError:
            pass

Hope it helps others .. as I spent a week on this .. :/
 
Last edited: