Exactly how Google sign-in, Supabase, Cloudflare, the orchestrator and the desktop SSO are configured and connected. Reproducible from scratch. Secrets redacted throughout.
Five systems, one chain. Each arrow is a real contract β if it breaks, sign-in fails at that hop.
browser
β 1. GET https://fastco.work/app (Cloudflare β Traefik β portal)
βΌ
portal ββ not signed in βββΆ login page (Google button only)
β
β 2. click β https://<project>.supabase.co/auth/v1/authorize?provider=google
βΌ
Supabase ββ 3. redirect βββΆ accounts.google.com (client_id = OUR Google OAuth client)
β
β 4. Google redirects to https://<project>.supabase.co/auth/v1/callback
β β this URL must be in Google Console "Authorized redirect URIs"
βΌ
Supabase mints a session, 5. redirects to https://fastco.work/app#access_token=...
β β fastco.work must be in Supabase "Redirect URLs"
βΌ
portal 6. POST /app/api/session {access_token}
β verifies it via GET <project>/auth/v1/user β email
β sets our OWN signed cookie (fcw_user)
βΌ
7. user presses "Start cowork" β orchestrator β clone/resume THEIR LXC
β
βΌ
8. orchestrator mints a Guacamole SSO assertion β desktop opens, no 2nd login
| Hop | Owner | Config that makes it work |
|---|---|---|
| 2β3 | Supabase | Google provider enabled + client id/secret |
| 3β4 | Authorized redirect URI = the Supabase callback | |
| 4β5 | Supabase | Site URL + Redirect URLs allowlist |
| 5β6 | Portal | token verified server-side, own cookie issued |
| 7β8 | Orchestrator | per-user LXC + guacamole-auth-json |
Project GENIS (genisdotai). A new client was created for fastco.work; the
pre-existing "Web client 1" / "Desktop client 1" were left untouched.
fastco.workhttps://fastco.workhttps://dmugfnvjwgzjxonapbuj.supabase.co/auth/v1/callbackWhile in Testing, only listed test users can sign in β everyone else is blocked. Publishing makes it available to any Google account.
Google Auth Platform β Audience β Publish app β Confirm.
Project dmugfnvjwgzjxonapbuj, organisation fastco.work (Free plan).
Authentication β Sign In / Providers β Google
The bug that cost several attempts: the dashboard will happily save the client ID/secret while silently leaving the Enable toggle OFF. The API log showed 500s that were a red herring. Flip the toggle first, then Save β and verify functionally (section 9), never by looking at the UI.
Authentication β URL Configuration. Defaults point at http://localhost:3000 and an empty
allowlist, so Supabase will refuse to redirect back to your app until this is set.
| Field | Value |
|---|---|
| Site URL | https://fastco.work/app |
| Redirect URLs | https://fastco.work/** |
Access is seat-metered: free to 50 users, then $7/user/month, and once seats are exhausted new users are hard-blocked. That breaks self-serve signup and inverts the unit economics. Supabase is unlimited at ~$0 marginal cost β and we keep our own user table, which billing and the userβVM mapping need. Access is still used for admin surfaces, where the user list is small and known.
One wildcard does all per-session routing, so publishing a new desktop URL needs no Cloudflare API call.
| Record / rule | Value |
|---|---|
| DNS | fastco.work, www, *.fastco.work β proxied CNAME β tunnel |
| Tunnel ingress | all three hostnames β http://192.168.1.99:8090 (Traefik) |
| Traefik | file provider watching /opt/fleet/dynamic; one YAML per session |
| Gate | ForwardAuth β fleet-auth, HMAC-signed ?t= links |
# a per-session route is just a file drop:
/opt/fleet/dynamic/u-<user>.yml
Host(`u-<user>.fastco.work`) β http://192.168.1.<ctid>:8080 [middleware: gig-auth]
Cookie gotcha: the gate cookie must be
SameSite=None; Secure. With Lax the browser drops it inside the embedded
<iframe> and the desktop pane renders a 403.
The Supabase secret key never reaches the browser; the page only ever carries the publishable key (and after the Google-only rework, not even that).
POST /app/api/session {access_token}
β GET <project>/auth/v1/user (verify with Supabase)
β email β slug = <localpart>-<sha256(email)[:6]>
β sign our own cookie fcw_user = b64(payload|exp|HMAC)
POST /app/api/start (auth required)
β orchestrator POST /users/<slug>/vm?size=&engine=&anydesk=
The slug is a stable hash of the email, so a given customer always resolves to the same VM and never to anyone else's.
golden CT121 ββ(pct template)βββΆ linked clones CT130-199
Debian + nesting + /dev/kvm + dockur Windows + Guacamole
POST /users/<u>/vm clone-or-resume, set unique IP, size, publish route, mint SSO
POST /users/<u>/stop pct suspend β returns billed_seconds (disk persists)
DELETE /users/<u>/vm destroy
POST /template seal the golden image
| Size | cores / memory |
|---|---|
| small | 2 / 4096 |
| medium | 4 / 8192 |
| large | 6 / 16384 |
Guacamole normally shows its own login. guacamole-auth-json (already bundled in the image)
accepts a signed + encrypted assertion that carries the user and the RDP connection inline β so there is
no Guacamole account per customer and no shared admin password in the flow.
JSON_SECRET_KEY (16-byte hex) on the guacamole service and restart.Extension "Encrypted JSON Authentication" (json) loaded.{username, expires, connections:{...rdp...}}HMAC-SHA256(key, json) prepended to the JSON, then AES-128-CBC (zero IV), base64.POST /guacamole/api/tokens with data=<blob> β authToken/guacamole/#/?token=<authToken> β lands straight in the desktop.The gate passes the deep link through as ?t=<hmac>&next=/guacamole/#/?token=..., so the
session is authorised and the desktop opens in one hop.
| Secret | Where it lives | Reaches browser? |
|---|---|---|
| Google client secret | /opt/fleet/.supabase.env (0600) + Supabase | no |
| Supabase secret key | /opt/fleet/.supabase.env (0600) | no |
| Supabase publishable key | portal env | yes (by design) |
| Fleet HMAC key | /opt/fleet/.secret (0600) | no |
| Control-plane API key | /opt/fleet/.secret.env (0600) | no |
| Guacamole JSON SSO key | per-VM compose env | no |
Outstanding: the SSO key and the Windows account password are still shared placeholders inherited from the golden template. Randomise them per clone before real customers, and rotate every credential that has been pasted into a chat or terminal.
Verify functionally β a green dashboard toggle is not evidence.
# Is Google really enabled?
curl -s -H "apikey: <PUBLISHABLE>" https://<project>.supabase.co/auth/v1/settings
# expect: "external": { "google": true, ... }
# Does the OAuth hop actually work?
curl -s -o /dev/null -w "%{redirect_url}" \
"https://<project>.supabase.co/auth/v1/authorize?provider=google&redirect_to=https%3A%2F%2Ffastco.work%2Fapp"
# expect: 302 β accounts.google.com/... with OUR client_id
# Is the app gated?
curl -o /dev/null -w "%{http_code}" -X POST https://fastco.work/app/api/start # 401
curl -o /dev/null -w "%{http_code}" https://u-<user>.fastco.work/guacamole/ # 403
# Fleet health
docker ps --filter name=fleet ; systemctl status fastco-orch
| Symptom | Cause | Fix |
|---|---|---|
Unsupported provider: provider is not enabled | Supabase Google toggle silently off despite saved credentials | flip toggle, then Save; verify via /auth/v1/settings |
| Desktop iframe shows 403 / broken | gate cookie was SameSite=Lax | SameSite=None; Secure |
| Every user lands on the same IP | clones inherit the template's static IP | pct set <ctid> --net0 ...ip=192.168.1.<ctid>/24 |
Cannot do full clones on a running container | golden template still running | stop it, then pct template |
Google returns access_blocked | consent screen still in Testing | Audience β Publish app |
OSError: [Errno 16] Resource busy | atomic replace over a bind-mounted file | bind-mount the directory instead |
| Cloudflare token reads but cannot write DNS | "Entire Account" scope selected but zero permissions ticked | tick DNS Write + Tunnel Write; /user/tokens/verify is unreliable for cfat_ tokens |
Two containers from /opt/fleet/rustdesk/compose.yml: rustdesk-hbbs
(rendezvous) and rustdesk-hbbr (relay). The public key clients must trust lives at
/opt/fleet/rustdesk/data/id_ed25519.pub.
The golden bakes the client and writes RustDesk2.toml with
custom-rendezvous-server, relay-server and key already set,
then deletes RustDesk.toml. That deletion matters: the ID lives in that file, so
leaving it in the golden would give every linked clone the same RustDesk ID. Clearing it
forces each clone to mint its own on first service start.
To push RustDesk into an already-running VM without a re-bake:
python3 /usr/local/bin/rustdesk-provision.py <CTID>
# writes provision.cmd; the in-guest agent runs it within ~60s and reports back
cat /opt/cowork/shared/rustdesk-id.txt # inside the CT: the minted ID
Results are written to the shared folder, not C:\fastco, because reading the
guest's NTFS disk while the VM runs returns stale data β a trap that previously caused a working
build to be declared failed.
Create /opt/fleet/.wa.env (mode 0600):
WA_TOKEN=... # permanent token from the Meta app
WA_PHONE_ID=... # phone number id, not the phone number
WA_APP_SECRET=... # used to verify X-Hub-Signature-256
Point the Meta webhook at https://fastco.work/wa/webhook with verify token
fastco-verify. The handshake returns the challenge on success and 403 on a wrong
token, so a misconfiguration fails loudly rather than silently accepting traffic.
Questions the FAQ didn't cover, or interested in the customizable Max plan?
Message us on WhatsApp or call us at +66 86 954 1666