kubernetesnetworkingrdp

Browser-Based Ubuntu Desktop with 3-Factor Auth and Zero Open Ports

Browser-Based Ubuntu Desktop with 3-Factor Auth and Zero Open Ports

Browser-Based Ubuntu Desktop with 3-Factor Auth and Zero Open Ports

Access your home lab from anywhere — Google OTP, Guacamole password, TOTP — then land directly on your Ubuntu desktop. No VPN client. No open ports. Free.


The Problem

Working remotely from a home lab usually means one of three bad options:

  • Port-forward SSH/RDP — your home IP is exposed, one misconfiguration away from the internet
  • Commercial VPN — monthly cost, vendor dependency, client software on every device
  • Tailscale/ZeroTier — great, but still needs a client installed on every machine you work from

I wanted to open a browser tab, authenticate three times, and land on a full Ubuntu GNOME desktop — from any device, anywhere, with zero ports open on the home router.

Here’s exactly how I built it.


Architecture

flowchart TD
    Browser["Any Browser\n(phone, work laptop, friend's PC)"]
    CF["☁️ Cloudflare Edge\nDDoS · WAF · Rate Limiting\nHome IP hidden behind proxy"]
    CFA["🔐 Cloudflare Access\nFactor 1: Email One-Time PIN\nBlocks unauthenticated requests at edge"]
    CT["cloudflared pod\nnamespace: tunnel\n2 replicas — outbound only\nZero inbound ports on router"]
    GUA["Apache Guacamole\nnamespace: guacamole\nFactor 2: Username + Password\nFactor 3: TOTP (6-digit code)"]
    GUACD["guacd\nProtocol daemon\nRDP · SSH · VNC"]
    LAPTOP["Ubuntu Desktop\nxrdp on port 3389\n100.89.50.27 (Tailscale IP)"]
    DB["PostgreSQL\nUsers · Connections\nAudit log"]

    Browser -->|"HTTPS guac.thecylon.org"| CF
    CF -->|"WAF inspect"| CFA
    CFA -->|"OTP verified — JWT issued"| CT
    CT -->|"NetworkPolicy: tunnel ns only\nport 8080"| GUA
    GUA -->|"Auth: password + TOTP"| DB
    GUA -->|"NetworkPolicy: port 4822 only"| GUACD
    GUACD -->|"RDP — locked to 100.89.50.27:3389\nguacd-egress NetworkPolicy"| LAPTOP

    style CF fill:#1a1200,stroke:#f39c12,color:#f5f5f5
    style CFA fill:#1a0808,stroke:#c0392b,color:#f5f5f5
    style CT fill:#001a12,stroke:#00b894,color:#f5f5f5
    style GUA fill:#05051a,stroke:#4f8ef7,color:#f5f5f5
    style GUACD fill:#05051a,stroke:#4f8ef7,color:#f5f5f5
    style LAPTOP fill:#120820,stroke:#a29bfe,color:#f5f5f5
    style DB fill:#05051a,stroke:#4f8ef7,color:#f5f5f5

Authentication Flow

sequenceDiagram
    participant B as Browser
    participant CF as Cloudflare Access
    participant OL as Outlook Inbox
    participant GUA as Guacamole
    participant AUTH as Authenticator App
    participant RDP as Ubuntu Desktop

    B->>CF: GET https://guac.thecylon.org
    CF->>B: Enter your email address
    B->>CF: you@example.com
    CF->>OL: One-time PIN email
    OL-->>B: PIN code (e.g. 847291)
    B->>CF: Submit PIN → ✅ Factor 1 complete
    CF->>GUA: Forward request (Cloudflare JWT in headers)
    GUA->>B: Guacamole login form
    B->>GUA: Username + Password → ✅ Factor 2 complete
    GUA->>B: TOTP challenge
    B->>AUTH: Read 6-digit code
    AUTH-->>B: 482019
    B->>GUA: Submit TOTP → ✅ Factor 3 complete
    GUA->>RDP: Auto-connect via RDP (only 1 connection assigned)
    RDP-->>B: Ubuntu GNOME desktop at 1920×1080

