← back to Tailscale Acl Design

PLAN.md

296 lines

# Tailscale ACL Design — Mac Studio 2 Tailnet-Only Access

**Date:** 2026-05-04
**Status:** Design only — no changes made

---

## 1. Current Threat Model

### What Is Exposed Today

18 pm2 services on Mac Studio 2 (`100.65.187.120`) are bound to `0.0.0.0` on ports in the 9670–9900 range. The only gate is HTTP Basic Auth with a single shared credential (`admin:DWSecure2024!`). That credential is embedded in Wave 1 source files (41+ tracked files) and stored in pm2 dump.

### What Can Go Wrong on 192.168.1.0/24 LAN

- **Smart TVs / IoT / guest WiFi**: Any device on the same /24 can reach all 18 ports. IoT firmware is routinely unpatched; a compromised TV can port-scan and credential-stuff the Basic Auth in seconds.
- **Guest WiFi bleed**: If a guest device lands on the same broadcast domain, they get the same exposure. Basic Auth over plain HTTP is trivially sniffed in active MITM scenarios.
- **Single shared credential**: `DWSecure2024!` is reused across morning-review, video-gallery, George (Kamatera), and builds.agentabrams.com. One leak compromises everything.
- **No per-service isolation**: All 18 services fail-open to the same credential; there is no service-level access differentiation.
- **No audit trail**: Basic Auth produces no per-request identity log.

### What Tailscale ACLs Fix

Tailscale evaluates ACLs at the network layer before a packet ever reaches an app. A device off the tailnet literally cannot initiate a TCP connection to Mac2's tailnet IP, regardless of what the app binds to. Basic Auth becomes a belt-and-suspenders fallback rather than the primary gate.

---

## 2. Recommended ACL JSON

