# INFRA.md — 本機共用基礎設施（範本）

> 這份是「大樓的水電說明書」：每個專案（住戶）都需要網頁、通知、備份，但不該每戶自己牽一條；
> 由一個 `infra/` 專案統一提供，其他專案照這份說明書接上去。
>
> 寫法是**情境式**：讀的人（多半是別的專案的 agent）帶著「我要做 X」來，翻到對應情境就知道步驟、為什麼、坑。
> **從其他專案過來的話讀這份就夠**，不必去讀 `infra/` 的內部。
>
> 使用方式：這份是作者的架構，**剛起步不用做**。等某個專案第一次需要「給手機看網頁」或「做完通知我」時，
> 再建 infra，並從那一個情境開始寫。`<...>` 是要換成你自己環境的值。所有 IP、主機名、topic 名都不要寫死在專案程式裡，寫在這裡一處。

## 1. 內部構成

```
              私人 VPN（tailnet；只有你自己登入的裝置看得到）
                        │
              <hostname>.<tailnet-name>.ts.net
                        │
                 tailscale serve          ← TLS 在這裡終結
                  │              │
        :443 → 127.0.0.1:<caddy-port>    :8443 → 127.0.0.1:<ntfy-port>
                  │                          │
        ┌─────────┴──────────┐               │
        │      Caddy         │             ntfy
        │  (純 HTTP, :80)    │        (純 HTTP, :80)
        └─────────┬──────────┘               │
                  │ docker network: edge     │ docker network: ntfy_default
        ┌─────────┼─────────┐                │
     myapp     otherapp    ...          （不在 edge 上）
```

重點只有三個：

1. **對外只有 VPN。** 每個 stack 的 port 都綁在 `127.0.0.1`，公網和區網都看不到。
   TLS 憑證完全由 `tailscale serve` 處理，Caddy 和 ntfy 內部只講 HTTP。
2. **Caddy 是共用入口。** 你的服務加入 `edge` 這個 Docker network，
   Caddy 就能用容器名字直接連到你，你不必公開任何 port。
3. **ntfy 走旁路。** 它自己被 `tailscale serve` 直接掛在另一個 port，
   沒有經過 Caddy，也**不在** `edge` 上。

### 現況清單（換成你的）

| 項目 | 值 |
|---|---|
| Caddy HTTP | `127.0.0.1:<caddy-port>` → 容器 `:80` |
| ntfy HTTP | `127.0.0.1:<ntfy-port>` → 容器 `:80` |
| 共用 network | `edge`（external，由 `infra/bootstrap.sh` 建立） |
| ntfy base-url | `https://<hostname>.<tailnet-name>.ts.net:8443` |
| ntfy 認證 | 無。`auth-default-access: read-write`，tailnet 內誰都能讀能發 |
| 開機啟動 | `docker.service` enabled ＋ 容器 `restart: unless-stopped` |
| 路由片段目錄 | `infra/caddy/conf.d/*.caddy` |
| 靜態網頁根目錄 | `infra/caddy/sites/<project>/` → 網址 `/<project>/` |

### 為什麼是這樣

- **Caddyfile 設了 `auto_https off`、site address 寫 `:80` 不寫 domain** ——
  避免 Caddy 去跑 ACME 申請憑證。憑證是 tailscale 的事，Caddy 碰了只會失敗。
- **ntfy 不加入 `edge`** —— 它不需要被 Caddy 代理。代價見第 4 節：你的容器要發通知得多做一步。
- **port 綁 loopback 而非 `0.0.0.0`** —— 這是「不對公網開放」唯一真正生效的一層。
  tailscale serve 從 host 本機連過去，所以綁 loopback 就夠了。
  **不要**為了讓容器連得到就把它改成 `0.0.0.0`。
