#!/usr/bin/env bash set -Eeuo pipefail umask 077 readonly app_name="AiTECH Manager" readonly app_version="1.2.0" readonly install_root="/opt/aitech-manager" readonly build_dir="${install_root}/build" readonly bin_dir="${install_root}/bin" readonly config_dir="${install_root}/config" readonly log_dir="${install_root}/logs" readonly temp_dir="${install_root}/tmp" readonly system_config_dir="/etc/aitech-manager" readonly system_certificate_dir="${system_config_dir}/certificates" readonly system_log_dir="/var/log/aitech-manager" readonly system_temp_dir="/var/tmp/aitech-manager" readonly core_binary="${build_dir}/aitech-core" readonly launcher="${bin_dir}/aitech" readonly system_launcher="/usr/local/bin/aitech" readonly manager_config="${config_dir}/manager.conf" readonly server_public_key="${config_dir}/server_public.pem" readonly verify_service="/etc/systemd/system/aitech-manager-verify.service" readonly verify_timer="/etc/systemd/system/aitech-manager-verify.timer" readonly verify_timer_name="aitech-manager-verify.timer" readonly release_repository="Alouk0/aitech-manager-releases" readonly release_tag="v1.2.0-websocket-pro.16" readonly release_binary_name="aitech-core-linux-amd64" readonly release_binary_url="https://github.com/${release_repository}/releases/download/${release_tag}/${release_binary_name}" readonly release_checksum_url="https://github.com/${release_repository}/releases/download/${release_tag}/${release_binary_name}.sha256" readonly release_public_key_url="https://github.com/${release_repository}/releases/download/${release_tag}/server_public.pem" readonly expected_binary_sha256="e3a1d3e89819f64b0d94ee47007b3747c6db63f905229fe8e75b39140494e6b2" readonly expected_public_key_sha256="02213a4a045dd8629e372115fa08861df123d491cff9922398f3e157b8bea06d" readonly license_server_url="https://install.fastlysmarty.co.uk/api" readonly support_telegram_url="https://t.me/Aluk0" readonly backup_base="/opt/aitech-manager-backups/client-installer" license_key="${AITECH_LICENSE_KEY:-}" server_domain="${AITECH_DOMAIN:-}" dnstt_nameserver="${AITECH_DNSTT_NS:-}" dnstt_zone="${AITECH_DNSTT_ZONE:-}" dnstt_ns_target="" license_tty_state="" check_only=0 temporary_directory="" backup_directory="" installation_started=0 installation_completed=0 resolved_binary_sha256="" previous_timer_enabled=0 previous_timer_active=0 previous_certbot_timer_exists=0 previous_certbot_timer_active=0 previous_certbot_timer_enabled="" previous_acme_home_exists=0 previous_acme_target_exists=0 previous_acme_account_exists=0 previous_acme_ca_exists=0 previous_root_crontab_exists=0 previous_ipv4_forward="" provision_service_enabled=() provision_service_active=() provision_service_failed=() readonly -a provision_service_units=( "ssh.service" "ssh.socket" "nginx.service" "aitech-openvpn-connection-limit.service" "openvpn-server@server.service" "openvpn-server@server-tcp.service" "aitech-openvpn-websocket.service" "openvpn.service" "aitech-openvpn-firewall.service" "aitech-dropbear.service" "aitech-ssh-websocket.service" "aitech-stunnel.service" "aitech-badvpn.service" "xray.service" "aitech-manager-xray-expiry.service" "aitech-manager-xray-expiry.timer" "hysteria-server.service" "aitech-dnstt.service" "aitech-udp53-mux.service" ) readonly -a provision_paths=( "$system_config_dir" "$system_log_dir" "$system_temp_dir" "/etc/openvpn" "/var/lib/openvpn/server/ipp-tcp.txt" "/var/log/openvpn/status-tcp.log" "/var/log/openvpn/server-tcp.log" "/etc/nginx" "/etc/stunnel" "/etc/ssh" "/etc/sysctl.d" "/etc/localtime" "/etc/timezone" "/usr/local/sbin/aitech-dropbear" "/usr/local/bin/aitech-ssh-websocket" "/usr/local/bin/aitech-badvpn-udpgw" "/usr/local/sbin/aitech-badvpn-udpgw" "/usr/local/bin/xray" "/usr/local/etc/xray" "/etc/xray" "/usr/local/bin/hysteria" "/etc/hysteria" "/usr/local/bin/dnstt-server" "/etc/systemd/system/aitech-openvpn-firewall.service" "/etc/systemd/system/aitech-openvpn-connection-limit.service" "/etc/systemd/system/aitech-openvpn-websocket.service" "/etc/systemd/system/openvpn-server@server.service.d/aitech-connection-limit.conf" "/etc/systemd/system/openvpn-server@server-tcp.service.d/aitech-connection-limit.conf" "/etc/systemd/system/aitech-dropbear.service" "/etc/systemd/system/aitech-ssh-websocket.service" "/etc/systemd/system/aitech-stunnel.service" "/etc/systemd/system/aitech-badvpn.service" "/etc/systemd/system/xray.service" "/etc/systemd/system/aitech-manager-xray-expiry.service" "/etc/systemd/system/aitech-manager-xray-expiry.timer" "/etc/systemd/system/hysteria-server.service" "/etc/systemd/system/aitech-dnstt.service" "/etc/systemd/system/aitech-udp53-mux.service" ) usage() { cat <<'USAGE' AiTECH Manager client installer Usage: sudo bash install.sh sudo bash install.sh --license-key sudo bash install.sh --license-key --domain --dnstt-zone sudo bash install.sh --license-key --domain --dnstt-ns sudo bash install.sh --check sudo bash install.sh --help Fresh installations require these DNS records before provisioning: A -> NS -> Use --dnstt-zone (or AITECH_DNSTT_ZONE) for fresh installations. --dnstt-ns and AITECH_DNSTT_NS retain the legacy two-argument provisioning semantics and cannot be combined with the delegated-zone input. One-command interactive installation: wget -qO- https://install.fastlysmarty.co.uk/install.sh | sudo bash The installer does not print the supplied license key. Licence access and deployment support: https://t.me/Aluk0 USAGE } log() { printf '[AiTECH] %s\n' "$*" } fail() { printf '[AiTECH] ERROR: %s\n' "$*" >&2 return 1 } prompt_for_license_key() { local entered_license_key="" command -v stty >/dev/null 2>&1 || fail "stty is required for secure interactive license input" if ! license_tty_state="$( stty -g 2>/dev/null < /dev/tty )"; then license_tty_state="" fail "a client license key is required; no interactive terminal is available; contact ${support_telegram_url}" return 1 fi printf 'Need a licence key? Message AiTECH NETWORKS: %s\n' \ "$support_telegram_url" > /dev/tty printf 'AiTECH Manager license key: ' > /dev/tty if ! stty -echo < /dev/tty; then stty "$license_tty_state" >/dev/null 2>&1 < /dev/tty || true license_tty_state="" fail "unable to disable terminal echo for license input" return 1 fi if ! IFS= read -r entered_license_key < /dev/tty; then stty "$license_tty_state" >/dev/null 2>&1 < /dev/tty || true license_tty_state="" printf '\n' 2>/dev/null > /dev/tty || true fail "unable to read the client license key" return 1 fi if ! stty "$license_tty_state" < /dev/tty; then printf '\n' 2>/dev/null > /dev/tty || true fail "unable to restore terminal settings after license input" return 1 fi license_tty_state="" printf '\n' > /dev/tty license_key="$entered_license_key" entered_license_key="" } resolve_license_key() { license_key="$( printf '%s' "$license_key" | tr -d '\r\n' )" if [ "$check_only" -eq 1 ]; then return 0 fi if [ -z "$license_key" ]; then prompt_for_license_key fi license_key="$( printf '%s' "$license_key" | tr -d '\r\n' )" if [ -z "$license_key" ]; then fail "a client license key is required; contact ${support_telegram_url}" return 1 fi } prompt_for_server_domain() { local entered_domain="" if [ ! -r /dev/tty ]; then fail "a server domain is required; no interactive terminal is available" return 1 fi printf 'AiTECH Manager server domain: ' > /dev/tty if ! IFS= read -r entered_domain < /dev/tty; then fail "unable to read the server domain" return 1 fi server_domain="$entered_domain" } resolve_server_domain() { local label="" local -a labels=() if [ "$check_only" -eq 1 ]; then return 0 fi server_domain="$(printf '%s' "$server_domain" | tr -d '\r\n' | tr '[:upper:]' '[:lower:]')" if [ -z "$server_domain" ]; then prompt_for_server_domain server_domain="$(printf '%s' "$server_domain" | tr -d '\r\n' | tr '[:upper:]' '[:lower:]')" fi if [ -z "$server_domain" ]; then fail "a server domain is required" return 1 fi if [ "${#server_domain}" -gt 253 ]; then fail "server domain is too long" return 1 fi case "$server_domain" in .*|*.|*..*|*[!a-z0-9.-]*) fail "server domain is invalid: $server_domain" return 1 ;; esac case "$server_domain" in *.*) ;; *) fail "server domain must be a fully qualified domain name" return 1 ;; esac IFS='.' read -r -a labels <<< "$server_domain" for label in "${labels[@]}"; do if [ -z "$label" ] || [ "${#label}" -gt 63 ]; then fail "server domain contains an invalid label" return 1 fi case "$label" in -*|*-|*[!a-z0-9-]*) fail "server domain contains an invalid label: $label" return 1 ;; esac done } prompt_for_dnstt_zone() { local entered_zone="" if [ ! -r /dev/tty ]; then fail "a DNSTT delegated zone is required; no interactive terminal is available" return 1 fi printf 'Required DNS: A -> detected VPS IP; NS -> \n' > /dev/tty printf 'AiTECH Manager DNSTT delegated domain/zone: ' > /dev/tty if ! IFS= read -r entered_zone < /dev/tty; then fail "unable to read the DNSTT delegated zone" return 1 fi dnstt_zone="$entered_zone" } resolve_dnstt_configuration() { local label="" local value="" local description="" local -a labels=() dnstt_zone="$(printf '%s' "$dnstt_zone" | tr -d '\r\n' | tr '[:upper:]' '[:lower:]')" dnstt_nameserver="$(printf '%s' "$dnstt_nameserver" | tr -d '\r\n' | tr '[:upper:]' '[:lower:]')" if [ -n "$dnstt_zone" ] && [ -n "$dnstt_nameserver" ]; then fail "--dnstt-zone/AITECH_DNSTT_ZONE cannot be combined with legacy --dnstt-ns/AITECH_DNSTT_NS" return 1 fi if [ "$check_only" -eq 1 ]; then return 0 fi if [ -n "$dnstt_nameserver" ]; then value="$dnstt_nameserver" description="DNSTT nameserver" else if [ -z "$dnstt_zone" ]; then prompt_for_dnstt_zone dnstt_zone="$(printf '%s' "$dnstt_zone" | tr -d '\r\n' | tr '[:upper:]' '[:lower:]')" fi if [ -z "$dnstt_zone" ]; then fail "a DNSTT delegated zone is required" return 1 fi value="$dnstt_zone" description="DNSTT delegated zone" fi if [ "${#value}" -gt 253 ]; then fail "$description is too long" return 1 fi case "$value" in .*|*.|*..*|*[!a-z0-9.-]*) fail "$description is invalid: $value" return 1 ;; esac case "$value" in *.*) ;; *) fail "$description must be a fully qualified domain name" return 1 ;; esac IFS='.' read -r -a labels <<< "$value" for label in "${labels[@]}"; do if [ -z "$label" ] || [ "${#label}" -gt 63 ]; then fail "$description contains an invalid label" return 1 fi case "$label" in -*|*-|*[!a-z0-9-]*) fail "$description contains an invalid label: $label" return 1 ;; esac done if [ -n "$dnstt_zone" ]; then dnstt_ns_target="$server_domain" fi } parse_arguments() { while [ "$#" -gt 0 ]; do case "$1" in --check) check_only=1 ;; --help|-h) usage exit 0 ;; --license-key) shift [ "$#" -gt 0 ] || fail "--license-key requires a value" license_key="$1" ;; --license-key=*) license_key="${1#*=}" ;; --domain) shift [ "$#" -gt 0 ] || fail "--domain requires a value" server_domain="$1" ;; --domain=*) server_domain="${1#*=}" ;; --dnstt-ns) shift [ "$#" -gt 0 ] || fail "--dnstt-ns requires a value" dnstt_nameserver="$1" ;; --dnstt-ns=*) dnstt_nameserver="${1#*=}" ;; --dnstt-zone) shift [ "$#" -gt 0 ] || fail "--dnstt-zone requires a value" dnstt_zone="$1" ;; --dnstt-zone=*) dnstt_zone="${1#*=}" ;; --*) fail "unknown option: $1" ;; *) if [ -n "$license_key" ]; then fail "multiple license keys were supplied" fi license_key="$1" ;; esac shift done license_key="$(printf '%s' "$license_key" | tr -d '\r\n')" } require_root() { if [ "$(id -u)" -ne 0 ]; then fail "run this installer as root or with sudo" fi } platform_numeric_at_least() { local value="$1" minimum="$2" normalized="" [[ "$value" =~ ^[0123456789]+$ ]] || return 1 normalized="${value#"${value%%[!0]*}"}" [ -n "$normalized" ] || normalized="0" if [ "${#normalized}" -ne "${#minimum}" ]; then [ "${#normalized}" -gt "${#minimum}" ] return fi (( 10#$normalized >= 10#$minimum )) } platform_version_supported() { local os_id="$1" version_id="$2" major="" minor="" case "$os_id" in debian) platform_numeric_at_least "$version_id" 11 ;; ubuntu) [[ "$version_id" =~ ^[0123456789]+\.[0123456789]+$ ]] || return 1 major="${version_id%%.*}" minor="${version_id#*.}" platform_numeric_at_least "$minor" 0 || return 1 if platform_numeric_at_least "$major" 21; then return 0 fi platform_numeric_at_least "$major" 20 && platform_numeric_at_least "$minor" 4 ;; *) return 1 ;; esac } detect_platform() { [ -r /etc/os-release ] || fail "/etc/os-release is unavailable" local os_id="" local version_id="" # shellcheck disable=SC1091 . /etc/os-release os_id="${ID:-}" version_id="${VERSION_ID:-}" platform_version_supported "$os_id" "$version_id" || fail "unsupported operating system: ${os_id:-unknown} ${version_id:-unknown}" case "$(uname -m)" in x86_64|amd64) ;; *) fail "unsupported architecture: $(uname -m); this release requires amd64" ;; esac [ -d /run/systemd/system ] || fail "systemd is required on the client VPS" log "Supported platform detected: ${os_id} ${version_id} amd64" } ensure_dependencies() { local missing=() local command="" for command in curl sha256sum install mktemp systemctl systemd-analyze awk tr cp rm ln chmod mkdir date; do if ! command -v "$command" >/dev/null 2>&1; then missing+=("$command") fi done if [ "${#missing[@]}" -eq 0 ]; then return 0 fi command -v apt-get >/dev/null 2>&1 || fail "required commands are missing and apt-get is unavailable: ${missing[*]}" log "Installing required system packages" export DEBIAN_FRONTEND=noninteractive apt-get update apt-get install -y --no-install-recommends ca-certificates curl coreutils systemd for command in curl sha256sum install mktemp systemctl systemd-analyze awk tr cp rm ln chmod mkdir date; do command -v "$command" >/dev/null 2>&1 || fail "required command is still unavailable after package installation: $command" done } create_temporary_directory() { temporary_directory="$(mktemp -d /tmp/aitech-manager-install.XXXXXX)" } download_file() { local url="$1" local destination="$2" curl \ --fail \ --silent \ --show-error \ --location \ --proto '=https' \ --tlsv1.2 \ --retry 5 \ --retry-delay 2 \ --connect-timeout 15 \ --max-time 300 \ --user-agent "AiTECH-Manager-Installer/${app_version}" \ --output "$destination" \ "$url" } verify_release_assets() { local downloaded_binary="${temporary_directory}/${release_binary_name}" local downloaded_checksum="${temporary_directory}/${release_binary_name}.sha256" local downloaded_public_key="${temporary_directory}/server_public.pem" local published_checksum="" local actual_binary_checksum="" local actual_public_key_checksum="" log "Downloading signed release assets from GitHub" download_file "$release_binary_url" "$downloaded_binary" download_file "$release_checksum_url" "$downloaded_checksum" download_file "$release_public_key_url" "$downloaded_public_key" published_checksum="$( awk 'NF >= 1 {print $1; exit}' "$downloaded_checksum" )" [ "$published_checksum" = "$expected_binary_sha256" ] || fail "published binary checksum does not match this installer release" actual_binary_checksum="$( sha256sum "$downloaded_binary" | awk '{print $1}' )" [ "$actual_binary_checksum" = "$published_checksum" ] || fail "downloaded binary does not match the published SHA-256 checksum" resolved_binary_sha256="$actual_binary_checksum" actual_public_key_checksum="$( sha256sum "$downloaded_public_key" | awk '{print $1}' )" [ "$actual_public_key_checksum" = "$expected_public_key_sha256" ] || fail "downloaded license public key failed SHA-256 verification" chmod 0755 "$downloaded_binary" [ "$("$downloaded_binary" version)" = "${app_name} ${app_version}" ] || fail "downloaded binary version verification failed" log "Release assets verified" } record_provision_state() { local unit="" previous_ipv4_forward="$( sysctl -n net.ipv4.ip_forward 2>/dev/null || true )" provision_service_enabled=() provision_service_active=() provision_service_failed=() for unit in "${provision_service_units[@]}"; do if systemctl is-enabled "$unit" >/dev/null 2>&1; then provision_service_enabled+=(1) else provision_service_enabled+=(0) fi if systemctl is-active "$unit" >/dev/null 2>&1; then provision_service_active+=(1) else provision_service_active+=(0) fi if systemctl is-failed --quiet "$unit" >/dev/null 2>&1; then provision_service_failed+=(1) else provision_service_failed+=(0) fi done } backup_provision_state() { local index="" local path="" local destination="" local state_dir="${backup_directory}/provision-state" install -d -m 0700 "$state_dir" for index in "${!provision_paths[@]}"; do path="${provision_paths[$index]}" destination="${state_dir}/path-${index}" if [ -d "$(dirname "$path")" ] || [ -L "$(dirname "$path")" ]; then : > "${destination}.parent-present" fi if [ -e "$path" ] || [ -L "$path" ]; then cp -a -- "$path" "$destination" : > "${destination}.present" fi done backup_certificate_renewal_state } backup_certificate_renewal_state() { local acme_home="/root/.acme.sh" local target="${acme_home}/${server_domain}_ecc" local state_dir="${backup_directory}/certificate-renewal" install -d -m 0700 "$state_dir" if [ -e "$acme_home" ] || [ -L "$acme_home" ]; then [ -d "$acme_home" ] && [ ! -L "$acme_home" ] || fail "unsafe existing ACME home" previous_acme_home_exists=1 fi if [ -e "$target" ] || [ -L "$target" ]; then [ -d "$target" ] && [ ! -L "$target" ] || fail "unsafe existing target ACME lineage" cp -a -- "$target" "${state_dir}/target-lineage" previous_acme_target_exists=1 fi if [ -e "${acme_home}/account.conf" ] || [ -L "${acme_home}/account.conf" ]; then [ -f "${acme_home}/account.conf" ] && [ ! -L "${acme_home}/account.conf" ] || fail "unsafe existing ACME account metadata" cp -a -- "${acme_home}/account.conf" "${state_dir}/account.conf" previous_acme_account_exists=1 fi if [ -e "${acme_home}/ca" ] || [ -L "${acme_home}/ca" ]; then [ -d "${acme_home}/ca" ] && [ ! -L "${acme_home}/ca" ] || fail "unsafe existing ACME account directory" cp -a -- "${acme_home}/ca" "${state_dir}/ca" previous_acme_ca_exists=1 fi if ! command -v crontab >/dev/null 2>&1; then : > "${state_dir}/root-crontab" elif crontab -l > "${state_dir}/root-crontab" 2> "${state_dir}/crontab-error"; then previous_root_crontab_exists=1 elif ! grep -qi 'no crontab for' "${state_dir}/crontab-error"; then fail "cannot snapshot root renewal crontab" fi rm -f -- "${state_dir}/crontab-error" } restore_certificate_renewal_state() { local state_dir="${backup_directory}/certificate-renewal" local acme_home="/root/.acme.sh" local target="${acme_home}/${server_domain}_ecc" local current="${state_dir}/current-crontab" local check="${state_dir}/current-crontab-check" local cron_error="${state_dir}/crontab-read-error" local merged="${state_dir}/merged-crontab" local original="${state_dir}/root-crontab" local current_crontab_exists=0 if [ "$previous_acme_target_exists" -eq 1 ]; then rollback_restore_path "${state_dir}/target-lineage" "$target" 1 || : elif [ -e "$target" ] || [ -L "$target" ]; then rollback_attempt "remove new AiTECH target ACME lineage" rm -rf -- "$target" || : fi if [ "$previous_acme_home_exists" -eq 1 ]; then rollback_restore_path "${state_dir}/account.conf" "${acme_home}/account.conf" "$previous_acme_account_exists" || : rollback_restore_path "${state_dir}/ca" "${acme_home}/ca" "$previous_acme_ca_exists" || : fi if [ "$previous_acme_home_exists" -eq 0 ] && [ -d "$acme_home" ]; then # A newly installed ACME home is owned by this failed transaction. # Refuse to delete it if another lineage appeared concurrently. if find "$acme_home" -mindepth 1 -maxdepth 1 -name '*_ecc' -print -quit | grep -q .; then rollback_fail "new ACME home contains another lineage; retained for manual reconciliation" else rollback_attempt "remove new AiTECH ACME home" rm -rf -- "$acme_home" || : fi fi if ! command -v crontab >/dev/null 2>&1; then if [ "$previous_root_crontab_exists" -eq 0 ]; then return 0; fi rollback_fail "root crontab command disappeared during renewal rollback" return 1 fi if crontab -l > "$current" 2>"$cron_error"; then current_crontab_exists=1 elif grep -qi 'no crontab for' "$cron_error"; then : > "$current" else rollback_fail "cannot read current root crontab during renewal rollback" return 1 fi # Replace only the AiTECH renewal adapter. Preserve unrelated entries that # an administrator may have added while the installer was running. awk '!index($0, "\"/opt/aitech-manager/build/aitech-core\" certificate-renew ")' "$current" > "$merged" if [ "$previous_root_crontab_exists" -eq 1 ]; then awk 'index($0, "\"/opt/aitech-manager/build/aitech-core\" certificate-renew ")' "$original" >> "$merged" awk 'NR==FNR { seen[$0]=1; next } /\/root\/\.acme\.sh.*acme\.sh --cron/ && !seen[$0] { print }' "$current" "$original" >> "$merged" fi if crontab -l > "$check" 2>"$cron_error"; then : elif grep -qi 'no crontab for' "$cron_error"; then : > "$check" else rollback_fail "cannot recheck root crontab during renewal rollback" return 1 fi if ! cmp -s -- "$current" "$check"; then rollback_fail "root crontab changed during renewal rollback; retained for manual reconciliation" return 1 fi if cmp -s -- "$current" "$merged"; then return 0 fi if [ -s "$merged" ]; then rollback_attempt "restore AiTECH renewal entry without replacing unrelated root cron" crontab "$merged" || : elif [ "$current_crontab_exists" -eq 1 ]; then rollback_attempt "remove failed AiTECH renewal entry" crontab -r || : fi } rollback_fail() { rollback_failures+=("$1") printf '[AiTECH] Recovery failure: %s\n' "$1" >&2 } rollback_attempt() { local description="$1" shift if "$@" >/dev/null 2>&1; then return 0 fi rollback_fail "$description" return 1 } rollback_service_action() { local action="$1" unit="$2" state="" if [ "$action" = start ] || [ "$action" = reload-or-restart ]; then if [ "$rollback_services_safe" -ne 1 ]; then rollback_fail "skip $action $unit: service files or shutdown/reload are incomplete" return 1 fi case "$unit" in "$verify_timer_name"|aitech-manager-verify.service|aitech-udp53-mux.service|aitech-manager-xray-expiry.service|aitech-manager-xray-expiry.timer|aitech-ssh-websocket.service|aitech-openvpn-websocket.service|aitech-openvpn-connection-limit.service|openvpn-server@server.service|openvpn-server@server-tcp.service|openvpn.service) if [ "$rollback_core_ready" -ne 1 ]; then rollback_fail "skip $action $unit: previous core was not restored" return 1 fi ;; esac fi if ! state="$(systemctl show "$unit" --property=LoadState --value 2>/dev/null)"; then rollback_fail "inspect $unit before $action" return 1 fi if [ "$state" = not-found ]; then case "$action" in stop|disable|reset-failed) return 0 ;; esac rollback_fail "$action $unit: required unit is missing" return 1 fi if [ -z "$state" ]; then rollback_fail "inspect $unit before $action: empty load state" return 1 fi if [ "$action" = reload-or-restart ]; then if systemctl reload "$unit" >/dev/null 2>&1 || systemctl restart "$unit" >/dev/null 2>&1; then return 0 fi rollback_fail "reload or restart $unit" return 1 fi rollback_attempt "$action $unit" systemctl "$action" "$unit" } rollback_restore_path() { local source="$1" destination="$2" existed="$3" if [ "$existed" -eq 1 ] && [ ! -e "$source" ] && [ ! -L "$source" ]; then rollback_fail "restore $destination: backup object is missing" return 1 fi if ! rollback_attempt "remove failed-install path $destination" rm -rf -- "$destination"; then return 1 fi if [ "$existed" -eq 1 ]; then if ! rollback_attempt "prepare restore parent for $destination" mkdir -p -- "$(dirname "$destination")"; then return 1 fi rollback_attempt "restore $destination" cp -a -- "$source" "$destination" return $? fi return 0 } stop_provision_services() { local index="" local unit="" for ((index=${#provision_service_units[@]}-1; index>=0; index--)); do unit="${provision_service_units[$index]}" case "$unit" in ssh.service|ssh.socket) continue ;; esac rollback_service_action disable "$unit" || : if ! rollback_service_action stop "$unit"; then rollback_services_safe=0 fi done } restore_provision_paths() { local index="" path="" source="" existed=0 parent="" local state_dir="${backup_directory}/provision-state" for index in "${!provision_paths[@]}"; do path="${provision_paths[$index]}" source="${state_dir}/path-${index}" existed=0 [ ! -e "${source}.present" ] || existed=1 if ! rollback_restore_path "$source" "$path" "$existed"; then rollback_services_safe=0 continue fi if [ "$existed" -eq 0 ] && [ ! -e "${source}.parent-present" ]; then parent="$(dirname "$path")" if [ -e "$parent" ] || [ -L "$parent" ]; then # Nonempty parents contain neighboring files and must remain. rollback_attempt "remove empty new parent $parent" \ rmdir --ignore-fail-on-non-empty -- "$parent" || : fi fi done } restore_provision_services() { local index="" local unit="" if ! rollback_attempt "reload restored systemd units" systemctl daemon-reload; then rollback_services_safe=0 fi for index in "${!provision_service_units[@]}"; do unit="${provision_service_units[$index]}" if [ "${provision_service_enabled[$index]:-0}" -eq 1 ]; then rollback_service_action enable "$unit" || : else rollback_service_action disable "$unit" || : fi done for index in "${!provision_service_units[@]}"; do unit="${provision_service_units[$index]}" if [ "$unit" = "ssh.service" ]; then if [ "${provision_service_active[$index]:-0}" -eq 1 ]; then rollback_service_action reload-or-restart "$unit" || : fi continue fi if [ "${provision_service_active[$index]:-0}" -eq 1 ]; then rollback_service_action start "$unit" || : else rollback_service_action stop "$unit" || : fi done for index in "${!provision_service_units[@]}"; do unit="${provision_service_units[$index]}" if [ "${provision_service_failed[$index]:-0}" -eq 0 ]; then rollback_service_action reset-failed "$unit" || : fi done if [ -n "$previous_ipv4_forward" ]; then rollback_attempt "restore IPv4 forwarding" \ sysctl -w "net.ipv4.ip_forward=${previous_ipv4_forward}" || : fi } restore_provisioned_state() { stop_provision_services restore_provision_paths # Services must wait until restore_previous_installation restores the core. } record_existing_state() { local certbot_load="" certbot_active="" if systemctl is-enabled "$verify_timer_name" >/dev/null 2>&1; then previous_timer_enabled=1 fi if systemctl is-active "$verify_timer_name" >/dev/null 2>&1; then previous_timer_active=1 fi certbot_load="$(systemctl show certbot.timer --property=LoadState --value)" if [ "$certbot_load" = loaded ]; then previous_certbot_timer_exists=1 certbot_active="$(systemctl show certbot.timer --property=ActiveState --value)" previous_certbot_timer_enabled="$(systemctl show certbot.timer --property=UnitFileState --value)" case "$certbot_active:$previous_certbot_timer_enabled" in active:enabled|active:disabled|inactive:enabled|inactive:disabled) ;; *) fail "unsupported Certbot timer state before backup" ;; esac [ "$certbot_active" != active ] || previous_certbot_timer_active=1 elif [ "$certbot_load" != not-found ]; then fail "cannot establish Certbot timer state before backup" fi record_provision_state } backup_existing_installation() { local timestamp="" timestamp="$(date -u +%Y%m%dT%H%M%SZ)" backup_directory="${backup_base}/${timestamp}" mkdir -p "$backup_directory" chmod 0700 "$backup_base" "$backup_directory" if [ -e "$install_root" ]; then cp -a "$install_root" "${backup_directory}/aitech-manager" : > "${backup_directory}/aitech-manager.present" fi if [ -e "$system_launcher" ] || [ -L "$system_launcher" ]; then cp -a "$system_launcher" "${backup_directory}/system-launcher" : > "${backup_directory}/system-launcher.present" fi if [ -e "$verify_service" ]; then cp -a "$verify_service" "${backup_directory}/verify.service" : > "${backup_directory}/verify.service.present" fi if [ -e "$verify_timer" ]; then cp -a "$verify_timer" "${backup_directory}/verify.timer" : > "${backup_directory}/verify.timer.present" fi backup_provision_state } restore_previous_installation() { local -a rollback_failures=() local rollback_services_safe=1 rollback_core_ready=0 local verify_files_ready=1 name="" source="" destination="" existed=0 set +e trap - ERR printf '[AiTECH] Installation failed; restoring the previous state.\n' >&2 rollback_service_action disable "$verify_timer_name" || : if ! rollback_service_action stop "$verify_timer_name"; then rollback_services_safe=0 fi restore_provisioned_state for name in aitech-manager system-launcher verify.service verify.timer; do source="${backup_directory}/$name" case "$name" in aitech-manager) destination="$install_root" ;; system-launcher) destination="$system_launcher" ;; verify.service) destination="$verify_service" ;; verify.timer) destination="$verify_timer" ;; esac existed=0 if [ -e "${source}.present" ] || [ -e "$source" ] || [ -L "$source" ]; then existed=1 fi if rollback_restore_path "$source" "$destination" "$existed"; then if [ "$name" = aitech-manager ] && [ "$existed" -eq 1 ]; then if [ -f "$core_binary" ] && [ -x "$core_binary" ]; then rollback_core_ready=1 else rollback_fail "previous core is missing or not executable after restoration" fi fi else case "$name" in verify.service|verify.timer) verify_files_ready=0 ;; esac fi done restore_provision_services restore_certificate_renewal_state if [ "$previous_certbot_timer_exists" -eq 1 ]; then if [ "$(systemctl show certbot.timer --property=UnitFileState --value 2>/dev/null)" != "$previous_certbot_timer_enabled" ]; then rollback_fail "Certbot timer enabled state changed during installation" fi if [ "$previous_certbot_timer_active" -eq 1 ]; then rollback_service_action start certbot.timer || : else rollback_service_action stop certbot.timer || : fi if [ "$(systemctl show certbot.timer --property=ActiveState --value 2>/dev/null)" != "$( if [ "$previous_certbot_timer_active" -eq 1 ]; then printf active; else printf inactive; fi )" ]; then rollback_fail "Certbot timer active state was not restored" fi fi if [ "$previous_timer_enabled" -eq 1 ]; then rollback_service_action enable "$verify_timer_name" || : fi if [ "$previous_timer_active" -eq 1 ]; then if [ "$verify_files_ready" -eq 1 ]; then rollback_service_action start "$verify_timer_name" || : else rollback_fail "skip start $verify_timer_name: verification unit restoration failed" fi fi printf '[AiTECH] Recovery backups retained at: %s\n' "$backup_directory" >&2 if [ "${#rollback_failures[@]}" -ne 0 ]; then printf '[AiTECH] Recovery INCOMPLETE: %d required action(s) failed or were skipped. Manual recovery is required.\n' \ "${#rollback_failures[@]}" >&2 return 1 fi printf '[AiTECH] Previous installation restored.\n' >&2 return 0 } cleanup() { local status=$? trap - EXIT trap - ERR set +e if [ -n "$license_tty_state" ]; then stty "$license_tty_state" >/dev/null 2>&1 < /dev/tty || true license_tty_state="" printf '\n' 2>/dev/null > /dev/tty || true fi if [ -n "$temporary_directory" ]; then if ! rm -rf "$temporary_directory"; then printf '[AiTECH] Warning: temporary installer files could not be removed.\n' >&2 fi fi if [ "$status" -ne 0 ] && [ "$installation_started" -eq 1 ] && [ "$installation_completed" -eq 0 ]; then # Recovery status must not replace the original installation failure. restore_previous_installation || : fi exit "$status" } install_directories() { install -d -m 0755 "$install_root" "$build_dir" "$bin_dir" "$config_dir" "$log_dir" install -d -m 0700 "$temp_dir" install -d -m 0755 "$system_config_dir" "$system_log_dir" install -d -m 0700 "$system_certificate_dir" "$system_temp_dir" } install_runtime_files() { local downloaded_binary="${temporary_directory}/${release_binary_name}" local downloaded_public_key="${temporary_directory}/server_public.pem" install -m 0755 "$downloaded_binary" "$core_binary" install -m 0644 "$downloaded_public_key" "$server_public_key" cat > "$manager_config" < "$launcher" <<'LAUNCHER' #!/usr/bin/env bash set -Eeuo pipefail readonly core_binary="/opt/aitech-manager/build/aitech-core" if [ ! -x "$core_binary" ]; then printf 'AiTECH Manager core is missing or not executable: %s\n' \ "$core_binary" >&2 exit 1 fi exec "$core_binary" "$@" LAUNCHER chmod 0755 "$launcher" ln -sfn "$launcher" "$system_launcher" } install_verification_timer() { cat > "$verify_service" <<'SERVICE' [Unit] Description=AiTECH Manager License Verification After=network-online.target Wants=network-online.target ConditionPathExists=/opt/aitech-manager/build/aitech-core [Service] Type=oneshot User=root Group=root WorkingDirectory=/opt/aitech-manager ExecStart=/opt/aitech-manager/build/aitech-core verify TimeoutStartSec=30s UMask=0077 NoNewPrivileges=true CapabilityBoundingSet= AmbientCapabilities= PrivateTmp=true PrivateDevices=true ProtectHome=true ProtectSystem=strict ReadWritePaths=/opt/aitech-manager/config ProtectKernelTunables=true ProtectKernelModules=true ProtectKernelLogs=true ProtectControlGroups=true ProtectClock=true ProtectHostname=true ProtectProc=invisible ProcSubset=all RestrictSUIDSGID=true RestrictRealtime=true RestrictNamespaces=true LockPersonality=true MemoryDenyWriteExecute=true SystemCallArchitectures=native SystemCallFilter=~@clock @cpu-emulation @debug @module @mount @obsolete @privileged @raw-io @reboot @swap SystemCallErrorNumber=EPERM RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 RemoveIPC=true KeyringMode=private StandardOutput=journal StandardError=journal SERVICE cat > "$verify_timer" <<'TIMER' [Unit] Description=Schedule AiTECH Manager License Verification [Timer] OnActiveSec=1min OnUnitActiveSec=5min RandomizedDelaySec=30s AccuracySec=30s Persistent=true Unit=aitech-manager-verify.service [Install] WantedBy=timers.target TIMER chmod 0644 "$verify_service" "$verify_timer" systemd-analyze verify "$verify_service" "$verify_timer" systemctl daemon-reload systemctl enable "$verify_timer_name" systemctl is-enabled --quiet "$verify_timer_name" } activate_license() { log "Activating the client license" env -u LD_PRELOAD -u LD_AUDIT "$core_binary" activate "$license_key" env -u LD_PRELOAD -u LD_AUDIT "$core_binary" verify systemctl start "$verify_timer_name" systemctl is-active --quiet "$verify_timer_name" systemctl is-enabled --quiet "$verify_timer_name" } provision_command_supported() { local output="" local status=0 if output="$(env -u LD_PRELOAD -u LD_AUDIT "$core_binary" provision 2>&1)"; then status=0 else status=$? fi [ "$status" -eq 1 ] || return 1 if [ -n "$dnstt_zone" ]; then printf '%s\n' "$output" | grep -Fq 'aitech-core provision ' return fi printf '%s\n' "$output" | grep -Fq 'Usage: aitech-core provision ' } provision_server() { if ! provision_command_supported; then log "Core release ${release_tag} does not yet expose full provisioning; preserving current release behavior" return 0 fi log "Provisioning complete AiTECH server stack for ${server_domain}" if [ -n "$dnstt_zone" ]; then env -u LD_PRELOAD -u LD_AUDIT AITECH_INSTALLER_OWNS_CERTBOT_ROLLBACK=1 "$core_binary" provision \ "$server_domain" "$dnstt_zone" "$dnstt_ns_target" return fi env -u LD_PRELOAD -u LD_AUDIT AITECH_INSTALLER_OWNS_CERTBOT_ROLLBACK=1 "$core_binary" provision \ "$server_domain" "$dnstt_nameserver" } verify_installed_files() { [ -n "$resolved_binary_sha256" ] || fail "release binary checksum was not resolved" [ "$(sha256sum "$core_binary" | awk '{print $1}')" = "$resolved_binary_sha256" ] || fail "installed binary checksum verification failed" [ "$(sha256sum "$server_public_key" | awk '{print $1}')" = "$expected_public_key_sha256" ] || fail "installed public key checksum verification failed" [ "$("$system_launcher" version)" = "${app_name} ${app_version}" ] || fail "installed launcher version verification failed" } # Existing long-running transports may still have the previous core mapped after # install_runtime_files atomically replaces the executable. The public TCP/80 # dispatcher must run the verified release core before HTTP-01 validation in # provisioning stage 1, otherwise an older dispatcher can intercept the ACME # challenge before the later SSH WebSocket provisioning stage restarts it. # # Fresh installations and inactive services are deliberately left untouched. refresh_active_core_transports() { local unit="aitech-ssh-websocket.service" local unit_path="/etc/systemd/system/aitech-ssh-websocket.service" local load_state="" local fragment_path="" local main_pid="" local running_sha="" load_state="$( systemctl show "$unit" \ --property=LoadState \ --value \ 2>/dev/null || true )" case "$load_state" in ""|not-found) return 0 ;; loaded) ;; *) fail "cannot safely refresh $unit: unexpected load state $load_state" return 1 ;; esac if ! systemctl is-active --quiet "$unit"; then return 0 fi fragment_path="$( systemctl show "$unit" \ --property=FragmentPath \ --value \ 2>/dev/null || true )" if [ "$fragment_path" != "$unit_path" ]; then fail "cannot safely refresh active $unit: unmanaged unit path" return 1 fi if [ ! -f "$unit_path" ]; then fail "cannot safely refresh active $unit: managed unit file is missing" return 1 fi if [ "$( grep -Fc \ 'ExecStart=/opt/aitech-manager/build/aitech-core ssh-websocket' \ "$unit_path" )" -ne 1 ]; then fail "cannot safely refresh active $unit: unexpected ExecStart" return 1 fi log "Refreshing active SSH WebSocket transport onto the verified release core" if ! systemctl restart "$unit"; then fail "failed to restart $unit onto the verified release core" return 1 fi if ! systemctl is-active --quiet "$unit"; then fail "$unit is not active after release-core refresh" return 1 fi main_pid="$( systemctl show "$unit" \ --property=MainPID \ --value )" case "$main_pid" in ""|*[!0-9]*|0) fail "$unit reported an invalid MainPID after release-core refresh" return 1 ;; esac if [ ! -r "/proc/${main_pid}/exe" ]; then fail "cannot inspect the running executable for $unit" return 1 fi running_sha="$( sha256sum "/proc/${main_pid}/exe" | awk '{print $1}' )" if [ "$running_sha" != "$resolved_binary_sha256" ]; then fail "$unit did not restart onto the verified release core" return 1 fi } main() { parse_arguments "$@" require_root resolve_license_key resolve_server_domain resolve_dnstt_configuration detect_platform ensure_dependencies create_temporary_directory verify_release_assets if [ "$check_only" -eq 1 ]; then log "Installer readiness check passed" printf 'Release: %s\n' "$app_version" printf 'Binary SHA-256: %s\n' "$resolved_binary_sha256" return 0 fi record_existing_state backup_existing_installation installation_started=1 log "Installing ${app_name} ${app_version}" install_directories install_runtime_files install_verification_timer verify_installed_files activate_license verify_installed_files refresh_active_core_transports provision_server installation_completed=1 log "Installation completed successfully" printf '\n' printf 'Run AiTECH Manager with:\n' printf ' sudo aitech\n' printf '\n' printf 'Version:\n' printf ' %s\n' "$("$system_launcher" version)" if find "$backup_directory" -mindepth 1 -print -quit | grep -q .; then printf '\nPrevious installation backup:\n' printf ' %s\n' "$backup_directory" else rmdir "$backup_directory" fi } trap cleanup EXIT main "$@"