AnyBot/deploy/server/setup.sh

302 lines
15 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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.
# 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; }
# ---------- 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
busy=$(ss -ltnH 2>/dev/null | awk '{print $4}' | grep -E ':(80|443)$' | grep -v docker || 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:-}
AT=3; SKIP=1
fi
fi
if [ "${SKIP:-0}" != 1 ]; then
# ---------- 1. the address ----------
AT=1; MSG=""; TS=0
# 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
fi
if [ -n "$TSNAME" ]; then
choose HOW "手機和其他電腦用哪個網址連到這台?" \
"用 Tailscale:https://$TSNAME|只有你 Tailscale 裡的手機和電腦連得到。不用網域,也不用開 80、443 埠" \
"用自己的網域|任何地方都連得到(網域要先指到這台)"
if [ "$HOW" = 0 ]; then DOMAIN=$TSNAME; TS=1; fi
fi
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"
if [ "$TS" = 1 ]; then
q ANYBOT_TAILSCALE 1
q COMPOSE_FILE "docker-compose.yml:docker-compose.tailscale.yml"
fi
} >.env
chmod 600 .env
umask 022
sed "s/^anybot.example.com {/$DOMAIN {/" ../../relay/Caddyfile >Caddyfile
fi
# ---------- 4. start ----------
AT=3; header; card_open "啟動"
ok "寫好設定(.env、Caddyfile)"
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).
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
card_close
printf '\n'
card_open "$([ -n "$joined" ] && echo "完成了" || echo "差一點")"
line "${B}手機${N} 打開 ${A}https://$DOMAIN${N},用 ${B}$USERNAME${N} 登入,可以「加入主畫面」"
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'