From 746d475f66f55c224673571dfdd0cc0cce139568 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 20:25:02 +0000 Subject: [PATCH 1/2] fix(release): ship the library's companions and survive the v1.1.0 updater A release built from main could not be installed or upgraded to. installer/lib/row-template.sh sources lib/transaction.sh and panels/ at load time and aborted without them, but tools/make-release.sh shipped only the library: - Fresh install: install.sh sources $PAYLOAD/lib/row-template.sh, which stopped with "FAIL panel interface missing". - Upgrade from 1.1.0: `row-template update` runs the INSTALLED 1.1.0 library, whose updater copies only template.html, VERSION, lib/row-template.sh and bin/row-template. Afterwards every row-template command died loading the new library. No release can change what that updater copies. Packaging - The library declares its companions in RT_INSTALLER_COMPANIONS, and make-release.sh reads that line, so packaging cannot drift from what the installer loads. They ship under lib/ and panels/ and are covered by the payload's inner SHA256SUMS. Install and update - The library and its companions are one unit. rt_payload_companions reads the list from the PAYLOAD's own library, so the v1.1.0 payload, whose library declares none, still installs (a deliberate downgrade keeps working). Each declared path must be a plain .sh file directly in lib/ or panels/; the line is split with read, never glob-expanded. - A payload with a library but a missing or checksum-mismatched companion is refused before anything changes. - rt_cmd_install and rt_cmd_update install the companions atomically next to the library. A payload without a library (as before) leaves the installed set alone. Loading - ABSENT is not BROKEN. A missing panels/ or transaction.sh (the state the 1.1.0 updater leaves) now loads without that layer: the loader returns 2 and rt_installer_complete reports the gap. No command calls either layer, and a future caller must check rt_installer_complete first. A layer that is present but will not load still aborts at source time, as before. Completing an upgrade - `row-template update` always re-applies the latest release, so running it once more installs the design store and the companions. - `verify` warns "installer components are missing ... run 'row-template update' to complete the installation", and its missing-store failure now names the same remedy. - The manager's update menu no longer calls an incomplete install "up to date"; it offers the re-install and defaults to yes. Uninstall already removes the whole install root, so panels/ goes with it. Legacy backup and rollback are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011QC3E9ChkFK7sDFBTfwNJ3 --- installer/lib/row-template.sh | 144 +- tests/fixtures/installer-1.1.0/README.md | 19 + .../fixtures/installer-1.1.0/row-template.sh | 1979 +++++++++++++++++ tools/make-release.sh | 14 +- 4 files changed, 2135 insertions(+), 21 deletions(-) create mode 100644 tests/fixtures/installer-1.1.0/README.md create mode 100644 tests/fixtures/installer-1.1.0/row-template.sh diff --git a/installer/lib/row-template.sh b/installer/lib/row-template.sh index a880e3e..9d94cb0 100644 --- a/installer/lib/row-template.sh +++ b/installer/lib/row-template.sh @@ -64,6 +64,16 @@ RT_LIB_DIR="$RT_ROOT/lib" RT_BACKUPS="$RT_ROOT/backups" RT_BACKUPS_V2="$RT_ROOT/backups.v2" # format-2 snapshots (P2 only) RT_PANEL_STAGE="$RT_ROOT/.panel-stage" # where an adapter stages panel state (P2) +RT_PANELS_DIR="$RT_ROOT/panels" # the panel interface layer, beside lib/ + +# The management library's companions: the files it sources at load time +# (rt_panels_load, rt_transaction_load), as paths relative to a release payload +# and to RT_ROOT. They ship and install together with lib/row-template.sh: +# tools/make-release.sh packages exactly this list, and install and update read +# it back from the payload's own library (rt_payload_companions). Both read the +# line as text, which is why it is never expanded in this file. +# shellcheck disable=SC2034 +RT_INSTALLER_COMPANIONS="lib/transaction.sh panels/3xui.sh panels/index.sh panels/interface.sh" # Limits. RT_LOGO_MAX_BYTES=$((256 * 1024)) # raw image cap before base64 @@ -1735,6 +1745,75 @@ rt_layout_ensure() { chmod 700 "$RT_BACKUPS" 2>/dev/null || true } +rt_payload_companions() { + # Print the companions PAYLOAD's own management library declares, one per + # line. The payload's library, not the running one, decides: a v1.1.0 + # payload's library declares none and needs none, so installing it (a + # deliberate downgrade) stays possible. Fails on any path outside the two + # directories a companion may live in, so a payload can never direct a write + # elsewhere under -- or outside -- the install root. + local lib="$1/lib/row-template.sh" list rel + local -a rels=() + [ -f "$lib" ] || return 0 + list="$(LC_ALL=C sed -n 's/^RT_INSTALLER_COMPANIONS="\(.*\)"$/\1/p' "$lib" | head -n 1)" + # split with read, never an unquoted expansion: the declaration is payload + # data, and a word like panels/*.sh must reach the check below as written + # rather than be glob-expanded first. + read -r -a rels <<< "$list" || true + for rel in ${rels[@]+"${rels[@]}"}; do + case "$rel" in + lib/row-template.sh) rt_err "the release payload declares an invalid companion: $rel"; return 1 ;; + lib/*.sh|panels/*.sh) + case "${rel#*/}" in + */*|.*|*[!A-Za-z0-9._-]*) rt_err "the release payload declares an invalid companion: $rel"; return 1 ;; + esac ;; + *) rt_err "the release payload declares an invalid companion: $rel"; return 1 ;; + esac + printf '%s\n' "$rel" + done +} + +rt_payload_companions_ok() { + # 0 when PAYLOAD carries every companion its management library declares, as + # regular files that match the payload's SHA256SUMS. The library and its + # companions are one unit: a payload whose library is present but whose + # companions are not is refused, because installing it would pair a new + # library with missing or stale companions. A payload without a library has + # nothing to check; the installed library and its companions stay as they are. + local payload="$1" list rel want + list="$(rt_payload_companions "$payload")" || return 1 + for rel in $list; do + if [ ! -f "$payload/$rel" ] || [ -L "$payload/$rel" ]; then + rt_err "the release payload is incomplete: $rel is missing."; return 1 + fi + want="$(rt_sums_lookup "$rel" "$payload/SHA256SUMS")" + if [ -n "$want" ] && ! rt_verify_sha256 "$payload/$rel" "$want" >/dev/null 2>&1; then + rt_err "payload checksum mismatch: $rel"; return 1 + fi + done + return 0 +} + +rt_install_companions() { + # Install the companions PAYLOAD's library declares beside it, each one + # atomically. Only after rt_payload_companions_ok has accepted the payload. + local payload="$1" list rel + list="$(rt_payload_companions "$payload")" || return 1 + [ -n "$list" ] || return 0 + rt_assert_not_symlink "$RT_PANELS_DIR" || return 1 + for rel in $list; do + rt_atomic_install "$payload/$rel" "$RT_ROOT/$rel" 644 || return 1 + done + return 0 +} + +rt_installer_complete() { + # 0 when this library loaded both companion layers. A host updated to this + # version by the 1.1.0 updater has neither -- that updater copies only the + # library and the CLI -- until `row-template update` installs them. + [ -n "${RT_PANELS_LOADED:-}" ] && [ -n "${RT_TRANSACTION_LOADED:-}" ] +} + rt_set_dist() { # install SRC as the pristine canonical artifact and record its checksum. # SRC must already be a structurally valid Row-Template artifact. @@ -2106,6 +2185,7 @@ rt_cmd_install() { if [ -n "$w" ]; then rt_verify_sha256 "$payload/template.html" "$w" || rt_die "payload artifact checksum mismatch."; fi fi rt_validate_template "$payload/template.html" || rt_die "install artifact failed structural validation." + rt_payload_companions_ok "$payload" || rt_die "the release payload is incomplete; nothing was changed." # environment discovery + hard version gate (fail closed) rt_detect_xui || rt_die "no 3x-ui installation was detected on this host." @@ -2153,6 +2233,8 @@ rt_cmd_install() { rt_atomic_install "$payload/lib/row-template.sh" "$RT_LIB_DIR/row-template.sh" 644 \ || rt_warn "could not install the management library; the CLI may be unavailable." fi + rt_install_companions "$payload" \ + || rt_warn "could not install the management library's companions; run 'row-template update' to retry." if [ -f "$payload/bin/row-template" ]; then rt_atomic_install "$payload/bin/row-template" "$RT_BIN" 755 \ || rt_warn "could not install the row-template CLI to $RT_BIN." @@ -2335,7 +2417,7 @@ rt_cmd_verify() { rt_err "selected template '$sel_id' is missing from the template store."; fails=$((fails + 1)) fi else - rt_err "template store missing or empty; re-run the installer."; fails=$((fails + 1)) + rt_err "template store missing or empty; run 'row-template update' to install it."; fails=$((fails + 1)) fi if [ -f "$RT_LIVE" ] && [ -r "$RT_LIVE" ]; then @@ -2357,6 +2439,11 @@ rt_cmd_verify() { [ -f "$RT_LIB_DIR/row-template.sh" ] && rt_ok "Management library present." \ || { rt_warn "management library not found under the install root."; warns=$((warns + 1)); } + if rt_installer_complete; then rt_ok "Installer components present." + else + rt_warn "installer components are missing (lib/transaction.sh, panels/), as after an update from 1.1.0; run 'row-template update' to complete the installation." + warns=$((warns + 1)) + fi [ -x "$RT_BIN" ] && rt_ok "CLI present: $RT_BIN" \ || { rt_warn "CLI not found or not executable at $RT_BIN."; warns=$((warns + 1)); } @@ -2488,6 +2575,7 @@ rt_cmd_update() { if [ -n "$w" ]; then rt_verify_sha256 "$payload/template.html" "$w" || rt_die "payload artifact checksum mismatch."; fi fi rt_validate_template "$payload/template.html" || rt_die "the release artifact failed structural validation." + rt_payload_companions_ok "$payload" || rt_die "the release payload is incomplete; nothing was changed." # stage the release's template store, then keep the operator's selection when # this release still ships it. The top-level template.html stays the Row @@ -2522,6 +2610,8 @@ rt_cmd_update() { rt_atomic_install "$payload/lib/row-template.sh" "$RT_LIB_DIR/row-template.sh" 644 \ || rt_warn "could not update the management library." fi + rt_install_companions "$payload" \ + || rt_warn "could not update the management library's companions; run 'row-template update' to retry." if [ -f "$payload/bin/row-template" ]; then rt_atomic_install "$payload/bin/row-template" "$RT_BIN" 755 \ || rt_warn "could not update the row-template CLI." @@ -2773,7 +2863,10 @@ rt_manager_update() { rt_ui_kv "Installed" "${cur:-unknown}" if avail="$(rt_remote_version 2>/dev/null)" && [ -n "$avail" ]; then rt_ui_kv "Available" "$avail" - if [ -n "$cur" ] && rt_semver_ge "$cur" "$avail"; then + if [ -n "$cur" ] && rt_semver_ge "$cur" "$avail" && ! rt_installer_complete; then + rt_ui_warn "This installation is incomplete: some installer components are missing, as after an update from 1.1.0." + rt_ui_confirm "Re-install $avail now to complete it?" yes || return 0 + elif [ -n "$cur" ] && rt_semver_ge "$cur" "$avail"; then rt_ui_success "Row-Template is up to date." rt_ui_confirm "Re-install $avail anyway?" no || return 0 else @@ -3198,18 +3291,25 @@ rt_install_success_screen() { # interface.sh resolves against at CALL time. Neither reads the other at load # time, so the order is a readability choice, not a load-bearing one. # -# This layer must not be sourced by a build that has no panels/ directory -# (an older payload). That is a FAILURE rather than a silent skip: a caller -# must never reach a panel operation and find the function simply absent, -# because "command not found" is an exit 127 that no return-code contract -# describes. Detectable failure beats an undefined symbol. +# ABSENT is not the same as BROKEN. A host updated to this version by the +# 1.1.0 updater has no panels/ directory: that updater copies only the library +# and the CLI, and nothing in a release can change what it copies. Refusing to +# load there would leave every `row-template` command dead, including the +# `update` that installs the layer. So an absent layer loads WITHOUT it: +# rt_panels_load returns 2, RT_PANELS_LOADED stays empty, rt_installer_complete +# reports the gap, `verify` names it and `update` repairs it. No command calls +# a panel operation today; one that ever does must check rt_installer_complete +# first, so it meets a reported gap rather than an exit 127. A layer that is +# PRESENT but fails to load is damage, not an older updater, and still fails +# loudly at source time. rt_panels_load() { # Source the frozen panel interface layer exactly once. Idempotent, so a - # re-source of this library cannot double-define anything. + # re-source of this library cannot double-define anything. Returns 2 when + # the layer is absent, 1 when it is present but will not load. [ -n "${RT_PANELS_LOADED:-}" ] && return 0 local dir dir="$(dirname "${BASH_SOURCE[0]}")/../panels" - [ -d "$dir" ] || { rt_err "panel interface missing: $dir"; return 1; } + [ -d "$dir" ] || return 2 . "$dir/interface.sh" || { rt_err "could not load panel interface"; return 1; } . "$dir/index.sh" || { rt_err "could not load panel registry"; return 1; } RT_PANELS_LOADED=1 @@ -3217,11 +3317,12 @@ rt_panels_load() { } # Loaded eagerly, because every caller of a panel operation should be able to -# assume the contract is present rather than remembering to load it. A failure -# here is loud and fatal at source time -- the same posture as a missing -# row-template.sh in the payload -- rather than deferred to first use. +# assume the contract is present rather than remembering to load it. A layer +# that is present but broken is loud and fatal at source time rather than +# deferred to first use; an absent one is the older-updater case above. RT_PANELS_LOADED="" -if ! rt_panels_load; then +rt_load_rc=0; rt_panels_load || rt_load_rc=$? +if [ "$rt_load_rc" -ne 0 ] && [ "$rt_load_rc" -ne 2 ]; then # Sourced: abort the source so the caller sees a failure. Executed # directly: exit, since there is no caller to return to. Both paths end # the run rather than leaving a half-loaded interface behind. @@ -3238,27 +3339,30 @@ fi # is loaded eagerly: a caller of rt_transaction_run must be able to assume the # engine is present rather than remembering to load it. The order is not # load-bearing -- neither file reads the other at load time, and the engine -# resolves rt_panel_* at CALL time -- but a missing engine must fail loudly at -# source time rather than surfacing as an undefined command at run time, which -# is an exit 127 no return-code contract describes. +# resolves rt_panel_* at CALL time. Absent and broken are told apart exactly as +# for the panel layer: an engine the 1.1.0 updater never installed loads +# without it (return 2, reported by rt_installer_complete); an engine that is +# present but will not load fails loudly at source time. rt_transaction_load() { # Source the transaction engine exactly once. Idempotent, so a re-source of - # this library cannot double-define anything. + # this library cannot double-define anything. Returns 2 when the engine is + # absent, 1 when it is present but will not load. [ -n "${RT_TRANSACTION_LOADED:-}" ] && return 0 local dir dir="$(dirname "${BASH_SOURCE[0]}")" - [ -f "$dir/transaction.sh" ] || { - rt_err "transaction engine missing: $dir/transaction.sh"; return 1; } + [ -f "$dir/transaction.sh" ] || return 2 . "$dir/transaction.sh" || { rt_err "could not load the transaction engine"; return 1; } RT_TRANSACTION_LOADED=1 return 0 } RT_TRANSACTION_LOADED="" -if ! rt_transaction_load; then +rt_load_rc=0; rt_transaction_load || rt_load_rc=$? +if [ "$rt_load_rc" -ne 0 ] && [ "$rt_load_rc" -ne 2 ]; then # Sourced: abort the source so the caller sees a failure. Executed directly: # exit, since there is no caller to return to. Both paths end the run rather # than leaving an engine half-loaded behind. return 1 2>/dev/null || exit 1 fi +unset rt_load_rc diff --git a/tests/fixtures/installer-1.1.0/README.md b/tests/fixtures/installer-1.1.0/README.md new file mode 100644 index 0000000..7922746 --- /dev/null +++ b/tests/fixtures/installer-1.1.0/README.md @@ -0,0 +1,19 @@ +# The v1.1.0 management library + +`row-template.sh` is `installer/lib/row-template.sh` exactly as released in +Row-Template v1.1.0 (tag `v1.1.0`, commit `137075a`), byte for byte: + +``` +sha256 c5a2b069826e5f1b46c1ace42d111d8f7a035c3c9651f064b69e62ca41ed32ac +``` + +It is the code already running on every host that installed v1.1.0, and it is +what performs the update to a newer release: `row-template update` runs the +*installed* library, so a new release is installed by the old updater. That +updater copies only `template.html`, `VERSION`, `lib/row-template.sh` and +`bin/row-template` from the payload. `tests/release.test.mjs` runs this file +against the real release tarball to prove an upgrade from v1.1.0 works. + +It is kept here, rather than read from git history, so the test also runs in a +shallow clone or an unpacked archive. Never edit it: the test pins the checksum +above. diff --git a/tests/fixtures/installer-1.1.0/row-template.sh b/tests/fixtures/installer-1.1.0/row-template.sh new file mode 100644 index 0000000..76206de --- /dev/null +++ b/tests/fixtures/installer-1.1.0/row-template.sh @@ -0,0 +1,1979 @@ +#!/usr/bin/env bash +# Row-Template management library. +# +# Sourced by installer/install.sh (bootstrap) and by the installed CLI at +# /usr/local/bin/row-template. It carries every operation the administration +# layer performs: environment discovery, configuration, template generation, +# atomic install, backup/rollback, verification and uninstall. +# +# Design rules honoured throughout: +# - No runtime dependency on node/go/python/jq/sqlite3. Standard Linux +# userland only (bash, coreutils, curl, tar, sha256sum, awk, sed, od). +# - Administrator-supplied text (service name, support URL) is treated as +# DATA, never as shell. It is stored base64-encoded and injected into the +# template as JSON string literals. config.env is never sourced/eval'd. +# - The panel's own subscription source tree is never written to; Row-Template +# lives under its own external root. 3x-ui source is never patched. +# - Every production file replacement is staged, validated, then swapped with +# an atomic rename. A failed step never truncates the working install. +# +# The entry scripts set `set -Eeuo pipefail` and an ERR trap; functions here +# use explicit checks and return codes so they behave under that regime. + +# --- constants --------------------------------------------------------------- + +RT_NAME="row-template" + +# The Row-Template install root. Deliberately under /etc/3x-ui (created and +# owned by us) and NOT under the panel's config root, which may be /etc/x-ui. +# subThemeDir is an absolute path, so the two are independent. Overridable for +# tests via RT_ROOT in the environment. +: "${RT_ROOT:=/etc/3x-ui/sub_templates/row-template}" +: "${RT_BIN:=/usr/local/bin/row-template}" +RT_MIN_XUI="3.6.0" + +# Project identity. This is the ONLY terminal-facing branding for the management +# tool itself (distinct from the operator's white-label service branding, which +# lives in config.env). Shown on the installer welcome, the manager header, the +# Installation Info screen and help — never on the served subscription page. +RT_PROJECT_NAME="Row-Template" +RT_DEVELOPER="iitzSeriZdev" +RT_GITHUB="https://github.com/iitzSeriZdev/Row-Template" + +# Public release channel. GitHub resolves releases/latest/download/ to the +# newest published (non-draft, non-prerelease) release's asset, over https, with +# no API token — so anonymous installs and update checks follow the stable +# channel with zero per-user configuration. RT_RELEASE_DIR / RT_RELEASE_URL +# override this (tests, local staging) and take precedence when set. +: "${RT_DEFAULT_RELEASE_URL:=$RT_GITHUB/releases/latest/download}" + +# Derived layout. +RT_LIVE="$RT_ROOT/sub.html" # what x-ui serves (generated) +RT_DIST="$RT_ROOT/dist/template.html" # canonical pristine artifact +RT_DIST_SUM="$RT_ROOT/dist/template.html.sha256" +RT_CONFIG="$RT_ROOT/config.env" # admin branding config (data) +RT_VERSION_FILE="$RT_ROOT/VERSION" +RT_LIB_DIR="$RT_ROOT/lib" +RT_BACKUPS="$RT_ROOT/backups" + +# Limits. +RT_LOGO_MAX_BYTES=$((256 * 1024)) # raw image cap before base64 +RT_NAME_MAX_CHARS=120 + +# --- output ------------------------------------------------------------------ + +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + RT_C_DIM=$'\033[2m'; RT_C_RED=$'\033[31m'; RT_C_YEL=$'\033[33m' + RT_C_GRN=$'\033[32m'; RT_C_BLD=$'\033[1m'; RT_C_RST=$'\033[0m' +else + RT_C_DIM=""; RT_C_RED=""; RT_C_YEL=""; RT_C_GRN=""; RT_C_BLD=""; RT_C_RST="" +fi + +rt_section() { printf '\n%s%s%s\n' "$RT_C_BLD" "$1" "$RT_C_RST"; } +rt_info() { printf ' %s\n' "$1"; } +rt_ok() { printf ' %sok%s %s\n' "$RT_C_GRN" "$RT_C_RST" "$1"; } +rt_warn() { printf ' %swarn%s %s\n' "$RT_C_YEL" "$RT_C_RST" "$1" >&2; } +rt_err() { printf ' %sFAIL%s %s\n' "$RT_C_RED" "$RT_C_RST" "$1" >&2; } +rt_die() { rt_err "$1"; exit "${2:-1}"; } + +# --- pure helpers (unit-tested) ---------------------------------------------- +# These have no filesystem or panel side effects (except reading argv/stdin) so +# tests/installer.test.mjs can drive them directly through bash. + +rt_trim() { + # strip leading and trailing ASCII/Unicode whitespace, print the rest. + local s="$1" + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + printf '%s' "$s" +} + +rt_has_control_chars() { + # true (0) when the argument contains a C0/C1-range control byte. Used to + # reject newlines/NUL-ish input before it reaches the template or config. + # grep -z makes the input one NUL-terminated record so an embedded newline is + # matched as content rather than silently swallowed as a line separator. + printf '%s' "$1" | LC_ALL=C grep -qz '[[:cntrl:]]' +} + +rt_b64_encode() { base64 | tr -d '\n'; } # stdin -> single-line base64 +rt_b64_decode() { tr -d '\n' | base64 -d; } # base64 stdin -> raw + +rt_normalize_semver() { + # echo the numeric X.Y.Z core of a version: drop a leading v, drop any + # -prerelease/+build suffix and any trailing junk, default missing fields. + local v="${1#v}"; v="${v%%[-+ ]*}" + v="$(printf '%s' "$v" | LC_ALL=C sed 's/[^0-9.].*$//')" + local a b c IFS=. + read -r a b c _ <<<"$v" + printf '%d.%d.%d' "$((10#${a:-0}))" "$((10#${b:-0}))" "$((10#${c:-0}))" +} + +rt_semver_ge() { + # succeed when version $1 >= version $2 (numeric, field by field). + local A B a1 a2 a3 b1 b2 b3 IFS=. + A="$(rt_normalize_semver "$1")"; B="$(rt_normalize_semver "$2")" + read -r a1 a2 a3 <<<"$A"; read -r b1 b2 b3 <<<"$B" + if [ "$a1" -ne "$b1" ]; then [ "$a1" -gt "$b1" ]; return; fi + if [ "$a2" -ne "$b2" ]; then [ "$a2" -gt "$b2" ]; return; fi + [ "$a3" -ge "$b3" ] +} + +rt_json_escape() { + # emit the argument as the interior of a double-quoted JS/JSON string. Escapes + # backslash, double-quote and '<' (so a literal can never form in + # the injected branding block). Control chars must be rejected by the caller. + # LC_ALL=C keeps sed byte-oriented; UTF-8 trail bytes (>=0x80) never collide + # with the ASCII bytes \ " < being rewritten. + printf '%s' "$1" | LC_ALL=C sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's//dev/null | tail -n1 || true)" + [ -n "$line" ] || return 0 + printf '%s' "${line#*=}" +} + +rt_config_get_text() { + # decode a base64-stored value (SERVICE_NAME_B64 / SUPPORT_URL_B64 / …). + local raw; raw="$(rt_config_get_raw "$1" "${2:-$RT_CONFIG}")" + [ -n "$raw" ] || return 0 + printf '%s' "$raw" | rt_b64_decode +} + +rt_config_write() { + # args: service_name support_url logo_mime logo_data_b64 — all treated as data. + # Written atomically at mode 640 (never world-readable/-writable). + local name="$1" url="$2" mime="$3" logo_b64="$4" dir tmp + dir="$(dirname "$RT_CONFIG")" + tmp="$(mktemp "$dir/.config.XXXXXX")" || return 1 + { + printf '# Row-Template configuration — generated file. Do NOT source this.\n' + printf '# Values are base64 data, read with grep+base64 and never executed.\n' + printf 'RT_CONFIG_VERSION=1\n' + printf 'SERVICE_NAME_B64=%s\n' "$(printf '%s' "$name" | rt_b64_encode)" + printf 'SUPPORT_URL_B64=%s\n' "$(printf '%s' "$url" | rt_b64_encode)" + printf 'LOGO_MIME=%s\n' "$mime" + printf 'LOGO_DATA_B64=%s\n' "$logo_b64" + } > "$tmp" || { rm -f "$tmp"; return 1; } + chmod 640 "$tmp" || { rm -f "$tmp"; return 1; } + mv -f "$tmp" "$RT_CONFIG" +} + +# --- logo validation (content signature, not extension) ---------------------- + +rt_file_size() { + local f="$1" s + s="$(stat -c%s "$f" 2>/dev/null)" || s="$(wc -c <"$f" 2>/dev/null)" || return 1 + printf '%s' "$s" +} + +rt_logo_detect_mime() { + # echo an allowed image mime derived from the file's magic bytes, or nothing. + # PNG / JPEG / WebP only. SVG, GIF, HTML, scripts and anything else are + # rejected by producing no output — detection never trusts the extension. + local f="$1" hdr + [ -f "$f" ] || return 0 + hdr="$(LC_ALL=C od -An -tx1 -N16 "$f" 2>/dev/null | tr -d ' \n')" || return 0 + case "$hdr" in + 89504e470d0a1a0a*) printf 'image/png' ;; # \x89PNG\r\n\x1a\n + ffd8ff*) printf 'image/jpeg' ;; # JPEG SOI + marker + 52494646????????57454250*) printf 'image/webp' ;; # RIFF????WEBP + *) : ;; + esac +} + +rt_logo_validate() { + # succeed only if FILE is a supported image within the size cap; on success + # echo the detected mime. Rejects symlinks, empty, oversized and non-images. + local f="$1" size mime + [ -e "$f" ] || { rt_err "logo file not found: $f"; return 1; } + [ -L "$f" ] && { rt_err "logo path is a symlink; refusing to read it"; return 1; } + [ -f "$f" ] || { rt_err "logo path is not a regular file"; return 1; } + size="$(rt_file_size "$f")" || { rt_err "cannot size logo file"; return 1; } + [ "$size" -gt 0 ] || { rt_err "logo file is empty"; return 1; } + if [ "$size" -gt "$RT_LOGO_MAX_BYTES" ]; then + rt_err "logo too large: ${size} bytes (max ${RT_LOGO_MAX_BYTES})"; return 1 + fi + mime="$(rt_logo_detect_mime "$f")" + [ -n "$mime" ] || { rt_err "unsupported image: only PNG, JPEG or WebP by content"; return 1; } + printf '%s' "$mime" +} + +# --- template generation ----------------------------------------------------- +# The installer never edits repository source. It splices operator branding into +# a copy of the pristine artifact; the monogram itself is computed at runtime by +# src/scripts/brand.js, so there is no Bash monogram implementation to disagree +# with the browser. Only serviceName / supportUrl / logo are injected here. + +rt_build_branding_block() { + # write the replacement branding block to stdout. Values arrive already + # chosen; each is emitted as a JSON-escaped double-quoted JS string literal. + local name="$1" url="$2" logo="$3" + printf '/* row:branding */\n' + printf 'var BRANDING = {\n' + printf ' serviceName: "%s",\n' "$(rt_json_escape "$name")" + printf ' supportUrl: "%s",\n' "$(rt_json_escape "$url")" + printf ' logo: "%s"\n' "$(rt_json_escape "$logo")" + printf '};\n' + printf '/* row:branding end */\n' +} + +rt_generate() { + # DIST -> OUT: splice a fresh branding block built from config.env into a copy + # of the pristine artifact. Never touches the live file; OUT is a caller-owned + # staging path. Validates the result before returning success. + local dist="$1" out="$2" name url mime logo_b64 logo blockf nopen nclose + [ -f "$dist" ] || { rt_err "canonical artifact missing: $dist"; return 1; } + + nopen="$(LC_ALL=C grep -Fc '/* row:branding */' "$dist" || true)" + nclose="$(LC_ALL=C grep -Fc '/* row:branding end */' "$dist" || true)" + if [ "$nopen" != "1" ] || [ "$nclose" != "1" ]; then + rt_err "artifact branding markers not found exactly once (open=$nopen close=$nclose)" + return 1 + fi + + name="$(rt_config_get_text SERVICE_NAME_B64)" + url="$(rt_config_get_text SUPPORT_URL_B64)" + # re-assert the input contract at generation time: config.env is validated when + # written, but a hand-edited or restored file could carry a raw newline/tab that + # rt_json_escape does not neutralise and would inject a line break into the JS + # string. Fail closed rather than swap a syntactically broken template live. + if rt_has_control_chars "$name" || rt_has_control_chars "$url"; then + rt_err "branding contains control characters; refusing to generate." + return 1 + fi + mime="$(rt_config_get_raw LOGO_MIME)" + logo_b64="$(rt_config_get_raw LOGO_DATA_B64)" + logo="" + if [ -n "$logo_b64" ] && [ -n "$mime" ]; then + logo="data:${mime};base64,${logo_b64}" + fi + + blockf="$(mktemp)" || return 1 + rt_build_branding_block "$name" "$url" "$logo" > "$blockf" \ + || { rm -f "$blockf"; return 1; } + + # index() matches the markers as plain text. The close marker never contains + # the open marker as a substring, and `done` fires the splice exactly once. + LC_ALL=C awk -v bf="$blockf" ' + BEGIN { blk=""; while ((getline l < bf) > 0) blk = blk l "\n"; close(bf) } + !done && index($0, "/* row:branding */") { printf "%s", blk; skip=1; done=1; next } + skip && index($0, "/* row:branding end */") { skip=0; next } + skip { next } + { print } + ' "$dist" > "$out" || { rm -f "$blockf"; return 1; } + rm -f "$blockf" + + rt_validate_template "$out" +} + +rt_validate_template() { + # dependency-free structural gate — the server-side analogue of tools/verify.mjs. + # A generated file that fails any check must never be swapped into place. + local f="$1" size nopen nclose + [ -f "$f" ] || { rt_err "generated template missing"; return 1; } + size="$(rt_file_size "$f")" || { rt_err "cannot size generated template"; return 1; } + [ "$size" -ge $((40 * 1024)) ] \ + || { rt_err "generated template implausibly small (${size} bytes)"; return 1; } + LC_ALL=C head -c 512 "$f" | LC_ALL=C grep -qi '' \ + || { rt_err "generated template does not begin with "; return 1; } + LC_ALL=C tail -c 64 "$f" | LC_ALL=C grep -q '' \ + || { rt_err "generated template does not end with "; return 1; } + nopen="$(LC_ALL=C grep -Fc '/* row:branding */' "$f" || true)" + nclose="$(LC_ALL=C grep -Fc '/* row:branding end */' "$f" || true)" + { [ "$nopen" = "1" ] && [ "$nclose" = "1" ]; } \ + || { rt_err "branding markers not intact in generated template"; return 1; } + LC_ALL=C grep -q 'id="sub-data"' "$f" \ + || { rt_err "generated template missing the #sub-data island"; return 1; } + LC_ALL=C grep -q 'var BRANDING' "$f" \ + || { rt_err "generated template missing the BRANDING block"; return 1; } + if LC_ALL=C grep -qE '/\*__[A-Z_]+__\*/|__FONT_BASE64__' "$f"; then + rt_err "generated template still contains unsubstituted build placeholders"; return 1 + fi + return 0 +} + +# --- integrity: sha256, SHA256SUMS, manifest --------------------------------- + +rt_sha256() { + # echo the lowercase hex sha256 of FILE via whichever tool is present. + local f="$1" out + if command -v sha256sum >/dev/null 2>&1; then + out="$(sha256sum -- "$f")" || return 1 + elif command -v shasum >/dev/null 2>&1; then + out="$(shasum -a 256 -- "$f")" || return 1 + else + rt_err "no sha256 tool (sha256sum or shasum) found"; return 1 + fi + printf '%s' "${out%% *}" +} + +rt_verify_sha256() { + # succeed only when FILE's sha256 equals EXPECTED. There is deliberately no + # skip/override path: a missing or mismatched checksum aborts the caller. + local f="$1" expected="$2" actual + expected="$(printf '%s' "$expected" | LC_ALL=C tr 'A-F' 'a-f' | LC_ALL=C tr -cd 'a-f0-9')" + [ "${#expected}" -eq 64 ] || { rt_err "expected checksum is not 64 hex characters"; return 1; } + [ -f "$f" ] || { rt_err "cannot checksum missing file: $f"; return 1; } + actual="$(rt_sha256 "$f")" || return 1 + actual="$(printf '%s' "$actual" | LC_ALL=C tr 'A-F' 'a-f')" + if [ "$actual" != "$expected" ]; then + rt_err "checksum mismatch (expected ${expected:0:12}… got ${actual:0:12}…)"; return 1 + fi + return 0 +} + +rt_sums_lookup() { + # echo the sha256 recorded for basename NAME in a SHA256SUMS file (coreutils + # "hex name" or "hex *name" form), last match wins, or nothing. + local name="$1" sums="$2" + [ -f "$sums" ] || return 0 + LC_ALL=C awk -v n="$name" ' + { f=$2; sub(/^\*/,"",f); if (f==n) hex=$1 } + END { if (hex!="") print hex } + ' "$sums" 2>/dev/null || true +} + +rt_manifest_get() { + # echo VALUE for KEY in a KEY=VALUE manifest, last wins, parsed as data. + local key="$1" file="$2" line + [ -f "$file" ] || return 0 + line="$(LC_ALL=C grep -E "^${key}=" "$file" 2>/dev/null | tail -n1 || true)" + [ -n "$line" ] || return 0 + printf '%s' "${line#*=}" +} + +# --- safe paths, symlink guards, atomic swap --------------------------------- + +rt_realpath_m() { + # normalize a path without requiring it to exist (realpath -m / readlink -m / + # lexical fallback). Used only for containment checks, never to widen access. + local p="$1" + if command -v realpath >/dev/null 2>&1; then + realpath -m -- "$p" 2>/dev/null && return 0 + fi + if command -v readlink >/dev/null 2>&1; then + readlink -m -- "$p" 2>/dev/null && return 0 + fi + printf '%s' "$p" +} + +rt_is_within() { + # succeed when PATH resolves inside BASE (both normalized). Guards every + # recursive delete so nothing outside Row-Template's own tree is ever removed. + local rb rp + rb="$(rt_realpath_m "$1")" || return 1 + rp="$(rt_realpath_m "$2")" || return 1 + [ -n "$rb" ] && [ -n "$rp" ] || return 1 + case "$rp/" in "$rb"/*) return 0;; *) return 1;; esac +} + +rt_assert_not_symlink() { + # refuse to write through / delete a path that is itself a symlink — a classic + # vector for redirecting a privileged write into an unrelated system file. + if [ -L "$1" ]; then rt_err "refusing to operate on a symlink: $1"; return 1; fi + return 0 +} + +rt_atomic_install() { + # stage SRC beside DEST, set MODE, then rename over DEST. A same-filesystem + # rename is atomic, so readers see either the whole old or whole new file. + local src="$1" dest="$2" mode="${3:-644}" dir tmp + dir="$(dirname "$dest")" + rt_assert_not_symlink "$dest" || return 1 + [ -d "$dir" ] || mkdir -p "$dir" || return 1 + tmp="$(mktemp "$dir/.stage.XXXXXX")" || return 1 + cp -- "$src" "$tmp" || { rm -f "$tmp"; return 1; } + chmod "$mode" "$tmp" || { rm -f "$tmp"; return 1; } + mv -f "$tmp" "$dest" || { rm -f "$tmp"; return 1; } + return 0 +} + +# --- backups ----------------------------------------------------------------- +# A backup snapshots the pristine artifact + VERSION + config so a rollback can +# reconstruct any prior install. Backup dir names are UTC timestamps, so a plain +# lexical sort is chronological. + +rt_safe_rmdir() { + # rm -rf a directory ONLY after positively confirming it is inside RT_BACKUPS + # and is not a symlink. This is the single choke point for recursive deletes. + local d="$1" + [ -n "$d" ] || return 1 + rt_assert_not_symlink "$d" || return 1 + if ! rt_is_within "$RT_BACKUPS" "$d"; then + rt_err "refusing to recursively delete path outside backups: $d"; return 1 + fi + rm -rf -- "$d" +} + +rt_backup_create() { + # snapshot the current install into a new timestamped dir; echo its path. + local ts dir ver + [ -f "$RT_DIST" ] || { rt_err "nothing to back up: $RT_DIST missing"; return 1; } + ver="$(cat "$RT_VERSION_FILE" 2>/dev/null || echo unknown)" + ver="$(printf '%s' "$ver" | LC_ALL=C tr -cd 'A-Za-z0-9._-')" + [ -n "$ver" ] || ver="unknown" + ts="$(date -u +%Y%m%dT%H%M%SZ)" + dir="$RT_BACKUPS/${ts}__${ver}" + mkdir -p "$dir" || return 1 + cp -- "$RT_DIST" "$dir/template.html" || { rt_safe_rmdir "$dir"; return 1; } + rt_sha256 "$dir/template.html" > "$dir/template.html.sha256" \ + || { rt_safe_rmdir "$dir"; return 1; } + [ -f "$RT_CONFIG" ] && cp -- "$RT_CONFIG" "$dir/config.env" + [ -f "$RT_VERSION_FILE" ] && cp -- "$RT_VERSION_FILE" "$dir/VERSION" + { printf 'created=%s\n' "$ts"; printf 'version=%s\n' "$ver"; } > "$dir/meta" + chmod 700 "$dir" 2>/dev/null || true + [ -f "$dir/config.env" ] && chmod 640 "$dir/config.env" 2>/dev/null || true + printf '%s' "$dir" +} + +rt_backup_validate() { + # a backup is usable only if its template is present and still matches the + # checksum recorded alongside it. + local d="$1" want + [ -d "$d" ] || return 1 + [ -f "$d/template.html" ] || return 1 + [ -f "$d/template.html.sha256" ] || return 1 + want="$(LC_ALL=C awk '{print $1; exit}' "$d/template.html.sha256" 2>/dev/null)" + rt_verify_sha256 "$d/template.html" "$want" >/dev/null 2>&1 +} + +rt_backups_list() { + # print valid backup dir paths, newest first. + [ -d "$RT_BACKUPS" ] || return 0 + local d + for d in "$RT_BACKUPS"/*/; do + [ -d "$d" ] || continue + d="${d%/}" + rt_backup_validate "$d" && printf '%s\n' "$d" + done | LC_ALL=C sort -r +} + +rt_backup_latest() { rt_backups_list | head -n1; } + +rt_backups_prune() { + # keep the KEEP newest valid backups (KEEP>=2 enforced by callers); delete the + # rest through the safe choke point. Corrupt/foreign dirs are left untouched. + local keep="${1:-2}" n=0 d + [ "$keep" -ge 1 ] 2>/dev/null || keep=2 + while IFS= read -r d; do + [ -n "$d" ] || continue + n=$((n + 1)) + [ "$n" -le "$keep" ] && continue + rt_safe_rmdir "$d" || rt_warn "could not prune backup: $d" + done < <(rt_backups_list) +} + +# --- 3x-ui discovery --------------------------------------------------------- +# The panel and Row-Template are deliberately independent. Discovery locates the +# panel binary, its systemd unit and (only if present) its database; it never +# infers the Row-Template root from the panel, or vice versa. + +rt_require_root() { + if [ "$(id -u)" -ne 0 ]; then + rt_die "this operation must run as root (e.g. sudo $RT_NAME ...)" + fi +} + +rt_detect_xui() { + # sets RT_XUI_BIN (the Go binary, preferred over the menu wrapper) and + # RT_XUI_UNIT (systemd unit name). Succeeds if either is found. + RT_XUI_BIN=""; RT_XUI_UNIT="" + local c + for c in /usr/local/x-ui/x-ui /usr/local/bin/x-ui; do + if [ -x "$c" ]; then RT_XUI_BIN="$c"; break; fi + done + if [ -z "$RT_XUI_BIN" ] && command -v x-ui >/dev/null 2>&1; then + RT_XUI_BIN="$(command -v x-ui)" + fi + # NB: no `grep -q` here. Under `set -o pipefail`, grep -q closes the pipe on the + # first match and systemctl dies of SIGPIPE (rc 141), which pipefail then makes + # the pipeline's status — so the condition read false and the unit was "missed" + # on every box. Letting grep drain all input keeps systemctl's writer happy. + if command -v systemctl >/dev/null 2>&1 \ + && systemctl list-unit-files 2>/dev/null | LC_ALL=C grep '^x-ui\.service' >/dev/null 2>&1; then + RT_XUI_UNIT="x-ui.service" + fi + [ -n "$RT_XUI_BIN" ] || [ -n "$RT_XUI_UNIT" ] +} + +rt_detect_xui_version() { + # echo the panel's X.Y.Z as reported by the Go binary's -v output. Empty (and + # non-zero) if it cannot be determined — callers treat that as fail-closed. + RT_XUI_VERSION="" + local bin="${RT_XUI_BIN:-}" out ver + [ -n "$bin" ] || return 1 + out="$("$bin" -v 2>/dev/null || true)" + ver="$(printf '%s' "$out" | LC_ALL=C grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -n1 || true)" + [ -n "$ver" ] || return 1 + RT_XUI_VERSION="$ver" + printf '%s' "$ver" +} + +rt_check_min_version() { + # fail closed: no detectable version, or below minimum, means do not proceed. + local v="${RT_XUI_VERSION:-}" + [ -n "$v" ] || rt_die "cannot determine the 3x-ui version; refusing to proceed" + if ! rt_semver_ge "$v" "$RT_MIN_XUI"; then + rt_die "3x-ui $v is below the required minimum $RT_MIN_XUI; not installing" + fi +} + +rt_detect_xui_db() { + # sets RT_XUI_DB if a panel database can be located. Absence is not fatal — it + # only means subThemeDir must be set manually rather than programmatically. + RT_XUI_DB="" + local candidates=() c + [ -n "${XUI_DB_FOLDER:-}" ] && candidates+=("$XUI_DB_FOLDER/x-ui.db") + candidates+=(/etc/x-ui/x-ui.db /usr/local/x-ui/x-ui.db /etc/3x-ui/x-ui.db) + for c in "${candidates[@]}"; do + if [ -f "$c" ]; then RT_XUI_DB="$c"; return 0; fi + done + return 1 +} + +# --- service safety ---------------------------------------------------------- + +rt_service_active() { + [ -n "${RT_XUI_UNIT:-}" ] || return 1 + systemctl is-active --quiet "$RT_XUI_UNIT" +} +rt_service_start() { + [ -n "${RT_XUI_UNIT:-}" ] || return 1 + systemctl start "$RT_XUI_UNIT" +} +rt_service_stop() { + [ -n "${RT_XUI_UNIT:-}" ] || return 1 + systemctl stop "$RT_XUI_UNIT" +} + +# --- subThemeDir: point the panel at Row-Template ---------------------------- +# Upstream exposes no CLI or config-file mechanism for subThemeDir (confirmed +# against 3x-ui primary source: the `x-ui setting` subcommand covers port/user/ +# pass/webBasePath/cert/tgbot/listen/2FA only). So the choices are: mutate the +# settings row directly when sqlite3 is available, or guide the admin to set it +# in the panel UI. We never install sqlite3 to force the first path. + +rt_subtheme_get_sqlite() { + # echo the stored subThemeDir value (may be empty). Non-zero if unreadable. + command -v sqlite3 >/dev/null 2>&1 || return 2 + [ -n "${RT_XUI_DB:-}" ] || return 2 + sqlite3 "$RT_XUI_DB" "SELECT value FROM settings WHERE key='subThemeDir' LIMIT 1;" 2>/dev/null +} + +rt_subtheme_set_sqlite() { + # set subThemeDir to RT_ROOT following stop -> write -> start -> verify, so a + # running panel can't flush a stale in-memory value back over the change. + # Returns 0 on verified persist, 2 if sqlite3/DB unavailable, 1 on failure. + local want="$RT_ROOT" was_active=0 esc n cur + command -v sqlite3 >/dev/null 2>&1 || return 2 + [ -n "${RT_XUI_DB:-}" ] || return 2 + rt_service_active && was_active=1 || true + if [ "$was_active" -eq 1 ]; then rt_service_stop || return 1; fi + esc="${want//\'/\'\'}" # SQL single-quote escaping (data-safe) + # settings.key has a non-unique index, so INSERT-or-UPDATE explicitly rather + # than relying on ON CONFLICT (which needs a unique constraint). + n="$(sqlite3 "$RT_XUI_DB" "SELECT COUNT(*) FROM settings WHERE key='subThemeDir';" 2>/dev/null || echo 0)" + if [ "${n:-0}" -gt 0 ]; then + sqlite3 "$RT_XUI_DB" "UPDATE settings SET value='$esc' WHERE key='subThemeDir';" 2>/dev/null \ + || { [ "$was_active" -eq 1 ] && rt_service_start || true; return 1; } + else + sqlite3 "$RT_XUI_DB" "INSERT INTO settings (key,value) VALUES ('subThemeDir','$esc');" 2>/dev/null \ + || { [ "$was_active" -eq 1 ] && rt_service_start || true; return 1; } + fi + if [ "$was_active" -eq 1 ]; then rt_service_start || return 1; fi + cur="$(rt_subtheme_get_sqlite || true)" + [ "$cur" = "$want" ] +} + +rt_subtheme_configure() { + # echo the outcome: "auto" (set + verified in DB) or "manual" (no safe + # programmatic path — the caller prints panel instructions). A genuine failure + # (e.g. the service would not restart) is propagated as a non-zero return. + local rc=0 + rt_subtheme_set_sqlite || rc=$? + case "$rc" in + 0) printf 'auto' ;; + 2) printf 'manual' ;; + *) return 1 ;; + esac +} + +# --- functional render smoke ------------------------------------------------- +# Filesystem presence is not proof. Where a test URL can be built, confirm what +# the panel actually serves. Any subId used here is a secret: it is read into a +# local variable and never printed, logged or returned. + +rt_smoke_derive_url() { + # echo a localhost /sub/ URL, or nothing. Reads the sub port/path and one + # client subId via sqlite3; requires the DB and sqlite3 to be present. + command -v sqlite3 >/dev/null 2>&1 || return 1 + [ -n "${RT_XUI_DB:-}" ] || return 1 + local port path sid + port="$(sqlite3 "$RT_XUI_DB" "SELECT value FROM settings WHERE key='subPort' LIMIT 1;" 2>/dev/null || true)" + path="$(sqlite3 "$RT_XUI_DB" "SELECT value FROM settings WHERE key='subPath' LIMIT 1;" 2>/dev/null || true)" + [ -n "$port" ] || port=2096 + [ -n "$path" ] || path="/sub/" + sid="$(sqlite3 "$RT_XUI_DB" "SELECT settings FROM inbounds LIMIT 200;" 2>/dev/null \ + | LC_ALL=C grep -oE '"subId"[[:space:]]*:[[:space:]]*"[^"]+"' | head -n1 \ + | LC_ALL=C sed -E 's/.*"([^"]+)"$/\1/' || true)" + [ -n "$sid" ] || return 1 + case "$path" in /*) : ;; *) path="/$path" ;; esac + case "$path" in */) : ;; *) path="$path/" ;; esac + printf 'http://127.0.0.1:%s%s%s' "$port" "$path" "$sid" +} + +rt_render_smoke() { + # classify what a browser request receives: pass (Row-Template served), + # fallback (built-in default served — our template not active), skip (no test + # URL could be built), error (endpoint unreachable). Returns 0 always; the + # caller maps the class to PASS/WARN/FAIL. + local url body + url="${RT_SMOKE_URL:-}" + [ -n "$url" ] || url="$(rt_smoke_derive_url || true)" + [ -n "$url" ] || { printf 'skip'; return 0; } + command -v curl >/dev/null 2>&1 || { printf 'skip'; return 0; } + body="$(curl -fsS -m 10 -A 'Mozilla/5.0' -H 'Accept: text/html' "$url" 2>/dev/null || true)" + if [ -z "$body" ]; then + url="https://${url#http://}" + body="$(curl -fsS -m 10 -k -A 'Mozilla/5.0' -H 'Accept: text/html' "$url" 2>/dev/null || true)" + fi + [ -n "$body" ] || { printf 'error'; return 0; } + # Pure-bash substring test on purpose. `printf %s "$big" | grep -q PAT` under + # `set -o pipefail` misreports a match as failure: grep -q exits on the first + # hit, printf then dies with SIGPIPE (141) while writing the long tail, and + # pipefail promotes 141 to the pipeline status. The served Row-Template page + # is ~160 KB with an early `id="sub-data"` match, so the pipe form classifies + # a correctly-served page as 'fallback'. `[[ == *..* ]]` has no pipe. + if [[ "$body" == *'id="sub-data"'* ]]; then printf 'pass'; else printf 'fallback'; fi +} + +rt_render_smoke_vpn() { + # a VPN-client UA must still get raw subscription content, not the HTML page. + # pass (got non-HTML), fallback (got HTML — negotiation broken), skip/error. + local url body + url="${RT_SMOKE_URL:-}" + [ -n "$url" ] || url="$(rt_smoke_derive_url || true)" + [ -n "$url" ] || { printf 'skip'; return 0; } + command -v curl >/dev/null 2>&1 || { printf 'skip'; return 0; } + body="$(curl -fsS -m 10 -A 'v2rayNG/1.8.0' "$url" 2>/dev/null || true)" + if [ -z "$body" ]; then + url="https://${url#http://}" + body="$(curl -fsS -m 10 -k -A 'v2rayNG/1.8.0' "$url" 2>/dev/null || true)" + fi + [ -n "$body" ] || { printf 'error'; return 0; } + # Pure-bash test (see rt_render_smoke): a pipe here would hide a broken + # negotiation that wrongly returned the large HTML page (SIGPIPE -> 'pass'). + if [[ "$body" == *'id="sub-data"'* ]]; then printf 'fallback'; else printf 'pass'; fi +} + +# --- install tree + activation ---------------------------------------------- + +rt_layout_ensure() { + # create the Row-Template tree with conservative permissions. Idempotent. + mkdir -p "$RT_ROOT" "$(dirname "$RT_DIST")" "$RT_LIB_DIR" "$RT_BACKUPS" || return 1 + chmod 755 "$RT_ROOT" 2>/dev/null || true + chmod 700 "$RT_BACKUPS" 2>/dev/null || true +} + +rt_set_dist() { + # install SRC as the pristine canonical artifact and record its checksum. + # SRC must already be a structurally valid Row-Template artifact. + local src="$1" + rt_validate_template "$src" || { rt_err "refusing to install an invalid artifact"; return 1; } + rt_atomic_install "$src" "$RT_DIST" 644 || return 1 + rt_sha256 "$RT_DIST" > "$RT_DIST_SUM" || return 1 + chmod 644 "$RT_DIST_SUM" 2>/dev/null || true +} + +rt_activate() { + # regenerate sub.html from the current artifact + config, validate it, then + # swap it into the live path atomically. The live file is never truncated: on + # any failure the previous sub.html stays exactly as it was. + local staged dir + dir="$(dirname "$RT_LIVE")" + staged="$(mktemp "$dir/.live.XXXXXX")" || return 1 + if ! rt_generate "$RT_DIST" "$staged"; then rm -f "$staged"; return 1; fi + rt_atomic_install "$staged" "$RT_LIVE" 644 || { rm -f "$staged"; return 1; } + rm -f "$staged" +} + +rt_monogram_preview() { + # best-effort ASCII preview mirroring src/scripts/brand.js for simple Latin + # names (Katze-VPN -> K-V, Premium 100 GB -> P-G, 123 net -> N). For names + # with any non-ASCII character it returns non-zero: the caller then shows the + # name and notes the browser computes the monogram. This is deliberate — Bash + # ships no second Unicode monogram implementation to disagree with brand.js. + local name="$1" + case "$name" in *[!$'\x20'-$'\x7e']*) return 1;; esac + local norm toks=() t out="" first count=0 + norm="$(printf '%s' "$name" | LC_ALL=C tr '_|/+-' ' ')" + read -ra toks <<<"$norm" + for t in "${toks[@]}"; do + case "$t" in *[A-Za-z]*) : ;; *) continue;; esac + first="${t:0:1}" + out+="$(printf '%s' "$first" | LC_ALL=C tr 'a-z' 'A-Z')" + count=$((count + 1)) + [ "$count" -ge 2 ] && break + out+="-" + done + out="${out%-}" + [ -n "$out" ] || return 1 + printf '%s' "$out" +} + +# --- interactive configuration ---------------------------------------------- +# Prompts to stderr so a helper's stdout stays clean. Non-interactive callers +# (automation, CI) provide values through the environment: +# RT_SERVICE_NAME, RT_SUPPORT_URL, RT_LOGO_PATH, RT_LOGO_REMOVE=1. + +rt_prompt_text() { + local label="$1" def="$2" reply + if [ -n "$def" ]; then printf ' %s [%s]: ' "$label" "$def" >&2 + else printf ' %s: ' "$label" >&2; fi + IFS= read -r reply || reply="" + [ -n "$reply" ] || reply="$def" + [ "$reply" = "-" ] && reply="" # explicit clear sentinel + printf '%s' "$reply" +} + +rt_config_interactive() { + # gather branding (existing config supplies the defaults) and write config.env. + local cur_name cur_url cur_mime cur_logo_b64 name url mime logo_b64 ni=0 mg lp + cur_name="$(rt_config_get_text SERVICE_NAME_B64 2>/dev/null || true)" + cur_url="$(rt_config_get_text SUPPORT_URL_B64 2>/dev/null || true)" + cur_mime="$(rt_config_get_raw LOGO_MIME 2>/dev/null || true)" + cur_logo_b64="$(rt_config_get_raw LOGO_DATA_B64 2>/dev/null || true)" + name="$cur_name"; url="$cur_url"; mime="$cur_mime"; logo_b64="$cur_logo_b64" + { [ -t 0 ] && [ -z "${RT_ASSUME_NONINTERACTIVE:-}" ]; } || ni=1 + + # --- service name --- + if [ -n "${RT_SERVICE_NAME+x}" ]; then name="$RT_SERVICE_NAME" + elif [ "$ni" -eq 0 ]; then name="$(rt_prompt_text "Service name (Enter to keep, - to clear)" "$cur_name")"; fi + name="$(rt_trim "$name")" + rt_validate_service_name "$name" || { rt_err "service name rejected (control chars or too long)"; return 1; } + if [ "$ni" -eq 0 ] && [ -n "$name" ]; then + if mg="$(rt_monogram_preview "$name")"; then rt_info "Monogram preview: $mg" + else rt_info "Monogram: computed in the browser for \"$name\"."; fi + fi + + # --- support URL --- + if [ -n "${RT_SUPPORT_URL+x}" ]; then url="$RT_SUPPORT_URL" + elif [ "$ni" -eq 0 ]; then + while true; do + url="$(rt_prompt_text "Support URL, optional (Enter to keep, - to clear)" "$cur_url")" + rt_validate_support_url "$url" && break + rt_warn "Use https://, http://, tg:// or mailto: — or clear it with -" + done + fi + url="$(rt_trim "$url")" + rt_validate_support_url "$url" || { rt_err "support URL rejected"; return 1; } + + # --- logo --- + if [ -n "${RT_LOGO_REMOVE:-}" ]; then mime=""; logo_b64="" + elif [ -n "${RT_LOGO_PATH:-}" ]; then + mime="$(rt_logo_validate "$RT_LOGO_PATH")" || return 1 + [ -L "$RT_LOGO_PATH" ] && { rt_err "logo path became a symlink after validation; aborting."; return 1; } + logo_b64="$(base64 < "$RT_LOGO_PATH" | tr -d '\n')" + elif [ "$ni" -eq 0 ]; then + local have="none"; [ -n "$cur_logo_b64" ] && have="present ($cur_mime)" + printf ' Logo file [%s] (Enter to keep, - to remove, or a path): ' "$have" >&2 + IFS= read -r lp || lp="" + if [ -z "$lp" ]; then : # keep current + elif [ "$lp" = "-" ]; then mime=""; logo_b64="" + else + mime="$(rt_logo_validate "$lp")" || return 1 + [ -L "$lp" ] && { rt_err "logo path became a symlink after validation; aborting."; return 1; } + logo_b64="$(base64 < "$lp" | tr -d '\n')" + fi + fi + + rt_config_write "$name" "$url" "$mime" "$logo_b64" +} + +# --- temp cleanup (entry scripts trap EXIT -> rt_cleanup) -------------------- + +RT_TMP_TO_CLEAN=() +rt_mktemp_dir() { + local d; d="$(mktemp -d)" || return 1 + # NOTE: callers invoke this as `x="$(rt_mktemp_dir)"`, i.e. inside a command + # substitution, so this append lands in a subshell and is lost to the parent. + # It is kept for the (currently none) non-substituted caller; every command- + # substitution caller MUST also register the path in its own shell so the + # EXIT-trap rt_cleanup can see it (see rt_cmd_update). + RT_TMP_TO_CLEAN+=("$d"); printf '%s' "$d" +} +rt_cleanup() { + local d + for d in "${RT_TMP_TO_CLEAN[@]:-}"; do + [ -n "$d" ] && [ -d "$d" ] && rm -rf -- "$d" + done + # Always succeed: this runs from the entry scripts' EXIT trap, and a non-zero + # return here (e.g. the loop's last test failing on an empty array) would + # otherwise become the process exit status and make `help`/`version`/a passing + # `verify` look like a failure. + return 0 +} + +# --- reporting --------------------------------------------------------------- + +rt_render_report() { + # informational: print what the panel actually serves. Never fails the caller; + # strict PASS/FAIL semantics live in rt_cmd_verify. + local r rv + r="$(rt_render_smoke)" + case "$r" in + pass) rt_ok "Live check: a browser request renders Row-Template." ;; + fallback) rt_warn "Live check: the panel served its built-in page. If you just set the theme dir, restart the panel; otherwise run 'row-template verify'." ;; + skip) rt_info "Live check skipped (no test URL available without sqlite3)." ;; + error) rt_warn "Live check could not reach the subscription endpoint." ;; + esac + rv="$(rt_render_smoke_vpn)" + case "$rv" in + pass) rt_ok "Live check: a VPN client still receives normal subscription content." ;; + fallback) rt_warn "Live check: a VPN client received HTML instead of subscription content." ;; + *) : ;; + esac + return 0 +} + +rt_print_activation_note() { + # $1 = auto | manual + rt_section "Row-Template installed successfully." + rt_info "Template directory: $RT_ROOT" + rt_info "Served file: $RT_LIVE" + if [ "$1" = "auto" ]; then + rt_ok "Panel configured automatically: subThemeDir = $RT_ROOT" + else + rt_section "One manual step remains" + rt_info "In the panel: Settings -> Subscription -> Sub Theme Directory" + rt_info "Set it to exactly this absolute path, then save:" + printf '\n %s\n\n' "$RT_ROOT" + fi +} + +rt_cmd_version() { + local rtv xuiv + rtv="$(cat "$RT_VERSION_FILE" 2>/dev/null || true)"; [ -n "$rtv" ] || rtv="unknown" + rt_detect_xui >/dev/null 2>&1 || true + xuiv="$(rt_detect_xui_version 2>/dev/null || true)"; [ -n "$xuiv" ] || xuiv="unknown" + printf 'Row-Template %s\n' "$rtv" + printf 'Supported 3x-ui minimum: %s\n' "$RT_MIN_XUI" + printf 'Detected 3x-ui: %s\n' "$xuiv" +} + +# --- release acquisition (download -> verify -> extract) --------------------- +# The trusted, already-installed code performs acquisition. A downloaded shell +# payload is NEVER executed as the update mechanism: only a checksum-verified +# tar of data files is trusted, and its paths are screened before extraction. + +rt_fetch_one() { + # copy/download NAME from BASE into DEST, per source KIND (dir|url). For https + # sources curl is pinned to the TLS protocol so a redirect cannot downgrade the + # transport to http (--proto '=https'); http is only reached via the explicit + # RT_ALLOW_INSECURE_URL escape hatch validated in rt_fetch_release. + local kind="$1" base="$2" name="$3" dest="$4" + case "$kind" in + dir) [ -f "$base/$name" ] || return 1; cp -- "$base/$name" "$dest" ;; + url) + case "$base" in + https://*) curl --proto '=https' --tlsv1.2 -fsSL -m 120 -o "$dest" "$base/$name" ;; + *) curl -fsSL -m 120 -o "$dest" "$base/$name" ;; + esac ;; + *) return 1 ;; + esac +} + +rt_tar_extract_safe() { + # screen every archive entry for absolute paths and parent traversal, then + # extract. --no-same-owner/--no-same-permissions so neither ownership nor mode + # can be dictated by the archive. A name filter alone is not enough: a symlink + # or hardlink member (name "d") followed by a regular member "d/x" would let + # tar write outside DEST via the link, so any member that is not a regular + # file or directory is refused up front. + local tarball="$1" dest="$2" entry mtype + while IFS= read -r entry; do + case "$entry" in + /*|../*|*/../*|*/..|..) rt_err "unsafe path in archive: $entry"; return 1 ;; + esac + done < <(tar -tzf "$tarball" 2>/dev/null) + while IFS= read -r mtype; do + case "$mtype" in + -|d|"") : ;; # regular file, directory, or an occasional blank line + *) rt_err "unsafe member type in archive: '$mtype' (symlink/hardlink/special refused)"; return 1 ;; + esac + done < <(tar -tvzf "$tarball" 2>/dev/null | LC_ALL=C awk '{print substr($1,1,1)}') + tar -xzf "$tarball" -C "$dest" --no-same-owner --no-same-permissions +} + +rt_single_top() { + # if DIR contains exactly one child and it is a directory, echo it. + local d="$1" n first + n="$(find "$d" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l)" + if [ "$n" -eq 1 ]; then + first="$(find "$d" -mindepth 1 -maxdepth 1 2>/dev/null)" + [ -d "$first" ] && { printf '%s' "$first"; return 0; } + fi + return 1 +} + +rt_release_source() { + # Resolve the active release source into the globals RT_SRC_KIND (dir|url) and + # RT_SRC_BASE, applying the transport policy. RT_RELEASE_DIR / RT_RELEASE_URL + # override the baked-in public GitHub stable channel; when neither is set the + # default channel is used, so a normal user needs no configuration at all. + if [ -n "${RT_RELEASE_DIR:-}" ]; then + RT_SRC_KIND=dir; RT_SRC_BASE="$RT_RELEASE_DIR"; return 0 + fi + local base + if [ -n "${RT_RELEASE_URL:-}" ]; then base="${RT_RELEASE_URL%/}" + else base="${RT_DEFAULT_RELEASE_URL%/}"; fi + # authenticity note: the checksum is fetched from the same origin as the + # tarball, so it proves transit integrity, not provenance. https is required so + # an active network attacker cannot rewrite the bytes in flight; a real + # signature (see INSTALLER-DESIGN §16/§18) is the documented provenance control. + # http is reachable only via the explicit RT_ALLOW_INSECURE_URL hatch (local + # testing) and never applies to the default channel, which is https. + case "$base" in + https://?*) : ;; + http://?*) + if [ -n "${RT_ALLOW_INSECURE_URL:-}" ]; then + rt_warn "the release URL uses http:// (insecure); proceeding because RT_ALLOW_INSECURE_URL is set." + else + rt_err "the release URL must use https:// (set RT_ALLOW_INSECURE_URL=1 to override for local testing)." + return 1 + fi ;; + *) rt_err "the release URL must be an http(s) URL: $base"; return 1 ;; + esac + RT_SRC_KIND=url; RT_SRC_BASE="$base" +} + +rt_fetch_release() { + # obtain + verify + extract a release into WORK; echo the payload dir path. + # The source defaults to the public GitHub stable channel and is overridable + # via RT_RELEASE_DIR / RT_RELEASE_URL (see rt_release_source). + local work="$1" kind base manifest sums art tarball want pdir top + rt_release_source || return 1 + kind="$RT_SRC_KIND"; base="$RT_SRC_BASE" + mkdir -p "$work" || return 1 + manifest="$work/manifest.txt"; sums="$work/SHA256SUMS" + rt_fetch_one "$kind" "$base" "manifest.txt" "$manifest" || { rt_err "cannot fetch manifest.txt"; return 1; } + rt_fetch_one "$kind" "$base" "SHA256SUMS" "$sums" || { rt_err "cannot fetch SHA256SUMS"; return 1; } + art="$(rt_manifest_get artifact "$manifest")" + [ -n "$art" ] || { rt_err "manifest has no artifact= entry"; return 1; } + case "$art" in */*|*..*) rt_err "manifest artifact name is unsafe: $art"; return 1 ;; esac + tarball="$work/$art" + rt_fetch_one "$kind" "$base" "$art" "$tarball" || { rt_err "cannot fetch $art"; return 1; } + want="$(rt_sums_lookup "$art" "$sums")" + [ -n "$want" ] || { rt_err "no checksum for $art in SHA256SUMS; aborting (no override exists)"; return 1; } + rt_verify_sha256 "$tarball" "$want" || { rt_err "release checksum verification failed; aborting"; return 1; } + pdir="$work/payload"; mkdir -p "$pdir" + rt_tar_extract_safe "$tarball" "$pdir" || return 1 + top="$(rt_single_top "$pdir" || true)"; [ -n "$top" ] && pdir="$top" + printf '%s' "$pdir" +} + +rt_remote_version() { + # Echo the version= advertised by the active release source's manifest, without + # downloading the (large) artifact. Anonymous and stable-only via the public + # GitHub channel. Returns non-zero on any network/parse error so callers can + # tell "unable to check" apart from "already up to date". + local work m v + rt_release_source || return 1 + work="$(rt_mktemp_dir)" || return 1 + RT_TMP_TO_CLEAN+=("$work") # register in THIS shell (rt_mktemp_dir's own append is lost to the $( ) subshell) + m="$work/manifest.txt" + rt_fetch_one "$RT_SRC_KIND" "$RT_SRC_BASE" "manifest.txt" "$m" || return 1 + v="$(rt_manifest_get version "$m")" + [ -n "$v" ] || return 1 + printf '%s' "$v" +} + +rt_restore_from_backup() { + # install the artifact recorded in backup DIR as the canonical artifact and + # restore its VERSION. Admin config.env is deliberately left as-is so current + # branding is preserved across an update-rollback / rollback. + local dir="$1" + rt_backup_validate "$dir" || { rt_err "backup failed validation: $dir"; return 1; } + rt_set_dist "$dir/template.html" || return 1 + if [ -f "$dir/VERSION" ]; then + rt_atomic_install "$dir/VERSION" "$RT_VERSION_FILE" 644 \ + || rt_warn "could not restore VERSION from the backup." + fi + return 0 +} + +rt_subtheme_clear_sqlite() { + # reset subThemeDir to "" (stop -> write -> start -> verify). The panel then + # falls back to its built-in subscription page. 0 = cleared+verified, 2 = + # sqlite3/DB unavailable, 1 = failure. + local was_active=0 cur + command -v sqlite3 >/dev/null 2>&1 || return 2 + [ -n "${RT_XUI_DB:-}" ] || return 2 + rt_service_active && was_active=1 || true + if [ "$was_active" -eq 1 ]; then rt_service_stop || return 1; fi + sqlite3 "$RT_XUI_DB" "UPDATE settings SET value='' WHERE key='subThemeDir';" 2>/dev/null \ + || { [ "$was_active" -eq 1 ] && rt_service_start || true; return 1; } + if [ "$was_active" -eq 1 ]; then rt_service_start || return 1; fi + cur="$(rt_subtheme_get_sqlite || true)" + [ -z "$cur" ] +} + +# --- high-level flow: install ------------------------------------------------ +# Called by installer/install.sh with a verified, extracted payload directory. +# Runs the whole transaction: preflight -> stage -> validate -> backup -> +# activate -> configure panel -> verify. A failure never leaves a half-changed +# panel: the live template is only ever swapped atomically after validation. + +rt_cmd_install() { + local payload="$1" w + rt_require_root + [ -n "$payload" ] && [ -d "$payload" ] || rt_die "internal: install payload directory missing." + [ -f "$payload/template.html" ] || rt_die "install payload has no template.html." + + # payload integrity (defence in depth on top of the release tarball checksum) + if [ -f "$payload/SHA256SUMS" ]; then + w="$(rt_sums_lookup template.html "$payload/SHA256SUMS")" + if [ -n "$w" ]; then rt_verify_sha256 "$payload/template.html" "$w" || rt_die "payload artifact checksum mismatch."; fi + fi + rt_validate_template "$payload/template.html" || rt_die "install artifact failed structural validation." + + # environment discovery + hard version gate (fail closed) + rt_detect_xui || rt_die "no 3x-ui installation was detected on this host." + rt_detect_xui_version >/dev/null 2>&1 || true + rt_check_min_version + rt_detect_xui_db || true + + rt_assert_not_symlink "$RT_ROOT" || rt_die "install root is a symlink; refusing to proceed." + + # idempotency + friendly routing. A NON-INTERACTIVE run keeps the original + # contract exactly (repair in place, RT_ASSUME_YES gate). An INTERACTIVE run + # adds a welcome (fresh) or a re-run menu (existing) on top of it. + local existing=0 interactive=0 + rt_ui_is_interactive && interactive=1 + if [ -f "$RT_VERSION_FILE" ] || [ -f "$RT_DIST" ]; then existing=1; fi + if [ "$interactive" -eq 1 ]; then + if [ "$existing" -eq 1 ]; then + local action; action="$(rt_existing_install_menu)" + case "$action" in + manager) rt_manager_main; return 0 ;; + reconfigure) rt_run_action rt_cmd_config; return 0 ;; + update) rt_manager_update; return 0 ;; + exit) rt_info "No changes made."; return 0 ;; + repair) rt_info "Repairing in place (configuration preserved)." ;; + esac + else + rt_install_welcome "${RT_XUI_VERSION:-}" || { rt_info "Installation cancelled."; return 0; } + fi + elif [ "$existing" -eq 1 ]; then + if [ ! -t 0 ] && [ -z "${RT_ASSUME_YES:-}" ]; then + rt_die "an existing install was found at $RT_ROOT; re-run interactively or set RT_ASSUME_YES=1 to repair." + fi + rt_info "Existing install detected at $RT_ROOT; repairing in place (configuration preserved)." + fi + + rt_layout_ensure || rt_die "could not create the install tree." + if [ "$existing" -eq 1 ] && [ -f "$RT_DIST" ]; then + rt_backup_create >/dev/null || rt_warn "could not create a pre-install backup." + fi + + # stage the canonical artifact + supporting files (all atomic, symlink-guarded) + rt_set_dist "$payload/template.html" || rt_die "could not install the canonical artifact." + rt_atomic_install "$payload/VERSION" "$RT_VERSION_FILE" 644 || rt_die "could not install VERSION." + if [ -f "$payload/lib/row-template.sh" ]; then + rt_atomic_install "$payload/lib/row-template.sh" "$RT_LIB_DIR/row-template.sh" 644 \ + || rt_warn "could not install the management library; the CLI may be unavailable." + fi + if [ -f "$payload/bin/row-template" ]; then + rt_atomic_install "$payload/bin/row-template" "$RT_BIN" 755 \ + || rt_warn "could not install the row-template CLI to $RT_BIN." + fi + + # branding: a first install prompts; a repair keeps the existing config as-is. + if [ ! -f "$RT_CONFIG" ]; then + while true; do + rt_config_interactive || rt_die "configuration was not completed; the panel was not changed." + if [ "$interactive" -eq 1 ]; then + rt_install_summary_confirm && break + rt_info "Let's adjust the settings." + else + break + fi + done + else + rt_info "Existing branding configuration kept ($RT_CONFIG)." + fi + + # generate + validate + atomically swap the live template. + rt_activate || rt_die "the template failed to generate/validate; the panel was not changed." + + # point the panel at Row-Template. NON-INTERACTIVE: auto-configure exactly as + # before. INTERACTIVE: show the current subThemeDir and ASK before changing it. + local sub_outcome + if [ "$interactive" -eq 1 ]; then + rt_ui_section "Activate Row-Template as the subscription theme" + local sub_rc=0 sub_cur + sub_cur="$(rt_subtheme_get_sqlite 2>/dev/null)" || sub_rc=$? + if [ "$sub_rc" -ne 0 ]; then + rt_ui_info "Automatic activation is unavailable here (sqlite3 is not installed)." + rt_ui_info "Row-Template itself is installed successfully." + sub_outcome="manual" + else + rt_ui_kv "Current theme dir" "${sub_cur:-Not configured}" + rt_ui_kv "Row-Template dir" "$RT_ROOT" + if rt_ui_confirm "Make Row-Template the active 3X-UI subscription theme now?" yes; then + if sub_outcome="$(rt_subtheme_configure)"; then :; else + rt_service_active || rt_service_start || true + sub_outcome="manual" + rt_ui_warn "Could not set subThemeDir automatically; use the manual step below." + fi + else + sub_outcome="skipped" + fi + fi + else + if sub_outcome="$(rt_subtheme_configure)"; then :; else + rt_service_active || rt_service_start || true + sub_outcome="manual" + rt_warn "Could not set subThemeDir automatically; use the manual step below." + fi + fi + + rt_backups_prune 2 + if [ "$interactive" -eq 1 ]; then + rt_install_success_screen "$sub_outcome" + else + rt_print_activation_note "$sub_outcome" + fi + rt_render_report +} + +# --- high-level flow: config ------------------------------------------------- +# Reconfigure branding. The new template is generated and validated BEFORE the +# live file is swapped; on any failure the previous config and template are +# restored so a working install is never left broken. + +rt_cmd_config() { + rt_require_root + [ -f "$RT_DIST" ] || rt_die "Row-Template is not installed (run the installer first)." + rt_detect_xui || true + local saved="" + if [ -f "$RT_CONFIG" ]; then + saved="$(mktemp)" || rt_die "cannot create a temp file." + cp -- "$RT_CONFIG" "$saved" + fi + rt_section "Reconfigure Row-Template" + if ! rt_config_interactive; then + if [ -n "$saved" ]; then cp -f -- "$saved" "$RT_CONFIG"; chmod 640 "$RT_CONFIG" 2>/dev/null || true; rm -f "$saved"; fi + rt_die "configuration was not changed." + fi + if ! rt_activate; then + rt_err "the new branding failed validation; restoring the previous configuration." + if [ -n "$saved" ]; then cp -f -- "$saved" "$RT_CONFIG"; chmod 640 "$RT_CONFIG" 2>/dev/null || true; fi + [ -n "$saved" ] && rm -f "$saved" + rt_die "reconfiguration aborted; the previous template is still in place." + fi + [ -n "$saved" ] && rm -f "$saved" + rt_ok "Configuration updated and the live template was regenerated." + rt_render_report +} + +# --- high-level flow: verify ------------------------------------------------- +# Read-only health report. Emits ok/warn/FAIL lines and returns non-zero only +# when a hard check fails. Never changes anything and never prints secrets. + +rt_cmd_verify() { + local fails=0 warns=0 perm cur rc r rv + rt_section "Row-Template verification" + + if [ -d "$RT_ROOT" ] && [ ! -L "$RT_ROOT" ]; then rt_ok "Install root present: $RT_ROOT" + else rt_err "install root missing or is a symlink: $RT_ROOT"; fails=$((fails + 1)); fi + + if [ -f "$RT_DIST" ] && [ -r "$RT_DIST" ]; then + if [ -f "$RT_DIST_SUM" ] \ + && rt_verify_sha256 "$RT_DIST" "$(LC_ALL=C awk '{print $1; exit}' "$RT_DIST_SUM")" >/dev/null 2>&1; then + rt_ok "Canonical artifact integrity verified." + else rt_err "canonical artifact missing checksum or does not match it."; fails=$((fails + 1)); fi + else rt_err "canonical artifact missing or unreadable: $RT_DIST"; fails=$((fails + 1)); fi + + if [ -f "$RT_LIVE" ] && [ -r "$RT_LIVE" ]; then + if rt_validate_template "$RT_LIVE" >/dev/null 2>&1; then rt_ok "Live template is structurally valid and readable." + else rt_err "live template failed structural validation."; fails=$((fails + 1)); fi + else rt_err "live template missing or unreadable: $RT_LIVE"; fails=$((fails + 1)); fi + + if [ -s "$RT_VERSION_FILE" ]; then rt_ok "VERSION present: $(rt_trim "$(cat "$RT_VERSION_FILE")")" + else rt_err "VERSION file missing or empty."; fails=$((fails + 1)); fi + + if [ -f "$RT_CONFIG" ]; then + [ -r "$RT_CONFIG" ] && rt_ok "Config present and readable." \ + || { rt_warn "config present but not readable."; warns=$((warns + 1)); } + perm="$(stat -c '%a' "$RT_CONFIG" 2>/dev/null || true)" + if [ -n "$perm" ] && printf '%s' "$perm" | LC_ALL=C grep -qE '[2367]$'; then + rt_warn "config.env is other-writable (mode $perm); tighten to 640."; warns=$((warns + 1)) + fi + else rt_info "No config.env (white-label defaults)."; fi + + [ -f "$RT_LIB_DIR/row-template.sh" ] && rt_ok "Management library present." \ + || { rt_warn "management library not found under the install root."; warns=$((warns + 1)); } + [ -x "$RT_BIN" ] && rt_ok "CLI present: $RT_BIN" \ + || { rt_warn "CLI not found or not executable at $RT_BIN."; warns=$((warns + 1)); } + + if rt_detect_xui; then + if rt_detect_xui_version >/dev/null 2>&1; then + if rt_semver_ge "$RT_XUI_VERSION" "$RT_MIN_XUI"; then rt_ok "3x-ui $RT_XUI_VERSION meets the minimum $RT_MIN_XUI." + else rt_err "3x-ui $RT_XUI_VERSION is below the minimum $RT_MIN_XUI."; fails=$((fails + 1)); fi + else rt_warn "could not determine the 3x-ui version."; warns=$((warns + 1)); fi + else rt_warn "3x-ui installation was not detected."; warns=$((warns + 1)); fi + + rt_detect_xui_db || true + rc=0; cur="$(rt_subtheme_get_sqlite)" || rc=$? + if [ "$rc" -eq 0 ]; then + if [ "$cur" = "$RT_ROOT" ]; then rt_ok "Panel subThemeDir points at Row-Template." + elif [ -z "$cur" ]; then rt_warn "panel subThemeDir is empty; set it to $RT_ROOT."; warns=$((warns + 1)) + else rt_warn "panel subThemeDir does not point at Row-Template."; warns=$((warns + 1)); fi + else rt_info "subThemeDir not checked (sqlite3/DB unavailable)."; fi + + # Capture first rather than piping into `grep -q .`: under pipefail a match + # would SIGPIPE `find` (rc 141) and the leftover-staging warning would be lost. + local stray_stage + stray_stage="$(find "$RT_ROOT" -maxdepth 2 \( -name '.stage.*' -o -name '.live.*' \) 2>/dev/null || true)" + if [ -n "$stray_stage" ]; then + rt_warn "leftover staging files found under the install root (possible interrupted update)."; warns=$((warns + 1)) + fi + + r="$(rt_render_smoke)" + case "$r" in + pass) rt_ok "Live render check: a browser receives Row-Template." ;; + fallback) rt_warn "live render check: the panel served its built-in page."; warns=$((warns + 1)) ;; + error) rt_warn "live render check: the subscription endpoint was unreachable."; warns=$((warns + 1)) ;; + skip) rt_info "Live render check skipped (no test URL available)." ;; + esac + rv="$(rt_render_smoke_vpn)" + case "$rv" in + pass) rt_ok "VPN-client check: normal subscription content is served." ;; + fallback) rt_warn "VPN-client check: HTML was returned instead of subscription content."; warns=$((warns + 1)) ;; + *) : ;; + esac + + rt_section "Result" + # exit (not return) on failure: this is a terminal command, and exiting keeps + # the dispatcher's ERR trap from mislabelling an intended non-zero verdict as + # an "unexpected error". + if [ "$fails" -gt 0 ]; then rt_err "verification FAILED ($fails hard issue(s), $warns warning(s))."; exit 1 + elif [ "$warns" -gt 0 ]; then rt_warn "verification passed with $warns warning(s)."; return 0 + else rt_ok "verification passed with no warnings."; return 0; fi +} + +# --- high-level flow: uninstall ---------------------------------------------- +# Conservative by construction: the install root is positively identified as +# Row-Template's own before any recursive delete, and only files Row-Template +# created are removed. 3x-ui, its database, inbounds, clients and certificates +# are never touched. + +rt_uninstall_files() { + rt_assert_not_symlink "$RT_ROOT" || return 1 + # positively identify this as Row-Template's own root before deleting it. + if [ ! -f "$RT_VERSION_FILE" ] || { [ ! -f "$RT_DIST" ] && [ ! -f "$RT_LIB_DIR/row-template.sh" ]; }; then + rt_err "refusing to delete $RT_ROOT: it does not look like a Row-Template install root."; return 1 + fi + case "$RT_ROOT" in + ""|/|/etc|/usr|/usr/local|/var|/root|/home|/bin|/sbin|/lib|/opt|/etc/3x-ui|/etc/x-ui|/usr/local/x-ui) + rt_err "refusing to delete a system path: $RT_ROOT"; return 1 ;; + esac + rm -rf -- "$RT_ROOT" || return 1 + # remove the CLI only if it is unmistakably our launcher, never a namesake. + if [ -f "$RT_BIN" ] && [ ! -L "$RT_BIN" ] \ + && LC_ALL=C grep -q 'row-template CLI launcher' "$RT_BIN" 2>/dev/null; then + rm -f -- "$RT_BIN" + fi + return 0 +} + +rt_cmd_uninstall() { + rt_require_root + [ -f "$RT_VERSION_FILE" ] || rt_die "Row-Template does not appear to be installed at $RT_ROOT." + if [ -z "${RT_ASSUME_YES:-}" ]; then + if [ -t 0 ]; then + printf ' Remove Row-Template from %s and revert the panel to its built-in page? [y/N]: ' "$RT_ROOT" >&2 + local a; IFS= read -r a || a="" + case "$a" in y|Y|yes|YES) : ;; *) rt_info "Uninstall cancelled."; return 0 ;; esac + else + rt_die "refusing to uninstall non-interactively without RT_ASSUME_YES=1." + fi + fi + rt_detect_xui || true + rt_detect_xui_db || true + + # revert the panel to a safe state: clear subThemeDir only if it points at us. + local cur rc=0; cur="$(rt_subtheme_get_sqlite)" || rc=$? + if [ "$rc" -eq 0 ] && [ "$cur" = "$RT_ROOT" ]; then + if rt_subtheme_clear_sqlite; then rt_ok "Cleared subThemeDir; the panel reverts to its built-in page." + else + rt_service_active || rt_service_start || true + rt_warn "could not clear subThemeDir automatically. In the panel, clear Settings -> Subscription -> Sub Theme Directory." + fi + elif [ "$rc" -eq 0 ] && [ -n "$cur" ]; then + rt_info "subThemeDir points elsewhere; leaving it unchanged." + elif [ "$rc" -ne 0 ]; then + rt_warn "could not read subThemeDir (sqlite3/DB unavailable). If it points at $RT_ROOT, clear it in the panel." + fi + + if rt_uninstall_files; then + rt_ok "Removed Row-Template files from $RT_ROOT." + rt_info "3x-ui, its database, inbounds, clients and certificates were left untouched." + else + rt_die "uninstall could not complete safely; see the message above. No forced deletion was performed." + fi +} + +# --- high-level flow: update ------------------------------------------------- +# Download -> verify checksum -> validate -> back up current -> stage -> swap. +# The live template is only replaced by an atomic rename after the new one is +# generated and validated; if activation fails, the backup is restored so the +# previously-running version stays live. + +rt_cmd_update() { + rt_require_root + [ -f "$RT_DIST" ] || rt_die "Row-Template is not installed; run the installer first." + rt_detect_xui || true; rt_detect_xui_version >/dev/null 2>&1 || true + local work payload newver curver w backup + work="$(rt_mktemp_dir)" || rt_die "cannot create a work directory." + RT_TMP_TO_CLEAN+=("$work") # register in THIS shell; rt_mktemp_dir's own append is lost to the $( ) subshell + payload="$(rt_fetch_release "$work")" || rt_die "could not obtain a verified release." + [ -f "$payload/template.html" ] || rt_die "the release payload has no template.html." + if [ -f "$payload/SHA256SUMS" ]; then + w="$(rt_sums_lookup template.html "$payload/SHA256SUMS")" + if [ -n "$w" ]; then rt_verify_sha256 "$payload/template.html" "$w" || rt_die "payload artifact checksum mismatch."; fi + fi + rt_validate_template "$payload/template.html" || rt_die "the release artifact failed structural validation." + newver="$(rt_trim "$(cat "$payload/VERSION" 2>/dev/null || true)")" + curver="$(rt_trim "$(cat "$RT_VERSION_FILE" 2>/dev/null || true)")" + rt_info "Updating Row-Template ${curver:-unknown} -> ${newver:-unknown}" + + backup="$(rt_backup_create)" || rt_die "could not back up the current install; aborting." + + # stage the new artifact as canonical (live file untouched so far). + rt_set_dist "$payload/template.html" || rt_die "failed to stage the new artifact; the running template is unchanged." + rt_atomic_install "$payload/VERSION" "$RT_VERSION_FILE" 644 || rt_warn "could not update the VERSION file." + if [ -f "$payload/lib/row-template.sh" ]; then + rt_atomic_install "$payload/lib/row-template.sh" "$RT_LIB_DIR/row-template.sh" 644 \ + || rt_warn "could not update the management library." + fi + if [ -f "$payload/bin/row-template" ]; then + rt_atomic_install "$payload/bin/row-template" "$RT_BIN" 755 \ + || rt_warn "could not update the row-template CLI." + fi + + if rt_activate; then + rt_backups_prune 2 + rt_ok "Update complete: now running ${newver:-the new version}." + rt_render_report + else + rt_err "activation of the new version failed; rolling back." + if rt_restore_from_backup "$backup" && rt_activate; then + rt_warn "rolled back to ${curver:-the previous version}; no changes are live." + else + rt_die "activation failed AND automatic rollback failed; run 'row-template rollback' to recover." + fi + exit 1 # terminal command: exit so the dispatcher ERR trap stays quiet. + fi +} + +# --- high-level flow: rollback ----------------------------------------------- +# Restore a previous version from a validated backup. The current version is +# snapshotted first and is never destroyed until the rollback activates; if +# activation fails, the current version is restored. Admin config is preserved. + +rt_cmd_rollback() { + rt_require_root + [ -f "$RT_DIST" ] || rt_die "Row-Template is not installed." + rt_detect_xui || true + local mode="${1:-}" target="" safety + case "$mode" in + --to) target="${2:-}"; [ -n "$target" ] || rt_die "--to requires a backup directory." ;; + --auto|"") target="$(rt_backup_latest || true)" ;; + *) rt_die "usage: row-template rollback [--auto | --to ]" ;; + esac + [ -n "$target" ] || rt_die "no valid backup is available to roll back to." + [ -d "$target" ] || target="$RT_BACKUPS/$target" + rt_is_within "$RT_BACKUPS" "$target" || rt_die "refusing to roll back from a path outside the backups tree." + rt_backup_validate "$target" || rt_die "the selected backup failed validation: $target" + + # snapshot the CURRENT install first so the running version is never lost. + safety="$(rt_backup_create)" || { safety=""; rt_warn "could not snapshot the current version before rollback."; } + + rt_info "Rolling back to $(basename "$target")" + # stage the backup's artifact as canonical; the live file is not touched yet. + rt_restore_from_backup "$target" || rt_die "could not stage the backup; the running template is unchanged." + + # regenerate the live file from the restored artifact + current admin config. + if rt_activate; then + rt_ok "Rollback complete." + rt_render_report + else + rt_err "rollback activation failed; attempting to restore the current version." + if [ -n "$safety" ] && rt_restore_from_backup "$safety" && rt_activate; then + rt_warn "restored the previously-running version; nothing changed." + else + rt_die "rollback failed and the current version could not be restored automatically." + fi + exit 1 # terminal command: exit so the dispatcher ERR trap stays quiet. + fi +} + +# --- help -------------------------------------------------------------------- + +rt_print_help() { + cat <<'EOF' +Row-Template — custom subscription page manager for 3x-ui +by iitzSeriZdev — https://github.com/iitzSeriZdev/Row-Template + +Usage: + row-template Open the interactive manager (when run in a terminal) + row-template [options] + +Commands: + config Change the service name, support URL or logo, then regenerate + update Download, verify and activate a newer release (checksum enforced) + rollback Restore a previous version [--auto | --to ] + verify Check the install, panel wiring and live render (read-only) + version Show installed, minimum-supported and detected 3x-ui versions + uninstall Remove Row-Template and revert the panel to its built-in page + menu Open the interactive manager explicitly + help Show this help + +Run with no arguments in an interactive terminal to open the manager; in a +non-interactive context (scripts, CI, curl | bash) it prints this help instead. + +Commands that change the system (config/update/rollback/uninstall) must run as root. +EOF +} +# ============================================================================= +# Interactive terminal UI layer. +# +# A thin PRESENTATION layer over the tested rt_* lifecycle functions. It adds no +# new install/update/rollback/config/uninstall logic — every action routes to an +# existing rt_cmd_* implementation or an existing primitive. Colour is reused +# from the RT_C_* variables (already gated on `[ -t 1 ]` and NO_COLOR). Prompts +# go to stderr so a caller's stdout stays clean; menus never require dialog/fzf. +# ============================================================================= + +rt_ui_is_interactive() { + # true only when both ends are a terminal and automation has not opted out. + [ -t 0 ] && [ -t 1 ] && [ -z "${RT_ASSUME_NONINTERACTIVE:-}" ] +} + +rt_ui_rule() { + printf ' %s------------------------------------------------------------%s\n' \ + "$RT_C_DIM" "$RT_C_RST" +} + +rt_ui_header() { + # "Row-Template / by iitzSeriZdev" — the tool's own identity, colour-optional. + printf '\n %s%s%s %s/ by %s%s\n' \ + "$RT_C_BLD" "$RT_PROJECT_NAME" "$RT_C_RST" "$RT_C_DIM" "$RT_DEVELOPER" "$RT_C_RST" + rt_ui_rule +} + +# Named wrappers so callers read as UI intent; they reuse the tested emitters. +rt_ui_section() { rt_section "$1"; } +rt_ui_info() { rt_info "$1"; } +rt_ui_success() { rt_ok "$1"; } +rt_ui_warn() { rt_warn "$1"; } +rt_ui_error() { rt_err "$1"; } + +rt_ui_kv() { + # aligned "Label value" line for dashboards/info screens. + printf ' %s%-16s%s%s\n' "$RT_C_DIM" "$1" "$RT_C_RST" "$2" +} + +rt_ui_pause() { + # wait for Enter, but never block a non-interactive/automated caller. + rt_ui_is_interactive || return 0 + printf '\n %sPress Enter to return to the menu…%s ' "$RT_C_DIM" "$RT_C_RST" >&2 + IFS= read -r _ || true +} +rt_ui_confirm() { + # PROMPT DEFAULT(yes|no). Empty input takes the default; EOF (non-interactive) + # also takes the default so a piped/automated caller never hangs. 0 = yes. + local prompt="$1" def="${2:-no}" a hint + case "$def" in yes|y|Y|YES|Yes) hint="[Y/n]"; def="yes" ;; *) hint="[y/N]"; def="no" ;; esac + printf ' %s %s: ' "$prompt" "$hint" >&2 + IFS= read -r a || a="" + a="$(rt_trim "$a")" + [ -n "$a" ] || a="$def" + case "$a" in y|Y|yes|YES|Yes) return 0 ;; *) return 1 ;; esac +} + +rt_ui_menu_select() { + # echo a validated integer in [0,MAX]. Re-prompts on junk with a friendly + # message; on EOF returns "0" (Exit/Back) so a non-TTY caller cannot spin. + local max="$1" a + while true; do + printf ' %sSelect%s [0-%s]: ' "$RT_C_BLD" "$RT_C_RST" "$max" >&2 + IFS= read -r a || { printf '0'; return 0; } + a="$(rt_trim "$a")" + case "$a" in + ''|*[!0-9]*) rt_warn "Invalid option. Choose a number 0-$max." ; continue ;; + esac + if [ "$a" -ge 0 ] && [ "$a" -le "$max" ] 2>/dev/null; then printf '%s' "$a"; return 0; fi + rt_warn "Invalid option. Choose a number 0-$max." + done +} + +# --- evidence-based status --------------------------------------------------- + +rt_status_theme() { + # echo one token describing the install/activation state, decided from + # EVIDENCE (files + panel subThemeDir), never from mere file presence: + # notinstalled | damaged | active | inactive | unknown + # "unknown" means installed but activation is not verifiable here (no sqlite3). + [ -f "$RT_VERSION_FILE" ] || { printf 'notinstalled'; return 0; } + if [ ! -f "$RT_DIST" ] || ! rt_validate_template "$RT_LIVE" >/dev/null 2>&1; then + printf 'damaged'; return 0 + fi + local rc=0 cur + cur="$(rt_subtheme_get_sqlite 2>/dev/null)" || rc=$? + if [ "$rc" -ne 0 ]; then printf 'unknown'; return 0; fi + if [ "$cur" = "$RT_ROOT" ]; then printf 'active'; else printf 'inactive'; fi +} + +rt_status_label() { + case "$1" in + active) printf 'Installed / Active' ;; + inactive) printf 'Installed / Not active' ;; + unknown) printf 'Installed / Activation unverified' ;; + damaged) printf 'Installation damaged' ;; + notinstalled) printf 'Not installed' ;; + *) printf 'Unknown' ;; + esac +} +rt_status_service_label() { + # human label for the panel service, from discovery already run by the caller. + if [ -n "${RT_XUI_UNIT:-}" ]; then + if rt_service_active 2>/dev/null; then printf 'x-ui (running)'; else printf 'x-ui (stopped)'; fi + else + printf 'not detected' + fi +} + +rt_status_theme_label() { + case "$1" in + active) printf 'Row-Template (active)' ;; + inactive) printf 'Row-Template (installed, not the active theme)' ;; + unknown) printf 'Row-Template (installed; activation not verifiable without sqlite3)' ;; + damaged) printf 'Row-Template (files incomplete — run Verify/Repair)' ;; + *) printf 'Row-Template (not installed)' ;; + esac +} + +# --- manager: dashboard + main loop ------------------------------------------ +# Every action delegates to a tested rt_cmd_* function. rt_run_action isolates a +# terminal command (which may `exit`) in a subshell so a single failed operation +# returns to the menu instead of killing the manager, and cleans its own temp. + +rt_run_action() { ( trap 'rt_cleanup' EXIT; "$@" ) || true; } + +rt_manager_dashboard() { + local st rtv xuiv + rtv="$(rt_trim "$(cat "$RT_VERSION_FILE" 2>/dev/null || true)")"; [ -n "$rtv" ] || rtv="unknown" + xuiv="${RT_XUI_VERSION:-}"; [ -n "$xuiv" ] || xuiv="unknown" + st="$(rt_status_theme)" + rt_ui_header + rt_ui_kv "Version" "$rtv" + rt_ui_kv "3X-UI" "$xuiv" + rt_ui_kv "Status" "$(rt_status_label "$st")" + rt_ui_kv "Theme" "$(rt_status_theme_label "$st")" + rt_ui_kv "Service" "$(rt_status_service_label)" + printf '\n' + printf ' %s1%s Update\n' "$RT_C_BLD" "$RT_C_RST" + printf ' %s2%s Reconfigure branding\n' "$RT_C_BLD" "$RT_C_RST" + printf ' %s3%s Verify installation\n' "$RT_C_BLD" "$RT_C_RST" + printf ' %s4%s Rollback\n' "$RT_C_BLD" "$RT_C_RST" + printf ' %s5%s Activate / Re-apply theme\n' "$RT_C_BLD" "$RT_C_RST" + printf ' %s6%s Installation info\n' "$RT_C_BLD" "$RT_C_RST" + printf ' %s7%s Uninstall\n' "$RT_C_BLD" "$RT_C_RST" + printf ' %s0%s Exit\n' "$RT_C_BLD" "$RT_C_RST" +} +rt_manager_update() { + # Check the public stable channel for a newer version, then offer to apply it. + # A network / source failure is reported as "unable to check" — the running + # install is never described as damaged just because GitHub was unreachable. + rt_ui_section "Update" + local cur avail + cur="$(rt_trim "$(cat "$RT_VERSION_FILE" 2>/dev/null || true)")" + rt_ui_kv "Installed" "${cur:-unknown}" + if avail="$(rt_remote_version 2>/dev/null)" && [ -n "$avail" ]; then + rt_ui_kv "Available" "$avail" + if [ -n "$cur" ] && rt_semver_ge "$cur" "$avail"; then + rt_ui_success "Row-Template is up to date." + rt_ui_confirm "Re-install $avail anyway?" no || return 0 + else + rt_ui_info "A newer version is available." + rt_ui_confirm "Update ${cur:-current} -> $avail now?" yes || { rt_ui_info "Left unchanged."; return 0; } + fi + rt_run_action rt_cmd_update + else + rt_ui_warn "Unable to check for updates right now (network or release source unavailable)." + rt_ui_info "Your installation is unaffected. Published releases appear at:" + rt_ui_kv "GitHub" "$RT_GITHUB" + fi +} + +rt_manager_activate() { + # Detect the current subThemeDir, show it, and offer to point it at us. No + # write happens unless the operator confirms; the panel state is preserved. + rt_ui_section "Activate / Re-apply theme" + rt_detect_xui >/dev/null 2>&1 || true + rt_detect_xui_db >/dev/null 2>&1 || true + local rc=0 cur + cur="$(rt_subtheme_get_sqlite 2>/dev/null)" || rc=$? + if [ "$rc" -ne 0 ]; then + rt_ui_warn "Automatic activation is unavailable here (sqlite3 is not installed)." + rt_ui_info "Row-Template is installed. Activate it from the panel:" + rt_ui_info " Panel Settings -> Subscription -> Profile -> Sub Theme Directory" + rt_ui_kv "Enter exactly" "$RT_ROOT" + return 0 + fi + if [ "$cur" = "$RT_ROOT" ]; then + rt_ui_success "Row-Template is already the active subscription theme." + rt_ui_confirm "Re-apply and verify anyway?" no || return 0 + elif [ -n "$cur" ]; then + rt_ui_kv "Current theme dir" "$cur" + rt_ui_kv "Row-Template dir" "$RT_ROOT" + rt_ui_confirm "Point the panel at Row-Template now?" yes || { rt_ui_info "Left unchanged."; return 0; } + else + rt_ui_info "The panel has no subscription theme configured." + rt_ui_kv "Row-Template dir" "$RT_ROOT" + rt_ui_confirm "Make Row-Template the active theme now?" yes || { rt_ui_info "Left unchanged."; return 0; } + fi + local outcome + if outcome="$(rt_subtheme_configure)" && [ "$outcome" = "auto" ]; then + rt_ui_success "Row-Template is active." + rt_render_report + else + rt_service_active 2>/dev/null || rt_service_start 2>/dev/null || true + rt_ui_warn "Could not set the theme automatically; the panel service state was preserved." + rt_ui_info "Set it from the panel: Settings -> Subscription -> Sub Theme Directory" + rt_ui_kv "Enter exactly" "$RT_ROOT" + fi +} +rt_manager_info() { + # Non-sensitive install facts only. Never prints subscription URLs, subId, + # UUIDs, panel credentials, DB secrets, tokens or the operator's support URL. + rt_ui_section "Installation info" + rt_detect_xui >/dev/null 2>&1 || true + rt_detect_xui_version >/dev/null 2>&1 || true + rt_detect_xui_db >/dev/null 2>&1 || true + local rtv xuiv st logo url + rtv="$(rt_trim "$(cat "$RT_VERSION_FILE" 2>/dev/null || true)")"; [ -n "$rtv" ] || rtv="unknown" + xuiv="${RT_XUI_VERSION:-}"; [ -n "$xuiv" ] || xuiv="unknown" + st="$(rt_status_theme)" + [ -n "$(rt_config_get_raw LOGO_DATA_B64 2>/dev/null)" ] && logo="yes" || logo="no" + [ -n "$(rt_config_get_text SUPPORT_URL_B64 2>/dev/null)" ] && url="configured" || url="not configured" + rt_ui_kv "Version" "$rtv" + rt_ui_kv "Developer" "$RT_DEVELOPER" + rt_ui_kv "GitHub" "$RT_GITHUB" + rt_ui_kv "3X-UI" "$xuiv" + rt_ui_kv "Install dir" "$RT_ROOT" + rt_ui_kv "Theme status" "$(rt_status_label "$st")" + rt_ui_kv "Service" "$(rt_status_service_label)" + rt_ui_kv "Custom logo" "$logo" + rt_ui_kv "Support URL" "$url" +} + +# --- manager: reconfigure branding (per-field editors) ----------------------- +# All editors reuse the tested validators, rt_config_write and rt_activate via +# rt_apply_branding; none of them re-implement config parsing or generation. + +rt_apply_branding() { + # NAME URL MIME LOGO_B64 -> write config + regenerate the live template, with + # the same snapshot/restore safety as rt_cmd_config: a failed regeneration + # leaves the previous config and template exactly in place. + local name="$1" url="$2" mime="$3" logo="$4" saved="" + [ -f "$RT_DIST" ] || { rt_err "Row-Template is not installed."; return 1; } + if [ -f "$RT_CONFIG" ]; then saved="$(mktemp)" && cp -- "$RT_CONFIG" "$saved"; fi + if ! rt_config_write "$name" "$url" "$mime" "$logo"; then + [ -n "$saved" ] && { cp -f -- "$saved" "$RT_CONFIG"; rm -f "$saved"; } + rt_err "could not write the configuration."; return 1 + fi + if ! rt_activate; then + rt_err "the new branding failed validation; restoring the previous configuration." + if [ -n "$saved" ]; then cp -f -- "$saved" "$RT_CONFIG"; chmod 640 "$RT_CONFIG" 2>/dev/null || true; fi + [ -n "$saved" ] && rm -f "$saved" + return 1 + fi + [ -n "$saved" ] && rm -f "$saved" + return 0 +} +rt_reconfig_service_name() { + local cur new mg + cur="$(rt_config_get_text SERVICE_NAME_B64 2>/dev/null || true)" + rt_ui_kv "Current name" "${cur:- (white-label)}" + printf ' New service name (Enter to keep current, - to clear): ' >&2 + IFS= read -r new || new="" + if [ -z "$new" ]; then rt_ui_info "Kept the current service name."; return 0; fi + [ "$new" = "-" ] && new="" + new="$(rt_trim "$new")" + if ! rt_validate_service_name "$new"; then rt_ui_error "Rejected: control characters or too long."; return 0; fi + if [ -n "$new" ]; then + if mg="$(rt_monogram_preview "$new")"; then rt_ui_info "Monogram preview: $mg" + else rt_ui_info "Monogram: computed in the browser for \"$new\"."; fi + fi + if rt_apply_branding "$new" "$(rt_config_get_text SUPPORT_URL_B64 2>/dev/null || true)" \ + "$(rt_config_get_raw LOGO_MIME 2>/dev/null || true)" "$(rt_config_get_raw LOGO_DATA_B64 2>/dev/null || true)"; then + rt_ui_success "Service name updated." + fi +} + +rt_reconfig_support_url() { + local cur choice new + cur="$(rt_config_get_text SUPPORT_URL_B64 2>/dev/null || true)" + rt_ui_kv "Support URL" "${cur:-not configured}" + printf ' %s1%s Keep current %s2%s Change URL %s3%s Remove %s0%s Back\n' \ + "$RT_C_BLD" "$RT_C_RST" "$RT_C_BLD" "$RT_C_RST" "$RT_C_BLD" "$RT_C_RST" "$RT_C_BLD" "$RT_C_RST" + choice="$(rt_ui_menu_select 3)" + case "$choice" in + 1|0) rt_ui_info "Support URL unchanged."; return 0 ;; + 3) new="" ;; + 2) + while true; do + printf ' New support URL (https/http/tg/mailto): ' >&2 + IFS= read -r new || new="" + new="$(rt_trim "$new")" + rt_validate_support_url "$new" && break + rt_ui_warn "Use https://, http://, tg:// or mailto:." + done ;; + esac + if rt_apply_branding "$(rt_config_get_text SERVICE_NAME_B64 2>/dev/null || true)" "$new" \ + "$(rt_config_get_raw LOGO_MIME 2>/dev/null || true)" "$(rt_config_get_raw LOGO_DATA_B64 2>/dev/null || true)"; then + [ -n "$new" ] && rt_ui_success "Support URL updated." || rt_ui_success "Support URL removed." + fi +} +rt_reconfig_logo() { + local choice mime b64 lp kb + mime="$(rt_config_get_raw LOGO_MIME 2>/dev/null || true)" + b64="$(rt_config_get_raw LOGO_DATA_B64 2>/dev/null || true)" + printf ' %s1%s Use/Replace %s2%s Remove %s3%s Show status %s0%s Back\n' \ + "$RT_C_BLD" "$RT_C_RST" "$RT_C_BLD" "$RT_C_RST" "$RT_C_BLD" "$RT_C_RST" "$RT_C_BLD" "$RT_C_RST" + choice="$(rt_ui_menu_select 3)" + case "$choice" in + 0) return 0 ;; + 3) + if [ -n "$b64" ]; then + kb=$(( (${#b64} * 3 / 4) / 1024 )) + rt_ui_info "Custom ${mime:-image} logo / Size: ${kb} KB" + else + rt_ui_info "Generated monogram (no custom logo set)." + fi + return 0 ;; + 2) + if rt_apply_branding "$(rt_config_get_text SERVICE_NAME_B64 2>/dev/null || true)" \ + "$(rt_config_get_text SUPPORT_URL_B64 2>/dev/null || true)" "" ""; then + rt_ui_success "Logo removed; the monogram will be used." + fi + return 0 ;; + 1) + printf ' Path to a PNG, JPEG or WebP image (<= 256 KiB): ' >&2 + IFS= read -r lp || lp="" + lp="$(rt_trim "$lp")" + [ -n "$lp" ] || { rt_ui_info "No path entered; logo unchanged."; return 0; } + local newmime + if ! newmime="$(rt_logo_validate "$lp")"; then return 0; fi + [ -L "$lp" ] && { rt_ui_error "Path became a symlink; aborting."; return 0; } + local newb64; newb64="$(base64 < "$lp" | tr -d '\n')" + if rt_apply_branding "$(rt_config_get_text SERVICE_NAME_B64 2>/dev/null || true)" \ + "$(rt_config_get_text SUPPORT_URL_B64 2>/dev/null || true)" "$newmime" "$newb64"; then + rt_ui_success "Logo updated ($newmime)." + fi + return 0 ;; + esac +} + +rt_reconfig_reset() { + rt_ui_warn "This clears custom branding (service name, support URL, logo) and" + rt_ui_info "returns Row-Template to its default look. It does NOT remove Row-Template." + rt_ui_confirm "Reset branding to defaults?" no || { rt_ui_info "Reset cancelled."; return 0; } + if rt_apply_branding "" "" "" ""; then rt_ui_success "Branding reset to defaults."; fi +} +rt_manager_reconfigure() { + local choice + while true; do + rt_ui_section "Reconfigure branding" + printf ' %s1%s Service name\n' "$RT_C_BLD" "$RT_C_RST" + printf ' %s2%s Support URL\n' "$RT_C_BLD" "$RT_C_RST" + printf ' %s3%s Logo\n' "$RT_C_BLD" "$RT_C_RST" + printf ' %s4%s Reset branding\n' "$RT_C_BLD" "$RT_C_RST" + printf ' %s5%s Reconfigure everything\n' "$RT_C_BLD" "$RT_C_RST" + printf ' %s0%s Back\n' "$RT_C_BLD" "$RT_C_RST" + choice="$(rt_ui_menu_select 5)" + case "$choice" in + 1) rt_reconfig_service_name ;; + 2) rt_reconfig_support_url ;; + 3) rt_reconfig_logo ;; + 4) rt_reconfig_reset ;; + 5) rt_run_action rt_cmd_config ;; + 0) return 0 ;; + esac + rt_ui_pause + done +} + +# --- manager: entry point ---------------------------------------------------- + +rt_manager_main() { + # The interactive dashboard. Requires root (every changing action does) and + # reads real state each iteration. In a non-interactive context the menu + # selector returns 0 (Exit) on EOF, so this never blocks automation. + rt_require_root + [ -f "$RT_VERSION_FILE" ] || rt_die "Row-Template is not installed at $RT_ROOT; run the installer first." + rt_detect_xui >/dev/null 2>&1 || true + rt_detect_xui_version >/dev/null 2>&1 || true + rt_detect_xui_db >/dev/null 2>&1 || true + local choice + while true; do + rt_manager_dashboard + choice="$(rt_ui_menu_select 7)" + case "$choice" in + 1) rt_manager_update ;; + 2) rt_manager_reconfigure ;; + 3) rt_run_action rt_cmd_verify ;; + 4) rt_run_action rt_cmd_rollback ;; + 5) rt_manager_activate ;; + 6) rt_manager_info ;; + 7) rt_run_action rt_cmd_uninstall + [ -f "$RT_VERSION_FILE" ] || { rt_ui_info "Row-Template has been removed. Goodbye."; return 0; } ;; + 0) rt_ui_info "Goodbye."; return 0 ;; + esac + rt_ui_pause + done +} +# --- first-install / re-run presentation (used by rt_cmd_install) ------------ +# All of these are interactive-only. rt_cmd_install still runs unchanged in a +# non-interactive context (automation/CI/curl | bash), so nothing here can block +# or alter a scripted install. + +rt_install_welcome() { + # $1 = detected 3x-ui version (may be empty). Returns 0 to proceed, 1 to abort. + rt_ui_header + rt_ui_info "Welcome to the Row-Template installer." + rt_ui_kv "Detected 3X-UI" "${1:-unknown}" + rt_ui_info "Your panel data is safe: inbounds, clients, users and the panel" + rt_ui_info "database are NOT modified. Only a subscription theme is added." + rt_ui_kv "GitHub" "$RT_GITHUB" + printf '\n' + rt_ui_confirm "Continue installation?" yes +} + +rt_install_summary_confirm() { + # show the chosen branding + install target, then confirm. 0 = proceed. + local name url logo + name="$(rt_config_get_text SERVICE_NAME_B64 2>/dev/null || true)" + url="$(rt_config_get_text SUPPORT_URL_B64 2>/dev/null || true)" + [ -n "$(rt_config_get_raw LOGO_DATA_B64 2>/dev/null)" ] && logo="custom image" || logo="generated monogram" + rt_ui_section "Configuration summary" + rt_ui_kv "Service name" "${name:- (white-label)}" + rt_ui_kv "Support URL" "$([ -n "$url" ] && echo configured || echo none)" + rt_ui_kv "Logo" "$logo" + rt_ui_kv "Install dir" "$RT_ROOT" + printf '\n' + rt_ui_confirm "Install with these settings?" yes +} + +rt_existing_install_menu() { + # interactive re-run chooser. Echoes exactly one token on stdout: + # manager | reconfigure | update | repair | exit + local rtv choice + rtv="$(rt_trim "$(cat "$RT_VERSION_FILE" 2>/dev/null || true)")"; [ -n "$rtv" ] || rtv="unknown" + rt_ui_header >&2 + rt_ui_info "Row-Template $rtv is already installed at $RT_ROOT." >&2 + { + printf ' %s1%s Open the manager\n' "$RT_C_BLD" "$RT_C_RST" + printf ' %s2%s Reconfigure branding\n' "$RT_C_BLD" "$RT_C_RST" + printf ' %s3%s Update\n' "$RT_C_BLD" "$RT_C_RST" + printf ' %s4%s Repair / Verify\n' "$RT_C_BLD" "$RT_C_RST" + printf ' %s0%s Exit\n' "$RT_C_BLD" "$RT_C_RST" + } >&2 + choice="$(rt_ui_menu_select 4)" + case "$choice" in + 1) printf 'manager' ;; 2) printf 'reconfigure' ;; 3) printf 'update' ;; + 4) printf 'repair' ;; *) printf 'exit' ;; + esac +} +rt_install_success_screen() { + # $1 = auto | manual | skipped. Never claims "Active" unless activation was + # verified (auto). Shown on an interactive first install. + local outcome="$1" name rtv theme + name="$(rt_config_get_text SERVICE_NAME_B64 2>/dev/null || true)"; [ -n "$name" ] || name="(white-label)" + rtv="$(rt_trim "$(cat "$RT_VERSION_FILE" 2>/dev/null || true)")"; [ -n "$rtv" ] || rtv="unknown" + case "$outcome" in + auto) theme="Active" ;; + *) theme="Manual activation required" ;; + esac + printf '\n' + rt_ui_rule + printf ' %s%s installed%s\n' "$RT_C_GRN" "$RT_PROJECT_NAME" "$RT_C_RST" + rt_ui_kv "Service" "$name" + rt_ui_kv "Version" "$rtv" + rt_ui_kv "Template" "$RT_ROOT" + rt_ui_kv "Theme" "$theme" + rt_ui_kv "Manage" "run: row-template" + rt_ui_kv "GitHub" "$RT_GITHUB" + rt_ui_kv "Developer" "$RT_DEVELOPER" + rt_ui_rule + if [ "$theme" != "Active" ]; then + rt_ui_info "To activate: Panel Settings -> Subscription -> Sub Theme Directory" + rt_ui_kv "Enter exactly" "$RT_ROOT" + fi +} + + + + + + + + + + + + + + + + diff --git a/tools/make-release.sh b/tools/make-release.sh index 9f2862f..e2022b9 100755 --- a/tools/make-release.sh +++ b/tools/make-release.sh @@ -23,6 +23,9 @@ # shells///shell.html # the assembled shell for each supported # shells///shell.html.sha256 # panel, in that panel's own dialect # VERSION install.sh lib/row-template.sh bin/row-template +# lib/transaction.sh panels/*.sh # the library's companions: it sources +# # them at load time, so they ship and +# # install with it (RT_INSTALLER_COMPANIONS) # SHA256SUMS # inner checksums of the payload files # # The shells are PACKAGED, not installed. Nothing here places them on a target @@ -73,15 +76,24 @@ for f in "$ART_HTML" "$LIB" "$CLI" "$BOOT" "$ROOT/VERSION"; do [ -f "$f" ] || die "missing required file: $f" done +# The library's companions, read from the library's own declaration so the +# packaging cannot drift from what the installer loads and installs. +COMPANIONS="$(sed -n 's/^RT_INSTALLER_COMPANIONS="\(.*\)"$/\1/p' "$LIB")" +[ -n "$COMPANIONS" ] || die "the management library declares no RT_INSTALLER_COMPANIONS." +for rel in $COMPANIONS; do + [ -f "$ROOT/installer/$rel" ] || die "missing companion: installer/$rel" +done + NAME="row-template-$VERSION" STAGE="$(mktemp -d)"; trap 'rm -rf -- "$STAGE"' EXIT PAY="$STAGE/$NAME" -mkdir -p "$PAY/lib" "$PAY/bin" "$PAY/templates" +mkdir -p "$PAY/lib" "$PAY/bin" "$PAY/panels" "$PAY/templates" cp -- "$ART_HTML" "$PAY/template.html" cp -- "$ROOT/VERSION" "$PAY/VERSION" cp -- "$BOOT" "$PAY/install.sh" cp -- "$LIB" "$PAY/lib/row-template.sh" +for rel in $COMPANIONS; do cp -- "$ROOT/installer/$rel" "$PAY/$rel"; done cp -- "$CLI" "$PAY/bin/row-template" chmod 755 "$PAY/install.sh" "$PAY/bin/row-template" From 36ac6928fcc57d3273676d1570a47f8a607308e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 20:25:02 +0000 Subject: [PATCH 2/2] test: cover a fresh install and the upgrade from v1.1.0 end to end tests/release.test.mjs, against the real tarball from make-release.sh: - the payload ships the library with every companion, byte-identical and in SHA256SUMS, the declared list equals the files on disk, and the packaged library loads both layers from the extracted payload; - a fresh install from the extracted payload, sourcing the payload's own library as install.sh does, leaves a complete, working manager (companions, all designs, branding, `verify`, the installed CLI); - the upgrade from v1.1.0: install with the v1.1.0 library, then run ITS rt_cmd_update against the release directory. The CLI still runs, `verify` reports the install incomplete and names the remedy, and both `row-template update` and the manager's update menu complete it, with branding kept throughout. The v1.1.0 updater is the real one: tests/fixtures/installer-1.1.0 is installer/lib/row-template.sh from tag v1.1.0, byte for byte (sha256 pinned by the test), vendored so the suite runs without git history. tests/installer.test.mjs pins the rules underneath: - the library loads with companions absent and says so; a companion that is present but broken still stops it from loading; - install and update refuse a library without its companions, or a companion that fails its checksum, before changing anything; - a payload whose own library declares no companions (v1.1.0) is still accepted; - a declaration may name only plain files in lib/ or panels/. The panel stays stubbed (detected, no database, service no-ops), so no test can reach a real 3X-UI. Run against the unfixed code, every new test fails except the broken-companion guard, which pins behaviour that must not change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011QC3E9ChkFK7sDFBTfwNJ3 --- tests/installer.test.mjs | 178 +++++++++++++++++++++++++++- tests/release.test.mjs | 242 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 418 insertions(+), 2 deletions(-) diff --git a/tests/installer.test.mjs b/tests/installer.test.mjs index f3cf191..3330731 100644 --- a/tests/installer.test.mjs +++ b/tests/installer.test.mjs @@ -9,7 +9,7 @@ import { spawnSync } from 'node:child_process'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { platform, tmpdir } from 'node:os'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { build } from '../tools/build.mjs'; @@ -1268,3 +1268,179 @@ test('reconciliation never recurses: one write, one reconcile, one TEMPLATE line assert.match(r.out, /lines2=1/, 'repeated writes never duplicate or loop the selection'); assert.match(r.out, /tpl2=editorial/); }); + +/* --- the library and its companions are one unit (1.2.0 packaging) ------- + lib/row-template.sh sources lib/transaction.sh and panels/ at load time. A + release ships them together and an install puts them together, but the + 1.1.0 updater copies only the library and the CLI, so a host it updated has + the library alone. tests/release.test.mjs drives that path with the real + tarball and the real v1.1.0 updater; these pin the rules underneath it. */ + +const COMPANION_FILES = ['lib/transaction.sh', 'panels/3xui.sh', 'panels/index.sh', 'panels/interface.sh']; + +/* The library plus the given companions, laid out as under RT_ROOT. */ +function libraryTree(root, companions) { + mkdirSync(join(root, 'lib'), { recursive: true }); + copyFileSync(join(ROOT, 'installer', 'lib', 'row-template.sh'), join(root, 'lib', 'row-template.sh')); + for (const rel of companions) { + mkdirSync(dirname(join(root, rel)), { recursive: true }); + copyFileSync(join(ROOT, 'installer', rel), join(root, rel)); + } +} + +/* Source a tree's library in a fresh bash and report how it loaded. */ +function loadTree(prepare) { + const root = mkdtempSync(join(tmpdir(), 'row-lib-')).replace(/\\/g, '/'); + try { + prepare(root); + const r = spawnSync('bash', ['-c', [ + 'set -Eeuo pipefail', + 'export RT_ROOT="$1"', + 'if . "$RT_ROOT/lib/row-template.sh"; then', + ' echo "loaded panels=${RT_PANELS_LOADED:-} txn=${RT_TRANSACTION_LOADED:-}"', + ' if rt_installer_complete; then echo complete; else echo incomplete; fi', + 'else echo refused; fi', + ].join('\n'), 'load-tree', root], { cwd: ROOT, encoding: 'utf8' }); + if (r.error) throw r.error; + return { code: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() }; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +test('the library loads without the companions an older updater never installed, and says so', () => { + const alone = loadTree((root) => libraryTree(root, [])); + assert.equal(alone.code, 0, alone.err); + assert.match(alone.out, /loaded panels= txn=/, 'the library alone loads, with both layers absent'); + assert.match(alone.out, /incomplete/, 'and reports itself incomplete'); + assert.equal(alone.err, '', 'an absent layer is not an error at load time'); + + const partial = loadTree((root) => libraryTree(root, ['lib/transaction.sh'])); + assert.match(partial.out, /loaded panels= txn=1/, 'each layer is loaded on its own'); + assert.match(partial.out, /incomplete/, 'one layer is not a complete install'); + + const full = loadTree((root) => libraryTree(root, COMPANION_FILES)); + assert.match(full.out, /loaded panels=1 txn=1/); + assert.match(full.out, /complete/); + assert.doesNotMatch(full.out, /incomplete/); +}); + +test('a companion that is present but broken still stops the library from loading', () => { + const cases = [ + ['a panel file that will not parse', (root) => { + libraryTree(root, COMPANION_FILES); + writeFileSync(join(root, 'panels', 'index.sh'), 'this is ( not bash\n'); + }], + ['a panels/ directory missing its interface', (root) => { + libraryTree(root, COMPANION_FILES.filter((f) => f !== 'panels/interface.sh')); + }], + ['a transaction engine that will not parse', (root) => { + libraryTree(root, COMPANION_FILES); + writeFileSync(join(root, 'lib', 'transaction.sh'), 'this is ( not bash\n'); + }], + ]; + for (const [label, prepare] of cases) { + const r = loadTree(prepare); + assert.match(r.out, /refused/, `${label}: the library must refuse to load`); + assert.doesNotMatch(r.out, /loaded/, `${label}: nothing may run half-loaded`); + } +}); + +/* writePayload plus the management library, and the companions given. */ +function payloadWithLibrary(root, companions, { sums = false } = {}) { + writePayload(root); + libraryTree(join(root, 'payload'), companions); + if (sums) { + const lines = companions.map((rel) => + `${createHash('sha256').update(readFileSync(join(root, 'payload', rel))).digest('hex')} ${rel}`); + writeFileSync(join(root, 'payload', 'SHA256SUMS'), lines.join('\n') + '\n'); + } +} + +test('install refuses a payload that carries the library without its companions, before changing anything', () => { + const r = shRoot( + FLOW_STUBS + + 'if ( rt_cmd_install "$RT_ROOT/payload" ) >/dev/null; then echo "INCOMPLETE-ACCEPTED"; else echo "refused"; fi\n' + + '[ -e "$RT_LIVE" ] || echo "live-untouched"\n' + + '[ -e "$RT_LIB_DIR/row-template.sh" ] || echo "library-untouched"', + { prepare: (root) => payloadWithLibrary(root, ['lib/transaction.sh']) }, + ); + assert.match(r.out, /refused/); + assert.match(r.err, /the release payload is incomplete: panels\/3xui\.sh is missing/); + assert.match(r.out, /live-untouched/, 'nothing was activated'); + assert.match(r.out, /library-untouched/, 'no half of the unit was installed'); +}); + +test('update refuses a payload that carries the library without its companions, and the install stays as it was', () => { + const r = shRoot( + FLOW_STUBS + + 'trap "rt_cleanup" EXIT\n' + + 'before="$(rt_sha256 "$RT_LIVE")"\n' + + 'rt_fetch_release(){ printf "%s" "$RT_ROOT/payload"; }\n' + + 'if ( rt_cmd_update ) >/dev/null; then echo "INCOMPLETE-ACCEPTED"; else echo "refused"; fi\n' + + '[ "$(rt_sha256 "$RT_LIVE")" = "$before" ] && echo "live-unchanged"\n' + + 'printf "ver=%s\\n" "$(cat "$RT_VERSION_FILE")"\n' + + '[ -e "$RT_LIB_DIR/row-template.sh" ] || echo "library-untouched"', + { prepare: (root) => { prepareInstall(root); payloadWithLibrary(root, []); } }, + ); + assert.match(r.out, /refused/); + assert.match(r.err, /the release payload is incomplete: lib\/transaction\.sh is missing/); + assert.match(r.out, /live-unchanged/, 'the running page is untouched'); + assert.match(r.out, /ver=1\.1\.0/, 'the installed version is untouched'); + assert.match(r.out, /library-untouched/); +}); + +test('a companion that does not match the payload checksum is refused', () => { + const r = shRoot( + FLOW_STUBS + + 'if ( rt_cmd_install "$RT_ROOT/payload" ) >/dev/null; then echo "TAMPERED-ACCEPTED"; else echo "refused"; fi\n' + + '[ -e "$RT_LIVE" ] || echo "live-untouched"', + { prepare: (root) => { + payloadWithLibrary(root, COMPANION_FILES, { sums: true }); + writeFileSync(join(root, 'payload', 'panels', 'index.sh'), '# tampered\n'); + } }, + ); + assert.match(r.out, /refused/); + assert.match(r.err, /payload checksum mismatch: panels\/index\.sh/); + assert.match(r.out, /live-untouched/); +}); + +test('a payload whose own library declares no companions (v1.1.0) is still accepted', () => { + /* The payload's library decides, not the running one: updating to the + v1.1.0 release -- a deliberate downgrade -- must not be refused for lacking + files that version never needed. */ + const r = shRoot( + FLOW_STUBS + + 'trap "rt_cleanup" EXIT\n' + + 'rt_fetch_release(){ printf "%s" "$RT_ROOT/payload"; }\n' + + 'rt_cmd_update >/dev/null\n' + + 'cmp -s "$RT_ROOT/payload/lib/row-template.sh" "$RT_LIB_DIR/row-template.sh" && echo "library-installed"\n' + + '[ -e "$RT_PANELS_DIR" ] || echo "no-companions-installed"', + { prepare: (root) => { + prepareInstall(root); + writePayload(root); + mkdirSync(join(root, 'payload', 'lib'), { recursive: true }); + copyFileSync(join(ROOT, 'tests', 'fixtures', 'installer-1.1.0', 'row-template.sh'), + join(root, 'payload', 'lib', 'row-template.sh')); + } }, + ); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /library-installed/); + assert.match(r.out, /no-companions-installed/); +}); + +test('a payload library may declare companions only as plain files in lib/ or panels/', () => { + const bad = ['lib/../../evil.sh', 'panels/../x.sh', 'etc/passwd.sh', 'panels/.hidden.sh', + 'lib/row-template.sh', 'panels/sub/x.sh', 'panels/x.txt', 'panels/a$b.sh', '/abs/x.sh']; + /* One process for the whole table; the declaration is data, never sourced. */ + const r = shRoot( + 'mkdir -p "$RT_ROOT/p/lib"\n' + + 'check(){ printf "RT_INSTALLER_COMPANIONS=\\"%s\\"\\n" "$1" > "$RT_ROOT/p/lib/row-template.sh"\n' + + ' if rt_payload_companions "$RT_ROOT/p" >/dev/null 2>&1; then echo "ACCEPTED:$1"; else echo "refused:$1"; fi; }\n' + + bad.map((rel) => `check ${bq(rel)}\n`).join('') + + `check ${bq('lib/transaction.sh panels/3xui.sh panels/index.sh panels/interface.sh')}\n`, + ); + assert.equal(r.code, 0, r.err); + for (const rel of bad) assert.match(r.out, new RegExp(`refused:${rel.replace(/[.$/]/g, '\\$&')}`), rel); + assert.match(r.out, /ACCEPTED:lib\/transaction\.sh panels/, 'the real declaration is accepted'); +}); diff --git a/tests/release.test.mjs b/tests/release.test.mjs index 7b071fb..57cf530 100644 --- a/tests/release.test.mjs +++ b/tests/release.test.mjs @@ -19,7 +19,7 @@ import test, { after } from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { createHash } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; @@ -244,3 +244,243 @@ test('the release tarball is byte-deterministic', () => { rmSync(b, { recursive: true, force: true }); } }); + +/* ------------------------------------------------------------------------ */ +/* Installing from the release */ +/* */ +/* The tests above prove what the tarball CONTAINS. These prove it WORKS: a */ +/* fresh install from the extracted payload, and an upgrade from v1.1.0 */ +/* driven by the v1.1.0 updater itself -- the code actually installed on */ +/* existing hosts -- against this release. */ +/* */ +/* Everything runs against a throwaway RT_ROOT and RT_BIN. The panel is */ +/* stubbed as detected but without a database, and the service controls */ +/* are no-ops, so no test can reach a real 3X-UI on the machine running it. */ +/* install.sh itself is not run: it requires root, and everything after its */ +/* root check is what these tests do -- extract the tarball, source the */ +/* payload's own library, and call rt_cmd_install on the payload. */ +/* ------------------------------------------------------------------------ */ + +/* The management library's companions: what rt_panels_load and + rt_transaction_load source, so what a release must ship and an install + must put next to the library. */ +const COMPANIONS = ['lib/transaction.sh', 'panels/3xui.sh', 'panels/index.sh', 'panels/interface.sh']; + +/* installer/lib/row-template.sh exactly as released in v1.1.0; see its README. */ +const V110_LIB = join(ROOT, 'tests', 'fixtures', 'installer-1.1.0', 'row-template.sh'); +const V110_SHA = 'c5a2b069826e5f1b46c1ace42d111d8f7a035c3c9651f064b69e62ca41ed32ac'; + +const sha256 = (b) => createHash('sha256').update(b).digest('hex'); +const sq = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'"; +const same = (a, b) => readFileSync(a).equals(readFileSync(b)); + +/* A detected 3X-UI 3.7.0 with no database: activation takes the manual path. + Root is assumed and the service is never touched. Defined AFTER a library + is sourced, so they replace its functions. */ +const PANEL_STUBS = [ + 'rt_require_root(){ :; }', + 'rt_detect_xui(){ RT_XUI_UNIT="x-ui.service"; return 0; }', + 'rt_detect_xui_version(){ RT_XUI_VERSION="3.7.0"; printf "3.7.0"; }', + 'rt_detect_xui_db(){ RT_XUI_DB=""; return 1; }', + 'rt_service_active(){ return 1; }', + 'rt_service_start(){ :; }', + 'rt_service_stop(){ :; }', + 'trap "rt_cleanup" EXIT', +].join('\n'); + +/* Run a bash script with each PATHS entry exported as a POSIX path (cygpath on + Windows, as-is elsewhere). Returns {code, out, err}. */ +function bashRun(lines, paths = {}) { + const head = Object.entries(paths).map(([k, v]) => + `${k}="$(cygpath -u ${sq(v)} 2>/dev/null || printf '%s' ${sq(v)})"; export ${k}`); + const script = ['set -Eeuo pipefail', 'unset RT_TEMPLATE RT_RELEASE_URL RT_ASSUME_YES XUI_DB_FOLDER', + ...head, ...lines].join('\n'); + const r = spawnSync('bash', ['-c', script], { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + if (r.error) throw r.error; + return { code: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() }; +} + +function sandbox() { + const base = mkdtempSync(join(tmpdir(), 'row-install-')); + mkdirSync(join(base, 'bin')); + return { base, rt: join(base, 'rt'), bin: join(base, 'bin', 'row-template') }; +} + +/* What a fresh bash sees when it sources the INSTALLED library. */ +function installedState(sb) { + return bashRun([ + 'if . "$RT_ROOT/lib/row-template.sh"; then echo "loaded=yes"; else echo "loaded=NO"; exit 0; fi', + 'echo "panels=${RT_PANELS_LOADED:-} txn=${RT_TRANSACTION_LOADED:-}"', + 'if rt_installer_complete; then echo "complete=yes"; else echo "complete=no"; fi', + 'echo "name=$(rt_config_get_text SERVICE_NAME_B64)"', + PANEL_STUBS, + 'echo "--- verify"', + '( rt_cmd_verify ) 2>&1 || true', + ], { RT_ROOT: sb.rt, RT_BIN: sb.bin }); +} + +/* The installed CLI, run the way an operator runs it. `version` needs no root. */ +function cli(sb, ...args) { + return bashRun([`bash "$RT_BIN" ${args.join(' ')}`], { RT_ROOT: sb.rt, RT_BIN: sb.bin }); +} + +function assertComplete(sb, label) { + for (const rel of ['lib/row-template.sh', ...COMPANIONS]) { + assert.ok(existsSync(join(sb.rt, rel)), `${label}: ${rel} is installed`); + assert.ok(same(join(sb.rt, rel), join(ROOT, 'installer', rel)), `${label}: ${rel} is this release's file`); + } + assert.ok(same(sb.bin, join(ROOT, 'installer', 'bin', 'row-template')), `${label}: the CLI is this release's`); + assert.deepEqual(readdirSync(join(sb.rt, 'dist', 'templates')).sort(), availableTemplateIds().sort(), + `${label}: every design is in the store`); + const s = installedState(sb); + assert.equal(s.code, 0, s.err); + assert.match(s.out, /loaded=yes/, `${label}: the installed library loads`); + assert.match(s.out, /panels=1 txn=1/, `${label}: with the panel layer and the transaction engine`); + assert.match(s.out, /complete=yes/); + assert.match(s.out, /Installer components present/, `${label}: verify sees a complete install`); + assert.match(s.out, new RegExp(`Template store verified \\(${availableTemplateIds().length} design`)); + const v = cli(sb, 'version'); + assert.equal(v.code, 0, `${label}: the installed CLI runs\n${v.err}`); + assert.match(v.out, new RegExp(readFileSync(join(ROOT, 'VERSION'), 'utf8').trim().replace(/\./g, '\\.'))); + return s; +} + +test('the release payload ships the management library with every companion it loads', () => { + const { payload } = sharedPayload(); + assert.deepEqual(readdirSync(payload).sort(), + ['SHA256SUMS', 'VERSION', 'bin', 'install.sh', 'lib', 'panels', 'shells', 'template.html', 'templates'], + 'the payload top level'); + + /* The set is the one on disk, so a companion added to installer/ must ship. */ + const onDisk = [ + ...readdirSync(join(ROOT, 'installer', 'lib')).filter((f) => f !== 'row-template.sh').map((f) => `lib/${f}`), + ...readdirSync(join(ROOT, 'installer', 'panels')).map((f) => `panels/${f}`), + ].sort(); + assert.deepEqual(onDisk, COMPANIONS, 'every file beside the library is a companion'); + const lib = readFileSync(join(ROOT, 'installer', 'lib', 'row-template.sh'), 'utf8'); + const declared = (lib.match(/^RT_INSTALLER_COMPANIONS="([^"]*)"/m) || [])[1]; + assert.ok(declared, 'the library declares its companions'); + assert.deepEqual(declared.split(/\s+/).filter(Boolean).sort(), COMPANIONS, + 'and the declaration is exactly the files on disk'); + + const sums = readFileSync(join(payload, 'SHA256SUMS'), 'utf8'); + for (const rel of ['lib/row-template.sh', 'bin/row-template', 'install.sh', ...COMPANIONS]) { + const src = rel === 'install.sh' ? join(ROOT, 'installer', 'install.sh') : join(ROOT, 'installer', rel); + assert.ok(same(join(payload, rel), src), `${rel} ships byte-identical to the source`); + assert.ok(sums.includes(`${sha256(readFileSync(join(payload, rel)))} ${rel}\n`), `${rel} is in SHA256SUMS`); + } + + /* The packaged library loads from the payload, as install.sh sources it. */ + const sb = sandbox(); + try { + const r = bashRun(['. "$PAYLOAD/lib/row-template.sh"', + 'echo "panels=${RT_PANELS_LOADED:-} txn=${RT_TRANSACTION_LOADED:-}"'], + { PAYLOAD: payload, RT_ROOT: sb.rt }); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /panels=1 txn=1/, 'the packaged library loads both layers'); + } finally { + rmSync(sb.base, { recursive: true, force: true }); + } +}); + +test('a fresh install from the release tarball installs a complete, working manager', () => { + const { payload } = sharedPayload(); + const sb = sandbox(); + try { + const r = bashRun([ + '. "$PAYLOAD/lib/row-template.sh"', + PANEL_STUBS, + 'RT_SERVICE_NAME="Test VPN" rt_cmd_install "$PAYLOAD"', + ], { PAYLOAD: payload, RT_ROOT: sb.rt, RT_BIN: sb.bin }); + assert.equal(r.code, 0, r.err); + const s = assertComplete(sb, 'fresh install'); + assert.match(s.out, /name=Test VPN/, 'branding was applied'); + assert.ok(existsSync(join(sb.rt, 'sub.html')), 'the page was generated'); + } finally { + rmSync(sb.base, { recursive: true, force: true }); + } +}); + +/* A host as v1.1.0 left it, then updated by its own updater to this release: + install v1.1.0 with the v1.1.0 library, then run that library's + rt_cmd_update against the real release directory. bin/row-template is + unchanged since v1.1.0, so this release's copy is the v1.1.0 one. */ +function upgradedByV110(sb, out, payload) { + assert.equal(sha256(readFileSync(V110_LIB)), V110_SHA, 'the fixture is the released v1.1.0 library'); + const old = join(sb.base, 'payload-1.1.0'); + mkdirSync(join(old, 'lib'), { recursive: true }); + mkdirSync(join(old, 'bin')); + copyFileSync(join(payload, 'template.html'), join(old, 'template.html')); + writeFileSync(join(old, 'VERSION'), '1.1.0\n'); + copyFileSync(V110_LIB, join(old, 'lib', 'row-template.sh')); + copyFileSync(join(ROOT, 'installer', 'bin', 'row-template'), join(old, 'bin', 'row-template')); + + const paths = { OLD: old, REL: out, RT_ROOT: sb.rt, RT_BIN: sb.bin }; + const install = bashRun(['. "$OLD/lib/row-template.sh"', PANEL_STUBS, + 'RT_SERVICE_NAME="Test VPN" rt_cmd_install "$OLD"'], paths); + assert.equal(install.code, 0, 'v1.1.0 installs\n' + install.err); + assert.equal(readFileSync(join(sb.rt, 'VERSION'), 'utf8').trim(), '1.1.0'); + assert.ok(same(join(sb.rt, 'lib', 'row-template.sh'), V110_LIB), 'the v1.1.0 library is installed'); + + const update = bashRun(['. "$RT_ROOT/lib/row-template.sh"', PANEL_STUBS, + 'RT_RELEASE_DIR="$REL" rt_cmd_update'], paths); + assert.equal(update.code, 0, 'the v1.1.0 updater applies this release\n' + update.err); + assert.equal(readFileSync(join(sb.rt, 'VERSION'), 'utf8'), readFileSync(join(ROOT, 'VERSION'), 'utf8')); + assert.ok(same(join(sb.rt, 'lib', 'row-template.sh'), join(ROOT, 'installer', 'lib', 'row-template.sh')), + 'the old updater installed the new library'); + /* ...and nothing else: it copies four files, so this state is unavoidable. */ + for (const rel of COMPANIONS) { + assert.equal(existsSync(join(sb.rt, rel)), false, `the v1.1.0 updater cannot install ${rel}`); + } +} + +test('after the v1.1.0 updater applies this release, the manager still works and reports the install incomplete', () => { + const { out, payload } = sharedPayload(); + const sb = sandbox(); + try { + upgradedByV110(sb, out, payload); + const v = cli(sb, 'version'); + assert.equal(v.code, 0, 'the CLI must start with the companions absent\n' + v.err); + const s = installedState(sb); + assert.equal(s.code, 0, s.err); + assert.match(s.out, /loaded=yes/, 'the new library loads without its companions'); + assert.match(s.out, /panels= txn=/, 'and marks both layers absent'); + assert.match(s.out, /complete=no/); + assert.match(s.out, /name=Test VPN/, 'branding survived the update'); + assert.match(s.out, /installer components are missing[^\n]*row-template update/, + 'verify names the problem and the remedy'); + } finally { + rmSync(sb.base, { recursive: true, force: true }); + } +}); + +test('row-template update completes an install the v1.1.0 updater left incomplete', () => { + const { out, payload } = sharedPayload(); + const sb = sandbox(); + try { + upgradedByV110(sb, out, payload); + const r = bashRun(['. "$RT_ROOT/lib/row-template.sh"', PANEL_STUBS, + 'RT_RELEASE_DIR="$REL" rt_cmd_update'], { REL: out, RT_ROOT: sb.rt, RT_BIN: sb.bin }); + assert.equal(r.code, 0, r.err); + const s = assertComplete(sb, 'completed upgrade'); + assert.match(s.out, /name=Test VPN/, 'branding survived the whole path'); + } finally { + rmSync(sb.base, { recursive: true, force: true }); + } +}); + +test('the manager offers to complete an incomplete install even when it is up to date', () => { + const { out, payload } = sharedPayload(); + const sb = sandbox(); + try { + upgradedByV110(sb, out, payload); + /* stdin is not a terminal, so every confirmation takes its default. */ + const r = bashRun(['. "$RT_ROOT/lib/row-template.sh"', PANEL_STUBS, + 'RT_RELEASE_DIR="$REL" rt_manager_update &1'], { REL: out, RT_ROOT: sb.rt, RT_BIN: sb.bin }); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /incomplete/, 'the manager says why it offers a re-install'); + assertComplete(sb, 'manager completion'); + } finally { + rmSync(sb.base, { recursive: true, force: true }); + } +});