No description
  • Go 61.7%
  • Vue 25.3%
  • CSS 7.6%
  • TypeScript 4.1%
  • Dockerfile 0.6%
  • Other 0.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Tronax 95107ac316
Fix typing umlauts and special characters in RDP sessions
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.
2026-10-20 08:20:26 +02:00
docs Rename to WardGate and add SSH and VNC protocols 2026-10-19 12:48:47 +02:00
internal Fix typing umlauts and special characters in RDP sessions 2026-10-20 08:20:26 +02:00
tools Add SSH public-key auth, fix viewer cursor edges, add recording diagnostics 2026-10-20 06:30:36 +02:00
web Fix typing umlauts and special characters in RDP sessions 2026-10-20 08:20:26 +02:00
.dockerignore WardRDP: RDP bastion gateway with local + OIDC auth 2026-10-07 07:08:54 +02:00
.gitignore WardRDP: RDP bastion gateway with local + OIDC auth 2026-10-07 07:08:54 +02:00
docker-compose.yml Rename to WardGate and add SSH and VNC protocols 2026-10-19 12:48:47 +02:00
Dockerfile Rename to WardGate and add SSH and VNC protocols 2026-10-19 12:48:47 +02:00
embed.go WardRDP: RDP bastion gateway with local + OIDC auth 2026-10-07 07:08:54 +02:00
entrypoint.sh Rename to WardGate and add SSH and VNC protocols 2026-10-19 12:48:47 +02:00
go.mod Rename to WardGate and add SSH and VNC protocols 2026-10-19 12:48:47 +02:00
go.sum WardRDP: RDP bastion gateway with local + OIDC auth 2026-10-07 07:08:54 +02:00
LICENSE Add branded README with logo, screenshots and badges 2026-10-07 07:17:26 +02:00
main.go Add SSH public-key auth, fix viewer cursor edges, add recording diagnostics 2026-10-20 06:30:36 +02:00
Makefile Rename to WardGate and add SSH and VNC protocols 2026-10-19 12:48:47 +02:00
README.md Fix typing umlauts and special characters in RDP sessions 2026-10-20 08:20:26 +02:00

WardGate logo

A self-hosted bastion gateway for RDP, SSH and VNC. Open remote sessions in the browser — brokered, audited, recorded.

Version Go Vue guacamole-server Docker Tests License


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
WardGate login screen WardGate 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 .guac files 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, guacd is supervised as a child process (or pointed at an external one); the whole bastion is docker run away

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:

  1. 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
  2. Set the public URL users will use (drives the Secure cookie flag and the SSO redirect address)
  3. 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 old wardrdp.* 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 with 0600 permissions 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 the groups client scope (with a Group Membership mapper) to the token
  • Authentik — issuer https://auth.example.com/application/o/wardgate/; include groups in 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
  1. Realm wardgate-test; confidential client wardgate (secret of your choice, standard flow enabled) with redirect URI http://localhost:8080/api/auth/oidc/callback
  2. Client scope groups containing a Group Membership mapper (claim name groups, full path off, added to ID/access/userinfo tokens), assigned as a default client scope of the client
  3. Groups (e.g. bastion-admins) with users assigned
  4. 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 guacd run as an unprivileged user in the container; only the entrypoint (root) adjusts /data ownership
  • 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', no unsafe-inline, frame-ancestors 'none'), X-Frame-Options: DENY, nosniff and — 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-trusted X-Forwarded-For entry 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 with 0600 permissions
  • 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_TTL if 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