# TL;DR
我的開發環境是這樣的:
- 本地端:筆電(macOS)上跑的是 Hermes AI Agent 的 Web 管理介面(Multi-Agent 平台前端)
- 遠端:Hermes Agent 本身架在家裡的 mac mini,listen 在
127.0.0.1:8642,同時還肩負著 Telegram Bot、Discord Bot 等對外服務
我在咖啡廳寫程式時,需要本地的前端能打到家裡的 Hermes 後端 API。最後用 SSH Tunnel + Tailscale 解決,前後端 code 各自不動一行。
這篇記錄完整流程,包含原理、指令、以及實際踩過的坑。
# 場景與目標
# 拓撲
[公司 Wi-Fi 192.168.8.x] [家裡 Wi-Fi 192.168.1.x]
│ │
└── 筆電 akebi └── mac mini
192.168.8.97 192.168.1.173
Tailscale: 100.108.216.31 Tailscale: 100.67.90.58
│ ├── Hermes API server (:8642)
│ ├── Mattermost bot
│ ├── Telegram bot
│ └── Discord bot
│
└─────── Tailscale tailnet ──────────────────┘
(P2P direct,不走 relay)
筆電在公司、mac mini 在家 — 兩台根本不在同一個物理網路,中間隔著公司 router、ISP、家裡 router 這一整串 NAT。
讓兩台互通的方式有幾種:傳統的 Port Forwarding 要折騰防火牆、自架 VPN 要維護 server、Tailscale 零設定就能打穿 NAT。Tailscale 是我的選擇。
# 我要解決的問題
筆電上的 Vite dev server(http://localhost:5174)會把所有 /v1/* 的 fetch proxy 到一個固定 target:
// vite.config.ts
proxy: {
'/v1': {
target: 'http://127.0.0.1:8642',
changeOrigin: true,
},
}
問題是 mac mini 上的 Hermes 才是真的後端,筆電的 127.0.0.1:8642 沒東西在 listen。
# 我考慮過的方案
方案 A:改 vite.config 直接指到 mac mini
target: 'http://100.67.90.58:8642' // mac mini 的 Tailscale IP
問題:
- 把開發環境的網路拓撲寫進 repo(不該)
- 換筆電 / 換協作者就要改一次
- 之後想離線開發(在咖啡廳沒網路),又要改回
127.0.0.1
方案 B:筆電上自己跑一份 Hermes
可行,但 Hermes 跟 Mattermost / Telegram bot 綁定 — 兩台同時跑會搶同一個 bot token,訊息會被收兩次。要維護一份「只開 chat completions API、關掉所有 bot integration」的設定,麻煩。
方案 C:SSH tunnel
讓筆電的 127.0.0.1:8642 「看起來」有服務在 listen,但實際上請求被 SSH 透明轉發到 mac mini 的 127.0.0.1:8642。前端、後端、vite.config 都不用動。
選 C。
# 原理:SSH 的 port forwarding
SSH 不只是「遠端登入」,它還能在連線上開隧道,把網路流量在兩端之間搬。有兩種方向:
# -L 本地轉發(Local forward)
ssh -L 8642:localhost:8642 user@remote
# ↑ ↑ ↑
# | | SSH server (對方)
# | 從 SSH server 看出去要連到哪
# SSH client (我的筆電) 上要 listen 哪個 port
白話:我筆電的 127.0.0.1:8642 打過去,相當於在 mac mini 上對 127.0.0.1:8642 發請求。
# -R 反向轉發(Remote forward)
跟 -L 方向相反 — 在 SSH server 上開 port,封包流回 SSH client 那邊。我這次不需要,但要小心別搞錯方向。
我第一個想到的指令是
ssh -R 8642:localhost:8642 user@macmini,但其實這方向是錯的(會在 mac mini 上開 port 把流量導回筆電),跟我的需求剛好相反。要把筆電當「發起端」、把 mac mini 當「服務端」,永遠是-L。
# 步驟一:先確認兩台連得到
我以為兩台在同一個 Wi-Fi 就一定通,但實際上:
# 在筆電
ipconfig getifaddr en0
# → 192.168.8.97
route -n get default | grep gateway
# → 192.168.8.1
# 然後試 ping mac mini
ping -c 2 192.168.1.173
# → 100% packet loss ❌
兩台 IP 在不同 subnet,根本路由不過去。原因是家裡有兩台 router 串接,沒做正確的橋接。
# 解法:Tailscale
Tailscale 是 mesh VPN,每台裝置裝完登入後,會拿到一個 100.x.x.x 的 Tailscale IP,任何兩台 tailnet 內的裝置可以互相通訊,不管它們實際在哪個網路。
兩台都裝好之後:
tailscale status
# 100.108.216.31 akebi loyang0921@ macOS -
# 100.67.90.58 mini loyang0921@ macOS active; direct ...
active; direct 代表兩台之間建立了 P2P 直連(不走 Tailscale 的 relay server),延遲很低。
之後要連 mac mini 就用 hostname mini,Tailscale 會幫忙解析。
# 步驟二:確認 SSH 能登入
nc -zv -G 5 mini 22
# → Connection to mini port 22 [tcp/ssh] succeeded! ✓
ssh -o BatchMode=yes mini "echo OK"
# → Permission denied (publickey,password,...) ✗
Port 22 通了(mac mini 開了 Remote Login),但 public key 還沒推上去,所以拒絕登入。
# 推 public key 上 mac mini
# 確認筆電有 ssh key
ls ~/.ssh/id_ed25519.pub
# 推上 mac mini(會 prompt mac mini 上的密碼,輸入一次就好)
ssh-copy-id young@mini
ssh-copy-id 會把 ~/.ssh/id_ed25519.pub 的內容追加到 mac mini 上的 ~/.ssh/authorized_keys。之後 SSH 連線時,client 會用對應的 private key 簽名,server 用 authorized_keys 裡的 public key 驗證 — 通過就免密碼。
驗證:
ssh mini "echo OK; hostname"
# → OK
# → mini ✓
# 步驟三:寫進 ~/.ssh/config
每次打 ssh young@mini 很煩,而且 SSH tunnel 場景需要一些 keep-alive 設定避免假死。寫進 config:
Host mini
HostName mini
User young
IdentityFile ~/.ssh/id_ed25519
AddKeysToAgent yes
ServerAliveInterval 30
ServerAliveCountMax 3
ExitOnForwardFailure yes
ServerAliveInterval 30— 每 30 秒送一個 keep-alive packetServerAliveCountMax 3— 連續 3 次沒回應就判斷斷線、結束 SSH processExitOnForwardFailure yes— 如果 tunnel 開不起來(例如 port 已被佔用)就直接 exit,不要假裝 SSH 還活著
之後直接 ssh mini 就好。
# 步驟四:開 tunnel
ssh -N -L 8642:localhost:8642 mini
-N— 不要開遠端 shell,純粹做 tunnel-L 8642:localhost:8642 mini— 在筆電開 8642 port,把流量轉到 mac mini 上的localhost:8642
驗證:
# 筆電上 8642 是否在 listen
lsof -iTCP:8642 -sTCP:LISTEN -nP
# → ssh ... TCP 127.0.0.1:8642 (LISTEN) ✓
# 試打 API
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8642/v1/models
# → 200 ✓
打到筆電的 127.0.0.1:8642,請求被 SSH 透明轉發到 mac mini 的 Hermes,回 200。
# 步驟五:包成 npm script
每次手敲 ssh -N -L ... 會背到爛。寫進 package.json:
{
"scripts": {
"dev": "vite",
"tunnel": "ssh -N -L 8642:localhost:8642 ${HERMES_SSH_HOST:-mini}",
"tunnel:status": "lsof -iTCP:8642 -sTCP:LISTEN -nP || echo 'no tunnel'",
"tunnel:kill": "pkill -f 'ssh -N -L 8642:localhost:8642' || echo 'no tunnel'"
}
}
${HERMES_SSH_HOST:-mini} 是 shell parameter expansion — 如果有設 HERMES_SSH_HOST 環境變數就用它,否則 fallback 到 mini。
# 完整開發流程
# Terminal 1
npm run tunnel
# → 持續跑著,Ctrl-C 結束
# Terminal 2
npm run dev
# → http://localhost:5174 開啟,前後端整套都能正常用
# 心智模型小結
| 層 | 它在意什麼 | 它不知道什麼 |
|---|---|---|
| 前端 (Vite) | 127.0.0.1:8642 有沒有人 listen | 真的後端在哪 |
| SSH tunnel | 把流量從筆電 8642 搬到 mac mini 的 8642 | 流量內容是 HTTP 還是其他 |
| Hermes API | 收到 /v1/chat/completions 回應 | 請求從筆電還是直接從 mac mini 來 |
每一層只看自己。這就是所謂「不用改前端 / 後端任何一行 code」的原因 — 我們只是在中間插了一個透明的搬運層。
# 踩到的坑
# 坑 1:以為兩台同 Wi-Fi 就互通
192.168.8.x 跟 192.168.1.x 是不同 subnet,雖然 Wi-Fi 訊號都收得到、SSID 名字看起來像,但 router 沒做 inter-subnet routing 就是不通。最快驗證:ping 對方 IP,沒回就是不通。
# 坑 2:ssh -R vs ssh -L 混淆
直覺上「我要把 mac mini 的服務帶到筆電用」會想成 -R(remote bring back),但 -R 是站在 SSH server 那邊看的(在 server 上開 port)。要把筆電當入口、永遠用 -L。
# 坑 3:tunnel 看似活著但其實斷了
不加 ServerAliveInterval 的話,網路飄一下、TCP 沒收到 RST,OS 不知道連線斷了,lsof 看 8642 還在 LISTEN,但 curl 會永遠卡住。ServerAliveInterval 30 + ServerAliveCountMax 3 確保斷線後 90 秒內 SSH 一定 exit。
# 坑 4:Tailscale 直連 vs 走 relay
tailscale status 看到 active; direct 才是 P2P 直連。如果是 active; relay "xxx",代表兩台中間有 NAT 沒打穿,流量繞了 Tailscale 的中繼點,延遲會明顯變高。多數情境會 direct,但偶爾要注意。
# 我學到什麼
以前學網路,subnet、router、VPN 這些名詞看起來很抽象,但實際在兩個地方擺弄過一次,就突然全懂了。SSH tunnel 更是如此 — 在那之前我只知道 ssh user@host 可以登入機器的 shell,根本沒想過它還可以這樣當作透明轉發工具。
更重要的是,這套做法教會我一件事:很多問題不需要動 code,在網路層墊一層抽象就解決了。前端、後端、deployment script 都不用改,只因為我在中間多加了一條 SSH tunnel。這種「不改現有邏輯、加一層就能擴展」的思路,應該是寫軟體很重要的一個直覺。
學會這個之後,之後如果家裡有 GPU server 可以做 ML 訓練,我可以寫 code 在筆電上但,把編譯或訓練的請求送過 SSH tunnel 到家裡跑;或者把家裡的 PostgreSQL 帶到外面用 DataGrip 連。這些都是同一套技能的不同應用。
這篇文章的素材由 AI 協助整理,最後由本人校閱發布。