392 lines
20 KiB
Bash
Executable File
392 lines
20 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# AnyBot 伺服器設定: the account server, HTTPS and the brain on this server, step by step.
|
||
#
|
||
# cd app/deploy/server && ./setup.sh ask, write .env and Caddyfile, start, check
|
||
# ./setup.sh --dry-run ask and write the files, but don't start anything
|
||
#
|
||
# Run it again any time: it offers to keep the current settings (to update and restart).
|
||
#
|
||
# 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")"
|
||
DRY=0
|
||
[ "${1:-}" = "--dry-run" ] && DRY=1
|
||
AUTO=0
|
||
[ -n "${ANYBOT_SETUP_USER:-}" ] && [ -n "${ANYBOT_SETUP_PASSWORD:-}" ] && AUTO=1
|
||
|
||
# ---------- looks ----------
|
||
if [ -t 1 ]; then
|
||
B=$'\e[1m'; D=$'\e[90m'; A=$'\e[36m'; G=$'\e[32m'; Y=$'\e[33m'; R=$'\e[31m'; N=$'\e[0m'
|
||
else
|
||
B=""; D=""; A=""; G=""; Y=""; R=""; N=""
|
||
fi
|
||
STEPS=("檢查" "網址" "帳號" "啟動")
|
||
AT=0
|
||
|
||
header() {
|
||
printf '\e[2J\e[H\n'
|
||
printf ' %s◆%s %sAnyBot 伺服器設定%s ' "$A" "$N" "$B" "$N"
|
||
local i
|
||
for i in "${!STEPS[@]}"; do
|
||
[ "$i" -gt 0 ] && printf '%s ─ %s' "$D" "$N"
|
||
if [ "$i" -lt "$AT" ]; then printf '%s✓ %s%s' "$G" "${STEPS[$i]}" "$N"
|
||
elif [ "$i" -eq "$AT" ]; then printf '%s%s%d %s%s' "$A" "$B" $((i + 1)) "${STEPS[$i]}" "$N"
|
||
else printf '%s%d %s%s' "$D" $((i + 1)) "${STEPS[$i]}" "$N"; fi
|
||
done
|
||
printf '\n\n'
|
||
}
|
||
# A card: title, then lines (each printed after the left edge).
|
||
card_open() { printf ' %s╭─%s %s%s%s\n %s│%s\n' "$D" "$N" "$B" "$1" "$N" "$D" "$N"; }
|
||
line() { printf ' %s│%s %s\n' "$D" "$N" "$*"; }
|
||
note() { printf ' %s│%s %s%s%s\n' "$D" "$N" "$D" "$*" "$N"; }
|
||
ok() { printf ' %s│%s %s✓%s %s\n' "$D" "$N" "$G" "$N" "$*"; }
|
||
warn() { WARNED=1; printf ' %s│%s %s!%s %s\n' "$D" "$N" "$Y" "$N" "$*"; }
|
||
bad() { printf ' %s│%s %s✗ %s%s\n' "$D" "$N" "$R" "$*" "$N"; }
|
||
card_close() { printf ' %s│%s\n %s╰────────────────────────────────────────────────%s\n' "$D" "$N" "$D" "$N"; [ -n "${1:-}" ] && printf ' %s%s%s\n' "$D" "$1" "$N"; }
|
||
|
||
# Pad a label to 10 columns (Chinese characters are two columns wide).
|
||
padl() {
|
||
local w
|
||
w=$(printf '%s' "$1" | LC_ALL=C.UTF-8 wc -L 2>/dev/null || printf '%s' "$1" | wc -m)
|
||
printf '%s%*s' "$1" $((10 - w > 1 ? 10 - w : 1)) ""
|
||
}
|
||
|
||
# ask VAR "標籤" [default] [secret]: once answered, the line is redrawn without the marker.
|
||
ask() {
|
||
local __v=$1 label=$2 def=${3:-} secret=${4:-} got shown
|
||
printf ' %s│%s %s❯%s %s%s%s' "$D" "$N" "$A" "$N" "$A" "$(padl "$label")" "$N"
|
||
if [ -n "$secret" ]; then
|
||
IFS= read -rs got; printf '\n'
|
||
shown=$(printf '%s' "$got" | sed 's/./●/g')
|
||
else
|
||
IFS= read -r -e -i "$def" got || true
|
||
shown=$got
|
||
fi
|
||
printf '\e[1A\r\e[K %s│%s %s%s%s%s\n' "$D" "$N" "$D" "$(padl "$label")" "$N" "$shown"
|
||
printf -v "$__v" '%s' "$got"
|
||
}
|
||
|
||
# choose VAR "問題" "選項|說明" ... (↑↓ and Enter)
|
||
choose() {
|
||
# Without questions: the first choice (keep the setup, use Tailscale).
|
||
if [ "$AUTO" = 1 ]; then printf -v "$1" '%s' 0; return; fi
|
||
local __v=$1 title=$2; shift 2
|
||
local opts=("$@") sel=0 n=$# key rest i
|
||
while :; do
|
||
header
|
||
card_open "$title"
|
||
for i in "${!opts[@]}"; do
|
||
local name=${opts[$i]%%|*} desc=${opts[$i]#*|}
|
||
if [ "$i" -eq "$sel" ]; then line "${A}❯ ● $B$name$N"; else line " ${D}○$N $name"; fi
|
||
[ "$desc" != "${opts[$i]}" ] && [ -n "$desc" ] && note " $desc"
|
||
line ""
|
||
done
|
||
card_close "↑↓ 選擇 Enter 確定"
|
||
IFS= read -rsn1 key
|
||
if [ "$key" = $'\e' ]; then
|
||
IFS= read -rsn2 -t 0.05 rest || true
|
||
case "$rest" in "[A") sel=$(((sel + n - 1) % n)) ;; "[B") sel=$(((sel + 1) % n)) ;; esac
|
||
elif [ "$key" = "k" ]; then sel=$(((sel + n - 1) % n))
|
||
elif [ "$key" = "j" ]; then sel=$(((sel + 1) % n))
|
||
elif [ -z "$key" ]; then break
|
||
fi
|
||
done
|
||
printf -v "$__v" '%s' "$sel"
|
||
}
|
||
|
||
spin() { # spin "做什麼" command… : runs it, shows a spinner and the newest log line
|
||
local what=$1; shift
|
||
local log; log=$(mktemp)
|
||
"$@" >"$log" 2>&1 &
|
||
local pid=$! f=0 c=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
|
||
while kill -0 "$pid" 2>/dev/null; do
|
||
local last; last=$(tail -n 1 "$log" | tr -d '\r' | cut -c1-60)
|
||
printf '\r\e[K %s│%s %s%s%s %s %s%s%s' "$D" "$N" "$A" "${c[$((f % 10))]}" "$N" "$what" "$D" "$last" "$N"
|
||
f=$((f + 1)); sleep 0.15
|
||
done
|
||
wait "$pid"; local rc=$?
|
||
printf '\r\e[K'
|
||
if [ $rc -eq 0 ]; then ok "$what"; else bad "$what 失敗:"; tail -n 8 "$log" | while IFS= read -r l; do note " $l"; done; fi
|
||
rm -f "$log"
|
||
return $rc
|
||
}
|
||
|
||
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"
|
||
}
|
||
# The socket docker-socket-proxy has to mount: the one of the daemon this script runs compose
|
||
# against. Only rootless Docker needs it spelled out (/run/user/<uid>/docker.sock): there the
|
||
# compose default /var/run/docker.sock is another daemon (the root one, or none) and every Docker
|
||
# call of the brain comes back 503. Prints nothing when the default is right.
|
||
rootless_socket() {
|
||
docker info --format '{{json .SecurityOptions}}' 2>/dev/null | grep -q 'name=rootless' || return 0
|
||
local h=${DOCKER_HOST:-}
|
||
[ -z "$h" ] && h=$(docker context inspect --format '{{.Endpoints.docker.Host}}' 2>/dev/null)
|
||
case "$h" in unix://*) printf '%s' "${h#unix://}" ;; esac
|
||
}
|
||
|
||
# ---------- 0. this server ----------
|
||
AT=0; header; card_open "檢查這台伺服器"
|
||
FAIL=0; WARNED=0
|
||
if command -v docker >/dev/null; then ok "Docker 已安裝"; else bad "沒有 Docker:curl -fsSL https://get.docker.com | sh"; 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 command -v ss >/dev/null; then
|
||
# (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)
|
||
[ "${mem:-0}" -ge 2 ] && ok "記憶體 ${mem} GB" || warn "記憶體只有 ${mem:-?} GB(建議 2 GB 以上,第一次建置會比較久)"
|
||
disk=$(df -Pk . 2>/dev/null | awk 'NR==2 {print int($4/1024/1024)}')
|
||
[ "${disk:-0}" -ge 10 ] && ok "硬碟還有 ${disk} GB" || warn "硬碟只剩 ${disk:-?} GB(建議 10 GB 以上)"
|
||
if [ $FAIL = 1 ] && [ $DRY = 0 ]; then note ""; note "修好上面的問題後再執行一次 ./setup.sh"; die; fi
|
||
# All fine: go on by itself.
|
||
if [ $WARNED = 1 ]; then card_close; pause; else card_close; sleep 1; fi
|
||
|
||
# Keep the current settings?
|
||
if [ -f .env ] && [ -f Caddyfile ]; then
|
||
# shellcheck disable=SC1091
|
||
set -a; . ./.env; set +a
|
||
choose KEEP "這台已經設定過了(${ANYBOT_RELAY_PUBLIC_URL:-})" \
|
||
"用現有設定,更新並重新啟動|拉下最新版、重建、重新啟動,帳號和 Bot 都不變" \
|
||
"重新設定|網址、帳號或金鑰要改"
|
||
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; 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
|
||
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" \
|
||
"用自己的網域|任何地方都連得到(網域要先指到這台)"
|
||
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 憑證會自動申請、自動更新。"
|
||
line ""
|
||
[ -n "$MSG" ] && { printf '%s\n' "$MSG"; line ""; }
|
||
ask DOMAIN "網域" "${DOMAIN:-}"
|
||
DOMAIN=${DOMAIN#https://}; DOMAIN=${DOMAIN#http://}; DOMAIN=${DOMAIN%%/*}
|
||
if ! printf '%s' "$DOMAIN" | grep -Eq '^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'; then MSG=$(bad "請輸入像 anybot.example.com 這樣的網域"); continue; fi
|
||
ips=$(getent ahosts "$DOMAIN" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ')
|
||
if [ -z "$ips" ]; then
|
||
card_close
|
||
choose GO "「${DOMAIN}」還沒有指到任何位址" "重新輸入|" "仍然繼續|DNS 剛設好、還沒生效時可以先繼續;生效後憑證會自動申請"
|
||
[ "$GO" = 0 ] && { MSG=""; continue; }
|
||
else
|
||
mine=$(hostname -I 2>/dev/null || true)
|
||
hit=0; for ip in $ips; do case " $mine " in *" $ip "*) hit=1 ;; esac; done
|
||
if [ $hit = 1 ]; then MSG=$(ok "$DOMAIN → ${ips}(就是這台)"); else MSG=$(warn "$DOMAIN → ${ips}(這台的位址:${mine:-不知道};在 NAT 或雲端主機後面時這是正常的)"); fi
|
||
header; card_open "手機和其他電腦用哪個網址連到這台?"; printf '%s\n' "$MSG"; card_close; sleep 1
|
||
fi
|
||
break
|
||
done
|
||
|
||
[ "$TS" = 2 ] && TS=0
|
||
# ---------- 2. the account ----------
|
||
AT=2; MSG=""
|
||
if [ "$AUTO" = 1 ]; then USERNAME=$ANYBOT_SETUP_USER; PW1=$ANYBOT_SETUP_PASSWORD; BRAIN="雲端大腦"; fi
|
||
while [ "$AUTO" = 0 ]; do
|
||
header; card_open "你的 AnyBot 帳號"
|
||
note "手機、電腦都用這個帳號登入。第一次啟動時會自動建立。"
|
||
line ""
|
||
[ -n "$MSG" ] && { printf '%s\n' "$MSG"; line ""; }
|
||
ask USERNAME "帳號" "${USERNAME:-}"
|
||
if ! printf '%s' "$USERNAME" | grep -Eq '^[A-Za-z0-9._@-]{2,64}$'; then MSG=$(bad "帳號只能用英文字母、數字和 . _ @ -(2–64 個字)"); continue; fi
|
||
ask PW1 "密碼" "" secret
|
||
ask PW2 "再一次" "" secret
|
||
if [ "${#PW1}" -lt 8 ]; then MSG=$(bad "密碼至少 8 個字"); continue; fi
|
||
if [ "$PW1" != "$PW2" ]; then MSG=$(bad "兩次密碼不一樣"); continue; fi
|
||
case "$PW1" in *"'"*) MSG=$(bad "密碼裡不能有單引號 '"); continue ;; esac
|
||
BRAIN="雲端大腦"
|
||
break
|
||
done
|
||
# Only this account (others can be let in later: ANYBOT_RELAY_INVITE in .env). AI keys are set
|
||
# in the app (設定 › 模型); they stay on the brain.
|
||
INVITE=""; XAI=""; ANT=""; OAI=""; GEM=""; RESTORE=""
|
||
|
||
q() { printf "%s='%s'\n" "$1" "$2"; }
|
||
umask 077
|
||
{
|
||
echo "# Written by setup.sh. Run ./setup.sh again to change."
|
||
q ANYBOT_USER "$USERNAME"
|
||
q ANYBOT_PASSWORD "$PW1"
|
||
q ANYBOT_HOST_NAME "$BRAIN"
|
||
q ANYBOT_RELAY_INVITE "$INVITE"
|
||
q ANYBOT_RELAY_PUBLIC_URL "https://$DOMAIN"
|
||
q ANYBOT_RESTORE_FROM_ACCOUNT "$RESTORE"
|
||
q XAI_API_KEY "$XAI"
|
||
q ANTHROPIC_API_KEY "$ANT"
|
||
q OPENAI_API_KEY "$OAI"
|
||
q GEMINI_API_KEY "$GEM"
|
||
[ "$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
|
||
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
|
||
|
||
# Rootless Docker: pin its socket in .env, whether .env was just written or kept from an earlier
|
||
# setup (one made before docker-socket-proxy came along has no DOCKER_SOCKET).
|
||
SOCK=$(rootless_socket)
|
||
if [ -n "$SOCK" ]; then
|
||
if ! grep -qxF "DOCKER_SOCKET='$SOCK'" .env; then
|
||
(umask 077; { grep -v '^DOCKER_SOCKET=' .env; printf "DOCKER_SOCKET='%s'\n" "$SOCK"; } >.env.new) \
|
||
&& mv .env.new .env
|
||
fi
|
||
export DOCKER_SOCKET=$SOCK
|
||
fi
|
||
|
||
# ---------- 4. start ----------
|
||
AT=3; header; card_open "啟動"
|
||
ok "寫好設定(.env、Caddyfile)"
|
||
[ -n "$SOCK" ] && ok "Docker 是 rootless:大腦經由 $SOCK 管 Bot 的電腦"
|
||
if [ $DRY = 1 ]; then
|
||
warn "試跑(--dry-run):沒有啟動。要啟動:docker compose up -d --build"
|
||
card_close; exit 0
|
||
fi
|
||
if [ "${SKIP:-0}" = 1 ] && [ -d ../../.git ] && command -v git >/dev/null; then
|
||
spin "拉下最新版" git -C ../.. pull --ff-only || true
|
||
fi
|
||
# Small servers run out of memory compiling many crates at once: one compile per 2 GB.
|
||
if [ -z "${ANYBOT_BUILD_JOBS:-}" ] && [ -r /proc/meminfo ]; then
|
||
mem=$(awk '/MemTotal/ {m=$2} /SwapTotal/ {s=$2} END {print int((m + s / 2) / 1024)}' /proc/meminfo)
|
||
j=$((mem / 2000)); [ "$j" -lt 1 ] && j=1
|
||
export ANYBOT_BUILD_JOBS=$j
|
||
[ "$mem" -lt 3000 ] && warn "記憶體只有 ${mem} MB:編譯會比較久。被中斷的話先加 2 GB swap(見 docs/install.md)"
|
||
fi
|
||
spin "建置並啟動(第一次約 5–15 分鐘)" docker compose up -d --build || die
|
||
[ "${ANYBOT_TAILSCALE:-}" = 1 ] && TS=1
|
||
if [ "${TS:-0}" = 1 ]; then
|
||
# Tailscale gives the address HTTPS and passes it to the account server (remembered across reboots).
|
||
if tailscale serve --bg --https=443 http://127.0.0.1:8780 >/dev/null 2>&1 \
|
||
|| sudo tailscale serve --bg --https=443 http://127.0.0.1:8780 >/dev/null 2>&1; then
|
||
ok "Tailscale:https://$DOMAIN → 帳號伺服器"
|
||
else
|
||
bad "沒辦法設定 tailscale serve。請執行:sudo tailscale serve --bg --https=443 http://127.0.0.1:8780"
|
||
fi
|
||
fi
|
||
|
||
# The brain joins the account (and makes it, the first time).
|
||
f=0; c=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"); joined=""
|
||
for _ in $(seq 1 600); do
|
||
logs=$(docker compose logs --no-color anybot 2>/dev/null | tail -n 40)
|
||
if printf '%s' "$logs" | grep -q "透過帳號"; then joined=1; break; fi
|
||
printf '\r\e[K %s│%s %s%s%s 大腦正在登入帳號…' "$D" "$N" "$A" "${c[$((f % 10))]}" "$N"; f=$((f + 1)); sleep 0.3
|
||
done
|
||
printf '\r\e[K'
|
||
if [ -n "$joined" ]; then
|
||
printf '%s' "$logs" | grep -q "已建立帳號" && ok "建立帳號 ${USERNAME}" || ok "大腦「${BRAIN}」已登入帳號"
|
||
else
|
||
bad "大腦沒有登入帳號。最近的紀錄:"
|
||
printf '%s\n' "$logs" | grep "AnyBot:" | tail -n 4 | while IFS= read -r l; do note " ${l#*| }"; done
|
||
fi
|
||
|
||
# HTTPS from outside (the certificate can take a minute).
|
||
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 右上 ⋯ › 加一台電腦,把那一行貼到那台的終端機"
|
||
line ""
|
||
note "看紀錄 docker compose logs -f anybot"
|
||
note "更新 ./setup.sh(選「用現有設定」)"
|
||
card_close
|
||
printf '\n'
|