- **通用教訓：給手機的連結要考慮 DNS**。VPN 的 MagicDNS 名字在某些手機 App 版本會解析失敗；
  作者曾另開 `tailscale serve --tcp=<port>` 的純 TCP 轉發入口，讓手機用 VPN 內的 IP 直連（`http://<tailnet-ip>/`）。
  純 HTTP 在 VPN 隧道內沒有安全損失（流量本來就加密），但 TLS 憑證是簽給名字的，用 IP 連 HTTPS 會失敗（SNI 對不上）。
  這種「只給某台裝置用的例外」要寫進 root AGENTS.md（給使用者的連結一律用哪種格式），並寫明復原方式。

---

## 2. 情境 A：你的 project 自己跑一個容器在服務 HTTP

適用 Flask / Next.js / 自架 API 這類「有 process 在聽 port」的東西。
如果你只是**產生 HTML 檔案**，不要走這條，看情境 B。

三步驟。以一個叫 `myapp`、內部聽 8000 的服務為例。

### 步驟 1 — 把容器接上 `edge`

在你 project 的 `compose.yml`：

```yaml
services:
  myapp:
    image: ...
    # 注意：不需要 ports:，Caddy 走內網連你
    networks:
      - default
      - edge

networks:
  edge:
    external: true
```

`docker compose up -d` 起來。**不要加 `ports:`** ——
一加就等於在 host 上開了一個洞，繞過了整套設計。

### 步驟 2 — 放一份路由片段

`~/projects/infra/caddy/conf.d/myapp.caddy`，一個 project 一個檔：

```caddy
handle_path /myapp/* {
	reverse_proxy myapp:8000
}
```

- `myapp:8000` 是**容器名（或 compose service 名）+ 容器內部的 port**，不是 host 的 port。
- 這份片段是被 `import` 進 Caddyfile 的 `:80` 區塊**裡面**，所以只能寫 `handle` / `handle_path` 這類 directive，
  **不能**在裡面另外開一個 site address。
- `handle_path` 會把 `/myapp` 前綴剝掉再轉給你；想保留前綴就用 `handle`。
- 多個 `handle` 依 import 順序比對，先中先贏。

### 步驟 3 — reload Caddy

```bash
cd ~/projects/infra
docker compose -f caddy/compose.yml exec caddy caddy reload --config /etc/caddy/Caddyfile
```

零停機，不用 restart。改壞了想先確認可以先 `caddy validate`。驗證：`curl -s localhost:<caddy-port>/myapp/`。
順手把服務加進 Caddyfile 裡 landing page 的清單。

---

## 3. 情境 B：你的 project 只是產生 HTML 檔案

報表、dashboard、匯出的 notebook 這類「跑完吐出一包靜態檔」的產物，
不需要容器、不需要 network、不需要 port。Caddy 直接從磁碟讀。

`infra/caddy/sites/` 已經掛進 Caddy 容器的 `/srv`，**底下每個子資料夾自動可見**，
所以新增 project 永遠不用再改 `caddy/compose.yml`。

```
infra/caddy/sites/project1/index.html  →  https://<hostname>.<tailnet-name>.ts.net/project1/
```

三步驟：

**1. 建資料夾，把產物寫進去**（要有 `index.html`）。把你 project 的輸出路徑指到這裡，或在 build 的最後 rsync 過去。
這個掛載對 Caddy 是唯讀的。

**2. 放路由片段** `infra/caddy/conf.d/project1.caddy`

```caddy
redir /project1 /project1/
handle_path /project1/* {
	root * /srv/project1
	file_server
}
```

- `/srv/project1` 是**容器內**的路徑，對應 host 的 `caddy/sites/project1/`。不要在這裡寫 host 路徑。
- **`redir` 那行不能省。** `handle_path /project1/*` 不匹配沒有尾斜線的 `/project1`，
  少了它那個網址會掉進空白 200，而且 HTML 裡的相對路徑會全部解析錯。
- 想要目錄列表而不是強制 `index.html`，把 `file_server` 換成 `file_server browse`。

**3. reload**（和情境 A 同一道指令）。**之後改 HTML 不用 reload**，存檔就生效；reload 只在改「路由」時才需要。

### 每個網頁都要能從首頁一路點進去

使用者從首頁出發，不記個別連結。規則只有一條：**project 的每個頁面，都要能從首頁 → project 卡片 → … 一路點到。**
只在對話裡丟連結、首頁點不到的頁面，不算交付。

