Per-device attribution

How a request is routed to the correct device's feed via UUID-anchored port pool + SNI + IP whitelist.

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's public.device_uuid() helper extracts it. LogStreamer writes entries with device_uuid: <uuid> directly — names are not part of the wire.
  • proxy-server-side: every TrafficLogEntry ends up in pgStreamer.enqueue(entry, deviceName, uuid?). If a UUID is present (port-pool path, SNI), it's used directly. Otherwise resolveOrCreateDeviceUuid(name) runs a UUIDv5 derivation against a cached devices.name lookup.

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:

HostnameTargetRole
*.busymate.netA → 46.101.57.56Catch-all; per-device PAC subdomains land here
proxy.busymate.netA → 46.101.57.56Cert-bundle + /allocate + /whitelist/* (apex of the proxy service)
<port>.busymate.net(wildcard) → 46.101.57.56Per-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:

js
// 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:

js
// 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:

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:

  1. iOS PortAllocator.allocateNow() POSTs proxy.busymate.net/allocate with {uuid, deviceName, country: Locale.current.region} + cert-bundle Basic auth.
  2. handleAllocate (in proxy.ts):
    • Looks up uuid in 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) via pgGeoip.ts to UPSERT device_status.{ip, country}.
    • Persists portMap.json to disk.
  3. iOS writes streaming.allocatedPort = <port> into AppSettings and flips its Wi-Fi PAC URL to http://<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() POSTs proxy.busymate.net/whitelist/register with cert-bundle Basic auth (every 5 minutes while foregrounded).
  • The BGAppRefreshTask com.busymatehelper.app.refresh-whitelist also fires registerNow() 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:

  1. Dashboard PATCH /api/devices/<uuid> updates devices.name.
  2. broadcast_device_change() trigger fires Realtime broadcast on devices:all and device:<uuid> (UPDATE event).
  3. proxy-server's pgRealtime.ts listens on devices:all; the onDeviceRenamed handler runs portMap.renameUuid(uuid, newName) and persists.
  4. iOS's RealtimeSubscriber listens on device:<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

FileRole
web/proxy-server/src/index.tsemit(...) — the attribution priority chain.
web/proxy-server/src/portMap.tsPersistent {uuid: {port, deviceName}} JSON.
web/proxy-server/src/portPool.tsOne net.Server per claimed port; socket re-emit.
web/proxy-server/src/proxy.tshandleAllocate, handlePac, handleConnect, handlePlainHttp.
web/proxy-server/src/httpsListener.ts:8443 SNI listener.
web/proxy-server/src/whitelist.tsIn-memory IP whitelist + 24 h TTL.
web/proxy-server/src/ca.tsWildcard *.busymate.net leaf for :8443.
web/proxy-server/src/pgStreamer.tsresolveOrCreateDeviceUuid UUIDv5 cache.
web/proxy-server/src/pgRealtime.tsdevices:all rename broadcast subscriber.
web/proxy-server/src/identity.tsThis proxy's own UUID + name (priority-4 fallback).
ios/BusymateHelper/Services/PortAllocator.swiftiOS port-claim loop (/allocate every 30 min).
ios/BusymateHelper/Services/WhitelistRegistrar.swiftiOS IP-register loop (/whitelist/register every 5 min).
ios/Shared/AppSettings.swiftstreaming.allocatedPort (the port the device claimed).
supabase/migrations/20260520000001_devices_unique_lower_name.sqlDatabase 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.