Skill Tracer
Skills › gstack › ios-qa › tailscale-acl-example.md

tailscale-acl-example.md

157 lines · ios-qa/docs/tailscale-acl-example.md at 3f79d81

Open on GitHub
1# Tailscale ACL example for the iOS QA daemon
2
3The Mac-side daemon binds the Tailscale interface only when you pass
4`--tailnet`. By default the daemon is local-USB-only. This doc walks through
5the steps to expose your iPhone to remote agents safely so they can run iOS QA over the tailnet.
6

Tailscale ACL example for the iOS QA daemon

The Mac-side daemon binds the Tailscale interface only when you pass --tailnet. By default the daemon is local-USB-only. This doc walks through the steps to expose your iPhone to remote agents safely so they can run iOS QA over the tailnet.

Raw lines 1 to 6
7## Threat model recap
8
9- **iOS app StateServer:** loopback-only always. Reachable from the Mac via
10 the CoreDevice IPv6 tunnel. Never directly bound to tailnet.
11- **Mac daemon:** owns the tailnet interface. Binds two listeners — loopback
12 (full surface, never forwarded) and tailnet (locked allowlist with
13 capability tiers).
14- **Auth:** Tailscale identity validation via the local `tailscaled` socket
15 (`/var/run/tailscale.sock` LocalAPI WhoIs). Allowlist file at
16 `~/.gstack/ios-qa-allowlist.json` is the single source of truth for who can
17 do what.

Threat model recap

  • iOS app StateServer: loopback-only always. Reachable from the Mac via the CoreDevice IPv6 tunnel. Never directly bound to tailnet.
  • Mac daemon: owns the tailnet interface. Binds two listeners — loopback (full surface, never forwarded) and tailnet (locked allowlist with capability tiers).
  • Auth: Tailscale identity validation via the local tailscaled socket (/var/run/tailscale.sock LocalAPI WhoIs). Allowlist file at ~/.gstack/ios-qa-allowlist.json is the single source of truth for who can do what.
Raw lines 7 to 18
19## Step 1: Install and run Tailscale
21```bash
22brew install --cask tailscale
23# Login + start tailscaled, then verify:
24tailscale status
25```
27Confirm the daemon can read the LocalAPI socket:
29```bash
30test -S /var/run/tailscale.sock && echo "socket present" || echo "MISSING"
31```
33If missing, the daemon will refuse to open the tailnet listener (fail-closed).

Step 1: Install and run Tailscale

brew install --cask tailscale
# Login + start tailscaled, then verify:
tailscale status

Confirm the daemon can read the LocalAPI socket:

test -S /var/run/tailscale.sock && echo "socket present" || echo "MISSING"

If missing, the daemon will refuse to open the tailnet listener (fail-closed).

Raw lines 19 to 34
35## Step 2: Set up the daemon's ACL
37The daemon needs to know which Tailscale identities are allowed to control
38which devices at which capability tier. The allowlist file is JSON:
40```json
42 "version": 1,
43 "entries": [
44 {
45 "identity": "you@example.com",
46 "capabilities": ["restore"],
47 "expires_at": null,
48 "note": "Owner — full access"
49 },
50 {
51 "identity": "ci@example.com",
52 "capabilities": ["mutate"],
53 "expires_at": "2026-12-31T00:00:00Z",
54 "note": "CI runner — can write state but not full restore"
55 },
56 {
57 "identity": "tag:claude-readonly",
58 "capabilities": ["observe"],
59 "expires_at": null,
60 "note": "Agents that should only read"
61 }
62 ]
64```
66Identities are canonicalized via WhoIs:
68- **User OAuth:** `user@example.com` (no `acct:`, no domain rewriting).
69- **Tagged nodes:** `tag:<tagname>` (lowercased).
70- **Node keys:** `node:<nodekey-hex>` (rare; use tags instead).
72Capability tiers are ordered: `observe` < `interact` < `mutate` < `restore`.
73Granting `restore` implies all lower tiers.

