HTTPS on the local network's own IP, with a certificate the server signs itself

setup.sh offers 「區網 IP」 (alone, or next to Tailscale): Caddy serves https://<LAN IP> with its
own small certificate authority (tls internal, default_sni for clients that send no name) and
hands out the root at http://<LAN IP>/anybot-ca.crt for each device to trust once.
docker-compose.lan.yml binds Caddy to that address only, so it sits beside tailscale serve.

The app now accepts, for the account server, the certificates this computer trusts as well as
the built-in public ones (HTTP and the WebSocket alike), so it connects once the root is
trusted; before that it says what to install instead of "the certificate is wrong".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
This commit is contained in:
王性驊 2026-11-07 21:27:07 +08:00
parent 4506cc149b
commit 00f6723588
10 changed files with 295 additions and 25 deletions

62
Cargo.lock generated
View File

@ -362,6 +362,7 @@ dependencies = [
"ring", "ring",
"rodio", "rodio",
"rustls", "rustls",
"rustls-native-certs",
"serde", "serde",
"serde_json", "serde_json",
"tungstenite", "tungstenite",
@ -372,7 +373,7 @@ dependencies = [
"wayland-protocols", "wayland-protocols",
"wayland-protocols-misc", "wayland-protocols-misc",
"wayland-protocols-wlr", "wayland-protocols-wlr",
"webpki-roots", "webpki-root-certs",
"x11rb 0.13.2", "x11rb 0.13.2",
"zbus", "zbus",
"zhconv", "zhconv",
@ -3541,6 +3542,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl-probe"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1"
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@ -4307,6 +4314,18 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-native-certs"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.15.1" version = "1.15.1"
@ -4351,6 +4370,15 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "schannel"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "scoped-tls" name = "scoped-tls"
version = "1.0.1" version = "1.0.1"
@ -4376,6 +4404,29 @@ dependencies = [
"tiny-skia 0.11.4", "tiny-skia 0.11.4",
] ]
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "self_cell" name = "self_cell"
version = "1.3.0" version = "1.3.0"
@ -5811,6 +5862,15 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "webpki-root-certs"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.11" version = "1.0.11"

View File

@ -15,7 +15,11 @@ eframe = "0.37"
ureq = { version = "3", features = ["json"] } ureq = { version = "3", features = ["json"] }
# 長連線 to the account server (WebSocket over the same TLS ureq uses), and compressing what goes over it. # 長連線 to the account server (WebSocket over the same TLS ureq uses), and compressing what goes over it.
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
webpki-roots = "1" # Which certificates the account server may show: the public ones (built in), plus the ones this
# computer trusts itself, so a server on the local network that signs its own (https://10.0.0.25)
# works once this computer trusts it, the same as for the browser.
webpki-root-certs = "1"
rustls-native-certs = "0.8"
flate2 = "1" flate2 = "1"
# `anybot setup`: the guided setup in a terminal (servers and Linux computers without a window). # `anybot setup`: the guided setup in a terminal (servers and Linux computers without a window).
crossterm = "0.29" crossterm = "0.29"

View File

@ -1758,6 +1758,13 @@ computer_use_screenshot、click、mouse_move、mouse_button(按住/放開)
- 部署檔: - 部署檔:
- 雲端:`deploy/server/`(帳號伺服器、Caddy、AnyBot 三合一); - 雲端:`deploy/server/`(帳號伺服器、Caddy、AnyBot 三合一);
- Mac mini:`deploy/mac/com.anybot.serve.plist`。 - Mac mini:`deploy/mac/com.anybot.serve.plist`。
- 伺服器的網址(`setup.sh` 會問):
- Tailscale:`https://那台.xxx.ts.net`,只有 Tailscale 裡的裝置連得到;
- 自己的網域:憑證自動申請;
- 區網 IP(例如 `https://10.0.0.25`):憑證由伺服器自己簽(Caddy 的 `tls internal`),只有同一個區網連得到;可以和 Tailscale 一起用。
- 每台裝置先信任一次:打開 `http://那台的IP/anybot-ca.crt` 安裝。
- 電腦上的 AnyBot 連帳號伺服器時,除了內建的公開憑證,也認這台電腦自己信任的憑證(系統的憑證清單),所以裝好憑證後 App 也連得上。
- 還沒信任時,App 會直接說要先安裝並信任伺服器的憑證,不只說「憑證不對」。
### 裝置連結(一台 AnyBot 操作另一台) ### 裝置連結(一台 AnyBot 操作另一台)

View File

@ -12,3 +12,7 @@ OPENAI_API_KEY=
GEMINI_API_KEY= GEMINI_API_KEY=
# Public address, e.g. https://anybot.example.com # Public address, e.g. https://anybot.example.com
ANYBOT_RELAY_PUBLIC_URL= ANYBOT_RELAY_PUBLIC_URL=
# The local network's own address with HTTPS (https://10.0.0.25), certificate signed by this
# server itself: set it and add docker-compose.lan.yml to COMPOSE_FILE (setup.sh 「區網 IP」 does both).
#ANYBOT_LAN_IP=10.0.0.25
#COMPOSE_FILE=docker-compose.yml:docker-compose.lan.yml

View File

@ -0,0 +1,14 @@
# The local network's own address with HTTPS (https://10.0.0.25), no domain: Caddy signs the
# certificate itself (Caddyfile from ../../relay/Caddyfile.lan). setup.sh turns this on
# (COMPOSE_FILE in .env) when you pick 「區網 IP」.
#
# Caddy listens only on that address, so it works next to Tailscale (whose `tailscale serve`
# holds 443 on the Tailscale address): at home https://10.0.0.25, away the ts.net address.
# Needs docker compose 2.24.4 or newer (!reset / !override).
services:
caddy:
# Started even when docker-compose.tailscale.yml (listed before this) leaves Caddy out.
profiles: !reset []
ports: !override
- "${ANYBOT_LAN_IP:?ANYBOT_LAN_IP}:80:80"
- "${ANYBOT_LAN_IP:?ANYBOT_LAN_IP}:443:443"

View File

@ -9,6 +9,8 @@
# Without questions (scripts, Ansible…): give the answers as environment variables. # Without questions (scripts, Ansible…): give the answers as environment variables.
# ANYBOT_SETUP_USER=me ANYBOT_SETUP_PASSWORD=… ANYBOT_SETUP_DOMAIN=anybot.example.com ./setup.sh # ANYBOT_SETUP_USER=me ANYBOT_SETUP_PASSWORD=… ANYBOT_SETUP_DOMAIN=anybot.example.com ./setup.sh
# leave out ANYBOT_SETUP_DOMAIN on a computer with Tailscale to use its Tailscale address. # leave out ANYBOT_SETUP_DOMAIN on a computer with Tailscale to use its Tailscale address.
# ANYBOT_SETUP_DOMAIN=10.0.0.25 (an IP): only the local network, with a certificate this server
# signs itself; ANYBOT_SETUP_LAN_IP=10.0.0.25 with Tailscale: both.
# An existing setup is kept (updated and restarted). # An existing setup is kept (updated and restarted).
set -u set -u
cd "$(dirname "$0")" cd "$(dirname "$0")"
@ -116,6 +118,14 @@ spin() { # spin "做什麼" command… : runs it, shows a spinner and the newest
pause() { [ "$AUTO" = 1 ] && return; printf ' %s%s%s' "$D" "${1:-按 Enter 繼續}" "$N"; IFS= read -r _; } pause() { [ "$AUTO" = 1 ] && return; printf ' %s%s%s' "$D" "${1:-按 Enter 繼續}" "$N"; IFS= read -r _; }
die() { card_close; printf '\n'; exit 1; } die() { card_close; printf '\n'; exit 1; }
is_ip() { printf '%s' "$1" | grep -Eq '^([0-9]{1,3}\.){3}[0-9]{1,3}$'; }
# This server's address in the local network: the one its traffic leaves from.
lan_ip() {
local a
a=$(ip route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p' | head -1)
[ -z "$a" ] && a=$(hostname -I 2>/dev/null | awk '{print $1}')
printf '%s' "$a"
}
# ---------- 0. this server ---------- # ---------- 0. this server ----------
AT=0; header; card_open "檢查這台伺服器" AT=0; header; card_open "檢查這台伺服器"
@ -124,7 +134,8 @@ if command -v docker >/dev/null; then ok "Docker 已安裝"; else bad "沒有 Do
if docker compose version >/dev/null 2>&1; then ok "docker compose 可以用"; elif [ $FAIL = 0 ]; then bad "沒有 docker compose(Docker 太舊?請更新 Docker)"; FAIL=1; fi if docker compose version >/dev/null 2>&1; then ok "docker compose 可以用"; elif [ $FAIL = 0 ]; then bad "沒有 docker compose(Docker 太舊?請更新 Docker)"; FAIL=1; fi
if [ $FAIL = 0 ] && ! docker info >/dev/null 2>&1; then bad "沒有權限使用 Docker:sudo usermod -aG docker \$USER,登出再登入"; FAIL=1; fi if [ $FAIL = 0 ] && ! docker info >/dev/null 2>&1; then bad "沒有權限使用 Docker:sudo usermod -aG docker \$USER,登出再登入"; FAIL=1; fi
if command -v ss >/dev/null; then if command -v ss >/dev/null; then
busy=$(ss -ltnH 2>/dev/null | awk '{print $4}' | grep -E ':(80|443)$' | grep -v docker || true) # (Tailscale's own 443 on its 100.x address isn't in the way.)
busy=$(ss -ltnH 2>/dev/null | awk '{print $4}' | grep -E ':(80|443)$' | grep -v docker | grep -vE '^(100\.|\[fd7a:115c:a1e0:)' || true)
if [ -n "$busy" ] && ! docker compose ps -q caddy 2>/dev/null | grep -q .; then warn "80 或 443 埠已被別的程式佔用(HTTPS 需要它們)"; else ok "80、443 埠可以用"; fi if [ -n "$busy" ] && ! docker compose ps -q caddy 2>/dev/null | grep -q .; then warn "80 或 443 埠已被別的程式佔用(HTTPS 需要它們)"; else ok "80、443 埠可以用"; fi
fi fi
mem=$(awk '/MemTotal/ {print int($2/1024/1024+0.5)}' /proc/meminfo 2>/dev/null || echo 0) mem=$(awk '/MemTotal/ {print int($2/1024/1024+0.5)}' /proc/meminfo 2>/dev/null || echo 0)
@ -144,25 +155,55 @@ if [ -f .env ] && [ -f Caddyfile ]; then
"重新設定|網址、帳號或金鑰要改" "重新設定|網址、帳號或金鑰要改"
if [ "$KEEP" = 0 ]; then if [ "$KEEP" = 0 ]; then
DOMAIN=${ANYBOT_RELAY_PUBLIC_URL#https://}; USERNAME=$ANYBOT_USER; INVITE=${ANYBOT_RELAY_INVITE:-}; BRAIN=${ANYBOT_HOST_NAME:-} DOMAIN=${ANYBOT_RELAY_PUBLIC_URL#https://}; USERNAME=$ANYBOT_USER; INVITE=${ANYBOT_RELAY_INVITE:-}; BRAIN=${ANYBOT_HOST_NAME:-}
LANIP=${ANYBOT_LAN_IP:-}
AT=3; SKIP=1 AT=3; SKIP=1
fi fi
fi fi
if [ "${SKIP:-0}" != 1 ]; then if [ "${SKIP:-0}" != 1 ]; then
# ---------- 1. the address ---------- # ---------- 1. the address ----------
AT=1; MSG=""; TS=0 AT=1; MSG=""; TS=0; LAN=0; LANIP=""
# On a tailnet: offer its address (HTTPS from Tailscale, no domain, nothing open to the internet). # On a tailnet: offer its address (HTTPS from Tailscale, no domain, nothing open to the internet).
TSNAME=$(tailscale status --json 2>/dev/null | sed -n 's/.*"DNSName": *"\([^"]*\)\.".*/\1/p' | head -1) TSNAME=$(tailscale status --json 2>/dev/null | sed -n 's/.*"DNSName": *"\([^"]*\)\.".*/\1/p' | head -1)
if [ "$AUTO" = 1 ] && [ -n "${ANYBOT_SETUP_DOMAIN:-}" ]; then DOMAIN=${ANYBOT_SETUP_DOMAIN#https://}; TSNAME=""; TS=2; fi if [ "$AUTO" = 1 ] && [ -n "${ANYBOT_SETUP_DOMAIN:-}" ]; then
if [ "$AUTO" = 1 ] && [ "$TS" = 0 ] && [ -z "$TSNAME" ]; then DOMAIN=${ANYBOT_SETUP_DOMAIN#https://}; TSNAME=""; TS=2
header; card_open "網址"; bad "這台沒有 Tailscale:請加上 ANYBOT_SETUP_DOMAIN=你的網域"; die is_ip "$DOMAIN" && { LAN=1; LANIP=$DOMAIN; }
fi fi
if [ "$AUTO" = 1 ] && [ -n "$TSNAME" ] && [ -n "${ANYBOT_SETUP_LAN_IP:-}" ]; then LAN=1; LANIP=$ANYBOT_SETUP_LAN_IP; fi
if [ "$AUTO" = 1 ] && [ "$TS" = 0 ] && [ -z "$TSNAME" ]; then
header; card_open "網址"; bad "這台沒有 Tailscale:請加上 ANYBOT_SETUP_DOMAIN=你的網域(或區網 IP)"; die
fi
# The local network's own address: HTTPS with a certificate this server signs itself.
LANDESC="憑證是這台自己簽的:每台手機和電腦要先信任一次。位址要固定(在路由器設成固定 IP)"
if [ -n "$TSNAME" ]; then if [ -n "$TSNAME" ]; then
choose HOW "手機和其他電腦用哪個網址連到這台?" \ choose HOW "手機和其他電腦用哪個網址連到這台?" \
"用 Tailscale:https://$TSNAME|只有你 Tailscale 裡的手機和電腦連得到。不用網域,也不用開 80、443 埠" \ "用 Tailscale:https://$TSNAME|只有你 Tailscale 裡的手機和電腦連得到。不用網域,也不用開 80、443 埠" \
"Tailscale + 區網 IP:https://$(lan_ip)|在同一個區網用 IP 連,出門用 Tailscale。$LANDESC" \
"只用區網 IP:https://$(lan_ip)|只有同一個區網連得到。$LANDESC" \
"用自己的網域|任何地方都連得到(網域要先指到這台)" "用自己的網域|任何地方都連得到(網域要先指到這台)"
if [ "$HOW" = 0 ]; then DOMAIN=$TSNAME; TS=1; fi case "$HOW" in
0) DOMAIN=$TSNAME; TS=1 ;;
1) DOMAIN=$TSNAME; TS=1; LAN=1 ;;
2) LAN=1; TS=2 ;;
esac
elif [ "$AUTO" = 0 ]; then
choose HOW "手機和其他電腦用哪個網址連到這台?" \
"用區網 IP:https://$(lan_ip)|只有同一個區網連得到,不用網域。$LANDESC" \
"用自己的網域|任何地方都連得到(網域要先指到這台)"
[ "$HOW" = 0 ] && { LAN=1; TS=2; }
fi fi
while [ "$LAN" = 1 ] && [ "$AUTO" = 0 ]; do
header; card_open "這台在區網的 IP"
note "手機和電腦在同一個區網打開 https://這個 IP。$LANDESC。"
line ""
[ -n "$MSG" ] && { printf '%s\n' "$MSG"; line ""; }
ask LANIP "IP" "${LANIP:-$(lan_ip)}"
LANIP=${LANIP#https://}; LANIP=${LANIP#http://}; LANIP=${LANIP%%/*}
if ! is_ip "$LANIP"; then MSG=$(bad "請輸入像 10.0.0.25 這樣的 IP"); continue; fi
MSG=""
break
done
[ "$LAN" = 1 ] && [ "$TS" != 1 ] && DOMAIN=$LANIP
while [ "$TS" = 0 ]; do while [ "$TS" = 0 ]; do
header; card_open "手機和其他電腦用哪個網址連到這台?" header; card_open "手機和其他電腦用哪個網址連到這台?"
note "網域要先指到這台伺服器(DNS 的 A 紀錄)。HTTPS 憑證會自動申請、自動更新。" note "網域要先指到這台伺服器(DNS 的 A 紀錄)。HTTPS 憑證會自動申請、自動更新。"
@ -222,14 +263,21 @@ if [ "${SKIP:-0}" != 1 ]; then
q ANTHROPIC_API_KEY "$ANT" q ANTHROPIC_API_KEY "$ANT"
q OPENAI_API_KEY "$OAI" q OPENAI_API_KEY "$OAI"
q GEMINI_API_KEY "$GEM" q GEMINI_API_KEY "$GEM"
if [ "$TS" = 1 ]; then [ "$TS" = 1 ] && q ANYBOT_TAILSCALE 1
q ANYBOT_TAILSCALE 1 [ "$LAN" = 1 ] && q ANYBOT_LAN_IP "$LANIP"
q COMPOSE_FILE "docker-compose.yml:docker-compose.tailscale.yml" case "$TS$LAN" in
fi 10) q COMPOSE_FILE "docker-compose.yml:docker-compose.tailscale.yml" ;;
11) q COMPOSE_FILE "docker-compose.yml:docker-compose.tailscale.yml:docker-compose.lan.yml" ;;
01) q COMPOSE_FILE "docker-compose.yml:docker-compose.lan.yml" ;;
esac
} >.env } >.env
chmod 600 .env chmod 600 .env
umask 022 umask 022
sed "s/^anybot.example.com {/$DOMAIN {/" ../../relay/Caddyfile >Caddyfile if [ "$LAN" = 1 ]; then
sed "s/LAN_IP/$LANIP/g" ../../relay/Caddyfile.lan >Caddyfile
else
sed "s/^anybot.example.com {/$DOMAIN {/" ../../relay/Caddyfile >Caddyfile
fi
fi fi
# ---------- 4. start ---------- # ---------- 4. start ----------
@ -277,20 +325,40 @@ else
fi fi
# HTTPS from outside (the certificate can take a minute). # HTTPS from outside (the certificate can take a minute).
https="" if [ "$DOMAIN" != "${LANIP:-}" ]; then
for _ in $(seq 1 120); do https=""
if curl -fsS --max-time 5 "https://$DOMAIN/api/hello" 2>/dev/null | grep -q anybot-relay; then https=1; break; fi for _ in $(seq 1 120); do
printf '\r\e[K %s│%s %s%s%s 等 HTTPS 憑證…' "$D" "$N" "$A" "${c[$((f % 10))]}" "$N"; f=$((f + 1)); sleep 1 if curl -fsS --max-time 5 "https://$DOMAIN/api/hello" 2>/dev/null | grep -q anybot-relay; then https=1; break; fi
done printf '\r\e[K %s│%s %s%s%s 等 HTTPS 憑證…' "$D" "$N" "$A" "${c[$((f % 10))]}" "$N"; f=$((f + 1)); sleep 1
printf '\r\e[K' done
if [ -n "$https" ]; then ok "https://$DOMAIN 可以連" printf '\r\e[K'
elif [ "${TS:-0}" = 1 ]; then warn "https://$DOMAIN 還連不到。Tailscale 的 HTTPS 要在管理頁打開(DNS › HTTPS Certificates),看設定:tailscale serve status" if [ -n "$https" ]; then ok "https://$DOMAIN 可以連"
else warn "https://$DOMAIN 還連不到(DNS 還沒生效,或 80/443 埠被防火牆擋住)。看紀錄:docker compose logs caddy"; fi elif [ "${TS:-0}" = 1 ]; then warn "https://$DOMAIN 還連不到。Tailscale 的 HTTPS 要在管理頁打開(DNS › HTTPS Certificates),看設定:tailscale serve status"
else warn "https://$DOMAIN 還連不到(DNS 還沒生效,或 80/443 埠被防火牆擋住)。看紀錄:docker compose logs caddy"; fi
fi
# The local network's address: checked with the certificate Caddy signed it with.
if [ -n "${LANIP:-}" ]; then
lanok=""; ca=$(mktemp)
for _ in $(seq 1 60); do
if docker compose exec -T caddy cat /data/caddy/pki/authorities/local/root.crt >"$ca" 2>/dev/null \
&& curl -fsS --max-time 5 --cacert "$ca" "https://$LANIP/api/hello" 2>/dev/null | grep -q anybot-relay; then lanok=1; break; fi
printf '\r\e[K %s│%s %s%s%s 等區網的 HTTPS…' "$D" "$N" "$A" "${c[$((f % 10))]}" "$N"; f=$((f + 1)); sleep 1
done
printf '\r\e[K'; rm -f "$ca"
if [ -n "$lanok" ]; then ok "https://$LANIP 可以連(憑證:http://$LANIP/anybot-ca.crt)"
else warn "https://$LANIP 還連不到(80/443 埠被防火牆擋住?)。看紀錄:docker compose logs caddy"; fi
fi
card_close card_close
printf '\n' printf '\n'
card_open "$([ -n "$joined" ] && echo "完成了" || echo "差一點")" card_open "$([ -n "$joined" ] && echo "完成了" || echo "差一點")"
line "${B}手機${N} 打開 ${A}https://$DOMAIN${N},用 ${B}$USERNAME${N} 登入,可以「加入主畫面」" line "${B}手機${N} 打開 ${A}https://$DOMAIN${N},用 ${B}$USERNAME${N} 登入,可以「加入主畫面」"
if [ -n "${LANIP:-}" ]; then
[ "$DOMAIN" != "$LANIP" ] && line "${B}區網${N} 也可以打開 ${A}https://$LANIP${N}(同一個區網)"
line "${B}信任憑證${N} 每台手機和電腦先打開 ${A}http://$LANIP/anybot-ca.crt${N} 安裝它(一次就好)"
note " iPhone:裝好描述檔後,到 設定 › 一般 › 關於本機 › 憑證信任設定 打開它"
note " Mac:在「鑰匙圈存取」把它設成「永遠信任」;Linux:放進 /usr/local/share/ca-certificates/ 再執行 update-ca-certificates"
fi
line "${B}電腦${N} AnyBot › 設定 › 一般 › 手機與帳號 登入,在「大腦」選「${BRAIN}」" line "${B}電腦${N} AnyBot › 設定 › 一般 › 手機與帳號 登入,在「大腦」選「${BRAIN}」"
line "${B}AI 金鑰${N} 手機或電腦的 AnyBot › 設定 › 模型(只存在大腦上)" line "${B}AI 金鑰${N} 手機或電腦的 AnyBot › 設定 › 模型(只存在大腦上)"
line "${B}加一台電腦${N} 手機 AnyBot 右上 ⋯ › 加一台電腦,把那一行貼到那台的終端機" line "${B}加一台電腦${N} 手機 AnyBot 右上 ⋯ › 加一台電腦,把那一行貼到那台的終端機"

