#!/bin/sh # shellcheck shell=dash INSTALLER_VERSION="3.0.0" # Fork source. Defaults to NeoTachyon on the Gitea instance. # Override with TACHYON_REPO_HOST / TACHYON_REPO_OWNER / TACHYON_REPO_NAME, # or TACHYON_GITEA_USER / TACHYON_GITEA_TOKEN for a private instance. REPO_HOST="${TACHYON_REPO_HOST:-git.rukavishnikoff.ru}" REPO_OWNER="${TACHYON_REPO_OWNER:-hacker07}" REPO_NAME="${TACHYON_REPO_NAME:-NeoTachyon}" GITEA_USER="${TACHYON_GITEA_USER:-}" GITEA_TOKEN="${TACHYON_GITEA_TOKEN:-}" CONNECT_TIMEOUT_SECONDS=15 METADATA_TIMEOUT_SECONDS=60 DOWNLOAD_TIMEOUT_SECONDS=600 PACKAGE_TIMEOUT_SECONDS=420 APK_LOCK_WAIT_SECONDS=120 REQUIRED_OVERLAY_KB=12288 MIN_TMP_HEADROOM_KB=8192 LOG_FILE="/tmp/tachyon-install.log" LOCK_DIR="/tmp/tachyon.install.lock.d" TMP_DIR="" SNAPSHOT_DIR="" FETCHER="" PKG_MANAGER="" PKG_IS_APK=0 PACKAGE_INDEX_UPDATED=0 START_TIME="0" TX_ACTIVE=0 TX_COMMITTED=0 TACHYON_WAS_INSTALLED=0 TACHYON_WAS_RUNNING=0 TACHYON_WAS_ENABLED=0 INSTALLER_LANG="en" TACHYON_I18N_REQUESTED=0 SING_BOX_INSTALL_VARIANT="" ZRAM_INSTALL_REQUESTED=0 ASSUME_YES=0 DRY_RUN=0 VERBOSE=0 QUIET=0 SKIP_SING_BOX=0 ZRAM_INSTALL_OVERRIDE="" REPAIR_MODE=0 REINSTALL_MODE=0 RELEASE_TAG_REQUESTED="" RELEASE_CHANNEL="stable" TACHYON_RELEASE_JSON="" TACHYON_RELEASE_TAG="" TACHYON_BACKEND_URL="" TACHYON_BACKEND_NAME="" TACHYON_BACKEND_FILE="" TACHYON_APP_URL="" TACHYON_APP_NAME="" TACHYON_APP_FILE="" TACHYON_I18N_URL="" TACHYON_I18N_NAME="" TACHYON_I18N_FILE="" TACHYON_SHA256_URL="" TACHYON_SHA256_FILE="" TACHYON_DOWNLOAD_BYTES=0 ESC="$(printf '\033')" if [ -t 1 ] 2>/dev/null && [ "${TERM:-dumb}" != "dumb" ]; then C_RESET="${ESC}[0m"; C_BOLD="${ESC}[1m"; C_RED="${ESC}[31;1m" C_GREEN="${ESC}[32;1m"; C_YELLOW="${ESC}[33;1m"; C_CYAN="${ESC}[36;1m" else C_RESET=""; C_BOLD=""; C_RED=""; C_GREEN=""; C_YELLOW=""; C_CYAN="" fi log_line() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo '-------------------')" "$1" >>"$LOG_FILE" 2>/dev/null || true } msg() { log_line "INFO $1" [ "$QUIET" -eq 1 ] || printf ' %s%s%s\n' "$C_CYAN" "$1" "$C_RESET" } ok() { log_line "OK $1" [ "$QUIET" -eq 1 ] || printf ' %s%sтЬУ%s %s\n' "$C_GREEN" "$C_BOLD" "$C_RESET" "$1" } warn() { log_line "WARN $1" printf ' %s%sтЪа%s %s\n' "$C_YELLOW" "$C_BOLD" "$C_RESET" "$1" >&2 } err() { log_line "ERROR $1" printf ' %s%sтЬЧ%s %s\n' "$C_RED" "$C_BOLD" "$C_RESET" "$1" >&2 } debug() { log_line "DEBUG $1" [ "$VERBOSE" -eq 1 ] && printf ' [debug] %s\n' "$1" >&2 || true } usage() { cat </dev/null 2>&1; } read_openwrt_release_value() { key="$1" [ -r /etc/openwrt_release ] || return 0 sed -n "s/^${key}='\(.*\)'/\1/p" /etc/openwrt_release 2>/dev/null | head -n 1 } check_root() { [ "$(id -u 2>/dev/null || echo 1)" = "0" ] || { err "Run the installer as root"; return 1; } } init_tmp_dir() { TMP_DIR="$(mktemp -d /tmp/tachyon-installer.XXXXXX 2>/dev/null || true)" if [ -z "$TMP_DIR" ]; then TMP_DIR="/tmp/tachyon-installer.$$" mkdir -p "$TMP_DIR" || return 1 fi } acquire_lock() { if mkdir "$LOCK_DIR" 2>/dev/null; then printf '%s\n' "$$" >"$LOCK_DIR/pid" return 0 fi old_pid="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)" if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then err "Another Tachyon installer is running (pid $old_pid)" return 1 fi warn "Removing stale installer lock" rm -rf "$LOCK_DIR" 2>/dev/null || return 1 mkdir "$LOCK_DIR" 2>/dev/null || return 1 printf '%s\n' "$$" >"$LOCK_DIR/pid" } release_lock() { [ -d "$LOCK_DIR" ] || return 0 lock_pid="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)" [ "$lock_pid" = "$$" ] && rm -rf "$LOCK_DIR" 2>/dev/null || true } kill_tree() { _pid="$1" [ -n "$_pid" ] || return 0 children_file="/proc/$_pid/task/$_pid/children" if [ -r "$children_file" ]; then for _child in $(cat "$children_file" 2>/dev/null); do kill_tree "$_child" done fi kill -TERM "$_pid" 2>/dev/null || true sleep 1 kill -KILL "$_pid" 2>/dev/null || true } run_with_deadline() { _seconds="$1" shift "$@" & _cmd_pid=$! ( sleep "$_seconds" kill_tree "$_cmd_pid" ) & _watch_pid=$! wait "$_cmd_pid" _rc=$? kill "$_watch_pid" 2>/dev/null || true wait "$_watch_pid" 2>/dev/null || true return "$_rc" } run_logged() { _tag="$1"; shift _out="$TMP_DIR/cmd.$$.log" log_line "EXEC [$_tag] $*" "$@" >"$_out" 2>&1 _rc=$? if [ -s "$_out" ]; then while IFS= read -r _line; do log_line " [$_tag] $_line"; done <"$_out" fi if [ "$_rc" -ne 0 ] && [ "$VERBOSE" -eq 1 ]; then cat "$_out" >&2; fi rm -f "$_out" return "$_rc" } run_logged_timeout() { _tag="$1"; _seconds="$2"; shift 2 _out="$TMP_DIR/cmd.$$.log" log_line "EXEC [$_tag] (deadline ${_seconds}s) $*" run_with_deadline "$_seconds" "$@" >"$_out" 2>&1 _rc=$? if [ -s "$_out" ]; then while IFS= read -r _line; do log_line " [$_tag] $_line"; done <"$_out" fi if [ "$_rc" -ne 0 ] && [ "$VERBOSE" -eq 1 ]; then cat "$_out" >&2; fi rm -f "$_out" return "$_rc" } detect_fetcher() { if command_exists wget; then FETCHER="wget"; return 0; fi if command_exists curl; then FETCHER="curl"; return 0; fi err "wget or curl is required" return 1 } http_get() { _url="$1" _auth="" if [ -n "$GITEA_USER" ] && [ -n "$GITEA_TOKEN" ]; then case "$FETCHER" in wget) _auth="--user=$GITEA_USER --password=$GITEA_TOKEN" ;; curl) _auth="-u $GITEA_USER:$GITEA_TOKEN" ;; esac fi case "$FETCHER" in wget) run_with_deadline "$METADATA_TIMEOUT_SECONDS" wget -T "$CONNECT_TIMEOUT_SECONDS" $_auth -qO- "$_url" ;; curl) curl --connect-timeout "$CONNECT_TIMEOUT_SECONDS" --speed-limit 1024 --speed-time 15 --max-time "$METADATA_TIMEOUT_SECONDS" -fsSL $_auth "$_url" ;; *) return 1 ;; esac } download_file_once() { _url="$1"; _dst="$2" _auth="" if [ -n "$GITEA_USER" ] && [ -n "$GITEA_TOKEN" ]; then case "$FETCHER" in wget) _auth="--user=$GITEA_USER --password=$GITEA_TOKEN" ;; curl) _auth="-u $GITEA_USER:$GITEA_TOKEN" ;; esac fi case "$FETCHER" in wget) run_with_deadline "$DOWNLOAD_TIMEOUT_SECONDS" wget -T "$CONNECT_TIMEOUT_SECONDS" $_auth -q -O "$_dst" "$_url" ;; curl) curl --connect-timeout "$CONNECT_TIMEOUT_SECONDS" --speed-limit 1024 --speed-time 15 --max-time "$DOWNLOAD_TIMEOUT_SECONDS" -fsSL $_auth "$_url" -o "$_dst" ;; *) return 1 ;; esac } download_with_retry() { _url="$1"; _dst="$2"; _label="$3" _attempt=1 while [ "$_attempt" -le 4 ]; do _try_url="$_url" case "$_attempt" in 2) _try_url="https://gh-proxy.com/$_url" ;; 3) _try_url="https://ghproxy.net/$_url" ;; 4) _try_url="$_url" ;; esac msg "Downloading $_label (attempt $_attempt/4)" if download_file_once "$_try_url" "$_dst" && [ -s "$_dst" ]; then return 0; fi rm -f "$_dst" _attempt=$((_attempt + 1)) sleep 2 done return 1 } sync_time() { _year="$(date +%Y 2>/dev/null || echo 0)" case "$_year" in *[!0-9]*|"") _year=0 ;; esac [ "$_year" -ge 2024 ] && return 0 warn "System clock is invalid; attempting NTP sync before HTTPS/package access" if command_exists ntpd; then ntpd -q -p 216.239.35.0 -p 216.239.35.4 -p 162.159.200.1 -p 162.159.200.123 >/dev/null 2>&1 || true fi _year="$(date +%Y 2>/dev/null || echo 0)" case "$_year" in *[!0-9]*|"") _year=0 ;; esac [ "$_year" -ge 2024 ] || warn "Clock is still invalid; TLS downloads may fail" } detect_package_manager() { if command_exists apk; then PKG_MANAGER="apk"; PKG_IS_APK=1; return 0; fi if command_exists opkg; then PKG_MANAGER="opkg"; PKG_IS_APK=0; return 0; fi err "Neither apk nor opkg is available" return 1 } pkg_is_installed() { _pkg="$1" if [ "$PKG_IS_APK" -eq 1 ]; then apk info -e "$_pkg" >/dev/null 2>&1 else opkg status "$_pkg" 2>/dev/null | grep -q '^Status: .* installed' fi } apk_supports_wait() { apk --help 2>&1 | grep -q -- '--wait' } diagnose_apk_lock() { [ "$PKG_IS_APK" -eq 1 ] || return 0 _found=0 for _fd in /proc/[0-9]*/fd/*; do [ -e "$_fd" ] || continue _target="$(readlink "$_fd" 2>/dev/null || true)" case "$_target" in */apk/db/lock|*/lib/apk/db/lock) _pid="$(printf '%s' "$_fd" | cut -d/ -f3)" _comm="$(cat "/proc/$_pid/comm" 2>/dev/null || echo unknown)" _cmd="$(tr '\0' ' ' <"/proc/$_pid/cmdline" 2>/dev/null || true)" warn "APK lock holder: pid=$_pid process=$_comm command=${_cmd:-unknown}" _found=1 ;; esac done [ "$_found" -eq 1 ] || warn "APK database is locked but the holder could not be identified" } apk_run() { _tag="$1"; _seconds="$2"; shift 2 if apk_supports_wait; then run_logged_timeout "$_tag" "$_seconds" apk --wait "$APK_LOCK_WAIT_SECONDS" "$@" _rc=$? [ "$_rc" -eq 0 ] || diagnose_apk_lock return "$_rc" fi _attempt=1 while [ "$_attempt" -le 12 ]; do run_logged_timeout "$_tag" "$_seconds" apk "$@" _rc=$? [ "$_rc" -eq 0 ] && return 0 [ "$_rc" -eq 227 ] || return "$_rc" [ "$_attempt" -eq 1 ] && diagnose_apk_lock sleep 5 _attempt=$((_attempt + 1)) done diagnose_apk_lock return 227 } pkg_update_index() { [ "$PACKAGE_INDEX_UPDATED" -eq 1 ] && return 0 if [ "$DRY_RUN" -eq 1 ]; then msg "[dry-run] would refresh $PKG_MANAGER package indexes"; PACKAGE_INDEX_UPDATED=1; return 0; fi msg "Refreshing $PKG_MANAGER package indexes" if [ "$PKG_IS_APK" -eq 1 ]; then apk_run apk-update 180 update || return 1 else run_logged_timeout opkg-update 180 opkg update || return 1 fi PACKAGE_INDEX_UPDATED=1 } pkg_install_names() { [ "$#" -gt 0 ] || return 0 if [ "$DRY_RUN" -eq 1 ]; then msg "[dry-run] would install dependencies: $*"; return 0; fi if [ "$PKG_IS_APK" -eq 1 ]; then apk_run apk-add "$PACKAGE_TIMEOUT_SECONDS" add "$@" else run_logged_timeout opkg-add "$PACKAGE_TIMEOUT_SECONDS" opkg install "$@" fi } ensure_package() { _pkg="$1" pkg_is_installed "$_pkg" && return 0 pkg_update_index || return 1 pkg_install_names "$_pkg" } ensure_bootstrap_ucode_runtime() { if ! command_exists ucode; then pkg_update_index || return 1 pkg_install_names ucode || return 1 fi ensure_package ucode-mod-fs || return 1 ensure_package ucode-mod-uci || return 1 } ensure_runtime_dependencies() { _missing="" for _pkg in ca-bundle curl coreutils-base64 bind-dig nftables ip-full; do pkg_is_installed "$_pkg" || _missing="$_missing $_pkg" done [ -z "$_missing" ] && return 0 pkg_update_index || return 1 # shellcheck disable=SC2086 pkg_install_names $_missing } install_release_helper() { _helper="$TMP_DIR/release-helper.uc" cat >"$_helper" <<'EOF_UCODE' #!/usr/bin/env ucode let fs = require("fs"); function stdin_all() { let f = fs.open("/dev/stdin", "r"); if (!f) return ""; let d = f.read("all"); f.close(); return d || ""; } function obj() { try { return json(stdin_all()); } catch (e) { return null; } } function valid(v) { return match("" + (v || ""), /^[0-9]+[.][0-9]+[.][0-9]+$/) != null; } function pick_release(v, channel) { if (type(v) == "object") return v; if (type(v) != "array") return null; for (let r in v) { if (type(r) != "object" || r.draft) continue; if (channel == "beta") return r; if (!r.prerelease) return r; } return null; } function asset_name(kind, ext, ver) { if (kind == "sha256") return "sha256sums.txt"; if (kind == "backend") return "tachyon_" + ver + "." + ext; if (kind == "app") return "luci-app-tachyon_" + ver + "." + ext; if (kind == "i18n") return "luci-i18n-tachyon-ru_" + ver + "." + ext; return ""; } let mode = ARGV[0] || ""; let v = obj(); let r = pick_release(v, ARGV[1] || "stable"); if (!r) exit(2); let ver = "" + (r.tag_name || ""); if (mode == "tag") { if (valid(ver)) print(ver, "\n"); else exit(3); } else if (mode == "asset") { let kind = ARGV[2] || ""; let ext = ARGV[3] || ""; let wanted = asset_name(kind, ext, ver); for (let a in (r.assets || [])) if (a.name == wanted) { print("" + (a.browser_download_url || ""), "\n"); exit(0); } exit(4); } else if (mode == "size") { let kind = ARGV[2] || ""; let ext = ARGV[3] || ""; let wanted = asset_name(kind, ext, ver); for (let a in (r.assets || [])) if (a.name == wanted) { print("" + int(a.size || 0), "\n"); exit(0); } exit(4); } else exit(1); EOF_UCODE printf '%s\n' "$_helper" } release_helper() { ucode "$(install_release_helper)" "$@" } fetch_release_json() { if [ "$REPO_HOST" = "github.com" ]; then _api="https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}" else _api="https://${REPO_HOST}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}" fi if [ -n "$RELEASE_TAG_REQUESTED" ]; then _url="$_api/releases/tags/${RELEASE_TAG_REQUESTED}" elif [ "$RELEASE_CHANNEL" = "beta" ]; then _url="$_api/releases?per_page=20" else _url="$_api/releases/latest" fi _json="$(http_get "$_url" 2>/dev/null || true)" if [ -z "$_json" ]; then warn "GitHub release API failed; trying mirror" _json="$(http_get "https://gh-proxy.com/$_url" 2>/dev/null || true)" fi [ -n "$_json" ] || return 1 printf '%s' "$_json" } resolve_release() { _ext="ipk"; [ "$PKG_IS_APK" -eq 1 ] && _ext="apk" TACHYON_RELEASE_JSON="$(fetch_release_json)" || return 1 TACHYON_RELEASE_TAG="$(printf '%s' "$TACHYON_RELEASE_JSON" | release_helper tag "$RELEASE_CHANNEL" 2>/dev/null || true)" [ -n "$TACHYON_RELEASE_TAG" ] || { err "Could not resolve a valid release tag"; return 1; } if [ -n "$RELEASE_TAG_REQUESTED" ] && [ "$TACHYON_RELEASE_TAG" != "$RELEASE_TAG_REQUESTED" ]; then err "Resolved release $TACHYON_RELEASE_TAG does not match requested $RELEASE_TAG_REQUESTED" return 1 fi TACHYON_SHA256_URL="$(printf '%s' "$TACHYON_RELEASE_JSON" | release_helper asset "$RELEASE_CHANNEL" sha256 txt 2>/dev/null || true)" TACHYON_BACKEND_URL="$(printf '%s' "$TACHYON_RELEASE_JSON" | release_helper asset "$RELEASE_CHANNEL" backend "$_ext" 2>/dev/null || true)" TACHYON_APP_URL="$(printf '%s' "$TACHYON_RELEASE_JSON" | release_helper asset "$RELEASE_CHANNEL" app "$_ext" 2>/dev/null || true)" [ -n "$TACHYON_SHA256_URL" ] && [ -n "$TACHYON_BACKEND_URL" ] && [ -n "$TACHYON_APP_URL" ] || { err "Release $TACHYON_RELEASE_TAG is missing required $_ext assets" return 1 } TACHYON_BACKEND_NAME="$(basename "$TACHYON_BACKEND_URL")" TACHYON_APP_NAME="$(basename "$TACHYON_APP_URL")" if [ "$TACHYON_I18N_REQUESTED" -eq 1 ]; then TACHYON_I18N_URL="$(printf '%s' "$TACHYON_RELEASE_JSON" | release_helper asset "$RELEASE_CHANNEL" i18n "$_ext" 2>/dev/null || true)" [ -n "$TACHYON_I18N_URL" ] || { err "Release is missing Russian LuCI package"; return 1; } TACHYON_I18N_NAME="$(basename "$TACHYON_I18N_URL")" fi _s1="$(printf '%s' "$TACHYON_RELEASE_JSON" | release_helper size "$RELEASE_CHANNEL" backend "$_ext" 2>/dev/null || echo 0)" _s2="$(printf '%s' "$TACHYON_RELEASE_JSON" | release_helper size "$RELEASE_CHANNEL" app "$_ext" 2>/dev/null || echo 0)" _s3=0 [ "$TACHYON_I18N_REQUESTED" -eq 1 ] && _s3="$(printf '%s' "$TACHYON_RELEASE_JSON" | release_helper size "$RELEASE_CHANNEL" i18n "$_ext" 2>/dev/null || echo 0)" TACHYON_DOWNLOAD_BYTES=$((_s1 + _s2 + _s3 + 65536)) } free_kb() { _path="$1" df -Pk "$_path" 2>/dev/null | awk 'NR==2 {print $4}' } check_storage_preflight() { _overlay="$(free_kb /overlay)"; [ -n "$_overlay" ] || _overlay="$(free_kb /)" [ -n "$_overlay" ] || _overlay=0 if [ "$_overlay" -lt "$REQUIRED_OVERLAY_KB" ]; then err "Not enough persistent storage: $((_overlay / 1024)) MiB free, need at least $((REQUIRED_OVERLAY_KB / 1024)) MiB" return 1 fi } check_tmp_for_downloads() { _tmp_free="$(free_kb /tmp)"; [ -n "$_tmp_free" ] || _tmp_free=0 _need_kb=$(((TACHYON_DOWNLOAD_BYTES + 1023) / 1024 + MIN_TMP_HEADROOM_KB)) if [ "$_tmp_free" -lt "$_need_kb" ]; then err "Not enough /tmp RAM: $((_tmp_free / 1024)) MiB free, need about $((_need_kb / 1024)) MiB for verified downloads" return 1 fi } detect_language_and_i18n() { _lang="" if [ -r /etc/config/luci ]; then _lang="$(sed -n "s/.*option lang ['\"]\?\([a-zA-Z_-]*\)['\"]\?.*/\1/p" /etc/config/luci 2>/dev/null | head -n1)" fi if pkg_is_installed luci-i18n-tachyon-ru; then TACHYON_I18N_REQUESTED=1; INSTALLER_LANG="ru"; return 0; fi case "$_lang" in ru|ru-*|ru_*) TACHYON_I18N_REQUESTED=1; INSTALLER_LANG="ru"; return 0 ;; esac INSTALLER_LANG="en" if [ "$ASSUME_YES" -eq 1 ] || [ ! -t 0 ]; then return 0; fi printf 'Install Russian LuCI translation? [y/N]: ' read -r _answer || true case "$_answer" in y|Y|yes|YES) TACHYON_I18N_REQUESTED=1; INSTALLER_LANG="ru" ;; esac } sing_box_is_present() { command_exists sing-box || pkg_is_installed sing-box || pkg_is_installed sing-box-extended || pkg_is_installed sing-box-tiny || pkg_is_installed sing-box-lx } select_sing_box_installation() { SING_BOX_INSTALL_VARIANT="" sing_box_is_present && return 0 [ "$SKIP_SING_BOX" -eq 1 ] && return 0 if [ "$ASSUME_YES" -eq 1 ] || [ ! -t 0 ]; then SING_BOX_INSTALL_VARIANT="stable"; return 0; fi printf '\nSelect sing-box build:\n 1) stable (recommended)\n 2) extended (xHTTP)\n 3) skip\nChoice [1]: ' read -r _answer || _answer=1 case "${_answer:-1}" in 1) SING_BOX_INSTALL_VARIANT="stable" ;; 2) SING_BOX_INSTALL_VARIANT="extended" ;; 3) SING_BOX_INSTALL_VARIANT="" ;; *) warn "Unknown choice; using stable"; SING_BOX_INSTALL_VARIANT="stable" ;; esac } decide_zram() { ZRAM_INSTALL_REQUESTED=0 case "$ZRAM_INSTALL_OVERRIDE" in yes) ZRAM_INSTALL_REQUESTED=1; return 0 ;; no) return 0 ;; esac pkg_is_installed zram-swap && return 0 _ram_kb="$(awk '/MemTotal:/ {print $2; exit}' /proc/meminfo 2>/dev/null || echo 0)" case "$_ram_kb" in *[!0-9]*|"") _ram_kb=0 ;; esac [ "$_ram_kb" -gt 0 ] && [ "$_ram_kb" -le 262144 ] || return 0 if [ "$ASSUME_YES" -eq 1 ] || [ ! -t 0 ]; then ZRAM_INSTALL_REQUESTED=1; return 0; fi printf 'Low RAM detected (%s MiB). Install zram-swap? [Y/n]: ' "$((_ram_kb / 1024))" read -r _answer || true case "$_answer" in n|N|no|NO) ;; *) ZRAM_INSTALL_REQUESTED=1 ;; esac } record_service_state() { pkg_is_installed tachyon && TACHYON_WAS_INSTALLED=1 || TACHYON_WAS_INSTALLED=0 if ls /etc/rc.d/S*tachyon >/dev/null 2>&1; then TACHYON_WAS_ENABLED=1; fi if [ -x /usr/bin/tachyon ]; then _status="$TMP_DIR/status-before.json" if run_with_deadline 8 /usr/bin/tachyon get_status >"$_status" 2>/dev/null && grep -Eq '"running"[[:space:]]*:[[:space:]]*(1|true)' "$_status"; then TACHYON_WAS_RUNNING=1 fi fi } snapshot_state() { [ "$DRY_RUN" -eq 1 ] && return 0 _stamp="$(date +%Y%m%d-%H%M%S 2>/dev/null || echo $$)" SNAPSHOT_DIR="/etc/tachyon/installer-backups/$_stamp" mkdir -p "$SNAPSHOT_DIR" || return 1 chmod 0700 "$SNAPSHOT_DIR" 2>/dev/null || true for _f in /etc/config/tachyon /etc/config/netshift /etc/config/forkop /etc/config/forkop_plus /etc/config/podkop /etc/config/podkop_plus; do [ -f "$_f" ] || continue cp -a "$_f" "$SNAPSHOT_DIR/$(basename "$_f")" || return 1 done [ "$PKG_IS_APK" -eq 1 ] && [ -f /etc/apk/world ] && cp -a /etc/apk/world "$SNAPSHOT_DIR/apk-world" || true printf 'installed=%s\nrunning=%s\nenabled=%s\n' "$TACHYON_WAS_INSTALLED" "$TACHYON_WAS_RUNNING" "$TACHYON_WAS_ENABLED" >"$SNAPSHOT_DIR/state" TX_ACTIVE=1 ok "Recovery snapshot created: $SNAPSHOT_DIR" } restore_snapshot() { [ "$TX_ACTIVE" -eq 1 ] || return 0 [ "$TX_COMMITTED" -eq 0 ] || return 0 [ -n "$SNAPSHOT_DIR" ] && [ -d "$SNAPSHOT_DIR" ] || return 0 warn "Restoring configuration from recovery snapshot" if [ -f "$SNAPSHOT_DIR/tachyon" ]; then cp -a "$SNAPSHOT_DIR/tachyon" /etc/config/tachyon 2>/dev/null || true chmod 0600 /etc/config/tachyon 2>/dev/null || true fi if [ "$PKG_IS_APK" -eq 1 ] && [ -f "$SNAPSHOT_DIR/apk-world" ]; then cp -a "$SNAPSHOT_DIR/apk-world" /etc/apk/world 2>/dev/null || true fi if [ "$TACHYON_WAS_INSTALLED" -eq 1 ] && [ -x /usr/bin/tachyon ]; then [ "$TACHYON_WAS_ENABLED" -eq 1 ] && /etc/init.d/tachyon enable >/dev/null 2>&1 || /etc/init.d/tachyon disable >/dev/null 2>&1 || true [ "$TACHYON_WAS_RUNNING" -eq 1 ] && run_with_deadline 45 /usr/bin/tachyon start >/dev/null 2>&1 || true fi } is_protected_pid() { _candidate="$1" _p="$$" while [ -n "$_p" ] && [ "$_p" -gt 1 ] 2>/dev/null; do [ "$_candidate" = "$_p" ] && return 0 _stat="$(cat "/proc/$_p/stat" 2>/dev/null || true)" [ -n "$_stat" ] || break _rest="${_stat#*) }" set -- $_rest _p="$2" done return 1 } release_tachyon_init_lock() { [ -d /proc ] || return 0 _killed=0 for _fd in /proc/[0-9]*/fd/1000; do [ -e "$_fd" ] || continue _target="$(readlink "$_fd" 2>/dev/null || true)" case "$_target" in *procd_tachyon*|*tachyon*lock*) ;; *) continue ;; esac _pid="$(printf '%s' "$_fd" | cut -d/ -f3)" is_protected_pid "$_pid" && continue debug "Killing stale Tachyon init-lock holder pid=$_pid target=$_target" kill -KILL "$_pid" 2>/dev/null || true _killed=1 done ps 2>/dev/null | grep -E '99-tachyon-wan|flock 1000|/etc/init.d/tachyon' | grep -v grep | awk '{print $1}' | while read -r _pid; do [ -n "$_pid" ] || continue is_protected_pid "$_pid" && continue kill -KILL "$_pid" 2>/dev/null || true done [ "$_killed" -eq 0 ] || sleep 1 } prepare_transaction() { [ "$DRY_RUN" -eq 1 ] && return 0 if [ -x /usr/bin/tachyon ]; then run_with_deadline 45 /usr/bin/tachyon stop >/dev/null 2>&1 || true fi release_tachyon_init_lock } download_release() { TACHYON_SHA256_FILE="$TMP_DIR/sha256sums.txt" TACHYON_BACKEND_FILE="$TMP_DIR/$TACHYON_BACKEND_NAME" TACHYON_APP_FILE="$TMP_DIR/$TACHYON_APP_NAME" [ "$DRY_RUN" -eq 1 ] && { msg "[dry-run] would download and verify $TACHYON_RELEASE_TAG"; return 0; } download_with_retry "$TACHYON_SHA256_URL" "$TACHYON_SHA256_FILE" sha256sums.txt || return 1 download_with_retry "$TACHYON_BACKEND_URL" "$TACHYON_BACKEND_FILE" "$TACHYON_BACKEND_NAME" || return 1 download_with_retry "$TACHYON_APP_URL" "$TACHYON_APP_FILE" "$TACHYON_APP_NAME" || return 1 if [ "$TACHYON_I18N_REQUESTED" -eq 1 ]; then TACHYON_I18N_FILE="$TMP_DIR/$TACHYON_I18N_NAME" download_with_retry "$TACHYON_I18N_URL" "$TACHYON_I18N_FILE" "$TACHYON_I18N_NAME" || return 1 fi verify_one() { _file="$1"; _name="$(basename "$_file")" _expected="$(awk -v n="$_name" '$2 == n || $2 == "*" n {print $1; exit}' "$TACHYON_SHA256_FILE")" [ -n "$_expected" ] || { err "Checksum entry missing for $_name"; return 1; } _actual="$(sha256sum "$_file" 2>/dev/null | awk '{print $1}')" [ "$_expected" = "$_actual" ] || { err "Checksum mismatch for $_name"; return 1; } } verify_one "$TACHYON_BACKEND_FILE" || return 1 verify_one "$TACHYON_APP_FILE" || return 1 [ -z "$TACHYON_I18N_FILE" ] || verify_one "$TACHYON_I18N_FILE" || return 1 ok "Release checksums verified" } pkg_install_local_bundle() { [ "$DRY_RUN" -eq 1 ] && { msg "[dry-run] would install local package transaction: $*"; return 0; } if [ "$PKG_IS_APK" -eq 1 ]; then apk_run apk-transaction "$PACKAGE_TIMEOUT_SECONDS" add --allow-untrusted "$@" else run_logged_timeout opkg-transaction "$PACKAGE_TIMEOUT_SECONDS" opkg install --force-reinstall --force-overwrite --force-downgrade "$@" fi } install_core_transaction() { set -- "$TACHYON_BACKEND_FILE" "$TACHYON_APP_FILE" [ -z "$TACHYON_I18N_FILE" ] || set -- "$@" "$TACHYON_I18N_FILE" msg "Installing Tachyon package transaction" pkg_install_local_bundle "$@" } install_zram_if_requested() { [ "$ZRAM_INSTALL_REQUESTED" -eq 1 ] || return 0 ensure_package zram-swap || { warn "Could not install zram-swap; continuing"; return 0; } [ "$DRY_RUN" -eq 1 ] && return 0 [ -x /etc/init.d/zram ] && /etc/init.d/zram enable >/dev/null 2>&1 || true [ -x /etc/init.d/zram ] && /etc/init.d/zram start >/dev/null 2>&1 || true } install_selected_sing_box() { [ -n "$SING_BOX_INSTALL_VARIANT" ] || return 0 [ "$DRY_RUN" -eq 1 ] && { msg "[dry-run] would install sing-box variant $SING_BOX_INSTALL_VARIANT"; return 0; } [ -x /usr/bin/tachyon ] || return 1 case "$SING_BOX_INSTALL_VARIANT" in stable) _action="install_stable" ;; extended) _action="install_extended" ;; *) return 1 ;; esac msg "Installing sing-box ($SING_BOX_INSTALL_VARIANT)" run_logged_timeout sing-box "$PACKAGE_TIMEOUT_SECONDS" /usr/bin/tachyon component_action sing_box "$_action" } restore_service_intent() { [ "$DRY_RUN" -eq 1 ] && return 0 [ -x /etc/init.d/tachyon ] || return 1 if [ "$TACHYON_WAS_INSTALLED" -eq 1 ]; then if [ "$TACHYON_WAS_ENABLED" -eq 1 ]; then /etc/init.d/tachyon enable >/dev/null 2>&1 || true; else /etc/init.d/tachyon disable >/dev/null 2>&1 || true; fi if [ "$TACHYON_WAS_RUNNING" -eq 1 ]; then run_with_deadline 60 /usr/bin/tachyon start >/dev/null 2>&1 || return 1; else run_with_deadline 45 /usr/bin/tachyon stop >/dev/null 2>&1 || true; fi else /etc/init.d/tachyon enable >/dev/null 2>&1 || true run_with_deadline 60 /usr/bin/tachyon start >/dev/null 2>&1 || true fi } healthcheck() { [ "$DRY_RUN" -eq 1 ] && return 0 msg "Running installation healthcheck" pkg_is_installed tachyon || { err "tachyon package is not registered"; return 1; } pkg_is_installed luci-app-tachyon || { err "luci-app-tachyon package is not registered"; return 1; } [ -x /usr/bin/tachyon ] || { err "/usr/bin/tachyon is missing"; return 1; } [ -r /usr/lib/tachyon/core/constants.uc ] || { err "Tachyon runtime is incomplete"; return 1; } [ -f /usr/share/luci/menu.d/luci-app-tachyon.json ] || { err "LuCI menu file is missing"; return 1; } [ -r /etc/config/tachyon ] || { err "Tachyon UCI config is missing"; return 1; } chmod 0600 /etc/config/tachyon 2>/dev/null || true _version="$(/usr/bin/tachyon get_system_info 2>/dev/null | sed -n 's/.*"tachyon_version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" if [ -n "$_version" ] && [ "$_version" != "$TACHYON_RELEASE_TAG" ]; then err "Installed runtime reports version $_version, expected $TACHYON_RELEASE_TAG" return 1 fi ucode -L /usr/lib/tachyon -e 'require("core.common");' >/dev/null 2>&1 || { err "ucode cannot load Tachyon runtime"; return 1; } ok "Healthcheck passed" } commit_transaction() { TX_COMMITTED=1 TX_ACTIVE=0 [ -n "$SNAPSHOT_DIR" ] && printf '%s\n' "$TACHYON_RELEASE_TAG" >"$SNAPSHOT_DIR/installed-release" 2>/dev/null || true } cleanup() { if [ "$TX_ACTIVE" -eq 1 ] && [ "$TX_COMMITTED" -eq 0 ]; then restore_snapshot; fi [ -n "$TMP_DIR" ] && rm -rf "$TMP_DIR" 2>/dev/null || true release_lock } on_hup() { exit 129; } on_int() { exit 130; } on_term() { exit 143; } fail() { err "$1" err "Installer log: $LOG_FILE" exit 1 } banner() { [ "$QUIET" -eq 1 ] && return 0 printf '\n%s%sTachyon Installer%s v%s\n' "$C_CYAN" "$C_BOLD" "$C_RESET" "$INSTALLER_VERSION" printf 'Reliable OpenWrt install / update / repair\n\n' } system_preflight() { [ -r /etc/openwrt_release ] || { err "This installer supports OpenWrt only"; return 1; } _release="$(read_openwrt_release_value DISTRIB_RELEASE)" _major="$(printf '%s' "$_release" | sed 's/[^0-9].*$//')" case "$_major" in *[!0-9]*|"") _major=0 ;; esac [ "$_major" -eq 0 ] || [ "$_major" -ge 23 ] || { err "OpenWrt 23.05 or newer is required"; return 1; } check_storage_preflight } print_plan() { msg "Release: ${TACHYON_RELEASE_TAG} (${PKG_MANAGER})" [ "$TACHYON_I18N_REQUESTED" -eq 1 ] && msg "Russian LuCI translation: yes" || msg "Russian LuCI translation: no" [ -n "$SING_BOX_INSTALL_VARIANT" ] && msg "sing-box: $SING_BOX_INSTALL_VARIANT" || msg "sing-box: keep existing / skip" [ "$ZRAM_INSTALL_REQUESTED" -eq 1 ] && msg "zram-swap: install" || true } main() { trap cleanup EXIT trap on_hup HUP trap on_int INT trap on_term TERM parse_args "$@" || exit $? START_TIME="$(date +%s 2>/dev/null || echo 0)" : >"$LOG_FILE" 2>/dev/null || true acquire_lock || exit 1 check_root || fail "Root privileges are required" init_tmp_dir || fail "Could not create temporary directory" detect_fetcher || fail "No download client available" detect_package_manager || fail "No supported package manager available" banner sync_time system_preflight || fail "System preflight failed" ensure_bootstrap_ucode_runtime || fail "Could not bootstrap ucode runtime" detect_language_and_i18n select_sing_box_installation decide_zram record_service_state resolve_release || fail "Could not resolve Tachyon release" check_tmp_for_downloads || fail "Temporary storage preflight failed" print_plan download_release || fail "Release download or verification failed" ensure_runtime_dependencies || fail "Could not install Tachyon dependencies" snapshot_state || fail "Could not create recovery snapshot" prepare_transaction || fail "Could not prepare package transaction" install_core_transaction || fail "Tachyon package transaction failed" install_zram_if_requested install_selected_sing_box || fail "sing-box installation failed" restore_service_intent || fail "Could not restore Tachyon service state" healthcheck || fail "Installed Tachyon did not pass healthcheck" commit_transaction _end="$(date +%s 2>/dev/null || echo 0)" _elapsed=$((_end - START_TIME)); [ "$_elapsed" -ge 0 ] 2>/dev/null || _elapsed=0 ok "Tachyon ${TACHYON_RELEASE_TAG} installed successfully in ${_elapsed}s" msg "Log: $LOG_FILE" [ -n "$SNAPSHOT_DIR" ] && msg "Recovery snapshot: $SNAPSHOT_DIR" } if [ "${TACHYON_INSTALLER_TEST:-0}" != "1" ]; then main "$@" fi