# Tailscale Funnel — Atlas Public URL Setup

**Date:** 2026-05-25
**Owner:** Riv (this doc) / Forge (Atlas) / Cord (Coefficient consumer)
**Status:** LIVE — `https://minas-tirith.taildacccb.ts.net`

---

## What this gives us

Atlas's REST API on Minas (`http://localhost:3000`) is now publicly reachable via Tailscale Funnel at:

```
https://minas-tirith.taildacccb.ts.net
```

Every endpoint Atlas exposes — including the client-stage Phase 1 surface Forge just shipped — is reachable at the same paths over the public URL. Bearer auth still gates everything; nothing changed about the auth model.

**Verification on 2026-05-25:**
- `GET /api/clients` with bearer → 200, returns all 30 active clients (full JSON shape preserved)
- `GET /api/clients` without bearer → 401 "missing bearer token"
- `GET /api/clients` with wrong bearer → 401 "unauthorized"
- Write endpoints (PATCH stage, POST flags) reachable; auth gates them identically to localhost behavior

## Why Tailscale (over Cloudflare Tunnel)

Decision locked by Jimmie 2026-05-25 after weighing options:
- Free Personal plan covers J2 scale (3 users, 100 devices)
- Single daemon gives both mesh (Meshnet-equivalent) and public tunnel (Funnel)
- Cleaner long-term option than Cloudflare for J2 because Tailscale also covers private device-to-device connections (Gondor → Minas, phone → Minas) without exposing those to the internet
- Cost: $0/mo for the foreseeable future

## Infrastructure shape

- **Tailscale account:** signed up under `jimmie@j2bookkeeping.com` (Google SSO)
- **Tailnet name:** `taildacccb.ts.net`
- **First node:** `minas-tirith` (Minas, the headless server hosting Atlas)
- **Tailscale internal mesh IP:** `100.100.183.69` (also `fd7a:115c:a1e0::6739:b745` IPv6)
- **Public Funnel ingress IPs (Tailscale anycast, stable infra):**
  - IPv4: `103.84.155.153`, `103.84.155.217`
  - IPv6: `2403:2500:400:20::25a`, `2403:2500:400:20::e8e`
  - These are Tailscale's shared anycast IPs; routing happens via SNI (TLS Server Name Indication)

## Setup steps actually executed

1. **Tailscale account signup** via login.tailscale.com → Google SSO with `jimmie@j2bookkeeping.com` → Personal plan
2. **Tailscale daemon install on Minas** (Windows installer, completed by Jimmie)
3. **First-time login** — node `minas-tirith` joined the tailnet
4. **Per-node Funnel consent** — Tailscale's modern model requires a one-time approval per device:
   - `tailscale funnel 3000` printed a deep-link URL
   - Jimmie clicked it and approved Funnel for the Minas node
5. **HTTPS Certificates enabled** in the admin console under DNS settings (prerequisite for Funnel)
6. **`tailscale funnel --bg --yes 3000`** — published Atlas at the public URL
7. **`tailscale cert minas-tirith.taildacccb.ts.net`** — provisioned the Let's Encrypt cert (Tailscale auto-managed; renewal also handled by Tailscale)

Total time: ~10 minutes once Jimmie was ready to click through.

## How to verify Funnel is running

```powershell
& "C:\Program Files\Tailscale\tailscale.exe" funnel status
```

Expected output:
```
# Funnel on:
#     - https://minas-tirith.taildacccb.ts.net
https://minas-tirith.taildacccb.ts.net (Funnel on)
|-- / proxy http://127.0.0.1:3000
```

## How to test connectivity (and a gotcha)

**Gotcha:** Windows `Resolve-DnsName` and `Invoke-WebRequest` from Minas itself get intercepted by Tailscale's local DNS resolver, which short-circuits the public hostname to the mesh IP (100.100.183.69). This means **tests from Minas via PowerShell will look broken even though Funnel is fine**.

To test as an external client (mimicking Coefficient) from Minas:

```powershell
$TOKEN = (Get-Content "C:\PKA\Atlas\app\.env.local" | Select-String "^ATLAS_INGEST_TOKEN=") -replace "^ATLAS_INGEST_TOKEN=","" -replace '"',''
curl.exe -sS --resolve "minas-tirith.taildacccb.ts.net:443:103.84.155.153" `
  -H "Authorization: Bearer $TOKEN" `
  https://minas-tirith.taildacccb.ts.net/api/clients
```

