Files
2026-06-20 14:26:29 +08:00

376 lines
9.7 KiB
Bash

#!/usr/bin/env bash
set -euo pipefail
SERVICE_NAME="incudal-agent"
CONFIG_DIR="${INCUDAL_CONFIG_DIR:-/etc/incudal-agent}"
CONFIG_FILE="${INCUDAL_CONFIG_FILE:-${CONFIG_DIR}/config.yaml}"
INSTALL_DIR="${INCUDAL_INSTALL_DIR:-/usr/local/bin}"
BIN_PATH="${INCUDAL_AGENT_BIN:-${INSTALL_DIR}/incudal-agent}"
SERVICE_FILE="${INCUDAL_SERVICE_FILE:-/etc/systemd/system/${SERVICE_NAME}.service}"
HEARTBEAT_INTERVAL="${INCUDAL_HEARTBEAT_INTERVAL_SECONDS:-30}"
REQUEST_TIMEOUT="${INCUDAL_REQUEST_TIMEOUT_SECONDS:-10}"
DRY_RUN="${INCUDAL_AGENT_DRY_RUN:-0}"
INSTALL_CONFIG_PATH=""
fail() {
echo "error: $*" >&2
exit 1
}
need_env() {
local name="$1"
if [ -z "${!name:-}" ]; then
fail "${name} is required"
fi
}
detect_os() {
case "$(uname -s)" in
Linux) echo "linux" ;;
*) fail "unsupported OS: $(uname -s)" ;;
esac
}
detect_arch() {
case "$(uname -m)" in
x86_64|amd64) echo "amd64" ;;
aarch64|arm64) echo "arm64" ;;
*) fail "unsupported architecture: $(uname -m)" ;;
esac
}
run() {
if [ "${DRY_RUN}" = "1" ]; then
printf '+'
printf ' %q' "$@"
printf '\n'
return 0
fi
"$@"
}
write_file() {
local path="$1"
local mode="$2"
local owner="$3"
local tmp
if [ "${DRY_RUN}" = "1" ]; then
echo "+ write ${path} mode=${mode} owner=${owner}"
sed 's/^/| /'
return 0
fi
tmp="$(mktemp)"
cat > "${tmp}"
install -d -m 0755 "$(dirname "${path}")"
install -m "${mode}" -o "${owner%%:*}" -g "${owner##*:}" "${tmp}" "${path}"
rm -f "${tmp}"
}
download_binary_once() {
local binary_url="$1"
local target="$2"
local expected_sha256="${3:-}"
local binary_path="${binary_url%%\?*}"
binary_path="${binary_path%%#*}"
if [ "${DRY_RUN}" = "1" ]; then
echo "+ download ${binary_url} -> ${target}"
if [ -n "${expected_sha256}" ]; then
echo "+ verify sha256 ${expected_sha256} ${target}.download"
fi
return 0
fi
install -d -m 0755 "$(dirname "${target}")"
if [[ "${binary_url}" == file://* ]]; then
install -m 0644 "${binary_url#file://}" "${target}.download" || return 1
else
command -v curl >/dev/null 2>&1 || fail "curl is required"
curl -fsSL "${binary_url}" -o "${target}.download" || {
rm -f "${target}.download"
return 1
}
fi
if [ -n "${expected_sha256}" ]; then
verify_sha256 "${target}.download" "${expected_sha256}" || {
rm -f "${target}.download"
return 1
}
fi
if [[ "${binary_path}" == *.gz ]]; then
command -v gzip >/dev/null 2>&1 || {
rm -f "${target}.download"
return 1
}
gzip -dc "${target}.download" > "${target}.tmp" || {
rm -f "${target}.download" "${target}.tmp"
return 1
}
rm -f "${target}.download"
else
mv "${target}.download" "${target}.tmp"
fi
install -m 0755 "${target}.tmp" "${target}"
rm -f "${target}.tmp"
}
download_binary() {
local binary_url="$1"
local target="$2"
local fallback_url="${3:-}"
local expected_sha256="${4:-}"
if download_binary_once "${binary_url}" "${target}" "${expected_sha256}"; then
return 0
fi
if [ -n "${fallback_url}" ]; then
echo "warning: failed to download ${binary_url}, fallback to ${fallback_url}" >&2
download_binary_once "${fallback_url}" "${target}"
return $?
fi
return 1
}
append_query_param() {
local url="$1"
local key="$2"
local value="$3"
if [[ "${url}" == *\?* ]]; then
echo "${url}&${key}=${value}"
else
echo "${url}?${key}=${value}"
fi
}
sha256_file() {
local path="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "${path}" | awk '{print $1}'
return 0
fi
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "${path}" | awk '{print $1}'
return 0
fi
fail "sha256sum or shasum is required"
}
verify_sha256() {
local path="$1"
local expected="$2"
local actual
actual="$(sha256_file "${path}")"
if [ "${actual}" != "${expected}" ]; then
echo "warning: sha256 mismatch for ${path}: expected=${expected} actual=${actual}" >&2
return 1
fi
}
download_manifest() {
local manifest_url="$1"
local target="$2"
if [ "${DRY_RUN}" = "1" ]; then
echo "+ download ${manifest_url} -> ${target}"
return 0
fi
command -v curl >/dev/null 2>&1 || fail "curl is required"
curl -fsSL "${manifest_url}" -o "${target}"
}
manifest_value() {
local manifest_path="$1"
local platform="$2"
local key="$3"
awk -v platform="\"${platform}\"" -v key="\"${key}\"" '
$0 ~ platform { in_platform=1; next }
in_platform && $0 ~ /^[[:space:]]*}/ { exit }
in_platform && $0 ~ key {
line=$0
sub(/^[^:]*:[[:space:]]*/, "", line)
sub(/[,\r]*$/, "", line)
gsub(/^"|"$/, "", line)
print line
exit
}
' "${manifest_path}"
}
validate_binary_name() {
local name="$1"
local os="$2"
local arch="$3"
case "${name}" in
"incudal-agent-${os}-${arch}"|"incudal-agent-${os}-${arch}.gz")
return 0
;;
*)
fail "manifest binary name is invalid for ${os}-${arch}: ${name}"
;;
esac
}
install_binary_atomically() {
local source="$1"
local target="$2"
local next="${target}.new"
if [ "${DRY_RUN}" = "1" ]; then
echo "+ install ${source} -> ${target} (atomic replace)"
return 0
fi
install -d -m 0755 "$(dirname "${target}")"
install -m 0755 "${source}" "${next}"
mv -f "${next}" "${target}"
}
fetch_agent_install_config() {
local install_token="${INCUDAL_AGENT_INSTALL_TOKEN:-}"
if [ -z "${install_token}" ]; then
return 0
fi
local config_url="${PANEL_BASE_URL}/api/agent/install-config/${install_token}"
if [ "${DRY_RUN}" = "1" ]; then
echo "+ fetch ${config_url}"
INCUDAL_AGENT_ID="${INCUDAL_AGENT_ID:-agt_from_install_token}"
INCUDAL_AGENT_SECRET="${INCUDAL_AGENT_SECRET:-ias_from_install_token}"
return 0
fi
command -v curl >/dev/null 2>&1 || fail "curl is required"
INSTALL_CONFIG_PATH="$(mktemp)"
curl -fsSL "${config_url}" -o "${INSTALL_CONFIG_PATH}"
# shellcheck disable=SC1090
. "${INSTALL_CONFIG_PATH}"
rm -f "${INSTALL_CONFIG_PATH}"
INSTALL_CONFIG_PATH=""
}
need_env INCUDAL_PANEL_URL
PANEL_BASE_URL="${INCUDAL_PANEL_URL%/}"
OS="$(detect_os)"
ARCH="$(detect_arch)"
DEFAULT_BINARY_BASE_URL="${PANEL_BASE_URL}/api/agent/binary"
BINARY_BASE_URL="${INCUDAL_AGENT_BINARY_BASE_URL:-${DEFAULT_BINARY_BASE_URL}}"
BINARY_NAME="incudal-agent-${OS}-${ARCH}"
BINARY_URL="${INCUDAL_AGENT_BINARY_URL:-}"
BINARY_EXPECTED_SHA256="${INCUDAL_AGENT_BINARY_SHA256:-}"
BINARY_FALLBACK_URL=""
MANIFEST_PATH=""
MANIFEST_URL="${INCUDAL_AGENT_MANIFEST_URL:-${PANEL_BASE_URL}/api/agent/manifest.json}"
STAGED_BIN="${BIN_PATH}.download.$$"
cleanup() {
rm -f "${STAGED_BIN}" "${STAGED_BIN}.download" "${STAGED_BIN}.tmp" "${BIN_PATH}.new"
if [ -n "${INSTALL_CONFIG_PATH:-}" ]; then
rm -f "${INSTALL_CONFIG_PATH}"
fi
if [ -n "${MANIFEST_PATH:-}" ]; then
rm -f "${MANIFEST_PATH}"
fi
}
trap cleanup EXIT
fetch_agent_install_config
need_env INCUDAL_AGENT_ID
need_env INCUDAL_AGENT_SECRET
if [ -z "${INCUDAL_AGENT_BINARY_URL:-}" ]; then
# Cloudflare 等边缘缓存可能缓存旧二进制;默认面板下载强制按安装批次换 URL。
BINARY_CACHE_BUSTER="${INCUDAL_AGENT_BINARY_CACHE_BUSTER:-$(date +%s)}"
MANIFEST_URL="$(append_query_param "${MANIFEST_URL}" "v" "${BINARY_CACHE_BUSTER}")"
MANIFEST_PATH="${BIN_PATH}.manifest.$$"
download_manifest "${MANIFEST_URL}" "${MANIFEST_PATH}"
if [ "${DRY_RUN}" != "1" ]; then
BINARY_NAME="$(manifest_value "${MANIFEST_PATH}" "${OS}-${ARCH}" "name")"
BINARY_EXPECTED_SHA256="$(manifest_value "${MANIFEST_PATH}" "${OS}-${ARCH}" "sha256")"
if [ -z "${BINARY_NAME}" ] || [ -z "${BINARY_EXPECTED_SHA256}" ]; then
fail "agent manifest does not contain ${OS}-${ARCH} binary metadata"
fi
validate_binary_name "${BINARY_NAME}" "${OS}" "${ARCH}"
else
BINARY_NAME="${BINARY_NAME}.gz"
fi
BINARY_URL="${BINARY_BASE_URL}/${BINARY_NAME}"
BINARY_URL="$(append_query_param "${BINARY_URL}" "v" "${BINARY_CACHE_BUSTER}")"
elif [ -z "${BINARY_EXPECTED_SHA256}" ]; then
echo "warning: INCUDAL_AGENT_BINARY_URL is set without INCUDAL_AGENT_BINARY_SHA256, skip binary checksum verification" >&2
fi
echo "Installing Incudal Agent"
echo " panel: ${INCUDAL_PANEL_URL}"
echo " agent: ${INCUDAL_AGENT_ID}"
echo " binary: ${BINARY_URL}"
if [ -n "${BINARY_EXPECTED_SHA256}" ]; then
echo " sha256: ${BINARY_EXPECTED_SHA256}"
fi
if [ "${DRY_RUN}" != "1" ]; then
install -d -m 0755 "$(dirname "${STAGED_BIN}")"
fi
# 先下载到临时路径,再原子替换,避免覆盖正在运行的二进制时报 Text file busy。
download_binary "${BINARY_URL}" "${STAGED_BIN}" "${BINARY_FALLBACK_URL}" "${BINARY_EXPECTED_SHA256}"
install_binary_atomically "${STAGED_BIN}" "${BIN_PATH}"
write_file "${CONFIG_FILE}" 0600 root:root <<EOF_CONFIG
panel_url: "${INCUDAL_PANEL_URL}"
agent_id: "${INCUDAL_AGENT_ID}"
agent_secret: "${INCUDAL_AGENT_SECRET}"
heartbeat_interval_seconds: ${HEARTBEAT_INTERVAL}
request_timeout_seconds: ${REQUEST_TIMEOUT}
EOF_CONFIG
write_file "${SERVICE_FILE}" 0644 root:root <<EOF_SERVICE
[Unit]
Description=Incudal Host Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=${BIN_PATH} -config ${CONFIG_FILE}
Restart=always
RestartSec=5
User=root
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
[Install]
WantedBy=multi-user.target
EOF_SERVICE
if [ "${DRY_RUN}" = "1" ]; then
echo "Dry run completed."
exit 0
fi
systemctl daemon-reload
"${BIN_PATH}" -config "${CONFIG_FILE}" -once
systemctl enable "${SERVICE_NAME}"
# 已安装场景下 enable --now 不会重启旧进程;restart 确保升级后立即使用最新二进制和配置。
systemctl restart "${SERVICE_NAME}"
systemctl status "${SERVICE_NAME}" --no-pager --lines=20
echo "Incudal Agent installed or upgraded and started."