shell/install_iot.sh
2026-06-01 15:01:57 +08:00

586 lines
15 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# Default config for:
# curl -fsSL https://punchsky.cn/install_iot.sh | bash
#
# Fill these values before publishing this script at https://punchsky.cn/install_iot.sh.
WORK_DIR="${WORK_DIR:-/opt/app}"
CURRENT_USER="${SUDO_USER:-$(id -un)}"
SERVICE_USER="${SERVICE_USER:-$CURRENT_USER}"
SERVICE_GROUP="${SERVICE_GROUP:-$(id -gn "$SERVICE_USER")}"
# Configure programs here before publishing this script.
# Format:
# INSTALL_<APP>=(
# "service_name"
# "download_url"
# "start_command"
# "stop_command"
# "service_user"
# "service_group"
# )
#
# IOT_ENV is rendered into the systemd service file as Environment= lines,
# using the [prod] section from iot/env.file. Directory creation is driven by
# those service env values. IOT_DIR_ENV_KEYS lists env keys whose values are
# directories that must be created if they do not exist. IOT_PARENT_DIR_ENV_KEYS
# lists env keys whose values are file paths; their parent directories must be
# created if they do not exist.
#
# Use {install_dir}, {work_dir} and {service_name} in commands and directories.
# service_user and service_group are optional.
INSTALL_IOT=(
"iot"
"https://punchsky.cn/down/iot-0.1.0.tar.gz"
"{install_dir}/bin/iot foreground"
"{install_dir}/bin/iot stop"
""
""
)
IOT_ENV=(
"IOT_API_URL=http://127.0.0.1/api/v1"
"ENDPOINT_ROOT_DIR=/var/lib/endpoint/database/"
"ENDPOINT_LOG_PATH=/var/lib/endpoint/endpoint_log"
"IOT_MNESIA_DIR=/var/lib/iot/mnesia/"
"IOT_CTRL_SOCKET_PATH=/var/lib/iot/ctl.sock"
)
IOT_DIR_ENV_KEYS=(
"ENDPOINT_ROOT_DIR"
"ENDPOINT_LOG_PATH"
"IOT_MNESIA_DIR"
)
IOT_PARENT_DIR_ENV_KEYS=(
"IOT_CTRL_SOCKET_PATH"
)
TEMP_FILES=()
INSTALLED_SERVICES=()
usage() {
cat <<'EOF'
Usage:
curl -fsSL https://punchsky.cn/install_iot.sh | bash
curl -fsSL https://punchsky.cn/install_iot.sh | bash -s -- \
-w <work_dir>
Config:
Configure programs inside this script before publishing it.
Options:
-w, --work-dir Working directory used for installation.
--user Default service user. Default: current user.
--group Default service group. Default: current user's primary group.
-h, --help Show this help.
Array config inside script:
INSTALL_IOT
IOT_ENV
IOT_DIR_ENV_KEYS
IOT_PARENT_DIR_ENV_KEYS
Package format:
INSTALL_<APP>=(
"service_name"
"download_url"
"start_command"
"stop_command"
"service_user"
"service_group"
)
Placeholders:
{install_dir} will be replaced with the extracted package directory.
{work_dir} will be replaced with the root work directory.
{service_name} will be replaced with the service name.
Example:
curl -fsSL https://punchsky.cn/install_iot.sh | bash
EOF
}
log() {
printf '[install] %s\n' "$*"
}
cleanup() {
local file
set +u
for file in "${TEMP_FILES[@]}"; do
[[ -n "$file" ]] && rm -f "$file"
done
set -u
}
fail() {
printf '[install][error] %s\n' "$*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1"
}
require_option_value() {
local option="$1"
local value="${2:-}"
[[ -n "$value" ]] || fail "$option requires a value"
}
require_root_runner() {
if [[ "${EUID}" -ne 0 ]]; then
require_command sudo
fi
}
run_as_root() {
if [[ "${EUID}" -eq 0 ]]; then
"$@"
else
sudo "$@"
fi
}
strip_archive_suffix() {
local filename="$1"
case "$filename" in
*.tar.gz) printf '%s\n' "${filename%.tar.gz}" ;;
*.tgz) printf '%s\n' "${filename%.tgz}" ;;
*) fail "Unsupported archive suffix: $filename. Expected .tar.gz or .tgz" ;;
esac
}
archive_name_from_url() {
local download_url="$1"
local clean_url="$download_url"
clean_url="${clean_url%%\?*}"
clean_url="${clean_url%%#*}"
basename "$clean_url"
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
-w|--work-dir)
require_option_value "$1" "${2:-}"
WORK_DIR="${2:-}"
shift 2
;;
--user)
require_option_value "$1" "${2:-}"
SERVICE_USER="${2:-}"
shift 2
;;
--group)
require_option_value "$1" "${2:-}"
SERVICE_GROUP="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
}
validate_args() {
[[ -n "$WORK_DIR" ]] || fail "--work-dir is required"
[[ "$WORK_DIR" = /* ]] || fail "--work-dir must be an absolute path"
require_install_config INSTALL_IOT
}
prepare_work_dir() {
log "Creating root work directory: $WORK_DIR"
run_as_root install -d -m 0755 "$WORK_DIR"
}
render_service_file() {
local service_name="$1"
local service_user="$2"
local service_group="$3"
local install_dir="$4"
local start_command="$5"
local stop_command="$6"
local env_line
cat <<EOF
[Unit]
Description=${service_name} service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${service_user}
Group=${service_group}
WorkingDirectory=${install_dir}
EOF
while IFS= read -r env_line; do
[[ -n "$env_line" ]] || continue
validate_environment_line "$env_line"
printf 'Environment="%s"\n' "$env_line"
done < <(service_env_lines "$service_name")
printf 'ExecStart=%s\n' "$start_command"
if [[ -n "$stop_command" ]]; then
printf 'ExecStop=%s\n' "$stop_command"
fi
cat <<'EOF'
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
}
write_service_file() {
local service_file="$1"
local service_name="$2"
local service_user="$3"
local service_group="$4"
local install_dir="$5"
local start_command="$6"
local stop_command="$7"
if [[ -f "$service_file" ]]; then
local backup_file="${service_file}.bak.$(date +%Y%m%d%H%M%S)"
log "Backing up existing service file: $backup_file"
run_as_root cp "$service_file" "$backup_file"
fi
log "Writing systemd service file: $service_file"
render_service_file "$service_name" "$service_user" "$service_group" "$install_dir" "$start_command" "$stop_command" | run_as_root tee "$service_file" >/dev/null
}
validate_install_item() {
local service_name="$1"
local download_url="$2"
local start_command="$3"
[[ -n "$service_name" ]] || fail "Invalid package config: service name is empty"
[[ -n "$download_url" ]] || fail "Invalid package config for $service_name: download URL is empty"
[[ -n "$start_command" ]] || fail "Invalid package config for $service_name: start command is empty"
[[ "$service_name" =~ ^[a-zA-Z0-9_.@-]+$ ]] || fail "Invalid service name: $service_name"
}
require_install_config() {
local config_name="$1"
local config_count
local service_name
local download_url
local start_command
eval "config_count=\${#${config_name}[@]}"
eval "service_name=\"\${${config_name}[0]:-}\""
eval "download_url=\"\${${config_name}[1]:-}\""
eval "start_command=\"\${${config_name}[2]:-}\""
[[ "$config_count" -ge 4 ]] || fail "Invalid $config_name config. Expected service name, URL and commands"
[[ -n "$service_name" ]] || fail "Invalid $config_name config: service name is empty"
[[ -n "$download_url" ]] || fail "Invalid $config_name config: download URL is empty"
[[ -n "$start_command" ]] || fail "Invalid $config_name config: start command is empty"
}
replace_placeholders() {
local value="$1"
local install_dir="$2"
local service_name="$3"
value="${value//\{install_dir\}/$install_dir}"
value="${value//\{work_dir\}/$WORK_DIR}"
value="${value//\{service_name\}/$service_name}"
printf '%s\n' "$value"
}
validate_environment_line() {
local env_line="$1"
local env_key="${env_line%%=*}"
[[ "$env_line" == *=* ]] || fail "Invalid environment line: $env_line"
[[ "$env_key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || fail "Invalid environment key: $env_key"
}
service_env_lines() {
local service_name="$1"
local env_array_name=""
local env_count
local index
local env_line
case "$service_name" in
iot)
env_array_name="IOT_ENV"
;;
*)
return 0
;;
esac
eval "env_count=\${#${env_array_name}[@]}"
for ((index=0; index<env_count; index++)); do
eval "env_line=\"\${${env_array_name}[$index]}\""
printf '%s\n' "$env_line"
done
}
service_dir_env_keys() {
local service_name="$1"
local dir_keys_array_name=""
local dir_keys_count
local index
local env_key
case "$service_name" in
iot)
dir_keys_array_name="IOT_DIR_ENV_KEYS"
;;
*)
return 0
;;
esac
eval "dir_keys_count=\${#${dir_keys_array_name}[@]}"
for ((index=0; index<dir_keys_count; index++)); do
eval "env_key=\"\${${dir_keys_array_name}[$index]}\""
printf '%s\n' "$env_key"
done
}
service_parent_dir_env_keys() {
local service_name="$1"
local parent_dir_keys_array_name=""
local parent_dir_keys_count
local index
local env_key
case "$service_name" in
iot)
parent_dir_keys_array_name="IOT_PARENT_DIR_ENV_KEYS"
;;
*)
return 0
;;
esac
eval "parent_dir_keys_count=\${#${parent_dir_keys_array_name}[@]}"
for ((index=0; index<parent_dir_keys_count; index++)); do
eval "env_key=\"\${${parent_dir_keys_array_name}[$index]}\""
printf '%s\n' "$env_key"
done
}
env_value_for_key() {
local service_name="$1"
local env_key="$2"
local env_line
local value
while IFS= read -r env_line; do
[[ "$env_line" == "${env_key}="* ]] || continue
value="${env_line#*=}"
printf '%s\n' "$value"
return 0
done < <(service_env_lines "$service_name")
fail "Missing env key $env_key for $service_name"
}
create_dependency_dirs_from_env() {
local service_name="$1"
local install_dir="$2"
local service_user="$3"
local service_group="$4"
local env_key
local dependency_dir
while IFS= read -r env_key; do
[[ -n "$env_key" ]] || continue
dependency_dir="$(env_value_for_key "$service_name" "$env_key")"
dependency_dir="$(replace_placeholders "$dependency_dir" "$install_dir" "$service_name")"
[[ "$dependency_dir" = /* ]] || fail "Dependency directory must be an absolute path: $dependency_dir"
log "Creating dependency directory from $env_key: $dependency_dir"
run_as_root install -d -m 0755 "$dependency_dir"
run_as_root chown -R "${service_user}:${service_group}" "$dependency_dir"
done < <(service_dir_env_keys "$service_name")
}
create_parent_dependency_dirs_from_env() {
local service_name="$1"
local install_dir="$2"
local service_user="$3"
local service_group="$4"
local env_key
local dependency_path
local dependency_dir
while IFS= read -r env_key; do
[[ -n "$env_key" ]] || continue
dependency_path="$(env_value_for_key "$service_name" "$env_key")"
dependency_path="$(replace_placeholders "$dependency_path" "$install_dir" "$service_name")"
[[ "$dependency_path" = /* ]] || fail "Dependency path must be an absolute path: $dependency_path"
dependency_dir="$(dirname "$dependency_path")"
[[ -n "$dependency_dir" && "$dependency_dir" != "." ]] || fail "Cannot parse parent directory from path: $dependency_path"
log "Creating parent dependency directory from $env_key: $dependency_dir"
run_as_root install -d -m 0755 "$dependency_dir"
run_as_root chown -R "${service_user}:${service_group}" "$dependency_dir"
done < <(service_parent_dir_env_keys "$service_name")
}
link_iot_ctrl() {
local install_dir="$1"
local iot_ctrl_path="${install_dir}/bin/iot_ctrl"
local link_path="/usr/local/bin/iot_ctrl"
[[ -x "$iot_ctrl_path" ]] || fail "Missing executable iot control tool: $iot_ctrl_path. Build it before packaging the release"
log "Linking iot control tool: $link_path -> $iot_ctrl_path"
run_as_root install -d -m 0755 "$(dirname "$link_path")"
run_as_root ln -sfn "$iot_ctrl_path" "$link_path"
}
install_program() {
local service_name="$1"
local download_url="$2"
local start_command="$3"
local stop_command="$4"
local service_user="$5"
local service_group="$6"
local archive_name
validate_install_item "$service_name" "$download_url" "$start_command"
archive_name="$(archive_name_from_url "$download_url")"
[[ -n "$archive_name" ]] || fail "Cannot parse archive filename from URL: $download_url"
strip_archive_suffix "$archive_name" >/dev/null
local archive_path="${WORK_DIR}/${archive_name}"
local install_dir="${WORK_DIR}/${service_name}"
local service_file="/etc/systemd/system/${service_name}.service"
local temp_archive
temp_archive="$(mktemp)"
TEMP_FILES+=("$temp_archive")
service_user="${service_user:-$SERVICE_USER}"
service_group="${service_group:-$SERVICE_GROUP}"
start_command="$(replace_placeholders "$start_command" "$install_dir" "$service_name")"
stop_command="$(replace_placeholders "$stop_command" "$install_dir" "$service_name")"
log "Downloading $download_url"
wget --tries=3 --timeout=20 -O "$temp_archive" "$download_url"
run_as_root install -m 0644 "$temp_archive" "$archive_path"
log "Creating install directory: $install_dir"
run_as_root install -d -m 0755 "$install_dir"
log "Extracting archive to $install_dir"
run_as_root tar -zxvf "$archive_path" -C "$install_dir"
log "Removing downloaded archive: $archive_path"
run_as_root rm -f "$archive_path"
log "Setting owner for install directory: ${service_user}:${service_group}"
run_as_root chown -R "${service_user}:${service_group}" "$install_dir"
create_dependency_dirs_from_env "$service_name" "$install_dir" "$service_user" "$service_group"
create_parent_dependency_dirs_from_env "$service_name" "$install_dir" "$service_user" "$service_group"
link_iot_ctrl "$install_dir"
write_service_file "$service_file" "$service_name" "$service_user" "$service_group" "$install_dir" "$start_command" "$stop_command"
INSTALLED_SERVICES+=("$service_name")
}
install_program_from_config() {
local config_name="$1"
local service_name
local download_url
local start_command
local stop_command
local service_user
local service_group
eval "service_name=\"\${${config_name}[0]:-}\""
eval "download_url=\"\${${config_name}[1]:-}\""
eval "start_command=\"\${${config_name}[2]:-}\""
eval "stop_command=\"\${${config_name}[3]:-}\""
eval "service_user=\"\${${config_name}[4]:-}\""
eval "service_group=\"\${${config_name}[5]:-}\""
install_program \
"$service_name" \
"$download_url" \
"$start_command" \
"$stop_command" \
"$service_user" \
"$service_group"
}
install_programs() {
install_program_from_config INSTALL_IOT
}
reload_systemd() {
log "Reloading systemd"
run_as_root systemctl daemon-reload
}
enable_installed_services() {
local service_name
set +u
for service_name in "${INSTALLED_SERVICES[@]}"; do
set -u
log "Enabling service on boot: ${service_name}.service"
run_as_root systemctl enable "${service_name}.service"
set +u
done
set -u
}
main() {
trap cleanup EXIT
parse_args "$@"
validate_args
require_command wget
require_root_runner
require_command dirname
require_command ln
require_command tar
require_command systemctl
prepare_work_dir
install_programs
reload_systemd
enable_installed_services
log "IOT installation completed"
}
main "$@"