The `--resolve` flag forces connection to a known Tailscale public ingress IP, bypassing the local DNS hook. From a non-Tailscale machine (Coefficient, agents, etc.) this isn't needed — public DNS naturally returns the ingress IPs.

## Auth model (unchanged)

Atlas's bearer-token model from Forge's Phase 1 still gates everything:

| Token | Maps to | Notes |
|-------|---------|-------|
| `ATLAS_INGEST_TOKEN` in `.env.local` | `actor_id='system:legacy'` | Legacy shared token; backward-compat for Kade bot, ingest scripts, this tunnel verification, and future Coefficient |
| Future per-actor tokens in `actor_tokens` table | `actor_id='agent:<name>'` or `'user:<name>'` | Phase 2 — Riv issues these as agents come online |

**Do not rotate the legacy token.** Other callers depend on it. Phase 2 migration of those callers to per-actor tokens is on Riv's roadmap.

## Phase 2 implications (this tunnel makes them possible)

The Funnel URL unblocks several Phase 2 designs that Loom and Forge had pending:

1. **`atlas-agent-sdk`** — agents running on any device (not just Minas) can call `https://minas-tirith.taildacccb.ts.net/api/...` with their own bearer token
2. **QBO webhook callbacks** — Intuit's webhook delivery service can POST to the Funnel URL
3. **Slack event subscriptions** — Slack's outbound webhooks can hit Atlas directly (rather than going through the existing Python bot middleware)
4. **Future Coefficient + Quadratic integrations** — anything that consumes Atlas data from outside the LAN

All without adding any new network surface beyond what Funnel already exposes.

## Operational considerations

- **TLS cert renewal:** Tailscale handles Let's Encrypt renewal automatically; no manual rotation needed
- **Funnel staying on across reboots:** verified `--bg` persists; the Tailscale daemon (Windows service) restarts Funnel on machine restart
- **Failure mode:** if Minas reboots and the daemon doesn't auto-start, Funnel goes dark and external callers get connection refused. The Tailscale daemon is configured as a Windows service set to auto-start, so this should be rare. Adding a healthcheck cron (e.g., a Kade-style daily ping from outside) is a future hardening item.
- **Bandwidth:** Free plan has generous limits for our use case (JSON pulls every 15 min). Not a near-term concern.

## Future hardening (not blocking anything today)

| Item | Why | Priority |
|------|-----|----------|
| Cloudflare Access-style auth gate in front of Atlas | Right now bearer auth is the only gate. Adding an IP allowlist or extra auth layer at the Funnel edge would defense-in-depth. | Low — bearer is sufficient for the data sensitivity |
| Custom domain `atlas.j2bookkeeping.com` | Easier to remember; brand consistency | Low — `.ts.net` URL is fine for internal/Coefficient use |
| Tailscale on Gondor + Jimmie's phone | Private mesh access to Atlas without going through public Funnel | Medium — nice quality-of-life; Jimmie can self-serve when convenient |
| Tailscale ACLs to restrict which devices can use Funnel | Currently "all users and devices" can publish Funnel; tightening this is good hygiene as the tailnet grows | Medium — relevant once more nodes join |
| Monitoring: alert if Funnel status drops | Detect if reboot doesn't auto-restart Funnel | Low — once Phase 2 work surfaces real reliance |

## Files touched on Minas

- `C:\Program Files\Tailscale\` — Tailscale daemon (installed by Jimmie via MSI)
- `C:\Users\Minas_Tirith\minas-tirith.taildacccb.ts.net.crt` and `.key` — Let's Encrypt cert files written by `tailscale cert`
- No changes to Atlas codebase or PM2 config — the tunnel is a separate process layer

## Hand-off

- **Cord:** see `Team Inbox/Cord/atlas-public-url.txt` for the URL + Coefficient config snippet
- **Forge:** no changes needed; Atlas continues to bind 0.0.0.0:3000 and the tunnel forwards
- **Larry:** I'll report up with the verification result and the URL
