Part 3 of 3: target isolation, Windows preparation, and a validated RDP
connection.
Part 1
built the private Guacamole stack and gave guacd a dedicated target network.
Part 2
published the web application through Nginx while keeping its private listener
restricted to the reverse proxy.
This final part connects a private Windows VM through RDP. The target is added
to a firewall allowlist before the Guacamole connection is created, Windows
uses a dedicated non-administrator account, and credentials are prompted at
connection time rather than stored in the shared connection profile.
All names and addresses below are generic examples. Replace them with local
values. Do not publish real VM names, private addresses, usernames,
credentials, or access assignments.
Browser -> HTTPS/Nginx -> Guacamole web -> guacd -> approved VM:3389
|
all other targets dropped
Security boundary: signing in to Guacamole only grants access to a
connection definition. Windows still authenticates the RDP account and
applies its own local permissions. Neither layer replaces the other.
Pilot Design
The pilot uses:
- a non-critical Windows 11 Pro, Enterprise, or Education VM;
- RDP with Network Level Authentication;
- a dedicated, non-administrator Windows account;
- membership only in
Remote Desktop Users; - administrator-only Guacamole access during testing;
- explicit testing of stored versus user-prompted Windows credentials;
- no drive, printing, audio-input, or file-transfer redirection;
- persistent Trust on First Use certificate storage;
- a firewall rule allowing only the selected VM and RDP port.
Guacamole RDP is an administration path. It is not a replacement for a
low-latency game-streaming service and may lock or replace the active Windows
console session.
1. Confirm the Part 1 Network
Part 1 created:
network: guacamole_remote_access
bridge: br-guac-remote
subnet: 172.20.0.0/24
guacd address: 172.20.0.2
In the Guacamole container:
cd /opt/guacamole
docker network inspect guacamole_remote_access \
--format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{println}}{{end}}'
ip -brief address show br-guac-remote
docker compose exec -T guacd id
Expected:
guacamole-guacd 172.20.0.2/24
br-guac-remote has 172.20.0.1/24
guacd runs as its non-root image user
Stop if the network or fixed address differs from the firewall values below.
2. Prepare the Windows Target
On the Windows VM, open PowerShell as Administrator:
$cv = Get-ItemProperty `
'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion'
[pscustomobject]@{
ProductName = $cv.ProductName
EditionID = $cv.EditionID
DisplayVersion = $cv.DisplayVersion
Build = "$($cv.CurrentBuild).$($cv.UBR)"
}
[pscustomobject]@{
RDPEnabled = (
Get-ItemPropertyValue `
'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' `
'fDenyTSConnections'
) -eq 0
NLARequired = (
Get-ItemPropertyValue `
'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' `
'UserAuthentication'
) -eq 1
}
Get-Service TermService |
Select-Object Name, Status, StartType
Get-NetTCPConnection -State Listen -LocalPort 3389 `
-ErrorAction SilentlyContinue |
Select-Object LocalAddress, LocalPort, OwningProcess
Get-NetFirewallRule -DisplayGroup 'Remote Desktop' |
Where-Object Enabled -eq True |
Select-Object DisplayName, Profile, Direction, Action
Get-LocalGroupMember -Group 'Remote Desktop Users'
Get-LocalGroupMember -Group 'Administrators'
Required results:
- the Windows edition supports hosting RDP;
RDPEnabledisTrue;NLARequiredisTrue;TermServiceis running;- TCP 3389 is listening;
- the Remote Desktop firewall group is enabled;
- the dedicated RDP account is in
Remote Desktop Users; - the dedicated account is not in
Administrators.
Do not continue until those conditions are satisfied.
Create a Dedicated Local RDP User
Use a separate local Windows account for Guacamole rather than an everyday
desktop, Microsoft, or administrator account.
On Windows 11 Pro, Enterprise, or Education:
- Press
Win+R. - Enter
lusrmgr.mscand press Enter. - Select Users.
- Select Action > New User.
- Enter a neutral username that identifies the account as an RDP account.
- Enter a strong, unique password and confirm it.
- Clear User must change password at next logon. NLA cannot complete a
password-change workflow before the RDP session starts. - Leave Account is disabled cleared.
- Leave Password never expires cleared unless a documented password
rotation process cannot be implemented. - Select Create, then Close.
Add only the required RDP permission:
- In
lusrmgr.msc, select Groups. - Open Remote Desktop Users.
- Select Add.
- Enter the new local username.
- Select Check Names, then OK.
- Open Administrators and confirm the account is not a member.
Verify from an elevated PowerShell window:
Get-LocalUser |
Select-Object Name, Enabled, PasswordRequired, PasswordExpires
Get-LocalGroupMember -Group 'Remote Desktop Users'
Get-LocalGroupMember -Group 'Administrators'
The account should be enabled, require a password, appear in
Remote Desktop Users, and not appear in Administrators.
3. Add the Endpoint Firewall Policy
Part 2 created /usr/local/sbin/guacamole-firewall to restrict the private web
listener to Nginx. Replace that script with the complete version below, which
retains the web restriction and adds the VM-specific guacd allowlist.
The example values are:
Nginx proxy: 10.20.30.220
Guacamole listener: TCP 8080
guacd source: 172.20.0.2
RDP target: 10.20.30.202
RDP port: TCP 3389
Back up the current script:
cp -a /usr/local/sbin/guacamole-firewall \
/usr/local/sbin/guacamole-firewall.pre-target
Replace it:
cat > /usr/local/sbin/guacamole-firewall <<'EOF'
#!/bin/sh
set -eu
PATH=/usr/sbin:/usr/bin:/sbin:/bin
PARENT_CHAIN="DOCKER-USER"
WEB_CHAIN="GUAC-WEB"
PROXY_IP="10.20.30.220"
BACKEND_PORT="8080"
LAN_INTERFACE="eth0"
TARGET_CHAIN="GUAC-TARGETS"
REMOTE_INTERFACE="br-guac-remote"
GUACD_IP="172.20.0.2"
RDP_TARGET="10.20.30.202"
RDP_PORT="3389"
wait_for_docker_chain() {
attempts=0
until iptables -nL "$PARENT_CHAIN" >/dev/null 2>&1; do
attempts=$((attempts + 1))
if [ "$attempts" -ge 30 ]; then
echo "Timed out waiting for $PARENT_CHAIN" >&2
exit 1
fi
sleep 1
done
}
remove_web_jump() {
while iptables -C "$PARENT_CHAIN" \
-i "$LAN_INTERFACE" -p tcp --dport "$BACKEND_PORT" \
-j "$WEB_CHAIN" 2>/dev/null; do
iptables -D "$PARENT_CHAIN" \
-i "$LAN_INTERFACE" -p tcp --dport "$BACKEND_PORT" \
-j "$WEB_CHAIN"
done
}
remove_target_jump() {
while iptables -C "$PARENT_CHAIN" \
-i "$REMOTE_INTERFACE" -s "$GUACD_IP" \
-j "$TARGET_CHAIN" 2>/dev/null; do
iptables -D "$PARENT_CHAIN" \
-i "$REMOTE_INTERFACE" -s "$GUACD_IP" \
-j "$TARGET_CHAIN"
done
}
start_rules() {
wait_for_docker_chain
iptables -N "$WEB_CHAIN" 2>/dev/null || true
iptables -F "$WEB_CHAIN"
iptables -A "$WEB_CHAIN" \
-p tcp -s "$PROXY_IP" --dport "$BACKEND_PORT" \
-j ACCEPT
iptables -A "$WEB_CHAIN" \
-p tcp --dport "$BACKEND_PORT" \
-j DROP
remove_web_jump
iptables -I "$PARENT_CHAIN" 1 \
-i "$LAN_INTERFACE" -p tcp --dport "$BACKEND_PORT" \
-j "$WEB_CHAIN"
iptables -N "$TARGET_CHAIN" 2>/dev/null || true
iptables -F "$TARGET_CHAIN"
iptables -A "$TARGET_CHAIN" \
-p tcp -d "$RDP_TARGET" --dport "$RDP_PORT" \
-m conntrack --ctstate NEW,ESTABLISHED \
-j ACCEPT
iptables -A "$TARGET_CHAIN" -j DROP
remove_target_jump
iptables -I "$PARENT_CHAIN" 2 \
-i "$REMOTE_INTERFACE" -s "$GUACD_IP" \
-j "$TARGET_CHAIN"
while iptables -C "$PARENT_CHAIN" \
-i "$LAN_INTERFACE" -p tcp -s "$PROXY_IP" \
--dport "$BACKEND_PORT" -j ACCEPT 2>/dev/null; do
iptables -D "$PARENT_CHAIN" \
-i "$LAN_INTERFACE" -p tcp -s "$PROXY_IP" \
--dport "$BACKEND_PORT" -j ACCEPT
done
while iptables -C "$PARENT_CHAIN" \
-i "$LAN_INTERFACE" -p tcp --dport "$BACKEND_PORT" \
-j DROP 2>/dev/null; do
iptables -D "$PARENT_CHAIN" \
-i "$LAN_INTERFACE" -p tcp --dport "$BACKEND_PORT" \
-j DROP
done
}
stop_rules() {
if iptables -nL "$PARENT_CHAIN" >/dev/null 2>&1; then
remove_target_jump
remove_web_jump
fi
if iptables -nL "$TARGET_CHAIN" >/dev/null 2>&1; then
iptables -F "$TARGET_CHAIN"
iptables -X "$TARGET_CHAIN"
fi
if iptables -nL "$WEB_CHAIN" >/dev/null 2>&1; then
iptables -F "$WEB_CHAIN"
iptables -X "$WEB_CHAIN"
fi
}
case "${1:-}" in
start)
start_rules
;;
stop)
stop_rules
;;
*)
echo "Usage: $0 {start|stop}" >&2
exit 2
;;
esac
EOF
chmod 0750 /usr/local/sbin/guacamole-firewall
Restart the existing firewall service:
systemctl restart guacamole-firewall.service
systemctl status --no-pager guacamole-firewall.service
iptables -nvL DOCKER-USER --line-numbers
iptables -nvL GUAC-WEB --line-numbers
iptables -nvL GUAC-TARGETS --line-numbers
Expected:
DOCKER-USER rule 1 -> GUAC-WEB for eth0 TCP/8080
DOCKER-USER rule 2 -> GUAC-TARGETS for br-guac-remote source 172.20.0.2
GUAC-TARGETS rule 1 -> ACCEPT selected VM TCP/3389
GUAC-TARGETS rule 2 -> DROP everything else
4. Test the Allowed Path
From inside guacd:
docker compose exec -T guacd sh -c '
if command -v nc >/dev/null 2>&1; then
nc -zvw5 10.20.30.202 3389
elif command -v bash >/dev/null 2>&1; then
timeout 5 bash -c "</dev/tcp/10.20.30.202/3389"
else
echo "No TCP test utility available"
exit 2
fi
'
Expected:
connection to the selected VM on TCP 3389 succeeds
Check counters:
iptables -nvL GUAC-TARGETS --line-numbers
The packet counter on the allow rule must increase.
Test a known unapproved LAN destination and port:
docker compose exec -T guacd sh -c '
nc -zvw3 10.20.30.1 443
'
Expected:
the connection times out or fails
the DROP-rule packet counter increases
Restart Docker once before creating the connection:
systemctl restart docker
systemctl is-active docker
systemctl is-active guacamole-firewall.service
cd /opt/guacamole
docker compose ps
docker network inspect guacamole_remote_access \
--format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{println}}{{end}}'
iptables -nvL DOCKER-USER --line-numbers
iptables -nvL GUAC-TARGETS --line-numbers
Then repeat the allowed and denied connection tests. This confirms that Docker
restores the Compose-managed network, guacd retains its fixed source address,
and systemd restores the target policy.
5. Create the Guacamole RDP Connection
The connection is created only after the network test succeeds. This order
prevents a broadly configured Guacamole connection from becoming an accidental
route to systems that have not been approved.
Sign in as the Guacamole administrator and create a new RDP connection.
Use:
| Field | Pilot value |
|---|---|
| Name | a neutral administrative label |
| Protocol | RDP |
| Hostname | the private VM address |
| Port | 3389 |
| Username | leave blank for prompting |
| Password | leave blank for the pilot |
| Domain | leave blank for prompting |
| Security mode | NLA |
| Certificate handling | Trust on First Use |
| Drive redirection | disabled |
| Printing | disabled |
| Audio input | disabled |
| SFTP/file transfer | disabled |
| Clipboard | enable only if required |
| Resize method | display update |
Assign the connection only to the administrator during the pilot. Do not grant
access to all registered users until the session, audit, and credential model
have been validated.
Keep NLA required on the Windows target. Before the first connection, verify
that the persistent FreeRDP directory created in Part 1 is owned by the
guacd user and writable. A root-owned, read-only trust directory can cause
both forced NLA and Any to fail with the generic security-negotiation
message before credentials are evaluated.
Leave the complete authentication set blank if credentials must not be stored.
Guacamole will prompt for the Windows username and password when the
negotiated security protocol requests them. Supplying a username while leaving
only the password blank creates a partial preconfigured authenticator and can
prevent prompting.
6. Validate the Session
Open the connection through the public HTTPS endpoint and enter the dedicated
Windows username and password when prompted. For a local Windows account, use
the account name in the form accepted by that host, such as
COMPUTERNAME\username or .\username.
While the session is active, check Guacamole:
cd /opt/guacamole
docker compose logs --since=10m guacd
docker compose logs --since=10m guacamole
Required results:
- no fatal connection error;
- the RDP connection reaches the selected endpoint;
- the server certificate is added to persistent FreeRDP trust on first use;
- keyboard and display updates work;
- closing the browser ends the expected Guacamole session.
If guacd reports:
Security negotiation failed (wrong security type?)
before any authentication message, verify the FreeRDP trust-directory
ownership from Part 1 before changing the selected RDP security mode. If the
directory is not writable by UID 1000, correct it and retry with NLA before
changing Windows security policy.
Check persistent certificate trust:
docker compose exec -T guacd sh -c '
ls -l /home/guacd/.config/freerdp
test -s /home/guacd/.config/freerdp/known_hosts2 &&
echo "RDP certificate trust is persistent"
'
Expected:
known_hosts2 exists
RDP certificate trust is persistent
The live tunnel will also be verified in the Nginx access log and Guacamole
connection history before the connection is released to other users.
At this point the WebSocket path deferred from Part 2 is exercised for the
first time. Loading the Guacamole dashboard alone proves ordinary HTTPS
authentication; a working remote display proves that the long-lived tunnel,
Nginx upgrade headers, guacd, endpoint firewall, RDP negotiation, and Windows
authentication all work together.
7. Test Credential Behaviour with Another Guacamole User
Guacamole authorization and Windows authentication are separate:
- the Guacamole account controls whether the connection is visible and usable;
- Windows credentials authenticate the resulting RDP session;
- Windows credentials are not required from the user only when they are stored
in the connection, injected using tokens, or supplied by an external vault.
Use two temporary connection variants to confirm the intended behaviour.
Shared-Credential Test
Clone the working connection:
RDP Pilot - Shared Credential Test
Retain the dedicated Windows username and password in this clone. Grant
connection read access to a non-administrator Guacamole test user.
Expected result:
the Guacamole test user can open the RDP session without entering Windows
credentials because the connection supplies the shared account
Delete this clone after the test if shared credentials are not the selected
production model.
Prompted-Credential Test
Clone the connection again:
RDP Pilot - Prompted Credential Test
Clear all three Authentication values:
Username
Password
Domain
Keep NLA and Trust on First Use enabled. Grant read access to the same
non-administrator Guacamole test user.
Expected result:
Guacamole prompts for Windows credentials
an authorized Windows account connects
an invalid or unauthorized Windows account is rejected
This prompted model avoids storing a reusable Windows password in the
Guacamole connection and is the preferred model when different Guacamole users
should authenticate to Windows individually.
Additional Information
Windows Authentication Options
| Approach | Guacamole compatibility | Assessment |
|---|---|---|
| Windows Hello PIN or biometric | Not directly usable | The PIN unlocks a device-bound Windows Hello key and is not a reusable NLA network credential |
| Microsoft account app password | Not documented for Windows RDP | Avoid; it is a long-lived compatibility credential and does not provide an interactive MFA challenge |
| Microsoft account password | Usable by RDP | Works as a conventional password but does not trigger Microsoft Authenticator during the NLA exchange |
| Dedicated local Windows account and password | Supported and predictable | Recommended for a small self-hosted environment |
| Guacamole password, TOTP, then Windows password | Supported | Provides two independent authentication boundaries |
| Duo for Windows Logon and RDP | Supported by Duo, subject to local testing | Adds a second factor at Windows logon as well as any MFA protecting Guacamole |
| Windows Hello for Business certificate | Microsoft-supported for native RDP | Requires managed certificate and identity infrastructure and is not equivalent to forwarding a browser user's PIN through Guacamole |
| Microsoft Entra OpenID Connect for Guacamole | Supported for Guacamole authentication | Can replace or strengthen the Guacamole login, but does not automatically replace the downstream Windows RDP credential |
Recommended Windows User Profile
For a dedicated Guacamole RDP account:
- create a local account, not an administrator or everyday desktop identity;
- use a unique password stored in a password manager;
- grant membership only in
Remote Desktop Users; - keep NLA required;
- do not associate the profile with email, cloud storage, browser sync, or
other personal services; - keep the profile free of personal data and unnecessary applications;
- disable drive, printer, microphone, and file-transfer redirection unless a
specific workflow requires them; - apply normal Windows patching, lockout, password-length, and audit policies;
- disable the account when access is no longer required;
- rotate the password immediately if it has been exposed or shared beyond the
intended administrators.
For multiple people, prefer an individual Windows account for each person and
leave the Guacamole connection credentials blank so each user is prompted.
One stored shared account is simpler but gives all Guacamole users the same
Windows identity and audit trail.
Outcome
The completed design now has three separate controls:
- Nginx is the only system allowed to reach the Guacamole web listener.
- The controlled
guacdnetwork can reach only approved target addresses and
ports. - Each Windows target still requires an authorised, non-administrator Windows
account through NLA.
The browser session, public WebSocket tunnel, persistent certificate trust,
target allowlist, and prompted Windows login have all been validated. Guacamole
is suitable here as an administrative remote-access tool, not as a
high-frame-rate game or video-streaming platform.
References
- Apache Guacamole: Configuring Guacamole
- Apache Guacamole: Docker deployment and persistent RDP trust
- Apache Guacamole: Duo multi-factor authentication
- Apache Guacamole: OpenID Connect authentication
- Docker: Docker with iptables
- Microsoft: Enable Remote Desktop
- Microsoft: Remote Desktop sign-in with Windows Hello for Business
- Microsoft: App passwords
- Duo Authentication for Windows Logon and RDP
Recap: Adding Another Windows Machine
Use this sequence for each additional Windows connection:
-
Reserve the VM address and confirm native RDP works.
-
Keep Windows NLA enabled.
-
Create a dedicated local non-administrator account through
lusrmgr.msc. -
Add the account only to
Remote Desktop Users. -
Add the VM address and TCP 3389 to
GUAC-TARGETSor the equivalent owned
firewall chain before its final drop rule. -
Restart the firewall service and test:
guacd -> approved VM:3389 succeeds guacd -> unapproved destination fails -
Confirm the persistent FreeRDP directory remains owned by the
guacduser,
has mode0700, and is writable. -
Create the RDP connection using:
Security mode: NLA Ignore certificate: disabled Trust host certificate on first use: enabled Drive, printing, microphone and SFTP: disabled unless required -
Leave Username, Password, and Domain blank when each user should enter
individual Windows credentials. -
Grant only READ permission to the intended Guacamole users or groups.
-
Test through public HTTPS and review Guacamole connection history.
-
Remove temporary clones and any profile that stores unnecessary Windows
credentials.
The next connection guide will cover Linux VMs. It will distinguish browser
SSH administration from graphical access and will cover SSH key storage,
host-key pinning, SFTP restrictions, least-privilege Linux users, and the
additional controls required for VNC or RDP-based Linux desktops.
Series complete: return to Part 1
or review Part 2.
Comments are closed