Apache Guacamole Part 3: Connecting Private Virtual Machines

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;
  • RDPEnabled is True;
  • NLARequired is True;
  • TermService is 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:

  1. Press Win+R.
  2. Enter lusrmgr.msc and press Enter.
  3. Select Users.
  4. Select Action > New User.
  5. Enter a neutral username that identifies the account as an RDP account.
  6. Enter a strong, unique password and confirm it.
  7. Clear User must change password at next logon. NLA cannot complete a
    password-change workflow before the RDP session starts.
  8. Leave Account is disabled cleared.
  9. Leave Password never expires cleared unless a documented password
    rotation process cannot be implemented.
  10. Select Create, then Close.

Add only the required RDP permission:

  1. In lusrmgr.msc, select Groups.
  2. Open Remote Desktop Users.
  3. Select Add.
  4. Enter the new local username.
  5. Select Check Names, then OK.
  6. 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:

  1. Nginx is the only system allowed to reach the Guacamole web listener.
  2. The controlled guacd network can reach only approved target addresses and
    ports.
  3. 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

Recap: Adding Another Windows Machine

Use this sequence for each additional Windows connection:

  1. Reserve the VM address and confirm native RDP works.

  2. Keep Windows NLA enabled.

  3. Create a dedicated local non-administrator account through lusrmgr.msc.

  4. Add the account only to Remote Desktop Users.

  5. Add the VM address and TCP 3389 to GUAC-TARGETS or the equivalent owned
    firewall chain before its final drop rule.

  6. Restart the firewall service and test:

    guacd -> approved VM:3389 succeeds
    guacd -> unapproved destination fails
    
  7. Confirm the persistent FreeRDP directory remains owned by the guacd user,
    has mode 0700, and is writable.

  8. 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
    
  9. Leave Username, Password, and Domain blank when each user should enter
    individual Windows credentials.

  10. Grant only READ permission to the intended Guacamole users or groups.

  11. Test through public HTTPS and review Guacamole connection history.

  12. 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.

Tags:

Comments are closed

Latest Comments