一個坑：走 fallback 路由（沒有 `conf.d/<project>.caddy`）的頁面沒有 `Cache-Control`，
瀏覽器可能一直顯示快取的舊 index，新加的連結使用者看不到、以為頁面不存在。
會反覆更新的頁面建議放路由並加 `header Cache-Control "no-cache"`。

> 不要為了省事去掛 `~/projects` 整包。專案裡可能有金鑰檔，整包掛進去等於讓 VPN 內任何人都能下載它。只掛明確的子目錄。

---

## 4. 情境 C：你的 project 只是想發通知

ntfy 沒有認證，直接 POST 到某個 topic 就送出去了。topic 名稱自己取，不用預先建立，第一次發就會存在。

**失敗／異常通知一律推同一個 topic `<alerts-topic>`**（作者的教訓：每個 project 各開一個 topic，手機上要訂一堆、跟不上）。
`OnFailure=` 那類單元直接寫 `http://localhost:<ntfy-port>/<alerts-topic>`，
**標題帶 project 名稱**讓同一個 topic 裡分得出來源。
正常的內容推送（每日摘要之類）仍可用各 project 自己的 topic。

**推播風格**（替任何 project 加通知時比照）：報「新增量」而非全量（每日摘要只列當天新抓到的）；
沒有新內容時不要靜默，要明確推「執行完畢沒有新內容」，讓「沒消息」和「排程沒跑」分得開；
自動化動作（修復、部署）結束後主動推結果，成敗都報，不留懸念。使用者要從通知就能把關系統狀態，不想翻 log。

### 從 host 上的腳本 / systemd（最常見）

```bash
curl -s -H "Title: <project> 快照失敗" -H "Priority: high" -H "Tags: warning" \
     -d "update.py exit=1" \
     http://localhost:<ntfy-port>/<alerts-topic>
```

systemd unit 裡可以用 `OnFailure=notify-failure@%n.service` 掛一個發通知的 template service。

### 中文標題（作者實測，別再踩）

- **ntfy 收中文 header 沒問題**：curl 直接送 UTF-8 就通。
- **Python 不行**：`urllib.request`／`http.client`／`requests` 會把 header 以 latin-1 編碼，遇到中文直接
  `UnicodeEncodeError`——這是 Python 的限制，不是 ntfy 的。從 Python 發通知一律用 **JSON 發布**（標題放 body，topic 也寫在 body 裡）：

  ```python
  import json, urllib.request
  body = json.dumps({"topic": "<alerts-topic>", "title": "<project> 抓取失敗",
                     "message": "…", "priority": 4, "tags": ["warning"]},
                    ensure_ascii=False).encode("utf-8")
  urllib.request.urlopen(urllib.request.Request(
      "http://localhost:<ntfy-port>/", data=body,
      headers={"Content-Type": "application/json"}), timeout=10)
  ```

### 從 Docker 容器裡

**`http://localhost:<ntfy-port>` 在容器裡是不通的**，`host.docker.internal` 也不通
（ntfy 綁在 host 的 `127.0.0.1`，容器經 bridge gateway 連不到）。
要從容器發通知，把容器接上 ntfy 的網路（`ntfy_default`，external），然後用容器名連：`http://ntfy:80/<topic>`。

### 從手機 / 其他裝置訂閱

裝 ntfy App，server 填 `https://<hostname>.<tailnet-name>.ts.net:8443`，訂閱 topic 名稱。
裝置要在同一個 tailnet 裡，而且 `tailscale serve` 要是開著的。

> topic 名稱等同密碼：tailnet 內任何人知道名字就能讀能發。
> 這在單人 tailnet 沒問題，之後如果 tailnet 加入別人，要回頭處理這件事。

---

## 5. 情境 D：你的 project 要用外部 API 憑證（例：Google Drive 備份）

跟前面三個情境不同，這節跟 Docker、Caddy、ntfy 都沒關係。它講的是
**這台機器上跟外部服務講話的通行證放在哪、怎麼拿來用**。

### 現況清單（換成你的）

