- Go 61.7%
- Vue 25.3%
- CSS 7.6%
- TypeScript 4.1%
- Dockerfile 0.6%
- Other 0.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Typing a/O/u/sz and other non-ASCII characters into an RDP session was broken or produced wrong characters. Two independent causes, both fixed. Client side (viewer): - guacamole-common-js resolves keydown events through a US-only key-code table; on a German keyboard the umlaut keys collide with US punctuation (a -> apostrophe, u -> semicolon, sz -> bracket) and the correctness of the keypress fallback depends on browser and input-method behavior. - The viewer now intercepts keydown in the capture phase on document, registered before the Guacamole.Keyboard so it can preempt it. Any single non-ASCII character is sent directly as a Guacamole Unicode keysym (Latin-1 keysyms equal their codepoint, higher codepoints are 0x01000000 | codepoint) with an immediate press/release pair, bypassing the key-code table entirely. ASCII keys, modifier shortcuts (Ctrl+…) and IME composition keep flowing through the regular path; AltGr characters (@, EUR sign) are covered by the same interception since they arrive as single characters. - Verified end-to-end through the WebSocket bridge against mockguacd: a/oe/ue/sz arrive as keysyms 228/246/252/223, Shift+a as 196, all with clean press/release pairs, while Enter and Ctrl+C still go through guacamole's own interpretation. Server side (connection option): - guacd translates keysyms it cannot map into scancodes using its keyboard layout, which defaults to en-us-qwerty - a German umlaut keysym has no mapping there, so characters came out wrong or not at all depending on the target's Unicode input support. - RDP connections gain a keyboard-layout option (guacd keymap name, e.g. de-de-qwertz) that should match the CLIENT's keyboard layout; it is stored with the connection, sent in the guacd handshake and editable in the connection form. The schema column is added through the incremental migration. To fix umlauts in an existing deployment: edit the RDP connection and set Keyboard layout to the layout used on the client side. |
||
| docs | ||
| internal | ||
| tools | ||
| web | ||
| .dockerignore | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| embed.go | ||
| entrypoint.sh | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| main.go | ||
| Makefile | ||
| README.md | ||
A self-hosted bastion gateway for RDP, SSH and VNC. Open remote sessions in the browser — brokered, audited, recorded.
WardGate sits between your users and your remote machines — Windows desktops over RDP, Linux servers over SSH, workstations over VNC. Users log in (locally or via any OIDC provider), pick a connection and get a full remote session in the browser — no VPN, no RDP/SSH/VNC port exposed to clients, no local client installation. Every login, session and admin action lands in an audit trail; sessions can be recorded; access is granted per user or per group.
It ships as one Docker image containing the Go server, the embedded
Vue frontend and Apache guacd with its RDP, SSH and VNC plugins.
| Login | Session viewer |
|---|---|
![]() |
![]() |
Features
- 🖥️ RDP in the browser — Apache Guacamole protocol via
guacamole-common-js: keyboard, mouse, touch, dynamic display resizing (display-update), clipboard sync and fullscreen mode - 🐧 SSH terminal in the browser — public-key or password authentication (keys in OpenSSH format, optionally passphrase-protected, with the password kept as fallback), terminal size follows the browser window
- 🖱️ VNC in the browser — attach to existing graphical sessions
(macOS screen sharing,
x11vnc, Hyper-V/ESXi consoles, …), password auth, configurable color depth - 🧭 First-run setup wizard — create the initial admin (locally or by linking your identity provider), set the public URL and connect an OIDC provider interactively in the web UI; settings are persisted to a config file, no env var juggling required
- 🛠️ Runtime admin panel — change public URL, SSO settings and the login policy in the web UI while the server is running; everything is written to the config file and applied immediately, no restart
- 🔐 Two auth backends — local accounts (bcrypt + JWT session cookie + brute-force rate limiting) and OIDC single sign-on (Keycloak, Authentik, Entra ID, …) with just-in-time user provisioning and group-based admin mapping
- 🗝️ Fine-grained access control — grant connections to individual users and/or groups; connections without grants are open to every authenticated user
- 📜 Audit trail — logins, permission changes, session start/end with duration, all queryable from the admin UI
- ⏺️ Session recording — opt-in per connection (all protocols),
stored as
.guacfiles on the data volume (playable with Apache Guacamole's recording players), downloadable from the UI - 🔒 Security-first — target credentials encrypted at rest
(AES-256-GCM), strict CSP (no
unsafe-inline), HSTS, nosniff, frame-deny, non-root container runtime - 📱 Mobile-friendly — responsive layout with hamburger navigation and scrollable tables; in the viewer, touch input maps taps and drags to the remote and an on-screen keyboard toggle brings up the soft keyboard
- 📦 One image — the frontend is embedded via
go:embed,guacdis supervised as a child process (or pointed at an external one); the whole bastion isdocker runaway
Architecture
flowchart LR
U(("👩💻 User")) --> B["Browser<br/>Vue 3 SPA<br/>guacamole-common-js"]
B -- "WebSocket<br/>Guacamole protocol" --> W["WardGate server<br/>Go binary"]
subgraph C["Single container"]
direction TB
W -- supervises --> D["guacd"]
D -- "RDP · SSH · VNC" --> T["Windows · Linux · VNC targets"]
W --> S[("SQLite<br/>users · connections<br/>permissions · audit")]
W --> R[("Recordings<br/>.guac")]
end
The browser never speaks RDP/SSH/VNC. It talks the Guacamole WebSocket
protocol to the WardGate server, which performs the guacd handshake
server-side (with name-based argument mapping, so it works across
guacd versions), checks permissions against the database, then bridges
both streams. The guacd select carries the connection's protocol, so
guacd offers exactly the parameters that protocol supports.
Quick start
docker compose up -d
or manually:
docker build -t wardgate:latest .
docker run -d --name wardgate \
-p 8080:8080 \
-v wardgate-data:/data \
wardgate:latest
Open http://localhost:8080 — a setup wizard walks you through the
first steps right in the browser:
- Create the initial admin account — locally with username and password, or via single sign-on: pick Single sign-on and the first person who signs in through your identity provider becomes the admin
- Set the public URL users will use (drives the Secure cookie flag and the SSO redirect address)
- Optionally connect an OIDC provider (Keycloak, Authentik, Entra ID, …) — the connection is verified before it is saved
Everything you enter is written to wardgate.json in the data directory
and takes effect immediately, no restart needed. The wizard only runs
while no user exists; automated/headless deployments can skip it by
pre-seeding WARDGATE_ADMIN_PASSWORD (see below).
After setup, create your first target under Manage → New connection and pick its protocol (RDP, SSH or VNC — the port follows the protocol).
Migrating from WardRDP? Renamed files and variables:
wardrdp.json→wardgate.json,wardrdp.db→wardgate.db,WARDRDP_*→WARDGATE_*. The server keeps using the oldwardrdp.*files when they exist and no new-style path is set, so state survives the rename without manual steps.
Configuration
There are two layers:
- The admin panel (Config in the top bar) edits the settings below
in the running server: public URL, OIDC/SSO and the login policy.
Changes are validated, applied immediately and persisted to
wardgate.json— no restart needed. wardgate.json(in the data directory) is the file those settings live in. The setup wizard writes it; you can also edit it by hand (restart afterwards). It is created with0600permissions because it contains the OIDC client secret. When this file exists it is authoritative — matching environment variables are ignored.- Environment variables configure the infrastructure that must be known before the config file can be found (listen address, data dir, guacd, logging) and act as a fallback for public URL / OIDC on installations that never ran the wizard.
// /data/wardgate.json — written by the setup wizard and the admin panel
{
"setup_completed": true,
"public_url": "https://remote.example.com",
"local_login_disabled": false, // true = sign-in restricted to SSO
"oidc": {
"issuer": "https://sso.example.com/realms/main",
"client_id": "wardgate",
"client_secret": "…",
"redirect_url": "", // empty = <public_url>/api/auth/oidc/callback
"scopes": ["openid", "profile", "email", "groups"],
"admin_groups": ["bastion-admins"],
"label": "SSO"
}
}
Set "oidc": null (or remove the section) to keep local login only.
Password login can only be disabled ("local_login_disabled": true)
while SSO is enabled — the server rejects the combination, so you cannot
lock yourself out through the UI.
Environment variables (infrastructure)
| Variable | Default | Description |
|---|---|---|
WARDGATE_LISTEN |
:8080 |
HTTP listen address |
WARDGATE_DATA |
/data |
State directory (SQLite, config, secret key, recordings) |
WARDGATE_DB |
$WARDGATE_DATA/wardgate.db |
SQLite database path |
WARDGATE_CONFIG |
$WARDGATE_DATA/wardgate.json |
Config file path |
WARDGATE_SECRET |
generated → secret.key |
JWT + credential-encryption secret |
WARDGATE_TRUSTED_PROXIES |
127.0.0.1/8, ::1/128 |
IPs/CIDRs of reverse proxies whose X-Forwarded-For/X-Real-IP headers are trusted |
WARDGATE_SESSION_TTL |
12h |
Session cookie lifetime |
WARDGATE_COOKIE_SECURE |
auto |
auto = Secure + HSTS when the public URL is https |
WARDGATE_GUACD_ADDR |
127.0.0.1:4822 |
guacd address |
WARDGATE_EMBED_GUACD |
true |
Spawn/supervise guacd as a child process |
WARDGATE_GUACD_BIN |
probed | Explicit guacd binary path |
WARDGATE_GUACD_BIN_CANDIDATES |
/usr/sbin/guacd,/usr/bin/guacd,… |
Probe list when no explicit path is set |
WARDGATE_RECORDING_DIR |
$WARDGATE_DATA/recordings |
Where .guac recordings are written |
WARDGATE_LOG_LEVEL |
info |
debug logs every protocol instruction |
Environment variables (headless first-run fallback)
Only consulted while no wardgate.json exists. Setting
WARDGATE_ADMIN_PASSWORD pre-seeds the admin account and skips the
wizard for automated deployments.
| Variable | Default | Description |
|---|---|---|
WARDGATE_ADMIN_USER |
admin |
First-run admin username |
WARDGATE_ADMIN_PASSWORD |
– | Pre-seed the admin account (skips the wizard) |
WARDGATE_PUBLIC_URL |
– | Public base URL of the bastion |
WARDGATE_OIDC_ISSUER |
– | OIDC issuer URL (enables SSO together with the two below) |
WARDGATE_OIDC_CLIENT_ID |
– | OIDC client id |
WARDGATE_OIDC_CLIENT_SECRET |
– | OIDC client secret |
WARDGATE_OIDC_REDIRECT_URL |
$PUBLIC_URL/api/auth/oidc/callback |
OAuth2 redirect URI |
WARDGATE_OIDC_SCOPES |
openid,profile,email,groups |
Requested scopes |
WARDGATE_OIDC_ADMIN_GROUPS |
– | Comma-separated groups that get the admin role |
WARDGATE_OIDC_LABEL |
SSO |
Sign-in button label |
OIDC / SSO
Configure SSO in the setup wizard, in the admin panel (Config), or
directly in wardgate.json. Register WardGate as a confidential client
with the authorization-code flow and the redirect URI
https://<bastion>/api/auth/oidc/callback.
- Keycloak — issuer
https://sso.example.com/realms/<realm>; add thegroupsclient scope (with a Group Membership mapper) to the token - Authentik — issuer
https://auth.example.com/application/o/wardgate/; includegroupsin the property mappings - Entra ID — issuer
https://login.microsoftonline.com/<tenant>/v2.0; group claims require the groups claim on the ID token — WardGate falls back to the userinfo endpoint when the token has none
SSO users are provisioned on first login (JIT). If admin groups are configured, the role is re-derived from group membership on every login; otherwise roles are managed manually. Two extras around the first account:
- Admin via SSO — chosen in the setup wizard: while no user exists, the first SSO sign-in claims the admin role. If admin groups are set, membership decides instead (the restriction wins).
- OIDC-only login — in the admin panel, password login can be disabled entirely; the login page then shows only the SSO button.
Verified Keycloak test setup
WardGate's OIDC flow is tested end-to-end against a stock Keycloak container:
docker run -d --name keycloak-test -p 8090:8080 \
-e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:latest start-dev
- Realm
wardgate-test; confidential clientwardgate(secret of your choice, standard flow enabled) with redirect URIhttp://localhost:8080/api/auth/oidc/callback - Client scope
groupscontaining a Group Membership mapper (claim namegroups, full path off, added to ID/access/userinfo tokens), assigned as a default client scope of the client - Groups (e.g.
bastion-admins) with users assigned - Point WardGate at it — either through the setup wizard, by editing
wardgate.json, or via the env fallback:
WARDGATE_OIDC_ISSUER=http://localhost:8090/realms/wardgate-test
WARDGATE_OIDC_CLIENT_ID=wardgate
WARDGATE_OIDC_CLIENT_SECRET=…
WARDGATE_OIDC_ADMIN_GROUPS=bastion-admins
When testing repeated logins, end the IdP session via
…/protocol/openid-connect/logout — WardGate's own logout only ends the
local session (the IdP SSO session persists by design).
Connection options
The protocol of a connection (rdp, ssh or vnc) decides what
guacd speaks and which options apply; the default port follows the
protocol (3389 / 22 / 5900).
| Option | Protocols | What it does |
|---|---|---|
security |
RDP | RDP security negotiation: any (default), rdp, tls, nla |
keyboard-layout |
RDP | guacd keymap that should match the client's keyboard (e.g. de-de-qwertz); wrong/missing umlauts and special characters almost always mean this does not match |
ignore-cert |
RDP | Accept self-signed/untrusted TLS certificates from the target |
resize-method |
RDP | display-update resizes the remote desktop live with the browser window; reconnect reconnects; empty keeps a fixed size |
color-depth |
RDP, VNC | Force 8/16/24/32-bit color (default: negotiate) |
read-only |
all | View-only session — keyboard and mouse input are not forwarded |
recording |
all | Record the session as a .guac file |
private-key / key-passphrase |
SSH | OpenSSH private key for public-key auth (optional passphrase); tried before the stored password, which remains the fallback |
| domain / username / password | RDP, SSH | Credentials for the target (stored AES-256-GCM encrypted; for RDP an empty password = user is prompted at the login screen). VNC uses only the password |
Session recordings
Enable Record session on a connection. Every session is written to the
data volume as a Guacamole instruction capture (.guac) and shows up
under Recordings for admins, with size, timestamp and download.
Playback uses Apache Guacamole's session recording
players.
Local development
Requirements: Go 1.26+, Node 22+ and any guacd — or the bundled
mock.
make build-web # npm install + vite build → web/dist
make build # go build (embeds web/dist) → bin/wardgate
make run # serve on :8080, state in ./data
# frontend dev server with hot reload (proxies /api → :8080):
cd web && npm run dev
# end-to-end without a real Windows/Linux box — the mock guacd renders a
# static test desktop for any protocol (rdp/ssh/vnc), emulates session
# recordings and logs every key/mouse event:
go run ./tools/mockguacd -listen 127.0.0.1:4822 &
WARDGATE_DATA=./data WARDGATE_EMBED_GUACD=false ./bin/wardgate
# open a recorded session headlessly (login + WebSocket + guacamole
# handshake) to exercise the recording pipeline:
go run ./tools/sessionclient
Run the protocol unit tests with make test (Guacamole codec, wire
format, UTF-8 length prefixes, tunnel-internal pings, connect-argument
mapping for RDP, SSH and VNC, protocol selection in the guacd
handshake). The whole stack — browser, WebSocket bridge, guacd protocol,
JIT provisioning, audit trail — is exercised regularly against the mock
and a Keycloak test container.
Project layout
main.go entry point, wiring, admin bootstrap
embed.go go:embed of web/dist
internal/config/ wardgate.json config file + env configuration
internal/store/ SQLite (modernc, CGO-free): users, connections, permissions, audit
internal/auth/ JWT sessions, AES-GCM credential encryption, rate limit, OIDC
internal/guac/ Guacamole wire protocol, guacd handshake, guacd supervisor
internal/api/ REST API + Guacamole WebSocket bridge
internal/server/ mux, security headers, SPA serving
web/ Vue 3 + Vite frontend (viewer, admin, stores)
tools/mockguacd/ fake guacd for end-to-end testing
docs/ logo, icon, screenshots
Dockerfile node build → go build → alpine + guacamole-server
Security notes
- Server and
guacdrun as an unprivileged user in the container; only the entrypoint (root) adjusts/dataownership - Target credentials on connections are encrypted at rest (AES-256-GCM,
key derived from
WARDGATE_SECRET); they are never sent back to the browser - Session cookies are
HttpOnly,SameSite=Lax; roles and permissions are re-read from the database on every request, including every WebSocket upgrade - Responses carry a strict CSP (
script-src 'self', nounsafe-inline,frame-ancestors 'none'),X-Frame-Options: DENY,nosniffand — over HTTPS — HSTS - Login attempts are rate-limited (5 failures / 15 min per user+IP) with timing-equalized responses to prevent user enumeration
- Audit entries record the real client IP: proxy headers are only
honored when the direct peer is in
WARDGATE_TRUSTED_PROXIES, and the rightmost non-trustedX-Forwarded-Forentry wins — clients cannot spoof their IP through their own header. The same resolution drives the rate-limit bucket, so proxied clients are not throttled as one - The setup wizard only exists while no user account does — it cannot be
re-opened on a running instance;
wardgate.json(contains the OIDC client secret) is written atomically with0600permissions - The OIDC state parameter is bound to an HttpOnly cookie and single-use
- For production, terminate TLS in front (Caddy, Traefik, nginx) and set
the public URL (wizard or
wardgate.json) to the https URL - Sessions are stateless JWTs: revocation is not implemented — use a
short
WARDGATE_SESSION_TTLif that matters to you
Roadmap & limitations
- Recording playback UI is not included (download + Guacamole player works today)
- SSH terminal file transfer (SFTP) is not exposed yet; SFTP is disabled on the guacd side
- No microphone input; RDP audio output uses raw PCM
- Session revocation is not implemented
License
MIT © Tronax