Step 2: Set up the daemon's ACL

The daemon needs to know which Tailscale identities are allowed to control which devices at which capability tier. The allowlist file is JSON:

{
  "version": 1,
  "entries": [
    {
      "identity": "you@example.com",
      "capabilities": ["restore"],
      "expires_at": null,
      "note": "Owner — full access"
    },
    {
      "identity": "ci@example.com",
      "capabilities": ["mutate"],
      "expires_at": "2026-12-31T00:00:00Z",
      "note": "CI runner — can write state but not full restore"
    },
    {
      "identity": "tag:claude-readonly",
      "capabilities": ["observe"],
      "expires_at": null,
      "note": "Agents that should only read"
    }
  ]
}

Identities are canonicalized via WhoIs:

  • User OAuth: user@example.com (no acct:, no domain rewriting).
  • Tagged nodes: tag:<tagname> (lowercased).
  • Node keys: node:<nodekey-hex> (rare; use tags instead).

Capability tiers are ordered: observe < interact < mutate < restore. Granting restore implies all lower tiers.

Raw lines 35 to 74
75## Step 3: Mint a session token for a remote agent
77You can let agents self-mint (if their identity is allowlisted) or you can
78mint server-side for them:
80```bash
81# Server-side mint (owner-only, runs locally on the Mac with the device):
82gstack-ios-qa-mint --remote ci@example.com --capability mutate --ttl 1h
84# Self-service mint (agent over tailnet):
85curl -X POST http://<mac-tailnet-ip>:9999/auth/mint \
86 -H "Content-Type: application/json" \
87 -d '{"capability": "interact"}'
88# → {"session_token": "...", "expires_at": "...", "capability": "interact"}
89```

Step 3: Mint a session token for a remote agent

You can let agents self-mint (if their identity is allowlisted) or you can mint server-side for them:

# Server-side mint (owner-only, runs locally on the Mac with the device):
gstack-ios-qa-mint --remote ci@example.com --capability mutate --ttl 1h

# Self-service mint (agent over tailnet):
curl -X POST http://<mac-tailnet-ip>:9999/auth/mint \
  -H "Content-Type: application/json" \
  -d '{"capability": "interact"}'
# → {"session_token": "...", "expires_at": "...", "capability": "interact"}
Raw lines 75 to 90
91## Step 4: Tighten the Tailscale ACL (defense in depth)
93The daemon's allowlist is the primary access control. Belt-and-suspenders:
94restrict the tailnet ACL to limit who can even *reach* the daemon port.
96```jsonc
97// In your tailscale admin console:
99 "acls": [
100 // Allow CI runner to reach the iOS QA Mac on port 9999 only.
101 {
102 "action": "accept",
103 "src": ["ci@example.com"],
104 "dst": ["ios-qa-mac:9999"]
105 },
106 // Tagged Claude agents — observe tier only (enforced by daemon, not ACL).
107 {
108 "action": "accept",
109 "src": ["tag:claude-readonly"],
110 "dst": ["ios-qa-mac:9999"]
111 },
112 // Default deny.
113 {
114 "action": "drop",
115 "src": ["*"],
116 "dst": ["ios-qa-mac:9999"]
117 }
118 ]
120```

Step 4: Tighten the Tailscale ACL (defense in depth)

The daemon's allowlist is the primary access control. Belt-and-suspenders: restrict the tailnet ACL to limit who can even reach the daemon port.