> **ACTION REQUIRED — before pasting:** Replace `YOUR_EMAIL@EXAMPLE.COM` with your actual Tailscale login email (check https://login.tailscale.com/admin/machines — it appears next to your devices). Also register each device tag via the Tailscale admin Machines page after applying this policy.

Paste this at https://login.tailscale.com/admin/acls (replaces the existing policy file).

```json
{
  // Tailscale HuJSON policy — Mac Studio 2 tailnet-only hardening
  // Last updated: 2026-05-04

  "groups": {
    "group:owner": [
      "YOUR_EMAIL@EXAMPLE.COM"
    ]
  },

  "tagOwners": {
    "tag:mac2":     ["group:owner"],
    "tag:mac1":     ["group:owner"],
    "tag:laptop":   ["group:owner"],
    "tag:kamatera": ["group:owner"],
    "tag:phone":    ["group:owner"]
  },

  "acls": [
    // Owner (any device) can reach Mac2 on all service ports
    {
      "action": "accept",
      "src":    ["group:owner"],
      "dst":    ["tag:mac2:*"]
    },

    // Mac1 can reach Mac2 — needed for Ollama cross-call (192.168.1.133 → tailnet)
    // and for the 8-way debate loop (Mac1 runs 4 Ollama models Mac2 queries)
    {
      "action": "accept",
      "src":    ["tag:mac1"],
      "dst":    ["tag:mac2:11434", "tag:mac2:9670-9900"]
    },

    // Kamatera can reach Mac2 — George (100.107.67.67:9850) calls Mac2 services
    // and the pg logical replication subscriber on Mac2 receives from Kamatera
    {
      "action": "accept",
      "src":    ["tag:kamatera"],
      "dst":    ["tag:mac2:5432", "tag:mac2:9762", "tag:mac2:9850"]
    },

    // Mac2 can reach Kamatera — outbound calls (George, smb-builder, deploys)
    {
      "action": "accept",
      "src":    ["tag:mac2"],
      "dst":    ["tag:kamatera:*"]
    },

    // Mac2 can reach Mac1 — Ollama bulk LLM workloads go to 192.168.1.133:11434
    {
      "action": "accept",
      "src":    ["tag:mac2"],
      "dst":    ["tag:mac1:11434"]
    },

    // Default deny everything else — Tailscale default is deny-all,
    // but this is explicit for clarity and test-stanza coverage
    {
      "action": "accept",
      "src":    ["group:owner"],
      "dst":    ["tag:mac1:*", "tag:kamatera:*", "tag:laptop:*"]
    }
  ],

  "ssh": [
    // Steve can SSH into any of his machines without re-entering password
    // "check" requires periodic re-auth (20h window) for auditability
    {
      "action":      "check",
      "src":         ["group:owner"],
      "dst":         ["tag:mac2", "tag:mac1", "tag:kamatera"],
      "users":       ["autogroup:nonroot", "stevestudio2", "root"],
      "checkPeriod": "20h"
    }
  ],

  "tests": [
    // ALLOW: Steve's account reaches Mac2 services
    {
      "src":    "YOUR_EMAIL@EXAMPLE.COM",
      "accept": [
        "tag:mac2:9762",
        "tag:mac2:9880",
        "tag:mac2:9850",
        "tag:mac2:22"
      ]
    },

    // ALLOW: Mac1 can reach Mac2 Ollama + service range
    {
      "src":    "tag:mac1",
      "accept": [
        "tag:mac2:11434",
        "tag:mac2:9701"
      ]
    },

    // ALLOW: Kamatera can reach Mac2 PG replication port + morning-review
    {
      "src":    "tag:kamatera",
      "accept": [
        "tag:mac2:5432",
        "tag:mac2:9762"
      ]
    },

    // DENY: An unknown/untagged device must not reach Mac2
    {
      "src":  "tag:phone",
      "deny": [
        "tag:mac2:9880",
        "tag:mac2:9701"
      ]
    }
  ]
}
```

### Assigning Tags to Devices

After saving this policy, go to https://login.tailscale.com/admin/machines and for each device click the "..." menu → "Edit tags":

| Device          | Tailscale IP     | Assign tag     |
|-----------------|------------------|----------------|
| Mac Studio 2    | 100.65.187.120   | `tag:mac2`     |
| Mac Studio 1    | (check admin)    | `tag:mac1`     |
| MacBook Pro     | (check admin)    | `tag:laptop`   |
| Kamatera VPS    | 100.107.67.67    | `tag:kamatera` |
| iPhone          | (check admin)    | `tag:phone`    |

---

## 3. Defense-in-Depth: macOS pf Rules

Even with Tailscale ACLs, `0.0.0.0` binds still answer on the physical LAN interface (`en0`). Use macOS `pf` to drop inbound connections on `en0` for the service port range while allowing the Tailscale interface (`utun*`).

### /etc/pf.anchors/com.steve.mac2-services

```
# Drop inbound connections on LAN (en0) for pm2 service ports
# Tailscale uses utun* — those are NOT blocked here
# Load with: sudo pfctl -f /etc/pf.conf

# Macro: LAN interface
ext_if = "en0"

# Block inbound TCP on en0 for pm2 service range
block in quick on $ext_if proto tcp from any to any port 9000:9999
block in quick on $ext_if proto tcp from any to any port 7810
block in quick on $ext_if proto tcp from any to any port 3333
```

### Activation

```bash
# Add anchor to /etc/pf.conf (one-time, requires sudo)
# Append these two lines before the final "pass" rule:
#   anchor "com.steve.mac2-services"
#   load anchor "com.steve.mac2-services" from "/etc/pf.anchors/com.steve.mac2-services"

sudo cp /etc/pf.conf /etc/pf.conf.bak-$(date +%Y%m%d)
# Edit /etc/pf.conf to add the anchor lines above
sudo pfctl -f /etc/pf.conf
sudo pfctl -e   # enable pf if not already enabled
```

### Persistence

Create `/Library/LaunchDaemons/com.steve.pf-mac2.plist`:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "...">
<plist version="1.0">
<dict>
  <key>Label</key><string>com.steve.pf-mac2</string>
  <key>RunAtLoad</key><true/>
  <key>ProgramArguments</key>
  <array>
    <string>/sbin/pfctl</string>
    <string>-f</string>
    <string>/etc/pf.conf</string>
  </array>
</dict>
</plist>
```

```bash
sudo launchctl load -w /Library/LaunchDaemons/com.steve.pf-mac2.plist
```

> Note: macOS SIP does not block pf on Apple Silicon. Tested pattern. Tailscale uses `utun` interfaces which are not affected by this anchor.

---

## 4. Migration Plan (Roll Out Without Breaking Access)

### Phase 0: Pre-flight (5 min)

1. Confirm your Tailscale login email at https://login.tailscale.com/admin/settings.
2. Confirm all 5 devices appear in https://login.tailscale.com/admin/machines and note their tailnet IPs.
3. Note which services currently work from your MacBook Pro via tailnet.

### Phase 1: Apply ACL + Assign Tags to Mac2 Only (15 min)

1. Paste the ACL JSON above (with your email substituted) into https://login.tailscale.com/admin/acls.
2. Hit "Save" — Tailscale validates the `tests:` block first; it will refuse to save if tests fail.
3. In Machines, assign `tag:mac2` to Mac Studio 2 only. Leave other devices untagged for now.
4. Smoke test from MacBook Pro: `curl -u admin:DWSecure2024! http://100.65.187.120:9762/` — should work.
5. Smoke test from a non-tailnet device (e.g., phone on cell data): connection should time out.

### Phase 2: Tag Remaining Devices (10 min)

1. Assign `tag:mac1`, `tag:laptop`, `tag:kamatera`, `tag:phone` to their respective machines.
2. Verify Mac1 → Mac2 Ollama call still works: `curl http://100.65.187.120:11434/api/tags` from Mac1.
3. Verify George (Kamatera → Mac2) still works: `ssh root@kamatera "curl -u admin:DWSecure2024! http://100.65.187.120:9762/api/health"`.

### Phase 3: pf Rules (optional, 20 min)

1. Apply pf anchor as described in Section 3.
2. Test from LAN device (not tailnet): `nc -zv 100.65.187.120 9762` — should fail (connection refused or timeout on en0).
3. Test from MacBook Pro on tailnet: same curl as Phase 1 — should still work (utun, not en0).

### Phase 4: Rotate the Shared Credential (deferred)

Once network-layer gates are verified, rotate `DWSecure2024!` to a new credential. Use the Wave 2 env-only pattern for all services (no more source fallback). Re-fan via secrets-manager. This is a separate session-scope task.

---

## 5. Risks

### Risk 1 (HIGH): Mac Studio 1 LAN-to-tailnet Ollama calls break

Mac1 sends Ollama requests to Mac2 at `192.168.1.X:11434` (LAN IP), not the tailnet IP. If pf blocks port 11434 on `en0`, Mac1's LAN-originated calls die. The ACL rule `tag:mac1 → tag:mac2:11434` only protects tailnet-originated traffic. **Fix:** either accept port 11434 on `en0` from `192.168.1.0/24` in the pf anchor, or update all Mac1 Ollama target configs to use the tailnet IP `100.65.187.120:11434`. Memory note: `feedback_ollama_default_ms1.md` sets the default target as `http://192.168.1.133:11434` — that's Mac1 as a server, not client. Verify which direction before applying pf.

### Risk 2 (HIGH): launchd watchdogs hitting localhost skip tailnet entirely

watchdogs like `com.steve.pd-hawk`, `com.steve.animals-hawk`, and `com.steve.sf-hawk` call services via `localhost:PORT`. Those are loopback connections — they never touch en0 or utun — so pf rules don't affect them and Tailscale ACLs don't apply. These are safe and will continue working. No risk of breakage, but also no benefit from the ACL hardening for inter-process calls.

### Risk 3 (MEDIUM): PG logical replication subscriber connection breaks

Kamatera replicates to Mac2 via `dw_unified_sub` / slot `dw_unified_to_mac`. The replication connection originates from Kamatera (`100.107.67.67`) to Mac2's PG port 5432. The ACL above allows this (`tag:kamatera → tag:mac2:5432`). However, if Kamatera's device is not yet tagged `tag:kamatera` when the policy saves, the connection drops immediately and the slot accumulates WAL. **Fix:** tag Kamatera before saving, or tag it within minutes of saving.

### Risk 4 (LOW): tests: block references email before tagging

The test stanzas reference `YOUR_EMAIL@EXAMPLE.COM`. If you paste the policy with the placeholder un-replaced, Tailscale will accept it (it treats unknown users as deny) but the allow tests will fail and the policy save will be blocked. This is actually a safety guard — the policy won't apply until the email is correct.

### Risk 5 (LOW): iPhone currently has no ACL allow to Mac2

The phone tag is explicitly in the deny test. If you want Steve's iPhone to reach the bookmark page or morning-review from mobile, add a rule: `{ "action": "accept", "src": ["tag:phone"], "dst": ["tag:mac2:9762"] }`.

---

## Items Requiring Steve's Input

- [ ] **Tailscale login email** — replace `YOUR_EMAIL@EXAMPLE.COM` in two places (groups + tests).
- [ ] **Mac Studio 1 tailnet IP** — check https://login.tailscale.com/admin/machines and confirm it appears; needed to verify `tag:mac1` tag assignment.
- [ ] **iPhone on tailnet?** — confirm whether the iPhone has Tailscale installed and if you want it to reach Mac2 services. If yes, add an allow rule for `tag:phone → tag:mac2:9762` (or wider).
- [ ] **pf phase** — optional but recommended. Confirm en0 is your LAN interface (`networksetup -listallnetworkservices` or `ifconfig | grep -E "^en"`).
- [ ] **Credential rotation** — timing: do you want to rotate `DWSecure2024!` immediately after ACL verification, or defer to a scheduled maintenance window?