Bot 的電腦 can run under gVisor: its own kernel, Chromium's sandbox back on
容器隔離 in 設定 › 電腦: standard (runc, the container is the only wall, Chromium runs
--no-sandbox) or hardened (runsc: a virtualized kernel, all capabilities dropped but the few
the desktop needs, ANYBOT_BOX_SANDBOX=1 turns Chromium's own sandbox back on). Box image v7.
deploy/mac/gvisor-vm.sh makes a Lima VM with Docker + gVisor for a Mac (started at login),
gvisor-migrate.sh moves the box's files and logins there.
Checked and finished before committing:
- the hardened choice is offered only on a Docker host that has runsc; a box set to it on a
host without it opens the standard way and says so (it used to not start at all)
- the settings row shows what the box really runs on, and when 更新 is needed
- no swap only under gVisor (a standard box keeps Docker's default)
- the migrate script sees the running Mac app ("AnyBot", not "anybot")
- the VM script starts an existing VM by name, checks runsc and the kernel from the Mac
- the version test says 7
Real run in the Lima VM: desktop, VNC, numbered controls, xdotool and screenshots work; no
Chromium process has --no-sandbox, renderers run with seccomp and their own namespaces.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
This commit is contained in:
parent
b480ea16dd
commit
4605b317d8
|
|
@ -729,6 +729,7 @@ AnyBot 是一個桌面聊天 App,介面和運作方式照 Grok Bot 重做。
|
||||||
|
|
||||||
- 預設是這台 Mac 的 Docker(OrbStack 或 Docker Desktop)。
|
- 預設是這台 Mac 的 Docker(OrbStack 或 Docker Desktop)。
|
||||||
- 可以填 Docker 主機,例如 `ssh://me@server`(Tailscale 的機器名稱也行),Bot 的電腦就會在那台主機上;看畫面時 AnyBot 會自己開 SSH 通道。
|
- 可以填 Docker 主機,例如 `ssh://me@server`(Tailscale 的機器名稱也行),Bot 的電腦就會在那台主機上;看畫面時 AnyBot 會自己開 SSH 通道。
|
||||||
|
- **容器隔離**(設定 › 電腦 › 在哪裡執行):標準(runc:容器和 Docker 主機共用核心,Chromium 關掉自己的沙盒,容器是唯一那道牆)或強隔離(gVisor/runsc:電腦有自己的虛擬核心,權限全拿掉只留開機要的幾個,Chromium 的沙盒打開,兩道牆)。Docker 主機沒有 gVisor 時強隔離不能選;設定是強隔離、主機卻沒有 gVisor 時(例如換了 Docker 主機)先用標準開機並寫進紀錄,不會整台開不起來。那一列顯示電腦實際用的是哪種,和設定不同時提示按「更新」。Mac 上用 `deploy/mac/gvisor-vm.sh` 建一台自帶 Docker+gVisor 的 Lima 虛擬機(登入時自動啟動),`gvisor-migrate.sh` 把原本的檔案和登入搬過去(AnyBot 要先完全退出)。實測(Mac 上的 Lima VM):開機、畫面、VNC、無障礙編號、xdotool、截圖都正常;13 個 Chromium 程序都沒有 --no-sandbox,網頁程序 seccomp=2、有自己的命名空間;每一步 exec 55–69 ms、截圖 95 ms。
|
||||||
- 換位置後會在新的地方重新建立;舊檔案可以先「儲存快照」,換過去再「從快照復原」。
|
- 換位置後會在新的地方重新建立;舊檔案可以先「儲存快照」,換過去再「從快照復原」。
|
||||||
- **「網路流量改走這台 Mac」**(原版:網路流量可以改走你的本機電腦,「僅對新連線生效」,管理員可以關掉):打開後,Bot 的電腦之後執行的指令會經過這台 Mac 上的代理伺服器出去(例如要連公司內網或 VPN 才看得到的服務)。代理只聽這台 Mac 自己(127.0.0.1),用原版的埠 8791,被占用就往後找。設定頁顯示經過的連線數。Bot 的電腦在別的主機上時不能用。管理員可以用 `defaults write com.anybot.AnyBot AllowBoxTrafficViaThisMac false` 鎖住,這時顯示「🔒 由你的管理員設定,無法變更」。
|
- **「網路流量改走這台 Mac」**(原版:網路流量可以改走你的本機電腦,「僅對新連線生效」,管理員可以關掉):打開後,Bot 的電腦之後執行的指令會經過這台 Mac 上的代理伺服器出去(例如要連公司內網或 VPN 才看得到的服務)。代理只聽這台 Mac 自己(127.0.0.1),用原版的埠 8791,被占用就往後找。設定頁顯示經過的連線數。Bot 的電腦在別的主機上時不能用。管理員可以用 `defaults write com.anybot.AnyBot AllowBoxTrafficViaThisMac false` 鎖住,這時顯示「🔒 由你的管理員設定,無法變更」。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,20 @@ COPY overlay /etc/anybot/overlay
|
||||||
COPY Xresources /etc/anybot/Xresources
|
COPY Xresources /etc/anybot/Xresources
|
||||||
COPY menu /etc/anybot/menu
|
COPY menu /etc/anybot/menu
|
||||||
RUN chmod +x /usr/local/bin/anybot-box-start /usr/local/bin/anybot-a11y /usr/local/bin/anybot-browser \
|
RUN chmod +x /usr/local/bin/anybot-box-start /usr/local/bin/anybot-a11y /usr/local/bin/anybot-browser \
|
||||||
# Chromium has no sandbox inside a container (no user namespaces); the box itself is the
|
# Chromium has no sandbox inside a plain container (no user namespaces); the box itself is
|
||||||
# sandbox. Debian's chromium wrapper reads these flags for every launch (menu, bots, xdg-open).
|
# the sandbox. Under gVisor (容器隔離=runsc) the box has its own virtualized kernel and
|
||||||
&& printf '%s\n' 'export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --no-sandbox --disable-dev-shm-usage --no-first-run --no-default-browser-check --disable-gpu --password-store=basic --test-type"' > /etc/chromium.d/anybot
|
# Chromium's sandbox works: AnyBot then starts the box with ANYBOT_BOX_SANDBOX=1 and the
|
||||||
|
# sandbox is switched back on (one more wall between a hostile web page and the machine).
|
||||||
|
# Debian's chromium wrapper reads these flags for every launch (menu, bots, xdg-open).
|
||||||
|
&& printf '%s\n' \
|
||||||
|
'if [ "${ANYBOT_BOX_SANDBOX:-0}" = 1 ]; then' \
|
||||||
|
' export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --disable-dev-shm-usage --no-first-run --no-default-browser-check --disable-gpu --password-store=basic"' \
|
||||||
|
'else' \
|
||||||
|
' export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --no-sandbox --test-type --disable-dev-shm-usage --no-first-run --no-default-browser-check --disable-gpu --password-store=basic"' \
|
||||||
|
'fi' > /etc/chromium.d/anybot
|
||||||
# Accessibility on for every app, including those started over `docker exec`.
|
# Accessibility on for every app, including those started over `docker exec`.
|
||||||
ENV DISPLAY=:1 SCREEN_SIZE=1280x800x24 GTK_MODULES=atk-bridge GTK_A11Y=atspi GNOME_ACCESSIBILITY=1 NO_AT_BRIDGE=0
|
ENV DISPLAY=:1 SCREEN_SIZE=1280x800x24 GTK_MODULES=atk-bridge GTK_A11Y=atspi GNOME_ACCESSIBILITY=1 NO_AT_BRIDGE=0
|
||||||
EXPOSE 6080
|
EXPOSE 6080
|
||||||
LABEL anybot.box.version="6"
|
LABEL anybot.box.version="7"
|
||||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||||
CMD ["/usr/local/bin/anybot-box-start"]
|
CMD ["/usr/local/bin/anybot-box-start"]
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,12 @@ if [[ ! -e "$PROFILE" ]]; then
|
||||||
ln -s /home/bot/.config/anybot-browser "$PROFILE"
|
ln -s /home/bot/.config/anybot-browser "$PROFILE"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
FLAGS=(--no-sandbox --test-type --user-data-dir="$PROFILE" --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222
|
# The box (a container) is the sandbox by default, so Chromium runs --no-sandbox. Under gVisor
|
||||||
|
# (容器隔離=runsc) the box has a virtualized kernel of its own and AnyBot starts it with
|
||||||
|
# ANYBOT_BOX_SANDBOX=1 — Chromium's own sandbox works there, so keep it as a second wall.
|
||||||
|
SANDBOX=(--no-sandbox --test-type)
|
||||||
|
[ "${ANYBOT_BOX_SANDBOX:-0}" = 1 ] && SANDBOX=()
|
||||||
|
FLAGS=("${SANDBOX[@]}" --user-data-dir="$PROFILE" --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222
|
||||||
--remote-allow-origins=* --force-renderer-accessibility --disable-blink-features=AutomationControlled
|
--remote-allow-origins=* --force-renderer-accessibility --disable-blink-features=AutomationControlled
|
||||||
--disable-dev-shm-usage --disable-gpu --use-angle=swiftshader --enable-unsafe-swiftshader --password-store=basic
|
--disable-dev-shm-usage --disable-gpu --use-angle=swiftshader --enable-unsafe-swiftshader --password-store=basic
|
||||||
--no-first-run --no-default-browser-check --disable-session-crashed-bubble --hide-crash-restore-bubble
|
--no-first-run --no-default-browser-check --disable-session-crashed-bubble --hide-crash-restore-bubble
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# 把 Bot 的電腦整個搬到 gVisor VM:box 的全部檔案與瀏覽器登入都在 home 資料卷
|
||||||
|
# (anybot-box-home,Chromium 的 profile 也在裡面),整卷複製過去後,
|
||||||
|
# AnyBot 在 VM 上掛同名資料卷開機=檔案、登入、桌面設定全部跟著走。
|
||||||
|
# 所有 Bot 共用這一台、這一份登入與檔案:不分帳號,資料夾照常共用。
|
||||||
|
#
|
||||||
|
# 先完全退出 AnyBot 應用程式,然後:
|
||||||
|
# ./gvisor-migrate.sh 搬過去(本機這份保留,當備份)
|
||||||
|
# ./gvisor-migrate.sh --check 只比對兩邊,不動資料
|
||||||
|
set -euo pipefail
|
||||||
|
VM=anybot-gvisor
|
||||||
|
VOL=anybot-box-home
|
||||||
|
VMHOST="unix://$HOME/.lima/$VM/sock/docker.sock"
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 || { echo "找不到 docker 指令"; exit 1; }
|
||||||
|
if [ -n "${DOCKER_HOST:-}" ]; then
|
||||||
|
echo "現在環境裡有 DOCKER_HOST=$DOCKER_HOST(要從本機 Docker 搬出,請用普通終端機跑)"; exit 1
|
||||||
|
fi
|
||||||
|
docker volume inspect "$VOL" >/dev/null 2>&1 || { echo "本機找不到資料卷 $VOL(box 還沒建立過?)"; exit 1; }
|
||||||
|
limactl list "$VM" --format '{{.Status}}' 2>/dev/null | grep -q Running || { echo "VM $VM 沒在跑,先跑 gvisor-vm.sh"; exit 1; }
|
||||||
|
# The Mac app's process is "AnyBot" and `anybot --serve` is "anybot": match either case.
|
||||||
|
if pgrep -ix anybot >/dev/null 2>&1; then
|
||||||
|
echo "AnyBot 還在跑:請先從選單完全退出(它會把 box 叫醒、資料卷會一直在用)。"; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "本機 box 的檔案:$(docker run --rm -v "$VOL":/h alpine:3.20 sh -c 'find /h | wc -l') 個項目、$(docker run --rm -v "$VOL":/h alpine:3.20 sh -c 'du -sh /h | cut -f1')"
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--check" ]; then
|
||||||
|
echo "VM 上的檔案:$(DOCKER_HOST=$VMHOST docker run --rm -v "$VOL":/h alpine:3.20 sh -c 'find /h 2>/dev/null | wc -l') 個項目"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "停止本機的 box(容器留在本機當備份,不刪)…"
|
||||||
|
docker stop anybot-box anybot-box-home-holder >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
echo "VM 上準備同名資料卷…"
|
||||||
|
DOCKER_HOST=$VMHOST docker volume create "$VOL" >/dev/null
|
||||||
|
|
||||||
|
echo "搬移中(本機 → VM,同一條管線直送,不落地)…"
|
||||||
|
# 兩邊都是 Linux,檔案擁有者(uid 1000 = box 裡的 bot 使用者)直接對得上。
|
||||||
|
docker run --rm -v "$VOL":/h -w /h alpine:3.20 tar -cf - . \
|
||||||
|
| DOCKER_HOST=$VMHOST docker run --rm -i -v "$VOL":/h -w /h alpine:3.20 tar -xf -
|
||||||
|
|
||||||
|
echo "核對…"
|
||||||
|
DOCKER_HOST=$VMHOST docker run --rm -v "$VOL":/h alpine:3.20 sh -c 'echo "VM 上的檔案:$(find /h | wc -l) 個項目、$(du -sh /h | cut -f1)"'
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
搬好了。接著在 AnyBot(重新開啟)設定 › 電腦 › 在哪裡執行:
|
||||||
|
1. Docker 主機:填 $VMHOST → 套用
|
||||||
|
2. 容器隔離:選 強隔離(gVisor)
|
||||||
|
電腦卡片按「建立/更新」:VM 上會先建映像檔(第一次下載套件,幾分鐘),
|
||||||
|
開機後檔案與登入就是原本那些——所有 Bot 共用同一台、同一份,沒變。
|
||||||
|
本機那份 box 和資料卷都留著當備份;確認 VM 上都正常後想刪再刪:
|
||||||
|
docker rm -f anybot-box anybot-box-home-holder; docker volume rm $VOL
|
||||||
|
EOF
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# 強隔離 on this Mac: macOS can't run gVisor directly, and OrbStack/Docker Desktop's Linux VM
|
||||||
|
# can't take it. This makes a small Ubuntu VM (Lima, Apple Virtualization) with its OWN Docker
|
||||||
|
# and gVisor (runsc): Bot 的電腦 runs there on a virtualized kernel, verified by a sandboxed
|
||||||
|
# Chromium — the Mac's kernel is a whole VM away.
|
||||||
|
#
|
||||||
|
# ./gvisor-vm.sh create + start the VM (idempotent; re-run to fix/update)
|
||||||
|
# ./gvisor-vm.sh --rm remove the VM (the box's files live in a volume inside it)
|
||||||
|
#
|
||||||
|
# Afterwards, in AnyBot 設定 › 電腦 › 在哪裡執行:
|
||||||
|
# Docker 主機: ssh://lima-anybot-gvisor
|
||||||
|
# 容器隔離: 強隔離(gVisor)
|
||||||
|
# and press 更新 on the computer card. (AnyBot's Docker host accepts ssh://, the VNC viewer
|
||||||
|
# forwards through it.)
|
||||||
|
set -euo pipefail
|
||||||
|
VM=anybot-gvisor
|
||||||
|
CPUS=4
|
||||||
|
MEM=6 # GiB
|
||||||
|
DISK=60 # GiB
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--rm" ]; then
|
||||||
|
limactl delete --force "$VM" 2>/dev/null || true
|
||||||
|
echo "已移除 VM $VM。"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v limactl >/dev/null 2>&1 || { echo "先裝 Lima:brew install lima"; exit 1; }
|
||||||
|
|
||||||
|
STATUS=$(limactl list "$VM" --format '{{.Status}}' 2>/dev/null || true)
|
||||||
|
if [ "$STATUS" = "Running" ]; then
|
||||||
|
echo "VM $VM 已在執行。"
|
||||||
|
elif [ -n "$STATUS" ]; then
|
||||||
|
echo "VM $VM 是 $STATUS,啟動中…"
|
||||||
|
limactl start --tty=false "$VM"
|
||||||
|
else
|
||||||
|
echo "建立 VM ${VM}($CPUS CPU / ${MEM}GB / ${DISK}GB,第一次要下載映像檔,幾分鐘)…"
|
||||||
|
# template:docker-rootful = Lima's rootful Docker template (runsc wants a plain root daemon;
|
||||||
|
# the rootless docker template runs the daemon in a user namespace).
|
||||||
|
limactl start --name="$VM" --tty=false --cpus "$CPUS" --memory "$MEM" --disk "$DISK" template:docker-rootful
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "VM 裡安裝 gVisor(runsc,systrap 平台:不需要 KVM,ARM VM 可用)…"
|
||||||
|
limactl shell "$VM" -- bash -ec '
|
||||||
|
set -e
|
||||||
|
if ! command -v runsc >/dev/null 2>&1; then
|
||||||
|
sudo mkdir -p /etc/apt/keyrings
|
||||||
|
curl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg
|
||||||
|
echo "deb [signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases release main" | sudo tee /etc/apt/sources.list.d/gvisor.list >/dev/null
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq runsc
|
||||||
|
fi
|
||||||
|
# Register the runtime in the VM'\''s Docker daemon (merge, do not clobber other settings).
|
||||||
|
sudo python3 - <<EOF
|
||||||
|
import json, os
|
||||||
|
p = "/etc/docker/daemon.json"
|
||||||
|
d = json.load(open(p)) if os.path.exists(p) else {}
|
||||||
|
d.setdefault("runtimes", {})["runsc"] = {"path": "runsc"}
|
||||||
|
os.makedirs("/etc/docker", exist_ok=True)
|
||||||
|
json.dump(d, open(p, "w"), indent=2)
|
||||||
|
EOF
|
||||||
|
sudo systemctl restart docker
|
||||||
|
# Prove the wall works: a container whose kernel really is gVisor.
|
||||||
|
docker run --rm --runtime=runsc --entrypoint sh alpine:3.20 -c "grep -q gVisor /proc/version && uname -r" | sed "s/^/runsc 容器核心: /"
|
||||||
|
'
|
||||||
|
|
||||||
|
# Bot 的電腦 lives in this VM: start it with the Mac, or after a restart the computer is gone
|
||||||
|
# until someone runs this again.
|
||||||
|
limactl autostart enable "$VM" >/dev/null 2>&1 || limactl start-at-login "$VM" --enabled >/dev/null 2>&1 || echo "(沒能設定開機自動啟動:Mac 重開機後要再跑一次這個腳本)"
|
||||||
|
|
||||||
|
# Lima forwards the VM's Docker socket to the Mac: AnyBot's Docker 主機用這個 unix:// 位址,
|
||||||
|
# 不用 ssh、不用碰 ~/.ssh/config。
|
||||||
|
SOCK="$HOME/.lima/$VM/sock/docker.sock"
|
||||||
|
DOCKER_HOST="unix://$SOCK" docker info --format '{{range $k, $v := .Runtimes}}{{$k}} {{end}}' | grep -qw runsc || { echo "從 Mac 連到 VM 的 Docker 看不到 runsc"; exit 1; }
|
||||||
|
KERNEL=$(DOCKER_HOST="unix://$SOCK" docker run --rm --runtime=runsc --entrypoint cat alpine:3.20 /proc/version | cut -d' ' -f3)
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
都好了(從 Mac 驗證:runsc 容器的核心是 $KERNEL)。AnyBot 設定 › 電腦 › 在哪裡執行:
|
||||||
|
Docker 主機: unix://$SOCK
|
||||||
|
容器隔離: 強隔離(gVisor)
|
||||||
|
然後在電腦卡片按「建立/更新」。要讓舊 box 的檔案與登入跟著走,
|
||||||
|
先完全退出 AnyBot、跑同資料夾的 gvisor-migrate.sh,再回來開。
|
||||||
|
EOF
|
||||||
|
|
@ -42,6 +42,13 @@ services:
|
||||||
args: { JOBS: "${ANYBOT_BUILD_JOBS:-}" }
|
args: { JOBS: "${ANYBOT_BUILD_JOBS:-}" }
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on: [relay]
|
depends_on: [relay]
|
||||||
|
# A second wall for the bots' computer (optional, Linux): install gVisor on this host
|
||||||
|
# (see gvisor.dev/docs "Installation" for the apt repository + `apt-get install runsc`,
|
||||||
|
# then register it in /etc/docker/daemon.json: {"runtimes": {"runsc": {"path": "runsc"}}}
|
||||||
|
# and `systemctl restart docker`). In AnyBot: 設定 › 電腦 › 在哪裡執行 › 容器隔離 →
|
||||||
|
# 「強隔離(gVisor)」, then rebuild the computer (更新). The box gets a virtualized
|
||||||
|
# kernel and Chromium's own sandbox comes back on. On a host without runsc the box won't
|
||||||
|
# start — switch 容器隔離 back to 標準.
|
||||||
environment:
|
environment:
|
||||||
ANYBOT_ACCOUNT: "http://relay:8080|${ANYBOT_USER}"
|
ANYBOT_ACCOUNT: "http://relay:8080|${ANYBOT_USER}"
|
||||||
ANYBOT_ACCOUNT_PASSWORD: "${ANYBOT_PASSWORD}"
|
ANYBOT_ACCOUNT_PASSWORD: "${ANYBOT_PASSWORD}"
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ AnyBot 是一個 Rust 程式(`anybot`),用 egui/eframe 畫介面。同一
|
||||||
|
|
||||||
- **核准關卡**:工具動作照規則直接做、先問或拒絕(`policy.rs`,FEATURES.md 第 16、19 節)。「直接操作模式」可以跳過詢問。
|
- **核准關卡**:工具動作照規則直接做、先問或拒絕(`policy.rs`,FEATURES.md 第 16、19 節)。「直接操作模式」可以跳過詢問。
|
||||||
- **指令的沙盒(macOS)**:Bot 執行的指令在 `sandbox-exec` 裡跑,讀不到也寫不到 AnyBot 的資料資料夾、`.gnupg`、鑰匙圈、`.aws` 等;只有 `attachments/` 和 `.ssh` 可以讀(都不能寫,`.ssh` 可讀讓 git 和 ssh 連得動)。只有沙盒管得到的才是祕密防線,核准層不再逐字掃路徑。環境變數裡的模型金鑰會先拿掉,PATH 會補齊 Homebrew 等位置(從 Finder 或 launchd 啟動也要找得到指令)。
|
- **指令的沙盒(macOS)**:Bot 執行的指令在 `sandbox-exec` 裡跑,讀不到也寫不到 AnyBot 的資料資料夾、`.gnupg`、鑰匙圈、`.aws` 等;只有 `attachments/` 和 `.ssh` 可以讀(都不能寫,`.ssh` 可讀讓 git 和 ssh 連得動)。只有沙盒管得到的才是祕密防線,核准層不再逐字掃路徑。環境變數裡的模型金鑰會先拿掉,PATH 會補齊 Homebrew 等位置(從 Finder 或 launchd 啟動也要找得到指令)。
|
||||||
- **Bot 的電腦**:放在容器裡,和你的電腦分開。
|
- **Bot 的電腦**:放在容器裡,和你的電腦分開。標準(runc)容器和主機共用 Linux 核心,Chromium 在裡面關掉自己的沙盒,容器就是唯一那道牆。Docker 主機裝了 gVisor 時,設定 › 電腦 › 容器隔離可改「強隔離(gVisor)」:容器有自己的虛擬核心,Chromium 的沙盒重新開啟,牆變成兩道(虛擬核心+瀏覽器沙盒)。改完要按電腦的「更新」重建才生效。在 macOS 上沒有能裝 gVisor 的 Docker,用 `deploy/mac/gvisor-vm.sh` 建一台自帶 Docker+gVisor 的 Lima 虛擬機,再把 Docker 主機指過去。
|
||||||
- **保險庫**:密碼、卡號、CVC 不會交給模型,由 AnyBot 直接填進網頁。
|
- **保險庫**:密碼、卡號、CVC 不會交給模型,由 AnyBot 直接填進網頁。
|
||||||
- **多台電腦**:金鑰只留在大腦(下面說明);手腳只接受帳號裡的 AnyBot,用配對碼加入的只接受那一個大腦。
|
- **多台電腦**:金鑰只留在大腦(下面說明);手腳只接受帳號裡的 AnyBot,用配對碼加入的只接受那一個大腦。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,10 @@ pub struct ComputerUi {
|
||||||
replace_secret: Option<String>,
|
replace_secret: Option<String>,
|
||||||
secret_error: String,
|
secret_error: String,
|
||||||
docker_host: Option<String>,
|
docker_host: Option<String>,
|
||||||
|
/// 容器隔離 row: "runc" (standard) or "runsc" (gVisor).
|
||||||
|
box_runtime: String,
|
||||||
|
/// Checked in the background: the Docker host has gVisor, and the runtime the box runs on.
|
||||||
|
runtime_facts: Arc<Mutex<Option<RuntimeFacts>>>,
|
||||||
show_log: bool,
|
show_log: bool,
|
||||||
teach_name: String,
|
teach_name: String,
|
||||||
teach_for: Option<u64>,
|
teach_for: Option<u64>,
|
||||||
|
|
@ -51,6 +55,9 @@ pub struct ComputerUi {
|
||||||
thumb_at: Option<Instant>,
|
thumb_at: Option<Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// (the Docker host has gVisor, the runtime the box runs on now)
|
||||||
|
type RuntimeFacts = (bool, Option<String>);
|
||||||
|
|
||||||
fn status_color(r: &Run, p: Palette) -> Color32 {
|
fn status_color(r: &Run, p: Palette) -> Color32 {
|
||||||
match r {
|
match r {
|
||||||
Run::Running => Color32::from_rgb(0x14, 0xc3, 0x70),
|
Run::Running => Color32::from_rgb(0x14, 0xc3, 0x70),
|
||||||
|
|
@ -97,7 +104,11 @@ impl App {
|
||||||
if !self.demo && self.cu.update_checked.map_or(true, |t| t.elapsed().as_secs() > 30) {
|
if !self.demo && self.cu.update_checked.map_or(true, |t| t.elapsed().as_secs() > 30) {
|
||||||
self.cu.update_checked = Some(Instant::now());
|
self.cu.update_checked = Some(Instant::now());
|
||||||
let flag = self.cu.update_flag.clone();
|
let flag = self.cu.update_flag.clone();
|
||||||
std::thread::spawn(move || flag.store(cloudbox::update_available(), Ordering::SeqCst));
|
let facts = self.cu.runtime_facts.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
flag.store(cloudbox::update_available(), Ordering::SeqCst);
|
||||||
|
*facts.lock().unwrap() = Some((cloudbox::host_has_gvisor(), cloudbox::running_runtime()));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
self.cu.update_ready = self.cu.update_flag.load(Ordering::SeqCst);
|
self.cu.update_ready = self.cu.update_flag.load(Ordering::SeqCst);
|
||||||
ui.spacing_mut().item_spacing.y = 0.0;
|
ui.spacing_mut().item_spacing.y = 0.0;
|
||||||
|
|
@ -616,6 +627,39 @@ impl App {
|
||||||
}
|
}
|
||||||
ui.add(egui::TextEdit::singleline(&mut edit).hint_text(t("這台電腦(預設)")).desired_width(180.0).font(egui::TextStyle::Monospace));
|
ui.add(egui::TextEdit::singleline(&mut edit).hint_text(t("這台電腦(預設)")).desired_width(180.0).font(egui::TextStyle::Monospace));
|
||||||
});
|
});
|
||||||
|
let rt_now = cloudbox::box_runtime();
|
||||||
|
let facts = self.cu.runtime_facts.lock().unwrap().clone();
|
||||||
|
// What the box really runs on, and what pressing 更新 would change.
|
||||||
|
let status = match &facts {
|
||||||
|
Some((false, _)) if rt_now == "runsc" => "這台 Docker 主機沒有裝 gVisor:會用標準隔離開機".to_string(),
|
||||||
|
Some((_, Some(running))) if *running != rt_now => format!("目前是{};按電腦的「更新」重建後改成{}", if running == "runsc" { "強隔離" } else { "標準" }, if rt_now == "runsc" { "強隔離" } else { "標準" }),
|
||||||
|
Some((_, Some(running))) => format!("目前是{}", if running == "runsc" { "強隔離(gVisor)" } else { "標準" }),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let gvisor_here = facts.as_ref().map(|f| f.0).unwrap_or(false);
|
||||||
|
sec_row_s(ui, p, "容器隔離", &status, "標準:容器和這台 Docker 主機共用 Linux 核心,Bot 開網頁時只有一層牆。Docker 主機裝了 gVisor 才能選強隔離:電腦得到自己的虛擬核心,Chromium 的沙盒也會打開,多一道牆。改完按電腦的「更新」重建後生效。", |ui| {
|
||||||
|
let rt = if self.cu.box_runtime.is_empty() { rt_now.clone() } else { self.cu.box_runtime.clone() };
|
||||||
|
let std_on = rt == "runc";
|
||||||
|
let gvis_on = rt == "runsc";
|
||||||
|
if ui.selectable_label(std_on, t("標準")).clicked() {
|
||||||
|
self.cu.box_runtime = "runc".into();
|
||||||
|
}
|
||||||
|
let g = ui.add_enabled_ui(gvisor_here || gvis_on, |ui| ui.selectable_label(gvis_on, t("強隔離(gVisor)"))).inner;
|
||||||
|
if g.clicked() {
|
||||||
|
self.cu.box_runtime = "runsc".into();
|
||||||
|
}
|
||||||
|
g.on_disabled_hover_text(t("這台 Docker 主機沒有裝 gVisor(runsc)"));
|
||||||
|
if !self.cu.box_runtime.is_empty() && self.cu.box_runtime != rt_now {
|
||||||
|
if ui.add(primary_button("套用", p)).clicked() {
|
||||||
|
cloudbox::set_box_runtime(&self.cu.box_runtime);
|
||||||
|
self.cu.box_runtime.clear();
|
||||||
|
self.cu.update_checked = None;
|
||||||
|
}
|
||||||
|
if ui.add(egui::Button::new(RichText::new(t("取消")).size(FONT_XS).color(p.text3)).frame(false)).clicked() {
|
||||||
|
self.cu.box_runtime.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
if apply {
|
if apply {
|
||||||
cloudbox::set_docker_host(edit.trim());
|
cloudbox::set_docker_host(edit.trim());
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,13 @@ pub struct Config {
|
||||||
/// loopback-only, but the box's own programs can reach it, and they should need a key).
|
/// loopback-only, but the box's own programs can reach it, and they should need a key).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub vnc_password: String,
|
pub vnc_password: String,
|
||||||
|
/// 容器隔離: the Docker runtime the box runs on. Empty = Docker's default (runc: the box
|
||||||
|
/// shares the host's kernel, and Chromium runs with its own sandbox off). On a Linux Docker
|
||||||
|
/// host with gVisor installed, "runsc" gives the box a virtualized kernel and Chromium's
|
||||||
|
/// sandbox comes back — a second wall for the pages bots open. Takes effect on the next
|
||||||
|
/// 更新/重設/復原 (the container is recreated).
|
||||||
|
#[serde(default)]
|
||||||
|
pub runtime: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A scheduled update that was missed may still run for this long (the original: one hour).
|
/// A scheduled update that was missed may still run for this long (the original: one hour).
|
||||||
|
|
@ -312,6 +319,39 @@ fn save_config(c: &Config) {
|
||||||
let _ = std::fs::write(config_path(), serde_json::to_string_pretty(c).unwrap_or_default());
|
let _ = std::fs::write(config_path(), serde_json::to_string_pretty(c).unwrap_or_default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 容器隔離: the box's Docker runtime — "runc" (the default: a container sharing the host's
|
||||||
|
/// kernel) or "runsc" (gVisor: a virtualized kernel and user-space seccomp, for Docker hosts
|
||||||
|
/// that have it installed). Anything else falls back to the default.
|
||||||
|
pub fn box_runtime() -> String {
|
||||||
|
match config().lock().unwrap().runtime.as_str() {
|
||||||
|
"runsc" => "runsc".into(),
|
||||||
|
_ => "runc".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The box's Docker host has gVisor (a runtime registered as runsc).
|
||||||
|
pub fn host_has_gvisor() -> bool {
|
||||||
|
dk(&["info", "--format", "{{range $k, $v := .Runtimes}}{{$k}} {{end}}"]).map(|o| o.split_whitespace().any(|r| r == "runsc")).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The runtime the box is running on now ("runc", "runsc"), when there is a box.
|
||||||
|
pub fn running_runtime() -> Option<String> {
|
||||||
|
dk(&["inspect", "-f", "{{.HostConfig.Runtime}}", container()]).ok().map(|r| r.trim().to_string()).filter(|r| !r.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changing it takes effect on the box's next rebuild (更新/重設/復原), where the container
|
||||||
|
/// is created again — nothing is recreated here behind the person's back.
|
||||||
|
pub fn set_box_runtime(r: &str) {
|
||||||
|
let r = if r.trim() == "runsc" { "runsc" } else { "" };
|
||||||
|
let mut c = config().lock().unwrap();
|
||||||
|
if c.runtime == r {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
c.runtime = r.to_string();
|
||||||
|
save_config(&c);
|
||||||
|
log(&format!("容器隔離改為「{}」,下次重建電腦(更新、重設或復原)後生效。", if r == "runsc" { "gVisor(虛擬核心)" } else { "標準(共用主機核心)" }));
|
||||||
|
}
|
||||||
|
|
||||||
pub fn egress_via_mac() -> bool {
|
pub fn egress_via_mac() -> bool {
|
||||||
config().lock().unwrap().egress_via_mac
|
config().lock().unwrap().egress_via_mac
|
||||||
}
|
}
|
||||||
|
|
@ -688,8 +728,40 @@ fn create_container() -> Result<(), String> {
|
||||||
let env = format!("SCREEN_SIZE={SCREEN}");
|
let env = format!("SCREEN_SIZE={SCREEN}");
|
||||||
let vnc = format!("VNC_PASSWORD={}", vnc_password());
|
let vnc = format!("VNC_PASSWORD={}", vnc_password());
|
||||||
log("正在開一台新的電腦…");
|
log("正在開一台新的電腦…");
|
||||||
|
// 容器隔離: on a Docker host with gVisor (`runsc`), the box gets a virtualized kernel
|
||||||
|
// instead of the host's, its seccomp filters run in user space, and Chromium's own sandbox
|
||||||
|
// works inside the box (ANYBOT_BOX_SANDBOX=1 re-enables it; see box/Dockerfile). With the
|
||||||
|
// plain runc runtime the box itself is the sandbox, like the original.
|
||||||
|
// A Docker host without gVisor (the setting moved with a new Docker host, or gVisor was
|
||||||
|
// removed) would leave no computer at all ("unknown runtime"): open it the standard way and
|
||||||
|
// say so; the settings page shows which one it runs on.
|
||||||
|
let hardened = box_runtime() == "runsc" && host_has_gvisor();
|
||||||
|
if box_runtime() == "runsc" && !hardened {
|
||||||
|
log("這台 Docker 主機沒有 gVisor(runsc),電腦先用標準隔離開機;裝好 gVisor 後按「更新」重建。");
|
||||||
|
}
|
||||||
|
let runtime = "runsc";
|
||||||
|
let sandbox_env = "ANYBOT_BOX_SANDBOX=1";
|
||||||
|
let caps: Vec<String> = ["CHOWN", "SETUID", "SETGID", "FOWNER", "DAC_OVERRIDE", "KILL", "NET_BIND_SERVICE"]
|
||||||
|
.iter()
|
||||||
|
.flat_map(|c| ["--cap-add".to_string(), (*c).to_string()])
|
||||||
|
.collect();
|
||||||
|
let mut args: Vec<&str> = vec!["run", "-d", "--name", container(), "--hostname", "bot-computer", "--shm-size", "1g", "--pids-limit", "4096", "--memory", "2g", "--cpus", "2", "--add-host", "host.docker.internal:host-gateway", "-p", &publish, "-v", &vol, "-e", &env, "-e", &vnc];
|
||||||
|
if hardened {
|
||||||
|
// (No swap on top of the 2 GB under gVisor; a standard box keeps Docker's default.)
|
||||||
|
args.extend(["--memory-swap", "2g"]);
|
||||||
|
args.push("--runtime");
|
||||||
|
args.push(runtime);
|
||||||
|
args.push("--cap-drop");
|
||||||
|
args.push("ALL");
|
||||||
|
for c in &caps {
|
||||||
|
args.push(c);
|
||||||
|
}
|
||||||
|
args.push("-e");
|
||||||
|
args.push(sandbox_env);
|
||||||
|
}
|
||||||
|
args.extend(["--label", "anybot.box=1", IMAGE]);
|
||||||
// host.docker.internal → this Mac (for 網路流量改走你的電腦; Docker Desktop and OrbStack already have it).
|
// host.docker.internal → this Mac (for 網路流量改走你的電腦; Docker Desktop and OrbStack already have it).
|
||||||
dk(&["run", "-d", "--name", container(), "--hostname", "bot-computer", "--shm-size", "1g", "--pids-limit", "4096", "--memory", "2g", "--cpus", "2", "--add-host", "host.docker.internal:host-gateway", "-p", &publish, "-v", &vol, "-e", &env, "-e", &vnc, "--label", "anybot.box=1", IMAGE]).map_err(|e| format!("開不了電腦:{}", tail(&e, 400)))?;
|
dk(&args).map_err(|e| format!("開不了電腦:{}", tail(&e, 400)))?;
|
||||||
ensure_holder();
|
ensure_holder();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -2513,7 +2585,7 @@ mod tests {
|
||||||
assert!(!in_schedule_window("03:00", t("02:59")));
|
assert!(!in_schedule_window("03:00", t("02:59")));
|
||||||
assert!(in_schedule_window("23:30", t("00:10")));
|
assert!(in_schedule_window("23:30", t("00:10")));
|
||||||
assert!(!in_schedule_window("bad", t("00:10")));
|
assert!(!in_schedule_window("bad", t("00:10")));
|
||||||
assert_eq!(wanted_version(), "6");
|
assert_eq!(wanted_version(), "7");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue