diff --git a/Cargo.lock b/Cargo.lock index 2230f35..dabb29a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -362,6 +362,7 @@ dependencies = [ "ring", "rodio", "rustls", + "rustls-native-certs", "serde", "serde_json", "tungstenite", @@ -372,7 +373,7 @@ dependencies = [ "wayland-protocols", "wayland-protocols-misc", "wayland-protocols-wlr", - "webpki-roots", + "webpki-root-certs", "x11rb 0.13.2", "zbus", "zhconv", @@ -3541,6 +3542,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + [[package]] name = "option-ext" version = "0.2.0" @@ -4307,6 +4314,18 @@ dependencies = [ "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]] name = "rustls-pki-types" version = "1.15.1" @@ -4351,6 +4370,15 @@ dependencies = [ "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]] name = "scoped-tls" version = "1.0.1" @@ -4376,6 +4404,29 @@ dependencies = [ "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]] name = "self_cell" version = "1.3.0" @@ -5811,6 +5862,15 @@ dependencies = [ "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]] name = "webpki-roots" version = "1.0.11" diff --git a/Cargo.toml b/Cargo.toml index a126c34..b271612 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,11 @@ eframe = "0.37" ureq = { version = "3", features = ["json"] } # 長連線 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"] } -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" # `anybot setup`: the guided setup in a terminal (servers and Linux computers without a window). crossterm = "0.29" diff --git a/FEATURES.md b/FEATURES.md index cea68c6..46d61de 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1758,6 +1758,13 @@ computer_use_screenshot、click、mouse_move、mouse_button(按住/放開) - 部署檔: - 雲端:`deploy/server/`(帳號伺服器、Caddy、AnyBot 三合一); - 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 操作另一台) diff --git a/deploy/server/.env.example b/deploy/server/.env.example index db689fd..4606f9a 100644 --- a/deploy/server/.env.example +++ b/deploy/server/.env.example @@ -12,3 +12,7 @@ OPENAI_API_KEY= GEMINI_API_KEY= # Public address, e.g. https://anybot.example.com 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 diff --git a/deploy/server/docker-compose.lan.yml b/deploy/server/docker-compose.lan.yml new file mode 100644 index 0000000..d3e6142 --- /dev/null +++ b/deploy/server/docker-compose.lan.yml @@ -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" diff --git a/deploy/server/setup.sh b/deploy/server/setup.sh index b243efc..38d0a99 100755 --- a/deploy/server/setup.sh +++ b/deploy/server/setup.sh @@ -9,6 +9,8 @@ # 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 # 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). set -u 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 _; } 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 ---------- 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 [ $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 - 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 fi 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 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 fi fi if [ "${SKIP:-0}" != 1 ]; then # ---------- 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). 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 ] && [ "$TS" = 0 ] && [ -z "$TSNAME" ]; then - header; card_open "網址"; bad "這台沒有 Tailscale:請加上 ANYBOT_SETUP_DOMAIN=你的網域"; die + if [ "$AUTO" = 1 ] && [ -n "${ANYBOT_SETUP_DOMAIN:-}" ]; then + DOMAIN=${ANYBOT_SETUP_DOMAIN#https://}; TSNAME=""; TS=2 + is_ip "$DOMAIN" && { LAN=1; LANIP=$DOMAIN; } 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 choose HOW "手機和其他電腦用哪個網址連到這台?" \ "用 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 + 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 header; card_open "手機和其他電腦用哪個網址連到這台?" note "網域要先指到這台伺服器(DNS 的 A 紀錄)。HTTPS 憑證會自動申請、自動更新。" @@ -222,14 +263,21 @@ if [ "${SKIP:-0}" != 1 ]; then q ANTHROPIC_API_KEY "$ANT" q OPENAI_API_KEY "$OAI" q GEMINI_API_KEY "$GEM" - if [ "$TS" = 1 ]; then - q ANYBOT_TAILSCALE 1 - q COMPOSE_FILE "docker-compose.yml:docker-compose.tailscale.yml" - fi + [ "$TS" = 1 ] && q ANYBOT_TAILSCALE 1 + [ "$LAN" = 1 ] && q ANYBOT_LAN_IP "$LANIP" + case "$TS$LAN" in + 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 chmod 600 .env 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 # ---------- 4. start ---------- @@ -277,20 +325,40 @@ else fi # HTTPS from outside (the certificate can take a minute). -https="" -for _ in $(seq 1 120); do - if curl -fsS --max-time 5 "https://$DOMAIN/api/hello" 2>/dev/null | grep -q anybot-relay; then https=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' -if [ -n "$https" ]; then ok "https://$DOMAIN 可以連" -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 +if [ "$DOMAIN" != "${LANIP:-}" ]; then + https="" + for _ in $(seq 1 120); do + if curl -fsS --max-time 5 "https://$DOMAIN/api/hello" 2>/dev/null | grep -q anybot-relay; then https=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' + if [ -n "$https" ]; then ok "https://$DOMAIN 可以連" + 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 printf '\n' card_open "$([ -n "$joined" ] && echo "完成了" || echo "差一點")" 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}AI 金鑰${N} 手機或電腦的 AnyBot › 設定 › 模型(只存在大腦上)" line "${B}加一台電腦${N} 手機 AnyBot 右上 ⋯ › 加一台電腦,把那一行貼到那台的終端機" diff --git a/docs/multi-device.md b/docs/multi-device.md index abb72a9..7d6562b 100644 --- a/docs/multi-device.md +++ b/docs/multi-device.md @@ -19,6 +19,14 @@ - **Tailscale**(不用網域):那台裝好 Tailscale,並在 Tailscale 管理頁打開 HTTPS(DNS › HTTPS Certificates)。設定時會出現「用 Tailscale:https://那台.xxx.ts.net」,選它就好;只有你 Tailscale 裡的手機和電腦連得到,不用開任何埠。 - **自己的網域**: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 curl -fsSL https://code.30cm.net/daniel.w/AnyBot/raw/branch/main/install.sh | sh -s -- server diff --git a/relay/Caddyfile.lan b/relay/Caddyfile.lan new file mode 100644 index 0000000..ebcc70c --- /dev/null +++ b/relay/Caddyfile.lan @@ -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} + } +} diff --git a/src/remote.rs b/src/remote.rs index 93b6712..f1ef2ca 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -1013,7 +1013,8 @@ pub fn addresses(port: u16) -> Vec { static RELAY_GEN: AtomicU64 = AtomicU64::new(0); /// 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 { static AGENTS: OnceLock>> = OnceLock::new(); AGENTS @@ -1021,7 +1022,11 @@ fn agent(secs: u64) -> ureq::Agent { .lock() .unwrap() .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() } @@ -1031,6 +1036,10 @@ pub fn why_unreachable(e: &ureq::Error) -> String { use ureq::Error as E; let text = e.to_string().to_lowercase(); // 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") { 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); } + #[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] fn lan_login_lock_is_per_device() { // One noisy device guessing passwords must not lock the owner's phone out (a single diff --git a/src/tunnel.rs b/src/tunnel.rs index 5eb89db..5c6dad7 100644 --- a/src/tunnel.rs +++ b/src/tunnel.rs @@ -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>> = 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 { static C: OnceLock> = OnceLock::new(); 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( rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) .with_safe_default_protocol_versions() @@ -192,6 +207,7 @@ fn open(url: &str, token: &str) -> Result, NoLink> { 400 | 404 | 405 | 426 => Err(NoLink { old: true, why: String::new() }), _ => 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(_) => 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)] 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] fn compression_round_trips_and_shrinks_text() { let text = "你好,這是一段會重複的聊天內容。".repeat(400);