本日對應公開 Repo:dev-container/
昨天我們讓一台 NGINX 擋在 GitLab 前面替 AI Agent 蓋憑證的章,順便收了三層限制:端點白名單只放行六條規則、每條端點只開必要的請求方法、速率限制為 2 r/s。
今天處理 dev-container 的另一個選配能力:當我需要在容器內手動透過 SSH clone 或 push 時,如何完成身分驗證,卻不把私鑰交給容器。
先說清楚:目前 nathan-code-review Skill 是透過 HTTPS 取得程式碼,本身也不會 push;只跑審查時,可以直接關閉 ssh-agent 轉發。這篇處理的是容器同時被拿來進行其他 Git 操作時的憑證邊界。
先把賭注講在前面,因為這條跟前面幾天不是同一個量級。以我當時這套設定來說,CLI 憑證外洩主要會讓別人冒用我的模型額度;重複使用的 SSH 私鑰若外洩,攻擊者則可能以我的身分,嘗試登入任何網路可達、且仍信任那把公鑰的系統。
而我的金鑰是複用的,同一把進 GitLab,也進其他幾台機器。這件事我心裡有數,還是這樣用了,我相信不只我一個。
所以今天要解的不是「怎麼讓容器 git push 得動」,是「怎麼讓它 push 得動,同時我不必把那把鑰匙交出去」。
要透過 SSH clone 或 push,得先在 GitLab 平台上註冊自己的公鑰。從此 GitLab 便知道:「能證明自己持有這把公鑰所對應私鑰的人,就是這個帳號。」
後續在觸發身分驗證時,就會用上非對稱密碼學的「驗證簽章」情境。由本機使用私鑰產生數位簽章,GitLab 再使用已登記的公鑰驗證簽章。
最直覺的做法,是直接把 ~/.ssh 掛載進 dev-container;但這等於把金鑰雙手奉上給 AI Agent!
而這把私鑰一旦洩漏,爆炸半徑就是開頭講的那些機器。
爆炸半徑:一把金鑰外洩,影響會延伸到多少台機器
那我是否能仿造上面的做法?
先把昨天那套的形狀抽出來:秘密留在中間層,呼叫端只拿到一個入口。 AI Agent 手上只有 http://gitlab-proxy:5678 這個位址,token 在 NGINX 裡,它連看都看不到。
所以問題變成:SSH 有沒有這種東西?有沒有一個角色可以幫我保管私鑰,讓需要用的人只拿到一個入口,而不是金鑰本身?
有,而且它比 NGINX 更早就存在,多數人天天在用卻沒特別注意過它:ssh-agent。
圖上兩條路徑的判準是 socket 在不在:有掛入,容器才具備借用 agent 的通道;沒有掛入,就不開這項能力。判準落在實際存在的資源上,不需要猜測是誰啟動了容器。
以私鑰存放在 ~/.ssh/id_ed25519 且設有 passphrase 為例,如果沒有 agent,每次透過 SSH push 都可能再次要求輸入 passphrase。一天推十次就可能要輸入十次,實在很難長期忍受。
ssh-agent 就是為了這件事存在的:它是一個常駐的小程式,透過 ssh-add 載入私鑰後,便可替需要公鑰認證的 SSH client,以及使用 SSH remote 的 Git,完成簽章。
使用 SSH remote 時,Git 會呼叫 SSH client;SSH client 則透過 SSH_AUTH_SOCK 這個環境變數尋找 agent。agent 啟動時會開啟一個 Unix domain socket,並將路徑寫進這個變數。沒有這個變數時,SSH client 就無法使用該 agent。
在我的 Mac 上長這樣:
$ echo $SSH_AUTH_SOCK
/var/run/com.apple.launchd.e0m2nUIg8S/Listeners
$ ls -l $SSH_AUTH_SOCK
srw-rw-rw- 1 nathan wheel 0 /var/run/com.apple.launchd.e0m2nUIg8S/Listeners
開頭那個
s就是 socket。它不是設定檔、不是金鑰,是一個可以跟 agent 講話的門。
agent 協定可以管理 identities,也可以代為簽章,但沒有匯出私鑰的操作。
客戶端最常對 agent 說的話有三種:
這些操作裡(還有移除金鑰、鎖住 agent 等等),沒有「把 agent 已持有的私鑰匯出給客戶端」。認證時,ssh 客戶端把綁定這次連線的 session identifier 與認證請求內容交給 agent;agent 產生簽章後回傳;在這個流程中,私鑰不會透過 agent 協定回傳給客戶端。伺服器再以已登記的公鑰驗證這份簽章。
所以把 socket 掛進容器,交出去的是能力,不是秘密。容器可以請你的 agent 簽章,但拿不到金鑰本體;關掉容器後,它就無法再透過這個 socket 發出新的簽章請求。不過,容器存活期間已經完成的 push、遠端操作或權限異動不會因此自動復原。這跟
-v ~/.ssh:/home/xxx/.ssh完全是兩件事,後者是把一把通常會長期重複使用、直到撤銷前都有效的私鑰整個複製出去。
回頭看上面那行 srw-rw-rw-:socket 本身看起來是 0666,但它位於 drwx------ 的父目錄裡,其他使用者連路徑都走不到。
以目前的 OpenSSH ssh-agent 為例,接受連線後還會檢查 peer 的 effective UID:只接受 root 或與 agent 相同的 UID,其他非 root 使用者會被拒絕。這是額外的防線,不代表可以拿掉父目錄保護;root、同一 UID 下的其他程序,以及行為不同的 agent 實作,仍可能使用這個 socket。
echo $SSH_AUTH_SOCK # 有沒有 agent 可用
ssh-add -l # agent 裡現在有哪幾把金鑰
ssh-add # 把預設的金鑰加進去
ssh-add -l 是最有用的一個。我剛才在自己機器上跑,得到的是:
The agent has no identities.
agent 在跑、socket 也在,但裡面一把金鑰都沒有。這種狀態下就算把 socket 掛進容器,也一樣簽不了任何東西!
若沒有先跑
ssh-add -l,而是等到容器裡的 Git 操作失敗,常見訊息只會是Permission denied (publickey),不會直接指出 agent 裡沒有 identity。所以掛載之前,先在 host 上確認一次。
| 誰發起的 | 路徑、長相 |
|---|---|
| macOS launchd(一般登入環境通常已有) | /var/run/com.apple.launchd.XXXX/Listeners |
| systemd / gnome-keyring | /run/user/<uid>/keyring/ssh |
| 自己 ssh-agent -s | 由 OpenSSH 版本與平台決定;常見為 /tmp/ssh-XXXX/agent.<pid>,較新版本也可能使用 $HOME/.ssh/agent/s.* |
設定過程在 Repo 的 dev-container 下,分成兩件事:socket 怎麼進去,以及 known_hosts 怎麼準備。
掛載本身只有一行:
RUN_MOUNTS+=(-v "$SSH_AUTH_SOCK":/ssh/ssh_sock:ro)
容器那一側不用再設環境變數,因為 image 裡已經寫死了:
ENV SSH_AUTH_SOCK=/ssh/ssh_sock
真正花時間的是掛載之前與 socket 有關的三個判斷;known_hosts 則留到下一節處理:
STARTED_AGENT=1 記下來,收工時只在這個旗標成立的情況下 ssh-agent -k。host 本來就有 agent 的話一律不碰,不然殺掉的是使用者自己那一個。SSH_AUTH_SOCK 有值、socket 連得上,跟裡面有沒有 key 是三件不同的事。沒有 ssh-add 過就是空的,這時候會印一行警告說容器裡走 SSH 的 git 操作會失敗,而不是等它在容器裡撞牆。NCR_NO_SSH_AGENT,整個關閉這條授權面。SSH 首次連到尚未信任的主機時,預設會要求使用者確認 host key。若 Git 操作是由 AI Agent 透過非互動命令執行,就沒有可供人工輸入 yes 的終端,連線便會以 Host key verification failed. 結束。
所以 Dockerfile 多收一個選配的 ARG:GITLAB_SSH_HOST。只有在 build 時明確提供這個值,才會以 ssh-keyscan 取回當時看到的 host key,寫進 image 作為沒有 host known_hosts 時的備援。
這裡有三種做法要分開看:
ssh-keyscan 只負責取回當下看到的 host key,不會驗證它是不是正確的;若把第一次取得的結果直接寫入 known_hosts 並持續信任,整個流程就是 TOFU(Trust on first use,首次使用即信任)。如果 build 路徑上有中間人,就可能把假的 host key 永久烘進 image,而且外表看起來像「已經幫你設定好了」,比沒設定更危險。~/.ssh/known_hosts 以唯讀(:ro)方式掛入:沿用 host 既有的 host-key 信任,不在容器裡重新做一次 TOFU,也不讓容器反向改寫 host 的信任紀錄。StrictHostKeyChecking=no 會自動接受新的 host key,遇到已變更的 key 時,也可能在若干限制下繼續連線。它不是在準備一份經過驗證的 known_hosts,而是在放寬 host-key 檢查。若只想避免首次連線的互動提示,accept-new 至少會拒絕後續變更的 key,但第一次接受的仍是 TOFU;本篇的做法仍是預先準備並核對 known_hosts。綜合以上,我們的實作把 build-time ssh-keyscan 留作選配備援;預設仍由啟動腳本將 host 的 ~/.ssh/known_hosts(若有)以唯讀方式掛進容器。兩者同時存在時,runtime 掛載會覆蓋 image 內的版本。
今天這條路走下來,答案跟昨天是同一個形狀,但機制完全不同。
昨天是代理幫你蓋章:憑證留在 NGINX,呼叫端拿到一個入口。今天是 agent 幫你簽章:私鑰留在 ssh-agent,容器拿到一個 socket。共同點只有一句話:憑證本體不進 session。
差別在於 SSH 這條不能像 HTTP 一樣,讓代理在中間替每個請求附上一段憑證 header。SSH 仍然需要透過 host key 與 known_hosts 防範中間人;只是公鑰認證使用的不是一段可以注入的 token,而是持有私鑰的一方對這次認證內容產生簽章。所以這裡採取的機制不是代理注入,而是讓容器借用 agent 的簽章能力。
實作上真正花時間的也不是掛載那一行,是四個不寫下來就會踩的地方:
只收拾自己起的 agent。 host 本來就有 agent 的話,順手 ssh-agent -k 掉的是使用者原本那一個,之後依賴那個 agent 的終端機,其 Git over SSH 操作都會失敗,而且很難看出是誰關掉了 agent。
socket 掛唯讀,而且唯讀不會讓它連不上。 這兩件事我一開始都想錯了:以為 connect() 需要寫權限所以不能掛 :ro。實測推翻了它。unix(7) 說的寫權限指的是 socket inode 的 mode bits,而 :ro 設的是掛載層的旗標,兩者是不同的東西;kernel 只在真的要改檔案時(建立、刪除、開來寫、chmod)才檢查掛載旗標,而 connect() 整條路徑不經過那個檢查。在 Linux bind mount 的機制下,來源與容器內路徑會指向同一個 inode;不加 :ro 時,容器裡對 socket 下 chmod 可能改到 host 那一顆,症狀是其他依賴那個 agent 的終端機無法再進行 SSH 認證,而且很難把原因追到容器。Docker Desktop 則是另一種情況:它不會把 macOS 的 socket inode 原樣傳進 Linux VM,而是提供一個代理節點。
:ro 限制的是掛載層的檔案操作,不會把 agent protocol 變成唯讀。拿到 socket 的 client 仍可提出協定支援的簽章,以及加入、移除或鎖定 identity 等管理請求。
連不上的時候,看的是 inode 權限不是掛載模式。 在 Docker Desktop 上,目前實測代理節點是 root:root 0660。容器以 UID 1001 執行時,如果沒有對應的 group 權限,ssh-add -l 會回 Permission denied;若 socket 根本沒有掛載,則會回 No such file or directory。兩者都代表 agent 不可用,但可從錯誤訊息區分。前者可補 --group-add 0 讓 group 的讀寫權限生效,不必為了 chown 讓整個容器以 root 執行。不過這個做法不是只授權單一 socket:它也會讓容器程序取得其他 GID 0 資源的群組權限,因此仍要盤點 image 與其他 mount 裡是否有不該交出的 group-0 資源。
known_hosts 要先準備好。 非互動環境不會問你要不要信任,它只會回 Host key verification failed. 然後結束。
最後回到開頭那個賭注。爆炸半徑沒有因為改用 agent 就消失,只是換了形式:容器拿到 socket 後,無法自行把 agent 裡的 identity 篩成只剩某一把。 最直觀的限縮方式,是在 host 端另起一個只載入專用金鑰的 agent,再把那個 socket 指進容器。新版 OpenSSH 也支援以 ssh-add -h 加上目的地限制,並可搭配 -t 設定存活時間、-c 要求逐次確認;但這些限制依賴 OpenSSH 版本、known_hosts 與客戶端支援,導入前仍要實測。
明天換一個角度:容器現在拿得到憑證了,那它能連到哪裡去?該來調查網路的邊界。