| | `<用途 A>` | `<用途 B>` |
|---|---|---|
| 設定目錄 | `~/.config/<用途 A>/` | `~/.config/<用途 B>/` |
| `.env` 變數 | `<A>_CLIENT_ID`、`<A>_CLIENT_SECRET` | `<B>_CLIENT_ID`、`<B>_CLIENT_SECRET` |
| 權限範圍 | 最小夠用（例：`gmail.readonly`） | 最小夠用（例：`drive.file`） |
| 其他檔案 | `token.json` | `token.json`、`identity.txt`、`state.json` |

`identity.txt` 記的是這張通行證授權給哪個帳號。它不是給程式讀的，是給人看的——哪天懷疑「我是不是授權到別的帳號了」，看這個檔最快。

### 為什麼是這樣

- **一個用途一組 token，不共用。** 萬一哪張外洩或要撤銷，影響範圍就被關在那一個用途裡。
- **變數名字故意取得不一樣。** 兩邊如果都叫 `GOOGLE_CLIENT_ID`，同一支程式先後載入兩個 `.env` 時後者會蓋掉前者，
  而且錯得很安靜——你會拿著 B 的憑證去連 A，只看到一個看不懂的 401。
- **秘密放 `~/.config/` 不放 project 裡。** 一來 project 的規矩是秘密不進 git；二來放在家目錄，多個 project 可以共用同一張通行證，
  不必各自複製一份（複製了就會有人忘記一起換）。
- **權限只要最小的。** 例如 Drive 只要 `drive.file`（只看得到它自己建立的檔案），給完整權限等於讓一支備份腳本有能力翻你整個雲端硬碟。
- **目錄 700、檔案 600。** 同機器上的其他使用者讀不到。

### 你的 project 要用的話

不要自己去讀 `token.json`，讀設定目錄裡的 `.env` 就好，token 交給既有工具管：

```python
import os
from pathlib import Path
from dotenv import load_dotenv

CONFIG_DIR = Path(os.getenv("XDG_CONFIG_HOME") or Path.home() / ".config") / "<用途>"
load_dotenv(dotenv_path=CONFIG_DIR / ".env")
```

`XDG_CONFIG_HOME` 那段不是裝飾——寫死 `~/.config` 的話，之後改了設定目錄的位置，所有程式要一個一個改。

### 備份分工

**備份 script 不在 infra，在各專案自己的 repo 裡。** infra 只提供通用水電（認證、資料夾管理），
「這個專案哪些東西不可重建」是專案自己的政策判斷，必須跟著它的設計文件一起進版控、一起被 review，放 infra 會慢慢跟它脫節。

作者的做法（可依需要調整）：

- 每個要備份的專案放一支 `backup_<專案>.py`，有 `plan`（先看要做什麼，不動東西）與 `run`；配一支 `restore_<專案>.py`。
- 排程用 systemd `--user` timer 每週跑 `run --auto`（非互動）；密碼從 `~/.config/<備份用途>/backup-password`（600）讀；失敗 `OnFailure=` 推通知。
- 格式 **7z + AES-256，連檔名一起加密**——不用 zip 是因為 zip 的檔名清單不加密，而檔名裡常有帳號和姓名。
- 沒變化的包自動略過；覆蓋雲端上既有的包前，先把舊版 copy 進該專案的 `archive/`；**archive/ 只進不出，程式永遠不刪**，清理由使用者手動。
  理由：防「本機出事 → 自動備份把好備份毀掉」。
- **密碼檔是自動化的取捨**：本機本來就放著明文資料，密碼檔沒有增加新的暴露面，它防的是雲端外洩。
  但**機器全毀時密碼檔跟著毀，密碼仍然必須記在人腦中**，否則雲端上的備份等於全毀。
- `restore_*.py` 本身跟著備份一起上傳，所以機器整台不見時，從雲端抓那支 script 下來就能還原；要支援 `--from-dir`（餵本機下載好的包，不需要任何認證）。
- 本機測試用 `run --no-upload` 時不要寫 state，免得把包記成「已備份」害正式執行略過；狀態只反映「真的上去了」。