Prerequisites

Accounts and Services

RequirementNotes
Cloudflare account (free)Domain must be on Cloudflare nameservers
Domain on Cloudflaree.g. thecylon.org
Cloudflare Zero Trust teamFree — create at one.dash.cloudflare.com → Zero Trust
Email account for OTPOutlook, Gmail, or any email works
Authenticator appGoogle Authenticator, Aegis, Authy, etc.

Hardware and Software

RequirementNotes
Linux machine (the target)Ubuntu 22.04 or 24.04 recommended
Kubernetes clusterk0s, k3s, k8s — any distribution
cloudflared CLICloudflare Tunnel client
kubectlAccess to your cluster
xrdpsudo apt install xrdp
GNOME desktopsudo apt install gnome-session gnome-shell
Docker (for image inspection)Optional — to pin image digests

What You Need to Know

  • Basic Kubernetes (kubectl apply, namespaces, Deployments, Services)
  • Basic Cloudflare DNS management
  • Comfortable running commands as root on Linux

Implementation

Step 1 — Create the Cloudflare Tunnel

# Install cloudflared
curl -L https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update && sudo apt install cloudflared

# Login and create tunnel
cloudflared tunnel login
cloudflared tunnel create guacamole-tunnel

# Point DNS to the tunnel
cloudflared tunnel route dns guacamole-tunnel guac.yourdomain.com

Get the tunnel ID — you’ll need it in the ConfigMap:

cloudflared tunnel info guacamole-tunnel
# Note the UUID: 2f7f7ca5-0e69-453e-8592-ffe236bd8798

Step 2 — Create Kubernetes Namespaces

# k8s/namespaces.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: tunnel
  labels:
    name: tunnel     # required by NetworkPolicy namespaceSelector
---
apiVersion: v1
kind: Namespace
metadata:
  name: guacamole
  labels:
    name: guacamole
kubectl apply -f k8s/namespaces.yaml

Step 3 — Deploy cloudflared (Tunnel)

Get the tunnel token:

cloudflared tunnel token guacamole-tunnel
# Copy the base64 token output

Create the Secret:

kubectl create secret generic cloudflared-token \
  --namespace tunnel \
  --from-literal=token='<PASTE TOKEN HERE>'

ConfigMap — replace <TUNNEL-UUID> and yourdomain.com:

# k8s/tunnel/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: cloudflared-config
  namespace: tunnel
data:
  config.yaml: |
    tunnel: <TUNNEL-UUID>
    metrics: 0.0.0.0:2000
    no-autoupdate: true
    ingress:
      - hostname: guac.yourdomain.com
        service: http://guacamole-svc.guacamole.svc.cluster.local:8080
      - service: http_status:404

Pin the image digest before deploying (never use :latest in production):

docker pull cloudflare/cloudflared:latest
docker inspect cloudflare/cloudflared:latest --format='{{index .RepoDigests 0}}'
# cloudflare/cloudflared@sha256:6b599ca3e974349ead3286d178da61d291961182ec3fe9c505e1dd02c8ac31b0

Deployment:

# k8s/tunnel/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cloudflared
  namespace: tunnel
spec:
  replicas: 2        # HA — two connections to Cloudflare PoPs
  selector:
    matchLabels:
      app: cloudflared
  template:
    metadata:
      labels:
        app: cloudflared
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
      containers:
        - name: cloudflared
          image: cloudflare/cloudflared@sha256:<DIGEST>
          args: [tunnel, --config, /etc/cloudflared/config/config.yaml, run]
          env:
            - name: TUNNEL_TOKEN
              valueFrom:
                secretKeyRef:
                  name: cloudflared-token
                  key: token
          resources:
            requests:
              memory: 64Mi
              cpu: 50m
            limits:
              memory: 128Mi
              cpu: 200m
          livenessProbe:
            httpGet:
              path: /ready
              port: 2000
            initialDelaySeconds: 10
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /ready
              port: 2000
            initialDelaySeconds: 10
            periodSeconds: 10
          volumeMounts:
            - name: config
              mountPath: /etc/cloudflared/config
              readOnly: true
      volumes:
        - name: config
          configMap:
            name: cloudflared-config