View File

@ -19,6 +19,14 @@
- **Tailscale**(不用網域):那台裝好 Tailscale,並在 Tailscale 管理頁打開 HTTPS(DNS › HTTPS Certificates)。設定時會出現「用 Tailscale:https://那台.xxx.ts.net」,選它就好;只有你 Tailscale 裡的手機和電腦連得到,不用開任何埠。 - **Tailscale**(不用網域):那台裝好 Tailscale,並在 Tailscale 管理頁打開 HTTPS(DNS › HTTPS Certificates)。設定時會出現「用 Tailscale:https://那台.xxx.ts.net」,選它就好;只有你 Tailscale 裡的手機和電腦連得到,不用開任何埠。
- **自己的網域**:A 紀錄指到這台,打開 80 和 443 埠,HTTPS 憑證自動申請。 - **自己的網域**:A 紀錄指到這台,打開 80 和 443 埠,HTTPS 憑證自動申請。
- **區網 IP**(不用網域,例如 `https://10.0.0.25`):憑證由這台伺服器自己簽、自己更新,只有同一個區網連得到。可以單獨用,也可以和 Tailscale 一起用(在家用 IP、出門用 Tailscale)。這台的 IP 要固定(在路由器設成固定 IP)。
每台手機和電腦要先信任這張憑證一次,之後瀏覽器和 AnyBot 都不會再警告:
- 用瀏覽器打開 `http://那台的IP/anybot-ca.crt` 下載。
- iPhone:安裝描述檔,再到「設定 › 一般 › 關於本機 › 憑證信任設定」打開它。
- Android:設定 › 安全性 › 加密與憑證 › 安裝憑證 › CA 憑證。
- Mac:打開下載的檔案加進「鑰匙圈存取」,點兩下憑證,「信任」設成「永遠信任」。
- Windows:點兩下 › 安裝憑證 › 放進「受信任的根憑證授權單位」。
- Linux(手腳電腦):`sudo cp anybot-ca.crt /usr/local/share/ca-certificates/ && sudo update-ca-certificates`。
```sh ```sh
curl -fsSL https://code.30cm.net/daniel.w/AnyBot/raw/branch/main/install.sh | sh -s -- server curl -fsSL https://code.30cm.net/daniel.w/AnyBot/raw/branch/main/install.sh | sh -s -- server