### 雷點

- **`drive.file` 看不到你在網頁上手動建的東西。** 備份目的地一定要用程式建，不要手動建完期待程式找得到。
- **要換授權帳號，先刪 `token.json` 再跑 `auth`。** 舊 token 還能 refresh 時程式會直接沿用，根本不會問你要用哪個帳號。
- **撤銷授權在服務那邊做**（Google：帳號的第三方存取權頁面）。只刪本機的 `token.json` 不等於撤銷。
- **`auth` 失敗時不要刪掉舊 token。** 保住還可能有用的秘密，比「出錯就清乾淨」安全。

---

## 6. 情境 E：你的 project 要把靜態網頁公開到 internet（Cloudflare Pages）

情境 B 的網頁只有 VPN 內的裝置看得到。要讓 VPN **以外**的人看（分享連結給朋友、手機不掛 VPN 也能開），
走 Cloudflare Pages：把一個資料夾的靜態檔上傳上去，Cloudflare 給你一個 `*.pages.dev` 網址。
不經過這台機器的 Caddy，機器關機網頁也還在。

### 現況清單（換成你的）

| 項目 | 值 |
|---|---|
| 設定目錄 | `~/.config/cloudflare-pages/` |
| `.env` 變數 | `CLOUDFLARE_PAGES_API_TOKEN`、`CLOUDFLARE_PAGES_ACCOUNT_ID` |
| token 權限 | Cloudflare Pages: Edit ＋ Account Settings: Read |
| 工具 | wrangler，用 `npx wrangler` 隨叫隨用，不需全域安裝 |
| 已部署的 project | `<專案>` → Pages 專案 `<名稱>` → `https://<名稱>-<尾碼>.pages.dev/`（產物在哪、手動或自動部署、什麼時候可刪） |

token 是帳號層級、所有 project 共用（它只能動 Pages，範圍已經夠窄）。

### 你的 project 要用的話

一次性：建 Pages 專案（名稱全域唯一，Cloudflare 會在後面加隨機尾碼變成子網域）：

```bash
source ~/.config/cloudflare-pages/.env
export CLOUDFLARE_API_TOKEN=$CLOUDFLARE_PAGES_API_TOKEN
export CLOUDFLARE_ACCOUNT_ID=$CLOUDFLARE_PAGES_ACCOUNT_ID
cd /tmp   # 在空目錄執行，wrangler 會在 cwd 留檔
npx wrangler pages project create <專案名> --production-branch=main --force
rm -f wrangler.jsonc
```

（作者 2026-09 遇到：新版 wrangler 會把 `pages project create` 轉去 Workers，token 權限不夠會失敗，
還會在目前目錄留下一個 `wrangler.jsonc` 讓後續 `pages deploy` 認錯專案類型——刪掉它，建專案加 `--force` 走舊版 Pages；
之後的 deploy 不用也不要再加 `--force`。工具行為會變，以當時的實測為準。）

之後每次部署（`<資料夾>` 要有 `index.html`）：

```bash
npx wrangler pages deploy <資料夾> --project-name=<專案名> --branch=main --commit-dirty=true
```

### 雷點

- **管理操作一律走 wrangler，不要直接打 REST API**（作者遇過 API 回不明錯誤、wrangler 同 token 卻沒問題）。
- **Pages 會把 `.html` 結尾 308 轉址成無副檔名網址**（`/a/page.html` → `/a/page`）。站內相對連結不受影響，
  但你貼給別人的連結如果帶 `.html`，對方會經過一次轉址。`.md`、`.zip` 不受影響。
- **wrangler 會在執行目錄留下 `.wrangler/` 快取**，記得加進 `.gitignore`，或在 `/tmp` 執行。
- **公網等於對所有人可見，而且 Cloudflare 可能快取。** 只放公開資料；含個資或秘密的東西留在情境 B 的 VPN 內。
  不想被搜尋引擎收錄的加 `<meta name="robots" content="noindex">`（這不是存取控制，只是不進搜尋結果）。