Step 4 — Deploy Guacamole Stack

Secrets

# Generate a strong DB password
DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
kubectl create secret generic guacamole-db-creds \
  --namespace guacamole \
  --from-literal=POSTGRES_DB=guacamole \
  --from-literal=POSTGRES_USER=guacamole \
  --from-literal=POSTGRES_PASSWORD="$DB_PASS"
echo "DB password: $DB_PASS"   # save this!

PostgreSQL StatefulSet

# k8s/guacamole/postgres-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
  namespace: guacamole
spec:
  serviceName: postgres
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      securityContext:
        runAsUser: 999
        fsGroup: 999
      containers:
        - name: postgres
          image: postgres:16-alpine
          env:
            - name: POSTGRES_DB
              valueFrom:
                secretKeyRef:
                  name: guacamole-db-creds
                  key: POSTGRES_DB
            - name: POSTGRES_USER
              valueFrom:
                secretKeyRef:
                  name: guacamole-db-creds
                  key: POSTGRES_USER
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: guacamole-db-creds
                  key: POSTGRES_PASSWORD
          volumeMounts:
            - name: postgres-data
              mountPath: /var/lib/postgresql/data
          resources:
            requests:
              memory: 128Mi
              cpu: 100m
            limits:
              memory: 256Mi
  volumeClaimTemplates:
    - metadata:
        name: postgres-data
      spec:
        accessModes: [ReadWriteOnce]
        resources:
          requests:
            storage: 2Gi

Database Schema Init Job

A key lesson: Guacamole requires schema initialisation before first run. The init Job uses an initContainer to generate the SQL, then a second container to apply it — with an idempotency check so re-runs are safe:

# k8s/guacamole/db-init-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: guacamole-db-init
  namespace: guacamole
  labels:
    app: guacamole-db-init   # must be on pod template too — NetworkPolicy uses pod labels
spec:
  ttlSecondsAfterFinished: 120
  backoffLimit: 3
  template:
    metadata:
      labels:
        app: guacamole-db-init   # ← critical: NetworkPolicy matches pod labels, not Job labels
    spec:
      restartPolicy: Never
      initContainers:
        - name: generate-sql
          image: guacamole/guacamole:1.5.5
          command: ["/bin/sh", "-c"]
          args:
            - "/opt/guacamole/bin/initdb.sh --postgresql > /sql/initdb.sql"
          volumeMounts:
            - name: sql
              mountPath: /sql
      containers:
        - name: apply-sql
          image: postgres:16-alpine
          command: ["/bin/sh", "-c"]
          args:
            - |
              TABLE_EXISTS=$(PGPASSWORD=$POSTGRES_PASSWORD psql -h postgres \
                -U $POSTGRES_USER -d $POSTGRES_DB -tAc \
                "SELECT to_regclass('public.guacamole_user')")
              if [ "$TABLE_EXISTS" = "public.guacamole_user" ]; then
                echo "Schema already exists, skipping"
                exit 0
              fi
              PGPASSWORD=$POSTGRES_PASSWORD psql -h postgres \
                -U $POSTGRES_USER -d $POSTGRES_DB -f /sql/initdb.sql && \
              echo "Schema applied"
          env:
            - { name: POSTGRES_DB,   valueFrom: { secretKeyRef: { name: guacamole-db-creds, key: POSTGRES_DB } } }
            - { name: POSTGRES_USER, valueFrom: { secretKeyRef: { name: guacamole-db-creds, key: POSTGRES_USER } } }
            - { name: POSTGRES_PASSWORD, valueFrom: { secretKeyRef: { name: guacamole-db-creds, key: POSTGRES_PASSWORD } } }
          volumeMounts:
            - name: sql
              mountPath: /sql
      volumes:
        - name: sql
          emptyDir: {}