41
relay/Caddyfile.lan Normal file
View File

@ -0,0 +1,41 @@
# HTTPS on this server's own address in the local network (no domain): Caddy signs the
# certificate itself (its own small certificate authority, kept in the caddy-data volume) and
# renews it by itself. setup.sh 「區網 IP」 writes this with LAN_IP replaced by the address.
#
# Each phone and computer trusts that authority once: open http://LAN_IP/anybot-ca.crt and
# install it (iPhone: then 設定 › 一般 › 關於本機 › 憑證信任設定, turn it on). After that the
# browser and AnyBot connect to https://LAN_IP without warnings.
{
# A browser asking for an IP address doesn't say which name it wants (no SNI).
default_sni LAN_IP
# The authority lives in this container only; nothing is installed on the server itself.
skip_install_trust
}
https://LAN_IP {
tls internal
encode gzip
reverse_proxy relay:8080 {
# The phone's "anything new?" request waits up to 35 s. Each AnyBot also keeps one
# WebSocket open here (pinged every 15–20 s, so it never sits idle for 60 s).
transport http {
read_timeout 60s
}
# Reloading this file doesn't cut those connections at once.
stream_close_delay 5m
}
}
# Plain http only hands out the certificate to trust (it's public anyway), and sends
# everything else to https.
http://LAN_IP {
handle /anybot-ca.crt {
root * /data/caddy/pki/authorities/local
rewrite * /root.crt
header Content-Type application/x-x509-ca-cert
file_server
}
handle {
redir https://LAN_IP{uri}
}
}

