777926487b
initial QEMU slice, backed by imported contracts for majors 6–9. - Implement durable handlers for access/auth, cluster, LXC, storage, HA, firewall, Ceph, SDN, ACME, notifications, pools, mapping, and node ops - Serve an interactive Web UI with catalog browsing, demo seed controls, and OpenAPI/help surfaces - Bundle PVE 6.4-15, 7.4-16, and 8.4.5 contract revisions alongside 9.2.3 - Support in-memory runtime contract Apply (POST /ui/api/contract/apply) so /version and /api2 routes follow the selected major until restart - Expand seed profiles (including demo-cluster), migrations 007–008, TLS gateway config, Compose/Makefile tooling, and compatibility evidence - Tighten .gitignore for macOS, hidden directories (.*/), and local secrets
446 lines
20 KiB
Python
446 lines
20 KiB
Python
"""Additional QEMU guest/agent/console endpoints with durable guest state."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import secrets
|
|
from collections.abc import Mapping
|
|
from typing import Any, cast
|
|
|
|
from fastapi import Request
|
|
|
|
from app.api.errors import ApiError
|
|
from app.api.registry import HandlerRegistry
|
|
from app.config import Settings
|
|
from app.handlers.qemu import _agent_resource, _database, _qemu_resource, _state, _values
|
|
from app.security.auth import issue_ticket
|
|
|
|
|
|
def _settings(request: Request) -> Settings:
|
|
return cast(Settings, request.app.state.settings)
|
|
|
|
|
|
async def _save_guest_state(request: Request, resource_id: Any, state: dict[str, Any]) -> None:
|
|
await _database(request).pool.execute(
|
|
"UPDATE resources SET state=$2::jsonb, version=version+1, updated_at=now() WHERE id=$1",
|
|
resource_id,
|
|
json.dumps(state, sort_keys=True),
|
|
)
|
|
|
|
|
|
async def _save_guest_config(request: Request, resource_id: Any, config: dict[str, Any]) -> None:
|
|
await _database(request).pool.execute(
|
|
"UPDATE virtual_machines SET config=$2::jsonb WHERE resource_id=$1",
|
|
resource_id,
|
|
json.dumps(config, sort_keys=True),
|
|
)
|
|
|
|
|
|
def register_qemu_extra_handlers(registry: HandlerRegistry) -> None:
|
|
async def agent_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
|
await _agent_resource(request, _values(inputs))
|
|
return [
|
|
{"name": name}
|
|
for name in (
|
|
"exec",
|
|
"exec-status",
|
|
"file-read",
|
|
"file-write",
|
|
"fsfreeze-freeze",
|
|
"fsfreeze-status",
|
|
"fsfreeze-thaw",
|
|
"fstrim",
|
|
"get-fsinfo",
|
|
"get-memory-block-info",
|
|
"get-memory-blocks",
|
|
"get-timezone",
|
|
"get-users",
|
|
"get-vcpus",
|
|
"info",
|
|
"ping",
|
|
"set-user-password",
|
|
"shutdown",
|
|
"suspend-disk",
|
|
"suspend-hybrid",
|
|
"suspend-ram",
|
|
)
|
|
]
|
|
|
|
async def agent_post(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
payload = _values(inputs)
|
|
command = str(payload.get("command") or "ping")
|
|
resource = await _agent_resource(request, payload)
|
|
state = _state(resource["state"])
|
|
agent = state.setdefault("agent", {})
|
|
agent["last_command"] = command
|
|
await _save_guest_state(request, resource["id"], state)
|
|
return {"result": {"command": command, "ok": 1}}
|
|
|
|
async def _agent_blob(command: str, request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
resource = await _agent_resource(request, _values(inputs))
|
|
state = _state(resource["state"])
|
|
agent = state.setdefault("agent", {})
|
|
blobs = agent.setdefault("results", {})
|
|
defaults: dict[str, Any] = {
|
|
"get-users": [{"user": "root", "login-time": 0}],
|
|
"get-fsinfo": [{"name": "/", "type": "ext4", "total-bytes": 32 * 1024**3}],
|
|
"get-memory-block-info": {"size": 1024**3},
|
|
"get-memory-blocks": [{"start": 0, "size": 1024**3}],
|
|
"get-timezone": {"zone": "UTC", "offset": 0},
|
|
"get-vcpus": [{"online": True, "can-offline": False}],
|
|
"fsfreeze-status": "thawed",
|
|
}
|
|
if command not in blobs:
|
|
blobs[command] = defaults.get(command, {})
|
|
await _save_guest_state(request, resource["id"], state)
|
|
return {"result": blobs[command]}
|
|
|
|
async def agent_users(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
return await _agent_blob("get-users", request, inputs)
|
|
|
|
async def agent_fsinfo(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
return await _agent_blob("get-fsinfo", request, inputs)
|
|
|
|
async def agent_memory_block_info(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
return await _agent_blob("get-memory-block-info", request, inputs)
|
|
|
|
async def agent_memory_blocks(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
return await _agent_blob("get-memory-blocks", request, inputs)
|
|
|
|
async def agent_timezone(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
return await _agent_blob("get-timezone", request, inputs)
|
|
|
|
async def agent_vcpus(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
return await _agent_blob("get-vcpus", request, inputs)
|
|
|
|
async def agent_exec(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
payload = _values(inputs)
|
|
resource = await _agent_resource(request, payload)
|
|
state = _state(resource["state"])
|
|
agent = state.setdefault("agent", {})
|
|
execs = agent.setdefault("exec", {})
|
|
pid = int(agent.get("next_pid", 1000)) + 1
|
|
agent["next_pid"] = pid
|
|
execs[str(pid)] = {
|
|
"exited": 1,
|
|
"exitcode": 0,
|
|
"out-data": "",
|
|
"err-data": "",
|
|
"command": payload.get("command"),
|
|
}
|
|
await _save_guest_state(request, resource["id"], state)
|
|
return {"pid": pid}
|
|
|
|
async def agent_exec_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
payload = _values(inputs)
|
|
resource = await _agent_resource(request, payload)
|
|
pid = str(payload.get("pid") or "")
|
|
state = _state(resource["state"])
|
|
result = state.get("agent", {}).get("exec", {}).get(pid)
|
|
if not isinstance(result, dict):
|
|
raise ApiError(404, "exec process does not exist")
|
|
return {"result": result}
|
|
|
|
async def agent_file_read(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
payload = _values(inputs)
|
|
resource = await _agent_resource(request, payload)
|
|
path = str(payload.get("file") or payload.get("path") or "/etc/hostname")
|
|
state = _state(resource["state"])
|
|
files = state.setdefault("agent", {}).setdefault("files", {})
|
|
if path not in files:
|
|
files[path] = f"simulated:{path}\n"
|
|
await _save_guest_state(request, resource["id"], state)
|
|
content = str(files[path])
|
|
return {"result": {"content": content, "truncated": True}}
|
|
|
|
async def agent_file_write(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
payload = _values(inputs)
|
|
resource = await _agent_resource(request, payload)
|
|
default_path = "guest-agent-out"
|
|
path = str(payload.get("file") or payload.get("path") or default_path)
|
|
content = str(payload.get("content") or "")
|
|
state = _state(resource["state"])
|
|
files = state.setdefault("agent", {}).setdefault("files", {})
|
|
files[path] = content
|
|
await _save_guest_state(request, resource["id"], state)
|
|
return {"result": None}
|
|
|
|
async def agent_fsfreeze(
|
|
request: Request, inputs: dict[str, Any], status: str
|
|
) -> dict[str, Any]:
|
|
resource = await _agent_resource(request, _values(inputs))
|
|
state = _state(resource["state"])
|
|
agent = state.setdefault("agent", {})
|
|
agent["fsfreeze"] = status
|
|
agent.setdefault("results", {})["fsfreeze-status"] = status
|
|
await _save_guest_state(request, resource["id"], state)
|
|
return {"result": status}
|
|
|
|
async def agent_fsfreeze_freeze(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
return await agent_fsfreeze(request, inputs, "frozen")
|
|
|
|
async def agent_fsfreeze_thaw(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
return await agent_fsfreeze(request, inputs, "thawed")
|
|
|
|
async def agent_fsfreeze_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
return await _agent_blob("fsfreeze-status", request, inputs)
|
|
|
|
async def agent_fstrim(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
resource = await _agent_resource(request, _values(inputs))
|
|
state = _state(resource["state"])
|
|
state.setdefault("agent", {})["last_fstrim"] = True
|
|
await _save_guest_state(request, resource["id"], state)
|
|
return {"result": {"paths": [{"path": "/", "trimmed": 0}]}}
|
|
|
|
async def agent_set_password(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
payload = _values(inputs)
|
|
resource = await _agent_resource(request, payload)
|
|
username = str(payload.get("username") or "root")
|
|
state = _state(resource["state"])
|
|
passwords = state.setdefault("agent", {}).setdefault("passwords", {})
|
|
passwords[username] = True # store only presence, not secret
|
|
await _save_guest_state(request, resource["id"], state)
|
|
return {"result": None}
|
|
|
|
async def agent_shutdown(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
resource = await _agent_resource(request, _values(inputs))
|
|
state = _state(resource["state"])
|
|
state["status"] = "stopped"
|
|
await _save_guest_state(request, resource["id"], state)
|
|
return {"result": None}
|
|
|
|
async def agent_suspend(request: Request, inputs: dict[str, Any], mode: str) -> dict[str, Any]:
|
|
resource = await _agent_resource(request, _values(inputs))
|
|
state = _state(resource["state"])
|
|
state["status"] = "paused"
|
|
state.setdefault("agent", {})["suspend"] = mode
|
|
await _save_guest_state(request, resource["id"], state)
|
|
return {"result": None}
|
|
|
|
async def agent_suspend_disk(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
return await agent_suspend(request, inputs, "disk")
|
|
|
|
async def agent_suspend_ram(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
return await agent_suspend(request, inputs, "ram")
|
|
|
|
async def agent_suspend_hybrid(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
return await agent_suspend(request, inputs, "hybrid")
|
|
|
|
async def cloudinit_get(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
|
values = _values(inputs)
|
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
|
config = _state(resource["config"])
|
|
state = _state(resource["state"])
|
|
pending = cast(Mapping[str, Any], state.get("pending", {}))
|
|
keys = sorted(
|
|
{
|
|
key
|
|
for key in set(config) | set(pending)
|
|
if str(key).startswith(("ci", "ipconfig", "sshkeys", "nameserver", "searchdomain"))
|
|
}
|
|
)
|
|
return [
|
|
{
|
|
"key": key,
|
|
"value": str(config.get(key, "")),
|
|
"pending": str(pending[key]) if key in pending else None,
|
|
}
|
|
for key in keys
|
|
]
|
|
|
|
async def cloudinit_update(request: Request, inputs: dict[str, Any]) -> None:
|
|
values = _values(inputs)
|
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
|
state = _state(resource["state"])
|
|
state["cloudinit_generation"] = int(state.get("cloudinit_generation") or 0) + 1
|
|
await _save_guest_state(request, resource["id"], state)
|
|
|
|
async def cloudinit_dump(request: Request, inputs: dict[str, Any]) -> str:
|
|
values = _values(inputs)
|
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
|
config = _state(resource["config"])
|
|
return (
|
|
f"#cloud-config\nhostname: {config.get('name', values['vmid'])}\n"
|
|
f"manage_etc_hosts: true\n"
|
|
)
|
|
|
|
async def rrd(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
values = _values(inputs)
|
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
|
state = _state(resource["state"])
|
|
rrd_state = state.setdefault("rrd", {"filename": f"pve-vm-{values['vmid']}.rrd"})
|
|
await _save_guest_state(request, resource["id"], state)
|
|
return dict(rrd_state)
|
|
|
|
async def rrddata(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
|
values = _values(inputs)
|
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
|
state = _state(resource["state"])
|
|
series = state.setdefault(
|
|
"rrddata",
|
|
[
|
|
{
|
|
"time": 1_700_000_000,
|
|
"cpu": 0.05,
|
|
"mem": 256 * 1024 * 1024,
|
|
"netin": 0,
|
|
"netout": 0,
|
|
},
|
|
{
|
|
"time": 1_700_000_060,
|
|
"cpu": 0.08,
|
|
"mem": 260 * 1024 * 1024,
|
|
"netin": 100,
|
|
"netout": 80,
|
|
},
|
|
],
|
|
)
|
|
await _save_guest_state(request, resource["id"], state)
|
|
return list(series)
|
|
|
|
async def monitor(request: Request, inputs: dict[str, Any]) -> str:
|
|
values = _values(inputs)
|
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
|
command = str(values.get("command") or "info status")
|
|
state = _state(resource["state"])
|
|
history = state.setdefault("monitor", [])
|
|
if not isinstance(history, list):
|
|
history = state["monitor"] = []
|
|
output = f"OK {command}"
|
|
history.append({"command": command, "output": output})
|
|
await _save_guest_state(request, resource["id"], state)
|
|
return output
|
|
|
|
async def sendkey(request: Request, inputs: dict[str, Any]) -> None:
|
|
values = _values(inputs)
|
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
|
key = str(values.get("key") or "")
|
|
if not key:
|
|
raise ApiError(400, "parameter verification failed - 'key' missing")
|
|
state = _state(resource["state"])
|
|
keys = state.setdefault("sendkey", [])
|
|
if not isinstance(keys, list):
|
|
keys = state["sendkey"] = []
|
|
keys.append(key)
|
|
await _save_guest_state(request, resource["id"], state)
|
|
|
|
async def unlink(request: Request, inputs: dict[str, Any]) -> None:
|
|
values = _values(inputs)
|
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
|
idlist = [
|
|
item.strip()
|
|
for item in str(values.get("idlist") or values.get("ids") or "").split(",")
|
|
if item.strip()
|
|
]
|
|
if not idlist:
|
|
raise ApiError(400, "parameter verification failed - 'idlist' missing")
|
|
config = _state(resource["config"])
|
|
for disk in idlist:
|
|
config.pop(disk, None)
|
|
await _save_guest_config(request, resource["id"], config)
|
|
state = _state(resource["state"])
|
|
state["config"] = config
|
|
await _save_guest_state(request, resource["id"], state)
|
|
|
|
async def _console_proxy(request: Request, inputs: dict[str, Any], kind: str) -> dict[str, Any]:
|
|
values = _values(inputs)
|
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
|
key = _settings(request).ticket_signing_key.get_secret_value().encode()
|
|
ticket = issue_ticket(str(request.state.principal), key)
|
|
port = 5900 + int(values["vmid"]) % 1000
|
|
state = _state(resource["state"])
|
|
consoles = state.setdefault("consoles", {})
|
|
payload = {
|
|
"type": kind,
|
|
"port": port,
|
|
"ticket": ticket,
|
|
"upid": (
|
|
f"UPID:{values['node']}:{secrets.token_hex(4)}:"
|
|
f"{kind}:{values['vmid']}:{request.state.principal}:"
|
|
),
|
|
"user": str(request.state.principal),
|
|
"cert": "",
|
|
}
|
|
if values.get("generate-password") or values.get("websocket"):
|
|
payload["password"] = secrets.token_urlsafe(8)
|
|
consoles[kind] = {k: v for k, v in payload.items() if k != "ticket"}
|
|
await _save_guest_state(request, resource["id"], state)
|
|
return payload
|
|
|
|
async def vncproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
return await _console_proxy(request, inputs, "vnc")
|
|
|
|
async def spiceproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
return await _console_proxy(request, inputs, "spice")
|
|
|
|
async def termproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
return await _console_proxy(request, inputs, "term")
|
|
|
|
async def mtunnel(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
return await _console_proxy(request, inputs, "mtunnel")
|
|
|
|
async def websocket_ticket(
|
|
request: Request, inputs: dict[str, Any], kind: str
|
|
) -> dict[str, Any]:
|
|
values = _values(inputs)
|
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
|
state = _state(resource["state"])
|
|
console = state.get("consoles", {}).get(kind) or {"port": 5900}
|
|
key = _settings(request).ticket_signing_key.get_secret_value().encode()
|
|
return {
|
|
"port": console.get("port", 5900),
|
|
"ticket": issue_ticket(str(request.state.principal), key),
|
|
}
|
|
|
|
async def vncwebsocket(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
return await websocket_ticket(request, inputs, "vnc")
|
|
|
|
async def mtunnelwebsocket(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
return await websocket_ticket(request, inputs, "mtunnel")
|
|
|
|
async def dbus_vmstate(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
values = _values(inputs)
|
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
|
state = _state(resource["state"])
|
|
state["dbus_vmstate"] = True
|
|
await _save_guest_state(request, resource["id"], state)
|
|
return {"result": "OK"}
|
|
|
|
base = "/nodes/{node}/qemu/{vmid}"
|
|
registry.register(f"{base}/agent", "GET", agent_index)
|
|
registry.register(f"{base}/agent", "POST", agent_post)
|
|
registry.register(f"{base}/agent/exec", "POST", agent_exec)
|
|
registry.register(f"{base}/agent/exec-status", "GET", agent_exec_status)
|
|
registry.register(f"{base}/agent/file-read", "GET", agent_file_read)
|
|
registry.register(f"{base}/agent/file-write", "POST", agent_file_write)
|
|
registry.register(f"{base}/agent/fsfreeze-freeze", "POST", agent_fsfreeze_freeze)
|
|
registry.register(f"{base}/agent/fsfreeze-status", "POST", agent_fsfreeze_status)
|
|
registry.register(f"{base}/agent/fsfreeze-thaw", "POST", agent_fsfreeze_thaw)
|
|
registry.register(f"{base}/agent/fstrim", "POST", agent_fstrim)
|
|
registry.register(f"{base}/agent/get-fsinfo", "GET", agent_fsinfo)
|
|
registry.register(f"{base}/agent/get-memory-block-info", "GET", agent_memory_block_info)
|
|
registry.register(f"{base}/agent/get-memory-blocks", "GET", agent_memory_blocks)
|
|
registry.register(f"{base}/agent/get-timezone", "GET", agent_timezone)
|
|
registry.register(f"{base}/agent/get-users", "GET", agent_users)
|
|
registry.register(f"{base}/agent/get-vcpus", "GET", agent_vcpus)
|
|
registry.register(f"{base}/agent/set-user-password", "POST", agent_set_password)
|
|
registry.register(f"{base}/agent/shutdown", "POST", agent_shutdown)
|
|
registry.register(f"{base}/agent/suspend-disk", "POST", agent_suspend_disk)
|
|
registry.register(f"{base}/agent/suspend-hybrid", "POST", agent_suspend_hybrid)
|
|
registry.register(f"{base}/agent/suspend-ram", "POST", agent_suspend_ram)
|
|
registry.register(f"{base}/cloudinit", "GET", cloudinit_get)
|
|
registry.register(f"{base}/cloudinit", "PUT", cloudinit_update)
|
|
registry.register(f"{base}/cloudinit/dump", "GET", cloudinit_dump)
|
|
registry.register(f"{base}/rrd", "GET", rrd)
|
|
registry.register(f"{base}/rrddata", "GET", rrddata)
|
|
registry.register(f"{base}/monitor", "POST", monitor)
|
|
registry.register(f"{base}/sendkey", "PUT", sendkey)
|
|
registry.register(f"{base}/unlink", "PUT", unlink)
|
|
registry.register(f"{base}/vncproxy", "POST", vncproxy)
|
|
registry.register(f"{base}/spiceproxy", "POST", spiceproxy)
|
|
registry.register(f"{base}/termproxy", "POST", termproxy)
|
|
registry.register(f"{base}/mtunnel", "POST", mtunnel)
|
|
registry.register(f"{base}/vncwebsocket", "GET", vncwebsocket)
|
|
registry.register(f"{base}/mtunnelwebsocket", "GET", mtunnelwebsocket)
|
|
registry.register(f"{base}/dbus-vmstate", "POST", dbus_vmstate)
|