- 剛部署完正式網址偶爾會先回 522，等十幾秒再試；驗證用正式網址就好。

---

## 6b. Claude Code Remote Control 常駐（整個 `~/projects/` 一個 systemd --user 服務）

讓 claude.ai 與手機 app 能隨時開新 session 進 `~/projects/`。**只有一個 unit**：
`~/.config/systemd/user/claude-rc-projects.service`，session 開在 workspace 根目錄、讀 root 指令檔，
要做哪個專案由 agent 照進出協定 `cd` 進去。（作者早期是「一個專案一個 unit」，後來合併成一個：入口多了要記、要維護，而且跨專案查詢沒地方去。）

```ini
[Unit]
Description=Claude Code Remote Control - projects (root workspace)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
WorkingDirectory=/home/<user>/projects
ExecStart=/home/<user>/.local/bin/claude remote-control --name projects --spawn=same-dir
Restart=no
Environment=CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX=projects
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=default.target
```

```bash
systemctl --user daemon-reload && systemctl --user enable --now claude-rc-projects.service
systemctl --user status claude-rc-projects.service
journalctl --user -u claude-rc-projects.service -n 20
```

- 要先 `sudo loginctl enable-linger <user>`，開機後不用登入服務就會起來。
- `--spawn=same-dir`：所有 session 共用 `~/projects/`（各專案有未進 git 的資料，必須用這個）；`worktree` 會給每個 session 獨立 git worktree，這裡不適用。
- `Restart=no` 是刻意的：claude 更新或登入失效時不要無限重啟；掛了看 journal 再手動 `restart`。
- **新目錄要先接受 workspace trust**：沒接受過的目錄，remote-control 一啟動就退出（journal：`Workspace not trusted`）。
  先在該目錄互動跑一次 `claude`、按接受、`/exit`，再 `systemctl --user reset-failed` ＋ `start`。
- **systemd user service 的 PATH 沒有 `~/.local/bin`**，unit 裡的執行檔要寫絕對路徑（作者踩過：`claude` 找不到）。
- 執行與檔案都在本機，只有對話紀錄同步到 Anthropic；需要 claude.ai 訂閱登入（API key 不行）。

---

## 7. 排程（systemd --user timer）

- unit 檔的**正本放在專案自己的 `systemd/`**，用 `systemctl --user link <絕對路徑>` 掛入，這樣設定跟著專案的 git 走；`~/.config/systemd/user/` 裡只有 symlink。
- timer 加 `Persistent=true`，機器關機錯過的排程開機後補跑。
- 失敗用 `OnFailure=` 推通知（第 4 節）。
- **反向守門員**：靠別台機器推來的通知，要另外有一個「沒收到就報警」的 timer，否則那台整台掛了就沒人知道。

## 8. 通用雷點

- **`localhost:<caddy-port>` 通但 VPN 名稱不通** → 問題在 `tailscale serve`，不在 Caddy。先 `tailscale serve status`。
- **Caddy 對沒對到的路徑回空白 200，不是 404。** 所以「reload 後還是 200」不代表你的片段還在生效——要看 body。
  想要 404 的話在 Caddyfile 的 `:80` 區塊尾端補 `handle { respond 404 }`。
- **改了 `conf.d/` 一定要 reload**；改 `sites/` 底下的 HTML 不用。
- **改 ntfy 的 port 要同步改 `server.yml` 的 `base-url`**，否則 App 端點按通知會跳到錯的位址。
- **`docker compose down` 之後開機不會自己回來。** `down` 會把容器刪掉，重啟策略跟著消失，要重跑 bootstrap。
  手動 `stop` 也會被記成「刻意停掉」，重開機後不會自己起來。
- **不要幫某個 project 在 infra 之外另外跑一個 Caddy。** 要多一個服務就是多一份 `conf.d/*.caddy`，入口只留一個。
- **`sudo` 要密碼，agent session 裝不了 apt 套件。** 命令列工具一律裝到 `~/.local/bin`（抓 GitHub release 的二進位）。
- **時區**：機器與使用者所在地時區不同時，看日誌要換算；日期一律 `date +%F`。