View File

@ -1013,7 +1013,8 @@ pub fn addresses(port: u16) -> Vec<String> {
static RELAY_GEN: AtomicU64 = AtomicU64::new(0); static RELAY_GEN: AtomicU64 = AtomicU64::new(0);
/// One agent per timeout, kept: its connections stay open between requests (no new TCP and TLS /// One agent per timeout, kept: its connections stay open between requests (no new TCP and TLS
/// handshake each time). /// handshake each time). It accepts the same certificates as the long connection
/// (`tunnel::trusted_roots`: the public ones and the ones this computer trusts).
fn agent(secs: u64) -> ureq::Agent { fn agent(secs: u64) -> ureq::Agent {
static AGENTS: OnceLock<Mutex<HashMap<u64, ureq::Agent>>> = OnceLock::new(); static AGENTS: OnceLock<Mutex<HashMap<u64, ureq::Agent>>> = OnceLock::new();
AGENTS AGENTS
@ -1021,7 +1022,11 @@ fn agent(secs: u64) -> ureq::Agent {
.lock() .lock()
.unwrap() .unwrap()
.entry(secs) .entry(secs)
.or_insert_with(|| ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(secs))).http_status_as_error(false).build().into()) .or_insert_with(|| {
let roots: Vec<_> = crate::tunnel::trusted_roots().iter().map(|c| ureq::tls::Certificate::from_der(c.as_ref()).to_owned()).collect();
let tls = ureq::tls::TlsConfig::builder().root_certs(ureq::tls::RootCerts::new_with_certs(&roots)).build();
ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(secs))).http_status_as_error(false).tls_config(tls).build().into()
})
.clone() .clone()
} }
@ -1031,6 +1036,10 @@ pub fn why_unreachable(e: &ureq::Error) -> String {
use ureq::Error as E; use ureq::Error as E;
let text = e.to_string().to_lowercase(); let text = e.to_string().to_lowercase();
// A TLS failure often comes wrapped as an I/O error: look at the words first. // A TLS failure often comes wrapped as an I/O error: look at the words first.
// A certificate no one this computer trusts has signed: usually a server that signs its own.
if text.contains("unknownissuer") || text.contains("unknown issuer") {
return "這台電腦還不信任伺服器的憑證(伺服器自己簽的):先在這台電腦安裝並信任它的憑證(區網伺服器:用瀏覽器打開 http://伺服器位址/anybot-ca.crt),再試一次".to_string();
}
if text.contains("certificate") || text.contains("tls") || text.contains("handshake") { if text.contains("certificate") || text.contains("tls") || text.contains("handshake") {
return "和伺服器的安全連線失敗:對方的憑證不對(網址可能連到了別的服務,或憑證過期)".to_string(); return "和伺服器的安全連線失敗:對方的憑證不對(網址可能連到了別的服務,或憑證過期)".to_string();
} }
@ -1665,6 +1674,15 @@ mod tests {
assert_eq!(model_fetch_here(&json!({"url": "http://169.254.169.254/latest/meta-data/"}).to_string()).status, 403); assert_eq!(model_fetch_here(&json!({"url": "http://169.254.169.254/latest/meta-data/"}).to_string()).status, 403);
} }
#[test]
fn a_certificate_this_computer_doesnt_trust_says_what_to_do() {
let e = ureq::Error::Rustls(rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer));
assert!(why_unreachable(&e).contains("anybot-ca.crt"), "{}", why_unreachable(&e));
// Any other certificate problem keeps the general words.
let e = ureq::Error::Rustls(rustls::Error::InvalidCertificate(rustls::CertificateError::Expired));
assert!(why_unreachable(&e).contains("憑證不對"));
}
#[test] #[test]
fn lan_login_lock_is_per_device() { fn lan_login_lock_is_per_device() {
// One noisy device guessing passwords must not lock the owner's phone out (a single // One noisy device guessing passwords must not lock the owner's phone out (a single

View File

@ -111,10 +111,25 @@ impl Write for Stream {
} }
} }
/// The root certificates a connection to the account server accepts: the public ones every
/// browser knows (built in), plus the ones this computer trusts itself. So a server on the local
/// network that signs its own certificate (https://10.0.0.25) works here once this computer
/// trusts it, just as it does in the browser. Read once (the system's list takes a moment).
pub fn trusted_roots() -> &'static [rustls::pki_types::CertificateDer<'static>] {
static R: OnceLock<Vec<rustls::pki_types::CertificateDer<'static>>> = OnceLock::new();
R.get_or_init(|| {
let mut v = webpki_root_certs::TLS_SERVER_ROOT_CERTS.to_vec();
// A computer whose list can't be read still has the public ones.
v.extend(rustls_native_certs::load_native_certs().certs);
v
})
}
fn tls_config() -> Arc<rustls::ClientConfig> { fn tls_config() -> Arc<rustls::ClientConfig> {
static C: OnceLock<Arc<rustls::ClientConfig>> = OnceLock::new(); static C: OnceLock<Arc<rustls::ClientConfig>> = OnceLock::new();
C.get_or_init(|| { C.get_or_init(|| {
let roots = rustls::RootCertStore { roots: webpki_roots::TLS_SERVER_ROOTS.to_vec() }; let mut roots = rustls::RootCertStore::empty();
roots.add_parsable_certificates(trusted_roots().iter().cloned());
Arc::new( Arc::new(
rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions() .with_safe_default_protocol_versions()
@ -192,6 +207,7 @@ fn open(url: &str, token: &str) -> Result<WebSocket<Stream>, NoLink> {
400 | 404 | 405 | 426 => Err(NoLink { old: true, why: String::new() }), 400 | 404 | 405 | 426 => Err(NoLink { old: true, why: String::new() }),
_ => Err(fail("帳號伺服器暫時無法使用,稍後會自動再試")), _ => Err(fail("帳號伺服器暫時無法使用,稍後會自動再試")),
}, },
Err(tungstenite::HandshakeError::Failure(e)) if e.to_string().to_lowercase().contains("unknownissuer") => Err(fail("這台電腦還不信任伺服器的憑證(伺服器自己簽的):先在這台電腦安裝並信任它的憑證,再試一次")),
Err(tungstenite::HandshakeError::Failure(e)) if e.to_string().to_lowercase().contains("certificate") || e.to_string().to_lowercase().contains("tls") => Err(fail("和伺服器的安全連線失敗(伺服器的憑證有問題)")), Err(tungstenite::HandshakeError::Failure(e)) if e.to_string().to_lowercase().contains("certificate") || e.to_string().to_lowercase().contains("tls") => Err(fail("和伺服器的安全連線失敗(伺服器的憑證有問題)")),
Err(_) => Err(fail("連線到一半斷掉了")), Err(_) => Err(fail("連線到一半斷掉了")),
} }
@ -410,8 +426,38 @@ pub fn call(to: &str, method: &str, path: &str, query: &[(&str, String)], body:
}) })
} }
/// An account server on the local network with its own certificate (setup.sh 「區網 IP」, Caddy
/// `tls internal`). Trusted (SSL_CERT_FILE = its root, read like the system's list):
/// ANYBOT_TLS_URL=https://10.0.0.25 SSL_CERT_FILE=root.crt cargo test -- --ignored own_certificate
/// Without SSL_CERT_FILE (and not trusted by this computer) it checks the refusal says what to do.
#[cfg(test)]
mod own_certificate_live {
#[test]
#[ignore]
fn a_server_with_its_own_certificate() {
let url = std::env::var("ANYBOT_TLS_URL").expect("ANYBOT_TLS_URL");
let trusted = std::env::var_os("SSL_CERT_FILE").is_some();
// A sign-up with a wrong invite: refused by the server itself (never makes an account).
let http = crate::remote::relay_post(&format!("{url}/relay/signup"), "", &serde_json::json!({"user": "tls-probe", "password": "tls-probe-123", "invite": "wrong"})).unwrap_err();
let ws = super::open(&url, "not-a-token").err().map(|e| e.why).unwrap_or_default();
if trusted {
assert!(!http.starts_with("連不到帳號伺服器"), "the secure connection should work: {http}");
assert!(ws.contains("登入失效"), "the long connection should reach the server: {ws}");
} else {
assert!(http.contains("不信任伺服器的憑證"), "{http}");
assert!(ws.contains("不信任伺服器的憑證"), "{ws}");
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn the_public_certificates_are_always_trusted() {
// The system's list comes on top; a computer without one still reaches public servers.
assert!(super::trusted_roots().len() >= webpki_root_certs::TLS_SERVER_ROOT_CERTS.len());
}
#[test] #[test]
fn compression_round_trips_and_shrinks_text() { fn compression_round_trips_and_shrinks_text() {
let text = "你好,這是一段會重複的聊天內容。".repeat(400); let text = "你好,這是一段會重複的聊天內容。".repeat(400);