Files
NeoTachyon/install.sh
T

893 lines
33 KiB
Bash
Executable File

#!/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 <<EOF_USAGE
Tachyon installer v${INSTALLER_VERSION}
Usage: $0 [options]
-y, --yes Use recommended defaults without prompts
-n, --dry-run Resolve and validate the plan without changing the router
-v, --verbose Print diagnostics
-q, --quiet Suppress informational output
--repair Reinstall Tachyon and repair runtime integration
--reinstall Force reinstall of the selected release
--tag X.Y.Z Install an exact published release
--channel NAME stable (default) or beta
--skip-sing-box Do not install sing-box when none is present
--zram Install zram-swap
--no-zram Never install zram-swap
--version Print installer version
-h, --help Show this help
Log: $LOG_FILE
EOF_USAGE
}
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
-y|--yes) ASSUME_YES=1 ;;
-n|--dry-run) DRY_RUN=1 ;;
-v|--verbose) VERBOSE=1 ;;
-q|--quiet) QUIET=1 ;;
--repair) REPAIR_MODE=1; REINSTALL_MODE=1 ;;
--reinstall) REINSTALL_MODE=1 ;;
--skip-sing-box|--no-sing-box) SKIP_SING_BOX=1 ;;
--zram) ZRAM_INSTALL_OVERRIDE="yes" ;;
--no-zram) ZRAM_INSTALL_OVERRIDE="no" ;;
--tag)
shift
[ "$#" -gt 0 ] || { err "--tag requires X.Y.Z"; return 2; }
RELEASE_TAG_REQUESTED="$1"
;;
--channel)
shift
[ "$#" -gt 0 ] || { err "--channel requires stable or beta"; return 2; }
RELEASE_CHANNEL="$1"
case "$RELEASE_CHANNEL" in stable|beta) ;; *) err "Unsupported channel: $RELEASE_CHANNEL"; return 2 ;; esac
;;
--version) printf 'tachyon-installer %s\n' "$INSTALLER_VERSION"; exit 0 ;;
-h|--help) usage; exit 0 ;;
*) err "Unknown option: $1"; return 2 ;;
esac
shift
done
if [ "$VERBOSE" -eq 1 ] && [ "$QUIET" -eq 1 ]; then
err "--verbose and --quiet cannot be used together"
return 2
fi
case "$RELEASE_TAG_REQUESTED" in
"") ;;
*[!0-9.]*|*.*.*.*|.*|*.) err "Invalid --tag value: $RELEASE_TAG_REQUESTED"; return 2 ;;
esac
return 0
}
command_exists() { command -v "$1" >/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