How traffic captured by web/proxy-server gets attributed to the correct device on the dashboard. Covers the per-device port pool, wildcard DNS, the wildcard nginx vhost, SNI-attributed TLS, the in-memory IP whitelist, and the 4-way priority chain that decides whose feed a request lands in.
Goal
The proxy-server runs on a single VPS shared by many iOS devices (each one configured to use it as a Wi-Fi PAC proxy). Without attribution, the dashboard would show one giant pile of unlabeled requests. Every device needs its own feed, tagged with its own device_uuid, so the per-device modal + workspace filters work.
Identity: UUID over everything
device_uuid (a Postgres UUID column on public.devices) is the canonical identity. Display names (devices.name citext) are user-mutable and can be renamed at any time without breaking attribution — every route param, cache key, channel name, and Map key in dashboard / proxy-server / iOS uses the UUID, never the name.
Two mechanisms enforce this:
- iOS-side: the device JWT carries
app_metadata.device_uuid; RLS'spublic.device_uuid()helper extracts it.LogStreamerwrites entries withdevice_uuid: <uuid>directly — names are not part of the wire. - proxy-server-side: every
TrafficLogEntryends up inpgStreamer.enqueue(entry, deviceName, uuid?). If a UUID is present (port-pool path, SNI), it's used directly. OtherwiseresolveOrCreateDeviceUuid(name)runs a UUIDv5 derivation against a cacheddevices.namelookup.
Database invariant: unique(lower(devices.name)) (migration 20260520000001_devices_unique_lower_name.sql) — two devices can't claim the same display name.
DNS + TLS layout
Single VPS at 46.101.57.56. Wildcard DNS:
| Hostname | Target | Role |
|---|---|---|
*.busymate.net | A → 46.101.57.56 | Catch-all; per-device PAC subdomains land here |
proxy.busymate.net | A → 46.101.57.56 | Cert-bundle + /allocate + /whitelist/* (apex of the proxy service) |
<port>.busymate.net | (wildcard) → 46.101.57.56 | Per-device PAC URLs, where <port> ∈ 9000–9099 |
A wildcard Let's Encrypt cert covers *.busymate.net. The proxy-server's httpsListener.ts (:8443) loads the wildcard leaf via ca.leafFor("*.busymate.net") and accepts TLS on every subdomain.
nginx (/etc/nginx/sites-enabled/busymate-net-pac.conf) catches every non-explicit subdomain via server_name ~^.+\.busymate\.net$ and reverse-proxies:
GET /(and/proxy.pac,/wpad.dat) →127.0.0.1:8888/pac- Everything else → 404
The PAC payload
GET /pac handlers in web/proxy-server/src/proxy.ts look at the requested Host: header to decide which device + which body to return:
// renderPortPacBody — numeric subdomain (port-pool path)
function FindProxyForURL(url, host) {
if (isPlainHostName(host) || dnsDomainIs(host, ".local") || ...) return "DIRECT";
return "PROXY 9012.busymate.net:9012; DIRECT";
}Numeric subdomains (e.g. 9012.busymate.net) return a port-anchored body — every request flows through 9012.busymate.net:9012, and the proxy-server's port-pool listener on port 9012 attributes them to whatever UUID claimed port 9012.
Non-numeric subdomains (e.g. jadecardinal.busymate.net, the legacy slug path) return a three-stage chain:
// renderPacBody — legacy slug path
return "HTTPS jadecardinal.busymate.net:8443; PROXY jadecardinal.busymate.net:8888; DIRECT";Stage 1 hits :8443 (SNI-attributed TLS), stage 2 falls back to :8888 (port-shared), stage 3 is DIRECT. Modern iOS clients use the port-pool path; the slug path stays for legacy + non-iOS callers.
The 4-way attribution chain
In web/proxy-server/src/index.ts (the emit(entry, sourceIp, sniDeviceName?, portDeviceName?, portUuid?) function), priority order is:
1. Port-pool → portDeviceName + portUuid set by PortPool's net.Server accept
2. SNI → sniDeviceName set by httpsListener's SNI extraction
3. IP whitelist→ Look up sourceIp in IpWhitelist; returns last-known {deviceName, uuid}
4. Pinned name → This proxy's own identity.json (the proxy itself made the request)First match wins. Highest-priority signal (port-pool) is the most reliable because the iOS device explicitly claimed that port via /allocate; lowest-priority (pinned name) only fires when the proxy is making outbound requests on its own behalf.
1. Port-pool
web/proxy-server/src/portMap.ts is a JSON file at ~/.busymate-proxy/port-map.json:
{
"4aca87ad-4efe-405a-8b25-0f86ddc464fd": { "port": 9012, "deviceName": "jadecardinal" },
"5a5213f8-fa8d-4c2a-a26d-f559fa104779": { "port": 9013, "deviceName": "flor-gama" }
}portPool.ts opens one net.Server per claimed port. Each accepted socket gets __portDeviceName + __portUuid properties set and is re-emitted onto the shared main http.Server via httpServer.emit("connection", socket). Downstream the request handler picks up these annotations and tags the resulting TrafficLogEntry.
Claim flow:
- iOS
PortAllocator.allocateNow()POSTsproxy.busymate.net/allocatewith{uuid, deviceName, country: Locale.current.region}+ cert-bundle Basic auth. handleAllocate(inproxy.ts):- Looks up
uuidin the map; if present, returns the existing port. If absent, mints a free port from the configured range (9000–9099 by default) and adds the entry. - Calls
PortPool.ensure(port)to spin up the listener synchronously so the next request lands cleanly. - Fires
reportDeviceLocation(uuid, apparentIp, country)viapgGeoip.tsto UPSERTdevice_status.{ip, country}. - Persists
portMap.jsonto disk.
- Looks up
- iOS writes
streaming.allocatedPort = <port>intoAppSettingsand flips its Wi-Fi PAC URL tohttp://<port>.busymate.net/.
2. SNI
httpsListener.ts on :8443 extracts the SNI from the TLS ClientHello via tls.Server's SNICallback. If the SNI matches a known slug (legacy path), sniDeviceName is set on the socket. The shared http.Server then uses it.
This path supports the legacy slug-as-subdomain pattern (jadecardinal.busymate.net) — modern iOS clients hit the port-pool listeners on their allocated port instead.
3. IP whitelist
web/proxy-server/src/whitelist.ts is an in-memory map Map<IP, {deviceName, uuid, expiresAt}> with a 24 h TTL and a sweep cleaner. Entries are added two ways:
- iOS
WhitelistRegistrar.registerNow()POSTsproxy.busymate.net/whitelist/registerwith cert-bundle Basic auth (every 5 minutes while foregrounded). - The
BGAppRefreshTaskcom.busymatehelper.app.refresh-whitelistalso firesregisterNow()while backgrounded.
When a request arrives without a port or SNI attribution, the proxy looks up sourceIp in the whitelist. Useful for non-iOS clients (desktop browsers behind the same NAT) that don't have a UUID — they get attributed to whoever's IP they last registered with.
4. Pinned name fallback
If none of the above match, the entry is tagged with this proxy's own identity.json UUID — i.e. "the proxy itself made this request". Visible in the dashboard as the proxy-server's row.
Rename propagation
When the dashboard renames a device, the change cascades:
- Dashboard PATCH
/api/devices/<uuid>updatesdevices.name. broadcast_device_change()trigger fires Realtime broadcast ondevices:allanddevice:<uuid>(UPDATE event).- proxy-server's
pgRealtime.tslistens ondevices:all; theonDeviceRenamedhandler runsportMap.renameUuid(uuid, newName)and persists. - iOS's
RealtimeSubscriberlistens ondevice:<uuid>UPDATE; the handler re-fetches the canonical row over PostgREST and applies the new name to local UI.
The proxy never reaches into iOS's name; iOS never reaches into the port map. Both observe the same Realtime broadcast and update their local mirrors independently.
Files
| File | Role |
|---|---|
web/proxy-server/src/index.ts | emit(...) — the attribution priority chain. |
web/proxy-server/src/portMap.ts | Persistent {uuid: {port, deviceName}} JSON. |
web/proxy-server/src/portPool.ts | One net.Server per claimed port; socket re-emit. |
web/proxy-server/src/proxy.ts | handleAllocate, handlePac, handleConnect, handlePlainHttp. |
web/proxy-server/src/httpsListener.ts | :8443 SNI listener. |
web/proxy-server/src/whitelist.ts | In-memory IP whitelist + 24 h TTL. |
web/proxy-server/src/ca.ts | Wildcard *.busymate.net leaf for :8443. |
web/proxy-server/src/pgStreamer.ts | resolveOrCreateDeviceUuid UUIDv5 cache. |
web/proxy-server/src/pgRealtime.ts | devices:all rename broadcast subscriber. |
web/proxy-server/src/identity.ts | This proxy's own UUID + name (priority-4 fallback). |
ios/BusymateHelper/Services/PortAllocator.swift | iOS port-claim loop (/allocate every 30 min). |
ios/BusymateHelper/Services/WhitelistRegistrar.swift | iOS IP-register loop (/whitelist/register every 5 min). |
ios/Shared/AppSettings.swift | streaming.allocatedPort (the port the device claimed). |
supabase/migrations/20260520000001_devices_unique_lower_name.sql | Database invariant for name uniqueness. |
Stable URLs across renames
PAC URL: http://<port>.busymate.net/. The <port> is stable per UUID — renaming a device does NOT change its port. iOS doesn't need to re-allocate or update Wi-Fi settings.
Dashboard share URL: dash.busymate.net/?device=<uuid>. The slug variant dash.busymate.net/?device=<name> is supported but resolves to the same UUID via the in-memory uuidToNameRef Map.
Related
- Proxy server — overall proxy-server architecture and file map.
- PAC presence — how attributed devices stay "Online" while the iOS app is backgrounded.
- iOS connection modes — when each attribution path applies (VPN bypasses the port pool; PAC mode hits it on every connection).
SECURITY.md— cert bundle Basic auth rotation, why/allocateis gated.web/proxy-server/deploy/README.md— systemd + nginx + fail2ban templates.