Guacamole Deployment

Three env vars are critical and non-obvious (see Lessons Learned below):

# k8s/guacamole/guacamole-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: guacamole
  namespace: guacamole
spec:
  replicas: 1
  selector:
    matchLabels:
      app: guacamole
  template:
    metadata:
      labels:
        app: guacamole
    spec:
      enableServiceLinks: false    # prevents POSTGRES_PORT k8s env var pollution
      containers:
        - name: guacamole
          image: guacamole/guacamole:1.5.5
          ports:
            - containerPort: 8080
          env:
            - name: HOME
              value: /tmp          # image requires writable home dir
            - name: CATALINA_OPTS
              value: "-Dguacamole.home=/tmp/.guacamole"   # tells Tomcat where config lives
            - name: WEBAPP_CONTEXT
              value: ROOT          # serves at / instead of /guacamole/ — fixes 404
            - name: TOTP_ENABLED
              value: "true"
            - name: TOTP_ISSUER
              value: "yourdomain.com"
            - name: GUACD_HOSTNAME
              value: guacd
            - name: GUACD_PORT
              value: "4822"
            - name: POSTGRESQL_HOSTNAME
              value: postgres
            - name: POSTGRESQL_PORT
              value: "5432"
            - name: POSTGRESQL_DATABASE
              valueFrom:
                secretKeyRef:
                  name: guacamole-db-creds
                  key: POSTGRES_DB
            - name: POSTGRESQL_USER
              valueFrom:
                secretKeyRef:
                  name: guacamole-db-creds
                  key: POSTGRES_USER
            - name: POSTGRESQL_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: guacamole-db-creds
                  key: POSTGRES_PASSWORD
          resources:
            requests:
              memory: 256Mi
              cpu: 100m
            limits:
              memory: 512Mi
              cpu: 500m

Step 5 — NetworkPolicy: Default-Deny Everything

graph TD
    subgraph tunnel_ns["namespace: tunnel  ✅ NetworkPolicy: default-deny + exceptions"]
        CF2["cloudflared pods"]
    end

    subgraph guac_ns["namespace: guacamole  🔴 default-deny ALL ingress + egress"]
        GUA2["guacamole :8080"]
        GUACD2["guacd :4822"]
        PG["postgres :5432"]
    end

    Internet["Internet via Cloudflare"] -->|"outbound tunnel only"| CF2
    CF2 -->|"ALLOW: from namespace:tunnel → port 8080 only\n❌ direct access without CF JWT blocked"| GUA2
    GUA2 -->|"ALLOW: guacamole → guacd port 4822 only"| GUACD2
    GUA2 -->|"ALLOW: guacamole → postgres port 5432 only"| PG
    GUACD2 -->|"ALLOW: 100.89.50.27:22 + :3389 only\n❌ cannot reach rest of LAN"| Desktop["Ubuntu Desktop"]

    style guac_ns fill:#1a0808,stroke:#c0392b,color:#f5f5f5
    style tunnel_ns fill:#001a12,stroke:#00b894,color:#f5f5f5

The guacd-egress policy is the most important one — it locks guacd to connecting only to your specific machine on specific ports. A compromised Guacamole pod cannot pivot to the rest of your LAN:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: guacd-egress
  namespace: guacamole
spec:
  podSelector:
    matchLabels:
      app: guacd
  policyTypes:
    - Egress
  egress:
    - to:
        - ipBlock:
            cidr: 100.89.50.27/32    # your machine's IP only
      ports:
        - port: 22
          protocol: TCP
        - port: 3389
          protocol: TCP
    - ports:
        - port: 53
          protocol: UDP              # DNS only

Step 6 — Configure Cloudflare Access

In one.dash.cloudflare.com → Zero Trust → Access → Applications:

  1. Add Application → Self-hosted
  2. Application domain: guac.yourdomain.com
  3. Session duration: 8 hours
  4. Identity provider: One-time PIN (no IDP setup required)
  5. Policy: Email is your@email.com

Step 7 — Configure xrdp for Ubuntu GNOME