// In your tailscale admin console:
{
  "acls": [
    // Allow CI runner to reach the iOS QA Mac on port 9999 only.
    {
      "action": "accept",
      "src": ["ci@example.com"],
      "dst": ["ios-qa-mac:9999"]
    },
    // Tagged Claude agents — observe tier only (enforced by daemon, not ACL).
    {
      "action": "accept",
      "src": ["tag:claude-readonly"],
      "dst": ["ios-qa-mac:9999"]
    },
    // Default deny.
    {
      "action": "drop",
      "src": ["*"],
      "dst": ["ios-qa-mac:9999"]
    }
  ]
}
Raw lines 91 to 121
122## Step 5: Audit trail
124Every authenticated mutating request through the tailnet listener writes a
125row to `~/.gstack/security/ios-qa-audit.jsonl`:
127```jsonl
128{"ts":"2026-05-18T14:23:00Z","identity":"ci@example.com","device_udid":"00008101-XXXX","endpoint":"/tap","session_id":"abc...","capability":"interact","request_id":"req_001","status":200}
129```
131Rejections (no token, expired token, capability-insufficient, identity not
132allowlisted, rate limit hit) write to `~/.gstack/security/attempts.jsonl`.

Step 5: Audit trail

Every authenticated mutating request through the tailnet listener writes a row to ~/.gstack/security/ios-qa-audit.jsonl:

{"ts":"2026-05-18T14:23:00Z","identity":"ci@example.com","device_udid":"00008101-XXXX","endpoint":"/tap","session_id":"abc...","capability":"interact","request_id":"req_001","status":200}

Rejections (no token, expired token, capability-insufficient, identity not allowlisted, rate limit hit) write to ~/.gstack/security/attempts.jsonl.

Raw lines 122 to 133
134## Rate limits
136- `/auth/mint`: 10 mints / 60s per identity. 11th returns 429.
137- Per-tailnet-request body: 1MB hard cap (413 above).
138- Screenshot response: 10MB hard cap (500 above with sanitized error).

Rate limits

  • /auth/mint: 10 mints / 60s per identity. 11th returns 429.
  • Per-tailnet-request body: 1MB hard cap (413 above).
  • Screenshot response: 10MB hard cap (500 above with sanitized error).
Raw lines 134 to 139
140## Token lifetime
142- Daemon-minted session tokens: default 1h TTL, max 24h via
143 `--tailnet-session-ttl`.
144- Refreshable via `POST /session/heartbeat` (extends by `ttl_seconds`, capped
145 at the original max).
146- Boot token (between iOS app launch and daemon rotation): ~5s lifetime —
147 daemon rotates immediately on first scrape.

Token lifetime

  • Daemon-minted session tokens: default 1h TTL, max 24h via --tailnet-session-ttl.
  • Refreshable via POST /session/heartbeat (extends by ttl_seconds, capped at the original max).
  • Boot token (between iOS app launch and daemon rotation): ~5s lifetime — daemon rotates immediately on first scrape.
Raw lines 140 to 148
149## Failure modes
151| Symptom | Cause | Action |
152|---|---|---|
153| Daemon refuses to open tailnet listener | `/var/run/tailscale.sock` missing or permission-denied | Install Tailscale; verify `tailscale status` works as the user running daemon |
154| `403 identity_not_allowed` | identity missing from allowlist | Owner mint: `gstack-ios-qa-mint --remote <identity>` |
155| `403 capability_insufficient` | token tier below endpoint requirement | Owner mint with higher `--capability` tier |
156| `429 rate_limited` | >10 mints/min from one identity | Wait 60s; investigate why the agent is re-minting so often |
157| `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture from current app build |

Failure modes

SymptomCauseAction
Daemon refuses to open tailnet listener/var/run/tailscale.sock missing or permission-deniedInstall Tailscale; verify tailscale status works as the user running daemon
403 identity_not_allowedidentity missing from allowlistOwner mint: gstack-ios-qa-mint --remote <identity>
403 capability_insufficienttoken tier below endpoint requirementOwner mint with higher --capability tier
429 rate_limited>10 mints/min from one identityWait 60s; investigate why the agent is re-minting so often
409 schema_mismatch on /state/restoresnapshot from older app buildDiscard the snapshot; re-capture from current app build
Raw lines 149 to 157