Self-hosted system that lets you remotely view and control up to 10 Android phones from a browser. Any small Linux/Windows host runs the backend, brokers SignalR control, and relays WebRTC signaling — it does not transcode video. The phone encodes H.264 in hardware via MediaProjection + ScreenCapturerAndroid and ships the track straight to the browser's <video> element.
See remote-desktop-plan.md for the full architecture.
Phases 1–6 complete (Phase 6: TLS termination shipped; deployment-side bits — Cloudflare records and WAF/rate-limiting at the edge — still require a real domain). End-to-end working:
- Browser logs in, picks a device from the dashboard, hits Connect
- Backend tells the agent to start capturing; phone surfaces a one-time
MediaProjectionconsent dialog (per agent process) and acquires a screen wake lock - WebRTC handshake (phone-initiated offer, browser answer, ICE) flows through
/hubs/agent↔/hubs/control - Live H.264 video plays in the browser, capped at 1.5 Mbps per stream
- Mouse drag → swipe, mouse click → tap, navigation buttons →
KEYCODE_*global actions, all dispatched on the phone via anAccessibilityService - Admin can pair, rename (inline-edit on the device detail view; defaults to
Build.MODELfrom the agent), revoke trust, and remove a device entirely from the device detail view; revoke pushes aRevokedsignal to the agent which unpairs and stops itself; remove also wipes the device row + its session history - Offline devices stay clickable on the dashboard so an admin can drill in and delete stale entries — Connect is still gated by online status
- Admin pairs a phone via a QR code rendered in the web UI. The QR uses
rdpair://(http) orrdpairs://(https) — chosen by the backend based on the scheme ofPairing__BaseUrl— and the agent'sPairUriparser maps it back to the corresponding URL. The agent scans with CameraX + ML Kit, calls/api/agent/pair, and the admin's QR card auto-advances on thePairingCompletedSignalR event - ICE servers shipped over the SignalR control plane: by default the backend serves Cloudflare's public STUN (
stun:stun.cloudflare.com:3478) so each peer can discover its reflexive candidate. TURN is optional — setTURN_SECRET+TURN_HOSTNAMEto point at a hosted or self-hosted TURN service and the backend will additionally mint ephemeral HMAC-SHA1 credentials per session - Frontend nginx auto-switches to TLS termination on port 443 when a Cloudflare Origin Certificate is dropped into certs/ —
origin.pem+origin-key.pem. With no certs the container stays on plain HTTP/80 (dev mode). Behind Cloudflare's Full (Strict) mode, the edge speaks HTTPS to this origin. - Agent holds a
PARTIAL_WAKE_LOCKfor the foreground service's whole lifetime (not just during viewer sessions) so Android's App Standby + Doze can't suspend the SignalR socket. First launch after pairing prompts the user to whitelist the app from battery optimization — without that, Pixel/Samsung devices kill the network within ~5 minutes of screen-off.
.
├── docker-compose.yml
├── .env.example
├── api/ # ASP.NET Core 10 + SignalR + EF Core (Postgres)
│ ├── Dockerfile
│ └── RemoteDesktop/
├── web/ # Vite + React + TypeScript
│ ├── Dockerfile
│ ├── nginx.conf
│ └── src/
└── android/ # Kotlin + stream-webrtc-android
└── app/
- Backend (
/hubs/controlfor browsers,/hubs/agentfor devices)WebRtcSignalingService(singleton) tracksviewerOf(deviceId)↔agentOf(deviceId)so each hub can forward SDP/ICE messages to the other side viaIHubContext<TOtherHub>InputRelayServiceper-device bounded channel (DropOldest @ 64);AgentHubruns a background drain loop per connection that forwards events to the agent over SignalR- JWT auth for both browsers (user role) and agents (device role); pairing tokens are issued by admins via
/api/devices/pair/start(rendered as a QR code in the web UI) and consumed by the phone via/api/agent/pair
- Frontend — single-page React app. The
RemoteViewcomponent owns oneRTCPeerConnection, registers WebRTC handlers on the SignalR hub, paints the inbound track into a<video>element, and translates click/drag/wheel/key events intotap/swipe/scroll/keyInputEventpayloads (mapped from overlay coordinates into the phone's native pixel space). - Android agent — foreground service that holds the SignalR connection forever, plus a long-lived
WebRtcCaptureSessionthat keeps theMediaProjectionwarm between viewer sessions (so the consent dialog only appears once per agent process). Each viewer connect attaches a freshPeerConnectionto the live video track. See android/README.md for the full component map.
cp .env.example .env
docker compose up --build- Web UI: http://localhost:3000
- Postgres: not exposed by default (use the LAN override below if you need psql access)
docker compose up starts db, api, and web. The api is bound to 127.0.0.1:5000:8080 (loopback) — curl http://localhost:5000/healthz works from the host, but it's not reachable from other machines on the LAN. The web service's nginx proxies /api/ and /hubs/ to the api over the internal docker network, which is how the SPA reaches it.
Before deploying to the VPS, it's worth pairing a real phone against a backend running on your dev machine. The base compose hides the api on loopback for safety; docker-compose.override.yml opens the relevant ports for LAN reach and is auto-loaded by Docker Compose when you run plain docker compose up.
1. Bring everything up
docker compose up -d --buildCompose merges the base + override automatically — no flags needed.
2. Set .env to point at your dev machine's LAN IP
Start from the LAN-testing template:
cp .env.local.example .env
$EDITOR .envReplace the <LAN_IP> placeholder with your dev machine's actual address (ip addr on Linux, ipconfig on Windows, ipconfig getifaddr en0 on macOS). The template's other defaults (admin user, dev-only secrets) are fine to keep for LAN testing — they're dev-only and not safe for the VPS.
Restart so the new env is picked up: docker compose ... up -d.
3. Allow cleartext HTTP in the Android agent (LAN-only edit)
Android blocks cleartext HTTP by default. Edit android/app/src/main/res/xml/network_security_config.xml in your local checkout and add your dev machine's LAN IP:
<domain includeSubdomains="true">192.168.1.10</domain>Don't commit this — production uses HTTPS via Cloudflare and doesn't need a cleartext exception.
4. Build the debug APK and install
cd android
./gradlew :app:assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk5. Pair and test
- Phone + dev machine on the same WiFi.
- Open
http://192.168.1.10:3000in a browser (any machine on the LAN). - Log in with
ADMIN_EMAIL/ADMIN_PASSWORD. - Devices → Pair Device → scan the QR with the agent app.
- Click Connect. WebRTC will go peer-to-peer over the LAN (host candidates).
This validates the same code paths the VPS will run, minus TLS and the public internet. If it works here, the VPS bring-up is mostly a domain + cert exercise.
For production deployment to a VPS with a real domain and TLS, see the next section.
End-to-end recipe for putting the system on a Linux VPS with a real domain, TLS, and STUN-based WebRTC. The phone connects from anywhere — home WiFi, cellular, whatever — and the backend lives on a publicly reachable host instead of your LAN.
Throughout this section the placeholder nomain.uk stands in for your apex domain and 89.117.109.152 for your VPS IP — substitute your own.
- A Linux server (Ubuntu 22.04+ / Debian 12+) with at least 2 GB RAM
- Docker Engine 24+ and Docker Compose v2
- A domain name on Cloudflare (free plan is fine — nginx is pre-configured for Cloudflare Origin Certificates)
# Run as root or with sudo
curl -fsSL https://get.docker.com | shDo not run the application as root. Create a dedicated user and grant it Docker access:
sudo adduser deploy
sudo usermod -aG docker deployadduser prompts for a password — generate a strong one (openssl rand -base64 24) and store it in your password manager. You'll use it to ssh in as deploy from here on.
Give the user ownership of /srv/remote-android:
sudo mkdir -p /srv/remote-android
sudo chown deploy:deploy /srv/remote-androidInstall fail2ban — SSH on a public IP gets scanned constantly, and password auth is exactly the threat model fail2ban handles. Defaults are sensible; no tuning needed:
sudo apt update && sudo apt install -y fail2ban
sudo systemctl enable --now fail2banNow switch into the deploy user for all remaining steps:
su - deploy
# verify Docker access:
docker compose versionDNS → add one A record:
| Name | Type | Value | Proxy |
|---|---|---|---|
nomain.uk |
A | 89.117.109.152 |
Proxied (orange cloud) |
Optional: www.nomain.uk CNAME → nomain.uk, also proxied, if you want www. to work.
SSL/TLS → mode Full (Strict).
SSL/TLS → Origin Server → Create Certificate. Include nomain.uk and *.nomain.uk as SANs. Download as origin.pem + origin-key.pem — you'll drop these into certs/ in step 5.
Optional WAF rules (Security → WAF):
/api/auth/login: 10 req/min/IP./api/devices/pair/start+/api/agent/pair: 30 req/min/IP.
The app doesn't rate-limit — bcrypt slows brute-force naturally and pairing tokens are 122-bit UUIDs with 5-minute TTL, so any throttling belongs at the edge.
cd /srv
git clone <this repo>
cd /srv/remote-android
cp .env.example .env
nano .env # fill in secrets (see below)Generate strong secrets:
openssl rand -hex 32 # JWT_SECRET
openssl rand -base64 24 # DB_PASSWORD
openssl rand -base64 24 # ADMIN_PASSWORDThen set in .env:
DB_USER=rdadmin
DB_PASSWORD=<generated>
JWT_SECRET=<generated>
CORS_ORIGIN=https://nomain.uk
WEB_TLS_PORT=443
# Initial admin (seeded on first boot only). Email is the login identifier.
ADMIN_EMAIL=you@example.com
ADMIN_PASSWORD=<generated>
# Pairing URL — the QR will embed this; phone dials it from anywhere
PAIRING_BASE_URL=https://nomain.uk
# TURN — leave blank, STUN is enough for direct P2P
# TURN_SECRET=
# TURN_HOSTNAME=Place your Cloudflare Origin Certificate and key under certs/:
mkdir -p certs
# from your laptop:
# scp origin.pem origin-key.pem deploy@89.117.109.152:/opt/rdp/certs/
chmod 600 certs/origin-key.pemIf you are not using Cloudflare, replace web/nginx.prod.conf with your own TLS setup (e.g. Let's Encrypt via certbot) and adjust the server_name line.
# IMPORTANT: pass -f docker-compose.yml explicitly so the auto-loaded
# docker-compose.override.yml (which exposes the api on the LAN for dev)
# does NOT apply on the VPS.
docker compose -f docker-compose.yml up -d --buildThis starts three containers:
| Container | Purpose | Port |
|---|---|---|
db |
PostgreSQL 17 | not exposed (internal network only) |
api |
ASP.NET Core 10 | 127.0.0.1:5000 (host-only, for curl debugging) |
web |
nginx + React SPA | 80 and 443 (public) |
Verify:
docker compose -f docker-compose.yml ps # all services healthy
docker compose -f docker-compose.yml logs api | grep -i "now listening" # ASP.NET on :8080
docker compose -f docker-compose.yml logs api | grep -i "seeded.*admin" # confirms admin was created
curl -kI https://localhost/healthz # HTTP/2 200To save typing, export COMPOSE_FILE=docker-compose.yml in your shell (or stick it in ~/.bashrc) — then plain docker compose <cmd> skips the dev override.
Only expose 80 and 443 externally. Postgres and the api are not publicly accessible by design:
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enableVisit https://nomain.uk and log in with the ADMIN_EMAIL / ADMIN_PASSWORD you set in .env. Create additional users via User Management as needed.
If you forgot to set them, the seed falls back to admin@local / admin and logs a warning — change the password immediately via the user menu.
Build the APK locally:
cd android
./gradlew :app:assembleDebug
# APK lands at android/app/build/outputs/apk/debug/app-debug.apkTwo ways to get it onto a phone:
a) Install directly via ADB (your own dev phone over USB):
adb install -r app/build/outputs/apk/debug/app-debug.apkb) Publish on the web for users to self-install (the dashboard already shows a "Agent APK" download link in the header). Copy the APK to the VPS's downloads/ directory:
# from your laptop, after the gradlew build:
scp app/build/outputs/apk/debug/app-debug.apk \
deploy@89.117.109.152:/opt/rdp/downloads/nginx serves it at https://nomain.uk/downloads/app-debug.apk with Content-Disposition: attachment so Android offers to install it directly. The downloads/ directory is gitignored, so the APK never enters the repo. Re-upload whenever you rebuild.
The debug APK is fine for personal use. For a release APK (signed, stripped of debug symbols), see android/README.md.
The phone can pair from any network — home WiFi, cellular, whatever has internet access. The agent dials https://nomain.uk directly.
- In the web dashboard: Devices → Pair Device → QR code appears.
- In the agent app: Pair → scan the QR.
- The QR card auto-advances when pairing completes.
After pairing, the agent maintains a persistent SignalR connection from the phone to wss://nomain.uk/hubs/agent. The dashboard shows it as online regardless of which network the phone is on.
cd /opt/rdp
git pull
docker compose -f docker-compose.yml up -d --buildEF Core migrations run automatically on api startup (Program.cs:78).
- Cloudflare 526 (Invalid SSL Certificate) — The Origin Certificate doesn't cover the hostname being requested, or isn't being served by nginx. Check
docker compose logs webfor "TLS certs detected"; if missing, the bind mount didn't pick up./certs/origin.pem/./certs/origin-key.pem. If present but still 526, re-issue the cert withnomain.uk(and*.nomain.uk) as SANs. - Cloudflare 502 (Bad Gateway) — The api service isn't responding to nginx.
docker compose logs apishould show "Now listening on http://[::]:8080". If it's restarting, the DB likely isn't ready orDB_PASSWORDis wrong. - Login returns 401 —
ADMIN_EMAIL/ADMIN_PASSWORDdon't match. The seed only runs on an emptyUserstable — if you've already signed in once, changing.envwon't update the password. Use User Management in the dashboard. - QR pairing fails with "Pair URL invalid" —
PAIRING_BASE_URLis unset on the api. The QR fell back to the requestHostheader which is Cloudflare's edge, then the agent's parser rejected it. SetPAIRING_BASE_URL=https://nomain.ukand restart the api service. - Phone says paired, dashboard shows offline — the agent stored an old
serverUrl. Tap Unpair in the agent app and scan a fresh QR. - Phone session connects but video freezes after 30 min idle — likely the home router's UDP NAT idle timeout severing the WebRTC media path. See the diagnostic logging already wired into App.tsx (the
[rtc]stats poll) and the agent logcat. If reproducible, options are: aggressive RTCP keepalives, periodic dummy frames from the encoder, or adding TURN fallback by settingTURN_SECRET+TURN_HOSTNAMEto a hosted provider (Twilio, Metered.ca). - Want to inspect Postgres — it's not publicly exposed and not even bound to the host loopback in production. Easiest is to drop into the container directly:
docker compose -f docker-compose.yml exec db psql -U rdadmin -d remotedesktop.
API:
cd api/RemoteDesktop
dotnet runWeb:
cd web
npm install
npm run devThe Vite dev server proxies /api and /hubs to http://localhost:5000 (configured in web/vite.config.ts).
Android agent:
cd android
./gradlew :app:assembleDebug && adb install -r app/build/outputs/apk/debug/app-debug.apkSee android/README.md for SDK setup and pairing.