sudo apt install xrdp gnome-session gnome-shell

# Configure xrdp to launch GNOME
cat > ~/.xsession << 'EOF'
export GNOME_SHELL_SESSION_MODE=ubuntu
export XDG_SESSION_TYPE=x11
export XDG_CURRENT_DESKTOP=ubuntu:GNOME
export XDG_SESSION_DESKTOP=ubuntu
unset DBUS_SESSION_BUS_ADDRESS
unset DBUS_SESSION_BUS_PID
exec dbus-launch --exit-with-session gnome-session
EOF

# Suppress color manager auth popups in GNOME
sudo tee /etc/polkit-1/localauthority/50-local.d/45-allow-colord.pkla << 'EOF'
[Allow Colord all Users]
Identity=unix-user:*
Action=org.freedesktop.color-manager.create-device;org.freedesktop.color-manager.create-profile;org.freedesktop.color-manager.delete-device;org.freedesktop.color-manager.delete-profile;org.freedesktop.color-manager.modify-device;org.freedesktop.color-manager.modify-profile
ResultAny=no
ResultInactive=no
ResultActive=yes
EOF

Step 8 — Isolate xrdp Sessions from Physical Display

Without this fix, opening a folder on the laptop also opens it in the RDP session — they share the same D-Bus.

sudo tee /etc/xrdp/startwm.sh << 'EOF'
#!/bin/sh
if test -r /etc/profile; then . /etc/profile; fi
if test -r ~/.profile; then . ~/.profile; fi

# Isolate D-Bus: prevent xrdp session from attaching to the physical
# display's session bus. Without this, apps like Nautilus respond on
# both the laptop screen and the RDP window simultaneously.
unset DBUS_SESSION_BUS_ADDRESS
unset DBUS_SESSION_BUS_PID
export GNOME_SESSION_DONT_SAVE=1

test -x /etc/X11/Xsession && exec /etc/X11/Xsession
exec /bin/sh /etc/X11/Xsession
EOF

sudo systemctl restart xrdp

Lessons Learned (The Hard Bugs)

