165 lines
5.5 KiB
Python
165 lines
5.5 KiB
Python
"""Guacamole protocol helpers (instruction encode/decode + guacd handshake)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import uuid
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger("nexus.guacamole.protocol")
|
|
|
|
|
|
def encode_element(value: str) -> str:
|
|
s = value if value is not None else ""
|
|
return f"{len(s)}.{s}"
|
|
|
|
|
|
def encode_instruction(opcode: str, *args: str) -> str:
|
|
parts = [encode_element(opcode)] + [encode_element(a) for a in args]
|
|
return ",".join(parts) + ";"
|
|
|
|
|
|
def parse_instruction(message: str) -> tuple[str, list[str]]:
|
|
"""Parse one complete instruction (without trailing semicolon)."""
|
|
elements: list[str] = []
|
|
i = 0
|
|
length = len(message)
|
|
while i < length:
|
|
dot = message.find(".", i)
|
|
if dot < 0:
|
|
raise ValueError(f"invalid guacamole instruction at {i}")
|
|
el_len = int(message[i:dot])
|
|
start = dot + 1
|
|
end = start + el_len
|
|
elements.append(message[start:end])
|
|
i = end
|
|
if i < length and message[i] == ",":
|
|
i += 1
|
|
else:
|
|
break
|
|
if not elements:
|
|
raise ValueError("empty guacamole instruction")
|
|
return elements[0], elements[1:]
|
|
|
|
|
|
class GuacdStream:
|
|
"""Buffered guacd TCP reader — one instruction per read()."""
|
|
|
|
def __init__(self, reader: asyncio.StreamReader):
|
|
self._reader = reader
|
|
self._buf = b""
|
|
|
|
async def read_instruction(self) -> tuple[str, list[str]]:
|
|
while True:
|
|
semi = self._buf.find(b";")
|
|
if semi >= 0:
|
|
raw = self._buf[:semi]
|
|
self._buf = self._buf[semi + 1 :]
|
|
msg = raw.decode("utf-8", errors="replace")
|
|
return parse_instruction(msg)
|
|
chunk = await self._reader.read(8192)
|
|
if not chunk:
|
|
raise ConnectionError("guacd closed during read")
|
|
self._buf += chunk
|
|
|
|
async def read_raw_instruction(self) -> bytes:
|
|
"""Read one complete guacd instruction (including trailing ``;``)."""
|
|
while True:
|
|
semi = self._buf.find(b";")
|
|
if semi >= 0:
|
|
raw = self._buf[: semi + 1]
|
|
self._buf = self._buf[semi + 1 :]
|
|
return raw
|
|
chunk = await self._reader.read(8192)
|
|
if not chunk:
|
|
raise ConnectionError("guacd closed during read")
|
|
self._buf += chunk
|
|
|
|
async def read_raw_batch(self) -> bytes:
|
|
"""Read all currently buffered complete instructions (may be multiple)."""
|
|
parts: list[bytes] = []
|
|
while True:
|
|
semi = self._buf.find(b";")
|
|
if semi < 0:
|
|
if not parts:
|
|
chunk = await self._reader.read(8192)
|
|
if not chunk:
|
|
if parts:
|
|
break
|
|
raise ConnectionError("guacd closed during read")
|
|
self._buf += chunk
|
|
continue
|
|
break
|
|
parts.append(await self.read_raw_instruction())
|
|
return b"".join(parts)
|
|
|
|
|
|
def _param_for_arg(arg_name: str, params: dict[str, str]) -> str:
|
|
"""Map guacd arg name to connection parameter value."""
|
|
normalized = arg_name.replace("_", "-").lower()
|
|
for key, value in params.items():
|
|
if key.replace("_", "-").lower() == normalized:
|
|
return value or ""
|
|
return ""
|
|
|
|
|
|
async def guacd_rdp_handshake(
|
|
stream: GuacdStream,
|
|
writer: asyncio.StreamWriter,
|
|
params: dict[str, str],
|
|
*,
|
|
width: int = 1024,
|
|
height: int = 768,
|
|
dpi: int = 96,
|
|
) -> str:
|
|
"""Run guacd handshake for RDP; return connection id from ``ready``."""
|
|
writer.write(encode_instruction("select", "rdp").encode("utf-8"))
|
|
await writer.drain()
|
|
|
|
arg_names: list[str] = []
|
|
while True:
|
|
opcode, args = await stream.read_instruction()
|
|
if opcode == "args":
|
|
arg_names = list(args)
|
|
break
|
|
if opcode == "error":
|
|
raise RuntimeError(args[0] if args else "guacd error during args")
|
|
logger.warning("guacd unexpected during select: %s %s", opcode, args)
|
|
|
|
writer.write(encode_instruction("size", str(width), str(height), str(dpi)).encode("utf-8"))
|
|
await writer.drain()
|
|
writer.write(encode_instruction("audio").encode("utf-8"))
|
|
await writer.drain()
|
|
writer.write(encode_instruction("video").encode("utf-8"))
|
|
await writer.drain()
|
|
|
|
connect_values = [_param_for_arg(name, params) for name in arg_names]
|
|
writer.write(encode_instruction("connect", *connect_values).encode("utf-8"))
|
|
await writer.drain()
|
|
|
|
while True:
|
|
opcode, args = await stream.read_instruction()
|
|
if opcode == "ready":
|
|
if not args:
|
|
raise RuntimeError("guacd ready without connection id")
|
|
return args[0]
|
|
if opcode == "error":
|
|
raise RuntimeError(args[0] if args else "guacd connection failed")
|
|
logger.debug("guacd during handshake: %s %s", opcode, args)
|
|
|
|
|
|
def tunnel_open_instruction(connection_id: Optional[str] = None) -> str:
|
|
"""Empty opcode + UUID — tells guacamole-common-js the tunnel is OPEN."""
|
|
cid = connection_id or str(uuid.uuid4())
|
|
return encode_instruction("", cid)
|
|
|
|
|
|
def is_internal_instruction(message: str) -> bool:
|
|
"""True if message uses internal (empty) opcode — not for guacd."""
|
|
try:
|
|
opcode, _ = parse_instruction(message.rstrip(";"))
|
|
return opcode == ""
|
|
except ValueError:
|
|
return False
|