Files
Aether/install.sh
T

3020 lines
98 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
set -euo pipefail
REPO="${AETHER_REPO:-fawney19/Aether}"
2026-05-15 01:21:08 +08:00
SOURCE_REF="${AETHER_SOURCE_REF:-main}"
SOURCE_REF_EXPLICIT="false"
if [[ -n "${AETHER_SOURCE_REF:-}" ]]; then
SOURCE_REF_EXPLICIT="true"
fi
VERSION="${AETHER_VERSION:-}"
CHANNEL="${AETHER_CHANNEL:-stable}"
CHANNEL_EXPLICIT="false"
if [[ -n "${AETHER_CHANNEL:-}" ]]; then
CHANNEL_EXPLICIT="true"
fi
MODE="${AETHER_INSTALL_MODE:-auto}"
2026-05-15 18:43:47 +08:00
INSTALL_ROOT_EXPLICIT="false"
if [[ -n "${INSTALL_ROOT:-}" ]]; then
INSTALL_ROOT_EXPLICIT="true"
fi
INSTALL_ROOT="${INSTALL_ROOT:-/opt/aether}"
CONFIG_DIR="${CONFIG_DIR:-/etc/aether}"
2026-05-15 18:43:47 +08:00
COMPOSE_DIR="${AETHER_COMPOSE_DIR:-}"
COMPOSE_DIR_EXPLICIT="false"
if [[ -n "${AETHER_COMPOSE_DIR:-}" ]]; then
COMPOSE_DIR_EXPLICIT="true"
fi
IMAGE_REPO="${AETHER_IMAGE_REPO:-ghcr.io/fawney19/aether}"
APP_IMAGE="${AETHER_APP_IMAGE:-}"
SERVICE_USER_EXPLICIT="false"
SERVICE_GROUP_EXPLICIT="false"
if [[ -n "${SERVICE_USER:-}" ]]; then
SERVICE_USER_EXPLICIT="true"
fi
if [[ -n "${SERVICE_GROUP:-}" ]]; then
SERVICE_GROUP_EXPLICIT="true"
fi
SERVICE_USER="${SERVICE_USER:-aether}"
SERVICE_GROUP="${SERVICE_GROUP:-aether}"
SERVICE_NAME="aether-gateway"
COMPOSE_RELEASE_BASE_DIR="/opt/aether"
COMPOSE_RELEASE_CURRENT_DIR="${COMPOSE_RELEASE_BASE_DIR}/current"
COMPOSE_RELEASE_FRONTEND_DIR="${COMPOSE_RELEASE_CURRENT_DIR}/frontend"
COMPOSE_RELEASE_LOG_DIR="${COMPOSE_RELEASE_BASE_DIR}/logs"
COMPOSE_RELEASE_SQLITE_DATABASE_URL="sqlite://${COMPOSE_RELEASE_BASE_DIR}/data/aether.db"
COMPOSE_LOG_DESTINATION_DEFAULT="stdout"
COMPOSE_LOG_FORMAT_DEFAULT="pretty"
COMPOSE_LOG_ROTATION_DEFAULT="daily"
COMPOSE_LOG_RETENTION_DAYS_DEFAULT="7"
COMPOSE_LOG_MAX_FILES_DEFAULT="30"
COMPOSE_APP_PORT_DEFAULT="8084"
COMPOSE_CONTAINER_UID_DEFAULT="65532"
COMPOSE_CONTAINER_GID_DEFAULT="65532"
COMPOSE_CONTAINER_UID=""
COMPOSE_CONTAINER_GID=""
COMPOSE_CLI=()
LAUNCHD_LABEL="${AETHER_LAUNCHD_LABEL:-com.aether.gateway}"
LAUNCHD_LOG_DIR="${AETHER_LAUNCHD_LOG_DIR:-/var/log/aether}"
ENV_TARGET="${CONFIG_DIR}/aether-gateway.env"
SYSTEMD_UNIT_PATH="/etc/systemd/system/${SERVICE_NAME}.service"
LAUNCHD_PLIST_PATH="/Library/LaunchDaemons/${LAUNCHD_LABEL}.plist"
TMP_ROOT=""
ARCHIVE_PATH=""
BUNDLE_DIR=""
ENV_SOURCE=""
SKIP_START="false"
GENERATED_ENV=""
ADMIN_PASSWORD_SOURCE=""
UI_LANG="${AETHER_LANG:-${AETHER_LANGUAGE:-auto}}"
RELEASE_KEEP="${AETHER_RELEASE_KEEP:-3}"
RELEASE_ARCHIVE_URL="${AETHER_RELEASE_ARCHIVE_URL:-${AETHER_DOWNLOAD_URL:-}}"
MAX_RELEASE_ARCHIVE_ENTRIES=100000
MAX_RELEASE_UNPACKED_BYTES=$((2 * 1024 * 1024 * 1024))
usage() {
cat <<'EOF'
Usage: install.sh [options]
Install Aether Gateway.
Options:
2026-05-16 15:22:42 +08:00
--mode MODE Deployment mode: compose, compose-single-node, or single-node
compose: Docker Compose app + Postgres + Redis
2026-05-16 15:22:42 +08:00
compose-single-node: Docker Compose single-node app
single-node: single-node system service
Linux services use systemd; macOS services use launchd
2026-09-01 16:43:29 +08:00
--channel CHANNEL Release channel to resolve when --version is omitted: stable, latest, rc, beta, or nightly
stable/latest resolves the latest stable tag (default)
rc resolves the latest tag like v0.7.0-rc.1
beta resolves the latest tag like v0.7.0-beta.1
2026-09-01 16:43:29 +08:00
nightly resolves the rolling nightly build from main
--version VERSION Exact release tag to install, for example v0.7.0-rc.1 or nightly
--repo OWNER/REPO GitHub repository to download from (default: fawney19/Aether)
2026-05-15 01:21:08 +08:00
--source-ref REF Source branch/tag used for compose templates (default: main)
--archive PATH Install from a local release tarball instead of downloading
--download-url URL Download the release archive from this URL instead of GitHub
--env-file PATH Use an existing aether-gateway.env file
2026-05-15 18:43:47 +08:00
--install-root PATH Install root for system service mode (default: /opt/aether)
Also makes the default Docker Compose directory PATH/compose
--compose-dir PATH Docker Compose deployment directory (default: current directory)
--config-dir PATH Config directory (default: /etc/aether)
--lang LANG Installer language: zh or en
2026-05-15 18:43:47 +08:00
--skip-start Install files, but do not start Docker Compose or restart the service
--keep-releases N Keep the latest N releases, prune older ones (default: 3, 0=disable)
-h, --help Show this help
Environment overrides:
AETHER_REPO, AETHER_SOURCE_REF, AETHER_INSTALL_MODE, AETHER_CHANNEL, AETHER_VERSION
AETHER_LANG or AETHER_LANGUAGE
AETHER_RELEASE_ARCHIVE_URL or AETHER_DOWNLOAD_URL
AETHER_LAUNCHD_LABEL, AETHER_LAUNCHD_LOG_DIR, AETHER_RELEASE_KEEP
AETHER_IMAGE_REPO, AETHER_APP_IMAGE
INSTALL_ROOT, AETHER_COMPOSE_DIR, CONFIG_DIR, SERVICE_USER, SERVICE_GROUP
ADMIN_PASSWORD (required for non-interactive first install when generating a new env)
EOF
}
die() {
if ui_is_zh; then
echo "错误: $*" >&2
else
echo "ERROR: $*" >&2
fi
exit 1
}
info() {
echo ">>> $*" >&2
}
warn() {
if ui_is_zh; then
echo "警告: $*" >&2
else
echo "WARNING: $*" >&2
fi
}
ui_is_zh() {
case "${UI_LANG}" in
zh|zh-*|cn|chinese|Chinese|中文)
return 0
;;
*)
return 1
;;
esac
}
interactive_tty_available() {
[[ -r /dev/tty && -w /dev/tty ]]
}
normalize_ui_lang() {
local value="$1"
value="$(printf '%s' "${value}" | tr '[:upper:]' '[:lower:]')"
case "${value}" in
zh|zh-cn|cn|chinese|中文)
echo "zh"
;;
en|en-us|english|英语)
echo "en"
;;
auto|"")
echo "auto"
;;
*)
die "unsupported installer language: ${value}; expected zh or en"
;;
esac
}
select_language() {
UI_LANG="$(normalize_ui_lang "${UI_LANG}")"
if [[ "${UI_LANG}" != "auto" ]]; then
return
fi
if interactive_tty_available; then
cat >/dev/tty <<'EOF'
请选择安装语言 / Choose installer language:
1) 中文
2026-05-16 15:22:42 +08:00
2) English
请输入选项 / Enter choice [1]:
EOF
local choice
IFS= read -r choice </dev/tty || choice=""
case "${choice:-1}" in
1)
UI_LANG="zh"
;;
2)
UI_LANG="en"
;;
*)
UI_LANG="zh"
die "无效的语言选项: ${choice}"
;;
esac
else
UI_LANG="en"
fi
}
cleanup() {
if [[ -n "${TMP_ROOT}" && -d "${TMP_ROOT}" ]]; then
rm -rf "${TMP_ROOT}"
fi
}
if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
trap cleanup EXIT
fi
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--mode)
[[ $# -ge 2 ]] || die "--mode requires a value"
MODE="$2"
shift 2
;;
--channel)
[[ $# -ge 2 ]] || die "--channel requires a value"
CHANNEL="$2"
CHANNEL_EXPLICIT="true"
shift 2
;;
--version)
[[ $# -ge 2 ]] || die "--version requires a value"
VERSION="$2"
shift 2
;;
--repo)
[[ $# -ge 2 ]] || die "--repo requires a value"
REPO="$2"
shift 2
;;
--source-ref)
[[ $# -ge 2 ]] || die "--source-ref requires a value"
SOURCE_REF="$2"
SOURCE_REF_EXPLICIT="true"
shift 2
;;
--archive)
[[ $# -ge 2 ]] || die "--archive requires a path"
ARCHIVE_PATH="$2"
shift 2
;;
--download-url|--archive-url|--release-url)
[[ $# -ge 2 ]] || die "--download-url requires a value"
RELEASE_ARCHIVE_URL="$2"
shift 2
;;
--env-file)
[[ $# -ge 2 ]] || die "--env-file requires a path"
ENV_SOURCE="$2"
shift 2
;;
--install-root)
[[ $# -ge 2 ]] || die "--install-root requires a path"
INSTALL_ROOT="$2"
2026-05-15 18:43:47 +08:00
INSTALL_ROOT_EXPLICIT="true"
shift 2
;;
--compose-dir)
[[ $# -ge 2 ]] || die "--compose-dir requires a path"
COMPOSE_DIR="$2"
2026-05-15 18:43:47 +08:00
COMPOSE_DIR_EXPLICIT="true"
shift 2
;;
--config-dir)
[[ $# -ge 2 ]] || die "--config-dir requires a path"
CONFIG_DIR="$2"
ENV_TARGET="${CONFIG_DIR}/aether-gateway.env"
shift 2
;;
--lang|--language)
[[ $# -ge 2 ]] || die "--lang requires a value"
UI_LANG="$2"
shift 2
;;
--skip-start)
SKIP_START="true"
shift
;;
--keep-releases)
[[ $# -ge 2 ]] || die "--keep-releases requires a number"
RELEASE_KEEP="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown argument: $1"
;;
esac
done
}
install_os() {
case "$(uname -s)" in
Linux)
echo "linux"
;;
Darwin)
echo "macos"
;;
*)
if ui_is_zh; then
die "Aether 二进制安装仅支持 Linux 和 macOS"
else
die "Aether binary install is only supported on Linux and macOS"
fi
;;
esac
}
is_darwin() {
[[ "$(install_os)" == "macos" ]]
}
apply_platform_defaults() {
if is_darwin; then
if [[ "${SERVICE_USER_EXPLICIT}" != "true" ]]; then
SERVICE_USER="_aether"
fi
if [[ "${SERVICE_GROUP_EXPLICIT}" != "true" ]]; then
SERVICE_GROUP="_aether"
fi
fi
}
require_supported_os() {
install_os >/dev/null
}
require_root() {
if [[ "${EUID}" -ne 0 ]]; then
if ui_is_zh; then
die "请使用 root 运行"
else
die "run as root"
fi
fi
}
require_systemd() {
if ! command -v systemctl >/dev/null 2>&1; then
if ui_is_zh; then
die "未找到 systemctl"
else
die "systemctl not found"
fi
fi
}
require_launchd() {
if ! command -v launchctl >/dev/null 2>&1; then
if ui_is_zh; then
die "未找到 launchctl"
else
die "launchctl not found"
fi
fi
}
require_service_manager() {
case "$(install_os)" in
linux)
require_systemd
;;
macos)
require_launchd
;;
esac
}
service_manager_name() {
case "$(install_os)" in
linux)
echo "systemd"
;;
macos)
echo "launchd"
;;
esac
}
select_version() {
if [[ -n "${VERSION}" || -n "${ARCHIVE_PATH}" || "${CHANNEL_EXPLICIT}" == "true" ]]; then
return
fi
if interactive_tty_available; then
if ui_is_zh; then
cat >/dev/tty <<'EOF'
请选择 Aether 版本:
1) 最新正式版
2) 最新 RC 预发布版
3) 最新 Beta 预发布版
2026-09-01 16:43:29 +08:00
4) 最新 nightly 构建版
5) 指定 tag,例如 v0.7.0-rc.1
请输入选项 [1]:
EOF
else
cat >/dev/tty <<'EOF'
Choose Aether version:
1) Latest stable release
2) Latest RC prerelease
3) Latest beta prerelease
2026-09-01 16:43:29 +08:00
4) Latest nightly build
5) Exact tag, for example v0.7.0-rc.1
Enter choice [1]:
EOF
fi
local choice
IFS= read -r choice </dev/tty || choice=""
case "${choice:-1}" in
1)
CHANNEL="stable"
;;
2)
CHANNEL="rc"
;;
3)
CHANNEL="beta"
;;
4)
2026-09-01 16:43:29 +08:00
CHANNEL="nightly"
;;
5)
if ui_is_zh; then
cat >/dev/tty <<'EOF'
请输入准确 tag:
EOF
else
cat >/dev/tty <<'EOF'
Enter exact tag:
EOF
fi
IFS= read -r VERSION </dev/tty || VERSION=""
if [[ -z "${VERSION}" ]]; then
if ui_is_zh; then
die "准确 tag 不能为空"
else
die "exact tag cannot be empty"
fi
fi
;;
*)
if ui_is_zh; then
die "无效的版本选项: ${choice}"
else
die "invalid version choice: ${choice}"
fi
;;
esac
fi
}
is_safe_release_identifier() {
local value="$1"
[[ -n "${value}" && ${#value} -le 128 ]] || return 1
[[ "${value}" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]]
}
validate_release_identifier() {
local value="$1"
is_safe_release_identifier "${value}" \
|| die "release version contains unsafe URL or filesystem characters"
}
validate_installer_source_identifiers() {
[[ ${#REPO} -le 200 && "${REPO}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$ ]] \
|| die "repository must be a safe GitHub OWNER/REPO identifier"
[[ -n "${SOURCE_REF}" && ${#SOURCE_REF} -le 240 ]] \
|| die "source ref must be a non-empty identifier of at most 240 characters"
[[ "${SOURCE_REF}" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \
|| die "source ref contains unsafe URL characters"
case "/${SOURCE_REF}/" in
*"//"*|*"/./"*|*"/../"*)
die "source ref contains an unsafe path component"
;;
esac
if [[ -n "${VERSION}" ]]; then
validate_release_identifier "${VERSION}"
fi
}
validate_service_account_identifier() {
local kind="$1"
local value="$2"
[[ -n "${value}" && ${#value} -le 64 ]] \
|| die "${kind} must be a non-empty account identifier of at most 64 characters"
[[ "${value}" =~ ^[A-Za-z_][A-Za-z0-9_.-]*\$?$ ]] \
|| die "${kind} contains unsafe account-name characters"
}
validate_launchd_label() {
local value="$1"
[[ -n "${value}" && ${#value} -le 128 \
&& "${value}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] \
|| die "launchd label contains unsafe filesystem or service-name characters"
}
validate_managed_absolute_path() {
local kind="$1"
local value="$2"
[[ "${value}" == /* && ${#value} -le 1024 ]] \
|| die "${kind} must be an absolute path of at most 1024 characters"
[[ "${value}" != *$'\n'* && "${value}" != *$'\r'* ]] \
|| die "${kind} may not contain line breaks"
[[ "${value}" =~ ^/[A-Za-z0-9._/+,-]+$ ]] \
|| die "${kind} contains characters unsafe for generated service files"
case "/${value#/}/" in
*"//"*|*"/./"*|*"/../"*)
die "${kind} contains an unsafe path component"
;;
esac
}
validate_single_node_managed_paths() {
validate_service_account_identifier "service user" "${SERVICE_USER}"
validate_service_account_identifier "service group" "${SERVICE_GROUP}"
validate_managed_absolute_path "install root" "${INSTALL_ROOT}"
validate_managed_absolute_path "config directory" "${CONFIG_DIR}"
validate_managed_absolute_path "env target" "${ENV_TARGET}"
validate_privileged_path_ancestor "${INSTALL_ROOT}"
validate_privileged_path_ancestor "${CONFIG_DIR}"
validate_managed_regular_file "${ENV_TARGET}" true
case "$(install_os)" in
linux)
validate_managed_absolute_path "systemd unit path" "${SYSTEMD_UNIT_PATH}"
validate_privileged_path_ancestor "${SYSTEMD_UNIT_PATH}"
validate_managed_regular_file "${SYSTEMD_UNIT_PATH}" true
;;
macos)
validate_launchd_label "${LAUNCHD_LABEL}"
validate_managed_absolute_path "launchd plist path" "${LAUNCHD_PLIST_PATH}"
validate_managed_absolute_path "launchd log directory" "${LAUNCHD_LOG_DIR}"
validate_privileged_path_ancestor "${LAUNCHD_PLIST_PATH}"
validate_privileged_path_ancestor "${LAUNCHD_LOG_DIR}"
validate_managed_regular_file "${LAUNCHD_PLIST_PATH}" true
validate_managed_regular_file "$(launchd_wrapper_path)" true
validate_managed_regular_file \
"${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.out.log" false
validate_managed_regular_file \
"${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.err.log" false
;;
esac
}
select_mode() {
case "${MODE}" in
compose|docker|docker-compose)
MODE="compose"
return
;;
2026-05-16 15:22:42 +08:00
compose-single-node|docker-single-node|docker-single-node-compose)
MODE="compose-single-node"
return
;;
2026-05-16 15:22:42 +08:00
single-node|service|systemd|launchd|sqlite)
MODE="single-node"
return
;;
cluster|multi|multi-node)
2026-05-14 15:26:58 +08:00
if ui_is_zh; then
2026-05-16 15:22:42 +08:00
die "集群部署模式暂未开放;请先选择 compose、compose-single-node 或 single-node"
2026-05-14 15:26:58 +08:00
else
2026-05-16 15:22:42 +08:00
die "cluster deployment mode is temporarily disabled; choose compose, compose-single-node, or single-node"
2026-05-14 15:26:58 +08:00
fi
;;
auto|"")
;;
*)
2026-05-16 15:22:42 +08:00
die "unsupported install mode: ${MODE}; expected compose, compose-single-node, or single-node"
;;
esac
if interactive_tty_available; then
if ui_is_zh; then
cat >/dev/tty <<EOF
请选择 Aether 部署模式:
2026-05-16 15:22:42 +08:00
1) Docker Compose 标准部署(Postgres + Redis
2) Docker Compose 单节点部署(SQLite
3) 系统服务单节点部署(SQLite)
2026-05-16 15:22:42 +08:00
请输入选项 [3]:
EOF
else
cat >/dev/tty <<EOF
Choose Aether deployment mode:
2026-05-16 15:22:42 +08:00
1) Docker Compose standard deployment (Postgres + Redis)
2) Docker Compose single-node deployment (SQLite)
3) System service single-node deployment (SQLite)
2026-05-16 15:22:42 +08:00
Enter choice [3]:
EOF
fi
local choice
IFS= read -r choice </dev/tty || choice=""
2026-05-16 15:22:42 +08:00
case "${choice:-3}" in
1)
MODE="compose"
;;
2026-05-16 15:22:42 +08:00
2)
MODE="compose-single-node"
;;
2026-05-16 15:22:42 +08:00
3)
MODE="single-node"
;;
*)
if ui_is_zh; then
die "无效的部署模式选项: ${choice}"
else
die "invalid deployment mode choice: ${choice}"
fi
;;
esac
else
2026-05-16 15:22:42 +08:00
MODE="single-node"
fi
}
prompt_admin_password() {
if [[ -n "${ADMIN_PASSWORD:-}" ]]; then
ADMIN_PASSWORD_SOURCE="environment"
return
fi
if interactive_tty_available; then
local password confirm
while true; do
if ui_is_zh; then
printf '\n请输入初始管理员密码: ' >/dev/tty
else
printf '\nEnter initial admin password: ' >/dev/tty
fi
stty -echo </dev/tty
IFS= read -r password </dev/tty || password=""
stty echo </dev/tty
if ui_is_zh; then
printf '\n请再次输入初始管理员密码: ' >/dev/tty
else
printf '\nConfirm initial admin password: ' >/dev/tty
fi
stty -echo </dev/tty
IFS= read -r confirm </dev/tty || confirm=""
stty echo </dev/tty
printf '\n' >/dev/tty
[[ -n "${password}" ]] || {
if ui_is_zh; then
echo "管理员密码不能为空。" >/dev/tty
else
echo "Admin password cannot be empty." >/dev/tty
fi
continue
}
[[ "${password}" == "${confirm}" ]] || {
if ui_is_zh; then
echo "两次输入的密码不一致。" >/dev/tty
else
echo "Passwords did not match." >/dev/tty
fi
continue
}
ADMIN_PASSWORD="${password}"
ADMIN_PASSWORD_SOURCE="prompt"
return
done
fi
if ui_is_zh; then
die "非交互式安装生成新配置时必须设置 ADMIN_PASSWORD"
else
die "ADMIN_PASSWORD is required when installing without an interactive terminal"
fi
}
detect_arch() {
case "$(uname -m)" in
x86_64|amd64)
echo "amd64"
;;
aarch64|arm64)
echo "arm64"
;;
*)
die "unsupported CPU architecture: $(uname -m)"
;;
esac
}
validate_https_download_url() {
local url="$1"
local authority
[[ "${url}" == https://* ]] || die "remote downloads require an absolute HTTPS URL"
[[ "${url}" != *"#"* ]] || die "remote download URLs may not contain a fragment"
authority="${url#https://}"
authority="${authority%%[/?]*}"
[[ -n "${authority}" && "${authority}" != *"@"* ]] \
|| die "remote download URLs may not contain credentials or an empty host"
}
download_to() {
local url="$1"
local output="$2"
local mode="${3:-quiet}"
local show_progress="false"
command -v curl >/dev/null 2>&1 || die "curl is required for secure remote downloads"
validate_https_download_url "${url}"
if [[ "${mode}" == "progress" && -t 2 ]]; then
show_progress="true"
fi
if [[ "${show_progress}" == "true" ]]; then
curl -fL --proto '=https' --proto-redir '=https' --progress-bar "${url}" -o "${output}"
else
curl -fsSL --proto '=https' --proto-redir '=https' "${url}" -o "${output}"
fi
}
download_stdout() {
local url="$1"
command -v curl >/dev/null 2>&1 || die "curl is required for secure remote downloads"
validate_https_download_url "${url}"
curl -fsSL --proto '=https' --proto-redir '=https' "${url}"
}
verify_release_checksum() {
local archive="$1"
local checksum_file="$2"
local asset="$3"
local expected actual matches
[[ -f "${checksum_file}" ]] || die "release checksum manifest is missing"
matches="$(awk -v asset="${asset}" '
($2 == asset || $2 == "*" asset) && $1 ~ /^[0-9A-Fa-f]{64}$/ {
print tolower($1)
}
' "${checksum_file}")"
[[ "$(printf '%s\n' "${matches}" | awk 'NF { count += 1 } END { print count + 0 }')" -eq 1 ]] \
|| die "release checksum manifest must contain exactly one valid entry for ${asset}"
expected="$(printf '%s\n' "${matches}" | awk 'NF { print; exit }')"
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "${archive}" | awk '{print tolower($1)}')"
elif command -v shasum >/dev/null 2>&1; then
actual="$(shasum -a 256 "${archive}" | awk '{print tolower($1)}')"
else
die "sha256sum or shasum is required to verify release assets"
fi
[[ "${actual}" == "${expected}" ]] || die "SHA256 verification failed for ${asset}"
}
validate_release_archive() {
local archive="$1"
local expected_root="${2:-}"
local members_file listing_file normalized_file root member normalized permissions mode type
local entry_count size_field tar_version unpacked_bytes
members_file="${TMP_ROOT}/archive-members.txt"
listing_file="${TMP_ROOT}/archive-listing.txt"
normalized_file="${TMP_ROOT}/archive-members-normalized.txt"
LC_ALL=C tar -tzf "${archive}" >"${members_file}" 2>/dev/null \
|| die "release archive cannot be read"
tar_version="$(tar --version 2>/dev/null | head -n1 || true)"
case "${tar_version}" in
*bsdtar*)
size_field=5
;;
*GNU\ tar*)
size_field=3
;;
*)
die "unsupported tar implementation for safe release validation"
;;
esac
LC_ALL=C tar --numeric-owner -tvzf "${archive}" >"${listing_file}" 2>/dev/null \
|| die "release archive metadata cannot be read"
[[ -s "${members_file}" ]] || die "release archive is empty"
entry_count="$(wc -l <"${members_file}" | tr -d '[:space:]')"
[[ "${entry_count}" =~ ^[0-9]+$ && "${entry_count}" -le "${MAX_RELEASE_ARCHIVE_ENTRIES}" ]] \
|| die "release archive contains too many entries"
if [[ "${entry_count}" != "$(wc -l <"${listing_file}" | tr -d '[:space:]')" ]]; then
die "release archive contains malformed member names"
fi
unpacked_bytes="$(awk -v size_field="${size_field}" -v max="${MAX_RELEASE_UNPACKED_BYTES}" '
{
size = $size_field
if (size !~ /^[0-9]+$/ || size > max - total) {
exit 1
}
total += size
}
END {
if (total <= max) {
print total
}
}
' "${listing_file}")" \
|| die "release archive has invalid sizes or exceeds the unpacked size limit"
[[ "${unpacked_bytes}" =~ ^[0-9]+$ && "${unpacked_bytes}" -le "${MAX_RELEASE_UNPACKED_BYTES}" ]] \
|| die "release archive has invalid sizes or exceeds the unpacked size limit"
while IFS= read -r permissions; do
type="${permissions:0:1}"
mode="${permissions:0:10}"
[[ "${type}" == "-" || "${type}" == "d" ]] \
|| die "release archive may contain only regular files and directories"
[[ "${mode}" != *[sStT]* ]] \
|| die "release archive contains unsafe special permissions"
[[ "${mode:5:1}" != "w" && "${mode:8:1}" != "w" ]] \
|| die "release archive contains group- or world-writable members"
done <"${listing_file}"
: >"${normalized_file}"
root=""
while IFS= read -r member; do
[[ -n "${member}" ]] || die "release archive contains an empty member name"
[[ "${member}" != /* && "${member}" != *\\* ]] \
|| die "release archive contains an unsafe member path"
[[ "${member}" =~ ^[A-Za-z0-9._/@%+=,-]+/?$ ]] \
|| die "release archive contains a member name with unsafe characters"
normalized="${member%/}"
[[ -n "${normalized}" ]] \
|| die "release archive contains an invalid member path"
case "/${normalized}/" in
*"//"*|*"/./"*|*"/../"*)
die "release archive contains path traversal or an empty path component"
;;
esac
member="${normalized}"
normalized="${member%%/*}"
if [[ -z "${root}" ]]; then
root="${normalized}"
elif [[ "${normalized}" != "${root}" ]]; then
die "release archive must contain exactly one top-level bundle directory"
fi
printf '%s\n' "${member}" >>"${normalized_file}"
done <"${members_file}"
[[ -n "${root}" ]] || die "release archive did not contain a bundle directory"
if [[ -n "${expected_root}" && "${root}" != "${expected_root}" ]]; then
die "release archive root ${root} does not match expected bundle ${expected_root}"
fi
[[ -z "$(LC_ALL=C sort "${normalized_file}" | uniq -d | head -n1)" ]] \
|| die "release archive contains duplicate members"
printf '%s\n' "${root}"
}
extract_validated_release_archive() {
local archive="$1"
local -a tar_args=(-xzf "${archive}" -C "${TMP_ROOT}" --no-same-owner --no-acls --no-xattrs)
if tar --version 2>/dev/null | head -n1 | grep -qi 'bsdtar'; then
tar_args+=(--no-fflags --no-mac-metadata)
elif tar --version 2>/dev/null | head -n1 | grep -qi 'gnu tar'; then
tar_args+=(--no-selinux)
fi
tar "${tar_args[@]}" \
|| die "release archive extraction failed"
}
select_release_download_urls() {
local original_archive_url="$1"
if [[ -z "${RELEASE_ARCHIVE_URL}" && interactive_tty_available ]]; then
if ui_is_zh; then
cat >/dev/tty <<'EOF'
是否使用下载加速源?
1) 否,使用原始 GitHub 地址
2) 是,手动填写新的下载 URL
请输入选项 [1]:
EOF
else
cat >/dev/tty <<'EOF'
Use an accelerated download URL?
1) No, use the original GitHub URL
2) Yes, enter a replacement download URL
Enter choice [1]:
EOF
fi
local choice
IFS= read -r choice </dev/tty || choice=""
case "${choice:-1}" in
1)
;;
2)
if ui_is_zh; then
cat >/dev/tty <<EOF
原始压缩包 URL:
${original_archive_url}
请输入新的压缩包下载 URL:
EOF
else
cat >/dev/tty <<EOF
Original archive URL:
${original_archive_url}
Enter replacement archive download URL:
EOF
fi
IFS= read -r RELEASE_ARCHIVE_URL </dev/tty || RELEASE_ARCHIVE_URL=""
[[ -n "${RELEASE_ARCHIVE_URL}" ]] || {
if ui_is_zh; then
die "新的压缩包下载 URL 不能为空"
else
die "replacement archive download URL cannot be empty"
fi
}
;;
*)
if ui_is_zh; then
die "无效的下载源选项: ${choice}"
else
die "invalid download source choice: ${choice}"
fi
;;
esac
fi
if [[ -z "${RELEASE_ARCHIVE_URL}" ]]; then
RELEASE_ARCHIVE_URL="${original_archive_url}"
elif [[ "${RELEASE_ARCHIVE_URL}" != "${original_archive_url}" ]]; then
if ui_is_zh; then
info "使用自定义压缩包下载 URL"
info "原始压缩包 URL: ${original_archive_url}"
else
info "using custom archive download URL"
info "original archive URL: ${original_archive_url}"
fi
fi
}
raw_project_url() {
local path="$1"
printf 'https://raw.githubusercontent.com/%s/%s/%s' "${REPO}" "${SOURCE_REF}" "${path}"
}
2026-05-15 18:43:47 +08:00
same_path() {
local left="$1"
local right="$2"
local left_dir right_dir left_base right_base
[[ -e "${left}" && -e "${right}" ]] || return 1
left_dir="$(cd -- "$(dirname -- "${left}")" && pwd -P)"
right_dir="$(cd -- "$(dirname -- "${right}")" && pwd -P)"
left_base="$(basename -- "${left}")"
right_base="$(basename -- "${right}")"
[[ "${left_dir}/${left_base}" == "${right_dir}/${right_base}" ]]
}
stat_file_mode() {
stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null
}
stat_file_uid() {
stat -c '%u' "$1" 2>/dev/null || stat -f '%u' "$1" 2>/dev/null
}
stat_file_gid() {
stat -c '%g' "$1" 2>/dev/null || stat -f '%g' "$1" 2>/dev/null
}
stat_file_link_count() {
stat -c '%h' "$1" 2>/dev/null || stat -f '%l' "$1" 2>/dev/null
}
validate_managed_regular_file() {
local path="$1"
local allow_hardlinks="${2:-true}"
[[ ! -L "${path}" ]] || die "managed file may not be a symbolic link: ${path}"
if [[ -e "${path}" ]]; then
[[ -f "${path}" ]] || die "managed file path is not a regular file: ${path}"
if [[ "${allow_hardlinks}" != "true" ]]; then
[[ "$(stat_file_link_count "${path}")" == "1" ]] \
|| die "managed file may not have multiple hard links: ${path}"
fi
fi
}
validate_managed_parent_directory() {
local path="$1"
local parent
parent="$(dirname -- "${path}")"
[[ -d "${parent}" && ! -L "${parent}" ]] \
|| die "managed file parent must be a real directory: ${parent}"
}
validate_privileged_path_ancestor() {
local path="$1"
local ancestor canonical current mode permissions first
ancestor="${path}"
if [[ ! -d "${ancestor}" ]]; then
ancestor="$(dirname -- "${ancestor}")"
fi
while [[ ! -e "${ancestor}" && ! -L "${ancestor}" ]]; do
[[ "${ancestor}" != "/" && "${ancestor}" != "." ]] \
|| die "could not resolve a trusted ancestor for privileged path: ${path}"
ancestor="$(dirname -- "${ancestor}")"
done
[[ -d "${ancestor}" ]] \
|| die "privileged path ancestor is not a directory: ${ancestor}"
current="${ancestor}"
first="true"
while :; do
[[ "$(stat_file_uid "${current}")" == "0" ]] \
|| die "privileged path component must be owned by root: ${current}"
if [[ ! -L "${current}" ]]; then
[[ -d "${current}" ]] \
|| die "privileged path component is not a directory: ${current}"
mode="$(stat_file_mode "${current}")"
[[ "${mode}" =~ ^[0-7]+$ ]] \
|| die "privileged path component has an invalid mode: ${current}"
permissions=$((0${mode}))
if (( (permissions & 0022) != 0 )); then
if [[ "${first}" == "true" ]] || (( (permissions & 1000) == 0 )); then
die "privileged path component may not be group- or world-writable: ${current}"
fi
fi
fi
[[ "${current}" != "/" ]] || break
current="$(dirname -- "${current}")"
first="false"
done
canonical="$(cd -- "${ancestor}" && pwd -P)" \
|| die "could not resolve privileged path ancestor: ${ancestor}"
current="${canonical}"
first="true"
while :; do
[[ -d "${current}" && ! -L "${current}" ]] \
|| die "resolved privileged path component is not a real directory: ${current}"
[[ "$(stat_file_uid "${current}")" == "0" ]] \
|| die "resolved privileged path component must be owned by root: ${current}"
mode="$(stat_file_mode "${current}")"
[[ "${mode}" =~ ^[0-7]+$ ]] \
|| die "resolved privileged path component has an invalid mode: ${current}"
permissions=$((0${mode}))
if (( (permissions & 0022) != 0 )); then
if [[ "${first}" == "true" ]] || (( (permissions & 1000) == 0 )); then
die "resolved privileged path component may not be group- or world-writable: ${current}"
fi
fi
[[ "${current}" != "/" ]] || break
current="$(dirname -- "${current}")"
first="false"
done
}
atomic_install_managed_file() {
local source="$1"
local target="$2"
local mode="$3"
local owner="${4:-}"
local group="${5:-}"
local parent base temporary
[[ -f "${source}" && ! -L "${source}" ]] \
|| die "managed file source must be a regular file: ${source}"
if [[ "${EUID}" -eq 0 && ( -n "${owner}" || -n "${group}" ) ]]; then
validate_privileged_path_ancestor "${target}"
fi
validate_managed_parent_directory "${target}"
validate_managed_regular_file "${target}" true
parent="$(dirname -- "${target}")"
base="$(basename -- "${target}")"
temporary="$(mktemp "${parent}/.${base}.tmp.XXXXXXXX")" \
|| die "could not create a temporary managed file in ${parent}"
local -a install_args=(-m "${mode}")
[[ -z "${owner}" ]] || install_args+=(-o "${owner}")
[[ -z "${group}" ]] || install_args+=(-g "${group}")
if ! install "${install_args[@]}" "${source}" "${temporary}"; then
rm -f -- "${temporary}"
die "could not stage managed file: ${target}"
fi
validate_managed_parent_directory "${target}"
if [[ -L "${target}" || ( -e "${target}" && ! -f "${target}" ) ]]; then
rm -f -- "${temporary}"
die "managed file target changed to an unsafe type: ${target}"
fi
case "$(install_os)" in
linux)
if ! mv -fT -- "${temporary}" "${target}"; then
rm -f -- "${temporary}"
die "could not atomically replace managed file: ${target}"
fi
;;
macos)
if ! mv -fh -- "${temporary}" "${target}"; then
rm -f -- "${temporary}"
die "could not atomically replace managed file: ${target}"
fi
;;
esac
[[ -f "${target}" && ! -L "${target}" ]] \
|| die "managed file replacement did not produce a regular file: ${target}"
}
ensure_privileged_directory() {
local path="$1"
local mode="$2"
local owner="$3"
local group="$4"
validate_privileged_path_ancestor "${path}"
[[ ! -L "${path}" ]] || die "privileged directory may not be a symbolic link: ${path}"
if [[ -e "${path}" && ! -d "${path}" ]]; then
die "privileged directory path is not a directory: ${path}"
fi
install -d -o "${owner}" -g "${group}" -m "${mode}" "${path}"
[[ -d "${path}" && ! -L "${path}" ]] \
|| die "privileged directory became an unsafe path: ${path}"
}
2026-05-15 18:43:47 +08:00
resolve_compose_dir() {
if [[ -n "${COMPOSE_DIR}" ]]; then
return
fi
if [[ "${INSTALL_ROOT_EXPLICIT}" == "true" || "${COMPOSE_DIR_EXPLICIT}" == "true" ]]; then
COMPOSE_DIR="${INSTALL_ROOT}/compose"
else
COMPOSE_DIR="$(pwd -P)"
fi
}
install_project_file() {
local source_path="$1"
local target_path="$2"
local mode="$3"
local script_dir
script_dir="$(current_script_dir || true)"
ensure_directory "$(dirname "${target_path}")"
if [[ -n "${script_dir}" && -f "${script_dir}/${source_path}" && ! -L "${script_dir}/${source_path}" ]]; then
atomic_install_managed_file \
"${script_dir}/${source_path}" "${target_path}" "${mode}"
else
local downloaded
downloaded="$(mktemp)"
download_to "$(raw_project_url "${source_path}")" "${downloaded}"
atomic_install_managed_file "${downloaded}" "${target_path}" "${mode}"
rm -f -- "${downloaded}"
fi
}
2026-05-15 18:43:47 +08:00
install_generate_keys_script() {
local target_path="$1"
local script_dir
script_dir="$(current_script_dir || true)"
2026-05-15 18:43:47 +08:00
ensure_directory "$(dirname "${target_path}")"
if [[ -n "${script_dir}" && -f "${script_dir}/generate_keys.sh" && ! -L "${script_dir}/generate_keys.sh" ]]; then
atomic_install_managed_file \
"${script_dir}/generate_keys.sh" "${target_path}" 0755
2026-05-15 18:43:47 +08:00
else
write_generate_keys_script "${target_path}"
fi
}
ensure_directory() {
local path="$1"
local mode="${2:-0755}"
[[ ! -L "${path}" ]] || die "managed directory may not be a symbolic link: ${path}"
if [[ -e "${path}" && ! -d "${path}" ]]; then
die "managed directory path is not a directory: ${path}"
fi
2026-05-15 18:43:47 +08:00
if [[ ! -d "${path}" ]]; then
install -d -m "${mode}" "${path}"
fi
[[ ! -L "${path}" ]] || die "managed directory became a symbolic link: ${path}"
2026-05-15 18:43:47 +08:00
}
require_compose_runtime() {
resolve_compose_cli
}
resolve_compose_cli() {
if [[ "${#COMPOSE_CLI[@]}" -gt 0 ]]; then
return
fi
2026-05-15 18:43:47 +08:00
if docker compose version >/dev/null 2>&1; then
COMPOSE_CLI=(docker compose)
2026-05-15 18:43:47 +08:00
return
fi
if command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CLI=(docker-compose)
2026-05-15 18:43:47 +08:00
return
fi
if ui_is_zh; then
die "未找到可用的 Docker Compose,请先安装 Docker 和 Compose 插件"
else
die "no usable Docker Compose found; install Docker and the Compose plugin first"
fi
}
compose_command() {
resolve_compose_cli
printf '%s\n' "${COMPOSE_CLI[*]}"
}
run_compose() {
resolve_compose_cli
"${COMPOSE_CLI[@]}" "$@"
2026-05-15 18:43:47 +08:00
}
compose_next_steps() {
local gateway_port
local compose_cmd
compose_cmd="$(compose_command)"
gateway_port="$(awk -F= '/^[[:space:]]*APP_PORT=/{print $2}' "${COMPOSE_DIR}/.env" | tail -n1 | tr -d '[:space:]')"
gateway_port="${gateway_port:-8084}"
cat <<EOF
Install complete.
Docker Compose service:
cd ${COMPOSE_DIR}
2026-05-20 22:02:47 +08:00
./update.sh
${compose_cmd} -f docker-compose.yml ps
${compose_cmd} -f docker-compose.yml logs -f app
2026-05-15 18:43:47 +08:00
Health checks:
curl -fsS http://127.0.0.1:${gateway_port}/_gateway/health
curl -fsS http://127.0.0.1:${gateway_port}/readyz
Install directory:
${COMPOSE_DIR}
EOF
}
compose_manual_start_steps() {
local compose_cmd
compose_cmd="$(compose_command)"
cat <<EOF
Next steps:
cd ${COMPOSE_DIR}
${compose_cmd} -f docker-compose.yml pull
${compose_cmd} -f docker-compose.yml up -d
${compose_cmd} -f docker-compose.yml logs -f app
2026-05-20 22:02:47 +08:00
Later updates:
cd ${COMPOSE_DIR}
./update.sh
2026-05-15 18:43:47 +08:00
Generate a fresh key set any time:
cd ${COMPOSE_DIR}
./generate_keys.sh
EOF
}
start_compose_deployment() {
local -a compose_args=(--project-directory "${COMPOSE_DIR}" -f "${COMPOSE_DIR}/docker-compose.yml")
2026-05-15 18:43:47 +08:00
info "pulling Docker Compose images"
run_compose "${compose_args[@]}" pull
info "starting Docker Compose services"
run_compose "${compose_args[@]}" up -d
2026-05-15 18:43:47 +08:00
}
resolve_version() {
if [[ -n "${VERSION}" ]]; then
echo "${VERSION}"
return
fi
local tag=""
case "${CHANNEL}" in
stable|latest)
2026-05-07 11:28:44 +08:00
tag="$(download_stdout "https://api.github.com/repos/${REPO}/releases?per_page=50" |
sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' |
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
2026-05-07 11:28:44 +08:00
head -n1 || true)"
;;
rc)
tag="$(download_stdout "https://api.github.com/repos/${REPO}/releases?per_page=50" |
sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' |
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' |
head -n1 || true)"
;;
beta)
tag="$(download_stdout "https://api.github.com/repos/${REPO}/releases?per_page=50" |
sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' |
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$' |
head -n1 || true)"
;;
2026-09-01 16:43:29 +08:00
nightly)
# The nightly release is a single rolling tag, so no API listing is
# needed (and unauthenticated release-list calls are rate-limited).
tag="nightly"
;;
*)
2026-09-01 16:43:29 +08:00
die "unsupported release channel: ${CHANNEL}; expected stable, latest, rc, beta, or nightly"
;;
esac
echo "${tag}"
}
resolve_compose_release_identity() {
local tag
tag="$(resolve_version)"
[[ -n "${tag}" ]] || die "could not resolve ${CHANNEL} release tag for ${REPO}"
validate_release_identifier "${tag}"
VERSION="${tag}"
if [[ "${SOURCE_REF_EXPLICIT}" != "true" ]]; then
SOURCE_REF="${tag}"
fi
validate_installer_source_identifiers
}
current_script_dir() {
local source="${BASH_SOURCE[0]:-}"
[[ -n "${source}" && -f "${source}" ]] || return 1
while [[ -L "${source}" ]]; do
local source_dir target
source_dir="$(cd -- "$(dirname -- "${source}")" && pwd -P)"
target="$(readlink "${source}")"
if [[ "${target}" == /* ]]; then
source="${target}"
else
source="${source_dir}/${target}"
fi
done
[[ -f "${source}" ]] || return 1
cd -- "$(dirname -- "${source}")" && pwd -P
}
2026-05-16 15:22:42 +08:00
ensure_tmp_root() {
if [[ -z "${TMP_ROOT}" ]]; then
TMP_ROOT="$(mktemp -d)"
fi
}
absolute_path() {
local path="$1"
local dir
local base
if [[ "${path}" == /* ]]; then
printf '%s\n' "${path}"
return
fi
dir="$(dirname "${path}")"
base="$(basename "${path}")"
printf '%s/%s\n' "$(cd "${dir}" && pwd -P)" "${base}"
}
absolute_path_maybe_missing() {
local path="$1"
if [[ "${path}" == /* ]]; then
printf '%s\n' "${path}"
else
printf '%s/%s\n' "$(pwd -P)" "${path}"
fi
}
local_bundle_dir() {
local dir
dir="$(current_script_dir || true)"
[[ -n "${dir}" ]] || return 1
if [[ -d "${dir}" && ! -L "${dir}" \
&& -d "${dir}/bin" && ! -L "${dir}/bin" \
&& -f "${dir}/bin/aether-gateway" \
&& ! -L "${dir}/bin/aether-gateway" \
&& -x "${dir}/bin/aether-gateway" \
&& -d "${dir}/frontend" && ! -L "${dir}/frontend" ]]; then
echo "${dir}"
fi
}
validate_local_bundle_tree() {
local bundle="$1"
local binary="${bundle}/bin/aether-gateway"
local unsafe_path
[[ -d "${bundle}" && ! -L "${bundle}" ]] \
|| die "release bundle must be a real directory: ${bundle}"
[[ -d "${bundle}/bin" && ! -L "${bundle}/bin" ]] \
|| die "release bundle bin path must be a real directory: ${bundle}/bin"
[[ -f "${binary}" && ! -L "${binary}" && -x "${binary}" ]] \
|| die "release bundle binary must be a regular executable file: ${binary}"
[[ "$(stat_file_link_count "${binary}")" == "1" ]] \
|| die "release bundle binary may not have multiple hard links: ${binary}"
[[ -d "${bundle}/frontend" && ! -L "${bundle}/frontend" ]] \
|| die "release bundle frontend path must be a real directory: ${bundle}/frontend"
if ! unsafe_path="$(find "${bundle}" -type l -print -quit)"; then
die "could not inspect release bundle for symbolic links: ${bundle}"
fi
[[ -z "${unsafe_path}" ]] \
|| die "release bundle may not contain symbolic links: ${unsafe_path}"
if ! unsafe_path="$(find "${bundle}" ! -type d ! -type f -print -quit)"; then
die "could not inspect release bundle entry types: ${bundle}"
fi
[[ -z "${unsafe_path}" ]] \
|| die "release bundle may contain only directories and regular files: ${unsafe_path}"
if ! unsafe_path="$(find "${bundle}" -type f -links +1 -print -quit)"; then
die "could not inspect release bundle hard links: ${bundle}"
fi
[[ -z "${unsafe_path}" ]] \
|| die "release bundle may not contain multiply-linked files: ${unsafe_path}"
}
download_or_unpack_bundle() {
TMP_ROOT="$(mktemp -d)"
local archive_file archive_root archive_source expected_root
if [[ -n "${ARCHIVE_PATH}" ]]; then
[[ -f "${ARCHIVE_PATH}" ]] || die "archive not found: ${ARCHIVE_PATH}"
info "using local archive ${ARCHIVE_PATH}"
archive_source="$(absolute_path "${ARCHIVE_PATH}")"
archive_file="${TMP_ROOT}/local-release.tar.gz"
cp "${archive_source}" "${archive_file}" \
|| die "could not copy local release archive into the validation directory"
expected_root=""
else
local os arch
os="$(install_os)"
arch="$(detect_arch)"
local tag asset base_url archive_url archive_file
tag="$(resolve_version)"
[[ -n "${tag}" ]] || die "could not resolve ${CHANNEL} release tag for ${REPO}"
validate_release_identifier "${tag}"
VERSION="${tag}"
asset="aether-${tag}-${os}-${arch}.tar.gz"
base_url="https://github.com/${REPO}/releases/download/${tag}"
archive_url="${base_url}/${asset}"
archive_file="${TMP_ROOT}/${asset}"
select_release_download_urls "${archive_url}"
if [[ "${RELEASE_ARCHIVE_URL}" == "${archive_url}" ]]; then
info "downloading ${asset} from ${REPO}"
elif ui_is_zh; then
info "从自定义 URL 下载 ${asset}"
else
info "downloading ${asset} from custom URL"
fi
download_to "${RELEASE_ARCHIVE_URL}" "${archive_file}" progress
download_to "${base_url}/SHA256SUMS" "${TMP_ROOT}/SHA256SUMS"
verify_release_checksum "${archive_file}" "${TMP_ROOT}/SHA256SUMS" "${asset}"
expected_root="${asset%.tar.gz}"
fi
archive_root="$(validate_release_archive "${archive_file}" "${expected_root}")"
extract_validated_release_archive "${archive_file}"
local bundle="${TMP_ROOT}/${archive_root}"
[[ -n "${bundle}" ]] || die "release archive did not contain a bundle directory"
validate_local_bundle_tree "${bundle}"
if [[ -z "${VERSION}" ]]; then
VERSION="$(derive_local_bundle_version "${bundle}")"
fi
validate_release_identifier "${VERSION}"
BUNDLE_DIR="${bundle}"
}
urlsafe_rand() {
local bytes="$1"
if command -v openssl >/dev/null 2>&1; then
openssl rand -base64 "${bytes}" | tr '+/' '-_' | tr -d '='
else
od -An -N "${bytes}" -tx1 /dev/urandom | tr -d ' \n'
fi
}
write_generate_keys_script() {
local output="$1"
local output_dir output_dir_normalized config_dir_normalized rendered
output_dir="$(dirname "${output}")"
output_dir_normalized="${output_dir%/}"
config_dir_normalized="${CONFIG_DIR%/}"
[[ -n "${output_dir_normalized}" ]] || output_dir_normalized="/"
[[ -n "${config_dir_normalized}" ]] || config_dir_normalized="/"
if is_darwin && [[ "${output_dir_normalized}" == "${config_dir_normalized}" ]]; then
install_config_dir
else
ensure_directory "${output_dir}"
fi
rendered="$(mktemp)"
cat > "${rendered}" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
urlsafe_rand() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -base64 "$1" | tr '+/' '-_' | tr -d '='
else
od -An -N "$1" -tx1 /dev/urandom | tr -d ' \n'
fi
}
cat <<KEYS
JWT_SECRET_KEY=$(urlsafe_rand 32)
ENCRYPTION_KEY=$(urlsafe_rand 32)
DB_PASSWORD=$(urlsafe_rand 32)
REDIS_PASSWORD=$(urlsafe_rand 32)
MYSQL_PASSWORD=$(urlsafe_rand 32)
MYSQL_ROOT_PASSWORD=$(urlsafe_rand 32)
KEYS
EOF
atomic_install_managed_file "${rendered}" "${output}" 0755
rm -f -- "${rendered}"
}
validate_dotenv_scalar() {
local key="$1"
local value="$2"
[[ "${value}" != *$'\n'* && "${value}" != *$'\r'* ]] \
|| die "${key} may not contain CR or LF characters"
}
replace_or_append_env() {
local file="$1"
local key="$2"
local value="$3"
local parent base staged mode owner group line
local replaced="false"
[[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] \
|| die "invalid dotenv key: ${key}"
validate_dotenv_scalar "${key}" "${value}"
validate_managed_parent_directory "${file}"
validate_managed_regular_file "${file}" true
parent="$(dirname -- "${file}")"
base="$(basename -- "${file}")"
staged="$(mktemp "${parent}/.${base}.edit.XXXXXXXX")" \
|| die "could not create a temporary env file in ${parent}"
if [[ -e "${file}" ]]; then
while IFS= read -r line || [[ -n "${line}" ]]; do
if [[ "${replaced}" == "false" && "${line}" =~ ^#?[[:space:]]*${key}= ]]; then
printf '%s=%s\n' "${key}" "${value}" >>"${staged}"
replaced="true"
else
printf '%s\n' "${line}" >>"${staged}"
fi
done <"${file}"
fi
if [[ "${replaced}" == "false" ]]; then
printf '%s=%s\n' "${key}" "${value}" >> "${staged}"
fi
mode="0600"
owner=""
group=""
if [[ -e "${file}" ]]; then
mode="$(stat_file_mode "${file}")"
if [[ "${EUID}" -eq 0 ]]; then
owner="$(stat_file_uid "${file}")"
group="$(stat_file_gid "${file}")"
fi
fi
atomic_install_managed_file "${staged}" "${file}" "${mode}" "${owner}" "${group}"
rm -f -- "${staged}"
}
trim_whitespace() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "${value}"
}
strip_optional_quotes() {
local value="$1"
if [[ ${#value} -ge 2 ]]; then
if [[ "${value:0:1}" == "\"" && "${value: -1}" == "\"" ]]; then
value="${value:1:${#value}-2}"
elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then
value="${value:1:${#value}-2}"
fi
fi
printf '%s' "${value}"
}
is_placeholder_value() {
local value="$1"
case "${value}" in
*change-me*|*change-this*|*your_secure_password_here*|*your_redis_password_here*)
return 0
;;
*)
return 1
;;
esac
}
derive_local_bundle_version() {
local bundle="$1"
local name
name="$(basename "${bundle}")"
case "${name}" in
aether-*-linux-*|aether-*-macos-*)
name="${name#aether-}"
name="${name%-linux-*}"
name="${name%-macos-*}"
;;
esac
if [[ -z "${name}" || "${name}" == "." || "${name}" == "/" ]]; then
name="$(date +%Y%m%d%H%M%S)"
fi
echo "${name}"
}
generate_first_install_env() {
local output="$1"
local jwt_key encryption_key
prompt_admin_password
jwt_key="$(urlsafe_rand 32)"
encryption_key="$(urlsafe_rand 32)"
validate_dotenv_scalar "ADMIN_PASSWORD" "${ADMIN_PASSWORD}"
cat > "${output}" <<EOF
ENVIRONMENT=production
TZ=Asia/Shanghai
RUST_LOG=aether_gateway=info
AETHER_LOG_DESTINATION=both
AETHER_LOG_FORMAT=pretty
AETHER_LOG_DIR=${INSTALL_ROOT}/logs
AETHER_LOG_ROTATION=daily
AETHER_LOG_RETENTION_DAYS=7
AETHER_LOG_MAX_FILES=30
APP_PORT=${APP_PORT:-8084}
2026-05-23 20:14:26 +08:00
AETHER_BASE_DIR=${INSTALL_ROOT}
AETHER_UPDATE_STRATEGY=self
AETHER_GATEWAY_STATIC_DIR=${INSTALL_ROOT}/current/frontend
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
AETHER_GATEWAY_DATABASE_MODE=auto
AETHER_RUNTIME_BACKEND=memory
API_KEY_PREFIX=sk
2026-05-16 15:22:42 +08:00
AETHER_DATABASE_DRIVER=sqlite
AETHER_DATABASE_URL=sqlite://${INSTALL_ROOT}/data/aether.db
2026-05-14 15:26:58 +08:00
DATABASE_URL=sqlite://${INSTALL_ROOT}/data/aether.db
JWT_SECRET_KEY=${jwt_key}
ENCRYPTION_KEY=${encryption_key}
ADMIN_EMAIL=admin@example.local
ADMIN_USERNAME=admin
ADMIN_PASSWORD=${ADMIN_PASSWORD}
EOF
}
generate_cluster_env() {
local output="$1"
local jwt_key encryption_key role
prompt_admin_password
jwt_key="$(urlsafe_rand 32)"
encryption_key="$(urlsafe_rand 32)"
role="${AETHER_GATEWAY_NODE_ROLE:-frontdoor}"
validate_dotenv_scalar "ADMIN_PASSWORD" "${ADMIN_PASSWORD}"
validate_dotenv_scalar "ADMIN_EMAIL" "${ADMIN_EMAIL:-admin@example.local}"
validate_dotenv_scalar "ADMIN_USERNAME" "${ADMIN_USERNAME:-admin}"
validate_dotenv_scalar "DATABASE_URL" "${DATABASE_URL:-}"
validate_dotenv_scalar "REDIS_URL" "${REDIS_URL:-}"
cat > "${output}" <<EOF
ENVIRONMENT=production
TZ=Asia/Shanghai
RUST_LOG=aether_gateway=info
AETHER_LOG_DESTINATION=both
AETHER_LOG_FORMAT=pretty
AETHER_LOG_DIR=${INSTALL_ROOT}/logs
AETHER_LOG_ROTATION=daily
AETHER_LOG_RETENTION_DAYS=7
AETHER_LOG_MAX_FILES=30
APP_PORT=${APP_PORT:-8084}
2026-05-23 20:14:26 +08:00
AETHER_BASE_DIR=${INSTALL_ROOT}
AETHER_UPDATE_STRATEGY=manual
AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=multi-node
AETHER_GATEWAY_NODE_ROLE=${role}
AETHER_GATEWAY_STATIC_DIR=${INSTALL_ROOT}/current/frontend
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
AETHER_GATEWAY_DATABASE_MODE=auto
AETHER_RUNTIME_BACKEND=redis
API_KEY_PREFIX=sk
DATABASE_URL=${DATABASE_URL:-}
REDIS_URL=${REDIS_URL:-}
JWT_SECRET_KEY=${jwt_key}
ENCRYPTION_KEY=${encryption_key}
ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.local}
ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD=${ADMIN_PASSWORD}
EOF
}
compose_image() {
if [[ -n "${APP_IMAGE}" ]]; then
echo "${APP_IMAGE}"
return
fi
local tag=""
if [[ -n "${VERSION}" ]]; then
tag="${VERSION#v}"
else
case "${CHANNEL}" in
stable|latest)
tag="latest"
;;
2026-09-01 16:43:29 +08:00
rc|beta|nightly)
2026-05-07 11:28:44 +08:00
tag="${CHANNEL}"
;;
*)
2026-09-01 16:43:29 +08:00
die "unsupported release channel: ${CHANNEL}; expected stable, latest, rc, beta, or nightly"
;;
esac
fi
printf '%s:%s\n' "${IMAGE_REPO}" "${tag}"
}
compose_app_port() {
printf '%s\n' "${APP_PORT:-${COMPOSE_APP_PORT_DEFAULT}}"
}
validate_compose_container_id() {
local label="$1"
local value="$2"
[[ "${value}" =~ ^[1-9][0-9]*$ ]] \
|| die "${label} must be a positive numeric id, not root: ${value}"
[[ ${#value} -le 10 ]] \
|| die "${label} is outside the supported numeric id range: ${value}"
(( 10#${value} <= 2147483647 )) \
|| die "${label} is outside the supported numeric id range: ${value}"
}
resolve_new_compose_container_identity() {
local uid="${AETHER_CONTAINER_UID:-}"
local gid="${AETHER_CONTAINER_GID:-}"
if [[ -z "${uid}" || -z "${gid}" ]]; then
if [[ "${EUID}" -eq 0 && -n "${SUDO_UID:-}" && -n "${SUDO_GID:-}" ]]; then
uid="${uid:-${SUDO_UID}}"
gid="${gid:-${SUDO_GID}}"
elif [[ "${EUID}" -ne 0 ]]; then
uid="${uid:-$(id -u)}"
gid="${gid:-$(id -g)}"
else
uid="${uid:-${COMPOSE_CONTAINER_UID_DEFAULT}}"
gid="${gid:-${COMPOSE_CONTAINER_GID_DEFAULT}}"
fi
fi
validate_compose_container_id "AETHER_CONTAINER_UID" "${uid}"
validate_compose_container_id "AETHER_CONTAINER_GID" "${gid}"
COMPOSE_CONTAINER_UID="$((10#${uid}))"
COMPOSE_CONTAINER_GID="$((10#${gid}))"
}
resolve_compose_env_container_identity() {
local file="$1"
local uid gid
uid="$(env_file_value "${file}" "AETHER_CONTAINER_UID")"
gid="$(env_file_value "${file}" "AETHER_CONTAINER_GID")"
uid="$(strip_optional_quotes "${uid:-${COMPOSE_CONTAINER_UID_DEFAULT}}")"
gid="$(strip_optional_quotes "${gid:-${COMPOSE_CONTAINER_GID_DEFAULT}}")"
validate_compose_container_id "AETHER_CONTAINER_UID" "${uid}"
validate_compose_container_id "AETHER_CONTAINER_GID" "${gid}"
COMPOSE_CONTAINER_UID="$((10#${uid}))"
COMPOSE_CONTAINER_GID="$((10#${gid}))"
}
compose_app_container_is_running() {
command -v docker >/dev/null 2>&1 || return 1
[[ "$(docker inspect --format '{{.State.Running}}' aether-app 2>/dev/null || true)" == "true" ]]
}
prepare_compose_single_node_data_directory() {
local data_dir="${COMPOSE_DIR}/data"
local current_uid
local unsafe_path
resolve_compose_env_container_identity "${COMPOSE_DIR}/.env"
ensure_directory "${data_dir}" 0700
if compose_app_container_is_running; then
die "stop the existing aether-app container before migrating the Docker Compose SQLite data directory"
fi
unsafe_path="$(find "${data_dir}" -type l -print -quit)"
[[ -z "${unsafe_path}" ]] \
|| die "Docker Compose data directory may not contain symbolic links: ${unsafe_path}"
unsafe_path="$(find "${data_dir}" ! -type d ! -type f -print -quit)"
[[ -z "${unsafe_path}" ]] \
|| die "Docker Compose data directory may contain only directories and regular files: ${unsafe_path}"
unsafe_path="$(find "${data_dir}" -type f -links +1 -print -quit)"
[[ -z "${unsafe_path}" ]] \
|| die "Docker Compose data directory may not contain multiply-linked files: ${unsafe_path}"
current_uid="$(id -u)"
if [[ "${EUID}" -eq 0 ]]; then
chown -R -P "${COMPOSE_CONTAINER_UID}:${COMPOSE_CONTAINER_GID}" "${data_dir}" \
|| die "could not assign ${data_dir} to the non-root container identity"
elif [[ "${current_uid}" != "${COMPOSE_CONTAINER_UID}" ]]; then
die "${data_dir} must be owned by container uid ${COMPOSE_CONTAINER_UID}; rerun this installer as root to migrate existing SQLite data"
fi
find "${data_dir}" -type d -exec chmod 0700 {} + \
|| die "could not secure Docker Compose data directories: ${data_dir}"
find "${data_dir}" -type f -exec chmod 0600 {} + \
|| die "could not secure Docker Compose data files: ${data_dir}"
[[ "$(stat_file_uid "${data_dir}")" == "${COMPOSE_CONTAINER_UID}" ]] \
|| die "Docker Compose data directory owner does not match AETHER_CONTAINER_UID=${COMPOSE_CONTAINER_UID}: ${data_dir}"
info "prepared ${data_dir} for container uid ${COMPOSE_CONTAINER_UID} gid ${COMPOSE_CONTAINER_GID}"
}
append_compose_log_env_defaults() {
local output="$1"
replace_or_append_env "${output}" "AETHER_LOG_DESTINATION" "${COMPOSE_LOG_DESTINATION_DEFAULT}"
replace_or_append_env "${output}" "AETHER_LOG_FORMAT" "${COMPOSE_LOG_FORMAT_DEFAULT}"
replace_or_append_env "${output}" "AETHER_LOG_DIR" "${COMPOSE_RELEASE_LOG_DIR}"
replace_or_append_env "${output}" "AETHER_LOG_ROTATION" "${COMPOSE_LOG_ROTATION_DEFAULT}"
replace_or_append_env "${output}" "AETHER_LOG_RETENTION_DAYS" "${COMPOSE_LOG_RETENTION_DAYS_DEFAULT}"
replace_or_append_env "${output}" "AETHER_LOG_MAX_FILES" "${COMPOSE_LOG_MAX_FILES_DEFAULT}"
}
compose_log_env_block() {
cat <<EOF
AETHER_LOG_DESTINATION=${COMPOSE_LOG_DESTINATION_DEFAULT}
AETHER_LOG_FORMAT=${COMPOSE_LOG_FORMAT_DEFAULT}
AETHER_LOG_DIR=${COMPOSE_RELEASE_LOG_DIR}
AETHER_LOG_ROTATION=${COMPOSE_LOG_ROTATION_DEFAULT}
AETHER_LOG_RETENTION_DAYS=${COMPOSE_LOG_RETENTION_DAYS_DEFAULT}
AETHER_LOG_MAX_FILES=${COMPOSE_LOG_MAX_FILES_DEFAULT}
EOF
}
generate_compose_env() {
local output="$1"
local jwt_key encryption_key db_password redis_password mysql_password mysql_root_password
prompt_admin_password
jwt_key="$(urlsafe_rand 32)"
encryption_key="$(urlsafe_rand 32)"
db_password="$(urlsafe_rand 32)"
redis_password="$(urlsafe_rand 32)"
mysql_password="$(urlsafe_rand 32)"
mysql_root_password="$(urlsafe_rand 32)"
resolve_new_compose_container_identity
cp "${COMPOSE_DIR}/.env.example" "${output}"
replace_or_append_env "${output}" "APP_IMAGE" "$(compose_image)"
replace_or_append_env "${output}" "APP_PORT" "$(compose_app_port)"
replace_or_append_env "${output}" "AETHER_CONTAINER_UID" "${COMPOSE_CONTAINER_UID}"
replace_or_append_env "${output}" "AETHER_CONTAINER_GID" "${COMPOSE_CONTAINER_GID}"
replace_or_append_env "${output}" "DB_PASSWORD" "${db_password}"
replace_or_append_env "${output}" "REDIS_PASSWORD" "${redis_password}"
replace_or_append_env "${output}" "MYSQL_PASSWORD" "${mysql_password}"
replace_or_append_env "${output}" "MYSQL_ROOT_PASSWORD" "${mysql_root_password}"
replace_or_append_env "${output}" "JWT_SECRET_KEY" "${JWT_SECRET_KEY:-${jwt_key}}"
replace_or_append_env "${output}" "ENCRYPTION_KEY" "${ENCRYPTION_KEY:-${encryption_key}}"
replace_or_append_env "${output}" "ADMIN_EMAIL" "${ADMIN_EMAIL:-admin@example.local}"
replace_or_append_env "${output}" "ADMIN_USERNAME" "${ADMIN_USERNAME:-admin}"
replace_or_append_env "${output}" "ADMIN_PASSWORD" "${ADMIN_PASSWORD}"
2026-05-23 20:14:26 +08:00
replace_or_append_env "${output}" "AETHER_UPDATE_STRATEGY" "docker"
replace_or_append_env "${output}" "AETHER_DOCKER_UPDATE_COMMAND" "./update.sh"
append_compose_log_env_defaults "${output}"
replace_or_append_env "${output}" "AETHER_GATEWAY_DATABASE_MODE" "auto"
}
2026-05-16 15:22:42 +08:00
generate_compose_single_node_env() {
local output="$1"
local jwt_key encryption_key
prompt_admin_password
jwt_key="$(urlsafe_rand 32)"
encryption_key="$(urlsafe_rand 32)"
resolve_new_compose_container_identity
validate_dotenv_scalar "APP_IMAGE" "$(compose_image)"
validate_dotenv_scalar "APP_PORT" "$(compose_app_port)"
validate_dotenv_scalar "JWT_SECRET_KEY" "${JWT_SECRET_KEY:-${jwt_key}}"
validate_dotenv_scalar "ENCRYPTION_KEY" "${ENCRYPTION_KEY:-${encryption_key}}"
validate_dotenv_scalar "ADMIN_EMAIL" "${ADMIN_EMAIL:-admin@example.local}"
validate_dotenv_scalar "ADMIN_USERNAME" "${ADMIN_USERNAME:-admin}"
validate_dotenv_scalar "ADMIN_PASSWORD" "${ADMIN_PASSWORD}"
cat > "${output}" <<EOF
ENVIRONMENT=production
TZ=Asia/Shanghai
RUST_LOG=aether_gateway=info
$(compose_log_env_block)
APP_IMAGE=$(compose_image)
APP_PORT=$(compose_app_port)
AETHER_CONTAINER_UID=${COMPOSE_CONTAINER_UID}
AETHER_CONTAINER_GID=${COMPOSE_CONTAINER_GID}
2026-05-23 20:14:26 +08:00
AETHER_UPDATE_STRATEGY=docker
AETHER_DOCKER_UPDATE_COMMAND=./update.sh
AETHER_GATEWAY_STATIC_DIR=${COMPOSE_RELEASE_FRONTEND_DIR}
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
AETHER_GATEWAY_DATABASE_MODE=auto
AETHER_RUNTIME_BACKEND=memory
API_KEY_PREFIX=sk
2026-05-16 15:22:42 +08:00
AETHER_DATABASE_DRIVER=sqlite
AETHER_DATABASE_URL=${COMPOSE_RELEASE_SQLITE_DATABASE_URL}
DATABASE_URL=${COMPOSE_RELEASE_SQLITE_DATABASE_URL}
JWT_SECRET_KEY=${JWT_SECRET_KEY:-${jwt_key}}
ENCRYPTION_KEY=${ENCRYPTION_KEY:-${encryption_key}}
ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.local}
ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD=${ADMIN_PASSWORD}
EOF
}
install_config_dir() {
validate_privileged_path_ancestor "${CONFIG_DIR}"
[[ ! -L "${CONFIG_DIR}" ]] || die "config directory may not be a symbolic link: ${CONFIG_DIR}"
if [[ -e "${CONFIG_DIR}" && ! -d "${CONFIG_DIR}" ]]; then
die "config directory path is not a directory: ${CONFIG_DIR}"
fi
if is_darwin; then
install -d -o root -g "${SERVICE_GROUP}" -m 0750 "${CONFIG_DIR}"
else
install -d -o root -g root -m 0750 "${CONFIG_DIR}"
fi
[[ ! -L "${CONFIG_DIR}" ]] || die "config directory became a symbolic link: ${CONFIG_DIR}"
}
install_env_target_from() {
local source="$1"
local owner="" group=""
[[ -f "${source}" && ! -L "${source}" ]] \
|| die "env source must be a regular file and not a symbolic link: ${source}"
if is_darwin; then
if [[ "${EUID}" -eq 0 ]]; then
owner="root"
group="${SERVICE_GROUP}"
fi
atomic_install_managed_file "${source}" "${ENV_TARGET}" 0640 "${owner}" "${group}"
else
if [[ "${EUID}" -eq 0 ]]; then
owner="root"
group="root"
fi
atomic_install_managed_file "${source}" "${ENV_TARGET}" 0600 "${owner}" "${group}"
fi
}
ensure_env_target_permissions() {
local owner="" group="" mode="0600"
validate_managed_regular_file "${ENV_TARGET}" true
[[ -f "${ENV_TARGET}" ]] || die "env target is missing: ${ENV_TARGET}"
if [[ "${EUID}" -eq 0 ]]; then
owner="root"
group="root"
fi
if is_darwin; then
mode="0640"
if [[ "${EUID}" -eq 0 ]]; then
group="${SERVICE_GROUP}"
fi
fi
atomic_install_managed_file \
"${ENV_TARGET}" "${ENV_TARGET}" "${mode}" "${owner}" "${group}"
}
install_systemd_support_files() {
install_config_dir
write_generate_keys_script "${CONFIG_DIR}/generate_keys.sh"
}
find_nologin_shell() {
if [[ -x /usr/sbin/nologin ]]; then
echo "/usr/sbin/nologin"
elif [[ -x /sbin/nologin ]]; then
echo "/sbin/nologin"
else
echo "/bin/false"
fi
}
ensure_service_account() {
if ! getent group "${SERVICE_GROUP}" >/dev/null 2>&1; then
info "creating group ${SERVICE_GROUP}"
groupadd --system "${SERVICE_GROUP}"
fi
if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then
info "creating user ${SERVICE_USER}"
useradd \
--system \
--gid "${SERVICE_GROUP}" \
--home-dir "${INSTALL_ROOT}" \
--shell "$(find_nologin_shell)" \
"${SERVICE_USER}"
fi
}
macos_next_system_id() {
local record_type="$1"
local id_attr="$2"
dscl . -list "/${record_type}" "${id_attr}" 2>/dev/null |
awk '
$NF ~ /^[0-9]+$/ && $NF >= 350 && $NF < 500 { used[$NF] = 1 }
END {
for (i = 350; i < 500; i++) {
if (!(i in used)) {
print i
exit
}
}
}
'
}
macos_group_id() {
dscl . -read "/Groups/${SERVICE_GROUP}" PrimaryGroupID 2>/dev/null |
awk '/PrimaryGroupID:/ { print $2 }'
}
ensure_macos_service_account() {
local gid uid
if ! command -v dscl >/dev/null 2>&1; then
if ui_is_zh; then
die "未找到 dscl,无法创建 macOS 服务账号"
else
die "dscl not found; cannot create macOS service account"
fi
fi
if ! dscl . -read "/Groups/${SERVICE_GROUP}" >/dev/null 2>&1; then
gid="$(macos_next_system_id Groups PrimaryGroupID)"
[[ -n "${gid}" ]] || die "could not allocate a macOS service group id"
info "creating macOS group ${SERVICE_GROUP}"
dscl . -create "/Groups/${SERVICE_GROUP}"
dscl . -create "/Groups/${SERVICE_GROUP}" PrimaryGroupID "${gid}"
dscl . -create "/Groups/${SERVICE_GROUP}" Password "*"
fi
gid="$(macos_group_id)"
[[ -n "${gid}" ]] || die "could not resolve macOS group id for ${SERVICE_GROUP}"
if ! dscl . -read "/Users/${SERVICE_USER}" >/dev/null 2>&1; then
uid="$(macos_next_system_id Users UniqueID)"
[[ -n "${uid}" ]] || die "could not allocate a macOS service user id"
info "creating macOS user ${SERVICE_USER}"
dscl . -create "/Users/${SERVICE_USER}"
dscl . -create "/Users/${SERVICE_USER}" UserShell /usr/bin/false
dscl . -create "/Users/${SERVICE_USER}" RealName "Aether Gateway"
dscl . -create "/Users/${SERVICE_USER}" UniqueID "${uid}"
dscl . -create "/Users/${SERVICE_USER}" PrimaryGroupID "${gid}"
dscl . -create "/Users/${SERVICE_USER}" NFSHomeDirectory "${INSTALL_ROOT}"
dscl . -create "/Users/${SERVICE_USER}" IsHidden 1
dscl . -create "/Users/${SERVICE_USER}" Password "*"
fi
}
env_file_value() {
local file="$1"
local key="$2"
awk -v key="${key}" '
{
line = $0
sub(/^[[:space:]]*/, "", line)
if (line ~ /^#/ || line !~ /^[A-Za-z_][A-Za-z0-9_]*=/) {
next
}
name = line
sub(/=.*/, "", name)
if (name == key) {
value = line
sub(/^[^=]*=/, "", value)
print value
}
}
' "${file}" | tail -n1 | tr -d '[:space:]'
}
ensure_env_matches_requested_mode() {
local file="$1"
local mode="$2"
local topology
topology="$(env_file_value "${file}" "AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY")"
topology="${topology:-single-node}"
if [[ "${mode}" == "cluster" ]]; then
2026-05-16 15:22:42 +08:00
[[ "${topology}" == "multi-node" ]] || die "existing env ${file} is ${topology}; set AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=multi-node or use --mode single-node"
cluster_env_has_required_backends "${file}" || die "existing multi-node env ${file} must define DATABASE_URL and REDIS_URL"
2026-05-16 15:22:42 +08:00
elif [[ "${mode}" == "single-node" && "${topology}" == "multi-node" ]]; then
2026-05-14 15:26:58 +08:00
die "existing env ${file} is multi-node; cluster mode is temporarily disabled, edit the env file"
fi
}
cluster_env_has_required_backends() {
local file="$1"
local database_url redis_url
database_url="$(env_file_value "${file}" "AETHER_DATABASE_URL")"
[[ -n "${database_url}" ]] || database_url="$(env_file_value "${file}" "DATABASE_URL")"
[[ -n "${database_url}" ]] || database_url="$(env_file_value "${file}" "AETHER_GATEWAY_DATA_POSTGRES_URL")"
redis_url="$(env_file_value "${file}" "REDIS_URL")"
[[ -n "${redis_url}" ]] || redis_url="$(env_file_value "${file}" "AETHER_GATEWAY_DATA_REDIS_URL")"
[[ -n "${database_url}" && -n "${redis_url}" ]]
}
validate_env_file() {
local env_file="$1"
local raw_line=""
local line=""
local key=""
local value=""
local line_no=0
local topology="single-node"
local node_role="all"
local database_driver=""
local runtime_backend=""
local db_password=""
local redis_password=""
local database_url=""
local redis_url=""
local jwt_secret_key=""
local encryption_key=""
local video_task_store_path=""
local static_dir=""
[[ -f "${env_file}" ]] || die "env file not found: ${env_file}"
info "validating env file ${env_file}"
while IFS= read -r raw_line || [[ -n "${raw_line}" ]]; do
line_no=$((line_no + 1))
line="${raw_line%$'\r'}"
line="$(trim_whitespace "${line}")"
[[ -z "${line}" ]] && continue
[[ "${line:0:1}" == "#" ]] && continue
[[ "${line}" == export\ * ]] && die "env file ${env_file}:${line_no} must not use 'export'"
[[ "${line}" == *'${'* ]] && die "env file ${env_file}:${line_no} must not use variable expansion"
[[ "${line}" == *'$('* ]] && die "env file ${env_file}:${line_no} must not use command substitution"
[[ "${line}" == *'`'* ]] && die "env file ${env_file}:${line_no} must not use command substitution"
[[ "${line}" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || die "env file ${env_file}:${line_no} must be KEY=VALUE"
key="${line%%=*}"
value="${line#*=}"
value="$(strip_optional_quotes "${value}")"
case "${key}" in
AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY)
topology="${value}"
;;
AETHER_GATEWAY_NODE_ROLE)
node_role="${value}"
;;
AETHER_DATABASE_DRIVER)
database_driver="$(printf '%s' "${value}" | tr '[:upper:]' '[:lower:]')"
;;
AETHER_RUNTIME_BACKEND)
runtime_backend="$(printf '%s' "${value}" | tr '[:upper:]' '[:lower:]')"
;;
AETHER_DATABASE_URL|DATABASE_URL|AETHER_GATEWAY_DATA_POSTGRES_URL)
[[ -n "${value}" ]] && database_url="${value}"
;;
REDIS_URL|AETHER_GATEWAY_DATA_REDIS_URL)
[[ -n "${value}" ]] && redis_url="${value}"
;;
DB_PASSWORD)
db_password="${value}"
;;
REDIS_PASSWORD)
redis_password="${value}"
;;
JWT_SECRET_KEY)
jwt_secret_key="${value}"
;;
ENCRYPTION_KEY|AETHER_GATEWAY_DATA_ENCRYPTION_KEY)
[[ -n "${value}" ]] && encryption_key="${value}"
;;
AETHER_GATEWAY_VIDEO_TASK_STORE_PATH)
video_task_store_path="${value}"
;;
AETHER_GATEWAY_STATIC_DIR)
static_dir="${value}"
;;
esac
done < "${env_file}"
case "${topology}" in
single-node|multi-node)
;;
*)
die "AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY must be single-node or multi-node"
;;
esac
case "${node_role}" in
all|frontdoor|background)
;;
*)
die "AETHER_GATEWAY_NODE_ROLE must be all, frontdoor, or background"
;;
esac
[[ -n "${jwt_secret_key}" ]] || die "JWT_SECRET_KEY is required"
[[ -n "${encryption_key}" ]] || die "ENCRYPTION_KEY or AETHER_GATEWAY_DATA_ENCRYPTION_KEY is required"
is_placeholder_value "${jwt_secret_key}" && die "JWT_SECRET_KEY still uses the example placeholder"
is_placeholder_value "${encryption_key}" && die "ENCRYPTION_KEY still uses the example placeholder"
if [[ -n "${database_url}" ]] && is_placeholder_value "${database_url}"; then
die "DATABASE_URL still uses the example placeholder"
fi
if [[ -n "${redis_url}" ]] && is_placeholder_value "${redis_url}"; then
die "REDIS_URL still uses the example placeholder"
fi
local database_is_sqlite="false"
if [[ "${database_driver}" == "sqlite" || "${database_url}" == sqlite:* ]]; then
database_is_sqlite="true"
fi
if [[ "${topology}" == "multi-node" ]]; then
[[ "${node_role}" != "all" ]] || die "multi-node deployment requires AETHER_GATEWAY_NODE_ROLE=frontdoor or background"
[[ -n "${database_url}" ]] || die "multi-node deployment requires AETHER_DATABASE_URL, DATABASE_URL, or AETHER_GATEWAY_DATA_POSTGRES_URL"
[[ "${database_is_sqlite}" != "true" ]] || die "multi-node deployment must use shared Postgres/MySQL, not SQLite"
[[ -n "${redis_url}" ]] || die "multi-node deployment requires REDIS_URL or AETHER_GATEWAY_DATA_REDIS_URL"
[[ "${runtime_backend}" != "memory" ]] || die "multi-node deployment must not use AETHER_RUNTIME_BACKEND=memory"
[[ -z "${video_task_store_path}" ]] || die "multi-node deployment must not set AETHER_GATEWAY_VIDEO_TASK_STORE_PATH"
else
if [[ "${node_role}" != "all" ]]; then
2026-05-14 15:26:58 +08:00
warn "single-node deployment usually uses AETHER_GATEWAY_NODE_ROLE=all; split roles are not enabled by this installer"
fi
if [[ "${runtime_backend}" == "redis" && -z "${redis_url}" ]]; then
die "AETHER_RUNTIME_BACKEND=redis requires REDIS_URL or AETHER_GATEWAY_DATA_REDIS_URL"
fi
if [[ -z "${database_url}" && -z "${redis_url}" ]]; then
warn "single-node env is running in minimal mode without full Postgres/Redis persistence"
elif [[ "${database_is_sqlite}" == "true" && -z "${redis_url}" ]]; then
info "single-node env is using SQLite with in-process runtime coordination"
fi
fi
if is_placeholder_value "${db_password}"; then
warn "DB_PASSWORD still uses the example placeholder"
fi
if is_placeholder_value "${redis_password}"; then
warn "REDIS_PASSWORD still uses the example placeholder"
fi
if [[ -n "${static_dir}" && "${static_dir}" != "${INSTALL_ROOT}/current/frontend" ]]; then
warn "AETHER_GATEWAY_STATIC_DIR points to ${static_dir}; install script still publishes frontend to ${INSTALL_ROOT}/current/frontend"
fi
}
resolve_service_env_source() {
local mode="$1"
if [[ -n "${ENV_SOURCE}" ]]; then
[[ -f "${ENV_SOURCE}" ]] || die "env file not found: ${ENV_SOURCE}"
ensure_env_matches_requested_mode "${ENV_SOURCE}" "${mode}"
echo "${ENV_SOURCE}"
return
fi
if [[ -f "${ENV_TARGET}" ]]; then
ensure_env_matches_requested_mode "${ENV_TARGET}" "${mode}"
echo ""
return
fi
GENERATED_ENV="${TMP_ROOT:-$(mktemp -d)}/aether-gateway.env"
if [[ -z "${TMP_ROOT}" ]]; then
TMP_ROOT="$(dirname "${GENERATED_ENV}")"
fi
if [[ "${mode}" == "cluster" ]]; then
info "generating multi-node env file"
generate_cluster_env "${GENERATED_ENV}"
if ! cluster_env_has_required_backends "${GENERATED_ENV}"; then
install_config_dir
install_env_target_from "${GENERATED_ENV}"
cat <<EOF
Multi-node env scaffolded:
${ENV_TARGET}
Fill DATABASE_URL and REDIS_URL, then rerun:
sudo AETHER_INSTALL_MODE=cluster bash install.sh
Or provide them non-interactively:
curl -fsSL https://raw.githubusercontent.com/${REPO}/${SOURCE_REF}/install.sh | sudo DATABASE_URL=postgresql://... REDIS_URL=redis://... bash -s -- --mode cluster
EOF
exit 1
fi
else
2026-05-16 15:22:42 +08:00
info "generating first-install single-node env file"
generate_first_install_env "${GENERATED_ENV}"
fi
echo "${GENERATED_ENV}"
}
install_compose_mode() {
2026-05-15 18:43:47 +08:00
resolve_compose_dir
info "preparing Docker Compose deployment in ${COMPOSE_DIR}"
2026-05-15 18:43:47 +08:00
ensure_directory "${COMPOSE_DIR}"
2026-05-23 20:14:26 +08:00
ensure_directory "${COMPOSE_DIR}/logs"
install_project_file "docker-compose.yml" "${COMPOSE_DIR}/docker-compose.yml" "0644"
install_project_file ".env.example" "${COMPOSE_DIR}/.env.example" "0644"
2026-05-20 22:02:47 +08:00
install_project_file "update.sh" "${COMPOSE_DIR}/update.sh" "0755"
2026-05-15 18:43:47 +08:00
install_generate_keys_script "${COMPOSE_DIR}/generate_keys.sh"
validate_managed_regular_file "${COMPOSE_DIR}/.env" false
if [[ -f "${COMPOSE_DIR}/.env" ]]; then
warn "keeping existing ${COMPOSE_DIR}/.env"
else
local generated_compose_env
info "generating ${COMPOSE_DIR}/.env"
generated_compose_env="$(mktemp)"
generate_compose_env "${generated_compose_env}"
atomic_install_managed_file \
"${generated_compose_env}" "${COMPOSE_DIR}/.env" 0600
rm -f -- "${generated_compose_env}"
fi
cat <<EOF
Docker Compose files are ready:
${COMPOSE_DIR}/docker-compose.yml
${COMPOSE_DIR}/.env
${COMPOSE_DIR}/.env.example
2026-05-20 22:02:47 +08:00
${COMPOSE_DIR}/update.sh
${COMPOSE_DIR}/generate_keys.sh
2026-05-23 20:14:26 +08:00
${COMPOSE_DIR}/logs
EOF
2026-05-15 18:43:47 +08:00
if [[ "${SKIP_START}" == "true" ]]; then
compose_manual_start_steps
return
fi
require_compose_runtime
start_compose_deployment
compose_next_steps
}
2026-05-16 15:22:42 +08:00
install_compose_single_node_mode() {
2026-05-15 18:43:47 +08:00
resolve_compose_dir
2026-05-16 15:22:42 +08:00
info "preparing Docker Compose single-node deployment in ${COMPOSE_DIR}"
2026-05-15 18:43:47 +08:00
ensure_directory "${COMPOSE_DIR}"
2026-05-24 00:37:03 +08:00
ensure_directory "${COMPOSE_DIR}/data"
2026-05-23 20:14:26 +08:00
ensure_directory "${COMPOSE_DIR}/logs"
2026-05-16 15:22:42 +08:00
install_project_file "docker-compose.single-node.yml" "${COMPOSE_DIR}/docker-compose.yml" "0644"
install_project_file ".env.example" "${COMPOSE_DIR}/.env.example" "0644"
2026-05-20 22:02:47 +08:00
install_project_file "update.sh" "${COMPOSE_DIR}/update.sh" "0755"
2026-05-15 18:43:47 +08:00
install_generate_keys_script "${COMPOSE_DIR}/generate_keys.sh"
validate_managed_regular_file "${COMPOSE_DIR}/.env" false
if [[ -f "${COMPOSE_DIR}/.env" ]]; then
warn "keeping existing ${COMPOSE_DIR}/.env"
else
local generated_compose_env
info "generating ${COMPOSE_DIR}/.env"
generated_compose_env="$(mktemp)"
generate_compose_single_node_env "${generated_compose_env}"
atomic_install_managed_file \
"${generated_compose_env}" "${COMPOSE_DIR}/.env" 0600
rm -f -- "${generated_compose_env}"
fi
prepare_compose_single_node_data_directory
cat <<EOF
2026-05-16 15:22:42 +08:00
Docker Compose single-node files are ready:
${COMPOSE_DIR}/docker-compose.yml
${COMPOSE_DIR}/.env
${COMPOSE_DIR}/.env.example
2026-05-20 22:02:47 +08:00
${COMPOSE_DIR}/update.sh
${COMPOSE_DIR}/generate_keys.sh
2026-05-24 00:37:03 +08:00
${COMPOSE_DIR}/data
2026-05-23 20:14:26 +08:00
${COMPOSE_DIR}/logs
EOF
2026-05-15 18:43:47 +08:00
if [[ "${SKIP_START}" == "true" ]]; then
compose_manual_start_steps
return
fi
require_compose_runtime
start_compose_deployment
compose_next_steps
}
install_env_file() {
local env_file="$1"
install_config_dir
if [[ -n "${env_file}" ]]; then
info "installing env file to ${ENV_TARGET}"
install_env_target_from "${env_file}"
else
ensure_env_target_permissions
fi
replace_or_append_env "${ENV_TARGET}" "AETHER_UPDATE_STRATEGY" "manual"
}
switch_current_release_link() {
local release_dir="$1"
local current_link="$2"
local next_link="${current_link}.new"
[[ ! -e "${current_link}" || -L "${current_link}" ]] \
|| die "current release path exists and is not a symbolic link"
[[ ! -e "${next_link}" || -L "${next_link}" ]] \
|| die "temporary current release path exists and is not a symbolic link"
rm -f -- "${next_link}"
ln -s -- "${release_dir}" "${next_link}"
[[ -L "${next_link}" && "$(readlink "${next_link}")" == "${release_dir}" ]] \
|| die "could not create the temporary current release symbolic link"
# Recheck immediately before rename. The install root is root-owned and not
# writable by the service account, so an unprivileged process cannot race it.
[[ ! -e "${current_link}" || -L "${current_link}" ]] \
|| die "current release path changed to a non-symbolic-link"
case "$(install_os)" in
linux)
mv -fT -- "${next_link}" "${current_link}"
;;
macos)
mv -fh -- "${next_link}" "${current_link}"
;;
esac
}
select_release_install_directory() {
local requested="$1"
local selected
if [[ ! -e "${requested}" && ! -L "${requested}" ]]; then
printf '%s\n' "${requested}"
return
fi
[[ -d "${requested}" && ! -L "${requested}" ]] \
|| die "release path exists and is not a managed directory: ${requested}"
selected="$(mktemp -d "${requested}.XXXXXXXX")" \
|| die "could not allocate an immutable release directory beside ${requested}"
[[ -d "${selected}" && ! -L "${selected}" ]] \
|| die "release staging path is not a real directory: ${selected}"
printf '%s\n' "${selected}"
}
install_release() {
local bundle="$1"
local release_dir
local current_link="${INSTALL_ROOT}/current"
validate_release_identifier "${VERSION}"
release_dir="${INSTALL_ROOT}/releases/${VERSION}"
validate_local_bundle_tree "${bundle}"
[[ ! -L "${INSTALL_ROOT}" ]] || die "install root may not be a symbolic link: ${INSTALL_ROOT}"
if [[ -e "${INSTALL_ROOT}" && ! -d "${INSTALL_ROOT}" ]]; then
die "install root path is not a directory: ${INSTALL_ROOT}"
fi
[[ ! -L "${INSTALL_ROOT}/releases" ]] \
|| die "releases directory may not be a symbolic link: ${INSTALL_ROOT}/releases"
[[ ! -L "${INSTALL_ROOT}/data" ]] \
|| die "data directory may not be a symbolic link: ${INSTALL_ROOT}/data"
[[ ! -L "${INSTALL_ROOT}/logs" ]] \
|| die "logs directory may not be a symbolic link: ${INSTALL_ROOT}/logs"
install -d -o root -m 0755 "${INSTALL_ROOT}" "${INSTALL_ROOT}/releases"
[[ ! -L "${INSTALL_ROOT}" ]] || die "install root became a symbolic link: ${INSTALL_ROOT}"
[[ ! -L "${INSTALL_ROOT}/releases" ]] \
|| die "releases directory became a symbolic link: ${INSTALL_ROOT}/releases"
chmod 0755 "${INSTALL_ROOT}" "${INSTALL_ROOT}/releases"
install -d -m 0755 "${INSTALL_ROOT}/data" "${INSTALL_ROOT}/logs"
[[ ! -L "${INSTALL_ROOT}/data" && ! -L "${INSTALL_ROOT}/logs" ]] \
|| die "managed data or log directory became a symbolic link"
if is_darwin; then
install -d -o "${SERVICE_USER}" -g "${SERVICE_GROUP}" -m 0750 \
"${INSTALL_ROOT}/data" \
"${INSTALL_ROOT}/logs"
else
install -d -o "${SERVICE_USER}" -g "${SERVICE_GROUP}" -m 0750 \
"${INSTALL_ROOT}/data" \
"${INSTALL_ROOT}/logs"
fi
release_dir="$(select_release_install_directory "${release_dir}")"
info "installing release ${VERSION} into ${release_dir}"
install -d -m 0755 "${release_dir}/bin" "${release_dir}/frontend"
cp -P "${bundle}/bin/aether-gateway" "${release_dir}/bin/aether-gateway"
cp -RP "${bundle}/frontend/." "${release_dir}/frontend/"
validate_local_bundle_tree "${release_dir}"
chmod -R u=rwX,go=rX "${release_dir}"
if is_darwin; then
chown -R root:"${SERVICE_GROUP}" "${release_dir}"
else
chown -R root:"${SERVICE_GROUP}" "${release_dir}"
fi
chmod -R u=rwX,go=rX "${release_dir}"
switch_current_release_link "${release_dir}" "${current_link}"
}
prune_old_releases() {
local keep="${RELEASE_KEEP}"
[[ "${keep}" =~ ^[0-9]+$ ]] || return 0
[[ "${keep}" -gt 0 ]] || return 0
local releases_dir="${INSTALL_ROOT}/releases"
[[ -d "${releases_dir}" ]] || return 0
local current_target
current_target="$(readlink "${INSTALL_ROOT}/current" 2>/dev/null || true)"
current_target="$(basename "${current_target}" 2>/dev/null || true)"
local releases_dir_real
releases_dir_real="$(cd -- "${releases_dir}" && pwd -P)"
local dir name parent_real
local -a safe_release_dirs=()
for dir in "${releases_dir}"/*; do
[[ -d "${dir}" && ! -L "${dir}" ]] || continue
name="$(basename -- "${dir}")"
is_safe_release_identifier "${name}" || continue
parent_real="$(cd -- "$(dirname -- "${dir}")" && pwd -P)"
[[ "${parent_real}" == "${releases_dir_real}" ]] || continue
[[ "${name}" != "${current_target}" ]] || continue
safe_release_dirs+=("${dir}")
done
local count="${#safe_release_dirs[@]}"
if [[ "${count}" -ge "${keep}" ]]; then
local to_remove
to_remove="$(ls -1dt -- "${safe_release_dirs[@]}" 2>/dev/null | tail -n +$((keep)))"
local removed=0
while IFS= read -r dir; do
[[ -n "${dir}" ]] || continue
[[ -d "${dir}" && ! -L "${dir}" ]] || continue
name="$(basename -- "${dir}")"
is_safe_release_identifier "${name}" || continue
parent_real="$(cd -- "$(dirname -- "${dir}")" && pwd -P)"
[[ "${parent_real}" == "${releases_dir_real}" ]] || continue
info "pruning old release: $(basename "${dir}")"
rm -rf -- "${dir}"
removed=$((removed + 1))
done <<< "${to_remove}"
if [[ "${removed}" -gt 0 ]]; then
if ui_is_zh; then
info "已清理 ${removed} 个旧版本(保留最新 ${keep} 个)"
else
info "pruned ${removed} old release(s), keeping latest ${keep}"
fi
fi
fi
}
render_systemd_unit() {
cat <<EOF
[Unit]
Description=Aether Gateway
Documentation=https://github.com/${REPO}
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${SERVICE_USER}
Group=${SERVICE_GROUP}
WorkingDirectory=${INSTALL_ROOT}/current
EnvironmentFile=${ENV_TARGET}
ExecStart=${INSTALL_ROOT}/current/bin/aether-gateway
Restart=on-failure
RestartSec=3
TimeoutStopSec=20
UMask=0027
LimitNOFILE=65535
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
}
install_systemd_unit() {
local rendered_unit unit_dir
rendered_unit="$(mktemp)"
render_systemd_unit > "${rendered_unit}"
info "installing systemd unit to ${SYSTEMD_UNIT_PATH}"
unit_dir="$(dirname -- "${SYSTEMD_UNIT_PATH}")"
ensure_privileged_directory "${unit_dir}" 0755 root root
atomic_install_managed_file \
"${rendered_unit}" "${SYSTEMD_UNIT_PATH}" 0644 root root
rm -f -- "${rendered_unit}"
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}" >/dev/null
}
restart_service_if_requested() {
if [[ "${SKIP_START}" == "true" ]]; then
info "skipping service restart"
return
fi
info "restarting ${SERVICE_NAME}"
systemctl restart "${SERVICE_NAME}"
}
print_systemd_next_steps() {
local gateway_port
local database_driver
local database_url
gateway_port="$(awk -F= '/^[[:space:]]*APP_PORT=/{print $2}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')"
gateway_port="${gateway_port:-8084}"
database_driver="$(awk -F= '/^[[:space:]]*AETHER_DATABASE_DRIVER=/{print tolower($2)}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')"
database_url="$(awk -F= '/^[[:space:]]*(AETHER_DATABASE_URL|DATABASE_URL|AETHER_GATEWAY_DATA_POSTGRES_URL)=/{print $2}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')"
cat <<EOF
Install complete.
Gateway service:
sudo systemctl status ${SERVICE_NAME} --no-pager
sudo journalctl -u ${SERVICE_NAME} -n 100 --no-pager
sudo journalctl -u ${SERVICE_NAME} -f
Health checks:
curl -fsS http://127.0.0.1:${gateway_port}/_gateway/health
curl -fsS http://127.0.0.1:${gateway_port}/readyz
Install directory:
${INSTALL_ROOT}
data: ${INSTALL_ROOT}/data
logs: ${INSTALL_ROOT}/logs
EOF
if [[ "${database_driver}" == "sqlite" || "${database_url}" == sqlite:* ]]; then
cat <<EOF
SQLite data:
${database_url#sqlite://}
EOF
fi
cat <<EOF
Database:
schema migrations and data backfills are prepared automatically before startup
Current release:
${INSTALL_ROOT}/current
EOF
}
launchd_wrapper_path() {
printf '%s/bin/%s-launchd\n' "${INSTALL_ROOT}" "${SERVICE_NAME}"
}
install_launchd_support_files() {
install_config_dir
write_generate_keys_script "${CONFIG_DIR}/generate_keys.sh"
}
write_launchd_wrapper() {
local wrapper wrapper_dir rendered
wrapper="$(launchd_wrapper_path)"
wrapper_dir="$(dirname -- "${wrapper}")"
ensure_privileged_directory "${wrapper_dir}" 0755 root wheel
rendered="$(mktemp)"
{
cat <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
EOF
printf 'ENV_TARGET=%q\n' "${ENV_TARGET}"
printf 'AETHER_BIN=%q\n' "${INSTALL_ROOT}/current/bin/aether-gateway"
cat <<'EOF'
trim_whitespace() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "${value}"
}
strip_optional_quotes() {
local value="$1"
if [[ ${#value} -ge 2 ]]; then
if [[ "${value:0:1}" == "\"" && "${value: -1}" == "\"" ]]; then
value="${value:1:${#value}-2}"
elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then
value="${value:1:${#value}-2}"
fi
fi
printf '%s' "${value}"
}
if [[ ! -r "${ENV_TARGET}" ]]; then
echo "Aether env file not found or not readable: ${ENV_TARGET}" >&2
exit 1
fi
while IFS= read -r raw_line || [[ -n "${raw_line}" ]]; do
line="${raw_line%$'\r'}"
line="$(trim_whitespace "${line}")"
[[ -z "${line}" ]] && continue
[[ "${line:0:1}" == "#" ]] && continue
if [[ "${line}" == export\ * || ! "${line}" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then
echo "Invalid Aether env line: ${line}" >&2
exit 1
fi
key="${line%%=*}"
value="${line#*=}"
value="$(strip_optional_quotes "${value}")"
export "${key}=${value}"
done < "${ENV_TARGET}"
exec "${AETHER_BIN}"
EOF
} > "${rendered}"
atomic_install_managed_file "${rendered}" "${wrapper}" 0755 root wheel
rm -f -- "${rendered}"
}
xml_escape() {
local value="$1"
value="${value//&/&amp;}"
value="${value//</&lt;}"
value="${value//>/&gt;}"
value="${value//\"/&quot;}"
value="${value//\'/&apos;}"
printf '%s' "${value}"
}
render_launchd_plist() {
local wrapper label service_user service_group working_directory stdout_path stderr_path
wrapper="$(xml_escape "$(launchd_wrapper_path)")"
label="$(xml_escape "${LAUNCHD_LABEL}")"
service_user="$(xml_escape "${SERVICE_USER}")"
service_group="$(xml_escape "${SERVICE_GROUP}")"
working_directory="$(xml_escape "${INSTALL_ROOT}/current")"
stdout_path="$(xml_escape "${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.out.log")"
stderr_path="$(xml_escape "${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.err.log")"
cat <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${label}</string>
<key>ProgramArguments</key>
<array>
<string>${wrapper}</string>
</array>
<key>UserName</key>
<string>${service_user}</string>
<key>GroupName</key>
<string>${service_group}</string>
<key>WorkingDirectory</key>
<string>${working_directory}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>${stdout_path}</string>
<key>StandardErrorPath</key>
<string>${stderr_path}</string>
<key>Umask</key>
<integer>23</integer>
</dict>
</plist>
EOF
}
install_launchd_log_files() {
local path staged
local -a log_paths=(
"${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.out.log"
"${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.err.log"
)
ensure_privileged_directory "${LAUNCHD_LOG_DIR}" 0755 root wheel
for path in "${log_paths[@]}"; do
validate_managed_regular_file "${path}" false
done
for path in "${log_paths[@]}"; do
if [[ ! -e "${path}" ]]; then
staged="$(mktemp)"
atomic_install_managed_file \
"${staged}" "${path}" 0640 "${SERVICE_USER}" "${SERVICE_GROUP}"
rm -f -- "${staged}"
else
chown "${SERVICE_USER}:${SERVICE_GROUP}" "${path}"
chmod 0640 "${path}"
validate_managed_regular_file "${path}" false
fi
done
}
install_launchd_unit() {
local rendered_plist plist_dir
rendered_plist="$(mktemp)"
render_launchd_plist > "${rendered_plist}"
info "installing launchd plist to ${LAUNCHD_PLIST_PATH}"
plist_dir="$(dirname -- "${LAUNCHD_PLIST_PATH}")"
ensure_privileged_directory "${plist_dir}" 0755 root wheel
validate_managed_regular_file "${LAUNCHD_PLIST_PATH}" true
install_launchd_log_files
atomic_install_managed_file \
"${rendered_plist}" "${LAUNCHD_PLIST_PATH}" 0644 root wheel
rm -f -- "${rendered_plist}"
}
restart_launchd_if_requested() {
if [[ "${SKIP_START}" == "true" ]]; then
info "skipping launchd service restart"
return
fi
info "restarting ${LAUNCHD_LABEL} with launchd"
launchctl bootout system "${LAUNCHD_PLIST_PATH}" >/dev/null 2>&1 || true
launchctl bootstrap system "${LAUNCHD_PLIST_PATH}"
launchctl kickstart -k "system/${LAUNCHD_LABEL}"
}
print_launchd_next_steps() {
local gateway_port
local database_driver
local database_url
gateway_port="$(awk -F= '/^[[:space:]]*APP_PORT=/{print $2}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')"
gateway_port="${gateway_port:-8084}"
database_driver="$(awk -F= '/^[[:space:]]*AETHER_DATABASE_DRIVER=/{print tolower($2)}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')"
database_url="$(awk -F= '/^[[:space:]]*(AETHER_DATABASE_URL|DATABASE_URL|AETHER_GATEWAY_DATA_POSTGRES_URL)=/{print $2}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')"
cat <<EOF
Install complete.
Gateway service:
sudo launchctl print system/${LAUNCHD_LABEL}
sudo launchctl kickstart -k system/${LAUNCHD_LABEL}
sudo launchctl bootout system ${LAUNCHD_PLIST_PATH}
Logs:
tail -f ${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.out.log ${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.err.log
Health checks:
curl -fsS http://127.0.0.1:${gateway_port}/_gateway/health
curl -fsS http://127.0.0.1:${gateway_port}/readyz
Install directory:
${INSTALL_ROOT}
data: ${INSTALL_ROOT}/data
logs: ${INSTALL_ROOT}/logs
EOF
if [[ "${database_driver}" == "sqlite" || "${database_url}" == sqlite:* ]]; then
cat <<EOF
SQLite data:
${database_url#sqlite://}
EOF
fi
cat <<EOF
Database:
schema migrations and data backfills are prepared automatically before startup
Current release:
${INSTALL_ROOT}/current
EOF
}
install_systemd_mode() {
local bundle="$1"
local env_file="$2"
ensure_service_account
install_systemd_support_files
install_env_file "${env_file}"
validate_env_file "${ENV_TARGET}"
install_release "${bundle}"
prune_old_releases
install_systemd_unit
restart_service_if_requested
print_systemd_next_steps
}
install_launchd_mode() {
local bundle="$1"
local env_file="$2"
ensure_macos_service_account
install_launchd_support_files
install_env_file "${env_file}"
validate_env_file "${ENV_TARGET}"
install_release "${bundle}"
prune_old_releases
write_launchd_wrapper
install_launchd_unit
restart_launchd_if_requested
print_launchd_next_steps
}
main() {
local bundle env_file
parse_args "$@"
select_language
require_supported_os
apply_platform_defaults
select_version
validate_installer_source_identifiers
select_mode
if [[ "${MODE}" == "compose" ]]; then
resolve_compose_release_identity
install_compose_mode
2026-05-16 15:22:42 +08:00
elif [[ "${MODE}" == "compose-single-node" ]]; then
resolve_compose_release_identity
2026-05-16 15:22:42 +08:00
install_compose_single_node_mode
else
require_root
require_service_manager
validate_single_node_managed_paths
bundle="$(local_bundle_dir || true)"
if [[ -z "${bundle}" ]]; then
download_or_unpack_bundle
bundle="${BUNDLE_DIR}"
else
validate_local_bundle_tree "${bundle}"
if [[ -z "${VERSION}" ]]; then
VERSION="$(derive_local_bundle_version "${bundle}")"
fi
validate_release_identifier "${VERSION}"
info "installing from local extracted bundle ${bundle}"
fi
if is_darwin; then
ensure_macos_service_account
fi
env_file="$(resolve_service_env_source "${MODE}")"
case "$(install_os)" in
linux)
install_systemd_mode "${bundle}" "${env_file}"
;;
macos)
install_launchd_mode "${bundle}" "${env_file}"
;;
esac
fi
if [[ -n "${ADMIN_PASSWORD_SOURCE}" ]]; then
local password_note
if [[ "${ADMIN_PASSWORD_SOURCE}" == "prompt" ]]; then
password_note="set from prompt"
else
password_note="set from ADMIN_PASSWORD"
fi
cat <<EOF
Initial admin:
username: admin
password: ${password_note}
The password is stored in the generated env file. Change it after first login.
EOF
fi
}
if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
main "$@"
fi