timeline
    title Debugging Timeline
    Bug 1 : Kubernetes injects POSTGRES_PORT=tcp://IP:PORT as env var
           : start.sh uses it instead of POSTGRESQL_PORT=5432
           : Fix: enableServiceLinks=false
    Bug 2 : start.sh generates config to HOME/.guacamole
           : but HOME variable is not exported to Tomcat JVM
           : GUACAMOLE_HOME stays at /etc/guacamole (doesn't exist)
           : Fix: CATALINA_OPTS -Dguacamole.home=/tmp/.guacamole
    Bug 3 : Guacamole served at /guacamole/ context path
           : cloudflared routes to / — returns 404 after CF Access OTP
           : Fix: WEBAPP_CONTEXT=ROOT
    Bug 4 : DB init Job has label on Job metadata only
           : NetworkPolicy matches pod labels not Job labels
           : Pods have no egress to postgres — schema never applied
           : Fix: labels in spec.template.metadata.labels
    Bug 5 : xrdp/GNOME shares D-Bus with physical session
           : opening folder on laptop opens it in RDP too
           : Fix: unset DBUS_SESSION_BUS_ADDRESS in startwm.sh
    Bug 6 : WirePlumber restores IEC958 (optical S/PDIF) profile on boot
           : audio goes to unconnected optical port — silence
           : Fix: systemd user service to switch to analog-stereo on login

Audio Isolation Fix

After reboot, audio was silent — WirePlumber restored an IEC958 (optical S/PDIF) profile from a previous session. The USB audio adapter’s 3.5mm jack needs the analog stereo profile.

# Switch profile immediately
pactl set-card-profile \
  alsa_card.usb-ASUSTeK_COMPUTER_INC._C-Media_R__Audio-00 \
  output:analog-stereo+input:analog-stereo

# Make it permanent via systemd user service
cat > ~/.config/systemd/user/audio-profile-fix.service << 'EOF'
[Unit]
Description=Set USB audio to analog stereo profile
After=wireplumber.service pipewire-pulse.service
Requires=wireplumber.service

[Service]
Type=oneshot
ExecStartPre=/bin/sleep 3
ExecStart=/bin/bash -c '\
  pactl set-card-profile \
    alsa_card.usb-ASUSTeK_COMPUTER_INC._C-Media_R__Audio-00 \
    output:analog-stereo+input:analog-stereo && \
  SINK=$(pactl list sinks short | awk "/analog/{print \$1}" | head -1) && \
  pactl set-default-sink "$SINK"'
RemainAfterExit=yes

[Install]
WantedBy=default.target
EOF

systemctl --user daemon-reload
systemctl --user enable --now audio-profile-fix.service

Security Model

graph LR
    subgraph Outside["Public Internet"]
        Attacker["🔴 Attacker"]
        You["🟢 You (any browser)"]
    end

    subgraph Cloudflare["Cloudflare Edge (free)"]
        WAF["DDoS + WAF\nRate Limiting"]
        Access["Access Gate\nFactor 1: Email OTP"]
    end

    subgraph Cluster["k8s Cluster (home)"]
        Tunnel["cloudflared\noutbound only"]
        Guacamole["Guacamole\nFactor 2: Password\nFactor 3: TOTP"]
        Network["NetworkPolicy\ndefault-deny"]
    end

    Attacker -->|"Port scan: nothing open ✅"| Outside
    Attacker -->|"HTTP: DDoS blocked ✅"| WAF
    You --> WAF --> Access
    Access -->|"OTP verified"| Tunnel
    Tunnel --> Guacamole --> Network

    style Attacker fill:#1a0808,stroke:#c0392b,color:#f5f5f5
    style You fill:#001a12,stroke:#00b894,color:#f5f5f5
    style Access fill:#1a1200,stroke:#f39c12,color:#f5f5f5
LayerWhat it stops
Zero open portsPort scans find nothing — home IP never exposed
Cloudflare WAFDDoS, OWASP Top 10, rate limiting
Cloudflare Access OTPUnauthenticated access — even if they find the URL
Guacamole passwordMust know credentials even after passing CF Access
Guacamole TOTPBrute force and credential stuffing
NetworkPolicy default-denyCompromised container cannot pivot to LAN
guacd-egress lockguacd can only reach specific IP:port — no LAN traversal

Total cost: $0/month.


Quick Reference

# Check tunnel is connected
cloudflared tunnel info guacamole-tunnel

# Check all guacamole pods are Running
kubectl get pods -n guacamole

# Check cloudflared is routing
kubectl logs -n tunnel deployment/cloudflared --tail=20

# Test guacd can reach the laptop
GUACD=$(kubectl get pod -n guacamole -l app=guacd -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n guacamole $GUACD -- wget -qO- --timeout=3 http://100.89.50.27:22 2>&1 | head -1
# Expected: SSH-2.0-OpenSSH_...

# Check audio profile
pactl list cards | grep "Active Profile"

# Restart audio fix service
systemctl --user restart audio-profile-fix.service

What’s Next

  • Phase 2: Add OPNsense as a network gateway between ISP router and switch — VLAN isolation for the k8s cluster, Suricata IDS/IPS, firewall rule blocking k8s → home devices
  • Sealed Secrets: Encrypt the k8s Secrets at rest with the Bitnami Sealed Secrets operator
  • Falco: Runtime container anomaly detection — shell spawns, unexpected outbound connections
  • Monitoring: Prometheus + Grafana + Alertmanager → mobile push via Ntfy

Stack: Apache Guacamole 1.5.5 · k0s · Cloudflare Tunnel + Access · xrdp · PipeWire · WirePlumber · PostgreSQL 16 · Calico NetworkPolicy

Enjoyed this post?

Get the next one in your inbox — only when I ship something worth reading.

Newsletter form not configured.

Or follow on Substack for the newsletter.

Comments via GitHub Discussions

Comments not configured. Set GISCUS env vars to enable.