Embed browser-based remote desktops (RDP, VNC, SSH, Telnet) into your Python
web application. Guapy is a framework-free asyncio library for the Guacamole
protocol and its guacd daemon — no Java webapp, no Node.js sidecar.
It speaks the Guacamole wire protocol end to end: immutable instructions, an
incremental bounded parser, version negotiation, the full guacd handshake,
an asyncio TCP transport, and a mountable ASGI WebSocket tunnel for FastAPI,
Starlette, and Django Channels.
Apache Guacamole renders remote desktops in the browser, driven by a small C
daemon called guacd. To use it from a Python application you traditionally
either run Guacamole's full Java webapp (its own auth model, its own database)
or hand-write the protocol glue. Guapy is the missing Python layer:
| Guapy | guacamole-lite (Node) | Apache Guacamole (Java) | |
|---|---|---|---|
| Language | Python | Node.js | Java |
| Async IO | asyncio | event loop | servlet threads |
| Runs inside your app | ✅ ASGI mount | ✅ | ❌ separate webapp |
| Per-connection authorization | ✅ resolver you control | encrypted token | its own DB |
| Runtime dependencies | zero | ws, deep-extend |
servlet container |
| Typed exceptions with HTTP/WS mappings | ✅ | ❌ | ✅ |
Guapy deliberately ships no authentication, token crypto, session registry, or database. Your app authorizes each tunnel through a one-method resolver; Guapy handles everything below that.
Mount the tunnel in FastAPI and decide per request which remote host a user gets — the resolver sees the query string, headers (cookies!), and client address:
from fastapi import FastAPI
from guapy import (
GuacamoleConfiguration,
GuacamoleUnauthorizedError,
GuacdEndpoint,
QueryWhitelistResolver,
ResolvedConnection,
TunnelRequest,
)
from guapy.server import GuacamoleASGIApp
app = FastAPI()
class DashboardResolver:
"""Authorize tunnels with your own session machinery."""
async def resolve(self, request: TunnelRequest) -> ResolvedConnection:
session_id = request.headers.get("cookie", "")
if not is_logged_in(session_id): # your application logic
raise GuacamoleUnauthorizedError("sign in first")
vm = lookup_vm_for_user(session_id) # your application logic
return ResolvedConnection(
GuacamoleConfiguration(
protocol="rdp",
parameters={
"hostname": vm.host,
"port": "3389",
"username": vm.username,
"password": vm.password,
},
)
)
tunnel = GuacamoleASGIApp(
endpoint=GuacdEndpoint(host="guacd.internal", port=4822),
resolver=DashboardResolver(),
)
app.mount("/guacamole", tunnel)Point guacamole-common-js
in the browser at /guacamole/webSocket and the remote desktop renders.
Prefer the safe default? QueryWhitelistResolver wraps a fixed connection and
lets clients override only what you whitelist (display size, color scheme…):
from guapy import QueryWhitelistResolver
resolver = QueryWhitelistResolver(
GuacamoleConfiguration(
protocol="ssh", parameters={"hostname": "bastion.internal", "port": "22"}
),
allowed=("width", "height", "dpi", "color-scheme"),
)Guapy also works as a plain guacd client, no web layer involved:
import asyncio
from guapy import GuacamoleClient, GuacamoleConfiguration, GuacdEndpoint, Instruction
async def main() -> None:
configuration = GuacamoleConfiguration(
protocol="ssh",
parameters={
"hostname": "ssh.example.internal",
"port": "22",
"username": "alice",
"password": "provided-by-your-application",
},
)
client = GuacamoleClient()
async with await client.connect(GuacdEndpoint(), configuration) as session:
await session.send(Instruction.create("sync", "0"))
instruction = await session.receive()
if instruction is not None:
print(instruction)
asyncio.run(main())Applications remain responsible for which configuration is authorized. Never send connection credentials to an untrusted client or log them.
- Immutable Guacamole instructions and a bounded incremental parser that survives arbitrary TCP fragmentation and split UTF-8 code points.
- Complete
guacdhandshake with protocol version negotiation, client capabilities (screen, audio/video/image formats, timezone, name), and support for joining existing sessions by connection ID. - Pluggable transports — asyncio TCP adapter included; TLS to guacd is a constructor flag. Bring your own connector for proxies or recording.
- Ordered instruction filter pipelines for inspecting, rewriting, or dropping instructions on read and write paths.
- Typed status model — every error carries its Guacamole status code and the mapped HTTP status and WebSocket close code, so tunnels close exactly the way the official Java server closes them.
- Production-shaped ASGI tunnel: per-connection resolvers, connection
limits, inactivity timeouts, open/close hooks,
GET /health, ASGI lifespan support, and graceful drain on shutdown.
Guapy requires Python 3.10+ and installs with zero dependencies.
The e2e fixture is a complete Guacamole deployment in one command — Guapy,
official guacd, a containerized SSH server (demo/demo), and a browser
client:
docker compose -f e2e/docker-compose.yml up --buildOpen http://localhost:9090, select New Connection → SSH →
Connect. The same stack backs the integration tests:
GUAPY_TEST_GUACD_HOST=127.0.0.1 GUAPY_TEST_SSH_HOST=sshd uv run pytest tests/integrationCI runs exactly these tests against real guacd and sshd containers on
every push — the handshake is verified against the actual daemon, not mocks.
Guapy uses the standard-library logger named guapy and never configures
handlers or levels. It logs lifecycle events (tunnel open/close, protocol
disconnects, guacd EOF) and never logs instructions or credentials.
uv sync --dev
uv run pytest # unit tests (integration tests skip without guacd)
uv run ruff check .
uv run mypy srcSee Architecture.md for the design, layering, and the boundaries Guapy deliberately does not cross. Contributions welcome — start with CONTRIBUTING.md.
MIT. See LICENSE.