Files
VoCat/scripts/install.sh
T

494 lines
18 KiB
Bash
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
#
# vocat install / update script for systemd and OpenWrt/procd deployments.
#
# Usage:
# bash install.sh [version] # run directly when already root
# sudo bash install.sh [version] # run through sudo as a normal user
# bash install.sh --check-env # check VoWiFi host prerequisites
#
# Behavior:
# - Prompts for script language (中文 / English) as soon as it runs.
# - If the installed version equals the target version, does nothing (unless --force).
# - On first install, generates a random 32-char admin password, writes it to
# /etc/vocat/env (0600, loaded by the systemd unit), and prints it ONCE.
# - On update, preserves the existing env file and credentials.
# - Verifies Linux XFRM/IPsec support required by IMS; on OpenWrt it tries
# the matching opkg packages first.
# - (Re)writes a systemd or OpenWrt/procd service and restarts it.
#
# Published script: must contain no secrets, IPs, or passwords.
set -euo pipefail
# --- Publisher configuration -------------------------------------------------
# Default GitHub repository in owner/name form. Publishers: set this to your
# own repo, or override per-run with VOCAT_REPO.
REPO="${VOCAT_REPO:-MengMengCode/VoCat}"
INSTALL_DIR="/opt/vocat/bin"
BINARY_PATH="${INSTALL_DIR}/vocat"
LINK_PATH="/usr/local/bin/vocat"
ENV_DIR="/etc/vocat"
ENV_FILE="${ENV_DIR}/env"
UNIT_PATH="/etc/systemd/system/vocat.service"
OPENWRT_INIT_PATH="/etc/init.d/vocat"
# --- Language ----------------------------------------------------------------
LANG_CHOICE=""
msg() {
# $1 = zh text, $2 = en text
if [ "$LANG_CHOICE" = "en" ]; then
printf '%s\n' "$2"
else
printf '%s\n' "$1"
fi
}
prompt_language() {
if ! ( : </dev/tty ) 2>/dev/null; then
case "${VOCAT_LANG:-en}" in
zh|zh-CN|cn) LANG_CHOICE="zh" ;;
*) LANG_CHOICE="en" ;;
esac
return
fi
while true; do
echo "选择语言 / Select language: 1) 中文 2) English" >/dev/tty
printf '> ' >/dev/tty
if ! read -r choice </dev/tty; then
LANG_CHOICE="en"
return
fi
case "$choice" in
1|"") LANG_CHOICE="zh"; return ;;
2) LANG_CHOICE="en"; return ;;
esac
done
}
die() {
msg "$1" "$2" >&2
exit 1
}
# --- Root --------------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "请以 root 身份运行此脚本。" "Run this script as root."
prompt_language
# BusyBox/OpenWrt images often omit coreutils' install(1). Provide the small
# subset used by this script so the same installer works on router firmware.
if ! command -v install >/dev/null 2>&1; then
install() {
if [ "${1:-}" = "-d" ]; then
shift
local mode="0755"
if [ "${1:-}" = "-m" ]; then
mode="$2"
shift 2
fi
mkdir -p "$@"
chmod "$mode" "$@"
return
fi
local mode="0755"
if [ "${1:-}" = "-m" ]; then
mode="$2"
shift 2
fi
[ "$#" -eq 2 ] || return 2
cp "$1" "$2"
chmod "$mode" "$2"
}
fi
# --- Parse args --------------------------------------------------------------
FORCE=0
CHECK_ENV=0
SKIP_VOWIFI_CHECK="${VOCAT_SKIP_VOWIFI_CHECK:-0}"
TARGET_VERSION=""
for arg in "$@"; do
case "$arg" in
--force) FORCE=1 ;;
--check-env) CHECK_ENV=1 ;;
--skip-vowifi-check) SKIP_VOWIFI_CHECK=1 ;;
-h|--help)
msg "用法: bash install.sh [--force] [--check-env] [--skip-vowifi-check] [版本]" "Usage: bash install.sh [--force] [--check-env] [--skip-vowifi-check] [version]"
exit 0
;;
*) TARGET_VERSION="${arg#v}" ;;
esac
done
# --- Resolve target version --------------------------------------------------
resolve_target_version() {
if [ -n "$TARGET_VERSION" ]; then
TARGET_VERSION="${TARGET_VERSION#v}"
return
fi
local api_url="https://api.github.com/repos/${REPO}/releases/latest"
local auth_hdr=()
if [ -n "${GITHUB_TOKEN:-}" ]; then
auth_hdr=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
fi
local resp
resp=$(curl -fsSL "${auth_hdr[@]}" "$api_url") || die "无法获取最新版本信息。检查网络或 REPO 设置。" "Failed to fetch latest release. Check network or REPO."
# Parse "tag_name": "vX.Y.Z" without jq.
local tag
tag=$(printf '%s\n' "$resp" | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
[ -n "$tag" ] || die "无法解析最新版本的 tag_name。" "Could not parse tag_name from the release response."
TARGET_VERSION="${tag#v}"
}
# --- Host prerequisites ------------------------------------------------------
is_openwrt() {
[ -f /etc/openwrt_release ] || [ -x /sbin/procd ]
}
xfrm_works() {
command -v ip >/dev/null 2>&1 && ip xfrm state list >/dev/null 2>&1
}
opkg_has_package() {
opkg list "$1" 2>/dev/null | grep -q "^$1 -"
}
install_openwrt_vowifi_packages() {
msg "正在检查 OpenWrt/Kwrt 的 VoWiFi 内核组件..." "Checking OpenWrt/Kwrt VoWiFi kernel components..."
opkg update >/dev/null 2>&1 || msg \
"警告:opkg 软件源更新失败,将使用现有索引继续检查。" \
"Warning: opkg feed update failed; checking the existing index."
local packages=""
local package
for package in \
ip-full \
kmod-ipsec kmod-ipsec4 kmod-ipsec6 \
kmod-crypto-authenc kmod-crypto-cbc kmod-crypto-aes \
kmod-crypto-hmac kmod-crypto-sha1; do
if opkg_has_package "$package"; then
packages="$packages $package"
fi
done
if [ -n "$packages" ]; then
# Kernel packages must come from this firmware's own feed. opkg checks
# the kernel ABI and refuses mismatched modules; never bypass that check.
# shellcheck disable=SC2086
opkg install $packages >/dev/null 2>&1 || true
fi
}
install_linux_ip_tool() {
command -v ip >/dev/null 2>&1 && return 0
if command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y iproute2
elif command -v dnf >/dev/null 2>&1; then
dnf install -y iproute
elif command -v yum >/dev/null 2>&1; then
yum install -y iproute
elif command -v pacman >/dev/null 2>&1; then
pacman -Sy --noconfirm iproute2
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache iproute2
fi
}
check_vowifi_environment() {
if [ "$SKIP_VOWIFI_CHECK" = "1" ]; then
msg \
"已跳过 VoWiFi 内核环境检查;IMS 通话和短信可能不可用。" \
"Skipped the VoWiFi kernel check; IMS calls and SMS may not work."
return
fi
if is_openwrt && command -v opkg >/dev/null 2>&1; then
# Install the crypto algorithms even when NETLINK_XFRM already works;
# some minimal images provide xfrm_user but omit AES-CBC/authenc.
install_openwrt_vowifi_packages
elif ! xfrm_works; then
install_linux_ip_tool
fi
if xfrm_works; then
msg "VoWiFi XFRM/IPsec 环境安装并验证成功。" "VoWiFi XFRM/IPsec environment installed and verified."
return
fi
if is_openwrt; then
die \
"当前 OpenWrt/Kwrt 内核 $(uname -r) 不支持 NETLINK_XFRM,且软件源没有匹配的 kmod-ipsec。请使用包含 kmod-ipsec、kmod-ipsec4、kmod-ipsec6、kmod-crypto-authenc、kmod-crypto-cbc、kmod-crypto-aes 和 kmod-crypto-sha1 的同版本固件;严禁安装其他内核版本的 kmod。仅使用非 VoWiFi 功能时可加 --skip-vowifi-check。" \
"The OpenWrt/Kwrt kernel $(uname -r) lacks NETLINK_XFRM and its feed has no matching kmod-ipsec. Use a firmware built with matching kmod-ipsec, kmod-ipsec4/6, crypto-authenc, CBC, AES and SHA1 modules. Never force kmods from another kernel. Use --skip-vowifi-check only for non-VoWiFi operation."
fi
die \
"当前 Linux 内核不支持 XFRM/IPsecVoWiFi IMS 无法工作。请启用 CONFIG_XFRM、CONFIG_XFRM_USER、CONFIG_INET_ESP、CONFIG_INET6_ESP、AES-CBC 和 HMAC-SHA1。" \
"This Linux kernel lacks XFRM/IPsec required by VoWiFi IMS. Enable CONFIG_XFRM, CONFIG_XFRM_USER, CONFIG_INET_ESP, CONFIG_INET6_ESP, AES-CBC and HMAC-SHA1."
}
# --- Skip if already installed at the same version ---------------------------
skip_if_equal() {
[ -x "$BINARY_PATH" ] || return 0
[ "$FORCE" -eq 1 ] && return 0
local installed
installed=$("$BINARY_PATH" version 2>/dev/null | awk '{print $2}' | sed -E 's/[[:space:]]*\(.*$//') || return 0
[ -z "$installed" ] && return 0
if [ "$installed" = "$TARGET_VERSION" ]; then
install -d -m 0755 "$(dirname "$LINK_PATH")"
ln -sfn "$BINARY_PATH" "$LINK_PATH"
msg "已安装版本 $installed,与目标版本相同,跳过更新。" "Installed version $installed equals target; skipping."
exit 0
fi
msg "当前 $installed -> $TARGET_VERSION,开始更新。" "Updating $installed -> $TARGET_VERSION."
}
# --- Detect architecture -----------------------------------------------------
detect_arch() {
ARCH_FALLBACK=""
case "$(uname -m)" in
x86_64) ARCH="amd64" ;;
i386|i486|i586|i686) ARCH="386" ;;
aarch64) ARCH="aarch64"; ARCH_FALLBACK="arm64" ;;
arm64) ARCH="arm64"; ARCH_FALLBACK="aarch64" ;;
armv7l|armv7*) ARCH="armv7" ;;
*) die "不支持的架构: $(uname -m)" "Unsupported architecture: $(uname -m)" ;;
esac
}
# --- Download + verify -------------------------------------------------------
VOCAT_TMP=""
download_and_verify() {
VOCAT_TMP=$(mktemp -d)
trap 'rm -rf "$VOCAT_TMP"' EXIT
local base="https://github.com/${REPO}/releases/download/v${TARGET_VERSION}"
local asset="vocat-linux-${ARCH}"
if [ -n "$ARCH_FALLBACK" ] && ! curl -fsIL -o /dev/null "${base}/${asset}"; then
asset="vocat-linux-${ARCH_FALLBACK}"
fi
msg "下载 $asset ..." "Downloading $asset ..."
curl -fsSL -o "${VOCAT_TMP}/vocat" "${base}/${asset}" || die "下载二进制失败。" "Failed to download the binary."
curl -fsSL -o "${VOCAT_TMP}/SHA256SUMS" "${base}/SHA256SUMS" || die "下载 SHA256SUMS 失败。" "Failed to download SHA256SUMS."
local expected actual
# Match a line whose filename field equals the asset (with optional binary-mode * prefix).
expected=$(awk -v a="$asset" '$2 == a || $2 == ("*" a) {print $1; exit}' "${VOCAT_TMP}/SHA256SUMS")
[ -n "$expected" ] || die "SHA256SUMS 中找不到 $asset 的校验行。" "$asset not found in SHA256SUMS."
actual=$(sha256sum "${VOCAT_TMP}/vocat" | awk '{print $1}')
[ "$actual" = "$expected" ] || die "SHA-256 校验失败。" "SHA-256 verification failed."
chmod 0755 "${VOCAT_TMP}/vocat"
"${VOCAT_TMP}/vocat" version >/dev/null 2>&1 || die \
"Downloaded binary cannot run on this system; keeping the installed version." \
"The downloaded binary cannot run on this host; the installed version was not changed."
}
# --- Install binary ----------------------------------------------------------
install_binary() {
install -d -m 0755 "$INSTALL_DIR"
install -m 0755 "${VOCAT_TMP}/vocat" "${BINARY_PATH}.new"
if [ -e "$BINARY_PATH" ]; then
cp -a "$BINARY_PATH" "${BINARY_PATH}.bak"
fi
mv -f "${BINARY_PATH}.new" "$BINARY_PATH"
install -d -m 0755 "$(dirname "$LINK_PATH")"
ln -sfn "$BINARY_PATH" "$LINK_PATH"
}
# --- Data directory ----------------------------------------------------------
ensure_data_dir() {
install -d -m 0755 /opt/vocat/data
chown -R root:root /opt/vocat
}
# --- Env file (first install only) -------------------------------------------
# Generates a random 32-char secret, stores it in the 0600 env file, and flags
# FIRST_INSTALL so we can print the secret once at the end.
FIRST_INSTALL=0
setup_env() {
if [ -f "$ENV_FILE" ]; then
return
fi
install -d -m 0755 "$ENV_DIR"
local secret
secret=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')
[ -n "$secret" ] || die "生成随机密钥失败。" "Failed to generate a random secret."
printf 'VOCAT_ADMIN_PASSWORD=%s\n' "$secret" > "$ENV_FILE"
chmod 0600 "$ENV_FILE"
FIRST_INSTALL=1
}
# --- systemd unit ------------------------------------------------------------
write_unit() {
cat > "$UNIT_PATH" <<EOF
[Unit]
Description=vocat cellular and VoWiFi control service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/opt/vocat
EnvironmentFile=${ENV_FILE}
Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
ExecStart=${BINARY_PATH}
Restart=on-failure
RestartSec=3s
TimeoutStartSec=30s
# HTTP, VoWiFi, and modem cleanup have bounded shutdown contexts totalling up
# to 30 seconds. Leave a small margin before systemd resorts to SIGKILL.
TimeoutStopSec=40s
RuntimeDirectory=vocat
RuntimeDirectoryMode=0755
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=false
ProtectSystem=strict
ProtectHome=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectControlGroups=true
# The web/CLI self-updater verifies a release in this directory and atomically
# renames it over the running binary. Keep the rest of the host read-only.
ReadWritePaths=/opt/vocat/data /opt/vocat/bin
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK AF_PACKET
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
UMask=0077
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
EOF
chmod 0644 "$UNIT_PATH"
}
write_openwrt_init() {
cat > "$OPENWRT_INIT_PATH" <<'EOF'
#!/bin/sh /etc/rc.common
START=95
STOP=10
USE_PROCD=1
PROCD_TERM_TIMEOUT=40
PROGRAM=/opt/vocat/bin/vocat
ENV_FILE=/etc/vocat/env
start_service() {
procd_open_instance
procd_set_param command "$PROGRAM" serve
procd_set_param env VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
if [ -r "$ENV_FILE" ]; then
while IFS='=' read -r name value; do
case "$name" in VOCAT_*) procd_append_param env "$name=$value" ;; esac
done < "$ENV_FILE"
fi
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
service_triggers() { procd_add_reload_trigger vocat; }
EOF
chmod 0755 "$OPENWRT_INIT_PATH"
}
write_service() {
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
write_unit
return
fi
if [ -x /sbin/procd ] || [ -x /sbin/ubusd ]; then
write_openwrt_init
return
fi
die "Unsupported service manager." "Neither systemd nor OpenWrt procd was detected."
}
enable_and_start() {
if [ -x "$OPENWRT_INIT_PATH" ] && { [ -x /sbin/procd ] || [ -x /sbin/ubusd ]; }; then
"$OPENWRT_INIT_PATH" enable
if "$OPENWRT_INIT_PATH" restart; then
# Modems may need several seconds to release and reopen their AT
# port after procd stops the previous process. Require consecutive
# healthy observations so a short-lived respawn is not mistaken for
# a successful upgrade.
local attempt stable
stable=0
for attempt in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30; do
sleep 1
if "$OPENWRT_INIT_PATH" running; then
stable=$((stable + 1))
if [ "$stable" -ge 3 ]; then
rm -f "${BINARY_PATH}.bak"
return
fi
else
stable=0
fi
done
fi
if [ -e "${BINARY_PATH}.bak" ]; then
cp -a "${BINARY_PATH}.bak" "$BINARY_PATH"
"$OPENWRT_INIT_PATH" restart || true
fi
die "OpenWrt vocat service failed to start." "The OpenWrt vocat service failed to start."
fi
systemctl daemon-reload
systemctl enable vocat
if systemctl restart vocat; then
local attempt
for attempt in 1 2 3 4 5; do
if systemctl is-active --quiet vocat; then
rm -f "${BINARY_PATH}.bak"
return
fi
sleep 1
done
fi
if [ -e "${BINARY_PATH}.bak" ]; then
msg "新版本启动失败,正在恢复旧二进制。" "The new version failed to start; restoring the previous binary."
cp -a "${BINARY_PATH}.bak" "$BINARY_PATH"
systemctl restart vocat || true
fi
die "vocat 服务启动失败。" "The vocat service failed to start."
}
# --- Main --------------------------------------------------------------------
detect_arch
check_vowifi_environment
if [ "$CHECK_ENV" -eq 1 ]; then
msg "VoCat 运行环境检查完成。" "VoCat host environment check completed."
exit 0
fi
resolve_target_version
skip_if_equal
download_and_verify
install_binary
ensure_data_dir
setup_env
write_service
enable_and_start
if [ "$FIRST_INSTALL" -eq 1 ]; then
secret=$(grep -E '^VOCAT_ADMIN_PASSWORD=' "$ENV_FILE" | cut -d= -f2-)
echo
msg "================ 安装完成 ================" "================ Install complete ================"
msg "首次安装已生成管理员初始密码 (仅显示一次):" "First-install admin password (shown once):"
echo
echo " $secret"
echo
msg "用户名为 admin。请立即记录此密码。" "Username is admin. Record this password now."
msg "登录后或运行以下命令修改密码:" "Change it via the web UI or run:"
echo " vocat menu"
msg "==========================================" "=============================================="
else
echo
msg "================ 更新完成 ================" "================ Update complete ================"
msg "已更新到 $TARGET_VERSION,服务已重启。" "Updated to $TARGET_VERSION; service restarted."
msg "管理员密码保持不变。" "Admin password unchanged."
msg "==========================================" "=============================================="
fi