From 22581622f213b7344c0a425745f40d4a50efe51e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 22:12:20 +0000 Subject: [PATCH 01/25] fix(installer): repair and complete the template store after an update The library reads every design from $RT_ROOT/dist/templates only. Two states left that empty while the manager said "No templates are installed. Re-run the installer": - v1.1.0's updater installs the new library but copies only four files, so no design arrives with it; 1.2.0 needed a second `row-template update`, and `config` and the branding editors failed until then. - a release payload copied or extracted over the install root leaves its designs at $RT_ROOT/templates, beside dist/ instead of inside it. rt_repair_template_store heals from what the host has: a verified payload, then a misplaced store, moving each design only after it passes its own checksum and the structural gate, and retiring only the copies the store now covers. It never follows a symlink and never removes a file it does not recognise. install, update and verify call it. rt_complete_install finishes an install still short afterwards from a verified download of the INSTALLED version (the default channel is pinned to its tag; a payload of another version is refused), writing only the store and the library's companions. The manager, `config`, `verify` (as root) and the Template chooser call it, so one `row-template update` from 1.1.0 is enough. verify now names missing and corrupt designs. Test harnesses stub the release fetch so no test can reach the network. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BbSwnyeKjBZ2HoJddxdYBM --- installer/lib/row-template.sh | 306 ++++++++++++- tests/installer-template-store.test.mjs | 547 ++++++++++++++++++++++++ tests/installer.test.mjs | 13 +- tests/release.test.mjs | 137 +++++- 4 files changed, 988 insertions(+), 15 deletions(-) create mode 100644 tests/installer-template-store.test.mjs diff --git a/installer/lib/row-template.sh b/installer/lib/row-template.sh index 9d94cb0..4a03e0f 100644 --- a/installer/lib/row-template.sh +++ b/installer/lib/row-template.sh @@ -444,6 +444,7 @@ rt_stage_template_store() { want="$(LC_ALL=C awk '{print $1; exit}' "$dir/template.html.sha256")" rt_verify_sha256 "$dir/template.html" "$want" || { rt_err "payload template $id failed its checksum"; return 1; } rt_validate_template "$dir/template.html" || { rt_err "payload template $id failed structural validation"; return 1; } + rt_assert_not_symlink "$RT_TEMPLATE_STORE/$id" || return 1 mkdir -p "$RT_TEMPLATE_STORE/$id" || return 1 rt_atomic_install "$dir/template.html" "$RT_TEMPLATE_STORE/$id/template.html" 644 || return 1 rt_atomic_install "$dir/template.html.sha256" "$RT_TEMPLATE_STORE/$id/template.html.sha256" 644 || return 1 @@ -451,6 +452,146 @@ rt_stage_template_store() { return 0 } +# --- template store self-healing --------------------------------------------- +# Every reader above looks in ONE place, RT_TEMPLATE_STORE. A store anywhere +# else is invisible, and the manager then reports that no design is installed +# while the files sit one directory away. Two states lead there on real hosts: +# +# ABSENT v1.1.0's updater installs this library but copies only four +# files, so no design arrives with it (rt_complete_install). +# MISPLACED a release payload lays its designs out at templates//; a +# payload copied or extracted over the install root leaves them +# at $RT_ROOT/templates, beside dist/ instead of inside it. +# +# rt_repair_template_store heals from what the host already has; install, +# update and verify all run it, so a path mistake is repaired by whichever +# command meets it first and no operator has to move a file by hand. + +# Where a store has been found outside its home, relative to RT_ROOT. A closed +# list of fixed paths inside the install root, never derived from input. +RT_TEMPLATE_STORE_MISPLACED="templates" + +rt_template_entry_ok() { + # 0 when DIR holds a design whose artifact matches its own sidecar. No + # symlinks: a linked directory or file could hand over bytes from outside the + # install root. Silent; callers report. + local d="$1" want + [ -d "$d" ] && [ ! -L "$d" ] || return 1 + [ -f "$d/template.html" ] && [ ! -L "$d/template.html" ] || return 1 + [ -f "$d/template.html.sha256" ] && [ ! -L "$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_template_store_status() { + # echo ok | missing | corrupt for registry ID in the installed store. + # "missing" is a design with neither file; anything else short of a + # verifying pair (one file alone, a checksum mismatch, a symlink) is corrupt. + local id="$1" d + rt_template_allowed "$id" || return 1 + d="$RT_TEMPLATE_STORE/$id" + if rt_template_entry_ok "$d"; then printf 'ok'; return 0; fi + if [ ! -e "$d/template.html" ] && [ ! -e "$d/template.html.sha256" ] && [ ! -L "$d" ]; then + printf 'missing' + else + printf 'corrupt' + fi +} + +rt_template_store_missing() { + # echo the registry ids the store cannot supply (missing or corrupt), one per + # line, in catalogue order. Empty output means the store is complete. + local id + for id in $RT_TEMPLATES_AVAILABLE; do + [ "$(rt_template_store_status "$id")" = "ok" ] || printf '%s\n' "$id" + done + return 0 +} + +rt_template_store_retire() { + # remove a misplaced store's copy of every design the canonical store now + # supplies. Only the two files a design consists of are removed, only for + # registry ids, and only through real directories; a directory is then + # removed only if that left it empty. Anything else -- a foreign file, an + # unknown id, a copy of a design the store still lacks -- stays where it is. + local src="$1" id + for id in $RT_TEMPLATES_AVAILABLE; do + [ -d "$src/$id" ] && [ ! -L "$src/$id" ] || continue + [ "$(rt_template_store_status "$id")" = "ok" ] || continue + rm -f -- "$src/$id/template.html" "$src/$id/template.html.sha256" + rmdir -- "$src/$id" 2>/dev/null || true + done + rmdir -- "$src" 2>/dev/null || true +} + +rt_repair_template_store() { + # Make RT_TEMPLATE_STORE hold a verified copy of every design this release + # offers, from the sources on hand, in order of authority: + # + # 1. PAYLOAD's templates/, when given: a release that already passed its + # checksum. rt_stage_template_store verifies and installs every design. + # 2. a misplaced store inside the install root: a design the store cannot + # supply is taken from it only when that copy matches its own checksum + # and passes the structural gate; a copy that does not is reported and + # left in place. + # + # A misplaced copy is retired once the store covers its design, so the tree + # is left with one store, not two. Backups, config.env, the canonical + # artifact and the live page are never touched: this only fills the store. + # + # Returns 0 when the store is complete, 2 when designs are still missing (the + # caller decides whether that matters: an older payload carries no store at + # all), and 1 when the payload fails verification or a write fails. + local payload="${1:-}" rel src id moved warned + if [ -L "$RT_TEMPLATE_STORE" ] || [ -L "$(dirname "$RT_TEMPLATE_STORE")" ]; then + rt_err "the template store path is a symlink; refusing to repair it: $RT_TEMPLATE_STORE" + return 1 + fi + if [ -e "$RT_TEMPLATE_STORE" ] && [ ! -d "$RT_TEMPLATE_STORE" ]; then + rt_err "the template store path is not a directory: $RT_TEMPLATE_STORE" + return 1 + fi + + if [ -n "$payload" ]; then + rt_stage_template_store "$payload" || return 1 + fi + + for rel in $RT_TEMPLATE_STORE_MISPLACED; do + src="$RT_ROOT/$rel" + [ -e "$src" ] || [ -L "$src" ] || continue + if [ -L "$src" ] || [ ! -d "$src" ]; then + rt_warn "not reading templates from $src: it is not a plain directory." + continue + fi + moved=0; warned=0 + for id in $RT_TEMPLATES_AVAILABLE; do + [ -e "$src/$id" ] || [ -L "$src/$id" ] || continue + [ "$(rt_template_store_status "$id")" = "ok" ] && continue + if ! rt_template_entry_ok "$src/$id" \ + || ! rt_validate_template "$src/$id/template.html" >/dev/null 2>&1; then + rt_warn "the copy of design '$id' in $src fails its checksum or structural check; it was not moved." + warned=1 + continue + fi + rt_assert_not_symlink "$RT_TEMPLATE_STORE/$id" || return 1 + mkdir -p "$RT_TEMPLATE_STORE/$id" || return 1 + rt_atomic_install "$src/$id/template.html" "$RT_TEMPLATE_STORE/$id/template.html" 644 || return 1 + rt_atomic_install "$src/$id/template.html.sha256" "$RT_TEMPLATE_STORE/$id/template.html.sha256" 644 || return 1 + moved=$((moved + 1)) + done + rt_template_store_retire "$src" + if [ "$moved" -gt 0 ]; then + rt_ok "Template store: moved $moved design(s) from $src to $RT_TEMPLATE_STORE." + fi + if [ -e "$src" ] && [ "$warned" -eq 0 ]; then + rt_warn "left $src in place: it holds files Row-Template does not recognise." + fi + done + + [ -z "$(rt_template_store_missing)" ] && return 0 + return 2 +} + rt_switch_template() { # switch the active template as one transaction. Ordered so that nothing on # disk changes until the candidate has passed every check, and so that any @@ -2127,6 +2268,91 @@ rt_remote_version() { printf '%s' "$v" } +rt_release_url_for_version() { + # echo the default channel's address for exactly VERSION: GitHub serves a + # tag's assets at releases/download/v, where releases/latest/download + # would serve whatever is newest. A default channel that is not GitHub's + # latest-release address is returned unchanged. + local ver="$1" base="${RT_DEFAULT_RELEASE_URL%/}" + case "$base" in + */releases/latest/download) printf '%s/releases/download/v%s' "${base%/releases/latest/download}" "$ver" ;; + *) printf '%s' "$base" ;; + esac +} + +rt_complete_install() { + # Complete an install that is short of what its own version ships: designs + # missing from the template store, or the library's companions. v1.1.0's + # updater leaves exactly that (it copies four files), and it is what made + # 1.2.0 need a second `row-template update`. The manager and `config` call + # this on start, so the first run of the new code finishes the job instead. + # + # 1. from the host: rt_repair_template_store (a misplaced store). No network. + # 2. only if something is still missing: a verified download of the + # INSTALLED version -- the same release, never a newer one. An explicit + # RT_RELEASE_DIR / RT_RELEASE_URL is used as given; the default channel + # is pinned to the installed tag. A payload of any other version is + # refused: installing it would be an update, which is `update`'s job. + # + # Only the store and the companions are written. The canonical artifact, the + # live page, config.env, the selection and every backup are left untouched. + # Quiet when there is nothing to do. 0 = complete, 1 = still incomplete + # (reported), which callers treat as a warning, never a reason to stop. + local rc=0 ver work payload pver n + [ -f "$RT_DIST" ] && [ -w "$RT_ROOT" ] || return 0 + rt_repair_template_store || rc=$? + [ "$rc" -eq 1 ] && return 1 + if [ "$rc" -eq 0 ] && rt_installer_complete; then return 0; fi + + ver="$(rt_trim "$(cat "$RT_VERSION_FILE" 2>/dev/null || true)")" + case "$ver" in + [0-9]*) case "$ver" in *[!A-Za-z0-9.+-]*) ver="" ;; esac ;; + *) ver="" ;; + esac + if [ -z "$ver" ]; then + rt_warn "this installation is incomplete and its version is unknown; run 'row-template update' to repair it." + return 1 + fi + rt_info "Completing the Row-Template $ver installation (designs and installer files)..." + work="$(rt_mktemp_dir)" || return 1 + RT_TMP_TO_CLEAN+=("$work") # register in THIS shell (see rt_mktemp_dir) + if [ -n "${RT_RELEASE_DIR:-}" ] || [ -n "${RT_RELEASE_URL:-}" ]; then + payload="$(rt_fetch_release "$work")" || payload="" + else + payload="$(RT_RELEASE_URL="$(rt_release_url_for_version "$ver")" rt_fetch_release "$work")" || payload="" + fi + if [ -z "$payload" ]; then + rm -rf -- "$work" + rt_warn "could not download Row-Template $ver to complete the installation. It will be retried the next time the manager opens; 'row-template update' also completes it." + return 1 + fi + pver="$(rt_trim "$(cat "$payload/VERSION" 2>/dev/null || true)")" + if [ "$pver" != "$ver" ]; then + rm -rf -- "$work" + rt_warn "the release source offers ${pver:-an unknown version}, not the installed $ver; nothing was changed. Run 'row-template update' to update." + return 1 + fi + if ! rt_payload_companions_ok "$payload"; then + rm -rf -- "$work" + return 1 + fi + rc=0; rt_repair_template_store "$payload" || rc=$? + rt_install_companions "$payload" \ + || rt_warn "could not install the management library's companions; run 'row-template update' to retry." + rm -rf -- "$work" + # load what was just installed, so this run already sees a complete install + rt_panels_load >/dev/null 2>&1 || true + rt_transaction_load >/dev/null 2>&1 || true + + if [ "$rc" -eq 0 ] && rt_installer_complete; then + n="$(rt_template_offered | grep -c . || true)" + rt_ok "Installation completed: $n design(s) available." + return 0 + fi + rt_warn "the installation is still incomplete; run 'row-template verify' for details." + return 1 +} + rt_restore_from_backup() { # install the artifact recorded in backup DIR as the canonical artifact and # restore its VERSION. Admin branding in config.env is deliberately left @@ -2240,8 +2466,11 @@ rt_cmd_install() { || rt_warn "could not install the row-template CLI to $RT_BIN." fi - # template store: every design this release ships, verified before staging. - rt_stage_template_store "$payload" \ + # template store: every design this release ships, verified before staging, + # and any store a previous path mistake left outside dist/templates moved in. + local store_rc=0 + rt_repair_template_store "$payload" || store_rc=$? + [ "$store_rc" -ne 1 ] \ || rt_die "the release template store failed verification; nothing was activated." # the fresh-install design chooser (interactive only; defaults to Row). @@ -2346,6 +2575,9 @@ rt_cmd_config() { rt_require_root [ -f "$RT_DIST" ] || rt_die "Row-Template is not installed (run the installer first)." rt_detect_xui || true + # A branding write reconciles the selection against the template store, so an + # install left without one (v1.1.0's updater) is completed first. + rt_complete_install || true local saved="" distbak="" sumbak="" if [ -f "$RT_CONFIG" ]; then saved="$(mktemp)" || rt_die "cannot create a temp file." @@ -2376,8 +2608,10 @@ rt_cmd_config() { } # --- 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. +# Health report. Emits ok/warn/FAIL lines and returns non-zero only when a hard +# check fails. Never prints secrets. Its only writes heal the template store +# (see rt_repair_template_store and rt_complete_install), and only when it can +# write to the install root; run without root it changes nothing. rt_cmd_verify() { local fails=0 warns=0 perm cur rc r rv sel_id store_n @@ -2393,18 +2627,52 @@ rt_cmd_verify() { 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 + # Before the store is judged it is healed, when verify can write (as root): + # a store left outside dist/templates is moved home, and designs or + # installer files the installed version ships but the host lacks (as v1.1.0's + # updater leaves it) are completed from that same release. These are the only + # writes verify makes; run without root it changes nothing and reports. + local repaired=0 repair_rc=0 rel id corrupt="" missing="" n_avail=0 n_missing=0 + if [ -f "$RT_DIST" ] && [ -d "$RT_ROOT" ] && [ ! -L "$RT_ROOT" ] && [ -w "$RT_ROOT" ]; then + repaired=1 + rt_repair_template_store || repair_rc=$? + [ "$repair_rc" -ne 1 ] || fails=$((fails + 1)) + if [ "$repair_rc" -eq 2 ] || ! rt_installer_complete; then + rt_complete_install || true + fi + fi + for rel in $RT_TEMPLATE_STORE_MISPLACED; do + [ -e "$RT_ROOT/$rel" ] || [ -L "$RT_ROOT/$rel" ] || continue + if [ "$repaired" -eq 0 ]; then + rt_warn "templates were found outside the store at $RT_ROOT/$rel; run 'row-template verify' as root to move them." + fi + warns=$((warns + 1)) + done + # The template store is the release's own copy of every selectable design. # Every artifact in it must match its sidecar, the stored selection must be # present, and the canonical artifact must be the selection's own bytes — # a config.env that names one design while another is live is the one state - # this system must never report as healthy. + # this system must never report as healthy. Each design is checked by name, + # so a missing or damaged one is reported as itself. sel_id="$(rt_template_effective)" store_n=0; [ -d "$RT_TEMPLATE_STORE" ] && store_n="$(rt_template_store_ids | grep -c . || true)" if [ "$store_n" -gt 0 ]; then - if rt_template_verify_store; then - rt_ok "Template store verified ($store_n design(s))." + for id in $RT_TEMPLATES_AVAILABLE; do + n_avail=$((n_avail + 1)) + case "$(rt_template_store_status "$id")" in + corrupt) corrupt="$corrupt $id" ;; + missing) missing="$missing $id"; n_missing=$((n_missing + 1)) ;; + esac + done + if [ -n "$corrupt" ]; then + rt_err "a template in the store does not match its checksum:$corrupt."; fails=$((fails + 1)) else - rt_err "a template in the store does not match its checksum."; fails=$((fails + 1)) + rt_ok "Template store verified ($store_n design(s))." + fi + if [ -n "$missing" ]; then + rt_warn "template store is incomplete ($((n_avail - n_missing)) of $n_avail designs); missing:$missing. Run 'row-template update' to restore them." + warns=$((warns + 1)) fi if rt_template_store_has "$sel_id"; then rt_ok "Template: $(rt_template_display_name "$sel_id")" @@ -2582,7 +2850,12 @@ rt_cmd_update() { # artifact so an OLDER installed library updating against this payload # degrades safely to Row; only the freshly staged library understands the # store, so the selection is resolved from it, never from the top-level file. - rt_stage_template_store "$payload" || rt_die "the release template store failed verification." + # A store a path mistake left outside dist/templates is moved in as well, so + # an update always ends with the one store the library reads. Designs still + # missing afterwards (a payload that ships no store) fall back below. + local store_rc=0 + rt_repair_template_store "$payload" || store_rc=$? + [ "$store_rc" -ne 1 ] || rt_die "the release template store failed verification." picked="$(rt_template_effective)" if rt_template_store_has "$picked"; then source="$RT_TEMPLATE_STORE/$picked/template.html" @@ -2693,7 +2966,8 @@ 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) + verify Check the install, panel wiring and live render (as root, also + restores missing or misplaced designs) 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 @@ -3071,12 +3345,19 @@ rt_reconfig_template() { local list=() id prev cur choice n=0 i cur="$(rt_template_effective)" printf ' %sCurrent template:%s %s\n' "$RT_C_DIM" "$RT_C_RST" "$(rt_template_display_name "$cur")" + # designs the store should hold but does not are restored before the list is + # drawn (the manager also does this when it opens; this retries it, e.g. once + # the network is back) + if [ -n "$(rt_template_store_missing)" ]; then + rt_complete_install || true + fi while IFS= read -r id; do list+=("$id") done < <(rt_template_offered) n="${#list[@]}" if [ "$n" -eq 0 ]; then - rt_ui_warn "No templates are installed. Re-run the installer to restore the template store." + rt_ui_warn "No templates are installed, and they could not be restored automatically (see above)." + rt_ui_info "Open this menu again once the release source is reachable, or run 'row-template update'." return 0 fi @@ -3147,6 +3428,9 @@ rt_manager_main() { 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 + # The first run after v1.1.0's updater finishes that update here: every design + # and installer file of the installed version, before anything is offered. + rt_complete_install || true local choice while true; do rt_manager_dashboard diff --git a/tests/installer-template-store.test.mjs b/tests/installer-template-store.test.mjs new file mode 100644 index 0000000..f817827 --- /dev/null +++ b/tests/installer-template-store.test.mjs @@ -0,0 +1,547 @@ +/* The template store's self-healing. + * + * The library reads every selectable design from ONE place, + * $RT_ROOT/dist/templates//. A store anywhere else is invisible to it, and + * the manager then reports "No templates are installed" while the files sit one + * directory away. Two states reach that on real hosts: + * + * - the store is ABSENT: v1.1.0's updater installs a v1.2.0 library but copies + * only four files, so no design arrives with it; + * - the store is MISPLACED: a release payload lays its designs out at + * templates//, and a payload copied or extracted over the install root + * leaves them at $RT_ROOT/templates, one level above their home. + * + * rt_repair_template_store heals both from what the host already has (a verified + * payload, a misplaced store), and rt_complete_install finishes an install that + * is still short afterwards from a verified download of the INSTALLED version. + * Every case below runs against a throwaway RT_ROOT, never a real install. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { + copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, +} from 'node:fs'; +import { tmpdir, platform } from 'node:os'; +import { createHash } from 'node:crypto'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { build } from '../tools/build.mjs'; +import { availableTemplateIds, TEMPLATES } from '../tools/templates.mjs'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const IDS = availableTemplateIds(); +const VERSION = readFileSync(join(ROOT, 'VERSION'), 'utf8').trim(); + +/* Every selectable design, built once. Row is the committed artifact. */ +const HTML = Object.fromEntries(IDS.map((id) => [id, + id === 'row' ? readFileSync(join(ROOT, 'template', 'index.html'), 'utf8') : build(true, id).html])); +const sha = (s) => createHash('sha256').update(s).digest('hex'); + +/* One design directory, with its sidecar written the way make-release.sh + writes it (the digest, two spaces, the payload-relative path). */ +function writeDesign(dir, id, html = HTML[id]) { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'template.html'), html); + writeFileSync(join(dir, 'template.html.sha256'), `${sha(HTML[id])} templates/${id}/template.html\n`); +} + +function writeStore(dir, ids = IDS) { + for (const id of ids) writeDesign(join(dir, id), id); +} + +/* An installed v1.2.0 with NO store: what v1.1.0's updater leaves behind. The + canonical artifact is Row, the live page exists, branding is set and there + is one format-1 backup from before the update. */ +function prepareInstalled(root) { + mkdirSync(join(root, 'dist'), { recursive: true }); + writeFileSync(join(root, 'dist', 'template.html'), HTML.row); + writeFileSync(join(root, 'dist', 'template.html.sha256'), sha(HTML.row) + ' template.html\n'); + writeFileSync(join(root, 'sub.html'), HTML.row); + writeFileSync(join(root, 'VERSION'), VERSION + '\n'); + writeFileSync(join(root, 'config.env'), [ + 'RT_CONFIG_VERSION=1', + 'SERVICE_NAME_B64=' + Buffer.from('Test VPN', 'utf8').toString('base64'), + 'SUPPORT_URL_B64=', + 'LOGO_MIME=', + 'LOGO_DATA_B64=', + '', + ].join('\n')); + const bk = join(root, 'backups', '20260101T000000Z__1.1.0'); + mkdirSync(bk, { recursive: true }); + writeFileSync(join(bk, 'template.html'), HTML.row); + writeFileSync(join(bk, 'template.html.sha256'), sha(HTML.row) + '\n'); + writeFileSync(join(bk, 'VERSION'), '1.1.0\n'); +} + +/* A release payload of VERSION with every design and the library's companions, + as tools/make-release.sh lays it out. */ +function writePayload(dir, { version = VERSION, ids = IDS } = {}) { + mkdirSync(join(dir, 'lib'), { recursive: true }); + mkdirSync(join(dir, 'panels'), { recursive: true }); + writeFileSync(join(dir, 'template.html'), HTML.row); + writeFileSync(join(dir, 'VERSION'), version + '\n'); + copyFileSync(join(ROOT, 'installer', 'lib', 'row-template.sh'), join(dir, 'lib', 'row-template.sh')); + copyFileSync(join(ROOT, 'installer', 'lib', 'transaction.sh'), join(dir, 'lib', 'transaction.sh')); + for (const f of readdirSync(join(ROOT, 'installer', 'panels'))) { + copyFileSync(join(ROOT, 'installer', 'panels', f), join(dir, 'panels', f)); + } + writeStore(join(dir, 'templates'), ids); +} + +/* No release source is reachable unless a test provides one, so nothing here + can reach the network; a test that needs a download redefines + rt_fetch_release after these. */ +const STUBS = [ + 'rt_require_root(){ :; }', + 'rt_detect_xui(){ return 1; }', + 'rt_detect_xui_version(){ return 1; }', + 'rt_detect_xui_db(){ return 1; }', + 'rt_fetch_release(){ return 1; }', + 'trap "rt_cleanup" EXIT', + '', +].join('\n'); + +/* Run BODY with the INSTALLED library layout: the library is copied into the + sandbox's lib/ and sourced from there, so the companion loader looks where a + real install keeps its companions -- and finds them only if they are there. */ +function run(body, { prepare, env } = {}) { + const base = mkdtempSync(join(tmpdir(), 'row-store-')).replace(/\\/g, '/'); + const root = `${base}/rt`; + try { + mkdirSync(join(root, 'lib'), { recursive: true }); + copyFileSync(join(ROOT, 'installer', 'lib', 'row-template.sh'), join(root, 'lib', 'row-template.sh')); + if (prepare) prepare(root, base); + const script = [ + 'set -Eeuo pipefail', + 'unset RT_TEMPLATE RT_RELEASE_URL RT_RELEASE_DIR RT_ASSUME_YES XUI_DB_FOLDER', + `export RT_ROOT="${root}" RT_BIN="${base}/row-template"`, + 'BASE="' + base + '"', + '. "$RT_ROOT/lib/row-template.sh"', + STUBS, + body, + ].join('\n'); + const r = spawnSync('bash', ['-c', script], { + cwd: ROOT, encoding: 'utf8', env: env ? { ...process.env, ...env } : undefined, + }); + if (r.error) throw r.error; + return { code: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() }; + } finally { + rmSync(base, { recursive: true, force: true }); + } +} + +/* The store's contents as the library itself reads them, plus whether the + misplaced directory still exists. Printed by the bash side, because the + sandbox is gone once run() returns. */ +const REPORT = [ + 'printf "store=%s\\n" "$(rt_template_store_ids | tr "\\n" "," )"', + 'printf "offered=%s\\n" "$(rt_template_offered | wc -l | tr -d " ")"', + 'if rt_template_verify_store; then echo "verified=yes"; else echo "verified=no"; fi', + '[ -e "$RT_ROOT/templates" ] && echo "misplaced=present" || echo "misplaced=gone"', +].join('\n'); + +const ALL = IDS.slice().sort().join(',') + ','; +const field = (out, key) => (out.match(new RegExp(`^${key}=(.*)$`, 'm')) || [])[1]; + +/* --- the misplaced store ----------------------------------------------------- */ + +test('a store misplaced at $RT_ROOT/templates is moved into dist/templates and verified', () => { + const r = run([ + 'rc=0; rt_repair_template_store || rc=$?', + 'echo "rc=$rc"', + REPORT, + 'for id in ' + IDS.join(' ') + '; do', + ' cmp -s "$RT_TEMPLATE_STORE/$id/template.html" "$BASE/expect/$id/template.html" || echo "differs=$id"', + 'done', + ].join('\n'), { + prepare: (root, base) => { + prepareInstalled(root); + writeStore(join(root, 'templates')); + writeStore(join(base, 'expect')); + }, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'rc'), '0', 'a complete store after repair reports success'); + assert.equal(field(r.out, 'store'), ALL, 'every design is in the canonical store'); + assert.equal(field(r.out, 'offered'), String(IDS.length), 'and the chooser offers every one'); + assert.equal(field(r.out, 'verified'), 'yes'); + assert.equal(field(r.out, 'misplaced'), 'gone', 'the misplaced copy is retired once covered'); + assert.doesNotMatch(r.out, /differs=/, 'the designs are moved byte for byte'); + assert.match(r.out, new RegExp(`moved ${IDS.length} design`), 'the repair says what it did'); +}); + +test('a mixed install is repaired as far as the host allows, then completed from a payload', () => { + /* The broken layout from the report: two designs at the root, only the + canonical artifact under dist/. With nothing else on the host the repair + recovers those two and names the rest; with a payload it completes. */ + const local = run([ + 'rc=0; rt_repair_template_store || rc=$?', + 'echo "rc=$rc"', + 'printf "missing=%s\\n" "$(rt_template_store_missing | tr "\\n" ",")"', + REPORT, + ].join('\n'), { + prepare: (root) => { prepareInstalled(root); writeStore(join(root, 'templates'), ['row', 'prism']); }, + }); + assert.equal(local.code, 0, local.err); + assert.equal(field(local.out, 'rc'), '2', 'an incomplete store is reported as incomplete, not as success'); + assert.equal(field(local.out, 'store'), 'prism,row,'); + assert.equal(field(local.out, 'missing'), IDS.filter((id) => !['row', 'prism'].includes(id)).join(',') + ',', + 'every design still missing is named, in catalogue order'); + assert.equal(field(local.out, 'misplaced'), 'gone'); + + const payload = run([ + 'rc=0; rt_repair_template_store "$BASE/payload" || rc=$?', + 'echo "rc=$rc"', + REPORT, + ].join('\n'), { + prepare: (root, base) => { + prepareInstalled(root); + writeStore(join(root, 'templates'), ['row', 'prism']); + writePayload(join(base, 'payload')); + }, + }); + assert.equal(payload.code, 0, payload.err); + assert.equal(field(payload.out, 'rc'), '0'); + assert.equal(field(payload.out, 'store'), ALL); + assert.equal(field(payload.out, 'misplaced'), 'gone'); +}); + +test('a corrupt design is detected, replaced from a good copy, and never migrated when it is the copy', () => { + const r = run([ + 'rc=0; rt_repair_template_store 2>"$BASE/err" || rc=$?', + 'echo "rc=$rc"', + 'cat "$BASE/err"', + REPORT, + 'printf "missing=%s\\n" "$(rt_template_store_missing | tr "\\n" ",")"', + 'cmp -s "$RT_TEMPLATE_STORE/editorial/template.html" "$BASE/good-editorial.html" && echo "editorial=replaced"', + '[ -e "$RT_TEMPLATE_STORE/prism" ] && echo "prism=staged" || echo "prism=refused"', + ].join('\n'), { + prepare: (root, base) => { + prepareInstalled(root); + // the canonical store is complete except that editorial was tampered with + // and prism is missing + writeStore(join(root, 'dist', 'templates'), IDS.filter((id) => id !== 'prism')); + writeFileSync(join(root, 'dist', 'templates', 'editorial', 'template.html'), HTML.editorial + 'x'); + // the misplaced store has a good editorial and a corrupt prism + writeDesign(join(root, 'templates', 'editorial'), 'editorial'); + writeDesign(join(root, 'templates', 'prism'), 'prism', HTML.prism.replace('', '')); + writeFileSync(join(base, 'good-editorial.html'), HTML.editorial); + }, + }); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /editorial=replaced/, 'a damaged design is replaced from a copy that verifies'); + assert.match(r.out, /prism=refused/, 'a copy that fails its own checksum is never migrated'); + assert.match(r.out, /prism[^\n]*(checksum|verification)/, 'and the refusal names the design'); + assert.equal(field(r.out, 'rc'), '2'); + assert.equal(field(r.out, 'missing'), 'prism,'); + assert.equal(field(r.out, 'verified'), 'yes', 'what is in the store afterwards all verifies'); +}); + +test('the repair never follows a symlink out of the install root', { skip: platform() !== 'linux' }, () => { + const r = run([ + 'rc=0; rt_repair_template_store 2>/dev/null || rc=$?', + 'echo "rc=$rc"', + REPORT, + '[ -f "$BASE/outside/row/template.html" ] && echo "outside=intact"', + ].join('\n'), { + prepare: (root, base) => { + prepareInstalled(root); + writeStore(join(base, 'outside'), ['row']); + symlinkSync(join(base, 'outside'), join(root, 'templates')); + }, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'store'), '', 'nothing is taken through the link'); + assert.match(r.out, /outside=intact/, 'and nothing it points at is removed'); +}); + +test('files the repair does not recognise are left where they are', () => { + const r = run([ + 'rt_repair_template_store >/dev/null 2>&1 || true', + REPORT, + '[ -f "$RT_ROOT/templates/notes.txt" ] && echo "notes=kept"', + '[ -f "$RT_ROOT/templates/Custom/template.html" ] && echo "custom=kept"', + ].join('\n'), { + prepare: (root) => { + prepareInstalled(root); + writeStore(join(root, 'templates')); + writeFileSync(join(root, 'templates', 'notes.txt'), 'mine\n'); + mkdirSync(join(root, 'templates', 'Custom')); + writeFileSync(join(root, 'templates', 'Custom', 'template.html'), 'x'); + }, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'store'), ALL); + assert.match(r.out, /notes=kept/); + assert.match(r.out, /custom=kept/); + assert.equal(field(r.out, 'misplaced'), 'present', 'a directory still holding foreign files stays'); +}); + +/* --- the entry points: install, update, verify ------------------------------- */ + +test('verify moves a misplaced store and then reports it healthy', () => { + const r = run('rt_cmd_verify', { + prepare: (root) => { prepareInstalled(root); writeStore(join(root, 'templates')); }, + }); + assert.equal(r.code, 0, r.err); + assert.match(r.out, new RegExp(`moved ${IDS.length} design`)); + assert.match(r.out, new RegExp(`Template store verified \\(${IDS.length} design`)); + assert.doesNotMatch(r.err, /template store missing or empty/); +}); + +test('verify completes an install the v1.1.0 updater left short, then passes', () => { + /* The installation guide's own sequence: update, then verify. verify must not + fail on a state the next step of the same update resolves. */ + const r = run([ + 'rt_fetch_release(){ printf "%s" "$BASE/payload"; }', + 'rt_cmd_verify', + ].join('\n'), { + prepare: (root, base) => { prepareInstalled(root); writePayload(join(base, 'payload')); }, + }); + assert.equal(r.code, 0, r.err); + assert.match(r.out, new RegExp(`Installation completed: ${IDS.length} design`)); + assert.match(r.out, new RegExp(`Template store verified \\(${IDS.length} design`)); + assert.match(r.out, /Installer components present/); + assert.doesNotMatch(r.err, /template store missing or empty|installer components are missing/); +}); + +test('verify without a reachable release reports the gap and the remedy', () => { + const r = run('rt_cmd_verify', { prepare: prepareInstalled }); + assert.equal(r.code, 1, 'an empty store is still a hard failure'); + assert.match(r.err, /could not download/); + assert.match(r.err, /template store missing or empty; run 'row-template update'/); +}); + +test('verify names missing and corrupt designs', () => { + const partial = run('rt_cmd_verify', { + prepare: (root) => { prepareInstalled(root); writeStore(join(root, 'dist', 'templates'), IDS.slice(0, -2)); }, + }); + assert.equal(partial.code, 0, 'a partial store is a warning: every installed design still works\n' + partial.err); + assert.match(partial.err, new RegExp(`template store is incomplete[^\\n]*${IDS.at(-2)}[^\\n]*${IDS.at(-1)}`), + 'the missing designs are named'); + + const corrupt = run('rt_cmd_verify', { + prepare: (root) => { + prepareInstalled(root); + writeStore(join(root, 'dist', 'templates')); + writeFileSync(join(root, 'dist', 'templates', 'canvas', 'template.html'), HTML.canvas + 'x'); + }, + }); + assert.equal(corrupt.code, 1, 'a design that fails its checksum is a hard failure'); + assert.match(corrupt.err, /does not match its checksum[^\n]*canvas/, 'and the design is named'); +}); + +test('update moves a misplaced store and keeps every backup it found', () => { + const r = run([ + 'rt_fetch_release(){ printf "%s" "$BASE/payload"; }', + 'rt_cmd_update >/dev/null', + REPORT, + '[ -d "$RT_BACKUPS/20260101T000000Z__1.1.0" ] && echo "old-backup=kept"', + ].join('\n'), { + prepare: (root, base) => { + prepareInstalled(root); + writeStore(join(root, 'templates')); + writePayload(join(base, 'payload')); + }, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'store'), ALL); + assert.equal(field(r.out, 'misplaced'), 'gone'); + assert.match(r.out, /old-backup=kept/); +}); + +test('install (repair of an existing root) moves a misplaced store', () => { + const r = run([ + '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_service_active(){ return 1; }; rt_service_start(){ :; }; rt_service_stop(){ :; }', + 'RT_ASSUME_YES=1 rt_cmd_install "$BASE/payload" /dev/null', + REPORT, + ].join('\n'), { + prepare: (root, base) => { + prepareInstalled(root); + writeStore(join(root, 'templates'), ['row', 'canvas']); + writePayload(join(base, 'payload')); + }, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'store'), ALL); + assert.equal(field(r.out, 'misplaced'), 'gone'); +}); + +test('a fresh install never creates a store outside dist/templates', () => { + const r = run([ + '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_service_active(){ return 1; }; rt_service_start(){ :; }; rt_service_stop(){ :; }', + 'RT_SERVICE_NAME="Fresh" rt_cmd_install "$BASE/payload" /dev/null', + REPORT, + ].join('\n'), { + prepare: (root, base) => { writePayload(join(base, 'payload')); }, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'store'), ALL); + assert.equal(field(r.out, 'verified'), 'yes'); + assert.equal(field(r.out, 'misplaced'), 'gone'); +}); + +/* --- completing an install the v1.1.0 updater left short ---------------------- */ + +test('the first run of the new code completes the install from the installed version', () => { + const r = run([ + 'rt_fetch_release(){ echo "fetched $RT_RELEASE_URL" >> "$BASE/fetch.log"; printf "%s" "$BASE/payload"; }', + 'rc=0; rt_complete_install || rc=$?', + 'echo "rc=$rc"', + REPORT, + 'echo "panels=${RT_PANELS_LOADED:-} txn=${RT_TRANSACTION_LOADED:-}"', + 'rt_installer_complete && echo "complete=yes" || echo "complete=no"', + 'for f in lib/transaction.sh panels/index.sh panels/interface.sh panels/3xui.sh; do', + ' [ -f "$RT_ROOT/$f" ] || echo "absent=$f"', + 'done', + 'cat "$BASE/fetch.log"', + 'cmp -s "$RT_DIST" "$BASE/payload/template.html" && echo "dist=untouched"', + 'printf "name=%s\\n" "$(rt_config_get_text SERVICE_NAME_B64)"', + ].join('\n'), { + prepare: (root, base) => { prepareInstalled(root); writePayload(join(base, 'payload')); }, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'rc'), '0'); + assert.equal(field(r.out, 'store'), ALL, 'every design is installed'); + assert.equal(field(r.out, 'offered'), String(IDS.length)); + assert.match(r.out, /panels=1 txn=1/, 'the installer components load in the same run'); + assert.equal(field(r.out, 'complete'), 'yes'); + assert.doesNotMatch(r.out, /absent=/); + assert.match(r.out, new RegExp(`fetched https://github\\.com/iitzSeriZdev/Row-Template/releases/download/v${VERSION.replace(/\./g, '\\.')}$`, 'm'), + 'the download is pinned to the INSTALLED version, never "latest"'); + assert.match(r.out, /dist=untouched/, 'the live design is not changed'); + assert.equal(field(r.out, 'name'), 'Test VPN', 'branding is not touched'); +}); + +test('completion honours an explicit release source and refuses a different version', () => { + const r = run([ + 'export RT_RELEASE_DIR="$BASE/other"', + 'rt_fetch_release(){ echo "source=dir:${RT_RELEASE_DIR:-} url:${RT_RELEASE_URL:-}" >> "$BASE/fetch.log"; return 1; }', + 'rc=0; rt_complete_install 2>"$BASE/err" || rc=$?', + 'echo "rc=$rc"', + 'cat "$BASE/fetch.log"', + 'unset RT_RELEASE_DIR', + 'rt_fetch_release(){ printf "%s" "$BASE/payload"; }', + 'rc=0; rt_complete_install 2>>"$BASE/err" || rc=$?', + 'echo "rc2=$rc"', + 'cat "$BASE/err"', + REPORT, + ].join('\n'), { + prepare: (root, base) => { prepareInstalled(root); writePayload(join(base, 'payload'), { version: '9.9.9' }); }, + }); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /^source=dir:\S*\/other url:$/m, 'RT_RELEASE_DIR is used as given, not replaced by a pinned URL'); + assert.equal(field(r.out, 'rc'), '1', 'an unreachable source leaves the install incomplete, reported'); + assert.equal(field(r.out, 'rc2'), '1'); + assert.match(r.out, /9\.9\.9/, 'a payload of another version is refused, and says so'); + assert.equal(field(r.out, 'store'), '', 'nothing is staged from it'); +}); + +test('completion does nothing, and downloads nothing, on a complete install', () => { + const r = run([ + 'rt_fetch_release(){ echo "FETCHED"; return 1; }', + 'rc=0; rt_complete_install || rc=$?', + 'echo "rc=$rc"', + ].join('\n'), { + prepare: (root) => { + prepareInstalled(root); + writeStore(join(root, 'dist', 'templates')); + mkdirSync(join(root, 'panels'), { recursive: true }); + copyFileSync(join(ROOT, 'installer', 'lib', 'transaction.sh'), join(root, 'lib', 'transaction.sh')); + for (const f of readdirSync(join(ROOT, 'installer', 'panels'))) { + copyFileSync(join(ROOT, 'installer', 'panels', f), join(root, 'panels', f)); + } + }, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'rc'), '0'); + assert.doesNotMatch(r.out, /FETCHED/); + assert.equal(r.err, '', 'and prints nothing'); +}); + +test('completion from a misplaced store needs no download', () => { + const r = run([ + 'rt_fetch_release(){ echo "FETCHED"; printf "%s" "$BASE/payload"; }', + 'rt_complete_install >/dev/null 2>&1 || true', + REPORT, + ].join('\n'), { + prepare: (root, base) => { + prepareInstalled(root); + writeStore(join(root, 'templates')); + // the companions are present, so the store is the only gap + mkdirSync(join(root, 'panels'), { recursive: true }); + copyFileSync(join(ROOT, 'installer', 'lib', 'transaction.sh'), join(root, 'lib', 'transaction.sh')); + for (const f of readdirSync(join(ROOT, 'installer', 'panels'))) { + copyFileSync(join(ROOT, 'installer', 'panels', f), join(root, 'panels', f)); + } + writePayload(join(base, 'payload')); + }, + }); + assert.equal(r.code, 0, r.err); + assert.doesNotMatch(r.out, /FETCHED/, 'the host already had every design'); + assert.equal(field(r.out, 'store'), ALL); +}); + +/* --- what the operator sees ------------------------------------------------- */ + +test('Reconfigure branding -> Template offers every design after a v1.1.0 update', () => { + /* The report, reproduced: the store is absent, the chooser is opened. It must + complete the install and list every design -- not tell the operator to + re-run the installer. */ + const r = run([ + 'rt_fetch_release(){ printf "%s" "$BASE/payload"; }', + 'rt_reconfig_template &1', + ].join('\n'), { + prepare: (root, base) => { prepareInstalled(root); writePayload(join(base, 'payload')); }, + }); + assert.equal(r.code, 0, r.err); + assert.doesNotMatch(r.out, /No templates are installed/); + for (const id of IDS) { + assert.match(r.out, new RegExp(`^\\s*\\d+\\s+${TEMPLATES[id].name}$`, 'm'), `${TEMPLATES[id].name} is offered`); + } +}); + +test('the manager completes the install when it opens, before the operator picks anything', () => { + const r = run([ + 'rt_fetch_release(){ printf "%s" "$BASE/payload"; }', + 'rt_manager_main /dev/null 2>&1', + REPORT, + 'rt_installer_complete && echo "complete=yes" || echo "complete=no"', + ].join('\n'), { + prepare: (root, base) => { prepareInstalled(root); writePayload(join(base, 'payload')); }, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'store'), ALL); + assert.equal(field(r.out, 'complete'), 'yes'); +}); + +test('a branding change works on an install the v1.1.0 updater left short', () => { + /* Without a store the branding write cannot reconcile the selection (Row) and + is refused. `row-template config` completes the install first. */ + const r = run([ + 'rt_fetch_release(){ printf "%s" "$BASE/payload"; }', + 'RT_ASSUME_NONINTERACTIVE=1 RT_SERVICE_NAME="Renamed" rt_cmd_config /dev/null', + 'printf "name=%s\\n" "$(rt_config_get_text SERVICE_NAME_B64)"', + REPORT, + ].join('\n'), { + prepare: (root, base) => { prepareInstalled(root); writePayload(join(base, 'payload')); }, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'name'), 'Renamed'); + assert.equal(field(r.out, 'store'), ALL); +}); + +test('the library never creates dist/templates just by loading', () => { + const r = run('[ -e "$RT_TEMPLATE_STORE" ] && echo "store=created" || echo "store=absent"', { + prepare: prepareInstalled, + }); + assert.equal(r.code, 0, r.err); + assert.equal(field(r.out, 'store'), 'absent'); +}); diff --git a/tests/installer.test.mjs b/tests/installer.test.mjs index 3330731..6db50fc 100644 --- a/tests/installer.test.mjs +++ b/tests/installer.test.mjs @@ -675,14 +675,18 @@ function writeArtifact(dir, html, sha) { } /* Run a snippet against a Node-prepared install root. `prepare` receives the - POSIX-style root path before bash starts. */ + POSIX-style root path before bash starts. No release source is reachable: + the manager, config and verify complete an incomplete install by + downloading, and a test must never reach the network. A test that needs a + payload redefines rt_fetch_release in its body. */ function shRoot(body, { input, prepare, env } = {}) { const root = mkdtempSync(join(tmpdir(), 'row-t-')).replace(/\\/g, '/'); try { if (prepare) prepare(root); const r = spawnSync( 'bash', - ['-c', 'set -Eeuo pipefail\nexport RT_ROOT="' + root + '"\nsource installer/lib/row-template.sh\n' + body], + ['-c', 'set -Eeuo pipefail\nexport RT_ROOT="' + root + '"\nsource installer/lib/row-template.sh\n' + + 'rt_fetch_release(){ return 1; }\n' + body], { cwd: ROOT, encoding: 'utf8', input, env: env ? { ...process.env, ...env } : undefined }, ); if (r.error) throw r.error; @@ -743,11 +747,16 @@ function writePayload(root, { withStore = true } = {}) { } } +/* No release source is reachable unless a test provides one: verify, config + and the manager complete an incomplete install by downloading, and a test + must never reach the network. A test that needs a payload redefines + rt_fetch_release after these stubs. */ const FLOW_STUBS = [ 'rt_require_root(){ :; }', 'rt_detect_xui(){ return 1; }', 'rt_detect_xui_version(){ return 1; }', 'rt_detect_xui_db(){ return 1; }', + 'rt_fetch_release(){ return 1; }', '', ].join('\n'); diff --git a/tests/release.test.mjs b/tests/release.test.mjs index 57cf530..ac7fae7 100644 --- a/tests/release.test.mjs +++ b/tests/release.test.mjs @@ -189,8 +189,10 @@ test('the release payload ships every panel shell for every design, with checksu // all three families, and nothing else assert.deepEqual(readdirSync(shellRoot).sort(), buildablePanelIds().sort(), 'shells/ must contain exactly the buildable panels'); + /* Buildable, not supported: a shell is packaged for every panel in the + registry, but only 3X-UI can be installed (see panel-support.test.mjs). */ assert.deepEqual(buildablePanelIds().sort(), ['3xui', 'pasarguard', 'rebecca'], - 'the three supported panels'); + 'the three buildable panels'); // every design, under every panel for (const panel of buildablePanelIds()) { @@ -306,7 +308,10 @@ function sandbox() { return { base, rt: join(base, 'rt'), bin: join(base, 'bin', 'row-template') }; } -/* What a fresh bash sees when it sources the INSTALLED library. */ +/* What a fresh bash sees when it sources the INSTALLED library. verify + completes an incomplete install from the release source, so none is + reachable here: this reports the state as it is. (A library that predates + that ignores the stub.) */ function installedState(sb) { return bashRun([ 'if . "$RT_ROOT/lib/row-template.sh"; then echo "loaded=yes"; else echo "loaded=NO"; exit 0; fi', @@ -314,6 +319,7 @@ function installedState(sb) { 'if rt_installer_complete; then echo "complete=yes"; else echo "complete=no"; fi', 'echo "name=$(rt_config_get_text SERVICE_NAME_B64)"', PANEL_STUBS, + 'rt_fetch_release(){ return 1; }', 'echo "--- verify"', '( rt_cmd_verify ) 2>&1 || true', ], { RT_ROOT: sb.rt, RT_BIN: sb.bin }); @@ -484,3 +490,130 @@ test('the manager offers to complete an incomplete install even when it is up to rmSync(sb.base, { recursive: true, force: true }); } }); + +/* ------------------------------------------------------------------------ */ +/* One `row-template update` from v1.1.0 is enough */ +/* */ +/* v1.1.0's updater installs this release's library but copies only four */ +/* files, so the store arrives empty. The FIRST run of the new code -- the */ +/* manager an operator opens next -- completes the install from the same */ +/* release, and the Template chooser offers every design. No second update, */ +/* no manual copy. */ +/* ------------------------------------------------------------------------ */ + +const DESIGN_NAMES = availableTemplateIds().map((id) => TEMPLATES[id].name); + +/* The manager as an operator opens it, then Reconfigure branding -> Template. + stdin is not a terminal, so every menu reads EOF and backs out. */ +function openChooser(sb, rel) { + return bashRun(['. "$RT_ROOT/lib/row-template.sh"', PANEL_STUBS, + 'export RT_RELEASE_DIR="$REL"', + 'rt_manager_main "$RT_ROOT/../manager.log" 2>&1', + 'rt_reconfig_template &1'], { REL: rel, RT_ROOT: sb.rt, RT_BIN: sb.bin }); +} + +function assertChooserOffersAll(r, label) { + assert.equal(r.code, 0, r.err); + assert.doesNotMatch(r.out, /No templates are installed/, `${label}: the chooser is not empty`); + for (const name of DESIGN_NAMES) { + assert.match(r.out, new RegExp(`^\\s*\\d+\\s+${name}$`, 'm'), `${label}: ${name} is offered`); + } +} + +test('after one row-template update from v1.1.0, the next manager run offers every design', () => { + const { out, payload } = sharedPayload(); + const sb = sandbox(); + try { + upgradedByV110(sb, out, payload); + assert.equal(existsSync(join(sb.rt, 'dist', 'templates')), false, + 'the v1.1.0 updater leaves no store: this is the state operators are in'); + const backups = readdirSync(join(sb.rt, 'backups')).sort(); + + const r = openChooser(sb, out); + assertChooserOffersAll(r, 'first manager run'); + assert.match(readFileSync(join(sb.base, 'manager.log'), 'utf8'), new RegExp(`Installation completed: ${availableTemplateIds().length} design`), + 'the manager says what it completed'); + const s = assertComplete(sb, 'completed on first run'); + assert.match(s.out, /name=Test VPN/, 'branding survived'); + assert.equal(existsSync(join(sb.rt, 'templates')), false, 'no store outside dist/templates'); + assert.deepEqual(readdirSync(join(sb.rt, 'backups')).sort(), backups, 'every backup is kept'); + + /* rollback still works on the completed install */ + const rb = bashRun(['. "$RT_ROOT/lib/row-template.sh"', PANEL_STUBS, 'rt_cmd_rollback'], + { RT_ROOT: sb.rt, RT_BIN: sb.bin }); + assert.equal(rb.code, 0, 'rollback after the completed upgrade\n' + rb.err); + assert.match(rb.out, /Rollback complete/); + } finally { + rmSync(sb.base, { recursive: true, force: true }); + } +}); + +test('after one row-template update from v1.1.0, row-template verify completes the install and passes', () => { + const { out, payload } = sharedPayload(); + const sb = sandbox(); + try { + upgradedByV110(sb, out, payload); + const v = bashRun(['. "$RT_ROOT/lib/row-template.sh"', PANEL_STUBS, + 'RT_RELEASE_DIR="$REL" rt_cmd_verify'], { REL: out, RT_ROOT: sb.rt, RT_BIN: sb.bin }); + assert.equal(v.code, 0, 'verify passes on the first run after the update\n' + v.err); + assert.match(v.out, new RegExp(`Installation completed: ${availableTemplateIds().length} design`)); + assertComplete(sb, 'completed by verify'); + assertChooserOffersAll(openChooser(sb, out), 'after verify'); + } finally { + rmSync(sb.base, { recursive: true, force: true }); + } +}); + +/* The layout from the report: the payload's templates/ landed at the install + root, beside dist/ instead of inside it. */ +function misplaceStore(sb, payload) { + const dest = join(sb.rt, 'templates'); + for (const id of availableTemplateIds()) { + mkdirSync(join(dest, id), { recursive: true }); + for (const f of ['template.html', 'template.html.sha256']) { + copyFileSync(join(payload, 'templates', id, f), join(dest, id, f)); + } + } +} + +test('a store left at the install root is moved into dist/templates by update', () => { + const { out, payload } = sharedPayload(); + const sb = sandbox(); + try { + upgradedByV110(sb, out, payload); + misplaceStore(sb, 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); + assertComplete(sb, 'update over a misplaced store'); + assert.equal(existsSync(join(sb.rt, 'templates')), false, 'the misplaced copy is retired'); + assertChooserOffersAll(openChooser(sb, out), 'after update'); + } finally { + rmSync(sb.base, { recursive: true, force: true }); + } +}); + +test('a store left at the install root is moved home by verify, from the host alone', () => { + const { out, payload } = sharedPayload(); + const sb = sandbox(); + try { + upgradedByV110(sb, out, payload); + misplaceStore(sb, payload); + /* No release source is reachable, so every design must come from the host. + (verify still TRIES a download here, for the installer files v1.1.0's + updater never installs -- it fails, and that is reported separately.) */ + const v = bashRun(['. "$RT_ROOT/lib/row-template.sh"', PANEL_STUBS, + 'rt_fetch_release(){ return 1; }', + '( rt_cmd_verify ) 2>&1 || true'], { RT_ROOT: sb.rt, RT_BIN: sb.bin }); + assert.equal(v.code, 0, v.err); + assert.match(v.out, new RegExp(`moved ${availableTemplateIds().length} design`), 'verify moves the store home'); + assert.match(v.out, new RegExp(`Template store verified \\(${availableTemplateIds().length} design`), + 'and the store is complete without any download'); + assert.doesNotMatch(v.out, /template store is incomplete/); + assert.match(v.out, /installer components are missing/, 'the one gap left is named'); + assert.deepEqual(readdirSync(join(sb.rt, 'dist', 'templates')).sort(), availableTemplateIds().sort()); + assert.equal(existsSync(join(sb.rt, 'templates')), false); + } finally { + rmSync(sb.base, { recursive: true, force: true }); + } +}); From 5f973d4601f1b3e275af264382ba3faed7eb6eaa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 22:12:20 +0000 Subject: [PATCH 02/25] test(panels): pin PasarGuard and Rebecca support to what the installer does An audit of both panels against the code: the installer has no adapter for either (installer/panels holds only 3xui.sh), the registry resolves both to no implementation, every panel operation returns UNAVAILABLE, and on a host with PasarGuard or Rebecca but no 3X-UI the install refuses and writes nothing. What exists is build-time only: page shells in Jinja2 and pongo2, packaged under shells/; data adapters tested against samples written from each panel's source; and rendering tests through a test-only renderer, not a real panel. Both therefore stay research targets. The compatibility pages (en, fa, ar) now carry a per-capability matrix and list what exists and what is missing, and tests/panel-support.test.mjs derives each panel's status from the installer and checks every README, compatibility page and the changelog against it, so a panel cannot be documented as supported because a file for it exists. Also drops "supported" from two places that meant "buildable". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BbSwnyeKjBZ2HoJddxdYBM --- docs/src/content/docs/ar/compatibility.mdx | 37 +++- docs/src/content/docs/compatibility.mdx | 39 +++- docs/src/content/docs/fa/compatibility.mdx | 39 +++- tests/panel-support.test.mjs | 217 +++++++++++++++++++++ tools/make-release.sh | 2 +- 5 files changed, 318 insertions(+), 16 deletions(-) create mode 100644 tests/panel-support.test.mjs diff --git a/docs/src/content/docs/ar/compatibility.mdx b/docs/src/content/docs/ar/compatibility.mdx index c3f95fa..c166b39 100644 --- a/docs/src/content/docs/ar/compatibility.mdx +++ b/docs/src/content/docs/ar/compatibility.mdx @@ -51,11 +51,38 @@ description: ما هو مدعوم في بيئة الإنتاج اليوم، وم ### الحالة -| اللوحة | الحالة | -|---|---| -| ‎3X-UI‎ | **مدعومة** | -| ‎PasarGuard‎ | بحث — غير مدعومة، بلا تعليمات تثبيت | -| ‎Rebecca‎ | بحث — غير مدعومة، بلا تعليمات تثبيت | +ما يستطيع المثبّت فعله على كل لوحة اليوم. تتحقّق مجموعة الاختبارات +(‎`tests/panel-support.test.mjs`‎) من هذا الجدول مقابل المثبّت نفسه، فلا يمكنه أن يدّعي أكثر +ممّا تفعله الشيفرة. + +| اللوحة | الاكتشاف | التثبيت | التفعيل | التحقق | النسخ الاحتياطي والاستعادة | هيكل الصفحة | الحالة | +|---|---|---|---|---|---|---|---| +| ‎3X-UI‎ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **مدعومة** | +| ‎PasarGuard‎ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | بحث — غير مدعومة، بلا تعليمات تثبيت | +| ‎Rebecca‎ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | بحث — غير مدعومة، بلا تعليمات تثبيت | + +على خادم فيه ‎PasarGuard‎ أو ‎Rebecca‎ من دون ‎3X-UI‎، يتوقّف المثبّت بالرسالة +‎`no 3x-ui installation was detected on this host`‎ ولا يغيّر شيئًا. + +#### ما هو موجود لـ‎PasarGuard‎ و‎Rebecca‎ + +- **هيكل صفحة لكل تصميم** بلغة قوالب اللوحة نفسها (‎Jinja2‎ لـ‎PasarGuard‎ + و‎pongo2‎ لـ‎Rebecca‎). يبنيها كل إصدار ويحزمها تحت ‎`shells/`‎، ولا يضعها + المثبّت في مكانها. +- **محوّلات بيانات** تربط بيانات الاشتراك في كل لوحة بالحقول التي تستخدمها الصفحة، مختبَرة + على استجابات نموذجية كُتبت من الشيفرة المصدرية لكل لوحة — لا مأخوذة من لوحة قيد التشغيل. +- **اختبارات عرض** تملأ كل هيكل بتلك البيانات، باستخدام مُصيِّر اختباري صغير لا يفهم إلا الصيغة + التي تستخدمها الهياكل؛ ولم يُعرَض أي هيكل بعد على خادم ‎PasarGuard‎ أو ‎Rebecca‎ + حقيقي. + +#### ما يلزم قبل دعم أيٍّ منهما + +- محوّل في المثبّت: اكتشاف اللوحة وإعداداتها وخدمتها. +- وضع الهيكل حيث تقرأ اللوحة قوالبها، وتفعيله فيها، والتحقق من هذا التغيير واستعادته. +- الحالة الحيّة، التي تقدّمها اللوحتان على لاحقة مسار (انظر أعلاه). +- حالة الاشتراك ‎`on_hold`‎. تملكها اللوحتان، ولا تملك الصفحة بعد طريقة لعرضها، لذا + ترفضها المحوّلات. +- اختبار على خادم حقيقي لكلٍّ من اللوحتين. ## الخطوة التالية diff --git a/docs/src/content/docs/compatibility.mdx b/docs/src/content/docs/compatibility.mdx index c8890db..3f97e82 100644 --- a/docs/src/content/docs/compatibility.mdx +++ b/docs/src/content/docs/compatibility.mdx @@ -52,11 +52,40 @@ runtime change or a reverse proxy. That decision is deliberately deferred. ### Status -| Panel | Status | -|---|---| -| 3X-UI | **Supported** | -| PasarGuard | Research — not supported, no install instructions | -| Rebecca | Research — not supported, no install instructions | +What the installer can do on each panel today. The table is checked against the installer +by the test suite (`tests/panel-support.test.mjs`), so it cannot claim more than the code +does. + +| Panel | Detection | Install | Activation | Verification | Backup & rollback | Page shell | Status | +|---|---|---|---|---|---|---|---| +| 3X-UI | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **Supported** | +| PasarGuard | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | Research — not supported, no install instructions | +| Rebecca | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | Research — not supported, no install instructions | + +On a server with PasarGuard or Rebecca and no 3X-UI, the installer stops with +`no 3x-ui installation was detected on this host` and changes nothing. + +#### What exists for PasarGuard and Rebecca + +- **A page shell for every design**, in the panel's own template language (Jinja2 for + PasarGuard, pongo2 for Rebecca). Every release builds and ships them under `shells/`. + The installer does not place them. +- **Data adapters** that map each panel's subscription data onto the fields the page + uses, tested against sample responses written from each panel's source code — not + captured from a running panel. +- **Rendering tests** that fill each shell with that data. They use a small test renderer + that understands only the syntax the shells use; no shell has yet been rendered by a + real PasarGuard or Rebecca server. + +#### What is missing before either can be supported + +- An installer adapter: detecting the panel, its configuration and its service. +- Placing the shell where the panel loads templates, switching the panel to it, and + verifying and rolling back that change. +- Live status, which both panels serve on a path suffix (see above). +- The `on_hold` subscription state. Both panels have it, and the page has no way to show + it yet, so the adapters refuse it. +- A test on a real server of each panel. ## Next diff --git a/docs/src/content/docs/fa/compatibility.mdx b/docs/src/content/docs/fa/compatibility.mdx index 7bd00b3..e3a1bba 100644 --- a/docs/src/content/docs/fa/compatibility.mdx +++ b/docs/src/content/docs/fa/compatibility.mdx @@ -51,11 +51,40 @@ description: امروز چه چیزی در محیط عملیاتی پشتیبا ### وضعیت -| پنل | وضعیت | -|---|---| -| ۳X-UI | **پشتیبانی‌شده** | -| PasarGuard | پژوهش — پشتیبانی نمی‌شود، بدون دستور نصب | -| Rebecca | پژوهش — پشتیبانی نمی‌شود، بدون دستور نصب | +آنچه نصب‌کننده امروز روی هر پنل می‌تواند انجام دهد. این جدول با مجموعهٔ آزمون‌ها +(`tests/panel-support.test.mjs`) در برابر خود نصب‌کننده بررسی می‌شود، پس نمی‌تواند بیش از +آنچه کد انجام می‌دهد ادعا کند. + +| پنل | تشخیص | نصب | فعال‌سازی | بررسی | پشتیبان‌گیری و بازگردانی | پوستهٔ صفحه | وضعیت | +|---|---|---|---|---|---|---|---| +| ۳X-UI | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **پشتیبانی‌شده** | +| PasarGuard | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | پژوهش — پشتیبانی نمی‌شود، بدون دستور نصب | +| Rebecca | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | پژوهش — پشتیبانی نمی‌شود، بدون دستور نصب | + +روی سروری که PasarGuard یا Rebecca دارد و 3X-UI ندارد، نصب‌کننده با پیام +`no 3x-ui installation was detected on this host` متوقف می‌شود و چیزی را تغییر نمی‌دهد. + +#### آنچه برای PasarGuard و Rebecca وجود دارد + +- **پوستهٔ صفحه برای هر طرح**، به زبان قالب خود پنل (Jinja2 برای PasarGuard و pongo2 برای + Rebecca). هر نسخه آن‌ها را می‌سازد و زیر `shells/` بسته‌بندی می‌کند. نصب‌کننده آن‌ها را + جایگذاری نمی‌کند. +- **مبدل‌های داده** که داده‌های اشتراک هر پنل را به فیلدهای صفحه نگاشت می‌کنند و با + پاسخ‌های نمونه‌ای آزموده شده‌اند که از روی کد منبع هر پنل نوشته شده‌اند — نه از یک پنل در + حال اجرا. +- **آزمون‌های رندر** که هر پوسته را با این داده‌ها پر می‌کنند. این آزمون‌ها از یک رندرکنندهٔ + آزمایشی کوچک استفاده می‌کنند که فقط نحوی را که پوسته‌ها به کار می‌برند می‌فهمد؛ هنوز هیچ + پوسته‌ای روی یک سرور واقعی PasarGuard یا Rebecca رندر نشده است. + +#### آنچه پیش از پشتیبانی از هر کدام لازم است + +- یک آداپتور در نصب‌کننده: تشخیص پنل، پیکربندی و سرویس آن. +- قرار دادن پوسته جایی که پنل قالب‌ها را از آن می‌خواند، فعال کردن آن در پنل، و بررسی و + بازگردانی این تغییر. +- وضعیت زنده، که هر دو پنل آن را روی یک پسوند مسیر ارائه می‌دهند (بالا را ببینید). +- وضعیت اشتراک `on_hold`. هر دو پنل آن را دارند و صفحه هنوز راهی برای نمایش آن ندارد، + برای همین مبدل‌ها آن را رد می‌کنند. +- آزمون روی یک سرور واقعی از هر پنل. ## قدم بعدی diff --git a/tests/panel-support.test.mjs b/tests/panel-support.test.mjs new file mode 100644 index 0000000..ea75c05 --- /dev/null +++ b/tests/panel-support.test.mjs @@ -0,0 +1,217 @@ +/* What each panel's support status really is, and that the documentation says + * exactly that. + * + * The release packages a page shell for every panel in the registry (3X-UI, + * PasarGuard, Rebecca), and RT_PANEL_IDS names all three. Neither makes a panel + * supported. Support means the installer can find the panel, install onto it, + * activate, verify and roll back -- so the status is read from the installer + * itself, and every README and compatibility page is checked against it: + * + * - the panel registry (installer/panels/index.sh) is asked which panels have + * an implementation, and every panel operation is called for the others; + * - the install command is run on a host that has PasarGuard or Rebecca and no + * 3X-UI, with real detection, to show there is no other install path; + * - the capability matrix in docs/.../compatibility.mdx (English, Persian, + * Arabic) and the panel table in all five READMEs must match those results. + * + * A panel becomes "Supported" in the docs only when this file, unchanged, finds + * an implementation for it. Nothing here is satisfied by an adapter file merely + * existing. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, chmodSync, copyFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { buildablePanelIds } from '../tools/panels.mjs'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const read = (p) => readFileSync(join(ROOT, p), 'utf8'); + +function bash(body, env = {}) { + const r = spawnSync('bash', ['-c', 'set -Eeuo pipefail\n' + body], { + cwd: ROOT, encoding: 'utf8', env: { ...process.env, ...env }, + }); + if (r.error) throw r.error; + return { code: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() }; +} + +/* --- what the installer can do, per panel ----------------------------------- */ + +/* The operations a column of the matrix stands for. Each is a public panel + operation from installer/panels/interface.sh. */ +const VERBS = { + detection: ['detect'], + install: ['install_template', '/nonexistent/src'], + verification: ['verify', 'static'], + backup: ['backup_state'], + restore: ['restore_state', '/nonexistent/snap'], +}; + +/* Ask the installer. For every panel id: the implementation the registry + resolves to, and the return code of every operation above. */ +function installerMatrix() { + const lines = [ + 'export RT_ROOT="$(mktemp -d)/rt"; trap \'rm -rf "$(dirname "$RT_ROOT")"\' EXIT', + 'mkdir -p "$RT_ROOT"', + '. installer/lib/row-template.sh', + 'echo "ids=$RT_PANEL_IDS"', + 'for p in $RT_PANEL_IDS; do', + ' echo "impl-$p=$(rt_panel_impl_for "$p")"', + ]; + for (const [col, [verb, ...args]] of Object.entries(VERBS)) { + lines.push(` rc=0; rt_panel_${verb} "$p" ${args.join(' ')} >/dev/null 2>&1 || rc=$?; echo "${col}-$p=$rc"`); + } + lines.push('done'); + const r = bash(lines.join('\n')); + assert.equal(r.code, 0, r.err); + const kv = Object.fromEntries(r.out.split('\n').map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])); + const ids = kv.ids.split(' ').filter(Boolean); + return Object.fromEntries(ids.map((p) => [p, { + implemented: kv[`impl-${p}`] !== '', + rc: Object.fromEntries(Object.keys(VERBS).map((c) => [c, Number(kv[`${c}-${p}`])])), + }])); +} + +const MATRIX = installerMatrix(); +const INSTALLABLE = Object.keys(MATRIX).filter((p) => MATRIX[p].implemented); +const UNAVAILABLE = 2; + +test('the installer implements 3X-UI and no other panel', () => { + assert.deepEqual(Object.keys(MATRIX).sort(), ['3xui', 'pasarguard', 'rebecca'], 'the closed panel set'); + assert.deepEqual(INSTALLABLE, ['3xui'], 'only 3X-UI has an installer implementation'); + assert.deepEqual(buildablePanelIds().sort(), ['3xui', 'pasarguard', 'rebecca'], + 'while a page shell is still BUILT for all three -- which is not support'); +}); + +test('every installer operation on PasarGuard and Rebecca is UNAVAILABLE', () => { + for (const p of ['pasarguard', 'rebecca']) { + for (const [col, rc] of Object.entries(MATRIX[p].rc)) { + assert.equal(rc, UNAVAILABLE, `${p}: ${col} must be UNAVAILABLE (2), got ${rc}`); + } + } +}); + +/* A PasarGuard or Rebecca host: the panel's systemd unit is present, there is + no x-ui binary or unit. Detection runs for real, against a stand-in + systemctl on PATH. Skipped where a real 3X-UI binary is installed, since the + library looks for it at fixed system paths. */ +const HAS_REAL_XUI = ['/usr/local/x-ui/x-ui', '/usr/local/bin/x-ui'].some((p) => existsSync(p)); + +test('on a host with PasarGuard or Rebecca and no 3X-UI, install refuses and writes nothing', { skip: HAS_REAL_XUI }, () => { + for (const unit of ['pasarguard.service', 'rebecca.service']) { + const base = mkdtempSync(join(tmpdir(), 'row-panel-')); + try { + const bin = join(base, 'bin'); + mkdirSync(bin); + writeFileSync(join(bin, 'systemctl'), `#!/bin/sh\nprintf '%s\\n' '${unit} enabled enabled'\n`); + chmodSync(join(bin, 'systemctl'), 0o755); + const payload = join(base, 'payload'); + mkdirSync(join(payload, 'shells', 'pasarguard', 'row'), { recursive: true }); + mkdirSync(join(payload, 'shells', 'rebecca', 'row'), { recursive: true }); + copyFileSync(join(ROOT, 'template', 'index.html'), join(payload, 'template.html')); + writeFileSync(join(payload, 'VERSION'), read('VERSION')); + writeFileSync(join(payload, 'shells', 'pasarguard', 'row', 'shell.html'), '{{ user.username }}\n'); + writeFileSync(join(payload, 'shells', 'rebecca', 'row', 'shell.html'), '{{ user.username }}\n'); + + const r = bash([ + `export PATH="${bin}:$PATH" RT_ROOT="${base}/rt" RT_BIN="${base}/row-template"`, + '. installer/lib/row-template.sh', + 'rt_require_root(){ :; }', + `RT_ASSUME_YES=1 rt_cmd_install "${payload}" c.trim()); + if (cells.length !== width) continue; + const id = panelIdOf(cells[0]); + if (id) rows[id] = cells; + } + return rows; +} + +/* The capability matrix. Columns 1-5 are installer capabilities, 6 is the + page shell, 7 the status -- in every language. */ +const COMPAT = { + en: { file: 'docs/src/content/docs/compatibility.mdx', research: 'Research' }, + fa: { file: 'docs/src/content/docs/fa/compatibility.mdx', research: 'پژوهش' }, + ar: { file: 'docs/src/content/docs/ar/compatibility.mdx', research: 'بحث' }, +}; +const INSTALLER_COLUMNS = ['detection', 'install', 'activation', 'verification', 'backup']; + +for (const [lang, { file, research }] of Object.entries(COMPAT)) { + test(`the ${lang} compatibility matrix matches what the installer can do`, () => { + const rows = tableRows(read(file), 8); + assert.deepEqual(Object.keys(rows).sort(), Object.keys(MATRIX).sort(), `${file}: one matrix row per panel`); + for (const [p, cells] of Object.entries(rows)) { + const installable = INSTALLABLE.includes(p); + INSTALLER_COLUMNS.forEach((col, i) => { + assert.equal(cells[i + 1], installable ? '✅' : '❌', + `${file}: ${p} ${col} must be ${installable ? '✅' : '❌'} -- the installer ${installable ? 'implements' : 'does not implement'} it`); + }); + assert.equal(cells[6], buildablePanelIds().includes(p) ? '✅' : '❌', `${file}: ${p} page shell`); + if (installable) { + assert.ok(cells[7].startsWith('**'), `${file}: ${p} is marked supported`); + } else { + assert.equal(cells[7].includes('**'), false, `${file}: ${p} must not be marked supported`); + assert.ok(cells[7].includes(research), `${file}: ${p} is marked as research`); + } + } + }); +} + +const READMES = ['README.md', 'README.fa.md', 'README.ar.md', 'README.ru.md', 'README.zh-CN.md']; + +test('every README marks only installable panels as supported', () => { + for (const file of READMES) { + const rows = tableRows(read(file), 3); + assert.deepEqual(Object.keys(rows).sort(), Object.keys(MATRIX).sort(), `${file}: one row per panel`); + for (const [p, cells] of Object.entries(rows)) { + if (INSTALLABLE.includes(p)) { + assert.ok(cells[1].includes('✅'), `${file}: ${p} is supported`); + } else { + assert.equal(cells[1].includes('✅'), false, `${file}: ${p} must not be marked supported`); + assert.ok(cells[1].includes('🔬'), `${file}: ${p} is marked as research`); + } + } + } +}); + +test('the changelog never calls PasarGuard or Rebecca supported', () => { + const sentences = read('CHANGELOG.md').replace(/\n\s*/g, ' ').split(/(?<=[.!?])\s+/); + for (const s of sentences) { + if (!/PasarGuard|Rebecca/.test(s) || !/\bsupported\b/i.test(s)) continue; + assert.match(s, /\bnot (?:yet )?supported\b|\bunsupported\b|not supported panels/i, + `a changelog sentence names PasarGuard/Rebecca as supported: "${s}"`); + } +}); diff --git a/tools/make-release.sh b/tools/make-release.sh index e2022b9..0db6f87 100755 --- a/tools/make-release.sh +++ b/tools/make-release.sh @@ -20,7 +20,7 @@ # # against, so it must stay Row # templates//template.html # every selectable design of this # templates//template.html.sha256 # release, each with its checksum -# shells///shell.html # the assembled shell for each supported +# shells///shell.html # the assembled shell for each buildable # 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 From 926b17208420af68b09c2c1784c305ec1b627017 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 22:12:20 +0000 Subject: [PATCH 03/25] docs: release notes for 1.2.1 and verify's new behaviour 1.2.0 is already published with the upgrade defect, so the fix is prepared as 1.2.1: VERSION, a changelog entry (fixed, changed, known issues, upgrading), and 1.2.0's release date. The 1.2.0 note about needing two updates is kept as history and marked fixed. verify is no longer strictly read-only: as root it repairs the template store before checking it. Every README and docs page that called it read-only now says exactly what it may change, in each language. The configuration page and README describe the one-update path from 1.1.0, and troubleshooting (en, fa) covers the chooser's empty-store message and verify's new store messages. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BbSwnyeKjBZ2HoJddxdYBM --- CHANGELOG.md | 72 +++++++++++++++++++- README.ar.md | 3 +- README.fa.md | 3 +- README.md | 3 +- README.ru.md | 3 +- README.zh-CN.md | 3 +- VERSION | 2 +- docs/src/content/docs/ar/configuration.mdx | 12 +++- docs/src/content/docs/ar/getting-started.mdx | 2 +- docs/src/content/docs/ar/installation.mdx | 4 +- docs/src/content/docs/configuration.mdx | 14 +++- docs/src/content/docs/fa/configuration.mdx | 13 +++- docs/src/content/docs/fa/getting-started.mdx | 2 +- docs/src/content/docs/fa/installation.mdx | 5 +- docs/src/content/docs/fa/troubleshooting.mdx | 31 +++++++++ docs/src/content/docs/getting-started.mdx | 2 +- docs/src/content/docs/installation.mdx | 5 +- docs/src/content/docs/troubleshooting.mdx | 32 +++++++++ 18 files changed, 189 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 603bb4c..ad2daf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,71 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.2.0] - Unreleased +## [1.2.1] - Unreleased + +Fixes the update from 1.1.0, which could leave the manager with no designs to +choose from. 3X-UI (>= 3.6.0) stays the only supported panel. + +### Fixed + +- **One `row-template update` is enough to move from 1.1.0.** 1.1.0's own + updater installs the new version but copies only four files, so in 1.2.0 the + designs were missing until a second update, and **Reconfigure branding → + Template** said "No templates are installed". Now the first time you open + `row-template`, or run `row-template config` or `row-template verify` as + root, after the update, it downloads the rest of the same release — every + design and the remaining installer files, checksum-verified — before doing + anything else. It downloads the version you have installed, never a newer + one, and changes nothing else: the live page, branding, selected design and + backups stay as they are. If the release cannot be reached, it says so and + tries again the next time the manager opens. +- **Designs found outside their folder are moved back.** The designs belong in + `dist/templates/`. A copy at the install root's `templates/` — where a copied + or extracted release leaves it — is now moved into place automatically by + `install`, `update` and `verify`. Each design is checked against its own + checksum first; one that fails is reported and left where it is, and files + Row-Template does not recognise are never removed. +- **Changing branding works on an install the 1.1.0 updater left incomplete.** + `row-template config` and the manager's branding editors refused with "the + template selection could not be reconciled" until a second update; they now + complete the install first. +- **`row-template verify` names missing and damaged designs.** A design that + fails its checksum is reported by name as a failure; missing designs are a + warning that names them. It previously reported a failing store without + saying which design, and did not report missing ones at all. + +### Changed + +- `row-template verify` is no longer strictly read-only. Run as root, it first + repairs the template store — moving misplaced designs back into place and + downloading any the installed version is missing, from that same release — + and then checks it. It makes no other change, and none at all when run + without root. + +### Documentation + +- The compatibility page lists, per panel, what the installer can do today: + detection, install, activation, verification, and backup and rollback. For + PasarGuard and Rebecca the answer is none of them — only the page shells are + built and packaged — so both stay **research targets, not supported panels**. + A test checks every README and compatibility page against the installer. + +### Known issues + +- Rolling back from 1.2.x to a backup taken under 1.1.0 fails with "backup + artifact matches no installed template": 1.1.0's page is not one of the + current release's designs. The rollback stops before changing anything, so + the running page stays as it was. Rolling back to a backup taken under 1.2.x + is not affected. + +### Upgrading + +- From **1.1.0**: run `row-template update`. The next `row-template`, + `row-template config` or `row-template verify` completes the install. +- From **1.2.0**: run `row-template update`. This also completes a 1.2.0 + install that the 1.1.0 updater left without its designs. + +## [1.2.0] - 2026-09-24 Turns Row-Template from one page into a collection of designs. A minor release: Row stays the default design, and 3X-UI (>= 3.6.0) stays the only supported @@ -84,7 +148,8 @@ panel. page updates and your branding is kept — but copies only the library and the command, so only Row is available. The second, carried out by 1.2.0, installs every design and the remaining installer files. `row-template - verify` reports whether the second run is still needed. + verify` reports whether the second run is still needed. (Fixed in 1.2.1, + which needs one run.) ## [1.1.0] - 2026-08-30 @@ -162,6 +227,7 @@ First stable release. - Requires 3X-UI (MHSanaei) **>= 3.6.0**; validated against stock 3.7.0. - Recommended operating system: Ubuntu 24.04 LTS (x86_64). -[1.2.0]: https://github.com/iitzSeriZdev/Row-Template/compare/v1.1.0...main +[1.2.1]: https://github.com/iitzSeriZdev/Row-Template/compare/v1.2.0...main +[1.2.0]: https://github.com/iitzSeriZdev/Row-Template/releases/tag/v1.2.0 [1.1.0]: https://github.com/iitzSeriZdev/Row-Template/releases/tag/v1.1.0 [1.0.0]: https://github.com/iitzSeriZdev/Row-Template/releases/tag/v1.0.0 diff --git a/README.ar.md b/README.ar.md index 6726f26..fa2bb66 100644 --- a/README.ar.md +++ b/README.ar.md @@ -202,7 +202,7 @@ row-template | `row-template config` | تغيير اسم الخدمة أو رابط الدعم أو الشعار، ثم إعادة توليد الصفحة | | `row-template update` | تنزيل إصدار مستقر أحدث والتحقق منه وتفعيله (التحقق من المجموع الاختباري إلزامي) | | `row-template rollback` | استعادة إصدار سابق (`--auto` أو `--to `) | -| `row-template verify` | فحص التثبيت وربط اللوحة والصفحة الحالية (للقراءة فقط) | +| `row-template verify` | فحص التثبيت وربط اللوحة والصفحة الحالية (وبصلاحيات root يعيد أيضًا التصاميم المفقودة أو الموضوعة في غير مكانها) | | `row-template version` | عرض الإصدار المثبّت والحد الأدنى المدعوم وإصدار 3X-UI المكتشف | | `row-template uninstall` | إزالة Row-Template وإعادة اللوحة إلى صفحتها المدمجة | | `row-template help` | عرض طريقة الاستخدام | @@ -211,6 +211,7 @@ row-template - **العلامة التجارية** تُخزَّن كبيانات، ولا تُنفَّذ أبدًا، وتُحقن في الصفحة كنص. اترك أي حقل فارغًا للحصول على صفحة بلا علامة تجارية. لا يقبل رابط الدعم إلا البروتوكولات التي ينبغي للمتصفح فتحها، مثل `https://…` أو `tg://…` أو `mailto:…`. - **التحديثات** تتحقق من قناة الإصدارات المستقرة العامة ولا تغيّر شيئًا ما لم يوجد إصدار مستقر أحدث. إذا تعذّر الوصول إلى مصدر الإصدارات، يُبلغ `update` بأنه لم يتمكن من التحقق؛ ولا يُعامَل تثبيتك أبدًا على أنه تالف. +- **التحديث من 1.1.0** يكفيه تشغيل `row-template update` مرة واحدة. ينسخ مُحدِّث 1.1.0 نفسه جزءًا فقط من الإصدار الجديد، لذا فإن التشغيل التالي لـ`row-template` أو `row-template config` أو `row-template verify` بصلاحيات root ينزّل أولًا بقية الإصدار نفسه — كل التصاميم، مع التحقق من checksum. - **التراجع** يستعيد إصدارًا سابقًا من نسخة احتياطية جرى التحقق منها. تُلتقط لقطة (snapshot) للإصدار الحالي أولًا، بحيث يمكن التعافي من تراجع فاشل، وتُحفَظ علامتك التجارية. - **إلغاء التثبيت** يزيل ملفات Row-Template. ولا يمسح `subThemeDir` في اللوحة إلا إذا كان يشير إلى Row-Template، فتعود اللوحة إلى صفحتها المدمجة؛ ولا يمسّ الواردات (inbounds) أو العملاء أو الشهادات. diff --git a/README.fa.md b/README.fa.md index 3728d92..86065a3 100644 --- a/README.fa.md +++ b/README.fa.md @@ -203,7 +203,7 @@ row-template | `row-template config` | تغییر نام سرویس، پیوند پشتیبانی یا لوگو و سپس بازسازی صفحه | | `row-template update` | دانلود، بررسی و فعال سازی یک نسخهٔ پایدار جدیدتر (بررسی مجموع کنترلی الزامی) | | `row-template rollback` | بازگردانی یک نسخهٔ پیشین (`--auto` یا `--to `) | -| `row-template verify` | بررسی نصب، اتصال به پنل و صفحهٔ فعال (فقط خواندنی) | +| `row-template verify` | بررسی نصب، اتصال به پنل و صفحهٔ فعال (با دسترسی root، طرح های گم شده یا جابه جا شده را هم به جای خود برمی گرداند) | | `row-template version` | نمایش نسخهٔ نصب شده، حداقل نسخهٔ پشتیبانی شده و نسخهٔ شناسایی شدهٔ 3X-UI | | `row-template uninstall` | حذف Row-Template و بازگرداندن پنل به صفحهٔ داخلی خودش | | `row-template help` | نمایش راهنمای استفاده | @@ -212,6 +212,7 @@ row-template - **برندسازی** به عنوان داده ذخیره می شود، هرگز اجرا نمی شود و به صورت متن در صفحه تزریق می گردد. برای یک صفحهٔ بدون برند، فیلدی را خالی بگذارید. پیوند پشتیبانی تنها پروتکل هایی را می پذیرد که مرورگر باید باز کند، مانند `https://…`، `tg://…` یا `mailto:…`. - **به روزرسانی ها** کانال عمومی نسخه های پایدار را بررسی می کنند و تا وقتی نسخهٔ پایدار جدیدتری وجود نداشته باشد چیزی را تغییر نمی دهند. اگر منبع انتشار در دسترس نباشد، `update` گزارش می دهد که نتوانسته بررسی کند؛ نصب شما هرگز آسیب دیده تلقی نمی شود. +- **به روزرسانی از 1.1.0** با یک بار اجرای `row-template update` انجام می شود. به روزرسان خود 1.1.0 فقط بخشی از نسخهٔ جدید را کپی می کند، برای همین اجرای بعدی `row-template`، `row-template config` یا `row-template verify` با دسترسی root، ابتدا بقیهٔ همان نسخه را دریافت می کند — همهٔ طرح ها، با بررسی checksum. - **بازگردانی** یک نسخهٔ پیشین را از یک پشتیبان اعتبارسنجی شده بازیابی می کند. ابتدا از نسخهٔ فعلی یک عکس فوری (snapshot) گرفته می شود تا یک بازگردانی ناموفق قابل جبران باشد، و برندسازی شما حفظ می شود. - **حذف نصب** فایل های Row-Template را حذف می کند. `subThemeDir` پنل را تنها در صورتی پاک می کند که به Row-Template اشاره کند، تا پنل به صفحهٔ داخلی خود بازگردد؛ به inboundها، کلاینت ها و گواهی های شما دست زده نمی شود. diff --git a/README.md b/README.md index a115b4a..b5ea93e 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ Or use a command directly: | `row-template config` | Change the service name, support link, or logo, then regenerate the page | | `row-template update` | Download, verify, and activate a newer stable release (checksum enforced) | | `row-template rollback` | Restore a previous version (`--auto` or `--to `) | -| `row-template verify` | Check the install, the panel wiring, and the live page (read-only) | +| `row-template verify` | Check the install, the panel wiring, and the live page (as root, it also puts back missing or misplaced designs) | | `row-template version` | Show the installed, minimum-supported, and detected 3X-UI versions | | `row-template uninstall` | Remove Row-Template and revert the panel to its built-in page | | `row-template help` | Show usage | @@ -212,6 +212,7 @@ Commands that change the system (`config`, `update`, `rollback`, `uninstall`) mu - **Branding** is stored as data, never executed, and injected into the page as text. Leave a field blank for an unbranded page. The support link accepts only schemes a browser should open, such as `https://…`, `tg://…`, or `mailto:…`. - **Updates** check the public stable channel and change nothing unless a newer stable version exists. If the release source is unreachable, `update` reports that it could not check; your installation is never treated as damaged. +- **Updating from 1.1.0** takes one `row-template update`. 1.1.0's own updater copies only part of the new release, so the next `row-template`, `row-template config`, or `row-template verify` run as root first downloads the rest of that same release — every design, checksum-verified. - **Rollback** restores a previous version from a validated backup. The current version is snapshotted first, so a failed rollback can be recovered, and your branding is preserved. - **Uninstall** removes Row-Template's files. It clears the panel's `subThemeDir` only if it points at Row-Template, so the panel falls back to its built-in page; your inbounds, clients, and certificates are not touched. diff --git a/README.ru.md b/README.ru.md index f2a1e6e..c4419d7 100644 --- a/README.ru.md +++ b/README.ru.md @@ -203,7 +203,7 @@ row-template | `row-template config` | Меняет название сервиса, ссылку на поддержку или логотип и заново создаёт страницу | | `row-template update` | Скачивает, проверяет и активирует новый стабильный релиз (проверка контрольной суммы обязательна) | | `row-template rollback` | Восстанавливает предыдущую версию (`--auto` или `--to `) | -| `row-template verify` | Проверяет установку, связь с панелью и работающую страницу (только чтение) | +| `row-template verify` | Проверяет установку, связь с панелью и работающую страницу (от root также возвращает на место отсутствующие или перемещённые дизайны) | | `row-template version` | Показывает установленную, минимально поддерживаемую и обнаруженную версии 3X-UI | | `row-template uninstall` | Удаляет Row-Template и возвращает панели встроенную страницу | | `row-template help` | Показывает справку | @@ -212,6 +212,7 @@ row-template - **Оформление** хранится как данные, никогда не выполняется и вставляется в страницу как текст. Оставьте поле пустым, чтобы получить страницу без брендинга. Ссылка на поддержку принимает только схемы, которые браузер должен открывать, например `https://…`, `tg://…` или `mailto:…`. - **Обновления** проверяют публичный стабильный канал и ничего не меняют, если более новой стабильной версии нет. Если источник релизов недоступен, `update` сообщает, что не смог проверить; установка при этом никогда не считается повреждённой. +- **Обновление с 1.1.0** выполняется одним запуском `row-template update`. Механизм обновления самой версии 1.1.0 копирует лишь часть нового релиза, поэтому следующий запуск `row-template`, `row-template config` или `row-template verify` от root сначала загружает остальную часть того же релиза — все дизайны, с проверкой контрольных сумм. - **Откат** восстанавливает предыдущую версию из проверенной резервной копии. Сначала делается снимок (snapshot) текущей версии, поэтому неудачный откат можно исправить, а ваше оформление сохраняется. - **Удаление** стирает файлы Row-Template. Настройку `subThemeDir` панели оно очищает, только если та указывает на Row-Template, и панель возвращается к встроенной странице; ваши inbound'ы, клиенты и сертификаты не затрагиваются. diff --git a/README.zh-CN.md b/README.zh-CN.md index 4c532c2..cc75ed4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -202,7 +202,7 @@ row-template | `row-template config` | 更改服务名称、支持链接或徽标,然后重新生成页面 | | `row-template update` | 下载、校验并激活更新的稳定版本(强制校验校验和) | | `row-template rollback` | 恢复到之前的版本(`--auto` 或 `--to `) | -| `row-template verify` | 检查安装、面板连接和当前页面(只读) | +| `row-template verify` | 检查安装、面板连接和当前页面(以 root 运行时还会补回缺失或放错位置的设计) | | `row-template version` | 显示已安装版本、最低支持版本以及检测到的 3X-UI 版本 | | `row-template uninstall` | 移除 Row-Template 并让面板恢复其内置页面 | | `row-template help` | 显示用法 | @@ -211,6 +211,7 @@ row-template - **品牌信息**以数据形式存储,从不执行,并以文本形式注入页面。将某个字段留空即可得到无品牌的页面。支持链接只接受浏览器应当打开的协议,例如 `https://…`、`tg://…` 或 `mailto:…`。 - **更新**会检查公共稳定通道,只有存在更新的稳定版本时才会做出更改。如果无法访问发布源,`update` 会报告无法检查;你的安装绝不会因此被视为已损坏。 +- **从 1.1.0 更新**只需运行一次 `row-template update`。1.1.0 自带的更新程序只会复制新版本的一部分,因此下一次以 root 运行 `row-template`、`row-template config` 或 `row-template verify` 时,会先下载同一版本的其余部分——所有设计,并校验 checksum。 - **回滚**会从经过验证的备份中恢复之前的版本。系统会先为当前版本创建快照(snapshot),因此失败的回滚也可以恢复,且你的品牌配置会被保留。 - **卸载**会移除 Row-Template 的文件。只有当面板的 `subThemeDir` 指向 Row-Template 时才会将其清除,使面板恢复内置页面;你的入站(inbound)、客户端和证书都不会受到影响。 diff --git a/VERSION b/VERSION index 26aaba0..6085e94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.0 +1.2.1 diff --git a/docs/src/content/docs/ar/configuration.mdx b/docs/src/content/docs/ar/configuration.mdx index fd6daef..2f3f92d 100644 --- a/docs/src/content/docs/ar/configuration.mdx +++ b/docs/src/content/docs/ar/configuration.mdx @@ -17,7 +17,7 @@ row-template | ‎`row-template config`‎ | تغيير اسم الخدمة أو رابط الدعم أو الشعار، ثم إعادة إنشاء الصفحة | نعم — root | | ‎`row-template update`‎ | تنزيل إصدار أحدث والتحقق منه وتفعيله (checksum إلزامي) | نعم — root | | ‎`row-template rollback`‎ | استعادة إصدار سابق (‎`--auto`‎ أو ‎`--to `‎) | نعم — root | -| ‎`row-template verify`‎ | فحص التثبيت وربط اللوحة والعرض الحيّ | **لا** — قراءة فقط | +| ‎`row-template verify`‎ | فحص التثبيت وربط اللوحة والعرض الحيّ | **لا** — بصلاحيات ‎root‎ يصلح مخزن التصاميم فقط | | ‎`row-template version`‎ | عرض الإصدار المثبَّت والحد الأدنى المدعوم وإصدار ‎3X-UI‎ المكتشف | **لا** — قراءة فقط | | ‎`row-template uninstall`‎ | إزالة رو-تمبلت وإعادة اللوحة إلى صفحتها المدمجة | نعم — root | | ‎`row-template menu`‎ | فتح المدير التفاعلي صراحةً | — | @@ -43,7 +43,9 @@ row-template version row-template verify ``` -يفحص الملف المثبَّت وربط اللوحة والعرض الحيّ. قراءة فقط، لذا هو آمن دائمًا. +يفحص الملف المثبَّت وربط اللوحة والعرض الحيّ، وتشغيله آمن دائمًا. وبصلاحيات ‎root‎ يصلح +أيضًا مخزن التصاميم — يعيد التصاميم الموجودة خارج ‎`dist/templates`‎ إلى مكانها وينزّل ما +ينقص الإصدار المثبَّت منها — ولا يغيّر شيئًا غير ذلك. ## التحديث @@ -57,6 +59,12 @@ row-template update **تُحفظ تهيئة هويتك عبر التحديثات.** +**التحديث من ‎1.1.0‎ يكفيه تشغيل ‎`row-template update`‎ مرة واحدة.** ينسخ مُحدِّث ‎1.1.0‎ +نفسه جزءًا فقط من الإصدار الجديد. وفي المرة التالية التي تفتح فيها ‎`row-template`‎، أو تشغّل +‎`row-template config`‎ أو ‎`row-template verify`‎ بصلاحيات ‎root‎، تُنزَّل بقية الإصدار +نفسه — كل التصاميم، مع التحقق من ‎checksum‎ — قبل أي شيء آخر. ولا يُنزَّل إصدار أحدث أبدًا، +وتبقى صفحتك وهويتك والتصميم المختار ونسخك الاحتياطية كما هي. + ## الاستعادة ```bash diff --git a/docs/src/content/docs/ar/getting-started.mdx b/docs/src/content/docs/ar/getting-started.mdx index 2eda35c..3e07237 100644 --- a/docs/src/content/docs/ar/getting-started.mdx +++ b/docs/src/content/docs/ar/getting-started.mdx @@ -27,7 +27,7 @@ description: ما هو رو-تمبلت، وما يحتاجه، وما يحدث |---|---| | **‎3X-UI‎** | **الإصدار ‎3.6.0‎ أو أحدث.** يرفض المثبّت المتابعة تحت ذلك ويخبرك بالإصدار المكتشف. | | **خادم لينكس** | أي توزيعة تحتوي على ‎`bash`‎ و‎`coreutils`‎ و‎`curl`‎ و‎`tar`‎ و‎`sha256sum`‎. | -| **صلاحيات root** | للأوامر التي تغيّر النظام: ‎`config`‎ و‎`update`‎ و‎`rollback`‎ و‎`uninstall`‎. أما ‎`verify`‎ و‎`version`‎ فقراءة فقط. | +| **صلاحيات root** | للأوامر التي تغيّر النظام: ‎`config`‎ و‎`update`‎ و‎`rollback`‎ و‎`uninstall`‎. أما ‎`verify`‎ و‎`version`‎ فيعملان من دونها. | لا تحتاج إلى Node.js‎ أو Python أو قاعدة بيانات أو أي بيئة تشغيل على الخادم. diff --git a/docs/src/content/docs/ar/installation.mdx b/docs/src/content/docs/ar/installation.mdx index 424c75d..ffe59cc 100644 --- a/docs/src/content/docs/ar/installation.mdx +++ b/docs/src/content/docs/ar/installation.mdx @@ -55,7 +55,9 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d row-template verify ``` -هذا الأمر للقراءة فقط. يفحص الملف المثبَّت وربط اللوحة والعرض الحيّ. +يفحص الملف المثبَّت وربط اللوحة والعرض الحيّ. وإذا شُغّل بصلاحيات ‎root‎ يصلح أولًا مخزن +التصاميم: تُعاد التصاميم الموجودة خارج ‎`dist/templates`‎ إلى مكانها، وتُنزَّل من الإصدار نفسه +التصاميمُ التي يتضمنها الإصدار المثبَّت وتنقص الخادم. ولا يغيّر شيئًا غير ذلك. **المتوقع:** كل الفحوص بحالة OK. وإن فشل فحص، يذكر ما فشل وبأي قيمة وجدها. diff --git a/docs/src/content/docs/configuration.mdx b/docs/src/content/docs/configuration.mdx index 175a2e9..3770ddd 100644 --- a/docs/src/content/docs/configuration.mdx +++ b/docs/src/content/docs/configuration.mdx @@ -17,7 +17,7 @@ row-template | `row-template config` | Change the service name, support URL or logo, then regenerate | Yes — root | | `row-template update` | Download, verify and activate a newer release (checksum enforced) | Yes — root | | `row-template rollback` | Restore a previous version (`--auto` or `--to `) | Yes — root | -| `row-template verify` | Check the install, panel wiring and live render | **No** — read-only | +| `row-template verify` | Check the install, panel wiring and live render | **No** — as root it only repairs the template store | | `row-template version` | Show installed, minimum-supported and detected 3X-UI versions | **No** — read-only | | `row-template uninstall` | Remove Row-Template and revert the panel to its built-in page | Yes — root | | `row-template menu` | Open the interactive manager explicitly | — | @@ -44,8 +44,10 @@ detected on the server. Useful before reporting a problem. row-template verify ``` -Checks the installed artifact, the panel wiring and the live render. Read-only, so it is -always safe to run. +Checks the installed artifact, the panel wiring and the live render, and is always safe to +run. As root it also repairs the template store — it moves designs left outside +`dist/templates` back into place and downloads any the installed version is missing — and +changes nothing else. ## Updating @@ -60,6 +62,12 @@ damaged. **Your branding configuration is preserved across updates.** +**Updating from 1.1.0 takes one `row-template update`.** 1.1.0's own updater copies only part +of the new release. The next time you open `row-template`, or run `row-template config` or +`row-template verify` as root, it downloads the rest of that same release — every design, +checksum-verified — before doing anything else. It never downloads a newer version, and it +leaves your page, branding, selected design and backups as they are. + ## Rolling back ```bash diff --git a/docs/src/content/docs/fa/configuration.mdx b/docs/src/content/docs/fa/configuration.mdx index 14a8073..cb2cde7 100644 --- a/docs/src/content/docs/fa/configuration.mdx +++ b/docs/src/content/docs/fa/configuration.mdx @@ -17,7 +17,7 @@ row-template | `row-template config` | تغییر نام سرویس، آدرس پشتیبانی یا لوگو و ساخت دوبارهٔ صفحه | بله — root | | `row-template update` | دانلود، بررسی و فعال‌سازی نسخهٔ جدیدتر (با الزام checksum) | بله — root | | `row-template rollback` | بازگردانی نسخهٔ پیشین (`--auto` یا `--to `) | بله — root | -| `row-template verify` | بررسی نصب، اتصال پنل و رندر زنده | **نه** — فقط خواندنی | +| `row-template verify` | بررسی نصب، اتصال پنل و رندر زنده | **نه** — با root فقط مخزن طرح‌ها را ترمیم می‌کند | | `row-template version` | نمایش نسخهٔ نصب‌شده، حداقل نسخهٔ پشتیبانی‌شده و نسخهٔ ۳X-UI شناسایی‌شده | **نه** — فقط خواندنی | | `row-template uninstall` | حذف رو-تمپلیت و بازگرداندن پنل به صفحهٔ داخلی | بله — root | | `row-template menu` | باز کردن صریح مدیر تعاملی | — | @@ -44,7 +44,10 @@ row-template version row-template verify ``` -فایل نصب‌شده، اتصال پنل و رندر زنده را بررسی می‌کند. فقط خواندنی است، پس همیشه بی‌خطر است. +فایل نصب‌شده، اتصال پنل و رندر زنده را بررسی می‌کند و اجرای آن همیشه بی‌خطر است. با دسترسی +root مخزن طرح‌ها را هم ترمیم می‌کند — طرح‌هایی را که بیرون از `dist/templates` مانده‌اند به جای +خود برمی‌گرداند و طرح‌هایی را که نسخهٔ نصب‌شده کم دارد دریافت می‌کند — و جز این چیزی را +تغییر نمی‌دهد. ## به‌روزرسانی @@ -58,6 +61,12 @@ row-template update **تنظیمات برند شما در به‌روزرسانی حفظ می‌شود.** +**به‌روزرسانی از 1.1.0 با یک بار `row-template update` انجام می‌شود.** به‌روزرسان خود 1.1.0 +فقط بخشی از نسخهٔ جدید را کپی می‌کند. دفعهٔ بعد که `row-template` را باز کنید، یا +`row-template config` یا `row-template verify` را با دسترسی root اجرا کنید، بقیهٔ همان نسخه — +همهٔ طرح‌ها، با بررسی checksum — پیش از هر کار دیگری دریافت می‌شود. هرگز نسخهٔ جدیدتری +دریافت نمی‌شود و صفحه، برند، طرح انتخاب‌شده و پشتیبان‌های شما همان‌طور که هستند می‌مانند. + ## بازگردانی ```bash diff --git a/docs/src/content/docs/fa/getting-started.mdx b/docs/src/content/docs/fa/getting-started.mdx index 1b42620..f6dcb33 100644 --- a/docs/src/content/docs/fa/getting-started.mdx +++ b/docs/src/content/docs/fa/getting-started.mdx @@ -28,7 +28,7 @@ description: رو-تمپلیت چیست، چه چیزی لازم دارد، و |---|---| | **۳X-UI** | **نسخهٔ ۳.۶.۰ یا بالاتر.** نصب‌کننده پایین‌تر از این را نمی‌پذیرد و نسخهٔ تشخیص‌داده‌شده را به شما می‌گوید. | | **سرور لینوکس** | هر توزیعی که `bash`، `coreutils`، `curl`، `tar` و `sha256sum` داشته باشد. | -| **دسترسی root** | برای دستورهایی که سیستم را تغییر می‌دهند: `config`، `update`، `rollback` و `uninstall`. دو دستور `verify` و `version` فقط خواندنی‌اند. | +| **دسترسی root** | برای دستورهایی که سیستم را تغییر می‌دهند: `config`، `update`، `rollback` و `uninstall`. دو دستور `verify` و `version` بدون آن هم اجرا می‌شوند. | به Node.js، پایتون، پایگاه‌داده یا هر زمان اجرای دیگری روی سرور نیازی **نیست**. diff --git a/docs/src/content/docs/fa/installation.mdx b/docs/src/content/docs/fa/installation.mdx index a30dfcb..8ed4e87 100644 --- a/docs/src/content/docs/fa/installation.mdx +++ b/docs/src/content/docs/fa/installation.mdx @@ -56,7 +56,10 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d row-template verify ``` -این دستور فقط خواندنی است. فایل نصب‌شده، اتصال پنل و رندر زنده را بررسی می‌کند. +فایل نصب‌شده، اتصال پنل و رندر زنده را بررسی می‌کند. اگر با دسترسی root اجرا شود، ابتدا +مخزن طرح‌ها را درست می‌کند: طرح‌هایی که بیرون از `dist/templates` مانده‌اند به جای خود +برمی‌گردند، و طرح‌هایی که نسخهٔ نصب‌شده دارد ولی روی سرور نیستند از همان نسخه دریافت +می‌شوند. جز این چیزی را تغییر نمی‌دهد. **انتظار:** همهٔ بررسی‌ها OK باشند. اگر بررسی‌ای شکست بخورد، می‌گوید چه چیزی و با چه مقداری شکست خورده، و پیام‌های خود نصب‌کننده می‌گویند بعد چه دستوری را اجرا کنید. diff --git a/docs/src/content/docs/fa/troubleshooting.mdx b/docs/src/content/docs/fa/troubleshooting.mdx index 39b5927..8d6dcf2 100644 --- a/docs/src/content/docs/fa/troubleshooting.mdx +++ b/docs/src/content/docs/fa/troubleshooting.mdx @@ -101,6 +101,37 @@ description: مشکلات شناخته‌شده، معنی‌شان، و راه ## به‌روزرسانی و بازگردانی +### `No templates are installed` (Reconfigure branding → Template) + +**معنی.** مدیر هیچ طرحی در مخزن طرح‌ها، یعنی `dist/templates/` زیر ریشهٔ نصب، پیدا نکرد. + +**علت.** به‌روزرسانی از 1.1.0: به‌روزرسان خود 1.1.0 فقط بخشی از نسخهٔ جدید را کپی می‌کند. از +1.2.1 به بعد مدیر هنگام باز شدن طرح‌های گم‌شده را خودش دریافت می‌کند، پس این پیام فقط وقتی +دیده می‌شود که آن دریافت ناموفق بوده است؛ پیام بالای آن علتش را می‌گوید. + +**راه حل.** مطمئن شوید سرور به منبع انتشار دسترسی دارد، سپس منو را دوباره باز کنید یا +`row-template update` را اجرا کنید. + +--- + +### `template store is incomplete ( of designs); missing: …` + +**معنی.** `row-template verify` طرح‌هایی را در مخزن پیدا نکرد. همهٔ طرح‌های نصب‌شده همچنان کار +می‌کنند؛ طرح‌های گم‌شده در مدیر پیشنهاد نمی‌شوند. + +**راه حل.** `row-template verify` را با دسترسی root و در دسترس بودن منبع انتشار اجرا کنید، یا +`row-template update` را اجرا کنید. + +--- + +### `Template store: moved design(s) from …/templates to …/dist/templates` + +**معنی.** طرح‌ها در پوشهٔ `templates/` در ریشهٔ نصب پیدا شدند — جایی که یک نسخهٔ کپی‌شده یا +بازشده آن‌ها را می‌گذارد — نه در `dist/templates/`. هر کدام checksum خود را گذراند و به جای خود +منتقل شد. کاری لازم نیست. + +--- + ### `refusing to roll back from a path outside the backups tree` **معنی.** بازگردانی خواسته شده از پوشه‌ای انجام شود که پشتیبان رو-تمپلیت نیست. diff --git a/docs/src/content/docs/getting-started.mdx b/docs/src/content/docs/getting-started.mdx index 5dbe598..4216904 100644 --- a/docs/src/content/docs/getting-started.mdx +++ b/docs/src/content/docs/getting-started.mdx @@ -29,7 +29,7 @@ only changes what the subscriber sees. |---|---| | **3X-UI** | **3.6.0 or newer.** The installer refuses to proceed below this and tells you the detected version. | | **A Linux server** | Any distribution with `bash`, `coreutils`, `curl`, `tar` and `sha256sum`. These are present on virtually every Linux system. | -| **Root access** | Required for the commands that change the system: `config`, `update`, `rollback`, `uninstall`. `verify` and `version` are read-only. | +| **Root access** | Required for the commands that change the system: `config`, `update`, `rollback`, `uninstall`. `verify` and `version` run without it. | You do **not** need Node.js, Python, a database, or any runtime on the server. diff --git a/docs/src/content/docs/installation.mdx b/docs/src/content/docs/installation.mdx index be1f686..346180a 100644 --- a/docs/src/content/docs/installation.mdx +++ b/docs/src/content/docs/installation.mdx @@ -59,7 +59,10 @@ panel's built-in one. row-template verify ``` -This is read-only. It checks the installed artifact, the panel wiring and the live render. +It checks the installed artifact, the panel wiring and the live render. Run as root, it first +puts the template store right: designs left outside `dist/templates` are moved back, and +designs the installed version ships but the server lacks are downloaded from that same +release. It changes nothing else. **Expected:** every check reports OK. If a check fails it names what failed and what it found, and the installer's own messages tell you which command to run next. diff --git a/docs/src/content/docs/troubleshooting.mdx b/docs/src/content/docs/troubleshooting.mdx index deecb6b..c1b204a 100644 --- a/docs/src/content/docs/troubleshooting.mdx +++ b/docs/src/content/docs/troubleshooting.mdx @@ -120,6 +120,38 @@ revert the change. **A lock update is a reviewed event, not a formality.** ## Update and rollback +### `No templates are installed` (Reconfigure branding → Template) + +**What it means.** The manager found no designs in the template store, `dist/templates/` +under the install root. + +**Causes.** An update from 1.1.0: 1.1.0's own updater copies only part of the new release. +Since 1.2.1 the manager downloads the missing designs itself when it opens, so this appears +only when that download failed; the message above it says why. + +**Fix.** Make sure the server can reach the release source, then open the menu again, or run +`row-template update`. + +--- + +### `template store is incomplete ( of designs); missing: …` + +**What it means.** `row-template verify` found designs missing from the store. Every +installed design still works; the missing ones are not offered in the manager. + +**Fix.** Run `row-template verify` as root with the release source reachable, or run +`row-template update`. + +--- + +### `Template store: moved design(s) from …/templates to …/dist/templates` + +**What it means.** Designs were found in a `templates/` folder at the install root — where a +copied or extracted release leaves them — instead of in `dist/templates/`. Each one passed +its checksum and was moved into place. There is nothing to do. + +--- + ### `refusing to roll back from a path outside the backups tree` **What it means.** The rollback was asked to restore from a directory that is not a From 114bdcd91c909a98bc955a6b807ce4e9060975a3 Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Fri, 25 Sep 2026 16:48:35 +0330 Subject: [PATCH 04/25] fix(rollback): restore backups the template store cannot identify A backup written by 1.1.0 has no template= line in its meta, and its artifact may match no design in the current store (the store was re-staged, or the design has since changed). rt_restore_from_backup refused such a backup outright, which blocked `row-template rollback` on a host freshly updated from 1.1.0. Resolution order is now: checksum match against the store, then the template recorded in the backup's meta when this release still offers it, then Row. The restored artifact and the persisted selection still always agree; an unknown recorded id falls back to Row with a warning. Co-Authored-By: Claude Opus 5.5 --- installer/lib/row-template.sh | 14 ++++++++--- tests/installer.test.mjs | 44 ++++++++++++++++++++++++++++------- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/installer/lib/row-template.sh b/installer/lib/row-template.sh index 4a03e0f..2efc512 100644 --- a/installer/lib/row-template.sh +++ b/installer/lib/row-template.sh @@ -2360,13 +2360,21 @@ rt_restore_from_backup() { # the TEMPLATE selection, however, is re-derived from the artifact itself # (checksum match against the template store) and persisted, so the restored # artifact and the stored selection always agree — including when the backup - # predates the current release's store. + # predates the current release's store. If the checksum match fails (the backup + # artifact is not byte-identical to any installed template), fall back to the + # template recorded in the backup's meta, then to Row as a last resort. local dir="$1" tpl_id rt_backup_validate "$dir" || { rt_err "backup failed validation: $dir"; return 1; } tpl_id="$(rt_template_id_for_artifact "$dir/template.html")" if [ -z "$tpl_id" ]; then - rt_err "backup artifact matches no installed template; the template store may be damaged" - return 1 + tpl_id="$(rt_backup_meta template "$dir")" + if [ -z "$tpl_id" ]; then + tpl_id="row" + rt_warn "backup artifact has no store match and no recorded template; defaulting to Row." + elif ! rt_template_allowed "$tpl_id"; then + rt_warn "backup artifact recorded template '$tpl_id' is not installed; defaulting to Row." + tpl_id="row" + fi fi rt_set_dist "$dir/template.html" || return 1 if [ -f "$dir/VERSION" ]; then diff --git a/tests/installer.test.mjs b/tests/installer.test.mjs index 6db50fc..4ef19a1 100644 --- a/tests/installer.test.mjs +++ b/tests/installer.test.mjs @@ -400,8 +400,8 @@ test('restore-from-backup reinstates artifact + VERSION but keeps current config admin's CURRENT branding — restoring stale config would silently undo a rename the admin made after the backup. The template identity is now re-derived from the artifact against the store, so a store entry for the - backed-up design is part of the fixture; with no matching entry the - restore refuses (see the next test). */ + backed-up design is part of the fixture; without a matching entry the + restore falls back to the meta's template= or Row (see next test). */ const r = sh(GEN_SETUP + 'printf "0.8.0\\n" > "$RT_VERSION_FILE"; rt_config_write "OldName" "" "" ""; ' + 'rt_set_dist "$RT_DIST" >/dev/null; ' + @@ -418,18 +418,44 @@ test('restore-from-backup reinstates artifact + VERSION but keeps current config assert.match(r.out, /NAME=NewName/, 'the current admin config is preserved, not reverted'); }); -test('restore-from-backup refuses an artifact the template store cannot identify', () => { - /* Without a store match the restored artifact and the stored selection could - disagree, which is the one state this system must never produce. */ +test('restore-from-backup falls back to Row when no store match and no meta template', () => { + /* A v1.1.0-generated backup has no template= in its meta and its artifact + may not match any entry in the current store. The restore must not refuse — + it defaults to Row so the artifact and the persisted selection always agree. + This is the path that previously hard-failed and blocked rollback from a + fresh install. */ const r = sh(GEN_SETUP + 'printf "0.8.0\\n" > "$RT_VERSION_FILE"; rt_config_write "OldName" "" "" ""; ' + 'rt_set_dist "$RT_DIST" >/dev/null; ' + 'B="$(rt_backup_create)"; ' + 'printf "0.9.0\\n" > "$RT_VERSION_FILE"; rt_config_write "NewName" "https://t.me/x" "" ""; ' + - 'if rt_restore_from_backup "$B" 2>/dev/null; then echo "NO-STORE-ACCEPTED"; else echo "refused"; fi; ' + - 'printf "NAME=%s\\n" "$(rt_config_get_text SERVICE_NAME_B64)"'); - assert.match(r.out, /refused/, 'no store match, no restore'); - assert.match(r.out, /NAME=NewName/, 'and the current config is untouched'); + 'rt_restore_from_backup "$B" >/dev/null && echo RESTORED; ' + + 'printf "VER=%s\\n" "$(cat "$RT_VERSION_FILE")"; ' + + 'printf "NAME=%s\\n" "$(rt_config_get_text SERVICE_NAME_B64)"; ' + + 'printf "TPL=%s\\n" "$(rt_config_get_raw TEMPLATE)"; ' + + 'cmp -s "$RT_DIST" "$B/template.html" && echo "artifact-matches-source"'); + assert.match(r.out, /RESTORED/, 'no store match falls back to Row'); + assert.match(r.out, /VER=0\.8\.0/, 'the backed-up version is reinstated'); + assert.match(r.out, /NAME=NewName/, 'the current admin config is preserved, not reverted'); + assert.match(r.out, /TPL=row/, 'the selection defaults to Row'); + assert.match(r.out, /artifact-matches-source/, 'the restored artifact is the backed-up bytes'); +}); + +test('restore-from-backup ignores an unknown template= in meta and defaults to Row', () => { + /* If the backup's meta records a template id the current release does not + recognise (e.g. a design removed or renamed since the backup was taken), + restoring that id would produce a stale selection. The restore falls back + to Row instead, keeping the artifact and the selection in agreement. */ + const r = sh(GEN_SETUP + + 'printf "0.8.0\\n" > "$RT_VERSION_FILE"; rt_config_write "OldName" "" "" ""; ' + + 'rt_set_dist "$RT_DIST" >/dev/null; ' + + 'B="$(rt_backup_create)"; ' + + 'printf "template=ghost\\n" >> "$B/meta"; ' + + 'printf "0.9.0\\n" > "$RT_VERSION_FILE"; rt_config_write "NewName" "https://t.me/x" "" ""; ' + + 'rt_restore_from_backup "$B" >/dev/null && echo RESTORED; ' + + 'printf "TPL=%s\\n" "$(rt_config_get_raw TEMPLATE)"'); + assert.match(r.out, /RESTORED/, 'unknown meta template does not block restore'); + assert.match(r.out, /TPL=row/, 'an unknown recorded template falls back to Row'); }); test('archive extraction rejects a symlink member even when its name is clean', From ddc56538d3149b70886cd4020e296b616e564f0a Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Fri, 25 Sep 2026 16:49:02 +0330 Subject: [PATCH 05/25] fix(release): write checksums in the text form on every platform sha256sum under Git Bash and Cygwin prints the binary-mode form ' *', so a release built on Windows carried SHA256SUMS lines that differ from a Linux build and failed the release test that pins the coreutils text form. The release script now normalizes every checksum line to ' '; the installer already accepted both forms. Co-Authored-By: Claude Opus 5.5 --- tools/make-release.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tools/make-release.sh b/tools/make-release.sh index 0db6f87..7b8681a 100755 --- a/tools/make-release.sh +++ b/tools/make-release.sh @@ -39,7 +39,14 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" OUT="${1:-$ROOT/release}" die() { printf 'make-release: %s\n' "$1" >&2; exit 1; } -sha() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$@"; else shasum -a 256 "$@"; fi; } +# Always the coreutils TEXT form, " ". sha256sum on Windows (Git +# Bash, Cygwin) writes the binary-mode form " *" instead, which would +# make a release built there differ byte for byte from one built on Linux. The +# installer reads both forms; the release is normalized so it has only one. +sha() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$@"; else shasum -a 256 "$@"; fi \ + | sed 's/^\([0-9a-fA-F]\{64\}\) \*/\1 /' +} VERSION="$(tr -d ' \t\r\n' < "$ROOT/VERSION")" [ -n "$VERSION" ] || die "VERSION file is empty." From 0f1fb2cab0aa131df83f4a74b37cd638d4cd1777 Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Fri, 25 Sep 2026 17:02:30 +0330 Subject: [PATCH 06/25] feat(shells): render PasarGuard and Rebecca pages from their real context The PasarGuard and Rebecca shells were transpiled layouts that read 3X-UI's variable names (enabled, downloadByte, expire, ...). Neither panel supplies those names, so on a real panel every figure on the page would have rendered empty. Each shell now starts with a prelude that derives every name the layout reads from the context the panel really renders with: - PasarGuard (Jinja2): user (a model object), links, announce, now(). - Rebecca (pongo2): user (a map), links, support_url, current_timestamp. Its online_at is a zoneless UTC string and pongo2 cannot parse dates, so the civil date is converted with integer arithmetic. Escaping: PasarGuard builds its Jinja2 Environment without autoescape, so a username, link remark or announcement was written into the page as raw HTML. The body of every shell is now wrapped in an explicit autoescape block (Jinja2 and pongo2), and the build refuses any asset that contains a template delimiter, because both panels parse the whole file, inline CSS and JavaScript included. on_hold is no longer refused (a served page cannot refuse). Decided in docs/design/PANEL-ON-HOLD-DECISION.md: enabled, and on PasarGuard the clock starts on first connection for the hold duration; without a known duration the expiry is unknown, never "never expires". Adapter fixes found by the audit: PasarGuard's status table lacked the real `limited` and `expired` statuses, and its Clash link used `/clash-meta` where PasarGuard's route is `clash_meta` (and may be disabled), so the link is now omitted. tests/panels-engines.test.mjs renders the shipped shells with real Jinja2 (configured as PasarGuard configures it) and real pongo2 v6.1.0 (Rebecca's pinned version): every design, every fixture, 1,624 online_at conversions under a non-UTC TZ, and hostile data on both panels. The harnesses are independent implementations, since both panels are AGPL-3.0; they are test tooling and never ship. Co-Authored-By: Claude Opus 5.5 --- docs/design/PANEL-ON-HOLD-DECISION.md | 66 ++++ src/panels/pasarguard/prelude.jinja2 | 61 ++++ src/panels/rebecca/prelude.pongo2 | 84 +++++ tests/adapters-pasarguard.test.mjs | 25 +- tests/adapters-rebecca.test.mjs | 27 +- .../panels/pasarguard/00-showcase.json | 2 +- .../panels/pasarguard/01-active-online.json | 2 +- .../panels/pasarguard/02-active-offline.json | 2 +- .../panels/pasarguard/03-disabled.json | 2 +- .../panels/pasarguard/04-expired.json | 2 +- .../pasarguard/05-traffic-exhausted.json | 2 +- .../pasarguard/06-unlimited-traffic.json | 2 +- .../panels/pasarguard/07-never-expires.json | 2 +- .../panels/pasarguard/08-on-hold.json | 21 +- .../panels/pasarguard/09-zero-total.json | 2 +- .../panels/pasarguard/10-no-support.json | 2 +- .../panels/pasarguard/11-no-announce.json | 2 +- .../pasarguard/12-announce-encoded.json | 2 +- .../panels/pasarguard/13-title-encoded.json | 2 +- .../panels/pasarguard/14-persian.json | 2 +- .../panels/pasarguard/15-hostile-title.json | 2 +- .../panels/pasarguard/16-online-at-null.json | 2 +- .../panels/pasarguard/17-ip-present.json | 2 +- .../pasarguard/18-missing-optional.json | 2 +- .../pasarguard/19-expire-seconds-vs-ms.json | 2 +- .../panels/pasarguard/20-status-limited.json | 64 ++++ .../panels/pasarguard/21-status-expired.json | 64 ++++ .../pasarguard/22-on-hold-no-duration.json | 64 ++++ tests/fixtures/panels/rebecca/08-on-hold.json | 21 +- tests/panels-engines.test.mjs | 331 ++++++++++++++++++ tests/panels-fixtures-rebecca.test.mjs | 20 +- tests/panels-fixtures.test.mjs | 23 +- tests/panels-pasarguard-shell.test.mjs | 47 ++- tests/panels-rebecca-shell.test.mjs | 38 +- tools/adapters/pasarguard.mjs | 48 ++- tools/adapters/rebecca.mjs | 20 +- tools/engines.mjs | 173 +++++++++ tools/engines/jinja2_pasarguard.py | 146 ++++++++ tools/engines/pongo2/go.mod | 5 + tools/engines/pongo2/go.sum | 2 + tools/engines/pongo2/main.go | 248 +++++++++++++ tools/shell.mjs | 87 ++++- 42 files changed, 1610 insertions(+), 113 deletions(-) create mode 100644 docs/design/PANEL-ON-HOLD-DECISION.md create mode 100644 src/panels/pasarguard/prelude.jinja2 create mode 100644 src/panels/rebecca/prelude.pongo2 create mode 100644 tests/fixtures/panels/pasarguard/20-status-limited.json create mode 100644 tests/fixtures/panels/pasarguard/21-status-expired.json create mode 100644 tests/fixtures/panels/pasarguard/22-on-hold-no-duration.json create mode 100644 tests/panels-engines.test.mjs create mode 100644 tools/engines.mjs create mode 100644 tools/engines/jinja2_pasarguard.py create mode 100644 tools/engines/pongo2/go.mod create mode 100644 tools/engines/pongo2/go.sum create mode 100644 tools/engines/pongo2/main.go diff --git a/docs/design/PANEL-ON-HOLD-DECISION.md b/docs/design/PANEL-ON-HOLD-DECISION.md new file mode 100644 index 0000000..7f6acd9 --- /dev/null +++ b/docs/design/PANEL-ON-HOLD-DECISION.md @@ -0,0 +1,66 @@ +# on_hold on PasarGuard and Rebecca — Decision Record + +| | | +|---|---| +| Date | 2026-09-25 | +| Release | 1.3.0 | +| Supersedes | `REBECCA-ADAPTER-DECISIONS.md` §3 (option A, "reject") | +| Status | **Decided and implemented** | + +## Why the old decision had to change + +`REBECCA-ADAPTER-DECISIONS.md` §3 froze option **A — reject `on_hold`** on the grounds +that the adapters were not wired into anything, so a `throw` cost nothing. That is no +longer true. In 1.3.0 the PasarGuard and Rebecca pages are installed on real panels, and +a page template cannot throw: whatever the page does with an `on_hold` subscriber is +what that subscriber sees. "Refuse" is not an option a served page has. + +## The decision + +Option **B** from the old record, now with a rule for the case it did not cover. + +| Panel | Hold duration known to the page? | `enabled` | `expire` | The subscriber sees | +|---|---|---|---|---| +| PasarGuard | **yes** — `user.on_hold_expire_duration` | `true` | **−duration** (seconds) | "Starts on first connection · valid for N days after that" | +| PasarGuard | no (`null` / `0`) | `true` | **unknown** | an unknown expiry (—) | +| Rebecca | **no** — not in the page context | `true` | **unknown** | an unknown expiry (—) | + +**Why negative expire.** Row's `expire` encoding already has the slot: a negative value +is "a duration that starts on first connection" (`src/scripts/model.js`, `expiry()` +→ `kind: 'pending'`). That is exactly what `on_hold` means on both panels. No contract, +runtime or artifact change is needed. + +**Why unknown, and not "never".** When the duration is not available, the only honest +statement is that the expiry is unknown. `0` would say "never expires", which is false; +any negative number would invent a duration. The page encodes "unknown" the way the +contract already defines it: a value outside the plausible range +(`EXPIRE_MAX = 4.1e9`), which `normalize()` reads as `null`. The page template writes +`9999999999`; the JavaScript adapters return `null`; both normalize to the same model. + +**Why enabled.** Rebecca's own page classes `on_hold` as `active` +(`subscriptionStatusClass`), and both panels let an `on_hold` subscriber connect — that +is what starts the clock. + +## The other statuses (for completeness) + +| Status | `enabled` | Note | +|---|---|---| +| `active` | `true` | | +| `limited` | `true` | the page derives "limited" from `used ≥ total` | +| `expired` | `true` | the page derives "expired" from `expire` | +| `disabled` | `false` | the only "off" state | +| anything else | PasarGuard: cannot occur (a closed enum). Rebecca: **`false`**, because Rebecca's own `status_class` classes an unknown status as `disabled` — the page fails safe. The JavaScript adapter still refuses it, loudly. | + +PasarGuard's `limited` and `expired` were missing from its adapter's status table before +1.3.0 (the adapter would have refused a real `limited` subscriber). Both are now covered, +with fixtures `20-status-limited` and `21-status-expired`. + +## Where it is implemented and tested + +| | | +|---|---| +| Page (PasarGuard) | `src/panels/pasarguard/prelude.jinja2` | +| Page (Rebecca) | `src/panels/rebecca/prelude.pongo2` | +| Adapters | `tools/adapters/pasarguard.mjs`, `tools/adapters/rebecca.mjs` | +| Fixtures | `pasarguard/08-on-hold`, `pasarguard/22-on-hold-no-duration`, `rebecca/08-on-hold` | +| Real-engine proof | `tests/panels-engines.test.mjs` — renders the shipped pages with Jinja2 and pongo2 | diff --git a/src/panels/pasarguard/prelude.jinja2 b/src/panels/pasarguard/prelude.jinja2 new file mode 100644 index 0000000..84597b5 --- /dev/null +++ b/src/panels/pasarguard/prelude.jinja2 @@ -0,0 +1,61 @@ +{#- ========================================================================== + Row-Template -> PasarGuard page context. + + PasarGuard renders its subscription page with Jinja2 and hands the template + { user, links, announce, announce_url, apps }. The Row layouts read 3X-UI's + names instead (enabled, isOnline, downloadByte, expire, ...). This prelude + derives every one of those names from PasarGuard's own context, once, at the + top of the page, so the layout below it is the same document on every panel. + + Rules (docs/design/PASARGUARD-ADAPTER-AUDIT.md, tools/adapters/pasarguard.mjs): + enabled status != disabled; on_hold, limited and expired are enabled + (the page derives "expired"/"limited" from the numbers) + isOnline online_at within 120 s of now() -- an adapter window, not a + panel fact; never inferred from traffic + downloadByte used_traffic, the panel's single combined counter + uploadByte 0, exactly as PasarGuard's own subscription-userinfo header + totalByte data_limit, None/0 = unlimited = 0 + expire epoch seconds; 0 = never; on_hold with a duration becomes the + negative duration (the page's "starts on first connection"); + on_hold without one is outside the plausible range, which the + page reads as unknown + lastOnline online_at in epoch milliseconds, empty when never seen + subUrl user.subscription_url; empty makes the page use its own URL, + which on PasarGuard IS the subscription URL + support the admin's support_url when the panel supplies one + The subscriber's address (user.ip) is never read. + + Values are escaped on output by the autoescape block that follows this + prelude: PasarGuard's environment does NOT autoescape by itself. +========================================================================== -#} +{%- set rt_status = user.status.value if user.status.value is defined else (user.status ~ '') -%} +{%- set enabled = rt_status != 'disabled' -%} +{%- set rt_seen = user.online_at if user.online_at else none -%} +{%- set rt_age = (now() - rt_seen).total_seconds() if rt_seen else -1 -%} +{%- set isOnline = (rt_seen is not none) and rt_age >= 0 and rt_age <= 120 -%} +{%- set lastOnline = ((rt_seen.timestamp() * 1000) | int) if rt_seen else '' -%} +{%- set downloadByte = (user.used_traffic or 0) | int -%} +{%- set uploadByte = 0 -%} +{%- set totalByte = (user.data_limit or 0) | int -%} +{%- if rt_status == 'on_hold' and user.on_hold_expire_duration -%} +{%- set expire = 0 - (user.on_hold_expire_duration | int) -%} +{%- elif rt_status == 'on_hold' -%} +{%- set expire = 9999999999 -%} +{%- elif user.expire is number -%} +{%- set expire = user.expire | int -%} +{%- elif user.expire -%} +{%- set expire = user.expire.timestamp() | int -%} +{%- else -%} +{%- set expire = 0 -%} +{%- endif -%} +{%- set subUrl = user.subscription_url or '' -%} +{%- set subJsonUrl = '' -%} +{%- set subClashUrl = '' -%} +{%- set subTitle = '' -%} +{%- set subSupportUrl = (user.admin.support_url or '') if user.admin else '' -%} +{%- set datepicker = 'gregorian' -%} +{%- set announce = announce or '' -%} +{%- set links = links or [] -%} +{%- set used = downloadByte | bytesformat -%} +{%- set total = totalByte | bytesformat -%} +{%- set remained = ((totalByte - downloadByte) | bytesformat) if totalByte > downloadByte else '' -%} diff --git a/src/panels/rebecca/prelude.pongo2 b/src/panels/rebecca/prelude.pongo2 new file mode 100644 index 0000000..5535688 --- /dev/null +++ b/src/panels/rebecca/prelude.pongo2 @@ -0,0 +1,84 @@ +{%- comment -%} + Row-Template, Rebecca page context. + + Rebecca renders its subscription page with pongo2 and hands the template a + map: user (username, status, data_limit, used_traffic, expire, online_at, + subscription_url, ...), links, support_url and current_timestamp. The Row + layouts read the 3X-UI names instead (enabled, isOnline, downloadByte, + expire, ...). This prelude derives every one of them from Rebecca's own + context, once, so the layout below it is the same document on every panel. + + Rules (docs/design/REBECCA-ADAPTER-DECISIONS.md, tools/adapters/rebecca.mjs): + enabled status is not disabled; active, limited, expired and on_hold + are enabled (the page derives expired and limited itself). A + status Rebecca does not know is classed disabled by Rebecca + itself (status_class), and so it is here: fail safe + isOnline online_at within 120 s of current_timestamp while enabled, an + adapter window and not a panel fact; never inferred from traffic + downloadByte used_traffic, the panel's single combined counter + uploadByte 0, exactly as Rebecca's own subscription-userinfo header + totalByte data_limit, which Rebecca passes only when positive + expire epoch seconds, already; absent means never (0); on_hold is + outside the plausible range, which the page reads as unknown, + because Rebecca does not pass the hold duration to templates + lastOnline online_at in epoch milliseconds. Rebecca writes it WITHOUT a + zone and it is UTC; pongo2 cannot parse dates, so the civil + date is converted with integer arithmetic (days from civil). + A value with a non-UTC offset is left unknown, never guessed. + subUrl user.subscription_url, and Clash Meta at its /clash-meta suffix + support support_url + Rebecca has no announcement and no page title in its template context. + + Every value is escaped on output: pongo2 autoescapes by default, and the + explicit autoescape block after this prelude keeps it so. +{%- endcomment -%} +{%- set rt_status = user.status -%} +{%- set enabled = true -%} +{%- if rt_status == "disabled" or user.status_class == "disabled" -%}{%- set enabled = false -%}{%- endif -%} +{%- set downloadByte = user.used_traffic|integer -%} +{%- set uploadByte = 0 -%} +{%- set totalByte = 0 -%} +{%- if user.data_limit -%}{%- set totalByte = user.data_limit|integer -%}{%- endif -%} +{%- set expire = 0 -%} +{%- if rt_status == "on_hold" -%}{%- set expire = 9999999999 -%}{%- elif user.expire -%}{%- set expire = user.expire|integer -%}{%- endif -%} +{%- set isOnline = false -%} +{%- set lastOnline = "" -%} +{%- if user.online_at -%} +{%- set rt_at = user.online_at -%} +{%- set rt_tail = rt_at|slice:"19:" -%} +{%- set rt_zone_ok = false -%} +{%- if rt_tail == "" or rt_tail == "Z" or rt_tail == "+00:00" -%}{%- set rt_zone_ok = true -%}{%- endif -%} +{%- if rt_tail|slice:"0:1" == "." and not ("+" in rt_tail) and not ("-" in rt_tail) -%}{%- set rt_zone_ok = true -%}{%- endif -%} +{%- if rt_at|length >= 19 and rt_zone_ok and rt_at|slice:"4:5" == "-" and rt_at|slice:"7:8" == "-" and rt_at|slice:"13:14" == ":" and rt_at|slice:"16:17" == ":" -%} +{%- set rt_y = rt_at|slice:"0:4"|integer -%} +{%- set rt_m = rt_at|slice:"5:7"|integer -%} +{%- set rt_d = rt_at|slice:"8:10"|integer -%} +{%- set rt_hh = rt_at|slice:"11:13"|integer -%} +{%- set rt_mi = rt_at|slice:"14:16"|integer -%} +{%- set rt_ss = rt_at|slice:"17:19"|integer -%} +{%- set rt_yy = rt_y -%} +{%- if rt_m <= 2 -%}{%- set rt_yy = rt_y - 1 -%}{%- set rt_mp = rt_m + 9 -%}{%- else -%}{%- set rt_mp = rt_m - 3 -%}{%- endif -%} +{%- set rt_era = rt_yy / 400 -%} +{%- set rt_yoe = rt_yy - rt_era * 400 -%} +{%- set rt_doy = (153 * rt_mp + 2) / 5 + rt_d - 1 -%} +{%- set rt_doe = rt_yoe * 365 + rt_yoe / 4 - rt_yoe / 100 + rt_doy -%} +{%- set rt_secs = (rt_era * 146097 + rt_doe - 719468) * 86400 + rt_hh * 3600 + rt_mi * 60 + rt_ss -%} +{%- if rt_y >= 1970 and rt_m >= 1 and rt_m <= 12 and rt_d >= 1 and rt_d <= 31 -%} +{%- set lastOnline = rt_secs * 1000 -%} +{%- set rt_age = current_timestamp - rt_secs -%} +{%- if enabled and rt_age >= 0 and rt_age <= 120 -%}{%- set isOnline = true -%}{%- endif -%} +{%- endif -%} +{%- endif -%} +{%- endif -%} +{%- set subUrl = user.subscription_url -%} +{%- set subJsonUrl = "" -%} +{%- set subClashUrl = "" -%} +{%- if subUrl -%}{%- set subClashUrl = subUrl|add:"/clash-meta" -%}{%- endif -%} +{%- set subTitle = "" -%} +{%- set subSupportUrl = support_url -%} +{%- set datepicker = "gregorian" -%} +{%- set announce = "" -%} +{%- set used = downloadByte|bytesformat -%} +{%- set total = totalByte|bytesformat -%} +{%- set remained = "" -%} +{%- if totalByte > downloadByte -%}{%- set rt_left = totalByte - downloadByte -%}{%- set remained = rt_left|bytesformat -%}{%- endif -%} diff --git a/tests/adapters-pasarguard.test.mjs b/tests/adapters-pasarguard.test.mjs index 27e9dd4..a95b3e9 100644 --- a/tests/adapters-pasarguard.test.mjs +++ b/tests/adapters-pasarguard.test.mjs @@ -38,7 +38,7 @@ const withClock = (doc) => ({ ...doc.native, now: doc.source.clock * 1000 }); test('every fixture reproduces its expected model exactly', () => { for (const { file, doc } of FIXTURES) { - if (doc.expected.model === null) continue; /* deferred: on_hold */ + if (doc.expected.model === null) continue; /* none since 1.3.0 */ const got = island(withClock(doc)); assert.deepEqual(got, doc.expected.model, file + ': the adapter must match the fixture'); } @@ -175,15 +175,26 @@ test('a seconds/milliseconds swap would be caught', () => { assert.equal(m.expire * 1000 === m.lastOnline, false, 'they are not the same instant'); }); -/* 14 — on_hold */ -test('on_hold is refused explicitly, never coerced to another state', () => { +/* 14 — on_hold (decided for 1.3.0, docs/design/PANEL-ON-HOLD-DECISION.md) */ +test('on_hold resolves to a pending expiry of the hold duration, never a tempting wrong answer', () => { const doc = byCase('08-on-hold').doc; assert.equal(doc.native.info.status, 'on_hold'); - assert.throws(() => island(withClock(doc)), /unsupported on_hold state/); - /* And specifically NOT any of the tempting wrong answers. */ - for (const wrong of ['disabled', 'active']) { - assert.notEqual(doc.native.info.status, wrong); + const m = island(withClock(doc)); + assert.deepEqual(m, doc.expected.model); + assert.equal(m.enabled, true, 'not disabled'); + assert.equal(m.expire, -doc.native.info.on_hold_expire_duration, 'the clock starts on first connection'); + const noDuration = island(withClock(byCase('22-on-hold-no-duration').doc)); + assert.equal(noDuration.expire, null, 'without a duration the expiry is unknown, not 0 ("never")'); +}); + +test('limited and expired are enabled statuses, and a status outside the enum is refused', () => { + for (const kase of ['20-status-limited', '21-status-expired']) { + const doc = byCase(kase).doc; + assert.equal(island(withClock(doc)).enabled, true, kase); } + const doc = byCase('01-active-online').doc; + const bad = { ...doc.native, info: { ...doc.native.info, status: 'suspended' }, now: doc.source.clock * 1000 }; + assert.throws(() => island(bad), /unknown status "suspended"/); }); /* 15 — malformed payload */ diff --git a/tests/adapters-rebecca.test.mjs b/tests/adapters-rebecca.test.mjs index 89cd374..9b9d64d 100644 --- a/tests/adapters-rebecca.test.mjs +++ b/tests/adapters-rebecca.test.mjs @@ -35,10 +35,10 @@ const FIXTURES = readdirSync(DIR).filter((f) => f.endsWith('.json')).sort() const byCase = (name) => FIXTURES.find((f) => f.doc.case === name); const withClock = (doc) => ({ ...doc.native, now: doc.source.clock * 1000 }); -/* The fixtures whose expectation is deliberately absent: `on_hold` and the - unknown status. Both must THROW, so they are excluded from the oracle sweep - and covered by their own tests below. */ -const THROWS = ['08-on-hold', '17-unknown-status']; +/* The fixture whose expectation is deliberately absent: the unknown status. + It must THROW, so it is excluded from the oracle sweep and covered by its own + test below. (`on_hold` resolves since 1.3.0 and is swept like any other.) */ +const THROWS = ['17-unknown-status']; /* --- the oracle sweep ---------------------------------------------------- */ @@ -106,15 +106,13 @@ test('disabled is the ONLY status that means off', () => { /* --- on_hold and unknown status both throw ------------------------------- */ -test('on_hold is refused explicitly, never coerced to another state', () => { +test('on_hold resolves to enabled with an unknown expiry, never a tempting wrong answer', () => { const doc = byCase('08-on-hold').doc; assert.equal(doc.native.info.status, 'on_hold'); - assert.equal(doc.expected.model, null, 'the fixture records no expectation'); - assert.throws(() => island(withClock(doc)), /unsupported on_hold state/); - /* And specifically NOT any of the tempting wrong answers. */ - for (const wrong of ['disabled', 'active', 'expired']) { - assert.notEqual(doc.native.info.status, wrong); - } + const m = island(withClock(doc)); + assert.deepEqual(m, doc.expected.model); + assert.equal(m.enabled, true, 'not disabled'); + assert.equal(m.expire, null, 'not 0 ("never"), and not an invented duration'); }); test('an unknown status is refused rather than absorbed by an else branch', () => { @@ -127,7 +125,7 @@ test('an unknown status is refused rather than absorbed by an else branch', () = assert.throws(() => island(withClock(doc)), /suspended/); }); -test('the two refusals are the only fixtures without an expectation', () => { +test('the refusal is the only fixture without an expectation', () => { const deferred = FIXTURES.filter((f) => f.doc.expected.model === null).map((f) => f.doc.case); assert.deepEqual(deferred, THROWS); }); @@ -198,6 +196,11 @@ test('expire passes through as SECONDS, unchanged', () => { for (const { file, doc: d } of FIXTURES) { if (THROWS.includes(d.case)) continue; const e = d.native.info.expire; + if (d.native.info.status === 'on_hold') { + /* the clock has not started and Rebecca passes no hold duration */ + assert.equal(island(withClock(d)).expire, null, file + ': an on_hold expiry is unknown'); + continue; + } assert.equal(island(withClock(d)).expire, e === null || e <= 0 ? 0 : e, file + ': expire must be DIRECT, never converted'); } diff --git a/tests/fixtures/panels/pasarguard/00-showcase.json b/tests/fixtures/panels/pasarguard/00-showcase.json index e927969..a15457c 100644 --- a/tests/fixtures/panels/pasarguard/00-showcase.json +++ b/tests/fixtures/panels/pasarguard/00-showcase.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/01-active-online.json b/tests/fixtures/panels/pasarguard/01-active-online.json index e0a824e..0058ea4 100644 --- a/tests/fixtures/panels/pasarguard/01-active-online.json +++ b/tests/fixtures/panels/pasarguard/01-active-online.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/02-active-offline.json b/tests/fixtures/panels/pasarguard/02-active-offline.json index 3f39bd3..b5f726c 100644 --- a/tests/fixtures/panels/pasarguard/02-active-offline.json +++ b/tests/fixtures/panels/pasarguard/02-active-offline.json @@ -53,7 +53,7 @@ "lastOnline": 1789473600000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/03-disabled.json b/tests/fixtures/panels/pasarguard/03-disabled.json index 3802dd5..bfb5aed 100644 --- a/tests/fixtures/panels/pasarguard/03-disabled.json +++ b/tests/fixtures/panels/pasarguard/03-disabled.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/04-expired.json b/tests/fixtures/panels/pasarguard/04-expired.json index f28e9bc..6d5e0dd 100644 --- a/tests/fixtures/panels/pasarguard/04-expired.json +++ b/tests/fixtures/panels/pasarguard/04-expired.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/05-traffic-exhausted.json b/tests/fixtures/panels/pasarguard/05-traffic-exhausted.json index 139c1ad..f334662 100644 --- a/tests/fixtures/panels/pasarguard/05-traffic-exhausted.json +++ b/tests/fixtures/panels/pasarguard/05-traffic-exhausted.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/06-unlimited-traffic.json b/tests/fixtures/panels/pasarguard/06-unlimited-traffic.json index ee8ab13..f5ce2ef 100644 --- a/tests/fixtures/panels/pasarguard/06-unlimited-traffic.json +++ b/tests/fixtures/panels/pasarguard/06-unlimited-traffic.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/07-never-expires.json b/tests/fixtures/panels/pasarguard/07-never-expires.json index b00079a..b9e3d6a 100644 --- a/tests/fixtures/panels/pasarguard/07-never-expires.json +++ b/tests/fixtures/panels/pasarguard/07-never-expires.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/08-on-hold.json b/tests/fixtures/panels/pasarguard/08-on-hold.json index 71d9f2a..368eed5 100644 --- a/tests/fixtures/panels/pasarguard/08-on-hold.json +++ b/tests/fixtures/panels/pasarguard/08-on-hold.json @@ -1,7 +1,7 @@ { "panel": "pasarguard", "case": "08-on-hold", - "note": "on_hold is a third state with no slot in the contract — recorded, expectation deferred", + "note": "on_hold with a hold duration: enabled, and the clock starts on first connection (expire = -duration)", "source": { "route": "GET /{token}/info", "version": "5.4.1", @@ -42,6 +42,23 @@ } }, "expected": { - "model": null + "model": { + "enabled": true, + "online": true, + "download": 42949672960, + "upload": 0, + "used": 42949672960, + "total": 107374182400, + "expire": -2592000, + "lastOnline": 1789732710000, + "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", + "subJsonUrl": "", + "subClashUrl": "", + "title": "Premium 100 GB", + "supportUrl": "https://t.me/example_support", + "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", + "jalali": false, + "links": [] + } } } diff --git a/tests/fixtures/panels/pasarguard/09-zero-total.json b/tests/fixtures/panels/pasarguard/09-zero-total.json index c0ab6a3..919c4a6 100644 --- a/tests/fixtures/panels/pasarguard/09-zero-total.json +++ b/tests/fixtures/panels/pasarguard/09-zero-total.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/10-no-support.json b/tests/fixtures/panels/pasarguard/10-no-support.json index d81f9b7..005a6f1 100644 --- a/tests/fixtures/panels/pasarguard/10-no-support.json +++ b/tests/fixtures/panels/pasarguard/10-no-support.json @@ -52,7 +52,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/11-no-announce.json b/tests/fixtures/panels/pasarguard/11-no-announce.json index 8c21315..afba181 100644 --- a/tests/fixtures/panels/pasarguard/11-no-announce.json +++ b/tests/fixtures/panels/pasarguard/11-no-announce.json @@ -51,7 +51,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "", diff --git a/tests/fixtures/panels/pasarguard/12-announce-encoded.json b/tests/fixtures/panels/pasarguard/12-announce-encoded.json index d97dfae..3990c83 100644 --- a/tests/fixtures/panels/pasarguard/12-announce-encoded.json +++ b/tests/fixtures/panels/pasarguard/12-announce-encoded.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Line one.\nLine two.", diff --git a/tests/fixtures/panels/pasarguard/13-title-encoded.json b/tests/fixtures/panels/pasarguard/13-title-encoded.json index da65f3b..f967cac 100644 --- a/tests/fixtures/panels/pasarguard/13-title-encoded.json +++ b/tests/fixtures/panels/pasarguard/13-title-encoded.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "گزارش وضعیت", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/14-persian.json b/tests/fixtures/panels/pasarguard/14-persian.json index 75b55e9..55f25e3 100644 --- a/tests/fixtures/panels/pasarguard/14-persian.json +++ b/tests/fixtures/panels/pasarguard/14-persian.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "پرمیوم ۱۰۰ گیگابایت", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/15-hostile-title.json b/tests/fixtures/panels/pasarguard/15-hostile-title.json index 94f4d69..70e011c 100644 --- a/tests/fixtures/panels/pasarguard/15-hostile-title.json +++ b/tests/fixtures/panels/pasarguard/15-hostile-title.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": " & \"quoted\"", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/16-online-at-null.json b/tests/fixtures/panels/pasarguard/16-online-at-null.json index 427b184..c68d14d 100644 --- a/tests/fixtures/panels/pasarguard/16-online-at-null.json +++ b/tests/fixtures/panels/pasarguard/16-online-at-null.json @@ -53,7 +53,7 @@ "lastOnline": null, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/17-ip-present.json b/tests/fixtures/panels/pasarguard/17-ip-present.json index 6c260d6..7cbc374 100644 --- a/tests/fixtures/panels/pasarguard/17-ip-present.json +++ b/tests/fixtures/panels/pasarguard/17-ip-present.json @@ -53,7 +53,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/18-missing-optional.json b/tests/fixtures/panels/pasarguard/18-missing-optional.json index 9577a74..156a9a5 100644 --- a/tests/fixtures/panels/pasarguard/18-missing-optional.json +++ b/tests/fixtures/panels/pasarguard/18-missing-optional.json @@ -49,7 +49,7 @@ "lastOnline": 1789732710000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "", "supportUrl": "", "announce": "", diff --git a/tests/fixtures/panels/pasarguard/19-expire-seconds-vs-ms.json b/tests/fixtures/panels/pasarguard/19-expire-seconds-vs-ms.json index f5f7d5c..dd1dbf6 100644 --- a/tests/fixtures/panels/pasarguard/19-expire-seconds-vs-ms.json +++ b/tests/fixtures/panels/pasarguard/19-expire-seconds-vs-ms.json @@ -53,7 +53,7 @@ "lastOnline": 1789732600000, "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", "subJsonUrl": "", - "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "subClashUrl": "", "title": "Premium 100 GB", "supportUrl": "https://t.me/example_support", "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", diff --git a/tests/fixtures/panels/pasarguard/20-status-limited.json b/tests/fixtures/panels/pasarguard/20-status-limited.json new file mode 100644 index 0000000..5376585 --- /dev/null +++ b/tests/fixtures/panels/pasarguard/20-status-limited.json @@ -0,0 +1,64 @@ +{ + "panel": "pasarguard", + "case": "20-status-limited", + "note": "the panel reports status limited once used_traffic reaches data_limit; the account stays enabled and the page derives \"limited\" from the figures", + "source": { + "route": "GET /{token}/info", + "version": "5.4.1", + "recorded": "static-read", + "clock": 1789732800 + }, + "native": { + "info": { + "id": 42, + "username": "alice", + "status": "limited", + "used_traffic": 107374182400, + "lifetime_used_traffic": 98784247808, + "data_limit": 107374182400, + "expire": "2026-11-02T12:00:00Z", + "on_hold_expire_duration": null, + "on_hold_timeout": null, + "online_at": "2026-09-18T11:58:30Z", + "created_at": "2026-01-11T12:00:00Z", + "edit_at": null, + "data_limit_reset_strategy": "no_reset", + "hwid_limit": null, + "group_ids": [ + 1 + ], + "ip": null + }, + "headers": { + "subscription-userinfo": "upload=0; download=107374182400; total=107374182400; expire=1793620800", + "profile-web-page-url": "https://sub.example.com/sub/e3b0c44298fc1c14", + "profile-title": "base64:UHJlbWl1bSAxMDAgR0I=", + "support-url": "https://t.me/example_support", + "announce": "base64:U2NoZWR1bGVkIG1haW50ZW5hbmNlIG9uIFN1bmRheSwgMDM6MDAtMDQ6MDAgVVRDLg==", + "announce-url": "https://t.me/example_news", + "profile-update-interval": "12", + "content-disposition": "inline; filename=\"alice\"", + "Cache-Control": "no-store" + } + }, + "expected": { + "model": { + "enabled": true, + "online": true, + "download": 107374182400, + "upload": 0, + "used": 107374182400, + "total": 107374182400, + "expire": 1793620800, + "lastOnline": 1789732710000, + "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", + "subJsonUrl": "", + "subClashUrl": "", + "title": "Premium 100 GB", + "supportUrl": "https://t.me/example_support", + "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", + "jalali": false, + "links": [] + } + } +} diff --git a/tests/fixtures/panels/pasarguard/21-status-expired.json b/tests/fixtures/panels/pasarguard/21-status-expired.json new file mode 100644 index 0000000..49e5a80 --- /dev/null +++ b/tests/fixtures/panels/pasarguard/21-status-expired.json @@ -0,0 +1,64 @@ +{ + "panel": "pasarguard", + "case": "21-status-expired", + "note": "the panel reports status expired once expire passes; the account stays enabled and the page derives \"expired\" from expire", + "source": { + "route": "GET /{token}/info", + "version": "5.4.1", + "recorded": "static-read", + "clock": 1789732800 + }, + "native": { + "info": { + "id": 42, + "username": "alice", + "status": "expired", + "used_traffic": 42949672960, + "lifetime_used_traffic": 98784247808, + "data_limit": 107374182400, + "expire": "2026-09-01T00:00:00Z", + "on_hold_expire_duration": null, + "on_hold_timeout": null, + "online_at": "2026-08-31T23:00:00Z", + "created_at": "2026-01-11T12:00:00Z", + "edit_at": null, + "data_limit_reset_strategy": "no_reset", + "hwid_limit": null, + "group_ids": [ + 1 + ], + "ip": null + }, + "headers": { + "subscription-userinfo": "upload=0; download=42949672960; total=107374182400; expire=1788220800", + "profile-web-page-url": "https://sub.example.com/sub/e3b0c44298fc1c14", + "profile-title": "base64:UHJlbWl1bSAxMDAgR0I=", + "support-url": "https://t.me/example_support", + "announce": "base64:U2NoZWR1bGVkIG1haW50ZW5hbmNlIG9uIFN1bmRheSwgMDM6MDAtMDQ6MDAgVVRDLg==", + "announce-url": "https://t.me/example_news", + "profile-update-interval": "12", + "content-disposition": "inline; filename=\"alice\"", + "Cache-Control": "no-store" + } + }, + "expected": { + "model": { + "enabled": true, + "online": false, + "download": 42949672960, + "upload": 0, + "used": 42949672960, + "total": 107374182400, + "expire": 1788220800, + "lastOnline": 1788217200000, + "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", + "subJsonUrl": "", + "subClashUrl": "", + "title": "Premium 100 GB", + "supportUrl": "https://t.me/example_support", + "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", + "jalali": false, + "links": [] + } + } +} diff --git a/tests/fixtures/panels/pasarguard/22-on-hold-no-duration.json b/tests/fixtures/panels/pasarguard/22-on-hold-no-duration.json new file mode 100644 index 0000000..8e148b6 --- /dev/null +++ b/tests/fixtures/panels/pasarguard/22-on-hold-no-duration.json @@ -0,0 +1,64 @@ +{ + "panel": "pasarguard", + "case": "22-on-hold-no-duration", + "note": "on_hold without a hold duration: enabled, and the expiry is unknown -- never \"never expires\"", + "source": { + "route": "GET /{token}/info", + "version": "5.4.1", + "recorded": "static-read", + "clock": 1789732800 + }, + "native": { + "info": { + "id": 42, + "username": "alice", + "status": "on_hold", + "used_traffic": 42949672960, + "lifetime_used_traffic": 98784247808, + "data_limit": 107374182400, + "expire": null, + "on_hold_expire_duration": null, + "on_hold_timeout": null, + "online_at": "2026-09-18T11:58:30Z", + "created_at": "2026-01-11T12:00:00Z", + "edit_at": null, + "data_limit_reset_strategy": "no_reset", + "hwid_limit": null, + "group_ids": [ + 1 + ], + "ip": null + }, + "headers": { + "subscription-userinfo": "upload=0; download=42949672960; total=107374182400; expire=0", + "profile-web-page-url": "https://sub.example.com/sub/e3b0c44298fc1c14", + "profile-title": "base64:UHJlbWl1bSAxMDAgR0I=", + "support-url": "https://t.me/example_support", + "announce": "base64:U2NoZWR1bGVkIG1haW50ZW5hbmNlIG9uIFN1bmRheSwgMDM6MDAtMDQ6MDAgVVRDLg==", + "announce-url": "https://t.me/example_news", + "profile-update-interval": "12", + "content-disposition": "inline; filename=\"alice\"", + "Cache-Control": "no-store" + } + }, + "expected": { + "model": { + "enabled": true, + "online": true, + "download": 42949672960, + "upload": 0, + "used": 42949672960, + "total": 107374182400, + "expire": null, + "lastOnline": 1789732710000, + "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", + "subJsonUrl": "", + "subClashUrl": "", + "title": "Premium 100 GB", + "supportUrl": "https://t.me/example_support", + "announce": "Scheduled maintenance on Sunday, 03:00-04:00 UTC.", + "jalali": false, + "links": [] + } + } +} diff --git a/tests/fixtures/panels/rebecca/08-on-hold.json b/tests/fixtures/panels/rebecca/08-on-hold.json index 0d8634b..4d6f6ea 100644 --- a/tests/fixtures/panels/rebecca/08-on-hold.json +++ b/tests/fixtures/panels/rebecca/08-on-hold.json @@ -1,7 +1,7 @@ { "panel": "rebecca", "case": "08-on-hold", - "note": "on_hold has no slot in the contract — recorded, expectation deferred", + "note": "on_hold: enabled (Rebecca renders it as active); the hold duration is not in the page context, so the expiry is unknown (null)", "source": { "route": "GET /{token}/info", "version": "master", @@ -37,6 +37,23 @@ } }, "expected": { - "model": null + "model": { + "enabled": true, + "online": true, + "download": 42949672960, + "upload": 0, + "used": 42949672960, + "total": 107374182400, + "expire": null, + "lastOnline": 1789732710000, + "subUrl": "https://sub.example.com/sub/e3b0c44298fc1c14", + "subJsonUrl": "", + "subClashUrl": "https://sub.example.com/sub/e3b0c44298fc1c14/clash-meta", + "title": "Premium 100 GB", + "supportUrl": "https://t.me/example_support", + "announce": "", + "jalali": false, + "links": [] + } } } diff --git a/tests/panels-engines.test.mjs b/tests/panels-engines.test.mjs new file mode 100644 index 0000000..f6b0bc7 --- /dev/null +++ b/tests/panels-engines.test.mjs @@ -0,0 +1,331 @@ +/* The PasarGuard and Rebecca pages, rendered by the REAL template engines. + * + * Every other panel test renders the shells with a test-only stand-in. This + * file renders the SHIPPED shells -- prelude, autoescape block and all -- with + * real Jinja2 configured the way PasarGuard configures it, and real pongo2 + * v6.1.0 configured the way Rebecca configures it (tools/engines.mjs), from the + * page context each panel really builds. It is the evidence behind "Supported": + * + * - every design parses and renders on both engines; + * - the island a subscriber receives carries exactly the figures the panel + * holds, for every fixture, including the states that were refused before + * (on_hold) and the ones the old fixtures never used (limited, expired); + * - the time conversions are exact and timezone-independent, including + * Rebecca's zoneless online_at, converted in pongo2 integer arithmetic; + * - hostile panel data (a username, a link remark, an announcement) never + * becomes markup or template code on the page. On PasarGuard this is the + * shell's own doing: its Jinja2 environment does not autoescape. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { assembleShell, wrapForPanel, TEMPLATE_DELIMITER } from '../tools/shell.mjs'; +import { extractIsland, toModel } from '../tools/contract.mjs'; +import { templateIds } from '../tools/templates.mjs'; +import { + renderPasarGuard, renderRebecca, pasarguardContext, rebeccaContext, +} from '../tools/engines.mjs'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +function fixtures(panel) { + const dir = join(ROOT, 'tests', 'fixtures', 'panels', panel); + return readdirSync(dir).filter((f) => f.endsWith('.json')).sort() + .map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8'))); +} + +const PG = fixtures('pasarguard'); +const RB = fixtures('rebecca'); +const SHELL = { + pasarguard: assembleShell('pasarguard', 'row').html, + rebecca: assembleShell('rebecca', 'row').html, +}; + +const LINKS = [ + 'vless://11111111-1111-1111-1111-111111111111@203.0.113.10:443?security=reality&type=tcp#DE', + 'trojan://secret@203.0.113.11:443?sni=example.com#NL', +]; + +const HOSTILE = '">{{ 7*7 }}{% raw %}{# c #}\''; + +/* The page model a fixture should produce. The panels' page contexts carry + less than their /info payloads, and the adapters' header-derived fields are + replaced by what the page can actually see. */ +function pgExpected(doc, links) { + return { + ...doc.expected.model, + title: '', // not in PasarGuard's page context + subUrl: '', // not a database column: the page uses its own URL + subClashUrl: '', + links, + }; +} + +function rbExpected(doc, links) { + const subUrl = doc.native.info.subscription_url || ''; + return { + ...doc.expected.model, + title: '', // not in Rebecca's page context + announce: '', // Rebecca has no announcement + subUrl, + subClashUrl: subUrl ? `${subUrl}/clash-meta` : '', + links, + }; +} + +/* --- every design renders on both engines ----------------------------------- */ + +test('every design renders on PasarGuard\'s Jinja2 to a contract-valid island', () => { + const doc = PG.find((d) => d.case === '00-showcase'); + const ids = templateIds(); + const pages = renderPasarGuard(ids.map((id) => ({ + html: assembleShell('pasarguard', id).html, + context: pasarguardContext(doc, { links: LINKS }), + }))); + pages.forEach((html, i) => { + const m = toModel(extractIsland(html)); + assert.deepEqual(m, pgExpected(doc, LINKS), `${ids[i]}: the island carries the panel's figures`); + assert.ok(html.startsWith(''), `${ids[i]}: the page starts with the doctype`); + assert.ok(html.trimEnd().endsWith(''), `${ids[i]}: and ends with `); + assert.equal(TEMPLATE_DELIMITER.test(html.replace(/\{\{ 7\*7 \}\}/g, '')), false, + `${ids[i]}: no template syntax is left in the served page`); + }); +}); + +test('every design renders on Rebecca\'s pongo2 to a contract-valid island', () => { + const doc = RB.find((d) => d.case === '00-showcase'); + const ids = templateIds(); + const pages = renderRebecca(ids.map((id) => ({ + html: assembleShell('rebecca', id).html, + context: rebeccaContext(doc, { links: LINKS }), + }))); + pages.forEach((html, i) => { + const m = toModel(extractIsland(html)); + assert.deepEqual(m, rbExpected(doc, LINKS), `${ids[i]}: the island carries the panel's figures`); + assert.ok(html.startsWith(''), `${ids[i]}: the page starts with the doctype`); + assert.equal(TEMPLATE_DELIMITER.test(html), false, `${ids[i]}: no template syntax is left in the served page`); + }); +}); + +/* --- every fixture ---------------------------------------------------------- */ + +test('PasarGuard: every fixture renders exactly the model its adapter produces', () => { + const docs = PG.filter((d) => d.expected.model !== null); + const pages = renderPasarGuard(docs.map((d) => ({ html: SHELL.pasarguard, context: pasarguardContext(d) }))); + pages.forEach((html, i) => { + assert.deepEqual(toModel(extractIsland(html)), pgExpected(docs[i], []), docs[i].case); + }); + assert.ok(docs.some((d) => d.case === '08-on-hold'), 'on_hold is among them, no longer refused'); +}); + +test('Rebecca: every fixture renders exactly the model its adapter produces', () => { + const docs = RB.filter((d) => d.expected.model !== null); + const pages = renderRebecca(docs.map((d) => ({ html: SHELL.rebecca, context: rebeccaContext(d) }))); + pages.forEach((html, i) => { + assert.deepEqual(toModel(extractIsland(html)), rbExpected(docs[i], []), docs[i].case); + }); + assert.ok(docs.some((d) => d.case === '08-on-hold'), 'on_hold is among them, no longer refused'); +}); + +/* --- the states that matter ------------------------------------------------- */ + +test('PasarGuard on_hold: enabled, and the clock starts on first connection for the hold duration', () => { + const doc = PG.find((d) => d.case === '08-on-hold'); + const [html] = renderPasarGuard([{ html: SHELL.pasarguard, context: pasarguardContext(doc) }]); + const m = toModel(extractIsland(html)); + assert.equal(m.enabled, true); + assert.equal(m.expire, -doc.native.info.on_hold_expire_duration, 'expire is the negative hold duration'); + assert.match(html, /Starts on first connection/, 'and the no-script text says so'); +}); + +test('on_hold without a known duration is unknown, never "never expires"', () => { + const pg = PG.find((d) => d.case === '22-on-hold-no-duration'); + const rb = RB.find((d) => d.case === '08-on-hold'); + const [pgHtml] = renderPasarGuard([{ html: SHELL.pasarguard, context: pasarguardContext(pg) }]); + const [rbHtml] = renderRebecca([{ html: SHELL.rebecca, context: rebeccaContext(rb) }]); + for (const [name, html] of [['PasarGuard', pgHtml], ['Rebecca', rbHtml]]) { + const m = toModel(extractIsland(html)); + assert.equal(m.enabled, true, `${name}: on_hold is enabled`); + assert.equal(m.expire, null, `${name}: the expiry is unknown`); + assert.doesNotMatch(html, /id="expiry-value">Never expires/, `${name}: and is never shown as "never"`); + } +}); + +test('PasarGuard: the limited and expired statuses stay enabled and keep their figures', () => { + const docs = PG.filter((d) => ['20-status-limited', '21-status-expired'].includes(d.case)); + const pages = renderPasarGuard(docs.map((d) => ({ html: SHELL.pasarguard, context: pasarguardContext(d) }))); + pages.forEach((html, i) => { + const m = toModel(extractIsland(html)); + assert.equal(m.enabled, true, `${docs[i].case}: enabled`); + assert.deepEqual(m, pgExpected(docs[i], []), docs[i].case); + }); +}); + +test('Rebecca: a status the panel does not know renders disabled, as Rebecca classes it', () => { + const doc = RB.find((d) => d.case === '17-unknown-status'); + const [html] = renderRebecca([{ html: SHELL.rebecca, context: rebeccaContext(doc) }]); + const m = toModel(extractIsland(html)); + assert.equal(m.enabled, false); + assert.equal(m.online, false, 'and a disabled subscription is never online'); +}); + +/* --- time ------------------------------------------------------------------- */ + +test('PasarGuard: expire from a zoneless database datetime matches the panel\'s own header', () => { + /* PasarGuard's subscription-userinfo header is int(expire.timestamp()), and + the panel runs in UTC. A naive datetime read under TZ=UTC must give the + same second the header reports. */ + const doc = PG.find((d) => d.case === '01-active-online'); + const ctx = pasarguardContext(doc); + ctx.user.expire = '2026-11-02T12:00:00'; + ctx.user.expire_naive = true; + const [html] = renderPasarGuard([{ html: SHELL.pasarguard, context: ctx }], { TZ: 'UTC' }); + assert.equal(toModel(extractIsland(html)).expire, 1793620800); +}); + +test('PasarGuard: online is true inside 120 s of now() and false outside it', () => { + const doc = PG.find((d) => d.case === '01-active-online'); + const seen = Date.parse(doc.native.info.online_at) / 1000; + const cases = [[0, true], [120, true], [121, false], [-5, false]]; + const pages = renderPasarGuard(cases.map(([age]) => ({ + html: SHELL.pasarguard, context: pasarguardContext(doc, { now: seen + age }), + }))); + pages.forEach((html, i) => { + assert.equal(toModel(extractIsland(html)).online, cases[i][1], `age ${cases[i][0]} s`); + }); +}); + +/* Rebecca's online_at arrives without a zone and is UTC. pongo2 cannot parse a + date, so the prelude converts the civil date with integer arithmetic. Every + instant here goes through the SHIPPED prelude on the real engine. */ +function rebeccaTimeProbe(onlineAt, now) { + const body = '\n

{{ lastOnline }}|{% if isOnline %}1{% else %}0{% endif %}

\n\n'; + return { + html: wrapForPanel('rebecca', 'pongo2', body), + context: { + user: { status: 'active', status_class: 'active', used_traffic: 0, online_at: onlineAt, subscription_url: '' }, + links: [], support_url: '', current_timestamp: now, + }, + }; +} + +function probeResult(html) { + const m = html.match(/

([^|]*)\|([01])<\/p>/); + assert.ok(m, 'the probe rendered'); + return { lastOnline: m[1] === '' ? null : Number(m[1]), online: m[2] === '1' }; +} + +test('Rebecca: online_at converts to the exact UTC millisecond for every date form', () => { + /* A deterministic spread: every month boundary, leap days, both centuries. */ + const instants = []; + let seed = 20260918; + const rand = () => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed / 2147483648; }; + for (let i = 0; i < 400; i += 1) instants.push(Math.floor(rand() * 4102444800)); // 1970..2100 + for (const s of ['1970-01-01', '2000-02-29', '2024-02-29', '2024-03-01', '2100-02-28', '2026-12-31']) { + instants.push(Date.parse(`${s}T23:59:59Z`) / 1000); + } + const pad = (n, w = 2) => String(n).padStart(w, '0'); + const forms = (sec) => { + const d = new Date(sec * 1000); + const civil = `${pad(d.getUTCFullYear(), 4)}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`; + const clock = `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`; + return [`${civil} ${clock}`, `${civil}T${clock}Z`, `${civil}T${clock}.123456789Z`, `${civil} ${clock}.5`]; + }; + const jobs = []; + const want = []; + for (const sec of instants) { + for (const f of forms(sec)) { + jobs.push(rebeccaTimeProbe(f, sec + 30)); + want.push(sec * 1000); + } + } + const pages = renderRebecca(jobs, { TZ: 'Asia/Tehran' }); + pages.forEach((html, i) => { + const r = probeResult(html); + assert.equal(r.lastOnline, want[i], `instant ${want[i]}: ${jobs[i].context.user.online_at}`); + assert.equal(r.online, true, 'thirty seconds ago is online'); + }); +}); + +test('Rebecca: the online window and a value with a foreign offset', () => { + const at = '2026-09-18 11:58:30'; + const sec = Date.parse('2026-09-18T11:58:30Z') / 1000; + const cases = [ + [at, sec + 120, sec * 1000, true], + [at, sec + 121, sec * 1000, false], + [at, sec - 1, sec * 1000, false], // a clock behind the record is not "online" + ['2026-09-18T11:58:30+03:30', sec, null, false], // a non-UTC offset is not guessed + ['garbage', sec, null, false], + [null, sec, null, false], + ]; + const pages = renderRebecca(cases.map(([v, now]) => rebeccaTimeProbe(v, now))); + pages.forEach((html, i) => { + const r = probeResult(html); + assert.equal(r.lastOnline, cases[i][2], `lastOnline for ${cases[i][0]}`); + assert.equal(r.online, cases[i][3], `online for ${cases[i][0]} at +${cases[i][1] - sec}s`); + }); +}); + +/* --- hostile data ----------------------------------------------------------- */ + +function hostileContexts() { + const pgDoc = PG.find((d) => d.case === '01-active-online'); + const rbDoc = RB.find((d) => d.case === '01-active-online'); + const pg = pasarguardContext(pgDoc, { links: [`vless://x@h:1#${HOSTILE}`] }); + pg.user.username = HOSTILE; + pg.user.admin = { support_url: `https://t.me/${HOSTILE}` }; + pg.announce = `Maintenance ${HOSTILE}`; + const rb = rebeccaContext(rbDoc, { links: [`ss://x@h:1#${HOSTILE}`] }); + rb.user.username = HOSTILE; + rb.user.subscription_url = `https://sub.example.com/sub/${HOSTILE}`; + rb.support_url = `https://t.me/${HOSTILE}`; + return { pg, rb }; +} + +test('hostile panel data never becomes markup or template code on either page', () => { + const { pg, rb } = hostileContexts(); + const benignPg = pasarguardContext(PG.find((d) => d.case === '01-active-online')); + const benignRb = rebeccaContext(RB.find((d) => d.case === '01-active-online')); + const [pgBad, pgGood] = renderPasarGuard([ + { html: SHELL.pasarguard, context: pg }, { html: SHELL.pasarguard, context: benignPg }]); + const [rbBad, rbGood] = renderRebecca([ + { html: SHELL.rebecca, context: rb }, { html: SHELL.rebecca, context: benignRb }]); + const count = (html, re) => (html.match(re) || []).length; + for (const [name, bad, good] of [['PasarGuard', pgBad, pgGood], ['Rebecca', rbBad, rbGood]]) { + assert.equal(count(bad, / 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/ 0) ? substr(rest, 1, e - 1) : rest + } else { + c = index(v, " #"); if (c > 0) v = substr(v, 1, c - 1) + sub(/[ \t]+$/, "", v) + } + val = v; found = 1 + } + END { if (found) { printf "%s", val; exit 0 } exit 3 } + ' "$file" } # --- input validation -------------------------------------------------------- @@ -431,23 +509,35 @@ rt_stage_template_store() { # anything is staged. A payload without a templates/ directory (an older # release) simply carries no store; that is the caller's signal to fall back # to the top-level artifact. - local payload="$1" dir id want - [ -d "$payload/templates" ] || return 0 - for dir in "$payload"/templates/*/; do + # + # The designs come from the payload subtree of the panel this install serves + # (rt_payload_store): templates//template.html for 3X-UI, and + # shells///shell.html for PasarGuard and Rebecca. Either way they + # land in the store under the same name, so everything after staging is + # panel-agnostic. Every artifact must also fit the panel + # (rt_artifact_fits_panel) -- a store must never hold a page the panel + # cannot render safely. + local payload="$1" dir id want spec sub name sidecar + spec="$(rt_payload_store)"; sub="${spec%%|*}"; name="${spec#*|}" + [ -d "$payload/$sub" ] || return 0 + for dir in "$payload/$sub"/*/; do [ -d "$dir" ] || continue id="$(basename "$dir")" case "$id" in *[!a-z0-9]*|"") rt_warn "payload template directory is not a plain id: $id (skipped)"; continue ;; esac - [ -f "$dir/template.html" ] || { rt_warn "payload template $id has no template.html (skipped)"; continue; } - [ -f "$dir/template.html.sha256" ] || { rt_err "payload template $id has no checksum sidecar"; return 1; } - want="$(LC_ALL=C awk '{print $1; exit}' "$dir/template.html.sha256")" - rt_verify_sha256 "$dir/template.html" "$want" || { rt_err "payload template $id failed its checksum"; return 1; } - rt_validate_template "$dir/template.html" || { rt_err "payload template $id failed structural validation"; return 1; } + [ -f "$dir/$name" ] || { rt_warn "payload template $id has no $name (skipped)"; continue; } + sidecar="$dir/$name.sha256" + [ -f "$sidecar" ] || { rt_err "payload template $id has no checksum sidecar"; return 1; } + want="$(LC_ALL=C awk '{print $1; exit}' "$sidecar")" + rt_verify_sha256 "$dir/$name" "$want" || { rt_err "payload template $id failed its checksum"; return 1; } + rt_validate_template "$dir/$name" || { rt_err "payload template $id failed structural validation"; return 1; } + rt_artifact_fits_panel "$dir/$name" \ + || { rt_err "payload template $id is not a $(rt_panel_label "$(rt_panel_current)") page"; return 1; } rt_assert_not_symlink "$RT_TEMPLATE_STORE/$id" || return 1 mkdir -p "$RT_TEMPLATE_STORE/$id" || return 1 - rt_atomic_install "$dir/template.html" "$RT_TEMPLATE_STORE/$id/template.html" 644 || return 1 - rt_atomic_install "$dir/template.html.sha256" "$RT_TEMPLATE_STORE/$id/template.html.sha256" 644 || return 1 + rt_atomic_install "$dir/$name" "$RT_TEMPLATE_STORE/$id/template.html" 644 || return 1 + rt_atomic_install "$sidecar" "$RT_TEMPLATE_STORE/$id/template.html.sha256" 644 || return 1 done return 0 } @@ -568,7 +658,8 @@ rt_repair_template_store() { [ -e "$src/$id" ] || [ -L "$src/$id" ] || continue [ "$(rt_template_store_status "$id")" = "ok" ] && continue if ! rt_template_entry_ok "$src/$id" \ - || ! rt_validate_template "$src/$id/template.html" >/dev/null 2>&1; then + || ! rt_validate_template "$src/$id/template.html" >/dev/null 2>&1 \ + || ! rt_artifact_fits_panel "$src/$id/template.html"; then rt_warn "the copy of design '$id' in $src fails its checksum or structural check; it was not moved." warned=1 continue @@ -966,6 +1057,9 @@ rt_backup_create() { local tpl_id tpl_id="$(rt_template_id_for_artifact "$RT_DIST")" [ -n "$tpl_id" ] && printf 'template=%s\n' "$tpl_id" >> "$dir/meta" + # the panel the artifact was made for (1.3.0+); a backup without it is 3X-UI. + # Unlike template=, this one IS read: a restore refuses another panel's backup. + printf 'panel=%s\n' "$(rt_panel_current)" >> "$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" @@ -1364,6 +1458,9 @@ rt_backup_snapshot_check() { rt_backup_panel_state "$dir" "$p" >/dev/null || return 1 rt_backup_panel_meta_check "$dir" "$p" || return 1 rt_backup_panel_files "$dir" "$p" >/dev/null || return 1 + if [ -e "$dir/panels/$p/aux" ] || [ -L "$dir/panels/$p/aux" ]; then + rt_backup_panel_aux_check "$dir/panels/$p/aux" || return 1 + fi done return 0 } @@ -1507,6 +1604,72 @@ rt_backup_panel_write() { return 0 } +# --- the aux record (1.3.0) --------------------------------------------------- +# A panel may need more than one setting recorded to be restored exactly -- +# Rebecca selects its page by TWO columns, and one of them distinguishes NULL +# from ''. `selection` stays the panel's primary selection, unchanged; `aux` +# is an OPTIONAL file beside it holding further values: +# +# = one per line, LC_ALL=C sorted +# +# Keys are a closed grammar ([a-z_], 1-32 chars) and values are base64, so no +# value can break a line or be read as anything but data. A missing key reads +# as the empty string. A snapshot without `aux` is exactly a pre-1.3.0 one, and +# every reader accepts it; a present `aux` must be well formed or the snapshot +# is refused (rt_backup_snapshot_check). + +rt_backup_panel_aux_key_ok() { + case "${1:-}" in ''|*[!a-z_]*) return 1 ;; esac + [ "${#1}" -le 32 ] +} + +rt_backup_panel_aux_check() { + # 0 when FILE is a well-formed aux record: key=base64 lines, unique keys. + local f="$1" line key seen="" + [ -L "$f" ] && return 1 + [ -f "$f" ] || return 1 + while IFS= read -r line || [ -n "$line" ]; do + key="${line%%=*}" + [ "$key" != "$line" ] || return 1 + rt_backup_panel_aux_key_ok "$key" || return 1 + case "${line#*=}" in *[!A-Za-z0-9+/=]*) return 1 ;; esac + case ",$seen," in *",$key,"*) return 1 ;; esac + seen="${seen:+$seen,}$key" + done < "$f" + return 0 +} + +rt_backup_panel_aux_set() { + # rt_backup_panel_aux_set STAGE PANEL KEY VALUE -- record VALUE under KEY for + # a panel already staged by rt_backup_panel_write. Replaces an earlier value. + local stage="${1:-}" panel="${2:-}" key="${3:-}" value="${4:-}" d f tmp + rt_panel_id_ok "$panel" || { rt_err "aux: unknown panel id: $panel"; return 1; } + rt_backup_panel_aux_key_ok "$key" || { rt_err "aux: not a legal key: $key"; return 1; } + d="$stage/$panel" + [ -d "$d" ] && [ ! -L "$d" ] || { rt_err "aux: panel $panel is not staged"; return 1; } + f="$d/aux" + tmp="$(mktemp "$d/.aux.XXXXXX")" || return 1 + { + if [ -f "$f" ]; then LC_ALL=C grep -v "^${key}=" "$f" || true; fi + printf '%s=%s\n' "$key" "$(printf '%s' "$value" | rt_b64_encode)" + } | LC_ALL=C sort > "$tmp" || { rm -f "$tmp"; return 1; } + mv -f "$tmp" "$f" || { rm -f "$tmp"; return 1; } +} + +rt_backup_panel_aux() { + # rt_backup_panel_aux SNAPSHOT PANEL KEY -- echo the recorded value (empty + # when the key or the whole record is absent). 1 when the record is malformed. + local snap="${1:-}" panel="${2:-}" key="${3:-}" f line + rt_panel_id_ok "$panel" || return 1 + rt_backup_panel_aux_key_ok "$key" || return 1 + f="$snap/panels/$panel/aux" + [ -e "$f" ] || [ -L "$f" ] || return 0 + rt_backup_panel_aux_check "$f" || return 1 + line="$(LC_ALL=C grep "^${key}=" "$f" || true)" + [ -n "$line" ] || return 0 + printf '%s' "${line#*=}" | rt_b64_decode +} + rt_backup_manifest_write() { # Write SNAPDIR/manifest: one " " line per captured # file, LC_ALL=C sorted, never including the manifest itself. @@ -1594,6 +1757,7 @@ rt_backup_create_v2() { || { rt_safe_rmdir "$tmp"; return 1; } tpl_id="$(rt_template_id_for_artifact "$RT_DIST")" [ -n "$tpl_id" ] && printf 'template=%s\n' "$tpl_id" >> "$tmp/meta" + printf 'panel=%s\n' "$(rt_panel_current)" >> "$tmp/meta" # NOTE: no `format=` key is written into meta, and no `panels=` key either. # The format lives in its own canonical file; the panel list is the # filesystem, because a key can claim a panel whose state is not on disk. @@ -1606,7 +1770,7 @@ rt_backup_create_v2() { [ -d "$src" ] || { rt_err "no staged state for panel: $panel"; rt_safe_rmdir "$tmp"; return 1; } d="$tmp/panels/$panel" mkdir -p "$d" || { rt_safe_rmdir "$tmp"; return 1; } - for f in selection.state selection meta files; do + for f in selection.state selection meta files aux; do [ -e "$src/$f" ] || [ -L "$src/$f" ] || { # `files` is REQUIRED for a touched panel; the other three are # conditional on the state word. A staged panel without a files list @@ -1960,6 +2124,8 @@ rt_set_dist() { # 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_artifact_fits_panel "$src" \ + || { rt_err "refusing to install an artifact that is not a $(rt_panel_label "$(rt_panel_current)") page"; 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 @@ -1969,12 +2135,27 @@ 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 + # + # On PasarGuard and Rebecca the panel reads a COPY of the page, placed in its + # own template directory; when one is placed it is replaced here too, so the + # panel never serves a page older than the one just generated. The panel's + # selection is not touched: that is activation's job (rt_panel_activate). + local staged dir panel rc=0 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" + panel="$(rt_panel_current)" + if [ "$panel" != "3xui" ]; then + rt_installer_complete || { rt_err "the installer's panel components are missing; run 'row-template update'."; return 1; } + rt_panel_refresh_page "$panel" "$RT_LIVE" || rc=$? + case "$rc" in + 0|3) : ;; + *) rt_err "could not update the page in $(rt_panel_label "$panel")'s template directory."; return 1 ;; + esac + fi + return 0 } rt_monogram_preview() { @@ -2099,7 +2280,19 @@ rt_cleanup() { 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 + local r rv panel rc=0 + panel="$(rt_panel_current)" + if [ "$panel" != "3xui" ] && [ -n "${RT_PANELS_LOADED:-}" ] && [ -z "${RT_SMOKE_URL:-}" ]; then + # Without a subscription URL (a secret this tool never looks up), the + # strongest evidence is the running panel's own view of its settings. + rt_panel_verify "$panel" live >/dev/null 2>&1 || rc=$? + case "$rc" in + 0) rt_ok "Live check: the running $(rt_panel_label "$panel") uses the Row-Template page." ;; + 2) rt_info "Live check skipped (set RT_SMOKE_URL to a subscription URL to check the served page)." ;; + *) rt_warn "Live check: the running $(rt_panel_label "$panel") does not use the Row-Template page yet; run 'row-template verify'." ;; + esac + return 0 + fi r="$(rt_render_smoke)" case "$r" in pass) rt_ok "Live check: a browser request renders Row-Template." ;; @@ -2117,10 +2310,21 @@ rt_render_report() { } rt_print_activation_note() { - # $1 = auto | manual + # $1 = auto | manual | skipped | failed + local panel; panel="$(rt_panel_current)" rt_section "Row-Template installed successfully." + rt_info "Panel: $(rt_panel_label "$panel")" rt_info "Template directory: $RT_ROOT" - rt_info "Served file: $RT_LIVE" + rt_info "Generated page: $RT_LIVE" + if [ "$panel" != "3xui" ]; then + case "$1" in + auto) rt_ok "$(rt_panel_label "$panel") now serves the Row-Template page." ;; + manual) rt_section "One manual step remains"; rt_panel_manual_steps ;; + failed) rt_warn "Activation did not complete and was rolled back; run 'row-template' and choose Activate to retry." ;; + *) rt_info "Not activated yet: run 'row-template' and choose Activate when you are ready." ;; + esac + return 0 + fi if [ "$1" = "auto" ]; then rt_ok "Panel configured automatically: subThemeDir = $RT_ROOT" else @@ -2132,13 +2336,17 @@ rt_print_activation_note() { } rt_cmd_version() { - local rtv xuiv + local rtv xuiv panel 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" + panel="$(rt_installed_panel)" printf 'Row-Template %s\n' "$rtv" - printf 'Supported 3x-ui minimum: %s\n' "$RT_MIN_XUI" - printf 'Detected 3x-ui: %s\n' "$xuiv" + [ -n "$panel" ] && printf 'Panel: %s\n' "$(rt_panel_label "$panel")" + if [ -z "$panel" ] || [ "$panel" = "3xui" ]; then + rt_detect_xui >/dev/null 2>&1 || true + xuiv="$(rt_detect_xui_version 2>/dev/null || true)"; [ -n "$xuiv" ] || xuiv="unknown" + printf 'Supported 3x-ui minimum: %s\n' "$RT_MIN_XUI" + printf 'Detected 3x-ui: %s\n' "$xuiv" + fi } # --- release acquisition (download -> verify -> extract) --------------------- @@ -2363,8 +2571,15 @@ rt_restore_from_backup() { # predates the current release's store. If the checksum match fails (the backup # artifact is not byte-identical to any installed template), fall back to the # template recorded in the backup's meta, then to Row as a last resort. - local dir="$1" tpl_id + local dir="$1" tpl_id bpanel rt_backup_validate "$dir" || { rt_err "backup failed validation: $dir"; return 1; } + # A backup is only ever restored onto the panel it was made for. Every backup + # before 1.3.0 is a 3X-UI one, which is what an absent panel= means. + bpanel="$(rt_backup_meta panel "$dir")"; [ -n "$bpanel" ] || bpanel="3xui" + if [ "$bpanel" != "$(rt_panel_current)" ]; then + rt_err "backup $(basename "$dir") was made for $(rt_panel_label "$bpanel"), not $(rt_panel_label "$(rt_panel_current)"); refusing to restore it." + return 1 + fi tpl_id="$(rt_template_id_for_artifact "$dir/template.html")" if [ -z "$tpl_id" ]; then tpl_id="$(rt_backup_meta template "$dir")" @@ -2401,6 +2616,298 @@ rt_subtheme_clear_sqlite() { [ -z "$cur" ] } +# --- panels: which panel an install serves (1.3.0) --------------------------- +# Row-Template installs onto exactly one panel per host. The panel decides +# three things and nothing else: +# +# the ARTIFACT a Go template for 3X-UI, a Jinja2 page for PasarGuard, a +# pongo2 page for Rebecca (the release ships all three) +# the ROOT see RT_ROOT above +# ACTIVATION 3X-UI: subThemeDir (unchanged since 1.0.0). PasarGuard and +# Rebecca: their adapter, driven by the transaction engine. +# +# Branding, the template store, backups, rollback and the manager are the same +# code for all three. The panel an install serves is recorded in RT_PANEL_FILE; +# an install without the record predates 1.3.0, and every such install is 3X-UI. + +RT_ACTIVE_PANEL="" + +rt_panel_label() { + case "${1:-}" in + 3xui) printf '3X-UI' ;; + pasarguard) printf 'PasarGuard' ;; + rebecca) printf 'Rebecca' ;; + *) printf '%s' "${1:-unknown}" ;; + esac +} + +rt_installed_panel() { + # echo the panel of the install at RT_ROOT, or nothing when none is there. + local p + if [ -f "$RT_PANEL_FILE" ] && [ ! -L "$RT_PANEL_FILE" ]; then + p="$(head -n1 "$RT_PANEL_FILE" 2>/dev/null | LC_ALL=C tr -cd 'a-z0-9')" + if rt_panel_id_ok "$p"; then printf '%s' "$p"; return 0; fi + rt_warn "the panel record $RT_PANEL_FILE is not a panel this release knows; treating the install as 3X-UI." + fi + if [ -f "$RT_VERSION_FILE" ] || [ -f "$RT_DIST" ]; then printf '3xui'; fi + return 0 +} + +rt_panel_current() { + # echo the panel the current command acts on: the one being installed, else + # the installed one, else 3X-UI (the only panel before 1.3.0). + local p="${RT_ACTIVE_PANEL:-}" + [ -n "$p" ] || p="$(rt_installed_panel)" + [ -n "$p" ] || p="3xui" + printf '%s' "$p" +} + +rt_panel_record() { + # persist PANEL as the panel this install serves, atomically. + local tmp + rt_panel_id_ok "$1" || return 1 + rt_assert_not_symlink "$RT_PANEL_FILE" || return 1 + tmp="$(mktemp "$RT_ROOT/.panel.XXXXXX")" || return 1 + printf '%s\n' "$1" > "$tmp" && chmod 644 "$tmp" && mv -f "$tmp" "$RT_PANEL_FILE" || { rm -f "$tmp"; return 1; } +} + +rt_panel_on_host() { + # 0 when PANEL is on this host. 3X-UI keeps the rule it has had since 1.0.0 + # (its binary or its unit); PasarGuard and Rebecca need their adapter's two + # corroborating signals. Read-only. + local rc=0 + case "$1" in + 3xui) rt_detect_xui >/dev/null 2>&1 ;; + pasarguard|rebecca) + [ -n "${RT_PANELS_LOADED:-}" ] || return 1 + rt_panel_detect "$1" >/dev/null 2>&1 || rc=$? + [ "$rc" -eq 0 ] ;; + *) return 1 ;; + esac +} + +rt_panels_on_host() { + # echo every panel on this host, one per line, in RT_PANEL_IDS order. + local p + for p in $RT_PANEL_IDS; do + if rt_panel_on_host "$p"; then printf '%s\n' "$p"; fi + done + return 0 +} + +rt_existing_root() { + # echo the root of an existing install on this host (RT_ROOT when it was set + # explicitly), or nothing. Two installs at once are refused: which one the + # CLI manages would be a guess. + local found="" r + if [ -n "$RT_ROOT_EXPLICIT" ]; then + [ -f "$RT_ROOT/VERSION" ] && printf '%s' "$RT_ROOT" + return 0 + fi + for r in "$RT_ROOT_3XUI" "$RT_ROOT_SHARED"; do + [ -f "$r/VERSION" ] || continue + if [ -n "$found" ]; then + rt_err "Row-Template is installed twice ($found and $r); remove one with 'row-template uninstall' before continuing." + return 1 + fi + found="$r" + done + printf '%s' "$found" +} + +rt_panel_choose() { + # Decide the panel to install for: set RT_ACTIVE_PANEL and move RT_ROOT to + # that panel's root, IN THIS SHELL (never call it inside $( ): the root + # change would be lost with the subshell). In order: + # 1. an existing install: its panel, at its root (a repair or re-run); + # 2. RT_PANEL from the environment, which must name a panel on this host; + # 3. the one panel on this host; with several, the operator chooses + # (interactively) or must set RT_PANEL. + # Non-zero, with the reason printed, when no panel can be chosen. + local root panel found p n=0 i=0 choice + local -a list=() + root="$(rt_existing_root)" || return 1 + if [ -n "$root" ]; then + [ -n "$RT_ROOT_EXPLICIT" ] || rt_root_set "$root" + panel="$(rt_installed_panel)" + if [ -n "${RT_PANEL:-}" ] && [ "${RT_PANEL}" != "$panel" ]; then + rt_err "Row-Template is installed for $(rt_panel_label "$panel") at $RT_ROOT; uninstall it before installing for $(rt_panel_label "$RT_PANEL")." + return 1 + fi + RT_ACTIVE_PANEL="$panel" + return 0 + fi + + if [ -n "${RT_PANEL:-}" ]; then + rt_panel_id_ok "$RT_PANEL" || { rt_err "RT_PANEL='$RT_PANEL' is not a panel Row-Template supports (3xui, pasarguard, rebecca)."; return 1; } + if ! rt_panel_on_host "$RT_PANEL"; then + if [ "$RT_PANEL" = "3xui" ]; then rt_err "no 3x-ui installation was detected on this host." + else rt_err "RT_PANEL=$RT_PANEL, but $(rt_panel_label "$RT_PANEL") was not detected on this host."; fi + return 1 + fi + panel="$RT_PANEL" + else + found="$(rt_panels_on_host)" + n="$(printf '%s' "$found" | grep -c . || true)" + if [ "$n" -eq 0 ]; then + rt_panel_report_partial + rt_err "no supported panel was detected on this host: no 3x-ui installation, and no PasarGuard or Rebecca installation." + return 1 + elif [ "$n" -eq 1 ]; then + panel="$found" + elif rt_ui_is_interactive; then + { rt_ui_section "More than one panel is installed on this host"; } >&2 + while IFS= read -r p; do list+=("$p"); done <<< "$found" + for p in "${list[@]}"; do + i=$((i + 1)); printf ' %s%d%s %s\n' "$RT_C_BLD" "$i" "$RT_C_RST" "$(rt_panel_label "$p")" >&2 + done + choice="$(rt_ui_menu_select "$n")" + [ "$choice" -ge 1 ] 2>/dev/null || { rt_err "no panel was chosen; nothing was changed."; return 1; } + panel="${list[$((choice - 1))]}" + else + rt_err "more than one panel is installed here ($(printf '%s' "$found" | tr '\n' ' ')); choose one with RT_PANEL=3xui|pasarguard|rebecca." + return 1 + fi + fi + if [ -z "$RT_ROOT_EXPLICIT" ] && [ "$panel" != "3xui" ]; then rt_root_set "$RT_ROOT_SHARED"; fi + RT_ACTIVE_PANEL="$panel" + return 0 +} + +rt_panel_report_partial() { + # Say so when a panel is half-there (one detection signal): refusing to act + # on it is right, but the operator deserves to know why. + local p rc + [ -n "${RT_PANELS_LOADED:-}" ] || return 0 + for p in pasarguard rebecca; do + rc=0; rt_panel_detect "$p" >/dev/null 2>&1 || rc=$? + if [ "$rc" -eq "$RT_PANEL_FAIL" ]; then + rt_warn "$(rt_panel_label "$p") looks partly installed (only one of its files was found); it is not treated as present." + fi + done + return 0 +} + +rt_payload_store() { + # echo "

|": where the release payload keeps this panel's designs. + case "$(rt_panel_current)" in + 3xui) printf 'templates|template.html' ;; + *) printf 'shells/%s|shell.html' "$(rt_panel_current)" ;; + esac +} + +rt_artifact_fits_panel() { + # 0 when FILE is an artifact for the panel this install serves. The store, + # a backup or a payload can each hand the wrong one over, and each would + # break the page in its own way: a Go template on PasarGuard renders its + # actions as text; a Jinja2 page on 3X-UI fails to parse. A shell from + # before 1.3.0 is refused on PasarGuard and Rebecca: it has no context + # prelude (the page would render empty) and no autoescape block. + local f="$1" + case "$(rt_panel_current)" in + 3xui) + if LC_ALL=C grep -Eq '\{%-? *(autoescape|if|for|set|comment) ' "$f" 2>/dev/null; then return 1; fi + return 0 ;; + pasarguard) declare -F rt_panel_pasarguard_shell_ok >/dev/null && rt_panel_pasarguard_shell_ok "$f" ;; + rebecca) declare -F rt_panel_rebecca_shell_ok >/dev/null && rt_panel_rebecca_shell_ok "$f" ;; + *) return 1 ;; + esac +} + +rt_panel_activation_record() { + # remember SNAPSHOT as the state before Row-Template took over the panel, for + # uninstall to go back to -- unless the panel was ALREADY showing + # Row-Template when it was taken (a re-apply), in which case the earlier + # record is the true "before" and is kept. + local snap="$1" panel="$2" st v + [ -n "$snap" ] || return 0 + st="$(rt_backup_panel_state "$snap" "$panel" 2>/dev/null || true)" + v="$(rt_backup_panel_selection "$snap" "$panel" 2>/dev/null || true)" + if [ "$st" = "present" ] && [ "$v" = "row-template/index.html" ] && [ -f "$RT_PANEL_ACTIVATION" ]; then + return 0 + fi + printf '%s\n' "$(basename "$snap")" > "$RT_PANEL_ACTIVATION" 2>/dev/null || true + chmod 600 "$RT_PANEL_ACTIVATION" 2>/dev/null || true +} + +rt_panel_activate() { + # Make PasarGuard or Rebecca serve the generated page. Echo the outcome: + # auto placed and selected, through the transaction engine (snapshot, + # verify, and an automatic restore if anything fails) + # manual the page is placed, but the selection cannot be written here + # (Rebecca on MySQL/MariaDB, or without sqlite3): the operator + # selects it in the panel (rt_print_panel_manual) + # Non-zero when activation failed; the panel is then as it was. + local panel rc=0 + panel="$(rt_panel_current)" + rt_installer_complete || { rt_err "the installer's panel components are missing; run 'row-template update'."; return 1; } + if [ "$(rt_panel_status "$panel")" = "manual" ]; then + rt_panel_refresh_page "$panel" "$RT_LIVE" place >/dev/null || rc=$? + [ "$rc" -eq 0 ] || { rt_err "could not place the page for $(rt_panel_label "$panel")."; return 1; } + printf 'manual' + return 0 + fi + local errf + errf="$(mktemp)" || return 1 + if RT_TXN_QUIET=1 rt_transaction_run "$panel" "$RT_LIVE" >&2 2>"$errf"; then + cat "$errf" >&2; rm -f "$errf" + rt_panel_activation_record "$RT_TXN_SNAPSHOT" "$panel" + printf 'auto' + return 0 + fi + # The engine claims ROLLED_BACK only when its post-restore static check + # passes, and that check asks the INSTALL question ("does the panel serve + # Row-Template?"), whose honest answer after a rollback is no -- so a clean + # restore is reported as a failed one. Whether the restore was exact is + # decided here instead, by capturing the panel's state again and comparing it + # with the snapshot the transaction took before it changed anything. + if [ "${RT_TXN_MUTATED:-0}" = "1" ] && rt_panel_restore_confirmed "$panel" "${RT_TXN_SNAPSHOT:-}"; then + LC_ALL=C awk '{ print } /transaction: (template placement failed|static verification did not pass|live verification failed)/ { exit }' "$errf" >&2 + rt_warn "$(rt_panel_label "$panel") was restored exactly to its state before the attempt." + else + cat "$errf" >&2 + fi + rm -f "$errf" + return 1 +} + +rt_panel_restore_confirmed() { + # 0 when PANEL's state now equals what SNAPSHOT recorded before the change: + # the same record, captured again by the adapter, byte for byte. Anything that + # cannot be captured or compared is "not confirmed". + local panel="$1" snap="$2" f a b + [ -n "$snap" ] && [ -d "$snap/panels/$panel" ] || return 1 + rt_transaction_stage_reset >/dev/null 2>&1 || return 1 + rt_panel_backup_state "$panel" >/dev/null 2>&1 || return 1 + for f in selection.state selection meta files aux; do + a="$snap/panels/$panel/$f"; b="$RT_PANEL_STAGE/$panel/$f" + if [ -e "$a" ] || [ -e "$b" ]; then + cmp -s "$a" "$b" || return 1 + fi + done + rt_transaction_stage_reset >/dev/null 2>&1 || true + return 0 +} + +rt_panel_manual_steps() { + # print what the operator must set in the panel when activation is manual. + case "$(rt_panel_current)" in + rebecca) + rt_info "In the Rebecca dashboard: Settings -> Subscription -> Templates" + rt_info " Subscription page template: row-template/index.html" + rt_info " Custom templates directory: ${RT_RB_DATA_DIR:-/var/lib/rebecca}/templates" + rt_info "If a custom templates directory is already set, keep it and copy" + rt_info " ${RT_RB_DATA_DIR:-/var/lib/rebecca}/templates/row-template/ into it instead." + rt_info "(Automatic activation needs the sqlite3 command and Rebecca's SQLite database.)" ;; + pasarguard) + rt_info "In ${RT_PG_APP_DIR:-/opt/pasarguard}/.env set, then run 'pasarguard restart':" + rt_info " SUBSCRIPTION_PAGE_TEMPLATE = \"row-template/index.html\"" ;; + *) + rt_info "In the panel: Settings -> Subscription -> Sub Theme Directory" + rt_info "Set it to exactly: $RT_ROOT" ;; + esac +} + # --- high-level flow: install ------------------------------------------------ # Called by installer/install.sh with a verified, extracted payload directory. # Runs the whole transaction: preflight -> stage -> validate -> backup -> @@ -2408,7 +2915,7 @@ rt_subtheme_clear_sqlite() { # panel: the live template is only ever swapped atomically after validation. rt_cmd_install() { - local payload="$1" w picked_explicit="" picked source + local payload="$1" w picked_explicit="" picked panel 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." @@ -2421,11 +2928,20 @@ rt_cmd_install() { 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." - rt_detect_xui_version >/dev/null 2>&1 || true - rt_check_min_version - rt_detect_xui_db || true + # Which panel: an existing install's, the operator's RT_PANEL, or the one on + # this host. This also decides the install root (rt_panel_choose). + rt_panel_choose || rt_die "nothing was changed." + panel="$RT_ACTIVE_PANEL" + + # environment discovery + hard version gate (fail closed). 3X-UI only: the + # other panels are identified by their adapter, and their activation does not + # depend on a panel version. + if [ "$panel" = "3xui" ]; then + 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 + fi rt_assert_not_symlink "$RT_ROOT" || rt_die "install root is a symlink; refusing to proceed." @@ -2446,7 +2962,7 @@ rt_cmd_install() { repair) rt_info "Repairing in place (configuration preserved)." ;; esac else - rt_install_welcome "${RT_XUI_VERSION:-}" || { rt_info "Installation cancelled."; return 0; } + rt_install_welcome "$panel" "${RT_XUI_VERSION:-}" || { rt_info "Installation cancelled."; return 0; } fi elif [ "$existing" -eq 1 ]; then if [ ! -t 0 ] && [ -z "${RT_ASSUME_YES:-}" ]; then @@ -2460,9 +2976,14 @@ rt_cmd_install() { 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." + # stage the canonical artifact + supporting files (all atomic, symlink-guarded). + # The payload's top-level template.html is the 3X-UI Row artifact; on the + # other panels the canonical artifact comes from their own store, below. + if [ "$panel" = "3xui" ]; then + rt_set_dist "$payload/template.html" || rt_die "could not install the canonical artifact." + fi rt_atomic_install "$payload/VERSION" "$RT_VERSION_FILE" 644 || rt_die "could not install VERSION." + rt_panel_record "$panel" || rt_die "could not record the panel this install serves." 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." @@ -2474,12 +2995,15 @@ rt_cmd_install() { || rt_warn "could not install the row-template CLI to $RT_BIN." fi - # template store: every design this release ships, verified before staging, - # and any store a previous path mistake left outside dist/templates moved in. + # template store: every design this release ships for this panel, verified + # before staging, and any store a previous path mistake left outside + # dist/templates moved in. local store_rc=0 rt_repair_template_store "$payload" || store_rc=$? [ "$store_rc" -ne 1 ] \ || rt_die "the release template store failed verification; nothing was activated." + rt_template_store_has row \ + || rt_die "this release carries no $(rt_panel_label "$panel") pages; nothing was activated." # the fresh-install design chooser (interactive only; defaults to Row). if [ "$interactive" -eq 1 ] && [ "$existing" -eq 0 ]; then @@ -2530,10 +3054,12 @@ rt_cmd_install() { # 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. + # point the panel at Row-Template. NON-INTERACTIVE: activate exactly as + # before. INTERACTIVE: show what will change and ASK first. local sub_outcome - if [ "$interactive" -eq 1 ]; then + if [ "$panel" != "3xui" ]; then + sub_outcome="$(rt_install_activate_panel "$interactive")" + elif [ "$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=$? @@ -2571,6 +3097,40 @@ rt_cmd_install() { rt_render_report } +rt_install_activate_panel() { + # INTERACTIVE ($1=1): show what activation changes, ask, then activate. + # Echo auto | manual | skipped | failed on stdout; everything else to stderr. + local interactive="$1" panel outcome + panel="$(rt_panel_current)" + if [ "$interactive" -eq 1 ]; then + { + rt_ui_section "Activate Row-Template on $(rt_panel_label "$panel")" + case "$panel" in + pasarguard) + rt_ui_info "This places the page in PasarGuard's templates directory, adds a" + rt_ui_info "Row-Template block to ${RT_PG_APP_DIR:-/opt/pasarguard}/.env selecting it, and" + rt_ui_info "restarts PasarGuard once. Your users, nodes and settings are not touched." + rt_ui_info "Uninstalling removes the block again." ;; + rebecca) + rt_ui_info "This places the page in Rebecca's templates directory and selects it" + rt_ui_info "in Rebecca's subscription settings. No restart is needed. Your users," + rt_ui_info "nodes and other settings are not touched." ;; + esac + } >&2 + if ! rt_ui_confirm "Make Row-Template the active $(rt_panel_label "$panel") subscription page now?" yes; then + printf 'skipped' + return 0 + fi + fi + if outcome="$(rt_panel_activate)"; then + printf '%s' "$outcome" + else + rt_warn "activation on $(rt_panel_label "$panel") did not complete; the panel was restored to how it was." + printf 'failed' + fi + return 0 +} + # --- high-level flow: config ------------------------------------------------- # Reconfigure branding. The new template is generated and validated BEFORE the # live file is swapped, and the canonical artifact is reconciled to the @@ -2723,20 +3283,27 @@ rt_cmd_verify() { [ -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 + local vpanel + vpanel="$(rt_panel_current)" + if [ "$vpanel" = "3xui" ]; then + 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 + else + rt_verify_panel "$vpanel" || fails=$((fails + 1)) + warns=$((warns + RT_VERIFY_PANEL_WARNS)) + 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. @@ -2746,7 +3313,11 @@ rt_cmd_verify() { rt_warn "leftover staging files found under the install root (possible interrupted update)."; warns=$((warns + 1)) fi - r="$(rt_render_smoke)" + if [ "$vpanel" != "3xui" ] && [ -z "${RT_SMOKE_URL:-}" ]; then + r="skip" + else + r="$(rt_render_smoke)" + fi 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)) ;; @@ -2769,6 +3340,47 @@ rt_cmd_verify() { else rt_ok "verification passed with no warnings."; return 0; fi } +RT_VERIFY_PANEL_WARNS=0 +rt_verify_panel() { + # verify's checks for PasarGuard and Rebecca. Sets RT_VERIFY_PANEL_WARNS; + # returns non-zero on a hard failure. Prints only facts, never a secret. + local panel="$1" st rc=0 + RT_VERIFY_PANEL_WARNS=0 + if ! rt_installer_complete; then + rt_err "the panel components are missing; run 'row-template update'." + return 1 + fi + if rt_panel_on_host "$panel"; then rt_ok "$(rt_panel_label "$panel") detected." + else rt_warn "$(rt_panel_label "$panel") was not detected on this host."; RT_VERIFY_PANEL_WARNS=$((RT_VERIFY_PANEL_WARNS + 1)); fi + st="$(rt_panel_status "$panel")" + case "$st" in + active) + rt_panel_verify "$panel" static || rc=$? + if [ "$rc" -eq 0 ]; then + rt_ok "$(rt_panel_label "$panel") selects the Row-Template page, and the placed page is current." + else + rt_err "$(rt_panel_label "$panel")'s Row-Template page is out of step (see above); re-apply it from the manager (Activate)." + return 1 + fi + rc=0; rt_panel_verify "$panel" live || rc=$? + case "$rc" in + 0) rt_ok "Live check: the running $(rt_panel_label "$panel") uses the Row-Template page." ;; + 2) rt_info "Live check skipped (not available for this panel layout)." ;; + *) rt_warn "the running $(rt_panel_label "$panel") does not use the Row-Template page yet; restart the panel." + RT_VERIFY_PANEL_WARNS=$((RT_VERIFY_PANEL_WARNS + 1)) ;; + esac ;; + inactive) + rt_warn "$(rt_panel_label "$panel") does not select the Row-Template page; activate it from the manager." + RT_VERIFY_PANEL_WARNS=$((RT_VERIFY_PANEL_WARNS + 1)) ;; + manual) + rt_info "Panel selection not checked (activation is manual here: $(rt_panel_label "$panel")'s database cannot be read)." ;; + *) + rt_warn "the $(rt_panel_label "$panel") configuration could not be read." + RT_VERIFY_PANEL_WARNS=$((RT_VERIFY_PANEL_WARNS + 1)) ;; + esac + return 0 +} + # --- 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 @@ -2794,6 +3406,31 @@ rt_uninstall_files() { return 0 } +rt_uninstall_panel() { + # Put PasarGuard or Rebecca back to the page it had before Row-Template, and + # remove the page Row-Template placed. Returns non-zero only when the revert + # FAILED; "nothing to revert" and "must be reverted by hand" are reported and + # let the uninstall continue. + local panel="$1" rc=0 + if ! rt_installer_complete; then + rt_err "the panel components are missing, so $(rt_panel_label "$panel") cannot be reverted automatically; run 'row-template update' first." + return 1 + fi + rt_panel_uninstall_template "$panel" || rc=$? + case "$rc" in + 0) rt_ok "$(rt_panel_label "$panel") is back on the subscription page it had before Row-Template." ;; + 3) rt_info "$(rt_panel_label "$panel") was not using Row-Template; its selection was left as it is." ;; + 2) + rt_warn "$(rt_panel_label "$panel")'s selection cannot be changed automatically here." + case "$panel" in + rebecca) rt_info "In the Rebecca dashboard set Subscription page template back to subscription/index.html (or your own page)." ;; + *) rt_info "Select your previous subscription page in the panel." ;; + esac ;; + *) rt_err "could not revert $(rt_panel_label "$panel")."; return 1 ;; + esac + 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." @@ -2809,6 +3446,19 @@ rt_cmd_uninstall() { rt_detect_xui || true rt_detect_xui_db || true + local upanel + upanel="$(rt_panel_current)" + if [ "$upanel" != "3xui" ]; then + rt_uninstall_panel "$upanel" || rt_die "uninstall stopped before removing anything; the panel and Row-Template are unchanged." + if rt_uninstall_files; then + rt_ok "Removed Row-Template files from $RT_ROOT." + rt_info "$(rt_panel_label "$upanel")'s users, nodes, settings and database were left untouched." + else + rt_die "uninstall could not complete safely; see the message above. No forced deletion was performed." + fi + return 0 + fi + # 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 @@ -2872,8 +3522,10 @@ rt_cmd_update() { picked="row" if rt_template_store_has "row"; then source="$RT_TEMPLATE_STORE/row/template.html" - else + elif [ "$(rt_panel_current)" = "3xui" ]; then source="$payload/template.html" + else + rt_die "this release carries no $(rt_panel_label "$(rt_panel_current)") pages; nothing was changed." fi fi rt_validate_template "$source" || rt_die "the selected template failed structural validation." @@ -2963,7 +3615,7 @@ rt_cmd_rollback() { rt_print_help() { cat <<'EOF' -Row-Template — custom subscription page manager for 3x-ui +Row-Template — custom subscription page manager for 3X-UI, PasarGuard and Rebecca by iitzSeriZdev — https://github.com/iitzSeriZdev/Row-Template Usage: @@ -2976,7 +3628,7 @@ Commands: rollback Restore a previous version [--auto | --to ] verify Check the install, panel wiring and live render (as root, also restores missing or misplaced designs) - version Show installed, minimum-supported and detected 3x-ui versions + version Show the installed version and panel (and, on 3X-UI, its version) uninstall Remove Row-Template and revert the panel to its built-in page menu Open the interactive manager explicitly help Show this help @@ -3071,6 +3723,17 @@ rt_status_theme() { if [ ! -f "$RT_DIST" ] || ! rt_validate_template "$RT_LIVE" >/dev/null 2>&1; then printf 'damaged'; return 0 fi + local panel + panel="$(rt_panel_current)" + if [ "$panel" != "3xui" ]; then + [ -n "${RT_PANELS_LOADED:-}" ] || { printf 'unknown'; return 0; } + case "$(rt_panel_status "$panel")" in + active) printf 'active' ;; + inactive) printf 'inactive' ;; + *) printf 'unknown' ;; + esac + 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 @@ -3089,6 +3752,18 @@ rt_status_label() { } rt_status_service_label() { # human label for the panel service, from discovery already run by the caller. + local panel + panel="$(rt_panel_current)" + if [ "$panel" != "3xui" ]; then + if declare -F "rt_panel_${panel}_running" >/dev/null && "rt_panel_${panel}_running" 2>/dev/null; then + printf '%s (running)' "$(rt_panel_label "$panel")" + elif rt_panel_on_host "$panel"; then + printf '%s (stopped)' "$(rt_panel_label "$panel")" + else + printf 'not detected' + fi + return 0 + fi 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 @@ -3100,7 +3775,12 @@ 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)' ;; + unknown) + if [ "$(rt_panel_current)" = "3xui" ]; then + printf 'Row-Template (installed; activation not verifiable without sqlite3)' + else + printf 'Row-Template (installed; activation not verifiable here)' + fi ;; damaged) printf 'Row-Template (files incomplete — run Verify/Repair)' ;; *) printf 'Row-Template (not installed)' ;; esac @@ -3120,7 +3800,11 @@ rt_manager_dashboard() { st="$(rt_status_theme)" rt_ui_header rt_ui_kv "Version" "$rtv" - rt_ui_kv "3X-UI" "$xuiv" + if [ "$(rt_panel_current)" = "3xui" ]; then + rt_ui_kv "3X-UI" "$xuiv" + else + rt_ui_kv "Panel" "$(rt_panel_label "$(rt_panel_current)")" + fi rt_ui_kv "Status" "$(rt_status_label "$st")" rt_ui_kv "Template" "$(rt_template_display_name "$(rt_template_effective)")" rt_ui_kv "Theme" "$(rt_status_theme_label "$st")" @@ -3167,6 +3851,10 @@ 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" + if [ "$(rt_panel_current)" != "3xui" ]; then + rt_manager_activate_panel + return 0 + fi rt_detect_xui >/dev/null 2>&1 || true rt_detect_xui_db >/dev/null 2>&1 || true local rc=0 cur @@ -3201,6 +3889,35 @@ rt_manager_activate() { rt_ui_kv "Enter exactly" "$RT_ROOT" fi } +rt_manager_activate_panel() { + # Activate / re-apply on PasarGuard or Rebecca. The page is regenerated first, + # so what is activated is exactly what verify will check. + local panel st outcome + panel="$(rt_panel_current)" + st="$(rt_panel_status "$panel")" + case "$st" in + active) + rt_ui_success "Row-Template is already the active $(rt_panel_label "$panel") subscription page." + rt_ui_confirm "Re-apply and verify anyway?" no || return 0 ;; + manual) + rt_ui_warn "Automatic activation is unavailable here; the page will be placed for you to select." ;; + *) + rt_ui_confirm "Make Row-Template the active $(rt_panel_label "$panel") subscription page now?" yes \ + || { rt_ui_info "Left unchanged."; return 0; } ;; + esac + rt_activate || { rt_ui_error "the page could not be generated; nothing was changed."; return 0; } + if outcome="$(rt_panel_activate)"; then + if [ "$outcome" = "manual" ]; then + rt_panel_manual_steps + else + rt_ui_success "Row-Template is active on $(rt_panel_label "$panel")." + rt_render_report + fi + else + rt_ui_warn "Activation did not complete; $(rt_panel_label "$panel") was restored to how it was." + 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. @@ -3217,7 +3934,11 @@ rt_manager_info() { rt_ui_kv "Version" "$rtv" rt_ui_kv "Developer" "$RT_DEVELOPER" rt_ui_kv "GitHub" "$RT_GITHUB" - rt_ui_kv "3X-UI" "$xuiv" + if [ "$(rt_panel_current)" = "3xui" ]; then + rt_ui_kv "3X-UI" "$xuiv" + else + rt_ui_kv "Panel" "$(rt_panel_label "$(rt_panel_current)")" + fi rt_ui_kv "Install dir" "$RT_ROOT" rt_ui_kv "Template" "$(rt_template_display_name "$(rt_template_effective)")" rt_ui_kv "Theme status" "$(rt_status_label "$st")" @@ -3463,12 +4184,20 @@ rt_manager_main() { # or alter a scripted install. rt_install_welcome() { - # $1 = detected 3x-ui version (may be empty). Returns 0 to proceed, 1 to abort. + # $1 = panel id, $2 = detected 3x-ui version (may be empty). Returns 0 to + # proceed, 1 to abort. + local panel="${1:-3xui}" 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." + if [ "$panel" = "3xui" ]; then + rt_ui_kv "Detected panel" "3X-UI ${2:-(version 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." + else + rt_ui_kv "Detected panel" "$(rt_panel_label "$panel")" + rt_ui_info "Your panel data is safe: users, nodes, hosts and settings are NOT" + rt_ui_info "modified. Only a subscription page is added, and selected." + fi rt_ui_kv "GitHub" "$RT_GITHUB" printf '\n' rt_ui_confirm "Continue installation?" yes @@ -3550,15 +4279,21 @@ rt_install_success_screen() { 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" tpl="$(rt_template_display_name "$(rt_template_effective)")" + # On 3X-UI a declined activation is the manual step it has always been; on + # the other panels the manager's Activate does it later. + if [ "$outcome" = "skipped" ] && [ "$(rt_panel_current)" = "3xui" ]; then outcome="manual"; fi case "$outcome" in - auto) theme="Active" ;; - *) theme="Manual activation required" ;; + auto) theme="Active" ;; + skipped) theme="Not activated (run row-template -> Activate)" ;; + failed) theme="Activation failed and was rolled back" ;; + *) 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 "Panel" "$(rt_panel_label "$(rt_panel_current)")" rt_ui_kv "Template" "$tpl" rt_ui_kv "Install dir" "$RT_ROOT" rt_ui_kv "Theme" "$theme" @@ -3566,9 +4301,13 @@ rt_install_success_screen() { 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" + if [ "$outcome" = "manual" ]; then + if [ "$(rt_panel_current)" = "3xui" ]; then + rt_ui_info "To activate: Panel Settings -> Subscription -> Sub Theme Directory" + rt_ui_kv "Enter exactly" "$RT_ROOT" + else + rt_panel_manual_steps + fi fi } # --- panel interface (P3) ----------------------------------------------------- diff --git a/installer/panels/index.sh b/installer/panels/index.sh index c7b8f3f..3e1adda 100644 --- a/installer/panels/index.sh +++ b/installer/panels/index.sh @@ -9,15 +9,13 @@ # importantly — that P5 can add an implementation by changing this file alone, # without touching a single line of the contract. # -# TODAY THE ANSWER IS ALWAYS "NONE". P5 has not been written, so no panel has an -# implementation. This file does not pretend otherwise: it does not define a -# stub that returns SUCCESS, and it does not fall back to a generic -# implementation. Returning success for work that did not happen is the one -# failure mode a transaction engine cannot detect and cannot recover from. -# -# There are deliberately NO panel-specific files here (3xui.sh, pasarguard.sh, -# rebecca.sh). A file that exists is a file something can bind to; a stub that -# pretends to be an implementation is how a "temporary" shim becomes permanent. +# ALL THREE PANELS ARE IMPLEMENTED (3X-UI since P5A; PasarGuard and Rebecca +# since 1.3.0), each by one adapter file beside this one. The registry still +# never defines a stub that returns SUCCESS and never falls back to a generic +# implementation: a panel whose adapter file is absent or does not load is +# reported as having no implementation, which every verb answers UNAVAILABLE. +# Returning success for work that did not happen is the one failure mode a +# transaction engine cannot detect and cannot recover from. # --------------------------------------------------------------------------- # --- implementation files --------------------------------------------------- @@ -36,6 +34,8 @@ # an undefined function at call time, and "command not found" is an exit 127 # that no return-code contract describes. RT_PANEL_3XUI_LOADED="" +RT_PANEL_PASARGUARD_LOADED="" +RT_PANEL_REBECCA_LOADED="" rt_panel_registry_dir="$(dirname "${BASH_SOURCE[0]}")" if [ -f "$rt_panel_registry_dir/3xui.sh" ]; then if . "$rt_panel_registry_dir/3xui.sh"; then @@ -44,6 +44,20 @@ if [ -f "$rt_panel_registry_dir/3xui.sh" ]; then rt_err "panel registry: the 3xui adapter exists but could not be loaded" fi fi +if [ -f "$rt_panel_registry_dir/pasarguard.sh" ]; then + if . "$rt_panel_registry_dir/pasarguard.sh"; then + RT_PANEL_PASARGUARD_LOADED=1 + else + rt_err "panel registry: the pasarguard adapter exists but could not be loaded" + fi +fi +if [ -f "$rt_panel_registry_dir/rebecca.sh" ]; then + if . "$rt_panel_registry_dir/rebecca.sh"; then + RT_PANEL_REBECCA_LOADED=1 + else + rt_err "panel registry: the rebecca adapter exists but could not be loaded" + fi +fi unset rt_panel_registry_dir # --- implementation registry ----------------------------------------------- @@ -67,22 +81,14 @@ rt_panel_impl_for() { # differs per operation (UNAVAILABLE for most, NOT_APPLICABLE for detection). local panel="${1:-}" rt_panel_id_ok "$panel" || return 0 - # P5A: 3X-UI is implemented. It is reported ONLY when its adapter actually - # loaded, so a payload missing the file reports "none" rather than sending a - # caller to a function that is not there. - # - # The other two panels are still ABSENT from this mapping on purpose. They are - # not mapped to a function that reports success, and not mapped to a shared - # fallback: absence is the representation of "not implemented", and an absent - # key is impossible to mistake for a working one. + # Each panel is reported ONLY when its adapter actually loaded, so a payload + # missing a file reports "none" rather than sending a caller to a function + # that is not there. 3X-UI since P5A; PasarGuard and Rebecca since 1.3.0. case "$panel" in - 3xui) - if [ -n "${RT_PANEL_3XUI_LOADED:-}" ]; then - printf '%s\n' "3xui" - fi - return 0 ;; + 3xui) if [ -n "${RT_PANEL_3XUI_LOADED:-}" ]; then printf '%s\n' "3xui"; fi ;; + pasarguard) if [ -n "${RT_PANEL_PASARGUARD_LOADED:-}" ]; then printf '%s\n' "pasarguard"; fi ;; + rebecca) if [ -n "${RT_PANEL_REBECCA_LOADED:-}" ]; then printf '%s\n' "rebecca"; fi ;; esac - # P5B/P5C: add one line per implemented panel here. return 0 } @@ -132,6 +138,20 @@ rt_panel_dispatch() { 3xui:verify) rt_panel_3xui_verify "$panel" "$@" ;; 3xui:restore_state) rt_panel_3xui_restore_state "$panel" "$@" ;; 3xui:uninstall_template) rt_panel_3xui_uninstall_template "$panel" "$@" ;; + pasarguard:detect) rt_panel_pasarguard_detect "$panel" "$@" ;; + pasarguard:capabilities) rt_panel_pasarguard_capabilities "$panel" "$@" ;; + pasarguard:backup_state) rt_panel_pasarguard_backup_state "$panel" "$@" ;; + pasarguard:install_template) rt_panel_pasarguard_install_template "$panel" "$@" ;; + pasarguard:verify) rt_panel_pasarguard_verify "$panel" "$@" ;; + pasarguard:restore_state) rt_panel_pasarguard_restore_state "$panel" "$@" ;; + pasarguard:uninstall_template) rt_panel_pasarguard_uninstall_template "$panel" "$@" ;; + rebecca:detect) rt_panel_rebecca_detect "$panel" "$@" ;; + rebecca:capabilities) rt_panel_rebecca_capabilities "$panel" "$@" ;; + rebecca:backup_state) rt_panel_rebecca_backup_state "$panel" "$@" ;; + rebecca:install_template) rt_panel_rebecca_install_template "$panel" "$@" ;; + rebecca:verify) rt_panel_rebecca_verify "$panel" "$@" ;; + rebecca:restore_state) rt_panel_rebecca_restore_state "$panel" "$@" ;; + rebecca:uninstall_template) rt_panel_rebecca_uninstall_template "$panel" "$@" ;; *) rt_err "panel dispatch: no dispatch arm for implementation '$impl' verb '$verb'" return "$RT_PANEL_FAIL" ;; @@ -156,3 +176,41 @@ rt_panel_impl_install_template() { rt_panel_dispatch install_template "$@"; } rt_panel_impl_verify() { rt_panel_dispatch verify "$@"; } rt_panel_impl_restore_state() { rt_panel_dispatch restore_state "$@"; } rt_panel_impl_uninstall_template() { rt_panel_dispatch uninstall_template "$@"; } + +# --- outside the transaction (1.3.0) ----------------------------------------- +# Two helpers the MANAGEMENT commands use and the transaction engine never +# does. They are not a second way to perform any of the seven verbs above: +# +# rt_panel_refresh_page PANEL SOURCE [place] +# replace the page an adapter has placed (after a branding or design +# change) WITHOUT touching the panel's selection; `place` places it even +# when none is there yet (manual activation). 0 replaced, 3 nothing of +# ours is placed, 2 unavailable here, 1 failure. +# rt_panel_status PANEL +# echo active | inactive | manual | unknown, for the dashboard. +# +# 3X-UI places nothing: its page is served from the install root directly, so +# refresh is NOT_APPLICABLE and its status stays with rt_status_theme. +rt_panel_refresh_page() { + local panel="${1:-}" impl + rt_panel_id_ok "$panel" || return "$RT_PANEL_FAIL" + shift + impl="$(rt_panel_impl_for "$panel")" + case "$impl" in + pasarguard) rt_panel_pasarguard_refresh "$@" ;; + rebecca) rt_panel_rebecca_refresh "$@" ;; + 3xui) return "$RT_PANEL_NOT_APPLICABLE" ;; + *) return "$RT_PANEL_UNAVAILABLE" ;; + esac +} + +rt_panel_status() { + local panel="${1:-}" impl + rt_panel_id_ok "$panel" || return "$RT_PANEL_FAIL" + impl="$(rt_panel_impl_for "$panel")" + case "$impl" in + pasarguard) rt_panel_pasarguard_status ;; + rebecca) rt_panel_rebecca_status ;; + *) printf 'unknown' ;; + esac +} diff --git a/installer/panels/pasarguard.sh b/installer/panels/pasarguard.sh new file mode 100644 index 0000000..9892616 --- /dev/null +++ b/installer/panels/pasarguard.sh @@ -0,0 +1,609 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# installer/panels/pasarguard.sh -- the PasarGuard panel adapter (1.3.0). +# +# Sourced by installer/panels/index.sh and reached only through the seven +# public rt_panel_* verbs of installer/panels/interface.sh; the transaction +# engine never calls in here by name. +# +# WHAT PASARGUARD ACTIVATION ACTUALLY IS (audited against PasarGuard 5.x +# source and the official installer, docs/design/PASARGUARD-INSTALLER-AUDIT.md): +# +# * PasarGuard renders its subscription page with Jinja2 from a +# FileSystemLoader whose search path is [CUSTOM_TEMPLATES_DIRECTORY, +# app/templates], and picks the page by SUBSCRIPTION_PAGE_TEMPLATE. Both +# are read from the environment ONCE, at start-up. +# * The official installer runs it in Docker from /opt/pasarguard with +# `env_file: .env` and the bind mount /var/lib/pasarguard:/var/lib/pasarguard, +# so a path under /var/lib/pasarguard is the same path on the host and in +# the container. A source install runs it as pasarguard.service, reading +# .env from its working directory. +# +# So activation is two things, and this adapter does exactly those: +# +# 1. PLACE the generated page at /row-template/index.html, where +# is the operator's CUSTOM_TEMPLATES_DIRECTORY or, when there +# is none, /var/lib/pasarguard/templates. Only that one file is ever +# written, inside a directory named for Row-Template, and a file there +# that is not Row-Template's is never overwritten. +# 2. SELECT it by appending a MANAGED BLOCK to .env: +# +# # >>> row-template (managed by Row-Template; do not edit) nl=0 >>> +# CUSTOM_TEMPLATES_DIRECTORY = "/var/lib/pasarguard/templates" +# SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" +# # <<< row-template <<< +# +# dotenv (python-dotenv and Docker Compose alike) takes the LAST +# assignment of a key, so the block wins without a single operator line +# being edited. Removing the block is therefore the exact inverse of +# adding it: the file returns to its previous bytes, and any line the +# operator changed in the meantime is kept. CUSTOM_TEMPLATES_DIRECTORY is +# written only when the operator has not set one. `nl=1` records that a +# newline was added to a file that ended without one, so removal restores +# that too. +# +# Then the panel is restarted (docker compose up -d, which recreates the +# container because its environment changed), but only if it was running. +# Later page updates (a new design, new branding) replace the file alone: +# Jinja2 re-reads a changed template, so no further restart is needed. +# +# SECRETS. .env holds the panel's database URL, admin credentials and more. +# This adapter reads it only to find the two keys above; it never prints it, +# never copies it outside its own directory (the atomic rewrite stages a copy +# beside it, with the same mode), and never puts any of it in a snapshot. +# --------------------------------------------------------------------------- + +# The capability set, LC_ALL=C order, every token backed by code below. +RT_PANEL_PASARGUARD_CAPABILITIES="env_activation file_placement live_verify selection_read selection_write service_control static_verify" + +# Locations. Overridable (tests, a non-default APP_NAME), never derived from +# panel output. Read at call time, so a test may set them after sourcing. +: "${RT_PG_APP_DIR:=/opt/pasarguard}" +: "${RT_PG_DATA_DIR:=/var/lib/pasarguard}" +: "${RT_PG_CLI:=/usr/local/bin/pasarguard}" +: "${RT_PG_PROJECT:=pasarguard}" +: "${RT_PG_UNIT:=pasarguard.service}" + +RT_PG_SUBDIR="row-template" +RT_PG_PAGE="row-template/index.html" +RT_PG_KEY_PAGE="SUBSCRIPTION_PAGE_TEMPLATE" +RT_PG_KEY_DIR="CUSTOM_TEMPLATES_DIRECTORY" +RT_PG_BLOCK_OPEN="# >>> row-template (managed by Row-Template; do not edit)" +RT_PG_BLOCK_CLOSE="# <<< row-template <<<" + +# --- environment ------------------------------------------------------------ + +rt_panel_pasarguard_compose() { printf '%s' "$RT_PG_APP_DIR/docker-compose.yml"; } + +rt_panel_pasarguard_compose_ok() { + # 0 when the compose file exists and runs PasarGuard's own image. + local f; f="$(rt_panel_pasarguard_compose)" + [ -f "$f" ] && [ ! -L "$f" ] || return 1 + LC_ALL=C grep -Eq '^[[:space:]]*image:[[:space:]]*["'"'"']?(docker\.io/)?pasarguard/panel([:@"'"'"'[:space:]]|$)' "$f" 2>/dev/null +} + +rt_panel_pasarguard_unit_ok() { + # 0 when a pasarguard systemd unit is registered. No `grep -q` on a pipe: + # under pipefail, grep -q closing early SIGPIPEs systemctl (see rt_detect_xui). + command -v systemctl >/dev/null 2>&1 || return 1 + systemctl list-unit-files 2>/dev/null | LC_ALL=C grep "^${RT_PG_UNIT//./\\.}" >/dev/null 2>&1 +} + +rt_panel_pasarguard_mode() { + # docker | systemd | none. Docker wins: it is the official layout. + if rt_panel_pasarguard_compose_ok; then printf 'docker'; return 0; fi + if rt_panel_pasarguard_unit_ok; then printf 'systemd'; return 0; fi + printf 'none' +} + +rt_panel_pasarguard_env() { + # echo the .env the panel reads. Docker reads APP_DIR/.env (env_file); + # a source install reads .env from the unit's WorkingDirectory. + local wd + if [ "$(rt_panel_pasarguard_mode)" = "systemd" ]; then + wd="$(systemctl show -p WorkingDirectory --value "$RT_PG_UNIT" 2>/dev/null || true)" + if [ -n "$wd" ] && [ -f "$wd/.env" ]; then printf '%s' "$wd/.env"; return 0; fi + fi + printf '%s' "$RT_PG_APP_DIR/.env" +} + +rt_panel_pasarguard_env_ready() { + # 0 when the .env exists as a regular file we may edit. + local e; e="$(rt_panel_pasarguard_env)" + [ -f "$e" ] && [ ! -L "$e" ] +} + +# --- .env reading (as data; nothing is sourced or evaluated) ---------------- + +rt_panel_pasarguard_env_get() { + # rt_panel_pasarguard_env_get KEY [outside] + # + # Echo the value the panel will read for KEY (rt_dotenv_get: last assignment + # wins). With "outside", our managed block is ignored -- the operator's own + # value. Exit 0 with the value (possibly empty) when KEY is assigned, 3 when + # it is not: "unset" and "set to empty" are different facts, and a restore + # needs both. + local key="$1" outside="${2:-}" + if [ -n "$outside" ]; then + rt_dotenv_get "$(rt_panel_pasarguard_env)" "$key" "$RT_PG_BLOCK_OPEN" "$RT_PG_BLOCK_CLOSE" + else + rt_dotenv_get "$(rt_panel_pasarguard_env)" "$key" + fi +} + +rt_panel_pasarguard_block_state() { + # absent | present | malformed. Present means exactly one opening and one + # closing marker, in that order. Anything else is not ours to interpret. + local env n_open n_close + env="$(rt_panel_pasarguard_env)" + [ -f "$env" ] || { printf 'absent'; return 0; } + n_open="$(LC_ALL=C grep -Fc "$RT_PG_BLOCK_OPEN" "$env" 2>/dev/null || true)" + n_close="$(LC_ALL=C grep -Fxc "$RT_PG_BLOCK_CLOSE" "$env" 2>/dev/null || true)" + if [ "${n_open:-0}" = "0" ] && [ "${n_close:-0}" = "0" ]; then printf 'absent'; return 0; fi + if [ "$n_open" = "1" ] && [ "$n_close" = "1" ]; then + local lo lc + lo="$(LC_ALL=C grep -Fn "$RT_PG_BLOCK_OPEN" "$env" | head -n1 | cut -d: -f1)" + lc="$(LC_ALL=C grep -Fxn "$RT_PG_BLOCK_CLOSE" "$env" | head -n1 | cut -d: -f1)" + if [ -n "$lo" ] && [ -n "$lc" ] && [ "$lo" -lt "$lc" ]; then printf 'present'; return 0; fi + fi + printf 'malformed' +} + +rt_panel_pasarguard_block_value() { + # Echo KEY's value inside our block, empty when the block does not set it. + local key="$1" env + env="$(rt_panel_pasarguard_env)" + RT_K="$key" RT_BO="$RT_PG_BLOCK_OPEN" RT_BC="$RT_PG_BLOCK_CLOSE" LC_ALL=C awk ' + BEGIN { want = ENVIRON["RT_K"]; bo = ENVIRON["RT_BO"]; bc = ENVIRON["RT_BC"]; inb = 0 } + { line = $0; sub(/\r$/, "", line) } + index(line, bo) == 1 { inb = 1; next } + line == bc { inb = 0; next } + inb { + eq = index(line, "="); if (eq == 0) next + k = substr(line, 1, eq - 1); sub(/[ \t]+$/, "", k) + if (k != want) next + v = substr(line, eq + 1); sub(/^[ \t]+/, "", v); gsub(/"/, "", v) + printf "%s", v + } + ' "$env" 2>/dev/null || true +} + +# --- .env writing (atomic, mode-preserving, block only) --------------------- + +rt_panel_pasarguard_path_ok() { + # A path we are willing to write into .env: absolute, and only characters + # that need no quoting or escaping in any dotenv dialect. + case "${1:-}" in + /*) : ;; + *) return 1 ;; + esac + case "$1" in + *[!A-Za-z0-9._/-]*|*//*|*/../*|*/..|*/./*) return 1 ;; + esac + return 0 +} + +rt_panel_pasarguard_env_rewrite() { + # rt_panel_pasarguard_env_rewrite MODE [DIR] + # MODE=remove drop our block (restoring a missing final newline) + # MODE=write DIR|"" replace our block with one selecting our page; DIR + # is written as CUSTOM_TEMPLATES_DIRECTORY when given + # Staged beside the file with the file's own mode, then renamed over it, so + # the panel never reads a half-written .env. + local mode="$1" dir="${2:-}" env tmp nl=0 perm + env="$(rt_panel_pasarguard_env)" + [ -f "$env" ] && [ ! -L "$env" ] || { rt_err "panel pasarguard: .env is missing or a symlink: $env"; return 1; } + case "$(rt_panel_pasarguard_block_state)" in + malformed) rt_err "panel pasarguard: the Row-Template block in $env is damaged; fix or remove it by hand"; return 1 ;; + esac + perm="$(stat -c '%a' "$env" 2>/dev/null || echo 600)" + tmp="$(mktemp "$(dirname "$env")/.env.row-template.XXXXXX")" || return 1 + chmod 600 "$tmp" 2>/dev/null || true + + # 1. the file without our block, every other byte as it was -- including a + # last line that has no newline (awk would otherwise add one). A block + # written with nl=1 had itself added the newline before it; removing the + # block removes that newline again -- but only while the block is still + # the end of the file. A line the operator added after it owns it now. + local nonl=0 + if [ -s "$env" ] && [ "$(tail -c 1 "$env" | od -An -c | tr -d ' ')" != '\n' ]; then nonl=1; fi + RT_BO="$RT_PG_BLOCK_OPEN" RT_BC="$RT_PG_BLOCK_CLOSE" RT_NONL="$nonl" LC_ALL=C awk ' + BEGIN { bo = ENVIRON["RT_BO"]; bc = ENVIRON["RT_BC"]; nonl = (ENVIRON["RT_NONL"] == "1") + inb = 0; n = 0; strip = 0; closed = 0; lastkept = 0 } + { line = $0; raw = $0; sub(/\r$/, "", line) } + index(line, bo) == 1 { inb = 1; lastkept = 0; if (index(line, " nl=1 ") > 0) strip = 1; next } + line == bc { inb = 0; closed = 1; lastkept = 0; next } + inb { lastkept = 0; next } + closed { strip = 0 } + { buf[++n] = raw; lastkept = 1 } + END { + for (i = 1; i <= n; i++) { + if (i == n && (strip || (nonl && lastkept))) printf "%s", buf[i]; else printf "%s\n", buf[i] + } + } + ' "$env" > "$tmp" || { rm -f "$tmp"; return 1; } + + # 2. our block, appended. + if [ "$mode" = "write" ]; then + if [ -s "$tmp" ] && [ "$(tail -c 1 "$tmp" | od -An -c | tr -d ' ')" != '\n' ]; then + printf '\n' >> "$tmp"; nl=1 + fi + { + printf '%s nl=%s >>>\n' "$RT_PG_BLOCK_OPEN" "$nl" + [ -n "$dir" ] && printf '%s = "%s"\n' "$RT_PG_KEY_DIR" "$dir" + printf '%s = "%s"\n' "$RT_PG_KEY_PAGE" "$RT_PG_PAGE" + printf '%s\n' "$RT_PG_BLOCK_CLOSE" + } >> "$tmp" || { rm -f "$tmp"; return 1; } + fi + + chmod "$perm" "$tmp" 2>/dev/null || true + mv -f "$tmp" "$env" || { rm -f "$tmp"; return 1; } + return 0 +} + +# --- where the page goes ------------------------------------------------------ + +rt_panel_pasarguard_operator_dir() { + # The operator's own CUSTOM_TEMPLATES_DIRECTORY (outside our block), trailing + # slash removed; empty when unset or empty. + local v rc=0 + v="$(rt_panel_pasarguard_env_get "$RT_PG_KEY_DIR" outside)" || rc=$? + [ "$rc" -eq 0 ] || return 0 + v="${v%/}" + printf '%s' "$v" +} + +rt_panel_pasarguard_root() { + # Echo the templates root the page goes into: the operator's directory, else + # DATA_DIR/templates. In Docker the path must lie inside the bind-mounted + # DATA_DIR, the only place the host and the container see the same file; + # anything else is refused rather than guessed. + local root + root="$(rt_panel_pasarguard_operator_dir)" + [ -n "$root" ] || root="${RT_PG_DATA_DIR%/}/templates" + rt_panel_pasarguard_path_ok "$root" || { + rt_err "panel pasarguard: CUSTOM_TEMPLATES_DIRECTORY is not a plain absolute path: $root"; return 1; } + if [ "$(rt_panel_pasarguard_mode)" = "docker" ] && ! rt_is_within "$RT_PG_DATA_DIR" "$root"; then + rt_err "panel pasarguard: $root is outside $RT_PG_DATA_DIR, the directory the container shares with the host" + return 1 + fi + printf '%s' "$root" +} + +rt_panel_pasarguard_is_ours() { + # 0 when FILE is a page Row-Template generated for PasarGuard: our structural + # markers AND our prelude. Ownership is proven, never assumed. + local f="$1" + [ -f "$f" ] && [ ! -L "$f" ] || return 1 + LC_ALL=C grep -Fq '/* row:branding */' "$f" 2>/dev/null || return 1 + LC_ALL=C grep -Fq 'Row-Template -> PasarGuard page context' "$f" 2>/dev/null || return 1 + return 0 +} + +rt_panel_pasarguard_shell_ok() { + # 0 when FILE is a PasarGuard shell this release can serve safely: valid, with + # the context prelude and the autoescape block. A shell from a release before + # 1.3.0 has neither -- it would render empty and unescaped -- and is refused. + local f="$1" + rt_validate_template "$f" >/dev/null 2>&1 || return 1 + LC_ALL=C grep -Fq 'Row-Template -> PasarGuard page context' "$f" 2>/dev/null || return 1 + LC_ALL=C grep -Fq '{%- autoescape true -%}' "$f" 2>/dev/null || return 1 + LC_ALL=C grep -Fq '{%- endautoescape %}' "$f" 2>/dev/null || return 1 + return 0 +} + +# --- the service ---------------------------------------------------------------- + +rt_panel_pasarguard_container() { + # Echo the running panel container's name, or nothing. Found by its compose + # project label and its image, never by a name we assume. + command -v docker >/dev/null 2>&1 || return 0 + docker ps --filter "label=com.docker.compose.project=$RT_PG_PROJECT" --format '{{.Names}} {{.Image}}' 2>/dev/null \ + | LC_ALL=C awk '$2 ~ /^(docker\.io\/)?pasarguard\/panel([:@]|$)/ { print $1; exit }' || true +} + +rt_panel_pasarguard_running() { + case "$(rt_panel_pasarguard_mode)" in + docker) [ -n "$(rt_panel_pasarguard_container)" ] ;; + systemd) systemctl is-active --quiet "$RT_PG_UNIT" 2>/dev/null ;; + *) return 1 ;; + esac +} + +rt_panel_pasarguard_apply() { + # Make a changed .env take effect: recreate the container (compose sees the + # environment changed) or restart the unit. Only when the panel is running: + # a stopped panel picks the change up when its operator starts it. + rt_panel_pasarguard_running || return 0 + case "$(rt_panel_pasarguard_mode)" in + docker) + command -v docker >/dev/null 2>&1 || return 1 + rt_info "Restarting PasarGuard to apply the subscription page setting..." >&2 + docker compose -f "$(rt_panel_pasarguard_compose)" -p "$RT_PG_PROJECT" up -d >/dev/null 2>&1 || return 1 ;; + systemd) + rt_info "Restarting PasarGuard to apply the subscription page setting..." >&2 + systemctl restart "$RT_PG_UNIT" >/dev/null 2>&1 || return 1 ;; + esac + return 0 +} + +# --- the frozen verbs -------------------------------------------------------------- + +rt_panel_pasarguard_detect() { + # READ-ONLY. Two independent signals must agree (interface.sh): + # A the .env the official installer writes + # B a compose file running pasarguard/panel, or a pasarguard systemd unit + # C the pasarguard management CLI + # D the data directory + local signals=0 env + env="$RT_PG_APP_DIR/.env" + [ -f "$env" ] && [ ! -L "$env" ] && signals=$((signals + 1)) + if rt_panel_pasarguard_compose_ok || rt_panel_pasarguard_unit_ok; then signals=$((signals + 1)); fi + if [ -x "$RT_PG_CLI" ] && [ ! -d "$RT_PG_CLI" ] \ + && LC_ALL=C grep -q 'pasarguard' "$RT_PG_CLI" 2>/dev/null; then + signals=$((signals + 1)) + fi + [ -d "$RT_PG_DATA_DIR" ] && [ ! -L "$RT_PG_DATA_DIR" ] && signals=$((signals + 1)) + if [ "$signals" -ge 2 ]; then return "$RT_PANEL_OK"; fi + if [ "$signals" -eq 1 ]; then return "$RT_PANEL_FAIL"; fi + return "$RT_PANEL_NOT_APPLICABLE" +} + +rt_panel_pasarguard_capabilities() { + local t + for t in $RT_PANEL_PASARGUARD_CAPABILITIES; do printf '%s\n' "$t"; done + return "$RT_PANEL_OK" +} + +rt_panel_pasarguard_backup_state() { + # Stage the pre-change state through the P2 writer: + # selection the effective SUBSCRIPTION_PAGE_TEMPLATE (absent|empty|present) + # meta mechanism=env, was_running + # files the page, when THIS change will create it (a page that is + # already there and ours is replaced in place, not recorded) + # aux block=, dir=, and + # root_created=1 when the templates root does not exist yet + local panel="$1" state value="" rc=0 was_running=0 root page block files=() + rt_panel_pasarguard_env_ready || return "$RT_PANEL_UNAVAILABLE" + root="$(rt_panel_pasarguard_root)" || return "$RT_PANEL_FAIL" + block="$(rt_panel_pasarguard_block_state)" + [ "$block" = "malformed" ] && { rt_err "panel pasarguard: the Row-Template block in .env is damaged"; return "$RT_PANEL_FAIL"; } + rt_panel_pasarguard_running && was_running=1 + + value="$(rt_panel_pasarguard_env_get "$RT_PG_KEY_PAGE")" || rc=$? + if [ "$rc" -eq 3 ]; then state="absent"; value="" + elif [ "$rc" -ne 0 ]; then return "$RT_PANEL_FAIL" + elif [ -z "$value" ]; then state="empty" + else state="present"; fi + + page="$root/$RT_PG_PAGE" + [ -e "$page" ] || [ -L "$page" ] || files+=("$RT_PG_PAGE") + + rt_backup_panel_write "$RT_PANEL_STAGE" "$panel" "$state" "$value" env "$was_running" ${files[@]+"${files[@]}"} \ + || return "$RT_PANEL_FAIL" + rt_backup_panel_aux_set "$RT_PANEL_STAGE" "$panel" block "$block" || return "$RT_PANEL_FAIL" + rt_backup_panel_aux_set "$RT_PANEL_STAGE" "$panel" dir "$(rt_panel_pasarguard_block_value "$RT_PG_KEY_DIR")" \ + || return "$RT_PANEL_FAIL" + rt_backup_panel_aux_set "$RT_PANEL_STAGE" "$panel" root "$root" || return "$RT_PANEL_FAIL" + if [ ! -d "$root" ]; then + rt_backup_panel_aux_set "$RT_PANEL_STAGE" "$panel" root_created 1 || return "$RT_PANEL_FAIL" + fi + return "$RT_PANEL_OK" +} + +rt_panel_pasarguard_place() { + # Copy SRC to /row-template/index.html atomically. Refuses a symlinked + # directory, and a page there that is not Row-Template's. + local src="$1" root="$2" dir dest tmp + dir="$root/$RT_PG_SUBDIR"; dest="$root/$RT_PG_PAGE" + [ -L "$root" ] && { rt_err "panel pasarguard: the templates directory is a symlink: $root"; return 1; } + [ -L "$dir" ] && { rt_err "panel pasarguard: $dir is a symlink"; return 1; } + if [ -e "$dest" ] || [ -L "$dest" ]; then + rt_panel_pasarguard_is_ours "$dest" \ + || { rt_err "panel pasarguard: $dest exists and is not Row-Template's; it was left untouched"; return 1; } + fi + mkdir -p "$dir" || return 1 + chmod 755 "$dir" 2>/dev/null || true + tmp="$(mktemp "$dir/.index.XXXXXX")" || return 1 + cp -- "$src" "$tmp" && chmod 644 "$tmp" && mv -f "$tmp" "$dest" || { rm -f "$tmp"; return 1; } + return 0 +} + +rt_panel_pasarguard_install_template() { + # Place SOURCE (the generated page) and select it. Idempotent: on a panel + # where Row-Template is already selected only the page is replaced, and the + # panel is not restarted. + local panel="$1" src="$2" root dir_value="" before after + [ -n "$src" ] || { rt_err "panel pasarguard: SOURCE is required"; return "$RT_PANEL_FAIL"; } + [ -L "$src" ] && { rt_err "panel pasarguard: refusing a symlinked SOURCE"; return "$RT_PANEL_FAIL"; } + [ -f "$src" ] || { rt_err "panel pasarguard: SOURCE is not a regular file: $src"; return "$RT_PANEL_FAIL"; } + rt_is_within "$RT_ROOT" "$src" || { rt_err "panel pasarguard: SOURCE is outside $RT_ROOT"; return "$RT_PANEL_FAIL"; } + rt_panel_pasarguard_shell_ok "$src" || { + rt_err "panel pasarguard: SOURCE is not a PasarGuard page this release can serve"; return "$RT_PANEL_FAIL"; } + rt_panel_pasarguard_env_ready || return "$RT_PANEL_UNAVAILABLE" + root="$(rt_panel_pasarguard_root)" || return "$RT_PANEL_FAIL" + + rt_panel_pasarguard_place "$src" "$root" || return "$RT_PANEL_FAIL" + + [ -n "$(rt_panel_pasarguard_operator_dir)" ] || dir_value="$root" + before="$(rt_panel_pasarguard_block_state):$(rt_panel_pasarguard_block_value "$RT_PG_KEY_DIR")" + after="present:$dir_value" + if [ "$before" != "$after" ] || [ "$(rt_panel_pasarguard_env_get "$RT_PG_KEY_PAGE" || true)" != "$RT_PG_PAGE" ]; then + rt_panel_pasarguard_env_rewrite write "$dir_value" || return "$RT_PANEL_FAIL" + rt_panel_pasarguard_apply || { rt_err "panel pasarguard: the panel could not be restarted"; return "$RT_PANEL_FAIL"; } + fi + return "$RT_PANEL_OK" +} + +rt_panel_pasarguard_selected() { + # 0 when .env (as the panel will read it) selects our page from our root. + local root page dir + [ "$(rt_panel_pasarguard_block_state)" = "present" ] || return 1 + page="$(rt_panel_pasarguard_env_get "$RT_PG_KEY_PAGE" 2>/dev/null)" || return 1 + [ "$page" = "$RT_PG_PAGE" ] || return 1 + root="$(rt_panel_pasarguard_root 2>/dev/null)" || return 1 + dir="$(rt_panel_pasarguard_env_get "$RT_PG_KEY_DIR" 2>/dev/null || true)" + [ "${dir%/}" = "$root" ] || return 1 + return 0 +} + +rt_panel_pasarguard_verify() { + # static: the canonical shell, the generated page, the placed copy and the + # selection all agree. live: the RUNNING container reads our + # selection and can see the page (Docker only). + local panel="$1" mode="$2" root dest want name page + case "$mode" in + static|live) : ;; + *) rt_err "panel pasarguard: unknown verification mode '$mode'"; return "$RT_PANEL_FAIL" ;; + esac + if [ "$mode" = "live" ]; then + [ "$(rt_panel_pasarguard_mode)" = "docker" ] || return "$RT_PANEL_UNAVAILABLE" + name="$(rt_panel_pasarguard_container)" + [ -n "$name" ] || return "$RT_PANEL_UNAVAILABLE" + root="$(rt_panel_pasarguard_root 2>/dev/null)" || return "$RT_PANEL_FAIL" + page="$(docker exec "$name" printenv "$RT_PG_KEY_PAGE" 2>/dev/null || true)" + [ "$page" = "$RT_PG_PAGE" ] || { rt_err "panel pasarguard: the running panel does not use the Row-Template page yet (restart it)"; return "$RT_PANEL_FAIL"; } + docker exec "$name" test -f "$root/$RT_PG_PAGE" >/dev/null 2>&1 \ + || { rt_err "panel pasarguard: the running panel cannot see $root/$RT_PG_PAGE"; return "$RT_PANEL_FAIL"; } + return "$RT_PANEL_OK" + fi + + [ -f "$RT_DIST" ] || { rt_err "panel pasarguard: the artifact is missing: $RT_DIST"; return "$RT_PANEL_FAIL"; } + rt_panel_pasarguard_shell_ok "$RT_DIST" \ + || { rt_err "panel pasarguard: the artifact is not a valid PasarGuard page"; return "$RT_PANEL_FAIL"; } + if [ -f "$RT_DIST_SUM" ]; then + want="$(LC_ALL=C awk '{print $1; exit}' "$RT_DIST_SUM" 2>/dev/null || true)" + rt_verify_sha256 "$RT_DIST" "$want" >/dev/null 2>&1 \ + || { rt_err "panel pasarguard: the artifact does not match its recorded checksum"; return "$RT_PANEL_FAIL"; } + fi + rt_validate_template "$RT_LIVE" >/dev/null 2>&1 \ + || { rt_err "panel pasarguard: the generated page is missing or invalid: $RT_LIVE"; return "$RT_PANEL_FAIL"; } + root="$(rt_panel_pasarguard_root)" || return "$RT_PANEL_FAIL" + dest="$root/$RT_PG_PAGE" + rt_panel_pasarguard_is_ours "$dest" \ + || { rt_err "panel pasarguard: the page is not in place: $dest"; return "$RT_PANEL_FAIL"; } + [ "$(rt_sha256 "$dest" 2>/dev/null || true)" = "$(rt_sha256 "$RT_LIVE" 2>/dev/null || true)" ] \ + || { rt_err "panel pasarguard: the placed page differs from the generated one"; return "$RT_PANEL_FAIL"; } + rt_panel_pasarguard_selected \ + || { rt_err "panel pasarguard: .env does not select the Row-Template page"; return "$RT_PANEL_FAIL"; } + return "$RT_PANEL_OK" +} + +rt_panel_pasarguard_remove_page() { + # Remove our page from ROOT, then our directory and the root itself only when + # each is left empty (the root only when ROOT_CREATED says we made it). + local root="$1" root_created="${2:-0}" dest + dest="$root/$RT_PG_PAGE" + if [ -e "$dest" ] || [ -L "$dest" ]; then + rt_panel_pasarguard_is_ours "$dest" || { rt_warn "panel pasarguard: $dest is not Row-Template's; left in place"; return 0; } + rm -f -- "$dest" || return 1 + fi + rmdir -- "$root/$RT_PG_SUBDIR" 2>/dev/null || true + if [ "$root_created" = "1" ]; then rmdir -- "$root" 2>/dev/null || true; fi + return 0 +} + +rt_panel_pasarguard_restore_state() { + # Validate the record, put .env's block back the way it was, remove the page + # this change created, restore the running/stopped state. + local panel="$1" snap="$2" st mech was_running files f block dir root created + st="$(rt_backup_panel_state "$snap" "$panel")" || { rt_err "panel pasarguard: malformed selection.state"; return "$RT_PANEL_FAIL"; } + case "$st" in absent|empty|present) : ;; *) return "$RT_PANEL_FAIL" ;; esac + rt_backup_panel_meta_check "$snap" "$panel" || { rt_err "panel pasarguard: malformed panel meta"; return "$RT_PANEL_FAIL"; } + mech="$(rt_manifest_get mechanism "$snap/panels/$panel/meta")" + [ "$mech" = "env" ] || { rt_err "panel pasarguard: mechanism is '$mech', expected 'env'"; return "$RT_PANEL_FAIL"; } + was_running="$(rt_manifest_get was_running "$snap/panels/$panel/meta")" + files="$(rt_backup_panel_files "$snap" "$panel")" || { rt_err "panel pasarguard: malformed files record"; return "$RT_PANEL_FAIL"; } + for f in $files; do + [ "$f" = "$RT_PG_PAGE" ] || { rt_err "panel pasarguard: refusing to restore: the record lists a file this adapter never places: $f"; return "$RT_PANEL_FAIL"; } + done + block="$(rt_backup_panel_aux "$snap" "$panel" block)" || return "$RT_PANEL_FAIL" + case "$block" in absent|present) : ;; *) rt_err "panel pasarguard: malformed block record"; return "$RT_PANEL_FAIL" ;; esac + dir="$(rt_backup_panel_aux "$snap" "$panel" dir)" || return "$RT_PANEL_FAIL" + root="$(rt_backup_panel_aux "$snap" "$panel" root)" || return "$RT_PANEL_FAIL" + created="$(rt_backup_panel_aux "$snap" "$panel" root_created)" || return "$RT_PANEL_FAIL" + rt_panel_pasarguard_path_ok "$root" || { rt_err "panel pasarguard: malformed root record"; return "$RT_PANEL_FAIL"; } + [ -z "$dir" ] || rt_panel_pasarguard_path_ok "$dir" || { rt_err "panel pasarguard: malformed dir record"; return "$RT_PANEL_FAIL"; } + + rt_panel_pasarguard_env_ready || return "$RT_PANEL_UNAVAILABLE" + if [ "$block" = "absent" ]; then + rt_panel_pasarguard_env_rewrite remove || return "$RT_PANEL_FAIL" + else + rt_panel_pasarguard_env_rewrite write "$dir" || return "$RT_PANEL_FAIL" + fi + if [ -n "$files" ]; then + rt_panel_pasarguard_remove_page "$root" "${created:-0}" || return "$RT_PANEL_FAIL" + fi + if [ "$was_running" = "1" ]; then + rt_panel_pasarguard_apply || return "$RT_PANEL_FAIL" + fi + return "$RT_PANEL_OK" +} + +rt_panel_pasarguard_uninstall_template() { + # Remove our block and our page; restart the panel if it is running so it + # goes back to the page it had. NOT_APPLICABLE when neither is present. + local root block dest removed=0 created=0 + rt_panel_pasarguard_env_ready || return "$RT_PANEL_UNAVAILABLE" + block="$(rt_panel_pasarguard_block_state)" + [ "$block" = "malformed" ] && { rt_err "panel pasarguard: the Row-Template block in .env is damaged; fix or remove it by hand"; return "$RT_PANEL_FAIL"; } + root="$(rt_panel_pasarguard_root 2>/dev/null)" || root="" + # the root our block pointed at is the one the page lives in + if [ "$block" = "present" ]; then + local d; d="$(rt_panel_pasarguard_block_value "$RT_PG_KEY_DIR")" + if [ -n "$d" ] && rt_panel_pasarguard_path_ok "$d"; then root="$d"; created=1; fi + fi + if [ "$block" = "present" ]; then + rt_panel_pasarguard_env_rewrite remove || return "$RT_PANEL_FAIL" + removed=1 + fi + if [ -n "$root" ]; then + dest="$root/$RT_PG_PAGE" + if rt_panel_pasarguard_is_ours "$dest"; then + rt_panel_pasarguard_remove_page "$root" "$created" || return "$RT_PANEL_FAIL" + removed=1 + fi + fi + [ "$removed" -eq 1 ] || return "$RT_PANEL_NOT_APPLICABLE" + rt_panel_pasarguard_apply || { rt_err "panel pasarguard: the panel could not be restarted"; return "$RT_PANEL_FAIL"; } + return "$RT_PANEL_OK" +} + +# --- outside the transaction: refresh and status ---------------------------- +# Not part of the seven transactional verbs. `refresh` replaces the page after +# the operator changes branding or design -- the selection is not touched, so +# an operator who deselected Row-Template stays deselected. `status` is for the +# dashboard. Both are reached through rt_panel_refresh_page / rt_panel_status +# in installer/panels/index.sh. + +rt_panel_pasarguard_refresh() { + # rt_panel_pasarguard_refresh SOURCE [place] + # 0 page replaced | 3 no page of ours is placed (and `place` not given) | + # 2 .env unavailable | 1 failure + local src="$1" place="${2:-}" root + rt_panel_pasarguard_env_ready || return "$RT_PANEL_UNAVAILABLE" + rt_panel_pasarguard_shell_ok "$src" || return "$RT_PANEL_FAIL" + # Before activation there is nothing to refresh -- and no reason to resolve + # (let alone reject) a templates directory Row-Template has not used yet. + if [ -z "$place" ] && [ "$(rt_panel_pasarguard_block_state)" != "present" ]; then + return "$RT_PANEL_NOT_APPLICABLE" + fi + root="$(rt_panel_pasarguard_root)" || return "$RT_PANEL_FAIL" + if [ -z "$place" ] && ! rt_panel_pasarguard_is_ours "$root/$RT_PG_PAGE"; then + return "$RT_PANEL_NOT_APPLICABLE" + fi + rt_panel_pasarguard_place "$src" "$root" || return "$RT_PANEL_FAIL" + return "$RT_PANEL_OK" +} + +rt_panel_pasarguard_status() { + # active | inactive | unknown + local root + rt_panel_pasarguard_env_ready || { printf 'unknown'; return 0; } + root="$(rt_panel_pasarguard_root 2>/dev/null)" || { printf 'unknown'; return 0; } + if rt_panel_pasarguard_is_ours "$root/$RT_PG_PAGE" && rt_panel_pasarguard_selected; then + printf 'active' + else + printf 'inactive' + fi +} diff --git a/installer/panels/rebecca.sh b/installer/panels/rebecca.sh new file mode 100644 index 0000000..71360e9 --- /dev/null +++ b/installer/panels/rebecca.sh @@ -0,0 +1,489 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# installer/panels/rebecca.sh -- the Rebecca panel adapter (1.3.0). +# +# Sourced by installer/panels/index.sh and reached only through the seven +# public rt_panel_* verbs of installer/panels/interface.sh. +# +# WHAT REBECCA ACTIVATION ACTUALLY IS (audited against the Rebecca Go source +# and its official installer, docs/design/REBECCA-INSTALLER-AUDIT.md): +# +# * Rebecca renders its subscription page with pongo2. On EVERY request it +# reads the page's name and an optional custom directory from the newest +# row of its `subscription_settings` table: +# +# subscription_page_template default 'subscription/index.html' +# custom_templates_directory default NULL +# +# and reads the file / +# from disk (falling back to its bundled templates). Nothing is cached, so a +# change takes effect on the next request: no restart, ever. +# * The official installer runs it from /opt/rebecca, in Docker with the bind +# mount /var/lib/rebecca:/var/lib/rebecca, or as rebecca.service (binary +# mode). Its database is SQLite at /var/lib/rebecca/db.sqlite3 by default +# (SQLALCHEMY_DATABASE_URL in /opt/rebecca/.env), or MySQL/MariaDB. +# +# So activation is: +# +# 1. PLACE the generated page at /row-template/index.html, where is +# the operator's custom_templates_directory, else /var/lib/rebecca/templates. +# 2. SELECT it: set subscription_page_template = 'row-template/index.html', +# and custom_templates_directory = when the operator had none. Only +# that one row, only those two columns. +# +# Step 2 needs the sqlite3 command and a SQLite database. With MySQL/MariaDB, or +# without sqlite3, the adapter answers UNAVAILABLE and the installer prints the +# two values to enter in Rebecca's dashboard -- the same "manual activation" +# 3X-UI has without sqlite3. The adapter never asks for, reads or prints a +# database password: .env is read for one key, SQLALCHEMY_DATABASE_URL, and only +# a sqlite: URL is ever used. +# +# Per-administrator overrides. Rebecca lets an admin override both columns for +# their own users (admins.subscription_settings). Those users keep the admin's +# page; verify reports how many admins have one. +# --------------------------------------------------------------------------- + +RT_PANEL_REBECCA_CAPABILITIES="db_activation file_placement selection_read selection_write static_verify" + +: "${RT_RB_APP_DIR:=/opt/rebecca}" +: "${RT_RB_DATA_DIR:=/var/lib/rebecca}" +: "${RT_RB_CLI:=/usr/local/bin/rebecca}" +: "${RT_RB_PROJECT:=rebecca}" +: "${RT_RB_UNIT:=rebecca.service}" + +RT_RB_SUBDIR="row-template" +RT_RB_PAGE="row-template/index.html" +RT_RB_DEFAULT_PAGE="subscription/index.html" + +# --- environment ------------------------------------------------------------ + +rt_panel_rebecca_env() { printf '%s' "$RT_RB_APP_DIR/.env"; } + +rt_panel_rebecca_compose_ok() { + local f="$RT_RB_APP_DIR/docker-compose.yml" + [ -f "$f" ] && [ ! -L "$f" ] || return 1 + LC_ALL=C grep -Eq '^[[:space:]]*image:[[:space:]]*["'"'"']?(docker\.io/)?rebeccapanel/rebecca([:@"'"'"'[:space:]]|$)' "$f" 2>/dev/null +} + +rt_panel_rebecca_unit_ok() { + command -v systemctl >/dev/null 2>&1 || return 1 + systemctl list-unit-files 2>/dev/null | LC_ALL=C grep "^${RT_RB_UNIT//./\\.}" >/dev/null 2>&1 +} + +rt_panel_rebecca_mode() { + if rt_panel_rebecca_compose_ok; then printf 'docker'; return 0; fi + if rt_panel_rebecca_unit_ok; then printf 'binary'; return 0; fi + printf 'none' +} + +rt_panel_rebecca_running() { + case "$(rt_panel_rebecca_mode)" in + docker) + command -v docker >/dev/null 2>&1 || return 1 + [ -n "$(docker ps --filter "label=com.docker.compose.project=$RT_RB_PROJECT" --format '{{.Image}}' 2>/dev/null \ + | LC_ALL=C awk '/^(docker\.io\/)?rebeccapanel\/rebecca([:@]|$)/ { print; exit }' || true)" ] ;; + binary) systemctl is-active --quiet "$RT_RB_UNIT" 2>/dev/null ;; + *) return 1 ;; + esac +} + +rt_panel_rebecca_db() { + # Echo the host path of Rebecca's SQLite database, or fail. Reads ONE key of + # .env and never prints it: a MySQL URL carries a password. + local env url path rc=0 + env="$(rt_panel_rebecca_env)" + [ -f "$env" ] && [ ! -L "$env" ] || return 1 + url="$(rt_dotenv_get "$env" SQLALCHEMY_DATABASE_URL)" || rc=$? + if [ "$rc" -ne 0 ] || [ -z "$url" ]; then + rc=0; url="$(rt_dotenv_get "$env" DATABASE_URL)" || rc=$? + [ "$rc" -eq 0 ] && [ -n "$url" ] || return 1 + fi + case "$url" in + sqlite:///*|sqlite+*:///*) path="${url#*:///}" ;; + *) return 1 ;; # MySQL/MariaDB: not ours to touch + esac + path="${path%%\?*}" + case "$path" in + /*) : ;; + *) [ "$(rt_panel_rebecca_mode)" = "binary" ] || return 1 # relative: inside the image, not the host + path="$RT_RB_APP_DIR/$path" ;; + esac + if [ "$(rt_panel_rebecca_mode)" = "docker" ]; then + rt_is_within "$RT_RB_DATA_DIR" "$path" || return 1 + fi + rt_is_sqlite_db "$path" || return 1 + printf '%s' "$path" +} + +rt_panel_rebecca_db_ready() { + command -v sqlite3 >/dev/null 2>&1 || return 1 + RT_RB_DB="$(rt_panel_rebecca_db)" || return 1 + [ -n "$RT_RB_DB" ] +} + +rt_panel_rebecca_sql() { + # One statement against Rebecca's database, waiting for a lock rather than + # failing on it: the panel keeps the database open while it runs. Values in + # the statement are escaped by rt_panel_rebecca_quote; output is data. + sqlite3 -cmd '.timeout 5000' "$RT_RB_DB" "$1" +} + +rt_panel_rebecca_quote() { printf '%s' "${1//\'/\'\'}"; } + +RT_RB_ROW="(SELECT id FROM subscription_settings ORDER BY id DESC LIMIT 1)" + +rt_panel_rebecca_page_get() { + # Echo subscription_page_template of the row Rebecca reads. Fails when there + # is no row, or the value holds a newline (it could not be restored exactly). + local n v + n="$(rt_panel_rebecca_sql "SELECT COUNT(*) FROM subscription_settings;")" || return 1 + case "${n:-}" in ''|*[!0-9]*|0) return 1 ;; esac + v="$(rt_panel_rebecca_sql "SELECT subscription_page_template FROM subscription_settings WHERE id = $RT_RB_ROW;")" || return 1 + case "$v" in *' +'*) return 1 ;; esac + printf '%s' "$v" +} + +rt_panel_rebecca_dir_get() { + # Echo custom_templates_directory as STATE:VALUE, STATE = absent (NULL) | + # empty | present. NULL and '' are different, and a restore needs which. + local v + v="$(rt_panel_rebecca_sql "SELECT CASE WHEN custom_templates_directory IS NULL THEN 'N' ELSE 'V' || custom_templates_directory END FROM subscription_settings WHERE id = $RT_RB_ROW;")" || return 1 + case "$v" in *' +'*) return 1 ;; esac + case "$v" in + N) printf 'absent:' ;; + V) printf 'empty:' ;; + V*) printf 'present:%s' "${v#V}" ;; + *) return 1 ;; + esac +} + +rt_panel_rebecca_write() { + # rt_panel_rebecca_write PAGE DIR_STATE [DIR] -- set both columns of the row + # Rebecca reads, and nothing else. + local page dstate="$2" dir="${3:-}" dsql + page="$(rt_panel_rebecca_quote "$1")" + case "$dstate" in + absent) dsql="NULL" ;; + empty) dsql="''" ;; + present) dsql="'$(rt_panel_rebecca_quote "$dir")'" ;; + keep) dsql="custom_templates_directory" ;; + *) return 1 ;; + esac + rt_panel_rebecca_sql "UPDATE subscription_settings SET subscription_page_template = '$page', custom_templates_directory = $dsql WHERE id = $RT_RB_ROW;" +} + +rt_panel_rebecca_dir_ok() { + # An operator directory we are willing to place a file into: absolute, no + # control characters, no . or .. component. + case "${1:-}" in /*) : ;; *) return 1 ;; esac + rt_has_control_chars "$1" && return 1 + case "$1" in */../*|*/..|*/./*|*/.) return 1 ;; esac + return 0 +} + +rt_panel_rebecca_root() { + # The directory the page goes into: the operator's custom directory, else + # DATA_DIR/templates. In Docker it must be inside the bind-mounted DATA_DIR. + local d root + d="$(rt_panel_rebecca_dir_get)" || return 1 + root="${d#*:}"; root="${root%/}" + [ -n "$root" ] || root="${RT_RB_DATA_DIR%/}/templates" + rt_panel_rebecca_dir_ok "$root" || { rt_err "panel rebecca: custom_templates_directory is not a usable absolute path"; return 1; } + if [ "$(rt_panel_rebecca_mode)" = "docker" ] && ! rt_is_within "$RT_RB_DATA_DIR" "$root"; then + rt_err "panel rebecca: $root is outside $RT_RB_DATA_DIR, the directory the container shares with the host" + return 1 + fi + printf '%s' "$root" +} + +rt_panel_rebecca_is_ours() { + local f="$1" + [ -f "$f" ] && [ ! -L "$f" ] || return 1 + LC_ALL=C grep -Fq '/* row:branding */' "$f" 2>/dev/null || return 1 + LC_ALL=C grep -Fq 'Row-Template, Rebecca page context' "$f" 2>/dev/null || return 1 + return 0 +} + +rt_panel_rebecca_shell_ok() { + # A Rebecca shell this release can serve: valid, with the context prelude and + # the explicit autoescape block. Anything older is refused. + local f="$1" + rt_validate_template "$f" >/dev/null 2>&1 || return 1 + LC_ALL=C grep -Fq 'Row-Template, Rebecca page context' "$f" 2>/dev/null || return 1 + LC_ALL=C grep -Fq '{%- autoescape on -%}' "$f" 2>/dev/null || return 1 + LC_ALL=C grep -Fq '{%- endautoescape %}' "$f" 2>/dev/null || return 1 + return 0 +} + +rt_panel_rebecca_admin_overrides() { + # Echo how many admins override the page for their own users (0 when none or + # unknown). A warning, never a failure: those users are the admin's choice. + local n + n="$(rt_panel_rebecca_sql "SELECT COUNT(*) FROM admins WHERE subscription_settings LIKE '%\"subscription_page_template\":\"_%' OR subscription_settings LIKE '%\"subscription_page_template\": \"_%' OR subscription_settings LIKE '%\"custom_templates_directory\":\"_%' OR subscription_settings LIKE '%\"custom_templates_directory\": \"_%';" 2>/dev/null)" || n=0 + case "${n:-}" in ''|*[!0-9]*) n=0 ;; esac + printf '%s' "$n" +} + +# --- the frozen verbs -------------------------------------------------------------- + +rt_panel_rebecca_detect() { + # READ-ONLY. Two independent signals must agree: + # A /opt/rebecca/.env + # B a compose file running rebeccapanel/rebecca, or a rebecca systemd unit + # C the rebecca management CLI + # D the data directory + local signals=0 env + env="$(rt_panel_rebecca_env)" + [ -f "$env" ] && [ ! -L "$env" ] && signals=$((signals + 1)) + if rt_panel_rebecca_compose_ok || rt_panel_rebecca_unit_ok; then signals=$((signals + 1)); fi + if [ -x "$RT_RB_CLI" ] && [ ! -d "$RT_RB_CLI" ] && LC_ALL=C grep -qi 'rebecca' "$RT_RB_CLI" 2>/dev/null; then + signals=$((signals + 1)) + fi + [ -d "$RT_RB_DATA_DIR" ] && [ ! -L "$RT_RB_DATA_DIR" ] && signals=$((signals + 1)) + if [ "$signals" -ge 2 ]; then return "$RT_PANEL_OK"; fi + if [ "$signals" -eq 1 ]; then return "$RT_PANEL_FAIL"; fi + return "$RT_PANEL_NOT_APPLICABLE" +} + +rt_panel_rebecca_capabilities() { + local t + for t in $RT_PANEL_REBECCA_CAPABILITIES; do printf '%s\n' "$t"; done + return "$RT_PANEL_OK" +} + +rt_panel_rebecca_backup_state() { + # selection subscription_page_template (present, or empty) + # meta mechanism=db, was_running (recorded; Rebecca is never restarted) + # files the page, when this change will create it + # aux dir_state/dir: custom_templates_directory exactly (NULL, '' or + # a value), root, root_created + local panel="$1" page state was_running=0 d root files=() + rt_panel_rebecca_db_ready || return "$RT_PANEL_UNAVAILABLE" + page="$(rt_panel_rebecca_page_get)" || { rt_err "panel rebecca: cannot read subscription_settings"; return "$RT_PANEL_FAIL"; } + if [ -z "$page" ]; then state="empty"; else state="present"; fi + d="$(rt_panel_rebecca_dir_get)" || { rt_err "panel rebecca: cannot read custom_templates_directory"; return "$RT_PANEL_FAIL"; } + root="$(rt_panel_rebecca_root)" || return "$RT_PANEL_FAIL" + rt_panel_rebecca_running && was_running=1 + [ -e "$root/$RT_RB_PAGE" ] || [ -L "$root/$RT_RB_PAGE" ] || files+=("$RT_RB_PAGE") + rt_backup_panel_write "$RT_PANEL_STAGE" "$panel" "$state" "$page" db "$was_running" ${files[@]+"${files[@]}"} \ + || return "$RT_PANEL_FAIL" + rt_backup_panel_aux_set "$RT_PANEL_STAGE" "$panel" dir_state "${d%%:*}" || return "$RT_PANEL_FAIL" + rt_backup_panel_aux_set "$RT_PANEL_STAGE" "$panel" dir "${d#*:}" || return "$RT_PANEL_FAIL" + rt_backup_panel_aux_set "$RT_PANEL_STAGE" "$panel" root "$root" || return "$RT_PANEL_FAIL" + if [ ! -d "$root" ]; then + rt_backup_panel_aux_set "$RT_PANEL_STAGE" "$panel" root_created 1 || return "$RT_PANEL_FAIL" + fi + return "$RT_PANEL_OK" +} + +rt_panel_rebecca_place() { + local src="$1" root="$2" dir dest tmp + dir="$root/$RT_RB_SUBDIR"; dest="$root/$RT_RB_PAGE" + [ -L "$root" ] && { rt_err "panel rebecca: the templates directory is a symlink: $root"; return 1; } + [ -L "$dir" ] && { rt_err "panel rebecca: $dir is a symlink"; return 1; } + if [ -e "$dest" ] || [ -L "$dest" ]; then + rt_panel_rebecca_is_ours "$dest" \ + || { rt_err "panel rebecca: $dest exists and is not Row-Template's; it was left untouched"; return 1; } + fi + mkdir -p "$dir" || return 1 + chmod 755 "$dir" 2>/dev/null || true + tmp="$(mktemp "$dir/.index.XXXXXX")" || return 1 + cp -- "$src" "$tmp" && chmod 644 "$tmp" && mv -f "$tmp" "$dest" || { rm -f "$tmp"; return 1; } + return 0 +} + +rt_panel_rebecca_install_template() { + # Place SOURCE and select it. Idempotent: when already selected, only the + # page is replaced. + local panel="$1" src="$2" root d page + [ -n "$src" ] || { rt_err "panel rebecca: SOURCE is required"; return "$RT_PANEL_FAIL"; } + [ -L "$src" ] && { rt_err "panel rebecca: refusing a symlinked SOURCE"; return "$RT_PANEL_FAIL"; } + [ -f "$src" ] || { rt_err "panel rebecca: SOURCE is not a regular file: $src"; return "$RT_PANEL_FAIL"; } + rt_is_within "$RT_ROOT" "$src" || { rt_err "panel rebecca: SOURCE is outside $RT_ROOT"; return "$RT_PANEL_FAIL"; } + rt_panel_rebecca_shell_ok "$src" || { rt_err "panel rebecca: SOURCE is not a Rebecca page this release can serve"; return "$RT_PANEL_FAIL"; } + rt_panel_rebecca_db_ready || return "$RT_PANEL_UNAVAILABLE" + root="$(rt_panel_rebecca_root)" || return "$RT_PANEL_FAIL" + rt_panel_rebecca_place "$src" "$root" || return "$RT_PANEL_FAIL" + page="$(rt_panel_rebecca_page_get)" || return "$RT_PANEL_FAIL" + d="$(rt_panel_rebecca_dir_get)" || return "$RT_PANEL_FAIL" + if [ "$page" = "$RT_RB_PAGE" ] && [ "${d#*:}" != "" ]; then + return "$RT_PANEL_OK" + fi + if [ -n "${d#*:}" ]; then + rt_panel_rebecca_write "$RT_RB_PAGE" keep || return "$RT_PANEL_FAIL" + else + rt_panel_rebecca_write "$RT_RB_PAGE" present "$root" || return "$RT_PANEL_FAIL" + fi + return "$RT_PANEL_OK" +} + +rt_panel_rebecca_selected() { + local root page d + page="$(rt_panel_rebecca_page_get 2>/dev/null)" || return 1 + [ "$page" = "$RT_RB_PAGE" ] || return 1 + d="$(rt_panel_rebecca_dir_get 2>/dev/null)" || return 1 + root="$(rt_panel_rebecca_root 2>/dev/null)" || return 1 + [ "$(printf '%s' "${d#*:}" | sed 's:/*$::')" = "$root" ] +} + +rt_panel_rebecca_verify() { + local panel="$1" mode="$2" root dest want n + case "$mode" in + live) return "$RT_PANEL_UNAVAILABLE" ;; + static) : ;; + *) rt_err "panel rebecca: unknown verification mode '$mode'"; return "$RT_PANEL_FAIL" ;; + esac + [ -f "$RT_DIST" ] || { rt_err "panel rebecca: the artifact is missing: $RT_DIST"; return "$RT_PANEL_FAIL"; } + rt_panel_rebecca_shell_ok "$RT_DIST" || { rt_err "panel rebecca: the artifact is not a valid Rebecca page"; return "$RT_PANEL_FAIL"; } + if [ -f "$RT_DIST_SUM" ]; then + want="$(LC_ALL=C awk '{print $1; exit}' "$RT_DIST_SUM" 2>/dev/null || true)" + rt_verify_sha256 "$RT_DIST" "$want" >/dev/null 2>&1 \ + || { rt_err "panel rebecca: the artifact does not match its recorded checksum"; return "$RT_PANEL_FAIL"; } + fi + rt_validate_template "$RT_LIVE" >/dev/null 2>&1 \ + || { rt_err "panel rebecca: the generated page is missing or invalid: $RT_LIVE"; return "$RT_PANEL_FAIL"; } + rt_panel_rebecca_db_ready || { rt_err "panel rebecca: cannot read the panel selection (sqlite3 and a SQLite database are needed)"; return "$RT_PANEL_FAIL"; } + root="$(rt_panel_rebecca_root)" || return "$RT_PANEL_FAIL" + dest="$root/$RT_RB_PAGE" + rt_panel_rebecca_is_ours "$dest" || { rt_err "panel rebecca: the page is not in place: $dest"; return "$RT_PANEL_FAIL"; } + [ "$(rt_sha256 "$dest" 2>/dev/null || true)" = "$(rt_sha256 "$RT_LIVE" 2>/dev/null || true)" ] \ + || { rt_err "panel rebecca: the placed page differs from the generated one"; return "$RT_PANEL_FAIL"; } + rt_panel_rebecca_selected || { rt_err "panel rebecca: the panel does not select the Row-Template page"; return "$RT_PANEL_FAIL"; } + n="$(rt_panel_rebecca_admin_overrides)" + [ "$n" = "0" ] || rt_warn "panel rebecca: $n admin(s) override the subscription page for their own users; those users keep the admin's page." + return "$RT_PANEL_OK" +} + +rt_panel_rebecca_remove_page() { + local root="$1" root_created="${2:-0}" dest + dest="$root/$RT_RB_PAGE" + if [ -e "$dest" ] || [ -L "$dest" ]; then + rt_panel_rebecca_is_ours "$dest" || { rt_warn "panel rebecca: $dest is not Row-Template's; left in place"; return 0; } + rm -f -- "$dest" || return 1 + fi + rmdir -- "$root/$RT_RB_SUBDIR" 2>/dev/null || true + if [ "$root_created" = "1" ]; then rmdir -- "$root" 2>/dev/null || true; fi + return 0 +} + +rt_panel_rebecca_restore_record() { + # rt_panel_rebecca_restore_record SNAPSHOT PANEL -- put both columns back + # exactly as the record has them. Validates everything before writing. + local snap="$1" panel="$2" st page dstate dir + st="$(rt_backup_panel_state "$snap" "$panel")" || return 1 + case "$st" in + present) page="$(rt_backup_panel_selection "$snap" "$panel")" || return 1 ;; + empty) page="" ;; + *) rt_err "panel rebecca: subscription_page_template cannot have been absent (state '$st')"; return 1 ;; + esac + dstate="$(rt_backup_panel_aux "$snap" "$panel" dir_state)" || return 1 + dir="$(rt_backup_panel_aux "$snap" "$panel" dir)" || return 1 + case "$dstate" in absent|empty|present) : ;; *) rt_err "panel rebecca: malformed dir_state record"; return 1 ;; esac + rt_panel_rebecca_write "$page" "$dstate" "$dir" +} + +rt_panel_rebecca_restore_state() { + local panel="$1" snap="$2" mech files f root created + rt_backup_panel_state "$snap" "$panel" >/dev/null || { rt_err "panel rebecca: malformed selection.state"; return "$RT_PANEL_FAIL"; } + rt_backup_panel_meta_check "$snap" "$panel" || { rt_err "panel rebecca: malformed panel meta"; return "$RT_PANEL_FAIL"; } + mech="$(rt_manifest_get mechanism "$snap/panels/$panel/meta")" + [ "$mech" = "db" ] || { rt_err "panel rebecca: mechanism is '$mech', expected 'db'"; return "$RT_PANEL_FAIL"; } + files="$(rt_backup_panel_files "$snap" "$panel")" || { rt_err "panel rebecca: malformed files record"; return "$RT_PANEL_FAIL"; } + for f in $files; do + [ "$f" = "$RT_RB_PAGE" ] || { rt_err "panel rebecca: refusing to restore: the record lists a file this adapter never places: $f"; return "$RT_PANEL_FAIL"; } + done + root="$(rt_backup_panel_aux "$snap" "$panel" root)" || return "$RT_PANEL_FAIL" + created="$(rt_backup_panel_aux "$snap" "$panel" root_created)" || return "$RT_PANEL_FAIL" + rt_panel_rebecca_dir_ok "$root" || { rt_err "panel rebecca: malformed root record"; return "$RT_PANEL_FAIL"; } + rt_panel_rebecca_db_ready || return "$RT_PANEL_UNAVAILABLE" + rt_panel_rebecca_restore_record "$snap" "$panel" || return "$RT_PANEL_FAIL" + if [ -n "$files" ]; then + rt_panel_rebecca_remove_page "$root" "${created:-0}" || return "$RT_PANEL_FAIL" + fi + # was_running is recorded but never acted on: this adapter never stops or + # starts Rebecca, so the service is exactly as the record found it. + return "$RT_PANEL_OK" +} + +rt_panel_rebecca_uninstall_template() { + # Put the selection back the way it was before Row-Template (from the + # activation record when there is one), then remove our page. When the panel + # no longer selects our page, the selection is the operator's and is left + # alone; our page is still removed. + local page d root snap created=0 removed=0 + rt_panel_rebecca_db_ready || return "$RT_PANEL_UNAVAILABLE" + page="$(rt_panel_rebecca_page_get)" || return "$RT_PANEL_FAIL" + d="$(rt_panel_rebecca_dir_get)" || return "$RT_PANEL_FAIL" + root="$(rt_panel_rebecca_root 2>/dev/null)" || root="" + if [ "$page" = "$RT_RB_PAGE" ]; then + snap="" + if [ -f "${RT_PANEL_ACTIVATION:-/nonexistent}" ]; then + snap="$(rt_backup_resolve "$(head -n1 "$RT_PANEL_ACTIVATION")" 2>/dev/null || true)" + fi + if [ -n "$snap" ] && [ -d "$snap/panels/rebecca" ] && rt_panel_rebecca_restore_record "$snap" rebecca; then + created="$(rt_backup_panel_aux "$snap" rebecca root_created 2>/dev/null || echo 0)" + else + # No usable record: return to Rebecca's own default page, and clear the + # directory only when it is the one Row-Template itself sets. + if [ "${d#*:}" = "${RT_RB_DATA_DIR%/}/templates" ]; then + rt_panel_rebecca_write "$RT_RB_DEFAULT_PAGE" absent || return "$RT_PANEL_FAIL" + created=1 + else + rt_panel_rebecca_write "$RT_RB_DEFAULT_PAGE" keep || return "$RT_PANEL_FAIL" + fi + fi + removed=1 + fi + if [ -n "$root" ] && rt_panel_rebecca_is_ours "$root/$RT_RB_PAGE"; then + rt_panel_rebecca_remove_page "$root" "$created" || return "$RT_PANEL_FAIL" + removed=1 + fi + [ "$removed" -eq 1 ] || return "$RT_PANEL_NOT_APPLICABLE" + return "$RT_PANEL_OK" +} + +# --- outside the transaction: refresh and status ---------------------------- +# See installer/panels/pasarguard.sh. Without database access (MySQL/MariaDB, +# or no sqlite3) the page still goes to the default directory, so the +# operator's manual selection in the dashboard has a file to point at. + +rt_panel_rebecca_page_root() { + # The directory the page lives in: from the database when it can be read, + # else the default the manual instructions name. + if rt_panel_rebecca_db_ready; then rt_panel_rebecca_root; return; fi + printf '%s' "${RT_RB_DATA_DIR%/}/templates" +} + +rt_panel_rebecca_refresh() { + # rt_panel_rebecca_refresh SOURCE [place] + local src="$1" place="${2:-}" root + rt_panel_rebecca_shell_ok "$src" || return "$RT_PANEL_FAIL" + if ! root="$(rt_panel_rebecca_page_root 2>/dev/null)"; then + # A directory Row-Template cannot use holds no page of ours -- unless the + # panel selects our page from it, which is a real failure to report. + if [ -z "$place" ] && [ "$(rt_panel_rebecca_page_get 2>/dev/null || true)" != "$RT_RB_PAGE" ]; then + return "$RT_PANEL_NOT_APPLICABLE" + fi + rt_panel_rebecca_page_root >/dev/null + return "$RT_PANEL_FAIL" + fi + if [ -z "$place" ] && ! rt_panel_rebecca_is_ours "$root/$RT_RB_PAGE"; then + return "$RT_PANEL_NOT_APPLICABLE" + fi + rt_panel_rebecca_place "$src" "$root" || return "$RT_PANEL_FAIL" + return "$RT_PANEL_OK" +} + +rt_panel_rebecca_status() { + # active | inactive | manual (the selection cannot be read or written here) + local root + rt_panel_rebecca_db_ready || { printf 'manual'; return 0; } + root="$(rt_panel_rebecca_root 2>/dev/null)" || { printf 'inactive'; return 0; } + if rt_panel_rebecca_is_ours "$root/$RT_RB_PAGE" && rt_panel_rebecca_selected; then + printf 'active' + else + printf 'inactive' + fi +} diff --git a/tests/helpers/panel-hosts.mjs b/tests/helpers/panel-hosts.mjs new file mode 100644 index 0000000..32e5efe --- /dev/null +++ b/tests/helpers/panel-hosts.mjs @@ -0,0 +1,376 @@ +/* Fake PasarGuard and Rebecca hosts, for the installer suites. + * + * Each host is the panel's OFFICIAL layout (docs/design/*-INSTALLER-AUDIT.md), + * relocated under a temporary directory: + * + * PasarGuard opt/pasarguard/{.env,docker-compose.yml}, var/lib/pasarguard/, + * usr/local/bin/pasarguard + * Rebecca opt/rebecca/{.env,docker-compose.yml}, var/lib/rebecca/db.sqlite3, + * usr/local/bin/rebecca + * + * and the adapters are pointed at it through their RT_PG_* / RT_RB_* location + * variables -- the same knobs a non-default APP_NAME would use, so no adapter + * code is bypassed. + * + * WHAT IS DOUBLED, AND WHY. Two external programs the adapters talk to: + * + * docker a shim that keeps "is the panel container running" in a state + * directory, and on `compose up` loads the .env the way Docker + * Compose does (last assignment wins), so `docker exec printenv` + * answers with what a REAL recreated container would hold. It knows + * nothing about Row-Template. + * sqlite3 forwards to Python's sqlite3 module (as the 3X-UI suite does), so + * Rebecca's database is a real SQLite file and every statement the + * adapter runs is executed for real. + * + * Everything else -- the adapters, the transaction engine, the library -- is + * the shipping code. + */ + +import { spawnSync } from 'node:child_process'; +import { chmodSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, readdirSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { build } from '../../tools/build.mjs'; +import { assembleShell } from '../../tools/shell.mjs'; + +export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); +export const sha256 = (b) => createHash('sha256').update(b).digest('hex'); +export const sq = (s) => `'${String(s).split("'").join("'\\''")}'`; + +function workingProgram(candidates, args = ['--version']) { + for (const c of candidates) { + const r = spawnSync(c, args, { encoding: 'utf8' }); + if (!r.error && r.status === 0) return c; + } + throw new Error(`none of these programs is usable: ${candidates.join(', ')}`); +} +export const PYTHON = workingProgram(process.platform === 'win32' ? ['python', 'python3'] : ['python3', 'python']); + +/* Run bash with `set -Eeuo pipefail`. PATHS entries are exported as POSIX + paths (cygpath on Windows); ENV entries are exported as given. */ +export function bashRun(lines, { paths = {}, env = {} } = {}) { + 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_RELEASE_DIR RT_ASSUME_YES RT_PANEL RT_SMOKE_URL XUI_DB_FOLDER', + ...head, ...lines].join('\n'); + const r = spawnSync('bash', ['-c', script], { + cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, env: { ...process.env, ...env }, + }); + if (r.error) throw r.error; + return { code: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() }; +} + +/* The POSIX form of a Windows path, as the shell sees it. */ +export function posix(p) { + const r = spawnSync('bash', ['-c', `cygpath -u ${sq(p)} 2>/dev/null || printf '%s' ${sq(p)}`], { encoding: 'utf8' }); + return (r.stdout || '').trim(); +} + +/* --- a release payload, laid out exactly as tools/make-release.sh does -------- */ + +export function makePayload(dir, { ids = ['row', 'editorial'], panels = ['3xui', 'pasarguard', 'rebecca'], version } = {}) { + const put = (rel, content, mode) => { + const f = join(dir, rel); + mkdirSync(dirname(f), { recursive: true }); + writeFileSync(f, content); + if (mode) chmodSync(f, mode); + }; + const cp = (rel, src, mode) => put(rel, readFileSync(src), mode); + cp('template.html', join(ROOT, 'template', 'index.html')); + put('VERSION', `${version || readFileSync(join(ROOT, 'VERSION'), 'utf8').trim()}\n`); + cp('install.sh', join(ROOT, 'installer', 'install.sh'), 0o755); + cp('bin/row-template', join(ROOT, 'installer', 'bin', 'row-template'), 0o755); + cp('lib/row-template.sh', join(ROOT, 'installer', 'lib', 'row-template.sh')); + cp('lib/transaction.sh', join(ROOT, 'installer', 'lib', 'transaction.sh')); + for (const f of readdirSync(join(ROOT, 'installer', 'panels'))) { + cp(`panels/${f}`, join(ROOT, 'installer', 'panels', f)); + } + for (const id of ids) { + const html = id === 'row' ? readFileSync(join(ROOT, 'template', 'index.html')) : Buffer.from(build(true, id).html); + put(`templates/${id}/template.html`, html); + put(`templates/${id}/template.html.sha256`, `${sha256(html)} templates/${id}/template.html\n`); + } + for (const panel of panels) { + for (const id of ids) { + const html = Buffer.from(assembleShell(panel, id).html); + put(`shells/${panel}/${id}/shell.html`, html); + put(`shells/${panel}/${id}/shell.html.sha256`, `${sha256(html)} shells/${panel}/${id}/shell.html\n`); + } + } + const sums = []; + const walk = (rel) => { + for (const e of readdirSync(join(dir, rel), { withFileTypes: true })) { + const r = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory()) walk(r); + else if (r !== 'SHA256SUMS') sums.push(`${sha256(readFileSync(join(dir, r)))} ${r}`); + } + }; + walk(''); + put('SHA256SUMS', `${sums.sort().join('\n')}\n`); + return dir; +} + +/* --- the docker shim ------------------------------------------------------------ */ + +const DOCKER_SHIM = `#!/usr/bin/env bash +# Test double for docker. State lives in $RT_TEST_DOCKER; nothing here knows +# about Row-Template. +set -u +st="\${RT_TEST_DOCKER:?}" +printf '%s\\n' "$*" >> "$st/calls" +envget() { # KEY FILE: the last assignment, as compose reads it + awk -v k="$1" ' + { l=$0; sub(/\\r$/,"",l); s=l; sub(/^[ \\t]+/,"",s) + if (s=="" || substr(s,1,1)=="#") next + if (substr(s,1,7)=="export ") s=substr(s,8) + e=index(s,"="); if (!e) next + kk=substr(s,1,e-1); sub(/[ \\t]+$/,"",kk); if (kk!=k) next + v=substr(s,e+1); sub(/^[ \\t]+/,"",v); q=substr(v,1,1) + if (q=="\\"" || q=="\\047") { r=substr(v,2); i=index(r,q); v=(i?substr(r,1,i-1):r) } + else { c=index(v," #"); if (c) v=substr(v,1,c-1); sub(/[ \\t]+$/,"",v) } + val=v; f=1 } + END { if (f) printf "%s", val }' "$2" +} +case "\${1:-}" in + ps) + [ -f "$st/running" ] || exit 0 + img="$(cat "$st/image")" + case "$*" in + *'{{.Names}} {{.Image}}'*) printf '%s %s\\n' "$(cat "$st/name")" "$img" ;; + *) printf '%s\\n' "$img" ;; + esac ;; + compose) + shift; file="" + while [ "$#" -gt 0 ]; do + case "$1" in -f) file="$2"; shift 2 ;; -p) shift 2 ;; *) break ;; esac + done + case "\${1:-}" in + up) + [ -f "$st/fail_up" ] && { echo "compose: injected failure" >&2; exit 1; } + envf="$(dirname "$file")/.env" + : > "$st/container.env" + for k in SUBSCRIPTION_PAGE_TEMPLATE CUSTOM_TEMPLATES_DIRECTORY; do + printf '%s=%s\\n' "$k" "$(envget "$k" "$envf")" >> "$st/container.env" + done + touch "$st/running" + echo up >> "$st/restarts" ;; + *) : ;; + esac ;; + exec) + shift; shift + case "\${1:-}" in + printenv) v="$(grep "^$2=" "$st/container.env" 2>/dev/null | tail -n1 | cut -d= -f2-)"; [ -n "$v" ] || exit 1; printf '%s\\n' "$v" ;; + test) shift; test "$@" ;; + *) exit 1 ;; + esac ;; + version) echo "Docker version 99 (test double)" ;; + *) exit 0 ;; +esac +`; + +const SQLITE_SHIM = `#!/usr/bin/env bash +# Test double for the sqlite3 CLI: runs the statement with Python's real +# sqlite3 module. Accepts the adapter's "-cmd .timeout N" prefix. +set -u +while [ "$#" -gt 2 ]; do + case "$1" in -cmd) shift 2 ;; *) shift ;; esac +done +[ -n "\${RT_TEST_SQL_LOG:-}" ] && printf '%s\\n' "$2" >> "$RT_TEST_SQL_LOG" +db="$(cygpath -w "$1" 2>/dev/null || printf '%s' "$1")" +"$RT_TEST_PYTHON" - "$db" "$2" <<'PYEOF' +import sqlite3, sys +con = sqlite3.connect(sys.argv[1]) +try: + cur = con.execute(sys.argv[2]) + rows = cur.fetchall() + con.commit() + for r in rows: + print("|".join("" if v is None else str(v) for v in r)) +finally: + con.close() +PYEOF +`; + +/* The transaction engine locks with flock and refuses to run without it. Git + Bash on Windows has none, so -- exactly as tests/installer-transaction.test.mjs + does -- a minimal double provides flock's contract (an exclusive lock on the + file behind a descriptor, by an atomic mkdir). A host with a real flock uses + the real one. */ +const FLOCK_SHIM = [ + '#!/usr/bin/env bash', + 'mode=""; fd=""', + 'while [ "$#" -gt 0 ]; do', + ' case "$1" in', + ' -n|-x) mode="n"; shift ;;', + ' -u) mode="u"; shift ;;', + ' -*) shift ;;', + ' *) fd="$1"; shift ;;', + ' esac', + 'done', + '[ -n "$fd" ] || exit 1', + 'target="$(readlink /proc/self/fd/$fd 2>/dev/null)" || exit 1', + '[ -n "$target" ] || exit 1', + 'd="$target.d"', + 'case "$mode" in', + ' u) rmdir "$d" 2>/dev/null; exit 0 ;;', + 'esac', + 'mkdir "$d" 2>/dev/null || exit 1', + 'exit 0', +].join('\n') + '\n'; +const HOST_HAS_FLOCK = spawnSync('bash', ['-c', 'command -v flock'], { encoding: 'utf8' }).status === 0; + +function shims(base, { sqlite = true } = {}) { + const bin = join(base, 'shimbin'); + mkdirSync(bin, { recursive: true }); + writeFileSync(join(bin, 'docker'), DOCKER_SHIM); + chmodSync(join(bin, 'docker'), 0o755); + if (!HOST_HAS_FLOCK) { + writeFileSync(join(bin, 'flock'), FLOCK_SHIM); + chmodSync(join(bin, 'flock'), 0o755); + } + if (sqlite) { + writeFileSync(join(bin, 'sqlite3'), SQLITE_SHIM); + chmodSync(join(bin, 'sqlite3'), 0o755); + } + return bin; +} + +/* --- PasarGuard ------------------------------------------------------------- */ + +export const PG_ENV = [ + 'UVICORN_HOST = "0.0.0.0"', + 'UVICORN_PORT = 8000', + 'SUDO_USERNAME = "admin"', + 'SUDO_PASSWORD = "s3cr3t-Pa55w0rd-do-not-leak"', + '## Custom page templates.', + '# CUSTOM_TEMPLATES_DIRECTORY = "/var/lib/pasarguard/templates/"', + '# SUBSCRIPTION_PAGE_TEMPLATE = "subscription/index.html"', + 'SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite:////var/lib/pasarguard/db.sqlite3"', + 'JWT_SECRET = "jwt-secret-do-not-leak"', +].join('\n') + '\n'; + +export function pasarguardHost(base, { running = true, env = PG_ENV, compose = true, cli = true, data = true } = {}) { + const app = join(base, 'opt', 'pasarguard'); + const dataDir = join(base, 'var', 'lib', 'pasarguard'); + const cliPath = join(base, 'usr', 'local', 'bin', 'pasarguard'); + const docker = join(base, 'docker-state'); + mkdirSync(app, { recursive: true }); + mkdirSync(docker, { recursive: true }); + if (data) mkdirSync(dataDir, { recursive: true }); + if (env !== null) writeFileSync(join(app, '.env'), env); + if (compose) { + writeFileSync(join(app, 'docker-compose.yml'), [ + 'services:', ' pasarguard:', ' image: pasarguard/panel:latest', ' restart: always', + ' env_file: .env', ' network_mode: host', ' volumes:', + ` - ${posix(dataDir)}:${posix(dataDir)}`, ''].join('\n')); + } + if (cli) { + mkdirSync(dirname(cliPath), { recursive: true }); + writeFileSync(cliPath, '#!/usr/bin/env bash\n# pasarguard management script (test stand-in)\necho pasarguard "$@"\n'); + chmodSync(cliPath, 0o755); + } + writeFileSync(join(docker, 'image'), 'pasarguard/panel:latest'); + writeFileSync(join(docker, 'name'), 'pasarguard-pasarguard-1'); + if (running) writeFileSync(join(docker, 'running'), ''); + const bin = shims(base, { sqlite: false }); + return { + app, dataDir, cliPath, docker, bin, envFile: join(app, '.env'), + paths: { RT_PG_APP_DIR: app, RT_PG_DATA_DIR: dataDir, RT_PG_CLI: cliPath, RT_TEST_DOCKER: docker, RT_TEST_BIN: bin }, + }; +} + +/* --- Rebecca -------------------------------------------------------------------- */ + +export function rebeccaHost(base, { running = true, sqlite = true, url, customDir = null, pageTemplate = 'subscription/index.html', + rows = 1, admins = [], compose = true, cli = true } = {}) { + const app = join(base, 'opt', 'rebecca'); + const dataDir = join(base, 'var', 'lib', 'rebecca'); + const cliPath = join(base, 'usr', 'local', 'bin', 'rebecca'); + const docker = join(base, 'docker-state'); + const db = join(dataDir, 'db.sqlite3'); + mkdirSync(app, { recursive: true }); + mkdirSync(dataDir, { recursive: true }); + mkdirSync(docker, { recursive: true }); + const dbUrl = url || `sqlite:///${posix(db)}`; + writeFileSync(join(app, '.env'), [ + 'UVICORN_PORT = 8000', + 'SUDO_PASSWORD = "rebecca-secret-do-not-leak"', + `SQLALCHEMY_DATABASE_URL = "${dbUrl}"`, + '', + ].join('\n')); + if (compose) { + writeFileSync(join(app, 'docker-compose.yml'), [ + 'services:', ' rebecca:', ' image: rebeccapanel/rebecca:latest', ' env_file: .env', + ' network_mode: host', ' volumes:', ` - ${posix(dataDir)}:${posix(dataDir)}`, ''].join('\n')); + } + if (cli) { + mkdirSync(dirname(cliPath), { recursive: true }); + writeFileSync(cliPath, '#!/usr/bin/env bash\n# rebecca management script (test stand-in)\necho rebecca "$@"\n'); + chmodSync(cliPath, 0o755); + } + writeFileSync(join(docker, 'image'), 'rebeccapanel/rebecca:latest'); + writeFileSync(join(docker, 'name'), 'rebecca-rebecca-1'); + if (running) writeFileSync(join(docker, 'running'), ''); + // Rebecca's own schema for the two tables the adapter reads, plus the + // columns around them that must survive untouched. + const statements = [ + `CREATE TABLE subscription_settings (id INTEGER PRIMARY KEY, subscription_url_prefix VARCHAR(512) NOT NULL DEFAULT '', + subscription_support_url VARCHAR(512) NOT NULL DEFAULT 'https://t.me/', custom_templates_directory VARCHAR(512) NULL, + clash_subscription_template VARCHAR(255) NOT NULL DEFAULT 'clash/default.yml', + subscription_page_template VARCHAR(255) NOT NULL DEFAULT 'subscription/index.html', + home_page_template VARCHAR(255) NOT NULL DEFAULT 'home/index.html')`, + 'CREATE TABLE admins (id INTEGER PRIMARY KEY, username TEXT, subscription_settings TEXT)', + ]; + for (let i = 0; i < rows; i += 1) { + const last = i === rows - 1; + statements.push(`INSERT INTO subscription_settings (subscription_support_url, custom_templates_directory, subscription_page_template) + VALUES ('https://t.me/row${i}', ${last && customDir !== null ? `'${customDir.split("'").join("''")}'` : 'NULL'}, + '${last ? pageTemplate : 'old/page.html'}')`); + } + admins.forEach((a, i) => statements.push(`INSERT INTO admins (username, subscription_settings) VALUES ('a${i}', '${a.split("'").join("''")}')`)); + const r = spawnSync(PYTHON, ['-c', [ + 'import sqlite3,sys,json', + 'con=sqlite3.connect(sys.argv[1])', + 'for s in json.loads(sys.argv[2]): con.execute(s)', + 'con.commit(); con.close()', + ].join('\n'), db, JSON.stringify(statements)], { encoding: 'utf8' }); + if (r.status !== 0) throw new Error(r.stderr); + const bin = shims(base, { sqlite }); + return { + app, dataDir, cliPath, docker, bin, db, envFile: join(app, '.env'), + paths: { RT_RB_APP_DIR: app, RT_RB_DATA_DIR: dataDir, RT_RB_CLI: cliPath, RT_TEST_DOCKER: docker, RT_TEST_BIN: bin }, + }; +} + +/* Read Rebecca's selection row back, from Node, with NULL kept as null. */ +export function rebeccaRow(db) { + const r = spawnSync(PYTHON, ['-c', [ + 'import sqlite3,sys,json', + 'con=sqlite3.connect(sys.argv[1])', + 'rows=con.execute("SELECT id,subscription_page_template,custom_templates_directory,subscription_support_url,clash_subscription_template,home_page_template FROM subscription_settings ORDER BY id").fetchall()', + 'print(json.dumps(rows))', + ].join('\n'), db], { encoding: 'utf8' }); + if (r.status !== 0) throw new Error(r.stderr); + return JSON.parse(r.stdout); +} + +/* The bash preamble that points the library at a fake host: the shims first + on PATH, the host's paths exported, and root checks stubbed (the suite does + not run as root). */ +export const HOST_PREAMBLE = [ + 'export PATH="$RT_TEST_BIN:$PATH"', + `export RT_TEST_PYTHON=${sq(PYTHON)}`, + '. installer/lib/row-template.sh', + 'rt_require_root(){ :; }', + 'rt_detect_xui(){ RT_XUI_BIN=""; RT_XUI_UNIT=""; return 1; }', + 'trap "rt_cleanup" EXIT', +].join('\n'); + +export function copyInto(src, dest) { + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(src, dest); +} diff --git a/tests/installer-panel-3xui.test.mjs b/tests/installer-panel-3xui.test.mjs index a62c332..389903a 100644 --- a/tests/installer-panel-3xui.test.mjs +++ b/tests/installer-panel-3xui.test.mjs @@ -291,7 +291,7 @@ const svcState = (f) => readFileSync(join(f.work, 'svc'), 'utf8').trim(); /* 1. registration */ /* ------------------------------------------------------------------------ */ -test('3xui is registered and reachable; the other two panels are not', () => { +test('3xui is registered and reachable, as are the two panels added in 1.3.0', () => { const r = sh(` for p in 3xui pasarguard rebecca; do printf 'impl|%s|%s\\n' "$p" "$(rt_panel_impl_for "$p" || true)" @@ -301,19 +301,19 @@ test('3xui is registered and reachable; the other two panels are not', () => { assert.equal(r.code, 0, r.err); const got = new Map(r.out.split('\n').filter(Boolean).map((l) => l.split('|').slice(1))); assert.equal(got.get('3xui'), '3xui', '3xui must resolve to its real implementation'); - assert.equal(got.get('pasarguard'), '', 'pasarguard must resolve to nothing'); - assert.equal(got.get('rebecca'), '', 'rebecca must resolve to nothing'); + assert.equal(got.get('pasarguard'), 'pasarguard', 'pasarguard resolves to its own adapter'); + assert.equal(got.get('rebecca'), 'rebecca', 'rebecca resolves to its own adapter'); }); -test('the shipping adapter is what answers, and no other adapter exists', () => { - assert.equal(existsSync(ADAPTER), true, 'installer/panels/3xui.sh must exist'); - assert.equal(existsSync(join(PANELS_DIR, 'pasarguard.sh')), false); - assert.equal(existsSync(join(PANELS_DIR, 'rebecca.sh')), false); - /* The verbs the dispatcher reaches are the ones the adapter defines. */ - const src = read(ADAPTER); - for (const v of ['detect', 'capabilities', 'backup_state', 'install_template', - 'verify', 'restore_state', 'uninstall_template']) { - assert.match(src, new RegExp(`^rt_panel_3xui_${v}\\(\\)`, 'm'), `adapter must define ${v}`); +test('the shipping adapters are what answer, each defining every frozen verb', () => { + for (const name of ['3xui', 'pasarguard', 'rebecca']) { + const file = join(PANELS_DIR, `${name}.sh`); + assert.equal(existsSync(file), true, `installer/panels/${name}.sh must exist`); + const src = read(file); + for (const v of ['detect', 'capabilities', 'backup_state', 'install_template', + 'verify', 'restore_state', 'uninstall_template']) { + assert.match(src, new RegExp(`^rt_panel_${name}_${v}\\(\\)`, 'm'), `${name} adapter must define ${v}`); + } } }); diff --git a/tests/installer-panel-interface.test.mjs b/tests/installer-panel-interface.test.mjs index 74eda89..265b495 100644 --- a/tests/installer-panel-interface.test.mjs +++ b/tests/installer-panel-interface.test.mjs @@ -77,8 +77,9 @@ function sh(body, args = []) { * * TAB separation plus "$@" passes every argument through byte-exactly, * including ones that are empty or contain spaces. */ -function statusTable(rows) { +function statusTable(rows, prelude = '') { const r = sh(` + ${prelude} TAB=$(printf '\t') for row in "$@"; do label="\${row%%"$TAB"*}" @@ -199,11 +200,11 @@ test('a known panel with no implementation is UNAVAILABLE, never SUCCESS and nev established (it performs no detection); SUCCESS would be a fabrication a transaction engine cannot detect. - P5A (2026-09-23) implements 3X-UI, so this sweep runs against the panels - that are STILL unimplemented. It is narrowed, not weakened: the property is - unchanged, and the 3X-UI side of it is asserted positively below rather than - dropped. Requiring a REAL adapter's verbs to return UNAVAILABLE would now be - asserting that working code is broken. */ + Since 1.3.0 every panel in the enum HAS an adapter, so "no implementation" + is produced the way a real host produces it: the adapter did not load (a + payload missing its file leaves exactly this state). The property is + unchanged; only the way the fixture reaches it moved from "no file was ever + written" to "the file is absent from this build". */ const UNIMPLEMENTED = ['pasarguard', 'rebecca']; const verbs = [ ['detect'], @@ -221,7 +222,7 @@ test('a known panel with no implementation is UNAVAILABLE, never SUCCESS and nev rows.push([p + '-' + v[0] + (v[1] ? '-' + v[1] : ''), 'rt_panel_' + v[0], p, ...v.slice(1)].join('\t')); } } - const got = statusTable(rows); + const got = statusTable(rows, 'RT_PANEL_PASARGUARD_LOADED=""; RT_PANEL_REBECCA_LOADED=""'); for (const p of UNIMPLEMENTED) { for (const v of verbs) { const k = p + '-' + v[0] + (v[1] ? '-' + v[1] : ''); @@ -231,34 +232,37 @@ test('a known panel with no implementation is UNAVAILABLE, never SUCCESS and nev } }); -test('the implemented panel is driven, and the unimplemented ones are still refused', () => { - /* The positive half of the property above, so the narrowing cannot hide a - regression: 3X-UI must reach its REAL adapter, and the other two must still - stop at the registry. */ - const r = sh(` +test('every implemented panel is driven, and an absent adapter is still refused', () => { + /* The positive half of the property above: each panel reaches its REAL + adapter, and the same panels stop at the registry the moment their adapter + is absent from the build. */ + const probe = ` for p in 3xui pasarguard rebecca; do printf 'impl-%s|%s\\n' "$p" "$(rt_panel_impl_for "$p" || true)" + rc=0; rt_panel_capabilities "$p" >/dev/null 2>&1 || rc=$? + printf 'caps-%s|%s\\n' "$p" "$rc" done - rc=0; rt_panel_capabilities 3xui >/dev/null 2>&1 || rc=$? - printf 'caps-3xui|%s\\n' "$rc" - rc=0; rt_panel_capabilities pasarguard >/dev/null 2>&1 || rc=$? - printf 'caps-pasarguard|%s\\n' "$rc" - rc=0; rt_panel_capabilities rebecca >/dev/null 2>&1 || rc=$? - printf 'caps-rebecca|%s\\n' "$rc" exit 0 - `); - assert.equal(r.code, 0, r.err); - const got = new Map(r.out.split('\n').filter(Boolean).map((l) => { + `; + const parse = (out) => new Map(out.split('\n').filter(Boolean).map((l) => { const i = l.indexOf('|'); return [l.slice(0, i), l.slice(i + 1)]; })); - assert.equal(got.get('impl-3xui'), '3xui', '3xui must resolve to its real implementation'); - assert.equal(got.get('impl-pasarguard'), '', 'pasarguard must resolve to nothing'); - assert.equal(got.get('impl-rebecca'), '', 'rebecca must resolve to nothing'); - /* 3X-UI reports its real capabilities; the other two never reach an adapter. */ - assert.equal(got.get('caps-3xui'), '0'); - assert.equal(got.get('caps-pasarguard'), '2'); - assert.equal(got.get('caps-rebecca'), '2'); + const r = sh(probe); + assert.equal(r.code, 0, r.err); + const got = parse(r.out); + for (const p of ['3xui', 'pasarguard', 'rebecca']) { + assert.equal(got.get(`impl-${p}`), p, `${p} must resolve to its real implementation`); + assert.equal(got.get(`caps-${p}`), '0', `${p} reports its real capabilities`); + } + const absent = sh('RT_PANEL_PASARGUARD_LOADED=""; RT_PANEL_REBECCA_LOADED=""\n' + probe); + assert.equal(absent.code, 0, absent.err); + const gone = parse(absent.out); + assert.equal(gone.get('impl-3xui'), '3xui', 'a loaded adapter is unaffected'); + for (const p of ['pasarguard', 'rebecca']) { + assert.equal(gone.get(`impl-${p}`), '', `${p} without its adapter must resolve to nothing`); + assert.equal(gone.get(`caps-${p}`), '2', `${p} without its adapter never reaches one`); + } }); test('a malformed invocation is FAILURE, distinct from UNAVAILABLE', () => { @@ -421,24 +425,35 @@ test('capability output is deterministic and machine-readable', () => { `sh()` trims stdout, so the exit status is captured separately rather than echoed into the same stream — mixing the two made an earlier version of this case assert against its own probe output instead of the interface's. */ - const a = sh('rt_panel_capabilities pasarguard 2>/dev/null || true'); - const b = sh('rt_panel_capabilities pasarguard 2>/dev/null || true'); + const a = sh('RT_PANEL_PASARGUARD_LOADED=""; RT_PANEL_REBECCA_LOADED=""; rt_panel_capabilities pasarguard 2>/dev/null || true'); + const b = sh('RT_PANEL_PASARGUARD_LOADED=""; RT_PANEL_REBECCA_LOADED=""; rt_panel_capabilities pasarguard 2>/dev/null || true'); assert.equal(a.out, b.out, 'two runs must produce identical bytes'); - assert.equal(a.out, '', 'an unimplemented panel must print NOTHING on stdout'); - const st = statusTable([['caps', 'rt_panel_capabilities', 'pasarguard'].join('\t')]); + assert.equal(a.out, '', 'a panel without its adapter must print NOTHING on stdout'); + const st = statusTable([['caps', 'rt_panel_capabilities', 'pasarguard'].join('\t')], 'RT_PANEL_PASARGUARD_LOADED=""; RT_PANEL_REBECCA_LOADED=""'); assert.equal(st.caps, 2, 'and must report UNAVAILABLE'); /* An IMPLEMENTED panel prints exactly its tokens and nothing else: no prose, no header, no decoration, byte-identical across runs. Narrowed to a panel that is still unimplemented above rather than weakened -- the machine-channel property is asserted for BOTH kinds of panel. */ - const c = sh('rt_panel_capabilities 3xui 2>/dev/null || true'); - const d = sh('rt_panel_capabilities 3xui 2>/dev/null || true'); - assert.equal(c.out, d.out, 'an implemented panel must be deterministic too'); - for (const line of c.out.split('\n').filter(Boolean)) { - assert.match(line, /^[a-z_]+$/, `only bare tokens may reach the machine channel: ${line}`); + const EXPECTED = { + '3xui': 'db_activation selection_read selection_write service_control static_verify', + pasarguard: 'env_activation file_placement live_verify selection_read selection_write service_control static_verify', + rebecca: 'db_activation file_placement selection_read selection_write static_verify', + }; + for (const [panel, tokens] of Object.entries(EXPECTED)) { + const c = sh(`rt_panel_capabilities ${panel} 2>/dev/null || true`); + const d = sh(`rt_panel_capabilities ${panel} 2>/dev/null || true`); + assert.equal(c.out, d.out, `${panel}: an implemented panel must be deterministic too`); + const lines = c.out.split('\n').filter(Boolean); + for (const line of lines) { + assert.match(line, /^[a-z_]+$/, `only bare tokens may reach the machine channel: ${line}`); + } + assert.deepEqual(lines, [...lines].sort(), `${panel}: tokens in LC_ALL=C order`); + assert.equal(lines.join(' '), tokens, `${panel}: exactly the capabilities its code implements`); } /* No prose may ever reach the machine channel from any verb. */ const r = sh(` + RT_PANEL_PASARGUARD_LOADED=""; RT_PANEL_REBECCA_LOADED="" for v in detect capabilities backup_state verify restore_state uninstall_template; do out="$(rt_panel_$v pasarguard 2>/dev/null || true)" [ -n "$out" ] && echo "STDOUT-FROM:$v[$out]" @@ -525,28 +540,28 @@ test('the interface never evals, sources or reconstructs a command', () => { assert.ok(libSources.length >= 1, 'the library must source the interface explicitly'); }); -test('the panels directory holds the contract plus exactly the authorised adapter', () => { +test('the panels directory holds the contract plus exactly the authorised adapters', () => { /* A stub that pretends to be an implementation is how a temporary shim - becomes permanent, and no phase may inherit one. The claim is therefore - kept and made PRECISE rather than dropped: the directory is the two frozen - contract files plus exactly the adapters a phase has been authorised to - add -- one, as of P5A (2026-09-23). A second file appearing here is still a - failure, and the two panels with no adapter are still asserted absent. */ + becomes permanent, and no phase may inherit one. The claim is kept and + made PRECISE: the directory is the two frozen contract files plus exactly + the adapters a release has authorised -- 3xui (P5A), pasarguard and + rebecca (1.3.0). A further file appearing here is still a failure. */ const r = sh('ls installer/panels/'); assert.equal(r.code, 0, r.err); const files = r.out.split('\n').map((s) => s.trim()).filter(Boolean).sort(); - assert.deepEqual(files, ['3xui.sh', 'index.sh', 'interface.sh'], - 'expected the two contract files plus the authorised 3xui adapter, found: ' + files.join(', ')); - for (const f of ['pasarguard.sh', 'rebecca.sh']) { - assert.equal(files.includes(f), false, f + ' must not exist: no adapter is authorised for it'); - } - /* And the registry names exactly one panel-specific implementation -- the - authorised one. The lookup itself is still the single decision point. */ + assert.deepEqual(files, ['3xui.sh', 'index.sh', 'interface.sh', 'pasarguard.sh', 'rebecca.sh'], + 'expected the two contract files plus the three authorised adapters, found: ' + files.join(', ')); + /* And the registry reaches exactly those three implementations through its + one lookup. */ const idx = codeOf(read(INDEX)); assert.match(idx, /rt_panel_impl_for/, 'the registry must expose one lookup'); - assert.equal(/rt_panel_(pasarguard|rebecca)_/.test(idx), false, - 'the registry must name no implementation for an unimplemented panel'); - assert.match(idx, /rt_panel_3xui_/, 'the registry must reach the authorised adapter'); + for (const p of ['3xui', 'pasarguard', 'rebecca']) { + assert.match(idx, new RegExp(`rt_panel_${p}_`), `the registry must reach the ${p} adapter`); + } + /* every dispatch arm for `detect`: exactly one per adapter, and no other */ + assert.deepEqual([...new Set(idx.match(/rt_panel_[a-z0-9]+_detect "\$panel"/g) || [])].sort(), + ['rt_panel_3xui_detect "$panel"', 'rt_panel_pasarguard_detect "$panel"', 'rt_panel_rebecca_detect "$panel"'], + 'and no other adapter'); }); test('no activation exists in the interface', () => { @@ -592,12 +607,12 @@ test('verify supports static and live, and rejects any other mode', () => { ['bogus', 'rt_panel_verify', '3xui', 'bogus'].join('\t'), ['upper', 'rt_panel_verify', '3xui', 'STATIC'].join('\t'), ['none', 'rt_panel_verify', '3xui'].join('\t'), - ]); + ], 'RT_PANEL_PASARGUARD_LOADED=""; RT_PANEL_REBECCA_LOADED=""'); /* static and live are accepted modes: they reach the implementation and get UNAVAILABLE (2), not FAILURE (1). A mode rejected by validation is 1. - Acceptance is asserted on a panel that is still UNIMPLEMENTED, because a - real adapter legitimately answers a valid mode with its own status rather - than UNAVAILABLE. Rejection is asserted on the IMPLEMENTED panel, which is + Acceptance is asserted on a panel whose adapter is ABSENT from the build, + because a real adapter legitimately answers a valid mode with its own + status rather than UNAVAILABLE. Rejection is asserted on the IMPLEMENTED panel, which is the stronger case: the real adapter must still refuse a bad mode. */ assert.equal(got.static, 2, 'static must be a valid mode (unimplemented panel => 2)'); assert.equal(got.live, 2, 'live must be a valid mode (unimplemented panel => 2)'); @@ -620,7 +635,7 @@ test('RT_PANEL_STAGE is the only structured backup-state channel', () => { /* It is the P2 variable, referenced not redeclared. */ assert.equal(/^RT_PANEL_STAGE=/m.test(iface), false, 'RT_PANEL_STAGE must be referenced, not redeclared'); - assert.match(read(LIB), /^RT_PANEL_STAGE=/m, 'the library owns the definition'); + assert.match(read(LIB), /^\s*RT_PANEL_STAGE=/m, 'the library owns the definition'); /* The interface must not invent a second staging location. */ const all = codeOf(iface); assert.equal(/(RT_ROOT\/[a-z.]*stage|RT_PANEL_TMP|RT_PANEL_WORK|\.panel-work)/.test(all), diff --git a/tests/installer-panel-pasarguard.test.mjs b/tests/installer-panel-pasarguard.test.mjs new file mode 100644 index 0000000..80c9eaf --- /dev/null +++ b/tests/installer-panel-pasarguard.test.mjs @@ -0,0 +1,377 @@ +/* The PasarGuard panel adapter (1.3.0) and the installer flows that drive it. + * + * Every case runs the SHIPPING code -- installer/panels/pasarguard.sh behind + * the frozen interface, the transaction engine, the library -- against a fake + * host laid out like the official PasarGuard installer's (tests/helpers/ + * panel-hosts.mjs). Only `docker` is doubled, by a shim that loads .env the way + * Docker Compose does, so "the running container uses our page" is a real + * question with a real answer. + * + * What is proven, in order: detection needs two signals; .env is read the way + * dotenv reads it; activation places exactly one file and appends exactly one + * block; everything is restored BYTE-EXACT (uninstall and rollback alike); the + * panel is restarted only when it was running and only when its environment + * changed; operator files and lines are never touched; secrets never leave + * .env; and the full install -> verify -> branding -> design switch -> rollback + * -> uninstall life cycle works end to end. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + bashRun, makePayload, pasarguardHost, HOST_PREAMBLE, sha256, PG_ENV, +} from './helpers/panel-hosts.mjs'; + +const PAYLOAD_DIR = mkdtempSync(join(tmpdir(), 'row-pg-payload-')); +const PAYLOAD = makePayload(PAYLOAD_DIR, { ids: ['row', 'editorial'] }); +process.on('exit', () => rmSync(PAYLOAD_DIR, { recursive: true, force: true })); + +/* A Row-Template install on a PasarGuard host, up to a generated page -- the + state activation starts from. */ +const SETUP = [ + 'RT_ACTIVE_PANEL=pasarguard', + 'rt_layout_ensure', + 'rt_repair_template_store "$PAYLOAD" >/dev/null || [ $? -eq 2 ]', + 'rt_set_dist "$RT_TEMPLATE_STORE/row/template.html"', + 'rt_config_write "Test VPN" "" "" ""', + 'rt_activate', +].join('\n'); + +function withHost(opts, fn) { + const base = mkdtempSync(join(tmpdir(), 'row-pg-')); + try { + const host = pasarguardHost(base, opts); + const rt = join(base, 'rt'); + const run = (lines) => bashRun([HOST_PREAMBLE, ...[].concat(lines)], + { paths: { ...host.paths, RT_ROOT: rt, RT_BIN: join(base, 'row-template'), PAYLOAD } }); + return fn({ base, host, rt, run }); + } finally { + rmSync(base, { recursive: true, force: true }); + } +} + +const page = (host, root = join(host.dataDir, 'templates')) => join(root, 'row-template', 'index.html'); +const restarts = (host) => (existsSync(join(host.docker, 'restarts')) + ? readFileSync(join(host.docker, 'restarts'), 'utf8').split('\n').filter(Boolean).length : 0); +const containerEnv = (host) => (existsSync(join(host.docker, 'container.env')) + ? readFileSync(join(host.docker, 'container.env'), 'utf8') : ''); + +/* --- detection ----------------------------------------------------------------- */ + +test('detection needs two independent signals', () => { + const cases = [ + [{}, 0, 'the official layout'], + [{ compose: false, cli: false }, 0, '.env and the data directory'], + [{ compose: false, cli: false, data: false }, 1, '.env alone is one signal: FAILURE, not a guess'], + [{ env: null, compose: false, cli: false }, 1, 'the data directory alone'], + [{ env: null, compose: false, cli: false, data: false }, 3, 'nothing: NOT_APPLICABLE'], + ]; + for (const [opts, want, label] of cases) { + withHost(opts, ({ run }) => { + const r = run('rc=0; rt_panel_detect pasarguard || rc=$?; echo "rc=$rc"'); + assert.match(r.out, new RegExp(`rc=${want}`), `${label}\n${r.err}`); + }); + } +}); + +test('capabilities are exactly what the adapter implements', () => { + withHost({}, ({ run }) => { + const r = run('rt_panel_capabilities pasarguard'); + assert.equal(r.code, 0, r.err); + assert.equal(r.out, ['env_activation', 'file_placement', 'live_verify', 'selection_read', + 'selection_write', 'service_control', 'static_verify'].join('\n')); + }); +}); + +/* --- .env, read as dotenv reads it ------------------------------------------ */ + +test('rt_dotenv_get reads a value the way dotenv does, and never evaluates it', () => { + withHost({}, ({ base, run }) => { + const f = join(base, 'sample.env'); + writeFileSync(f, [ + 'A=plain', 'B = "spaced and quoted"', "C='single'", 'export D=exported', + 'E=unquoted # a comment', 'F="kept # inside quotes"', 'G=first', 'G=last', + '# H=commented', 'I=', 'J=$(touch /tmp/row-pwned)', 'K=a\r', '', + ].join('\n')); + const r = run([ + `F=${JSON.stringify(f.split('\\').join('/'))}; F="$(cygpath -u "$F" 2>/dev/null || printf '%s' "$F")"`, + 'for k in A B C D E F G H I J K Z; do rc=0; v="$(rt_dotenv_get "$F" "$k")" || rc=$?; printf "%s=[%s] rc=%s\\n" "$k" "$v" "$rc"; done', + ]); + assert.equal(r.code, 0, r.err); + for (const line of ['A=[plain] rc=0', 'B=[spaced and quoted] rc=0', 'C=[single] rc=0', 'D=[exported] rc=0', + 'E=[unquoted] rc=0', 'F=[kept # inside quotes] rc=0', 'G=[last] rc=0', 'H=[] rc=3', 'I=[] rc=0', + 'J=[$(touch /tmp/row-pwned)] rc=0', 'K=[a] rc=0', 'Z=[] rc=3']) { + assert.ok(r.out.includes(line), `expected ${line}\n${r.out}`); + } + }); +}); + +/* --- activation, and its exact inverse --------------------------------------- */ + +test('activation places one page, appends one block, restarts once; uninstall restores .env byte for byte', () => { + withHost({}, ({ host, run }) => { + const before = readFileSync(host.envFile); + const r = run([SETUP, 'rt_transaction_run pasarguard "$RT_LIVE" 2>&1 | grep -v "^transaction:" || true', + 'echo "state=$RT_TXN_STATE"', + 'rc=0; rt_panel_verify pasarguard static || rc=$?; echo "static=$rc"', + 'rc=0; rt_panel_verify pasarguard live || rc=$?; echo "live=$rc"']); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /static=0/, r.err); + assert.match(r.out, /live=0/, `the recreated container reads the new page\n${r.err}`); + + const env = readFileSync(host.envFile, 'utf8'); + assert.ok(env.startsWith(before.toString()), 'every original byte is kept, in place'); + const block = env.slice(before.length); + assert.match(block, /^# >>> row-template \(managed by Row-Template; do not edit\) nl=0 >>>\n/); + assert.match(block, new RegExp(`CUSTOM_TEMPLATES_DIRECTORY = ".*/var/lib/pasarguard/templates"\\n`)); + assert.match(block, /SUBSCRIPTION_PAGE_TEMPLATE = "row-template\/index.html"\n# <<< row-template <<<\n$/); + assert.equal(existsSync(page(host)), true, 'the page is placed'); + assert.equal(readdirSync(join(host.dataDir, 'templates')).join(','), 'row-template', 'and nothing else'); + assert.equal(restarts(host), 1, 'the running panel is restarted exactly once'); + assert.match(containerEnv(host), /SUBSCRIPTION_PAGE_TEMPLATE=row-template\/index.html/); + + const u = run(['RT_ACTIVE_PANEL=pasarguard', 'rt_panel_uninstall_template pasarguard; echo "rc=$?"']); + assert.match(u.out, /rc=0/, u.err); + assert.equal(sha256(readFileSync(host.envFile)), sha256(before), '.env is back byte for byte'); + assert.equal(existsSync(join(host.dataDir, 'templates')), false, 'the directories Row-Template created are gone'); + assert.equal(restarts(host), 2, 'and the panel restarted onto its own page'); + assert.doesNotMatch(containerEnv(host), /row-template/); + }); +}); + +test('an operator CUSTOM_TEMPLATES_DIRECTORY is used, not overridden', () => { + withHost({}, ({ host, run }) => { + const own = join(host.dataDir, 'my-templates'); + mkdirSync(join(own, 'subscription'), { recursive: true }); + writeFileSync(join(own, 'subscription', 'index.html'), 'operator page'); + const ownPosix = run(`printf '%s' "$(cygpath -u '${own.split('\\').join('/')}' 2>/dev/null || printf '%s' '${own.split('\\').join('/')}')"`).out; + writeFileSync(host.envFile, `${PG_ENV}CUSTOM_TEMPLATES_DIRECTORY = "${ownPosix}/"\n`); + const before = readFileSync(host.envFile); + const r = run([SETUP, 'rc=0; rt_transaction_run pasarguard "$RT_LIVE" 2>/dev/null || rc=$?; echo "rc=$rc"']); + assert.match(r.out, /rc=0/, r.err); + const block = readFileSync(host.envFile, 'utf8').slice(before.length); + assert.doesNotMatch(block, /CUSTOM_TEMPLATES_DIRECTORY/, 'the operator directory is not overridden'); + assert.equal(existsSync(page(host, own)), true, 'the page goes into the operator directory'); + assert.equal(readFileSync(join(own, 'subscription', 'index.html'), 'utf8'), 'operator page', 'their page is untouched'); + const u = run('rt_panel_uninstall_template pasarguard; echo "rc=$?"'); + assert.match(u.out, /rc=0/, u.err); + assert.equal(sha256(readFileSync(host.envFile)), sha256(before)); + assert.equal(existsSync(join(own, 'row-template')), false, 'our directory is removed'); + assert.equal(existsSync(own), true, 'the operator directory is not'); + }); +}); + +test('a templates directory the container cannot share with the host is refused before anything changes', () => { + withHost({}, ({ host, run }) => { + writeFileSync(host.envFile, `${PG_ENV}CUSTOM_TEMPLATES_DIRECTORY = "/srv/elsewhere"\n`); + const before = readFileSync(host.envFile); + const r = run([SETUP, 'rc=0; rt_transaction_run pasarguard "$RT_LIVE" || rc=$?; echo "rc=$rc mutated=$RT_TXN_MUTATED"']); + assert.match(r.out, /rc=1 mutated=0/, r.err); + assert.match(r.err, /outside .*pasarguard, the directory the container shares with the host/); + assert.equal(sha256(readFileSync(host.envFile)), sha256(before), '.env is untouched'); + assert.equal(restarts(host), 0); + }); +}); + +test('a page that is not Row-Template\'s is never overwritten, and the failed activation is rolled back', () => { + withHost({}, ({ host, run }) => { + const before = readFileSync(host.envFile); + mkdirSync(join(host.dataDir, 'templates', 'row-template'), { recursive: true }); + writeFileSync(page(host), '

someone else

'); + const r = run([SETUP, 'rc=0; rt_transaction_run pasarguard "$RT_LIVE" || rc=$?; echo "rc=$rc state=$RT_TXN_STATE"']); + assert.match(r.out, /rc=1/, r.err); + assert.match(r.err, /exists and is not Row-Template's/); + assert.equal(readFileSync(page(host), 'utf8'), '

someone else

', 'the operator file is untouched'); + assert.equal(sha256(readFileSync(host.envFile)), sha256(before)); + }); +}); + +test('a failure after the change is made is rolled back to the exact previous state', () => { + withHost({}, ({ host, run }) => { + writeFileSync(join(host.docker, 'fail_up'), ''); + const before = readFileSync(host.envFile); + const r = run([SETUP, 'rc=0; rt_transaction_run pasarguard "$RT_LIVE" || rc=$?; echo "rc=$rc state=$RT_TXN_STATE"']); + assert.match(r.out, /rc=1 state=(ROLLED_BACK|FAILED)/, r.err); + assert.equal(sha256(readFileSync(host.envFile)), sha256(before), '.env is restored byte for byte'); + assert.equal(existsSync(join(host.dataDir, 'templates')), false, 'the page and its directories are removed'); + }); +}); + +test('a stopped panel is not started; its next start picks the change up', () => { + withHost({ running: false }, ({ host, run }) => { + const r = run([SETUP, 'rc=0; rt_transaction_run pasarguard "$RT_LIVE" 2>/dev/null || rc=$?; echo "rc=$rc"', + 'rc=0; rt_panel_verify pasarguard live || rc=$?; echo "live=$rc"']); + assert.match(r.out, /rc=0/, r.err); + assert.match(r.out, /live=2/, 'live verification is UNAVAILABLE, not a failure'); + assert.equal(restarts(host), 0, 'never started'); + assert.equal(existsSync(join(host.docker, 'running')), false); + }); +}); + +test('re-applying is idempotent: no second block, no second restart', () => { + withHost({}, ({ host, run }) => { + const r = run([SETUP, 'rt_transaction_run pasarguard "$RT_LIVE" 2>/dev/null', + 'rt_panel_install_template pasarguard "$RT_LIVE"; echo "rc=$?"']); + assert.match(r.out, /rc=0/, r.err); + assert.equal((readFileSync(host.envFile, 'utf8').match(/# >>> row-template/g) || []).length, 1); + assert.equal(restarts(host), 1); + }); +}); + +test('a .env without a final newline is restored without one', () => { + withHost({ env: PG_ENV.trimEnd() }, ({ host, run }) => { + const before = readFileSync(host.envFile); + run([SETUP, 'rt_transaction_run pasarguard "$RT_LIVE" 2>/dev/null']); + assert.match(readFileSync(host.envFile, 'utf8'), /nl=1 >>>/, 'the block records the newline it added'); + run('rt_panel_uninstall_template pasarguard'); + assert.equal(sha256(readFileSync(host.envFile)), sha256(before)); + }); +}); + +test('a line the operator adds after the block survives uninstall', () => { + withHost({}, ({ host, run }) => { + run([SETUP, 'rt_transaction_run pasarguard "$RT_LIVE" 2>/dev/null']); + writeFileSync(host.envFile, `${readFileSync(host.envFile, 'utf8')}DEBUG = true\n`); + run('rt_panel_uninstall_template pasarguard'); + assert.equal(readFileSync(host.envFile, 'utf8'), `${PG_ENV}DEBUG = true\n`); + }); +}); + +test('a damaged block is refused rather than interpreted', () => { + withHost({}, ({ host, run }) => { + writeFileSync(host.envFile, `${PG_ENV}# >>> row-template (managed by Row-Template; do not edit) nl=0 >>>\nX=1\n`); + const before = readFileSync(host.envFile); + const r = run([SETUP, 'rc=0; rt_panel_uninstall_template pasarguard || rc=$?; echo "rc=$rc"', + 'rc=0; rt_transaction_run pasarguard "$RT_LIVE" || rc=$?; echo "txn=$rc"']); + assert.match(r.out, /rc=1/); + assert.match(r.out, /txn=1/); + assert.equal(sha256(readFileSync(host.envFile)), sha256(before)); + }); +}); + +test('restore refuses a record that lists a file this adapter never places', () => { + withHost({}, ({ run }) => { + const r = run([SETUP, + 'rt_transaction_stage_reset', 'rt_panel_backup_state pasarguard', + 'printf "../../etc/passwd\\n" > "$RT_PANEL_STAGE/pasarguard/files"', + 'snap="$(rt_backup_create v2 pasarguard 2>/dev/null)" || { echo "snapshot-refused"; exit 0; }', + 'rc=0; rt_panel_restore_state pasarguard "$snap" || rc=$?; echo "rc=$rc"']); + assert.match(r.out, /snapshot-refused|rc=1/, r.err); + }); +}); + +test('verify fails a placed page that was changed, and a selection that was removed', () => { + withHost({}, ({ host, run }) => { + run([SETUP, 'rt_transaction_run pasarguard "$RT_LIVE" 2>/dev/null']); + const good = readFileSync(page(host)); + writeFileSync(page(host), good.toString().replace('Test VPN', 'Tampered')); + let r = run('rc=0; rt_panel_verify pasarguard static || rc=$?; echo "rc=$rc"'); + assert.match(r.out, /rc=1/); + assert.match(r.err, /placed page differs/); + writeFileSync(page(host), good); + const env = readFileSync(host.envFile, 'utf8'); + writeFileSync(host.envFile, env.replace(/SUBSCRIPTION_PAGE_TEMPLATE = "row-template\/index.html"/, 'SUBSCRIPTION_PAGE_TEMPLATE = "subscription/index.html"')); + r = run('rc=0; rt_panel_verify pasarguard static || rc=$?; echo "rc=$rc"'); + assert.match(r.out, /rc=1/); + assert.match(r.err, /does not select the Row-Template page/); + }); +}); + +test('secrets in .env never reach output, logs or snapshots', () => { + withHost({}, ({ rt, run }) => { + const r = run([SETUP, 'rt_transaction_run pasarguard "$RT_LIVE"', 'rt_panel_verify pasarguard static', + 'rt_panel_verify pasarguard live', 'rt_panel_uninstall_template pasarguard']); + for (const secret of ['s3cr3t-Pa55w0rd-do-not-leak', 'jwt-secret-do-not-leak']) { + assert.equal(r.out.includes(secret) || r.err.includes(secret), false, 'not in output'); + const snaps = join(rt, 'backups.v2'); + const walk = (d) => readdirSync(d, { withFileTypes: true }).flatMap((e) => + (e.isDirectory() ? walk(join(d, e.name)) : [join(d, e.name)])); + for (const f of existsSync(snaps) ? walk(snaps) : []) { + assert.equal(readFileSync(f).includes(secret), false, `not in ${f}`); + } + } + }); +}); + +/* --- the artifact must fit the panel ---------------------------------------- */ + +test('a 3X-UI artifact, or a shell from before 1.3.0, is refused on PasarGuard', () => { + withHost({}, ({ base, run }) => { + const old = join(base, 'old-shell.html'); + const shell = readFileSync(join(PAYLOAD, 'shells', 'pasarguard', 'row', 'shell.html'), 'utf8'); + // a 1.2.x shell: the same layout without the context prelude and escaping + writeFileSync(old, shell.replace(/\{#-[\s\S]*?-#\}\n/, '').replace(/\{%-? ?set [^%]*%\}\n?/g, '') + .replace('{%- autoescape true -%}\n', '').replace('{%- endautoescape %}', '')); + const r = run([SETUP, + 'rc=0; rt_set_dist "$PAYLOAD/template.html" 2>/dev/null || rc=$?; echo "xui=$rc"', + `O=${JSON.stringify(old.split('\\').join('/'))}; O="$(cygpath -u "$O" 2>/dev/null || printf '%s' "$O")"`, + 'rc=0; rt_set_dist "$O" 2>/dev/null || rc=$?; echo "old=$rc"', + 'rc=0; rt_set_dist "$RT_TEMPLATE_STORE/editorial/template.html" || rc=$?; echo "ok=$rc"']); + assert.match(r.out, /xui=1/, 'the 3X-UI artifact is refused'); + assert.match(r.out, /old=1/, 'a shell without the prelude and escaping is refused'); + assert.match(r.out, /ok=0/, 'a 1.3.0 PasarGuard page is accepted'); + }); +}); + +test('a backup made for another panel is never restored onto PasarGuard', () => { + withHost({}, ({ run }) => { + const r = run([SETUP, + 'B="$(rt_backup_create)"', + 'sed -i "s/^panel=pasarguard$/panel=3xui/" "$B/meta"', + 'rc=0; rt_restore_from_backup "$B" 2>/dev/null || rc=$?; echo "rc=$rc"', + 'B2="$(rt_backup_create)"; grep -c "^panel=pasarguard$" "$B2/meta"']); + assert.match(r.out, /rc=1/, 'refused'); + assert.match(r.out, /\n1$/, 'and a PasarGuard backup records its panel'); + }); +}); + +/* --- the whole life cycle ---------------------------------------------------- */ + +test('install, verify, rebrand, switch design, roll back and uninstall on a PasarGuard host', () => { + withHost({}, ({ base, host, rt, run }) => { + const envBefore = readFileSync(host.envFile); + const ENV = 'export RT_ASSUME_NONINTERACTIVE=1 RT_SERVICE_NAME="Aurora Net" RT_SUPPORT_URL="" RT_LOGO_REMOVE=1'; + let r = run([ENV, 'rt_cmd_install "$PAYLOAD" rmSync(PAYLOAD_DIR, { recursive: true, force: true })); + +const SETUP = [ + 'RT_ACTIVE_PANEL=rebecca', + 'rt_layout_ensure', + 'rt_repair_template_store "$PAYLOAD" >/dev/null || [ $? -eq 2 ]', + 'rt_set_dist "$RT_TEMPLATE_STORE/row/template.html"', + 'rt_config_write "Test VPN" "" "" ""', + 'rt_activate', +].join('\n'); + +function withHost(opts, fn) { + const base = mkdtempSync(join(tmpdir(), 'row-rb-')); + try { + const host = rebeccaHost(base, opts); + const rt = join(base, 'rt'); + const run = (lines, env = {}) => bashRun([HOST_PREAMBLE, ...[].concat(lines)], + { paths: { ...host.paths, RT_ROOT: rt, RT_BIN: join(base, 'row-template'), PAYLOAD }, env }); + return fn({ base, host, rt, run }); + } finally { + rmSync(base, { recursive: true, force: true }); + } +} + +const page = (dir) => join(dir, 'row-template', 'index.html'); +const last = (host) => rebeccaRow(host.db).at(-1); + +/* --- detection and the database ------------------------------------------- */ + +test('detection needs two independent signals', () => { + const cases = [ + [{}, 0, 'the official layout'], + [{ compose: false, cli: false }, 0, '.env and the data directory'], + ]; + for (const [opts, want, label] of cases) { + withHost(opts, ({ run }) => { + const r = run('rc=0; rt_panel_detect rebecca || rc=$?; echo "rc=$rc"'); + assert.match(r.out, new RegExp(`rc=${want}`), `${label}\n${r.err}`); + }); + } + withHost({ compose: false, cli: false }, ({ host, run }) => { + rmSync(host.envFile); + const r = run('rc=0; rt_panel_detect rebecca || rc=$?; echo "rc=$rc"'); + assert.match(r.out, /rc=1/, 'the data directory alone is one signal: FAILURE'); + }); +}); + +test('capabilities are exactly what the adapter implements', () => { + withHost({}, ({ run }) => { + const r = run('rt_panel_capabilities rebecca'); + assert.equal(r.code, 0, r.err); + assert.equal(r.out, ['db_activation', 'file_placement', 'selection_read', 'selection_write', 'static_verify'].join('\n')); + }); +}); + +test('only a sqlite: database inside the shared data directory is ever used', () => { + withHost({}, ({ host, run }) => { + let r = run('rt_panel_rebecca_db'); + assert.equal(r.out, posix(host.db), 'the official sqlite URL resolves to the database'); + const setUrl = (url) => writeFileSync(host.envFile, `SUDO_PASSWORD = "x"\nSQLALCHEMY_DATABASE_URL = "${url}"\n`); + for (const url of ['mysql+pymysql://rebecca:Sup3rS3cret@127.0.0.1:3306/rebecca', + 'sqlite:///db.sqlite3', 'sqlite:////etc/elsewhere/db.sqlite3']) { + setUrl(url); + r = run('rc=0; rt_panel_rebecca_db || rc=$?; echo "rc=$rc"; rc=0; rt_panel_status rebecca; echo'); + assert.match(r.out, /rc=1/, `${url} must not be used`); + assert.match(r.out, /manual/, 'activation becomes manual'); + assert.equal(r.out.includes('Sup3rS3cret') || r.err.includes('Sup3rS3cret'), false, 'a password is never printed'); + } + }); +}); + +/* --- activation, rollback, uninstall ------------------------------------- */ + +test('activation sets two columns of the newest row and nothing else, with no restart', () => { + withHost({ rows: 2 }, ({ host, run }) => { + const before = rebeccaRow(host.db); + const r = run([SETUP, 'rc=0; rt_transaction_run rebecca "$RT_LIVE" || rc=$?; echo "rc=$rc"', + 'rc=0; rt_panel_verify rebecca static || rc=$?; echo "static=$rc"']); + assert.match(r.out, /rc=0/, r.err); + assert.match(r.out, /static=0/, r.err); + const after = rebeccaRow(host.db); + assert.deepEqual(after[0], before[0], 'an older row is untouched'); + const want = [...before[1]]; + want[1] = 'row-template/index.html'; + want[2] = posix(join(host.dataDir, 'templates')); + assert.deepEqual(after[1], want, 'exactly the page and directory columns of the row Rebecca reads'); + assert.equal(existsSync(page(join(host.dataDir, 'templates'))), true, 'the page is placed'); + const calls = existsSync(join(host.docker, 'calls')) ? readFileSync(join(host.docker, 'calls'), 'utf8').split(/\r?\n/) : []; + assert.equal(calls.some((l) => l.startsWith('compose')), false, 'Rebecca is never restarted'); + }); +}); + +test('rollback and uninstall restore NULL, empty and a value exactly', () => { + for (const customDir of [null, '']) { + withHost({ customDir }, ({ host, run }) => { + const before = last(host); + run([SETUP, 'rt_transaction_run rebecca "$RT_LIVE" 2>/dev/null', + 'printf "%s\\n" "$(basename "$RT_TXN_SNAPSHOT")" > "$RT_PANEL_ACTIVATION"']); + assert.equal(last(host)[1], 'row-template/index.html'); + const r = run(['RT_ACTIVE_PANEL=rebecca', 'rt_panel_uninstall_template rebecca; echo "rc=$?"']); + assert.match(r.out, /rc=0/, r.err); + assert.deepEqual(last(host), before, `custom_templates_directory ${JSON.stringify(customDir)} is restored exactly`); + assert.equal(existsSync(join(host.dataDir, 'templates')), false, 'the page and the directory it needed are removed'); + }); + } + withHost({ customDir: '' }, ({ host, run }) => { + const before = last(host); + writeFileSync(join(host.docker, 'unused'), ''); + const r = run([SETUP, 'rc=0', + // a failure after the change: the placed page is sabotaged so the + // post-change static verification fails and the engine rolls back + 'rt_panel_rebecca_place_orig="$(declare -f rt_panel_rebecca_place)"', + 'rt_panel_rebecca_place() { eval "${rt_panel_rebecca_place_orig/rt_panel_rebecca_place/rt_orig_place}"; rt_orig_place "$@" && printf "tampered" >> "$2/row-template/index.html"; }', + 'out="$(rt_panel_activate)" || rc=$?; echo "rc=$rc out=$out"']); + assert.match(r.out, /rc=1 out=$/, r.err); + assert.deepEqual(last(host), before, "rollback restores '' exactly, not NULL"); + assert.match(r.err, /Rebecca was restored exactly to its state before the attempt/, + 'the operator is told the truth: the restore was exact'); + assert.doesNotMatch(r.err, /rollback also failed/, "and not the engine's conservative post-check"); + assert.match(r.err, /placed page differs/, 'the real cause is shown'); + }); +}); + +test('an operator directory is used and kept; only the page column changes', () => { + withHost({}, ({ host, run }) => { + const own = join(host.dataDir, 'my templates'); + mkdirSync(join(own, 'subscription'), { recursive: true }); + writeFileSync(join(own, 'subscription', 'index.html'), 'operator page'); + run(`python=1; true`); + const ownPosix = posix(own); + const r0 = run(`sqlite3 "$(cygpath -u '${host.db.split('\\').join('/')}' 2>/dev/null || printf '%s' '${host.db.split('\\').join('/')}')" "UPDATE subscription_settings SET custom_templates_directory = '${ownPosix}'"`, + { RT_TEST_PYTHON: undefined }); + assert.equal(r0.code, 0, r0.err); + const before = last(host); + const r = run([SETUP, 'rc=0; rt_transaction_run rebecca "$RT_LIVE" 2>/dev/null || rc=$?; echo "rc=$rc"', + 'printf "%s\\n" "$(basename "$RT_TXN_SNAPSHOT")" > "$RT_PANEL_ACTIVATION"']); + assert.match(r.out, /rc=0/, r.err); + assert.equal(last(host)[2], ownPosix, 'the operator directory is kept'); + assert.equal(existsSync(page(own)), true, 'the page goes into it'); + assert.equal(readFileSync(join(own, 'subscription', 'index.html'), 'utf8'), 'operator page'); + run('rt_panel_uninstall_template rebecca'); + assert.deepEqual(last(host), before); + assert.equal(existsSync(own), true, 'the operator directory stays'); + assert.equal(existsSync(join(own, 'row-template')), false); + }); +}); + +test('a quote in panel data cannot break the SQL', () => { + withHost({ customDir: "/srv/it's here" }, ({ host, run }) => { + const before = last(host); + // the directory is outside the shared data dir, so activation is refused + // -- but reading and restoring it goes through the quoting all the same + const r = run([SETUP, 'rt_panel_rebecca_db_ready', 'rt_panel_rebecca_dir_get; echo', + 'rt_panel_rebecca_write "x\'); DROP TABLE admins; --" present "$(rt_panel_rebecca_dir_get | cut -d: -f2-)"', + 'rt_panel_rebecca_page_get; echo']); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /present:\/srv\/it's here/); + assert.match(r.out, /x'\); DROP TABLE admins; --/, 'the value is stored as data'); + assert.equal(last(host)[2], before[2], 'the quoted directory round-trips'); + const tables = run(`sqlite3 "$(cygpath -u '${host.db.split('\\').join('/')}' 2>/dev/null || printf '%s' '${host.db.split('\\').join('/')}')" "SELECT count(*) FROM admins"`); + assert.equal(tables.code, 0, 'the admins table still exists'); + }); +}); + +test('uninstall without an activation record returns Rebecca to its default page', () => { + withHost({}, ({ host, run }) => { + const before = last(host); + run([SETUP, 'rt_transaction_run rebecca "$RT_LIVE" 2>/dev/null', 'rm -f "$RT_PANEL_ACTIVATION"']); + const r = run('rt_panel_uninstall_template rebecca; echo "rc=$?"'); + assert.match(r.out, /rc=0/, r.err); + assert.deepEqual(last(host), before, "the default page, and NULL for the directory Row-Template set"); + }); +}); + +test('an operator who moved away from Row-Template keeps their choice on uninstall', () => { + withHost({}, ({ host, run }) => { + run([SETUP, 'rt_transaction_run rebecca "$RT_LIVE" 2>/dev/null']); + const db = `"$(cygpath -u '${host.db.split('\\').join('/')}' 2>/dev/null || printf '%s' '${host.db.split('\\').join('/')}')"`; + run(`sqlite3 ${db} "UPDATE subscription_settings SET subscription_page_template = 'mine/page.html'"`); + const chosen = last(host); + const r = run('rt_panel_uninstall_template rebecca; echo "rc=$?"'); + assert.match(r.out, /rc=0/, r.err); + assert.deepEqual(last(host), chosen, 'the selection is theirs and is left alone'); + assert.equal(existsSync(page(join(host.dataDir, 'templates'))), false, 'our page is still removed'); + }); +}); + +test('admins who override the page for their users are reported by verify', () => { + withHost({ admins: ['{"subscription_page_template": "vip/index.html"}', '{}', '{"custom_templates_directory":"/x"}'] }, ({ run }) => { + const r = run([SETUP, 'rt_transaction_run rebecca "$RT_LIVE" 2>/dev/null', 'rt_panel_verify rebecca static; echo "rc=$?"']); + assert.match(r.out, /rc=0/); + assert.match(r.err, /2 admin\(s\) override the subscription page/); + }); +}); + +/* --- manual activation --------------------------------------------------------- */ + +test('without sqlite3, activation places the page and says exactly what to set', () => { + withHost({ sqlite: false }, ({ host, run }) => { + const before = last(host); + const r = run([SETUP, 'rt_panel_status rebecca; echo', 'out="$(rt_panel_activate)"; echo "outcome=$out"', 'rt_panel_manual_steps']); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /^manual/m); + assert.match(r.out, /outcome=manual/); + assert.match(r.out, /Subscription page template:\s+row-template\/index.html/); + assert.equal(existsSync(page(join(host.dataDir, 'templates'))), true, 'the page is in place for the operator to select'); + assert.deepEqual(last(host), before, 'and the database was not touched'); + }); +}); + +/* --- the whole life cycle ------------------------------------------------------ */ + +test('install, verify, rebrand, switch design, roll back and uninstall on a Rebecca host', () => { + withHost({}, ({ base, host, rt, run }) => { + const rowBefore = last(host); + const tpl = join(host.dataDir, 'templates'); + let r = run(['export RT_ASSUME_NONINTERACTIVE=1 RT_SERVICE_NAME="Aurora Net" RT_SUPPORT_URL="" RT_LOGO_REMOVE=1', + 'rt_cmd_install "$PAYLOAD" { - /* P4 added no adapter. P5A (2026-09-23) adds exactly one, and the claim is - kept PRECISE rather than dropped: a second adapter appearing without a - phase authorising it is still a failure, and the panels with no adapter are - still asserted absent. */ + /* P4 added no adapter; P5A added 3xui; 1.3.0 adds pasarguard and rebecca. + The claim stays PRECISE: an adapter appearing without a release + authorising it is still a failure. */ const files = readdirSync(PANELS_DIR).sort(); - assert.deepEqual(files, ['3xui.sh', 'index.sh', 'interface.sh'], - 'expected the two contract files plus the authorised 3xui adapter'); - for (const name of ['pasarguard.sh', 'rebecca.sh']) { - assert.equal(existsSync(join(PANELS_DIR, name)), false, - `${name} must not exist: no adapter is authorised for it`); - } + assert.deepEqual(files, ['3xui.sh', 'index.sh', 'interface.sh', 'pasarguard.sh', 'rebecca.sh'], + 'expected the two contract files plus the three authorised adapters'); }); test('the registry implements exactly the panels a phase has authorised', () => { /* The registry is the single decision point, so this is where "implemented" - is either true or false for every panel in the enum. A panel with no - implementation must resolve to NOTHING -- never to a stub that reports - success, because a transaction engine cannot detect a fabricated one. */ - const body = [ + is either true or false for every panel in the enum. An adapter that is + absent from the build must resolve to NOTHING -- never to a stub that + reports success, because a transaction engine cannot detect a fabricated + one. */ + const probe = [ 'for p in 3xui pasarguard rebecca; do', ' impl="$(rt_panel_impl_for "$p")"', ' printf "%s|%s\\n" "$p" "${impl:-none}"', 'done', 'exit 0', ].join('\n'); - const r = sh(body); + const r = sh(probe); assert.equal(r.code, 0, r.err); const got = new Map(r.out.split('\n').filter(Boolean).map((l) => l.split('|'))); - assert.equal(got.get('3xui'), '3xui', '3xui must resolve to its real implementation'); - assert.equal(got.get('pasarguard'), 'none', 'pasarguard must resolve to nothing'); - assert.equal(got.get('rebecca'), 'none', 'rebecca must resolve to nothing'); + for (const p of ['3xui', 'pasarguard', 'rebecca']) { + assert.equal(got.get(p), p, `${p} must resolve to its real implementation`); + } + const absent = sh('RT_PANEL_PASARGUARD_LOADED=""; RT_PANEL_REBECCA_LOADED=""\n' + probe); + assert.equal(absent.code, 0, absent.err); + const gone = new Map(absent.out.split('\n').filter(Boolean).map((l) => l.split('|'))); + assert.equal(gone.get('pasarguard'), 'none', 'an absent adapter resolves to nothing'); + assert.equal(gone.get('rebecca'), 'none', 'an absent adapter resolves to nothing'); }); test('a transaction against the real interface, with no panel on this host, fails closed', () => { diff --git a/tests/installer.test.mjs b/tests/installer.test.mjs index 4ef19a1..0bce26c 100644 --- a/tests/installer.test.mjs +++ b/tests/installer.test.mjs @@ -65,6 +65,17 @@ test('json escape neutralises a breakout without touching data', () => assert.equal(sh('rt_json_escape "a & b > c"').out, 'a & b > c'); }); +test('json escape leaves no brace, so branding can never form a template delimiter', () => { + /* The page is parsed as a template on every panel: Go on 3X-UI, Jinja2 on + PasarGuard (unsandboxed: a delimiter there is code execution), pongo2 on + Rebecca. Every { and } becomes a JavaScript escape of itself. */ + for (const name of ['{{ config }}', '{% endautoescape %}{{ 7*7 }}', '{# c #}', '{{ .subTitle }}', '}}{{']) { + const out = sh(`rt_json_escape ${JSON.stringify(name)}`).out; + assert.equal(/[{}]/.test(out), false, `${name} -> ${out}`); + assert.equal(JSON.parse(`"${out}"`), name, 'and JavaScript reads back exactly the original text'); + } +}); + test('support URL validation accepts only frontend-renderable schemes', () => { for (const u of ['https://t.me/x', 'http://a.b', 'tg://resolve?domain=x', 'mailto:a@b.c']) { assert.ok(ok(`rt_validate_support_url ${JSON.stringify(u)}`), u); diff --git a/tests/panel-support.test.mjs b/tests/panel-support.test.mjs index ea75c05..68e79f5 100644 --- a/tests/panel-support.test.mjs +++ b/tests/panel-support.test.mjs @@ -8,12 +8,18 @@ * itself, and every README and compatibility page is checked against it: * * - the panel registry (installer/panels/index.sh) is asked which panels have - * an implementation, and every panel operation is called for the others; - * - the install command is run on a host that has PasarGuard or Rebecca and no - * 3X-UI, with real detection, to show there is no other install path; + * an implementation, and every panel operation is called on a host where + * the panel is NOT installed, where none may report success; + * - the install command is run on a host where a panel is only half there + * (one detection signal), with real detection, to show it refuses; * - the capability matrix in docs/.../compatibility.mdx (English, Persian, * Arabic) and the panel table in all five READMEs must match those results. * + * Since 1.3.0 all three panels have an implementation, so all three are + * Supported; the full install/activate/verify/rollback/uninstall behaviour of + * PasarGuard and Rebecca is exercised in tests/installer-panel-pasarguard.test.mjs + * and tests/installer-panel-rebecca.test.mjs. + * * A panel becomes "Supported" in the docs only when this file, unchanged, finds * an implementation for it. Nothing here is satisfied by an adapter file merely * existing. @@ -81,28 +87,39 @@ const MATRIX = installerMatrix(); const INSTALLABLE = Object.keys(MATRIX).filter((p) => MATRIX[p].implemented); const UNAVAILABLE = 2; -test('the installer implements 3X-UI and no other panel', () => { +test('the installer implements all three panels', () => { assert.deepEqual(Object.keys(MATRIX).sort(), ['3xui', 'pasarguard', 'rebecca'], 'the closed panel set'); - assert.deepEqual(INSTALLABLE, ['3xui'], 'only 3X-UI has an installer implementation'); + assert.deepEqual(INSTALLABLE.sort(), ['3xui', 'pasarguard', 'rebecca'], + 'every panel in the registry has an installer implementation'); assert.deepEqual(buildablePanelIds().sort(), ['3xui', 'pasarguard', 'rebecca'], - 'while a page shell is still BUILT for all three -- which is not support'); + 'and a page shell is built for each'); }); -test('every installer operation on PasarGuard and Rebecca is UNAVAILABLE', () => { - for (const p of ['pasarguard', 'rebecca']) { +test('on a host without the panel, no operation reports success', () => { + /* This test host runs none of the panels. An operation that answered + SUCCESS here would be claiming work it could not have done. Detection must + say the panel is not here (NOT_APPLICABLE); everything else must refuse. */ + const HAS = { '3xui': ['/usr/local/x-ui/x-ui', '/usr/local/bin/x-ui'], + pasarguard: ['/opt/pasarguard/.env'], rebecca: ['/opt/rebecca/.env'] }; + for (const [p, paths] of Object.entries(HAS)) { + if (paths.some((x) => existsSync(x))) continue; // a real panel host: not this test's subject + assert.equal(MATRIX[p].rc.detection, 3, `${p}: detection must be NOT_APPLICABLE (3)`); for (const [col, rc] of Object.entries(MATRIX[p].rc)) { - assert.equal(rc, UNAVAILABLE, `${p}: ${col} must be UNAVAILABLE (2), got ${rc}`); + assert.notEqual(rc, 0, `${p}: ${col} must not report SUCCESS on a host without the panel`); } } }); -/* A PasarGuard or Rebecca host: the panel's systemd unit is present, there is - no x-ui binary or unit. Detection runs for real, against a stand-in - systemctl on PATH. Skipped where a real 3X-UI binary is installed, since the - library looks for it at fixed system paths. */ -const HAS_REAL_XUI = ['/usr/local/x-ui/x-ui', '/usr/local/bin/x-ui'].some((p) => existsSync(p)); +/* A host where PasarGuard or Rebecca is only HALF there: the panel's systemd + unit is registered, and nothing else (no .env, no data directory, no CLI). + One signal is not identification (installer/panels/interface.sh), so install + must refuse, write nothing, and say why. Detection runs for real, against a + stand-in systemctl on PATH. Skipped where a real panel is installed, since + the library looks at fixed system paths. */ +const HAS_REAL_PANEL = ['/usr/local/x-ui/x-ui', '/usr/local/bin/x-ui', '/opt/pasarguard', '/opt/rebecca', + '/var/lib/pasarguard', '/var/lib/rebecca'].some((p) => existsSync(p)); -test('on a host with PasarGuard or Rebecca and no 3X-UI, install refuses and writes nothing', { skip: HAS_REAL_XUI }, () => { +test('on a host where PasarGuard or Rebecca is only half there, install refuses and writes nothing', { skip: HAS_REAL_PANEL }, () => { for (const unit of ['pasarguard.service', 'rebecca.service']) { const base = mkdtempSync(join(tmpdir(), 'row-panel-')); try { @@ -125,7 +142,8 @@ test('on a host with PasarGuard or Rebecca and no 3X-UI, install refuses and wri `RT_ASSUME_YES=1 rt_cmd_install "${payload}" { } }); -test('the changelog never calls PasarGuard or Rebecca supported', () => { - const sentences = read('CHANGELOG.md').replace(/\n\s*/g, ' ').split(/(?<=[.!?])\s+/); - for (const s of sentences) { - if (!/PasarGuard|Rebecca/.test(s) || !/\bsupported\b/i.test(s)) continue; - assert.match(s, /\bnot (?:yet )?supported\b|\bunsupported\b|not supported panels/i, - `a changelog sentence names PasarGuard/Rebecca as supported: "${s}"`); +/* The changelog is history: every release's section must describe the panels + as they were IN THAT RELEASE. Before 1.3.0 no release supported PasarGuard or + Rebecca, so no older section may call them supported; from the release that + implements a panel on, its section may -- and only because the installer, + asked above, really implements it. */ +function changelogSections() { + const out = []; + let cur = null; + for (const line of read('CHANGELOG.md').split('\n')) { + const m = line.match(/^## \[?(\d+\.\d+\.\d+)\]?/); + if (m) { cur = { version: m[1], text: '' }; out.push(cur); continue; } + if (cur) cur.text += `${line}\n`; + } + return out; +} + +const semver = (v) => v.split('.').map(Number); +const before = (a, b) => { + const [x, y] = [semver(a), semver(b)]; + for (let i = 0; i < 3; i += 1) if (x[i] !== y[i]) return x[i] < y[i]; + return false; +}; + +test('the changelog calls PasarGuard or Rebecca supported only from the release that implements them', () => { + const sections = changelogSections(); + assert.ok(sections.some((s) => s.version === '1.3.0'), 'the 1.3.0 section exists'); + for (const { version, text } of sections) { + const sentences = text.replace(/\n\s*/g, ' ').split(/(?<=[.!?])\s+/); + for (const s of sentences) { + if (!/PasarGuard|Rebecca/.test(s) || !/\bsupported\b/i.test(s)) continue; + if (before(version, '1.3.0')) { + assert.match(s, /\bnot (?:yet )?supported\b|\bunsupported\b|not supported panels/i, + `${version}: a changelog sentence names PasarGuard/Rebecca as supported before 1.3.0: "${s}"`); + } else { + for (const p of ['pasarguard', 'rebecca']) { + if (new RegExp(p, 'i').test(s) && !/\bnot (?:yet )?supported\b|\bunsupported\b/i.test(s)) { + assert.ok(INSTALLABLE.includes(p), `${version}: claims ${p} is supported, but the installer does not implement it`); + } + } + } + } } + const current = sections.find((s) => s.version === '1.3.0').text; + assert.match(current, /PasarGuard/, 'the 1.3.0 section names PasarGuard'); + assert.match(current, /Rebecca/, 'the 1.3.0 section names Rebecca'); }); diff --git a/tests/release.test.mjs b/tests/release.test.mjs index ac7fae7..3efc6c4 100644 --- a/tests/release.test.mjs +++ b/tests/release.test.mjs @@ -266,7 +266,7 @@ test('the release tarball is byte-deterministic', () => { /* 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']; +const COMPANIONS = ['lib/transaction.sh', 'panels/3xui.sh', 'panels/index.sh', 'panels/interface.sh', 'panels/pasarguard.sh', 'panels/rebecca.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'); From 0b3d5f533f00c4dad0d626bdb0b85c2cf9bdca19 Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Fri, 25 Sep 2026 23:42:31 +0330 Subject: [PATCH 08/25] feat(templates): add Meter and Notebook, ported from the author's Pulse and Sketch The two designs the project's author contributed privately become core templates 16 and 17, on the shared runtime exactly like the other fifteen: own layout.html with all 79 hooks once, the three shared scripts and locale island byte for byte, both themes, RTL, the 44px tap floor, inside the 200 KiB refusal point, and byte-locked (size + SHA-256). They ship as `meter` and `notebook` because `pulse` and `sketch` are already core ids. The installer chooser, the Bash registry projection, the build, the panel shells (3X-UI, PasarGuard, Rebecca), the fixtures, the docs gallery and the captured previews all carry them; every count that said fifteen now says seventeen. Co-Authored-By: Claude Opus 5.5 --- docs/design/CUSTOM-TEMPLATE-GUIDELINES.md | 20 +- docs/public/previews/manifest.json | 42 +- docs/public/previews/meter-desktop.webp | Bin 0 -> 36592 bytes docs/public/previews/meter-mobile.webp | Bin 0 -> 24130 bytes docs/public/previews/notebook-desktop.webp | Bin 0 -> 61470 bytes docs/public/previews/notebook-mobile.webp | Bin 0 -> 38548 bytes docs/scripts/capture-previews.mjs | 12 +- docs/src/components/TemplateComparison.astro | 2 +- docs/src/components/TemplateGallery.astro | 2 +- docs/src/content/docs/ar/getting-started.mdx | 2 +- docs/src/content/docs/ar/index.mdx | 14 +- docs/src/content/docs/ar/templates/index.mdx | 14 +- .../content/docs/ar/templates/selecting.mdx | 4 +- docs/src/content/docs/custom-templates.mdx | 4 +- docs/src/content/docs/developer.mdx | 4 +- docs/src/content/docs/fa/custom-templates.mdx | 4 +- docs/src/content/docs/fa/developer.mdx | 4 +- docs/src/content/docs/fa/getting-started.mdx | 2 +- docs/src/content/docs/fa/index.mdx | 14 +- docs/src/content/docs/fa/templates/index.mdx | 14 +- .../content/docs/fa/templates/selecting.mdx | 4 +- docs/src/content/docs/getting-started.mdx | 2 +- docs/src/content/docs/index.mdx | 14 +- docs/src/content/docs/templates/index.mdx | 14 +- docs/src/content/docs/templates/selecting.mdx | 4 +- docs/src/data/templates.json | 34 +- installer/lib/row-template.sh | 4 +- src/templates/meter/base.css | 127 +++ src/templates/meter/components.css | 892 ++++++++++++++++++ src/templates/meter/layout.css | 58 ++ src/templates/meter/layout.html | 209 ++++ src/templates/meter/rtl.css | 26 + src/templates/meter/tokens.css | 148 +++ src/templates/notebook/base.css | 120 +++ src/templates/notebook/components.css | 861 +++++++++++++++++ src/templates/notebook/layout.css | 80 ++ src/templates/notebook/layout.html | 207 ++++ src/templates/notebook/rtl.css | 17 + src/templates/notebook/tokens.css | 161 ++++ tests/adapters-pasarguard.test.mjs | 4 +- tests/adapters-rebecca.test.mjs | 4 +- tests/adapters.test.mjs | 4 +- tests/build.test.mjs | 86 +- tests/contract.test.mjs | 4 +- tests/panels-fixtures-rebecca.test.mjs | 4 +- tests/panels-fixtures.test.mjs | 4 +- tests/panels-pasarguard-shell.test.mjs | 4 +- tests/panels-rebecca-shell.test.mjs | 4 +- tests/panels.test.mjs | 6 +- tests/preview-determinism.test.mjs | 28 +- tests/registry.test.mjs | 10 +- tools/fixtures-all.mjs | 2 +- tools/templates.mjs | 35 +- 53 files changed, 3211 insertions(+), 128 deletions(-) create mode 100644 docs/public/previews/meter-desktop.webp create mode 100644 docs/public/previews/meter-mobile.webp create mode 100644 docs/public/previews/notebook-desktop.webp create mode 100644 docs/public/previews/notebook-mobile.webp create mode 100644 src/templates/meter/base.css create mode 100644 src/templates/meter/components.css create mode 100644 src/templates/meter/layout.css create mode 100644 src/templates/meter/layout.html create mode 100644 src/templates/meter/rtl.css create mode 100644 src/templates/meter/tokens.css create mode 100644 src/templates/notebook/base.css create mode 100644 src/templates/notebook/components.css create mode 100644 src/templates/notebook/layout.css create mode 100644 src/templates/notebook/layout.html create mode 100644 src/templates/notebook/rtl.css create mode 100644 src/templates/notebook/tokens.css diff --git a/docs/design/CUSTOM-TEMPLATE-GUIDELINES.md b/docs/design/CUSTOM-TEMPLATE-GUIDELINES.md index b68aa61..7fcba4b 100644 --- a/docs/design/CUSTOM-TEMPLATE-GUIDELINES.md +++ b/docs/design/CUSTOM-TEMPLATE-GUIDELINES.md @@ -1,7 +1,7 @@ # Custom Template Guidelines **Status:** binding contract for every future custom template. -**Applies to:** any template added to this repository that is not one of the fifteen +**Applies to:** any template added to this repository that is not one of the seventeen frozen core designs. **Companion documents:** `CUSTOM-TEMPLATES-PROPOSAL.md` (the design rationale and phased plan), `CONTRIBUTING.md` (the general contribution process), @@ -85,14 +85,14 @@ behaviour and defaults a custom entry to unlocked"*. The gap between 15 and 200 is intentional: it leaves room for future core designs without a renumbering, and makes the two tiers distinguishable at a glance. -**Checked by:** `tests/registry.test.mjs` — *"core templates keep order 1..15 and a +**Checked by:** `tests/registry.test.mjs` — *"core templates keep order 1..17 and a custom template must use order >= 200"*, *"the selectable set is sorted by order…"*. ### 1.5 The frozen set The frozen set is **core-only**. `FROZEN_ARTIFACTS` in `tests/build.test.mjs` holds -eleven of the fifteen; `row`, `editorial`, `canvas` and `pulsenova` hold individual -lock tests. The union is exactly the fifteen core templates. +thirteen of the seventeen; `row`, `editorial`, `canvas` and `pulsenova` hold individual +lock tests. The union is exactly the seventeen core templates. > **A custom template MUST NOT appear in the frozen set.** @@ -101,7 +101,7 @@ and the build reads only `styles` and `emitDataTemplate` off a registry entry, s entry can add itself to the frozen set. The test that pins this fails if a `tier: 'custom'` id ever appears there. -**Checked by:** `tests/build.test.mjs` — *"the frozen set is exactly the fifteen core +**Checked by:** `tests/build.test.mjs` — *"the frozen set is exactly the seventeen core templates, and a custom template can never enter it"*. --- @@ -109,7 +109,7 @@ templates, and a custom template can never enter it"*. ## 2. Mandatory Runtime Contract Every custom template MUST satisfy the following. These are the same requirements -the fifteen core templates satisfy; there is no reduced contract for custom work. +the seventeen core templates satisfy; there is no reduced contract for custom work. | # | requirement | how it is checked | |---|---|---| @@ -142,7 +142,7 @@ The artifact carries three ` + + + + + + + + + + + +
+ +
+
+
+ +

+
+
+ + {{ if .enabled }}Enabled{{ else }}Disabled{{ end }} + + +
+
+
+ + +
+
+ +
+ +
+
+

Subscription status

+ {{ if .subTitle }}{{ .subTitle }}{{ end }} +
+

{{ if .remained }}{{ .remained }} remaining{{ end }}

+
+

+ + {{ .used }} + {{ if eq .totalByte 0 }}used{{ else }}used of {{ .total }}{{ end }} +

+
+
+
+

{{ if eq .expire 0 }}Never expires{{ else if lt .expire 0 }}Starts on first connection{{ else }}—{{ end }}

+

+
+ +
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+

Connect

+
+
+

+
+ +
+ +
+
+

Configurations

+ +
+

+ +
+ + +
+ + + {{ if .subSupportUrl }}
Contact support
{{ end }}
+ +
+
+ +
+ + +
+

QR code

+ +
+
+
+

Scan with your client application

+
+ + +
+
+
+ + + + +
+

+ +
+
+
+

+
+ + +
+ + +
+
+ + + + + diff --git a/src/templates/meter/rtl.css b/src/templates/meter/rtl.css new file mode 100644 index 0000000..0dcd600 --- /dev/null +++ b/src/templates/meter/rtl.css @@ -0,0 +1,26 @@ +/* Direction-specific rules. Every offset is a logical property, so dir on + does the layout; what remains are the glyphs with a direction of + their own, and Latin tracking, which would break Arabic joins. The meter + fills from the start edge in both directions: the track is masked as a + whole, so its segments stay aligned either way. */ + +[dir="rtl"] .icon-chevron, +[dir="rtl"] .icon-external { + transform: scaleX(-1); +} + +[lang="fa"] .cfg-proto, +[lang="ar"] .cfg-proto, +[lang="fa"] .brand-name, +[lang="ar"] .brand-name, +[lang="fa"] .hero-big, +[lang="ar"] .hero-big { + letter-spacing: 0; +} + +/* Persian and Arabic ascenders and descenders need room the tight Latin + display leading would clip. */ +[lang="fa"] .hero-big, +[lang="ar"] .hero-big { + line-height: 1.35; +} diff --git a/src/templates/meter/tokens.css b/src/templates/meter/tokens.css new file mode 100644 index 0000000..4857f8d --- /dev/null +++ b/src/templates/meter/tokens.css @@ -0,0 +1,148 @@ +/* Design tokens. Every value a theme can change lives here and nowhere else; + the rest of the stylesheet only ever reads custom properties. + + Meter is the dashboard of the catalogue: near-black planes, one electric + blue, and a single segmented meter that reads like an equalizer. It is a + port of a design contributed by the project's author ("Pulse"), rebuilt on + the shared runtime: every figure, label and control comes from Row's hooks. */ + +:root { + --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", + Arial, sans-serif; + --font-arabic: Vazirmatn, "Segoe UI", Tahoma, "Noto Naskh Arabic", + "Geeza Pro", sans-serif; + + --fs-hero: clamp(2.25rem, 9vw, 3.75rem); + --fs-title: 0.9375rem; + --fs-body: 0.9375rem; + --fs-caption: 0.8125rem; + --fs-micro: 0.75rem; + + --lh-hero: 1.05; + --lh-tight: 1.35; + --lh-body: 1.6; + --measure: 60ch; + + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 20px; + --sp-6: 24px; + --sp-7: 32px; + + --r-card: 16px; + --r-control: 11px; + --r-seg: 3px; + --r-pill: 999px; + + --dur-fast: 120ms; + --dur: 180ms; + --dur-slow: 420ms; + --ease: cubic-bezier(0.4, 0, 0.2, 1); + --ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1); + + --page-max: 880px; + --tap: 44px; + --mark: 36px; + --btn-h: 44px; + + /* The meter: a track cut into equal segments, filled from the start. */ + --meter-h: 72px; + --seg: 7px; + --seg-gap: 3px; +} + +[data-theme="dark"] { + color-scheme: dark; + + --bg: #09090B; + --surface: #111113; + --surface-2: #1A1A1E; + --border: #212126; + --border-strong: #34343B; + + --text: #FAFAFA; + --muted: #A1A1AA; + --subtle: #8B8B94; + + --accent: #3B82F6; + --accent-strong: #2563EB; + --accent-soft: #14213A; + --on-accent: #FFFFFF; + + --meter-off: #1C1C21; + --meter-top: #60A5FA; + --meter-bottom: #2563EB; + --meter-warn-top: #FBBF24; + --meter-warn-bottom: #D97706; + --meter-over-top: #F87171; + --meter-over-bottom: #DC2626; + --glow: rgb(59 130 246 / 0.24); + + --success: #22C55E; + --warning: #F59E0B; + --danger: #EF4444; + --pending: #A78BFA; + --focus: #60A5FA; + --qr-bg: #FFFFFF; + --scrim: rgb(0 0 0 / 0.62); + --shadow: 0 18px 44px rgb(0 0 0 / 0.45); +} + +[data-theme="light"] { + color-scheme: light; + + --bg: #F4F4F6; + --surface: #FFFFFF; + --surface-2: #F1F1F4; + --border: #E4E4E9; + --border-strong: #CACAD2; + + --text: #0C0C0E; + --muted: #55555F; + --subtle: #6A6A74; + + --accent: #2563EB; + --accent-strong: #1D4ED8; + --accent-soft: #E3ECFD; + --on-accent: #FFFFFF; + + --meter-off: #E6E6EC; + --meter-top: #3B82F6; + --meter-bottom: #1D4ED8; + --meter-warn-top: #F59E0B; + --meter-warn-bottom: #B45309; + --meter-over-top: #EF4444; + --meter-over-bottom: #B91C1C; + --glow: rgb(37 99 235 / 0.16); + + --success: #15803D; + --warning: #B45309; + --danger: #B91C1C; + --pending: #6D28D9; + --focus: #2563EB; + --qr-bg: #FFFFFF; + --scrim: rgb(12 12 14 / 0.45); + --shadow: 0 18px 44px rgb(12 12 14 / 0.16); +} + +/* The Arabic-script face is opted into by language, and its unicode-range + keeps it off Latin, Cyrillic and CJK text even here. */ +[lang="fa"], +[lang="ar"] { + --font: var(--font-arabic); + --lh-body: 1.85; +} + +/* row:font-face */ +@font-face { + font-family: Vazirmatn; + src: url("data:font/woff2;base64,__FONT_BASE64__") format("woff2"); + font-weight: 400 700; + font-style: normal; + font-display: swap; + unicode-range: U+0600-06FF, U+200C-200F, U+2066-2069, U+FB50-FDFF, + U+FE70-FEFF; +} +/* row:font-face end */ diff --git a/src/templates/notebook/base.css b/src/templates/notebook/base.css new file mode 100644 index 0000000..521618b --- /dev/null +++ b/src/templates/notebook/base.css @@ -0,0 +1,120 @@ +/* Reset, document shell and the primitives everything else builds on. */ + +*, +*::before, +*::after { box-sizing: border-box; } + +html { + background: var(--paper); + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +/* Dotted notebook paper. */ +body { + margin: 0; + min-height: 100vh; + font-family: var(--font); + font-size: var(--fs-body); + line-height: var(--lh-body); + color: var(--ink); + background-color: var(--paper); + background-image: radial-gradient(var(--paper-dot) 1px, transparent 1.5px); + background-size: var(--dots) var(--dots); + font-variant-numeric: tabular-nums; + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3, p, ul, ol { + margin: 0; + font-weight: inherit; + font-size: inherit; +} + +ul, ol { padding: 0; list-style: none; } + +a { color: var(--blue); } + +button { + margin: 0; + font: inherit; + color: inherit; + font-variant-numeric: inherit; +} + +input, textarea { + font: inherit; + color: inherit; +} + +canvas { display: block; } + +svg.sprite { display: none; } + +.icon { + inline-size: 1.0625rem; + block-size: 1.0625rem; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 2.4; + stroke-linecap: round; + stroke-linejoin: round; +} + +.announce-text, +#connect-hint { max-inline-size: var(--measure); } + +.ltr { + direction: ltr; + unicode-bidi: isolate; +} + +.visually-hidden { + position: absolute; + inline-size: 1px; + block-size: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +html:not([data-js]) .js-only { display: none !important; } + +/* The tap floor, stated on element types so no control can slip under it: + every button, link-button, tab and field is at least 44px tall. */ +button, +a.btn, +[role="tab"], +input { min-block-size: var(--tap); } + +:focus-visible { + outline: 2.5px dashed var(--focus); + outline-offset: 3px; +} + +::selection { + background: var(--yellow); + color: var(--on-yellow); +} + +@keyframes ink-in { + from { + opacity: 0; + transform: scale(0.92) rotate(-2deg); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/src/templates/notebook/components.css b/src/templates/notebook/components.css new file mode 100644 index 0000000..d799a49 --- /dev/null +++ b/src/templates/notebook/components.css @@ -0,0 +1,861 @@ +/* The component vocabulary. */ + +[hidden] { display: none !important; } + +/* --- cards: ink outline, washi tape, a slight tilt ----------------------- */ + +.card { + position: relative; + display: flex; + flex-direction: column; + gap: var(--sp-3); + padding: var(--sp-5) var(--sp-4) var(--sp-4); + background: var(--card); + border: var(--ink-w) solid var(--line); + border-radius: var(--r-card-a); + box-shadow: var(--shadow); + transform: rotate(0.2deg); +} + +.card-expiry, +.card-explorer { + border-radius: var(--r-card-b); + transform: rotate(-0.25deg); +} + +.tape { + position: absolute; + inset-block-start: -10px; + inset-inline-start: calc(50% - 37px); + inline-size: 74px; + block-size: 16px; + border-radius: 2px; + box-shadow: 0 1px 3px var(--hard); + transform: rotate(-3deg); +} + +.tape-green { background: var(--tape-green); } +.tape-blue { background: var(--tape-blue); } +.tape-yellow { background: var(--tape-yellow); } +.tape-purple { background: var(--tape-purple); } + +.card-title { + font-size: var(--fs-title); + font-weight: 800; + line-height: var(--lh-tight); +} + +/* A highlighter stroke behind a heading. */ +.marker { + padding-inline: 7px; + background: linear-gradient(104deg, transparent 1%, var(--mark-yellow) 2.5%, var(--mark-yellow) 97%, transparent 99%); + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} + +.marker-green { + background: linear-gradient(104deg, transparent 1%, var(--mark-green) 2.5%, var(--mark-green) 97%, transparent 99%); +} + +.marker-purple { + background: linear-gradient(104deg, transparent 1%, var(--mark-purple) 2.5%, var(--mark-purple) 97%, transparent 99%); +} + +.note { + font-size: var(--fs-caption); + color: var(--ink-soft); + font-weight: 600; +} + +.note:empty { display: none; } + +/* --- brand -------------------------------------------------------------- */ + +.brand { + display: flex; + align-items: center; + gap: var(--sp-2); + min-inline-size: 0; + transform: rotate(-1.5deg); +} + +.brand-mark { + flex: none; + display: grid; + place-items: center; + inline-size: var(--mark); + block-size: var(--mark); + border: var(--ink-w) solid var(--line); + border-radius: var(--r-blob); + background: var(--card-2); + font-weight: 900; + overflow: hidden; + transform: rotate(-3deg); +} + +.brand-mark img { + inline-size: 100%; + block-size: 100%; + object-fit: cover; +} + +.brand-name { + min-inline-size: 0; + font-family: var(--hand); + font-size: 1.5rem; + font-weight: 700; + line-height: 1.2; + overflow-wrap: anywhere; +} + +.plan-label { + display: inline-block; + padding: 2px var(--sp-3); + border: 2px solid var(--line); + border-radius: var(--r-chip); + background: var(--card-2); + font-size: var(--fs-micro); + font-weight: 800; + transform: rotate(-1deg); +} + +/* --- the status, written large ------------------------------------------ */ + +.hero { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-4); + text-align: center; +} + +.status-word { + display: inline-block; + padding-block-end: 6px; + color: var(--green); + font-size: var(--fs-status); + font-weight: 900; + line-height: 1.1; + letter-spacing: -0.02em; + text-decoration: underline wavy 4px var(--green); + text-underline-offset: 12px; + animation: ink-in var(--dur-slow) var(--ease); +} + +.status-word .dot { display: none; } + +.status-word[data-state="limited"] { + color: var(--orange); + text-decoration-color: var(--orange); +} + +.status-word[data-state="expired"], +.status-word[data-state="disabled"] { + color: var(--red); + text-decoration-color: var(--red); +} + +.hero-note { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: var(--sp-2) var(--sp-3); + color: var(--ink-soft); + font-size: var(--fs-caption); + font-weight: 700; +} + +.live-state { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.live-state:empty { display: none; } + +.live-state .dot { + inline-size: 9px; + block-size: 9px; + border: 2px solid var(--line); + border-radius: 50%; + background: var(--card-2); +} + +.live-state[data-online="1"] .dot { background: var(--green); } + +/* --- buttons: ink outline with a hard offset shadow ----------------------- */ + +.btn, +.sk-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-block-size: var(--btn-h); + padding: var(--sp-2) var(--sp-4); + border: var(--ink-w) solid var(--line); + border-radius: var(--r-btn); + background: var(--card); + color: var(--ink); + font-size: var(--fs-caption); + font-weight: 800; + line-height: var(--lh-tight); + text-decoration: none; + box-shadow: var(--shadow); + cursor: pointer; + transition: transform var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease); +} + +.btn:active, +.sk-btn:active { + transform: translate(2px, 2px); + box-shadow: 1px 1px 0 var(--hard); +} + +.btn-primary { + background: var(--yellow); + color: var(--on-yellow); +} + +.btn-quiet { + border-color: transparent; + background: transparent; + box-shadow: none; + color: var(--ink-soft); +} + +.btn-sm { + min-block-size: var(--tap); + min-inline-size: 64px; + padding-inline: var(--sp-3); + font-size: var(--fs-micro); +} + +.btn:disabled { + opacity: 0.5; + cursor: default; +} + +.sk-btn { + min-inline-size: var(--tap); + min-block-size: var(--tap); + padding-inline: var(--sp-2); + font-size: var(--fs-micro); +} + +.sk-btn-icon { padding: 0; } + +.sk-btn[aria-expanded="true"] { color: var(--blue); border-color: var(--blue); } + +.btn-swap { + display: grid; + grid-template-areas: "label"; + place-items: center; + min-inline-size: 0; +} + +.btn-swap > span { + grid-area: label; + max-inline-size: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.btn-swap > .swap-done { visibility: hidden; } + +.btn[data-flash] .swap-live { visibility: hidden; } + +.btn[data-flash] .swap-done { visibility: visible; } + +.btn[data-flash] { + border-color: var(--green); + color: var(--green); +} + +.btn-primary[data-flash] { color: var(--on-yellow); } + +.icon-chevron { + inline-size: 0.875rem; + block-size: 0.875rem; +} + +/* --- menus ------------------------------------------------------------------ */ + +.menu-wrap { position: relative; } + +.menu { + position: absolute; + inset-block-start: calc(100% + var(--sp-2)); + inset-inline-end: 0; + z-index: 20; + min-inline-size: 180px; + padding: var(--sp-1); + background: var(--card); + border: var(--ink-w) solid var(--line); + border-radius: var(--r-note); + box-shadow: var(--shadow-lg); +} + +.menu-item { + display: flex; + align-items: center; + gap: var(--sp-2); + inline-size: 100%; + min-block-size: var(--tap); + padding-inline: var(--sp-3); + border: 0; + border-radius: var(--r-chip); + background: transparent; + font-size: var(--fs-caption); + font-weight: 700; + text-align: start; + cursor: pointer; +} + +.menu-item .icon:last-child { + margin-inline-start: auto; + opacity: 0; +} + +.menu-item[aria-checked="true"] { color: var(--blue); } + +.menu-item[aria-checked="true"] .icon:last-child { opacity: 1; } + +/* --- usage ------------------------------------------------------------------ */ + +.usage-values { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: var(--sp-1) var(--sp-3); +} + +.usage-big { + font-size: 1.5rem; + font-weight: 900; +} + +.usage-side { + color: var(--ink-soft); + font-size: var(--fs-caption); + font-weight: 700; +} + +.usage-bar:empty { display: none; } + +.bar-row { + display: flex; + align-items: center; + gap: var(--sp-3); +} + +.bar { + position: relative; + flex: 1 1 auto; + display: block; + block-size: 26px; + border: var(--ink-w) solid var(--line); + border-radius: var(--r-note); + background: var(--card-2); + overflow: hidden; + transform: rotate(-0.3deg); +} + +/* Hand-hatched ink fill. */ +.bar-fill { + display: block; + block-size: 100%; + border-radius: inherit; + background: repeating-linear-gradient(-45deg, var(--green) 0 9px, var(--green-dk) 9px 11px); + transition: inline-size var(--dur-slow) var(--ease); +} + +.bar[data-level="warn"] .bar-fill { + background: repeating-linear-gradient(-45deg, var(--yellow) 0 9px, var(--yellow-dk) 9px 11px); +} + +.bar[data-level="over"] .bar-fill { + background: repeating-linear-gradient(-45deg, var(--red) 0 9px, var(--red-dk) 9px 11px); +} + +.bar-pct { + flex: none; + font-family: var(--hand); + font-size: 1.0625rem; + font-weight: 700; +} + +/* --- expiry ----------------------------------------------------------------- */ + +.expiry-row { + display: flex; + align-items: center; + gap: var(--sp-4); +} + +.clock-doodle { + flex: none; + inline-size: 54px; + block-size: 54px; + fill: none; + stroke: var(--blue); + stroke-width: 2.4; + stroke-linecap: round; +} + +.clock-arc { opacity: 0.35; } + +.expiry-text { + display: flex; + flex-direction: column; + gap: 2px; + min-inline-size: 0; +} + +.expiry-big { + color: var(--blue); + font-size: clamp(1.25rem, 6vw, 1.625rem); + font-weight: 900; + line-height: var(--lh-tight); + transform: rotate(-0.4deg); +} + +.expiry-big[data-level="warn"] { color: var(--orange); } + +.expiry-big[data-level="over"] { color: var(--red); } + +.stamp { + align-self: flex-end; + font-family: var(--hand); + font-size: var(--fs-caption); + color: var(--ink-soft); +} + +.stamp:empty { display: none; } + +.stamp[data-stale] { color: var(--red); } + +/* --- the client area: app tiles -------------------------------------------- */ + +.tabs { + display: flex; + flex-wrap: wrap; + gap: var(--sp-2); +} + +.tab { + min-block-size: var(--tap); + padding-inline: var(--sp-3); + border: 2px solid var(--line); + border-radius: var(--r-chip); + background: var(--card-2); + font-size: var(--fs-micro); + font-weight: 800; + cursor: pointer; +} + +.tab[aria-selected="true"] { + background: var(--blue); + border-color: var(--blue); + color: var(--on-color); + transform: rotate(-1.5deg); +} + +.clients { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr)); + gap: var(--sp-3); +} + +.client { + display: flex; + flex-direction: column; + gap: var(--sp-3); + padding: var(--sp-3); + border: var(--ink-w) solid var(--line); + border-radius: 18px 12px 255px 16px / 14px 255px 15px 225px; + background: var(--card-2); + box-shadow: var(--shadow); + transform: rotate(-0.2deg); +} + +.client:nth-child(even) { + border-radius: var(--r-card-a); + transform: rotate(0.3deg); +} + +.client-head { + display: flex; + align-items: center; + gap: var(--sp-2); +} + +.client-name { + flex: 1 1 auto; + font-size: var(--fs-caption); + font-weight: 900; +} + +.client-tag { + font-family: var(--hand); + font-size: var(--fs-caption); + color: var(--green); +} + +.client-actions { + display: flex; + gap: var(--sp-2); +} + +.client-actions .btn { flex: 1 1 0; } + +/* --- configurations: sticky notes ------------------------------------------ */ + +.explorer-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); +} + +.cfg-count { + font-family: var(--hand); + font-size: 1.125rem; + font-weight: 700; + color: var(--purple); +} + +.cfg-count:empty { display: none; } + +.cfg-search { + display: flex; + align-items: center; + gap: var(--sp-2); + padding-inline: var(--sp-3); + border: var(--ink-w) solid var(--line); + border-radius: var(--r-note); + background: var(--card-2); +} + +.cfg-search input { + flex: 1 1 auto; + min-inline-size: 0; + block-size: var(--tap); + border: 0; + background: transparent; + outline: none; +} + +.cfg-list { + display: flex; + flex-direction: column; + gap: var(--sp-4); + padding-block-start: var(--sp-2); +} + +.cfg { + position: relative; + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-3); + border: var(--ink-w) solid var(--line); + border-radius: var(--r-note); + background: var(--card); + box-shadow: var(--shadow); + transform: rotate(0.2deg); +} + +.cfg:nth-child(even) { transform: rotate(-0.25deg); } + +.cfg::before { + content: ""; + position: absolute; + inset-block-start: -8px; + inset-inline-end: var(--sp-4); + inline-size: 56px; + block-size: 13px; + border-radius: 2px; + background: var(--tape-pink); + transform: rotate(-5deg); +} + +.cfg:nth-child(3n + 2)::before { background: var(--tape-blue); } +.cfg:nth-child(3n)::before { background: var(--tape-green); } + +.cfg-flag { + flex: none; + display: grid; + place-items: center; + inline-size: 38px; + block-size: 38px; + border: 2px solid var(--line); + border-radius: var(--r-chip); + background: var(--card-2); + font-size: 18px; + line-height: 1; + overflow: hidden; + transform: rotate(-2deg); +} + +.cfg-flag[data-mono] { + background: var(--blue); + color: var(--on-color); + font-family: var(--hand); + font-size: 1.0625rem; + font-weight: 700; +} + +.cfg-body { + flex: 1 1 auto; + min-inline-size: 0; + display: flex; + flex-direction: column; +} + +.cfg-name { + font-size: var(--fs-caption); + font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cfg-proto { + font-family: var(--hand); + font-size: var(--fs-caption); + color: var(--ink-soft); +} + +.cfg-actions { + flex: none; + display: flex; + gap: 6px; +} + +.cfg-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + min-block-size: var(--tap); + border: var(--ink-w) dashed var(--line); + border-radius: var(--r-btn); + background: transparent; + font-size: var(--fs-caption); + font-weight: 800; + cursor: pointer; +} + +.cfg-toggle[aria-expanded="true"] .icon { transform: rotate(180deg); } + +/* --- announcement: a yellow sticky note; support ---------------------------- */ + +.announce { + display: flex; + flex-direction: column; + gap: var(--sp-2); + padding: var(--sp-4); + border: var(--ink-w) solid var(--line); + border-radius: var(--r-note); + background: var(--mark-yellow); + box-shadow: var(--shadow); + transform: rotate(-0.6deg); +} + +.announce-head { + display: flex; + align-items: center; + gap: var(--sp-2); +} + +.section-title { + font-size: var(--fs-caption); + font-weight: 900; +} + +.announce-text { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + line-clamp: 4; + overflow: hidden; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: var(--fs-caption); + font-weight: 600; +} + +.announce[data-open="1"] .announce-text { + display: block; + -webkit-line-clamp: none; + line-clamp: none; + overflow: visible; +} + +.text-link { + align-self: flex-start; + min-block-size: var(--tap); + padding: 0; + border: 0; + background: none; + color: var(--blue); + font-family: var(--hand); + font-size: 1rem; + font-weight: 700; + text-decoration: underline wavy 2px; + text-underline-offset: 4px; + cursor: pointer; +} + +.support { + display: flex; + justify-content: center; +} + +.support-cta { min-inline-size: 220px; } + +/* --- dialogs ---------------------------------------------------------------- */ + +.dialog { + padding: 0; + border: var(--ink-w) solid var(--line); + border-radius: var(--r-card-a); + background: var(--card); + color: var(--ink); + inline-size: calc(100% - 2 * var(--sp-4)); + max-inline-size: 340px; + box-shadow: var(--shadow-lg); +} + +.dialog::backdrop { background: var(--scrim); } + +.dialog[open] { animation: ink-in var(--dur) var(--ease); } + +.dialog-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + padding: var(--sp-4) var(--sp-4) 0; +} + +.dialog-title { + font-size: var(--fs-title); + font-weight: 900; +} + +.qr-body { + display: flex; + flex-direction: column; + gap: var(--sp-3); + padding: var(--sp-4); +} + +.qr-frame { + display: grid; + place-items: center; + padding: var(--sp-3); + border: var(--ink-w) solid var(--line); + border-radius: var(--r-note); + background: var(--qr-bg); +} + +#qr-canvas, +#config-canvas { + inline-size: 100%; + max-inline-size: 220px; + block-size: auto; +} + +.url-field { + display: flex; + gap: var(--sp-2); +} + +.url-field input { + flex: 1 1 auto; + min-inline-size: 0; + min-block-size: var(--tap); + padding-inline: var(--sp-3); + border: 2px solid var(--line); + border-radius: var(--r-chip); + background: var(--card-2); + font-size: var(--fs-micro); +} + +.cfg-open { align-self: stretch; } + +.cfg-conf { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.conf-text { + inline-size: 100%; + padding: var(--sp-2) var(--sp-3); + border: 2px solid var(--line); + border-radius: var(--r-chip); + background: var(--card-2); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: var(--fs-micro); + resize: vertical; +} + +.conf-actions { + display: flex; + gap: var(--sp-2); +} + +.conf-actions .btn { flex: 1 1 0; } + +/* --- toast -------------------------------------------------------------------- */ + +.toast { + position: fixed; + inset-block-end: var(--sp-6); + inset-inline: 0; + z-index: 70; + inline-size: fit-content; + max-inline-size: calc(100% - 2 * var(--sp-4)); + margin-inline: auto; + padding: var(--sp-3) var(--sp-5); + border: var(--ink-w) solid var(--line); + border-radius: var(--r-btn); + background: var(--card); + color: var(--ink); + font-size: var(--fs-caption); + font-weight: 800; + box-shadow: 4px 4px 0 var(--hard); + opacity: 0; + transform: translateY(18px) rotate(-1deg); + transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease); +} + +.toast[data-show] { + opacity: 1; + transform: rotate(-1deg); +} + +/* --- hover, only where hover exists ------------------------------------------ */ + +@media (hover: hover) { + .btn-outline:hover, + .sk-btn:hover, + .cfg-toggle:hover { + border-color: var(--blue); + color: var(--blue); + } + + .btn-primary:hover { background: var(--yellow-dk); } + + .btn-quiet:hover, + .menu-item:hover { background: var(--card-2); } + + .tab:hover:not([aria-selected="true"]) { border-color: var(--blue); } +} diff --git a/src/templates/notebook/layout.css b/src/templates/notebook/layout.css new file mode 100644 index 0000000..075854e --- /dev/null +++ b/src/templates/notebook/layout.css @@ -0,0 +1,80 @@ +/* The page skeleton. Placement only. */ + +.page { + position: relative; + z-index: 1; + inline-size: 100%; + max-inline-size: var(--page-max); + margin-inline: auto; + padding: var(--sp-6) var(--sp-4) calc(var(--sp-7) * 2); +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + margin-block-end: var(--sp-5); +} + +.topbar-controls { + display: flex; + gap: var(--sp-2); + flex: none; +} + +#main { + display: flex; + flex-direction: column; + gap: var(--sp-6); +} + +.actions { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); + gap: var(--sp-3); +} + +/* Background doodles sit behind the page in the margins, never over text. */ +.doodles { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; +} + +.doodle { + position: absolute; + inline-size: 48px; + block-size: 48px; + fill: none; + stroke-width: 2.2; + stroke-linecap: round; + stroke-linejoin: round; + opacity: var(--doodle-opacity); +} + +.doodle-star { + inset-block-start: 72px; + inset-inline-start: 12px; + stroke: var(--yellow); +} + +.doodle-spiral { + inset-block-end: 40px; + inset-inline-start: 16px; + stroke: var(--pink); +} + +.doodle-squiggle { + inset-block-start: 38%; + inset-inline-end: -8px; + inline-size: 70px; + block-size: 40px; + stroke: var(--blue); +} + +@media (max-width: 380px) { + .page { padding-inline: var(--sp-3); } + .actions { grid-template-columns: minmax(0, 1fr); } +} diff --git a/src/templates/notebook/layout.html b/src/templates/notebook/layout.html new file mode 100644 index 0000000..e935797 --- /dev/null +++ b/src/templates/notebook/layout.html @@ -0,0 +1,207 @@ + + + + + + + + + +{{ if .subTitle }}{{ .subTitle }}{{ else }}Subscription{{ end }} + + + + + + + + + + + + + + + +
+ +
+
+ +

+
+
+ + +
+
+ +
+ +
+

{{ if .enabled }}Enabled{{ else }}Disabled{{ end }}

+

+ + {{ if .subTitle }}{{ .subTitle }}{{ end }} +

+
+ +
+ + +
+ +
+ +

Subscription status

+
+

{{ .used }}

+

{{ if eq .totalByte 0 }}used{{ else }}used of {{ .total }}{{ end }}

+
+
+

{{ if .remained }}{{ .remained }} remaining{{ end }}

+
+ +
+ +
+ +
+

{{ if eq .expire 0 }}Never expires{{ else if lt .expire 0 }}Starts on first connection{{ else }}—{{ end }}

+

+
+
+ +
+ +
+ +

Connect

+
+
+

+
+ +
+ +
+

Configurations

+ +
+

+ +
+ + +
+ + + {{ if .subSupportUrl }}
Contact support
{{ end }}
+ +
+
+ +
+ + +
+

QR code

+ +
+
+
+

Scan with your client application

+
+ + +
+
+
+ + + + +
+

+ +
+
+
+

+
+ + +
+ + +
+
+ + + + + diff --git a/src/templates/notebook/rtl.css b/src/templates/notebook/rtl.css new file mode 100644 index 0000000..e407af1 --- /dev/null +++ b/src/templates/notebook/rtl.css @@ -0,0 +1,17 @@ +/* Direction-specific rules. Every offset is a logical property, so dir on + does the layout; what remains are the glyphs with a direction of + their own, the hand tilt (mirrored so the page leans the same way to a + right-to-left reader), and Latin tracking, which breaks Arabic joins. */ + +[dir="rtl"] .icon-chevron, +[dir="rtl"] .icon-external { + transform: scaleX(-1); +} + +[dir="rtl"] .brand { transform: rotate(1.5deg); } + +[lang="fa"] .status-word, +[lang="ar"] .status-word { + letter-spacing: 0; + line-height: 1.35; +} diff --git a/src/templates/notebook/tokens.css b/src/templates/notebook/tokens.css new file mode 100644 index 0000000..1e2ddfc --- /dev/null +++ b/src/templates/notebook/tokens.css @@ -0,0 +1,161 @@ +/* Design tokens. Every value a theme can change lives here and nowhere else; + the rest of the stylesheet only ever reads custom properties. + + Notebook is the hand-made page of the catalogue: dotted paper, ink-drawn + cards held on with washi tape, a status written large and underlined by + hand. It is a port of a design contributed by the project's author + ("Sketch"), rebuilt on the shared runtime. The handwriting accent uses the + system's own script faces: nothing is fetched. */ + +:root { + --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", + Arial, sans-serif; + --font-arabic: Vazirmatn, "Segoe UI", Tahoma, "Noto Naskh Arabic", + "Geeza Pro", sans-serif; + --hand: "Segoe Print", "Bradley Hand", "Chalkboard SE", "Marker Felt", + "Comic Sans MS", cursive; + + --fs-status: clamp(2.75rem, 14vw, 4.5rem); + --fs-title: 1rem; + --fs-body: 0.9375rem; + --fs-caption: 0.8125rem; + --fs-micro: 0.75rem; + + --lh-tight: 1.35; + --lh-body: 1.6; + --measure: 60ch; + + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 20px; + --sp-6: 24px; + --sp-7: 32px; + + /* Ink lines are never quite straight: every shape has an uneven radius. */ + --ink-w: 2.5px; + --r-card-a: 255px 15px 225px 15px / 15px 225px 15px 255px; + --r-card-b: 15px 255px 15px 225px / 225px 15px 255px 15px; + --r-btn: 20px 10px 18px 12px / 12px 18px 10px 20px; + --r-chip: 14px 8px 12px 10px / 10px 12px 8px 14px; + --r-note: 12px 20px 14px 18px / 18px 12px 20px 14px; + --r-blob: 50% 42% 48% 40% / 46% 44% 50% 42%; + + --dur-fast: 120ms; + --dur: 180ms; + --dur-slow: 500ms; + --ease: ease; + + --page-max: 540px; + --tap: 44px; + --mark: 40px; + --btn-h: 44px; + --dots: 22px; +} + +[data-theme="light"] { + color-scheme: light; + + --paper: #F6F1E5; + --paper-dot: rgb(70 62 46 / 0.12); + --card: #FFFDF6; + --card-2: #FBF5E8; + --ink: #292724; + --ink-soft: #625B4E; + --line: #292724; + + --red: #C8432B; + --blue: #3470C4; + --yellow: #EAB93A; + --green: #3B8551; + --purple: #7F52B0; + --pink: #C85C8A; + --orange: #C9742A; + --green-dk: #2F6B42; + --yellow-dk: #C99A24; + --red-dk: #A8361F; + + --tape-pink: rgb(221 111 156 / 0.55); + --tape-blue: rgb(63 127 214 / 0.5); + --tape-green: rgb(70 148 92 / 0.5); + --tape-yellow: rgb(234 185 58 / 0.6); + --tape-purple: rgb(143 95 192 / 0.5); + --mark-green: rgb(70 148 92 / 0.25); + --mark-yellow: rgb(234 185 58 / 0.7); + --mark-purple: rgb(143 95 192 / 0.22); + + --hard: rgb(41 39 36 / 0.14); + --shadow: 3px 3px 0 rgb(41 39 36 / 0.16); + --shadow-lg: 6px 6px 0 rgb(0 0 0 / 0.2); + --on-color: #FFFFFF; + --on-yellow: #292724; + --qr-bg: #FFFFFF; + --scrim: rgb(41 39 36 / 0.55); + --focus: #3470C4; + --doodle-opacity: 0.5; +} + +[data-theme="dark"] { + color-scheme: dark; + + --paper: #1F1C17; + --paper-dot: rgb(240 232 212 / 0.08); + --card: #2A261F; + --card-2: #322D24; + --ink: #F0E8D4; + --ink-soft: #B3AA96; + --line: #F0E8D4; + + --red: #FF7A5C; + --blue: #6FA8FF; + --yellow: #FFD75E; + --green: #82D69C; + --purple: #C99AFF; + --pink: #FF9CC4; + --orange: #FFAB5C; + --green-dk: #5FBF7E; + --yellow-dk: #F0C23F; + --red-dk: #FF5C3A; + + --tape-pink: rgb(255 156 196 / 0.45); + --tape-blue: rgb(111 168 255 / 0.42); + --tape-green: rgb(130 214 156 / 0.4); + --tape-yellow: rgb(255 215 94 / 0.5); + --tape-purple: rgb(201 154 255 / 0.42); + --mark-green: rgb(130 214 156 / 0.25); + --mark-yellow: rgb(255 215 94 / 0.4); + --mark-purple: rgb(201 154 255 / 0.25); + + --hard: rgb(240 232 212 / 0.12); + --shadow: 3px 3px 0 rgb(0 0 0 / 0.35); + --shadow-lg: 6px 6px 0 rgb(0 0 0 / 0.45); + --on-color: #1F1C17; + --on-yellow: #1F1C17; + --qr-bg: #FFFFFF; + --scrim: rgb(0 0 0 / 0.6); + --focus: #6FA8FF; + --doodle-opacity: 0.35; +} + +/* The Arabic-script face is opted into by language, and its unicode-range + keeps it off Latin, Cyrillic and CJK text even here. A Latin script face + has no Arabic glyphs, so the handwriting accent falls back to the body. */ +[lang="fa"], +[lang="ar"] { + --font: var(--font-arabic); + --hand: var(--font-arabic); + --lh-body: 1.85; +} + +/* row:font-face */ +@font-face { + font-family: Vazirmatn; + src: url("data:font/woff2;base64,__FONT_BASE64__") format("woff2"); + font-weight: 400 700; + font-style: normal; + font-display: swap; + unicode-range: U+0600-06FF, U+200C-200F, U+2066-2069, U+FB50-FDFF, + U+FE70-FEFF; +} +/* row:font-face end */ diff --git a/tests/adapters-pasarguard.test.mjs b/tests/adapters-pasarguard.test.mjs index a95b3e9..26a1f71 100644 --- a/tests/adapters-pasarguard.test.mjs +++ b/tests/adapters-pasarguard.test.mjs @@ -268,7 +268,7 @@ test('the adapter is deterministic and does not mutate its input', () => { /* --- nothing was rebuilt ------------------------------------------------ */ -test('the 15 artifacts are byte-identical to their committed locks', async () => { +test('the 17 artifacts are byte-identical to their committed locks', async () => { const { build } = await import('../tools/build.mjs'); const { templateIds } = await import('../tools/templates.mjs'); const source = readFileSync(join(ROOT, 'tests', 'build.test.mjs'), 'utf8'); @@ -278,7 +278,7 @@ test('the 15 artifacts are byte-identical to their committed locks', async () => const id = m[2] === 'Row' ? 'row' : m[2] === 'Pulse Nova' ? 'pulsenova' : m[2].toLowerCase(); locked[id] = +m[1]; } - assert.equal(Object.keys(locked).length, 15); + assert.equal(Object.keys(locked).length, 17); for (const id of templateIds()) { assert.equal(Buffer.byteLength(build(true, id).html, 'utf8'), locked[id], id + ' must not move'); } diff --git a/tests/adapters-rebecca.test.mjs b/tests/adapters-rebecca.test.mjs index 9b9d64d..172f994 100644 --- a/tests/adapters-rebecca.test.mjs +++ b/tests/adapters-rebecca.test.mjs @@ -334,7 +334,7 @@ test('the other two panels are untouched by the activation', () => { /* --- nothing was rebuilt ------------------------------------------------- */ -test('the 15 artifacts are byte-identical to their committed locks', async () => { +test('the 17 artifacts are byte-identical to their committed locks', async () => { const { build } = await import('../tools/build.mjs'); const { templateIds } = await import('../tools/templates.mjs'); const source = readFileSync(join(ROOT, 'tests', 'build.test.mjs'), 'utf8'); @@ -344,7 +344,7 @@ test('the 15 artifacts are byte-identical to their committed locks', async () => const id = m[2] === 'Row' ? 'row' : m[2] === 'Pulse Nova' ? 'pulsenova' : m[2].toLowerCase(); locked[id] = +m[1]; } - assert.equal(Object.keys(locked).length, 15); + assert.equal(Object.keys(locked).length, 17); for (const id of templateIds()) { assert.equal(Buffer.byteLength(build(true, id).html, 'utf8'), locked[id], id + ' must not move'); } diff --git a/tests/adapters.test.mjs b/tests/adapters.test.mjs index c6d1e44..b355ac2 100644 --- a/tests/adapters.test.mjs +++ b/tests/adapters.test.mjs @@ -180,7 +180,7 @@ test('the adapter never carries the subscriber address', () => { /* --- nothing was rebuilt ------------------------------------------------ */ -test('the 15 artifacts are byte-identical to their committed locks', () => { +test('the 17 artifacts are byte-identical to their committed locks', () => { const source = readFileSync(join(ROOT, 'tests', 'build.test.mjs'), 'utf8'); const locked = {}; for (const m of source.matchAll(/^\s*\['([a-z]+)', (\d+), '([0-9a-f]{64})'\],/gm)) locked[m[1]] = +m[2]; @@ -188,7 +188,7 @@ test('the 15 artifacts are byte-identical to their committed locks', () => { const id = m[2] === 'Row' ? 'row' : m[2] === 'Pulse Nova' ? 'pulsenova' : m[2].toLowerCase(); locked[id] = +m[1]; } - assert.equal(Object.keys(locked).length, 15); + assert.equal(Object.keys(locked).length, 17); for (const id of templateIds()) { assert.equal(Buffer.byteLength(ARTIFACTS[id], 'utf8'), locked[id], id + ' must not move'); } diff --git a/tests/build.test.mjs b/tests/build.test.mjs index 7d310b1..3096a00 100644 --- a/tests/build.test.mjs +++ b/tests/build.test.mjs @@ -986,6 +986,8 @@ const FROZEN_ARTIFACTS = [ ['prismnova', 203226, 'c6eb485fdcf3fb66c5c705eb2f897b176c5fbc0c684b3ff624289714421708cd'], ['terminalnova', 203203, 'afd6ed22a69450915f87fb7dcdfa7b2f8ee47da65f3896ae57ad73d2e00b587a'], ['arcadenova', 203569, 'adb9b1088d8b3d492536cc883f53b806da54a5d2a2d749934fadf611964f450e'], + ['meter', 202571, '194ed2c361529fb0c399c36b724fe0b3053d6896e68d303b4a38429f4a872122'], + ['notebook', 203764, '5dd18cb6d7708e1b3ed50ab3186b84146c7a3b706567f2c6c45cfcb29034bda2'], ]; for (const [id, bytes, sha] of FROZEN_ARTIFACTS) { @@ -1002,14 +1004,88 @@ for (const [id, bytes, sha] of FROZEN_ARTIFACTS) { }); } +/* Meter and Notebook (1.3.0) are the two designs the project's author + contributed, ported onto the shared runtime. Each is held to exactly the + contract of the other fifteen: deterministic, whole, inside the refusal + point, every hook exactly once, and the runtime and locale island shared + byte for byte -- plus the reading order its design fixes, and no CSS + `order`, so the DOM order is the order a screen reader and a phone see. */ +for (const [id, sequence] of [ + ['meter', ['brand-mark', 'state-pill', 'status-heading', 'traffic-trailing', 'traffic-value', 'bar-slot', + 'expiry-value', 'copy-btn', 'qr-btn', 'connect', 'explorer', 'announce-slot', 'support-slot']], + ['notebook', ['brand-mark', 'state-pill', 'live-state', 'copy-btn', 'qr-btn', 'status-heading', 'traffic-value', + 'bar-slot', 'expiry-value', 'connect', 'explorer', 'announce-slot', 'support-slot']], +]) { + const art = build(true, id); + + test(`the ${id} build is deterministic, whole and inside its budget`, () => { + const html = art.html; + const size = Buffer.byteLength(html, 'utf8'); + assert.equal(build(true, id).html, html, 'same sources must produce the same bytes'); + assert.ok(html.startsWith('')); + assert.ok(html.trimEnd().endsWith('')); + assert.equal(html.match(/\/\*__[A-Z][A-Z0-9_]*__\*\//), null); + assert.equal((html.match(/')); + assert.equal(/(^|[;{\s])order\s*:/.test(style), false, 'no CSS order: DOM order is the reading order'); + assert.equal(/url\((?!"data:)/.test(style), false, 'no stylesheet reaches the network'); + assert.equal(/@import/.test(style), false, 'no stylesheet imports another'); + + let previous = -1; + for (const hook of sequence) { + const at = art.html.indexOf(`id="${hook}"`); + assert.ok(at > previous, `#${hook} must follow the region before it in source order`); + previous = at; + } + const mainEnd = art.html.indexOf(''); + for (const hook of ['qr-dialog', 'config-dialog', 'toast']) { + assert.ok(art.html.indexOf(`id="${hook}"`) > mainEnd, `#${hook} must stay outside
`); + } + }); + + test(`the ${id} sources are LF, carry both themes, and keep every literal colour in tokens.css`, () => { + const dir = join(ROOT, 'src', 'templates', id); + for (const f of ['layout.html', 'tokens.css', 'base.css', 'layout.css', 'components.css', 'rtl.css']) { + assert.equal(readFileSync(join(dir, f)).includes(13), false, `${f} must be LF`); + } + const tokens = readFileSync(join(dir, 'tokens.css'), 'utf8'); + assert.match(tokens, /\[data-theme="dark"\]/); + assert.match(tokens, /\[data-theme="light"\]/); + assert.match(tokens, /\/\* row:font-face \*\/[\s\S]*__FONT_BASE64__[\s\S]*\/\* row:font-face end \*\//); + assert.match(tokens, /\[lang="fa"\],\s*\[lang="ar"\]/); + for (const f of ['base.css', 'layout.css', 'components.css', 'rtl.css']) { + const css = readFileSync(join(dir, f), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); + assert.equal(/#[0-9a-fA-F]{3,8}\b(?![^(]*\))/.test(css.replace(/repeating-linear-gradient\([^;]*\)/g, '')), false, + `${f} must read colours from tokens.css`); + } + }); +} + /* --- the frozen set and the template tier --------------------------------- - The frozen set is core-only. The FROZEN_ARTIFACTS table holds eleven of the - fifteen; the other four hold individual locks above. These assertions pin both + The frozen set is core-only. The FROZEN_ARTIFACTS table holds thirteen of the + seventeen; the other four hold individual locks above. These assertions pin both the membership and the size, so a future custom template can never enter the frozen set by accident. */ const INDIVIDUALLY_LOCKED = ['row', 'editorial', 'canvas', 'pulsenova']; -test('the frozen set is exactly the fifteen core templates, and a custom template can never enter it', () => { +test('the frozen set is exactly the seventeen core templates, and a custom template can never enter it', () => { const tableIds = FROZEN_ARTIFACTS.map(([id]) => id); const frozen = [...tableIds, ...INDIVIDUALLY_LOCKED]; @@ -1026,8 +1102,8 @@ test('the frozen set is exactly the fifteen core templates, and a custom templat assert.equal(TEMPLATES[id].locked, true, `${id} is in the frozen set so it must be locked`); } - assert.equal(coreTemplateIds().length, 15, 'exactly fifteen core templates ship in this release'); - assert.equal(lockedTemplateIds().length, 15, 'every core template is locked'); + assert.equal(coreTemplateIds().length, 17, 'exactly seventeen core templates ship in this release'); + assert.equal(lockedTemplateIds().length, 17, 'every core template is locked'); /* Structural exclusion: the table is a literal array and the build reads only `styles` and `emitDataTemplate`, so no registry entry can add itself to the diff --git a/tests/contract.test.mjs b/tests/contract.test.mjs index 5042796..cf96fdb 100644 --- a/tests/contract.test.mjs +++ b/tests/contract.test.mjs @@ -247,7 +247,7 @@ test('validateModel rejects missing, unknown, and wrong-typed fields', () => { /* --- nothing was rebuilt ------------------------------------------------- */ -test('the 15 artifacts are byte-identical to their committed locks', () => { +test('the 17 artifacts are byte-identical to their committed locks', () => { const source = readFileSync(join(ROOT, 'tests', 'build.test.mjs'), 'utf8'); const locked = {}; for (const m of source.matchAll(/^\s*\['([a-z]+)', (\d+), '([0-9a-f]{64})'\],/gm)) locked[m[1]] = +m[2]; @@ -255,7 +255,7 @@ test('the 15 artifacts are byte-identical to their committed locks', () => { const id = m[2] === 'Row' ? 'row' : m[2] === 'Pulse Nova' ? 'pulsenova' : m[2].toLowerCase(); locked[id] = +m[1]; } - assert.equal(Object.keys(locked).length, 15); + assert.equal(Object.keys(locked).length, 17); for (const id of templateIds()) { assert.equal(Buffer.byteLength(ARTIFACTS[id], 'utf8'), locked[id], id + ' must not move'); } diff --git a/tests/panels-fixtures-rebecca.test.mjs b/tests/panels-fixtures-rebecca.test.mjs index aa4e00e..1316406 100644 --- a/tests/panels-fixtures-rebecca.test.mjs +++ b/tests/panels-fixtures-rebecca.test.mjs @@ -343,7 +343,7 @@ test('T5: the unknown status is marked for rejection, not coerced', () => { /* --- nothing was rebuilt ------------------------------------------------- */ -test('the 15 artifacts are byte-identical to their committed locks', async () => { +test('the 17 artifacts are byte-identical to their committed locks', async () => { const { build } = await import('../tools/build.mjs'); const { templateIds } = await import('../tools/templates.mjs'); const source = readFileSync(join(ROOT, 'tests', 'build.test.mjs'), 'utf8'); @@ -353,7 +353,7 @@ test('the 15 artifacts are byte-identical to their committed locks', async () => const id = m[2] === 'Row' ? 'row' : m[2] === 'Pulse Nova' ? 'pulsenova' : m[2].toLowerCase(); locked[id] = +m[1]; } - assert.equal(Object.keys(locked).length, 15); + assert.equal(Object.keys(locked).length, 17); for (const id of templateIds()) { assert.equal(Buffer.byteLength(build(true, id).html, 'utf8'), locked[id], id + ' must not move'); } diff --git a/tests/panels-fixtures.test.mjs b/tests/panels-fixtures.test.mjs index 637087c..cb02823 100644 --- a/tests/panels-fixtures.test.mjs +++ b/tests/panels-fixtures.test.mjs @@ -257,7 +257,7 @@ test('no fixture defers its expectation any longer', () => { /* --- nothing was rebuilt ------------------------------------------------- */ -test('the 15 artifacts are byte-identical to their committed locks', async () => { +test('the 17 artifacts are byte-identical to their committed locks', async () => { const { build } = await import('../tools/build.mjs'); const { templateIds } = await import('../tools/templates.mjs'); const source = readFileSync(join(ROOT, 'tests', 'build.test.mjs'), 'utf8'); @@ -267,7 +267,7 @@ test('the 15 artifacts are byte-identical to their committed locks', async () => const id = m[2] === 'Row' ? 'row' : m[2] === 'Pulse Nova' ? 'pulsenova' : m[2].toLowerCase(); locked[id] = +m[1]; } - assert.equal(Object.keys(locked).length, 15); + assert.equal(Object.keys(locked).length, 17); for (const id of templateIds()) { assert.equal(Buffer.byteLength(build(true, id).html, 'utf8'), locked[id], id + ' must not move'); } diff --git a/tests/panels-pasarguard-shell.test.mjs b/tests/panels-pasarguard-shell.test.mjs index eab7905..8da7a4d 100644 --- a/tests/panels-pasarguard-shell.test.mjs +++ b/tests/panels-pasarguard-shell.test.mjs @@ -291,7 +291,7 @@ test('on_hold renders enabled, with the hold duration as a pending expiry', () = /* --- the 3X-UI artifacts are untouched ----------------------------------- */ -test('the 15 frozen 3X-UI artifacts are byte-identical to their locks', async () => { +test('the 17 frozen 3X-UI artifacts are byte-identical to their locks', async () => { const { build } = await import('../tools/build.mjs'); const source = readFileSync(join(ROOT, 'tests', 'build.test.mjs'), 'utf8'); const locked = {}; @@ -300,7 +300,7 @@ test('the 15 frozen 3X-UI artifacts are byte-identical to their locks', async () const id = m[2] === 'Row' ? 'row' : m[2] === 'Pulse Nova' ? 'pulsenova' : m[2].toLowerCase(); locked[id] = +m[1]; } - assert.equal(Object.keys(locked).length, 15); + assert.equal(Object.keys(locked).length, 17); for (const id of templateIds()) { assert.equal(Buffer.byteLength(build(true, id).html, 'utf8'), locked[id], id + ' must not move'); } diff --git a/tests/panels-rebecca-shell.test.mjs b/tests/panels-rebecca-shell.test.mjs index 6d78599..fb84e09 100644 --- a/tests/panels-rebecca-shell.test.mjs +++ b/tests/panels-rebecca-shell.test.mjs @@ -309,7 +309,7 @@ test('build:panels writes the assembled shells to dist/shells/', async () => { /* --- the 3X-UI artifacts are untouched ----------------------------------- */ -test('the 15 frozen 3X-UI artifacts are byte-identical to their locks', async () => { +test('the 17 frozen 3X-UI artifacts are byte-identical to their locks', async () => { const { build } = await import('../tools/build.mjs'); const source = readFileSync(join(ROOT, 'tests', 'build.test.mjs'), 'utf8'); const locked = {}; @@ -318,7 +318,7 @@ test('the 15 frozen 3X-UI artifacts are byte-identical to their locks', async () const id = m[2] === 'Row' ? 'row' : m[2] === 'Pulse Nova' ? 'pulsenova' : m[2].toLowerCase(); locked[id] = +m[1]; } - assert.equal(Object.keys(locked).length, 15); + assert.equal(Object.keys(locked).length, 17); for (const id of templateIds()) { assert.equal(Buffer.byteLength(build(true, id).html, 'utf8'), locked[id], id + ' must not move'); } diff --git a/tests/panels.test.mjs b/tests/panels.test.mjs index 67ea358..f329644 100644 --- a/tests/panels.test.mjs +++ b/tests/panels.test.mjs @@ -92,7 +92,7 @@ test('transpiling is deterministic', () => { /* --- G2 — the frozen catalogue has not moved ---------------------------- */ -test('G2: all 15 artifacts are byte-identical to their committed locks', () => { +test('G2: all 17 artifacts are byte-identical to their committed locks', () => { const source = readFileSync(join(ROOT, 'tests', 'build.test.mjs'), 'utf8'); const locked = {}; for (const m of source.matchAll(/^\s*\['([a-z]+)', (\d+), '([0-9a-f]{64})'\],/gm)) { @@ -103,7 +103,7 @@ test('G2: all 15 artifacts are byte-identical to their committed locks', () => { locked[id] = +m[1]; } - assert.equal(Object.keys(locked).length, 15, 'all 15 templates must carry a byte lock'); + assert.equal(Object.keys(locked).length, 17, 'all 17 templates must carry a byte lock'); for (const id of templateIds()) { const bytes = Buffer.byteLength(build(true, id).html, 'utf8'); assert.equal(bytes, locked[id], id + ' must not move'); @@ -113,7 +113,7 @@ test('G2: all 15 artifacts are byte-identical to their committed locks', () => { test('G2: no layout source has been modified by this phase', () => { /* The layouts are read, never written. If one ever changes, this phase has overstepped — the count and the shared lines are the cheap tripwire. */ - assert.equal(Object.keys(LAYOUTS).length, 15); + assert.equal(Object.keys(LAYOUTS).length, 17); for (const id of templateIds()) { assert.ok(LAYOUTS[id].startsWith(''), id + ' must be a whole document'); assert.ok(LAYOUTS[id].includes('id="sub-data"'), id + ' must carry the island hook'); diff --git a/tests/preview-determinism.test.mjs b/tests/preview-determinism.test.mjs index 0191058..e736bd0 100644 --- a/tests/preview-determinism.test.mjs +++ b/tests/preview-determinism.test.mjs @@ -8,7 +8,7 @@ * renders a different date on a different day; * 2. the page derives its expiry caption from the *reader's* clock, so even a * fixture pinned to a fixed instant renders one day less on a later day; - * 3. the preview server serves fourteen of the fifteen designs out of + * 3. the preview server serves sixteen of the seventeen designs out of * dist/templates/**, which `npm run build` does not write, so a capture * could photograph stale artifacts and still report success. * @@ -232,9 +232,9 @@ test('the pinned instant reproduces the baseline caption as well as the date', ( /* --- the freshness invariant, executed ----------------------------------- */ -test('an all-template build produces fifteen fresh artifacts', () => { +test('an all-template build produces seventeen fresh artifacts', () => { const ids = read('tools', 'templates.mjs').match(/^\s{2}(\w+):\s*\{/gm) || []; - assert.ok(ids.length >= 15, 'expected at least 15 templates in the registry'); + assert.ok(ids.length >= 17, 'expected at least 17 templates in the registry'); const r = spawnSync('node', [join('tools', 'build.mjs'), '--all', '--quiet'], { cwd: ROOT, encoding: 'utf8', @@ -260,7 +260,7 @@ test('an all-template build produces fifteen fresh artifacts', () => { .map((e) => join(ROOT, 'dist', 'templates', e.name, 'template.html')), ]; - assert.equal(served.length, 15, `the preview server should see 15 artifacts, saw ${served.length}`); + assert.equal(served.length, 17, `the preview server should see 17 artifacts, saw ${served.length}`); for (const p of served) { assert.ok(existsSync(p), `${p} should exist after an --all build`); assert.ok(statSync(p).mtimeMs >= srcNewest, @@ -313,7 +313,7 @@ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex'); const entryName = (e) => `${e.id}-${e.mode}.webp`; /* The registry is the source of truth for which ids exist; the manifest has to - cover exactly it, not merely a plausible-looking 30 files. */ + cover exactly it, not merely a plausible-looking 34 files. */ const { templateIds } = await import(pathToFileURL(join(ROOT, 'tools', 'templates.mjs')).href); const REGISTRY = templateIds(); @@ -325,11 +325,11 @@ const ON_DISK = new Map( ); test('the preview registry and the manifest agree on size', () => { - assert.equal(REGISTRY.length, 15, - `the registry should list exactly 15 templates, listed ${REGISTRY.length}`); - assert.equal(MANIFEST.total, 30, `manifest.total should be 30, is ${MANIFEST.total}`); - assert.equal(MANIFEST.entries.length, 30, - `the manifest should carry 30 entries, carries ${MANIFEST.entries.length}`); + assert.equal(REGISTRY.length, 17, + `the registry should list exactly 17 templates, listed ${REGISTRY.length}`); + assert.equal(MANIFEST.total, 34, `manifest.total should be 34, is ${MANIFEST.total}`); + assert.equal(MANIFEST.entries.length, 34, + `the manifest should carry 34 entries, carries ${MANIFEST.entries.length}`); /* Exactly desktop + mobile for every id, and no pair described twice. */ const pairs = MANIFEST.entries.map(entryName); @@ -353,7 +353,7 @@ test('every manifest entry has a file, and no preview is orphaned', () => { assert.deepEqual([...ON_DISK.keys()].filter((f) => !claimed.includes(f)), [], 'preview files exist that the manifest does not describe'); - assert.equal(ON_DISK.size, 30, `expected 30 preview files on disk, found ${ON_DISK.size}`); + assert.equal(ON_DISK.size, 34, `expected 34 preview files on disk, found ${ON_DISK.size}`); }); test('every manifest entry matches its file byte for byte', () => { @@ -385,10 +385,10 @@ test('the manifest totals are the real totals', () => { const unique = new Set(MANIFEST.entries.map((e) => e.sha256)).size; assert.equal(MANIFEST.uniqueHashes, unique, `uniqueHashes ${MANIFEST.uniqueHashes} is not the real count, ${unique}`); - assert.equal(unique, 30, `30 previews should be 30 distinct images, found ${unique}`); + assert.equal(unique, 34, `34 previews should be 34 distinct images, found ${unique}`); /* And distinct on disk, not just distinct as the manifest repeats them: a duplicated capture would otherwise still satisfy every count above. */ - assert.equal(new Set(ON_DISK.values()).size, 30, - 'the preview files on disk are not 30 distinct images'); + assert.equal(new Set(ON_DISK.values()).size, 34, + 'the preview files on disk are not 34 distinct images'); }); diff --git a/tests/registry.test.mjs b/tests/registry.test.mjs index 8ce682e..6d96c14 100644 --- a/tests/registry.test.mjs +++ b/tests/registry.test.mjs @@ -170,10 +170,10 @@ test('every template carries a tier and a lock, and the defaults are core + lock assert.equal(typeof tpl.locked, 'boolean', `${id} locked must be a boolean`); } - /* The fifteen shipped designs are core and locked. None of them declares the + /* The seventeen shipped designs are core and locked. None of them declares the fields in the registry literal, so this also proves the defaults apply. */ const core = coreTemplateIds(); - assert.equal(core.length, 15, 'exactly fifteen core templates ship in this release'); + assert.equal(core.length, 17, 'exactly seventeen core templates ship in this release'); for (const id of core) { assert.equal(TEMPLATES[id].tier, 'core', `${id} is core`); assert.equal(TEMPLATES[id].locked, true, `${id} is locked`); @@ -181,11 +181,11 @@ test('every template carries a tier and a lock, and the defaults are core + lock assert.deepEqual(lockedTemplateIds(), core, 'every core template is locked'); }); -test('core templates keep order 1..15 and a custom template must use order >= 200', () => { +test('core templates keep order 1..17 and a custom template must use order >= 200', () => { const coreOrders = coreTemplateIds() .map((id) => TEMPLATES[id].order) .sort((a, b) => a - b); - assert.deepEqual(coreOrders, Array.from({ length: 15 }, (_, i) => i + 1)); + assert.deepEqual(coreOrders, Array.from({ length: 17 }, (_, i) => i + 1)); /* The rule a future custom entry must satisfy. Enforced here rather than at import time so a misconfiguration fails a test instead of breaking the @@ -201,7 +201,7 @@ test('core templates keep order 1..15 and a custom template must use order >= 20 }); test('applyTierDefaults keeps the current behaviour and defaults a custom entry to unlocked', () => { - /* An entry that declares nothing is core and locked - exactly what all fifteen + /* An entry that declares nothing is core and locked - exactly what all seventeen current entries rely on. */ assert.deepEqual(applyTierDefaults({}), { tier: 'core', locked: true }); diff --git a/tools/fixtures-all.mjs b/tools/fixtures-all.mjs index 88b8dd3..c1e58c4 100644 --- a/tools/fixtures-all.mjs +++ b/tools/fixtures-all.mjs @@ -10,7 +10,7 @@ * Each template is built in memory by the same build(true, id) the tests * compare against, then rendered and checked by the Go fixture tool. The tool is * compiled once and run per template, rather than `go run` per template, which - * would recompile it fifteen times. Pages are always regenerated, never reused: + * would recompile it seventeen times. Pages are always regenerated, never reused: * a stale page would let the suite pass against sources that no longer exist. * * `npm run fixtures` is unchanged: it still renders the committed Row artifact diff --git a/tools/templates.mjs b/tools/templates.mjs index 6e5d13c..8779c84 100644 --- a/tools/templates.mjs +++ b/tools/templates.mjs @@ -250,6 +250,39 @@ export const TEMPLATES = { ['src/templates/arcadenova/rtl.css', 'templates/arcadenova/rtl.css'], ], }, + /* 1.3.0: two designs contributed by the project's author, ported onto the + shared runtime. They began as standalone pages named Pulse and Sketch; + those ids were taken, so each is named for its defining structure. */ + meter: { + id: 'meter', + name: 'Meter', + order: 16, + available: true, + emitDataTemplate: true, + layout: true, + styles: [ + ['src/templates/meter/tokens.css', 'templates/meter/tokens.css'], + ['src/templates/meter/base.css', 'templates/meter/base.css'], + ['src/templates/meter/layout.css', 'templates/meter/layout.css'], + ['src/templates/meter/components.css', 'templates/meter/components.css'], + ['src/templates/meter/rtl.css', 'templates/meter/rtl.css'], + ], + }, + notebook: { + id: 'notebook', + name: 'Notebook', + order: 17, + available: true, + emitDataTemplate: true, + layout: true, + styles: [ + ['src/templates/notebook/tokens.css', 'templates/notebook/tokens.css'], + ['src/templates/notebook/base.css', 'templates/notebook/base.css'], + ['src/templates/notebook/layout.css', 'templates/notebook/layout.css'], + ['src/templates/notebook/components.css', 'templates/notebook/components.css'], + ['src/templates/notebook/rtl.css', 'templates/notebook/rtl.css'], + ], + }, }; /* Tier defaults. @@ -259,7 +292,7 @@ export const TEMPLATES = { * difference between the tiers is the lock, not the build: both are held to the * same hook and runtime contract. * - * The fields are applied here rather than written into all fifteen entries, so + * The fields are applied here rather than written into all seventeen entries, so * adding a core template stays a one-line change and a custom template only has * to declare `tier: 'custom'`. An entry may override `locked` explicitly, which * is how a core design still in development would opt out of its lock. From 21f0f7eb1e911acaa53cffb5ec8777939052525f Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Fri, 25 Sep 2026 23:42:38 +0330 Subject: [PATCH 09/25] feat(pasarguard): report the database settings that outrank the page; audit docs PasarGuard serves an admin's own `sub_template` to that admin's users, and serves no page at all when `disable_sub_template` is on (app/operation/subscription.py). Row-Template never changes either, but static verify now reads them -- read-only, SQLite only, the URL never printed -- and warns, so a page that "does not show" is explained. Rebecca's suite gains the refusals PasarGuard's already proved: a foreign page, a bad restore record, a tampered page and a moved selection, secrets anywhere under the install root, 3X-UI/PasarGuard/pre-1.3.0 artifacts, and a backup from another panel. PASARGUARD-INSTALLER-AUDIT.md and REBECCA-INSTALLER-AUDIT.md record what each panel does, from source, and how the adapter follows it; the backup design documents the optional `aux` record. Co-Authored-By: Claude Opus 5.5 --- docs/design/INSTALLER-BACKUP-DESIGN.md | 12 ++ docs/design/PASARGUARD-INSTALLER-AUDIT.md | 193 ++++++++++++++++++++++ docs/design/README.md | 3 + docs/design/REBECCA-INSTALLER-AUDIT.md | 153 +++++++++++++++++ installer/panels/pasarguard.sh | 54 ++++++ tests/helpers/panel-hosts.mjs | 31 +++- tests/installer-panel-pasarguard.test.mjs | 31 ++++ tests/installer-panel-rebecca.test.mjs | 99 ++++++++++- 8 files changed, 571 insertions(+), 5 deletions(-) create mode 100644 docs/design/PASARGUARD-INSTALLER-AUDIT.md create mode 100644 docs/design/REBECCA-INSTALLER-AUDIT.md diff --git a/docs/design/INSTALLER-BACKUP-DESIGN.md b/docs/design/INSTALLER-BACKUP-DESIGN.md index 8631f12..3841edb 100644 --- a/docs/design/INSTALLER-BACKUP-DESIGN.md +++ b/docs/design/INSTALLER-BACKUP-DESIGN.md @@ -128,6 +128,18 @@ backups/20260921T081500Z__1.1.0/ ... ``` +> **1.3.0 amendment — the optional `aux` record.** A panel directory may also hold +> `aux`: `key=value` lines, the value base64-encoded, keys `[a-z_]{1,32}` and unique, +> sorted (`rt_backup_panel_aux_set` / `rt_backup_panel_aux` / `rt_backup_panel_aux_check` +> in `installer/lib/row-template.sh`). It carries the few facts a restore needs that +> `selection` cannot hold — PasarGuard's `block`, `dir`, `root`, `root_created`; +> Rebecca's `dir_state` (`null` / `empty` / `present`), `dir`, `root`, `root_created`. +> It is **optional**: 3X-UI writes none, a snapshot without one is read exactly as +> before, and a malformed record (a symlink, a bad key, a non-base64 value, a repeated +> key) makes the snapshot invalid rather than being partly read. It never holds a +> secret: each adapter writes named keys only, from values it has already validated +> (see PASARGUARD-INSTALLER-AUDIT.md §6 and REBECCA-INSTALLER-AUDIT.md §6). + ### Why `format` is not optional An older library must not mis-read a newer snapshot. Without a format marker, an old `row-template` diff --git a/docs/design/PASARGUARD-INSTALLER-AUDIT.md b/docs/design/PASARGUARD-INSTALLER-AUDIT.md new file mode 100644 index 0000000..4dca7f2 --- /dev/null +++ b/docs/design/PASARGUARD-INSTALLER-AUDIT.md @@ -0,0 +1,193 @@ +# Installer Panel Adapter — PasarGuard + +**Status:** implemented in 1.3.0 (`installer/panels/pasarguard.sh`). +**Companion documents:** [PASARGUARD-ADAPTER-AUDIT.md](PASARGUARD-ADAPTER-AUDIT.md) +(what the page renders from), [INSTALLER-PANEL-INTERFACE.md](INSTALLER-PANEL-INTERFACE.md) +(the seven verbs), [INSTALLER-TRANSACTION-DESIGN.md](INSTALLER-TRANSACTION-DESIGN.md) +(capture → snapshot → mutate → verify → rollback), [PANEL-ON-HOLD-DECISION.md](PANEL-ON-HOLD-DECISION.md). + +This document records what PasarGuard actually does, file by file, and how the +installer adapter follows it. Every claim below was read from the PasarGuard +source (`pasarguard/panel`, main branch) and the official installer +(`PasarGuard/scripts`, `pasarguard.sh`), not inferred from Marzban, from which +PasarGuard descends. PasarGuard is AGPL-3.0: nothing from it is copied into this +repository — the test hosts are independent implementations of the few facts +below. + +--- + +## 1. What selects the subscription page + +| fact | source | +|---|---| +| The page is rendered with **Jinja2**, `Environment(loader=FileSystemLoader(template_directories))` — non-sandboxed, **autoescape off**. | `app/templates/__init__.py` | +| The loader's search path is `[CUSTOM_TEMPLATES_DIRECTORY, "app/templates"]` — the custom directory first, only when set. | `app/templates/__init__.py` | +| `CUSTOM_TEMPLATES_DIRECTORY` (default none) and `SUBSCRIPTION_PAGE_TEMPLATE` (default `subscription/index.html`) are pydantic settings, read from the environment and `.env` **once, at start-up**. | `config.py`, `TemplateSettings`; `EnvSettings.model_config = SettingsConfigDict(env_file=".env")` | +| A browser request (`Accept` contains `text/html`) is answered with the page, **unless** the database setting `subscription.disable_sub_template` is on. | `app/operation/subscription.py`, `is_subscription_page_request` | +| The page name is the requesting user's **admin's** `sub_template` when that admin has one, else `SUBSCRIPTION_PAGE_TEMPLATE`. | `app/operation/subscription.py`; `admins.sub_template`, `app/db/models.py` | + +**Consequences for the installer.** + +1. Activation is an environment change, so it needs a restart of the panel — once. + After that, Jinja2's loader re-reads a changed file (its auto-reload checks the + mtime), so a new design or new branding only replaces the file. +2. `.env` is the only switch. The installer changes nothing in the database. +3. Two database settings can outrank the selection. They are the operator's + choices, so they are **reported, never changed** (§8). + +## 2. How the official installer lays PasarGuard out + +| fact | source | +|---|---| +| Application directory `/opt/pasarguard`, data directory `/var/lib/pasarguard`, CLI `/usr/local/bin/pasarguard`. | `PasarGuard/scripts` `pasarguard.sh` (`APP_DIR`, `DATA_DIR`) | +| Docker Compose, `image: pasarguard/panel:latest`, `env_file: .env`, `network_mode: host`, bind mount `/var/lib/pasarguard:/var/lib/pasarguard`. | `docker-compose.yml` in the panel repository and the installer's generated compose file | +| SQLite is written as an **absolute** URL, `sqlite+aiosqlite:////var/lib/pasarguard/db.sqlite3`; PostgreSQL, TimescaleDB, MySQL and MariaDB are written as server URLs **with the password inline**. | `pasarguard.sh`, `sqlite_absolute_database_url`, the `SQLALCHEMY_DATABASE_URL=` writes | +| The shipped `.env.example` carries both page keys commented out, pointing at `/var/lib/pasarguard/templates/`. | `.env.example` | +| A source install runs as `pasarguard.service`, with `.env` in its `WorkingDirectory`. | `install_service.sh` | + +Because the data directory is bind-mounted **at the same path**, a file under +`/var/lib/pasarguard` has one path that is valid on the host and in the container. +That is the only place the adapter will put a page for a Docker install. + +## 3. Detection + +`rt_panel_pasarguard_detect` is read-only and needs **two independent signals of +four**: + +| | signal | +|---|---| +| A | `/opt/pasarguard/.env`, a regular file | +| B | a compose file whose `image:` is `pasarguard/panel` (optionally `docker.io/`), or a registered `pasarguard.service` unit | +| C | an executable `/usr/local/bin/pasarguard` that names PasarGuard | +| D | the data directory `/var/lib/pasarguard` | + +Two or more → `OK`. Exactly one → `FAIL` ("looks partly installed"; the installer +refuses rather than guesses). None → `NOT_APPLICABLE`. A leftover `.env` alone, a +stray directory alone, or a compose file running some other image is never taken for +a panel. The unit check does not use `grep -q` on a pipe (the pipefail + SIGPIPE +trap documented in `rt_detect_xui`). + +## 4. Capability set + +``` +env_activation file_placement live_verify selection_read selection_write service_control static_verify +``` + +Every token is backed by code in the adapter: the selection is read from and +written to `.env`; the page is placed as a file; the service is restarted (only if it +was running); verify has a static and — in Docker — a live mode. + +## 5. The change it makes + +**Place.** The generated page goes to `/row-template/index.html`, where +`` is the operator's own `CUSTOM_TEMPLATES_DIRECTORY` when there is one, +else `/var/lib/pasarguard/templates`. For Docker, an operator directory outside the +bind-mounted data directory is **refused before anything changes** — the container +could not see a page placed there. The write is atomic (temp file + `mv`), mode 644, +into a directory named for Row-Template. A symlinked directory, and a file at that +path that is not Row-Template's (marker check), are refused. + +**Select.** A managed block is appended to `.env`: + +``` +# >>> row-template (managed by Row-Template; do not edit) nl=0 >>> +CUSTOM_TEMPLATES_DIRECTORY = "/var/lib/pasarguard/templates" +SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" +# <<< row-template <<< +``` + +python-dotenv and Docker Compose both take the **last** assignment of a key, so the +block wins without a single operator line being edited. `CUSTOM_TEMPLATES_DIRECTORY` +is written only when the operator has none. `nl=1` records that a newline was added to +a file that ended without one. The rewrite is atomic and keeps the file's mode. + +**Apply.** If — and only if — the panel was running: `docker compose -f +/opt/pasarguard/docker-compose.yml -p pasarguard up -d` (Compose recreates the +container because its environment changed), or `systemctl restart pasarguard` for a +source install. A stopped panel is not started; its next start reads the block. + +**Idempotent.** On a panel that already selects the page, only the file is replaced +and nothing restarts. + +## 6. Backup — and what never enters it + +The P2 snapshot records, through `rt_backup_panel_write`: + +| field | value | +|---|---| +| `selection.state` | `absent`, `empty` or `present` — the effective `SUBSCRIPTION_PAGE_TEMPLATE` | +| `selection` | its value, when present | +| `meta` | `mechanism=env`, `was_running=0|1` | +| `files` | `row-template/index.html`, only when this change creates it | +| `aux` | `block=absent|present`, `dir=`, `root=`, `root_created=1` when the adapter will create the templates directory | + +**Secrets.** `.env` holds `SUDO_PASSWORD`, `JWT_SECRET`, the database URL and more. +It is read only through `rt_dotenv_get` — as data, never sourced or evaluated — and +only for the keys above. It is never printed, never copied outside its own directory +(the atomic rewrite stages a sibling with the same mode), and no part of it except +the two page keys is ever written to a snapshot. `tests/installer-panel-pasarguard.test.mjs` +("secrets in .env never reach output, logs or snapshots") walks every snapshot file +for planted secrets. + +## 7. Restore and uninstall + +**Restore** (transaction rollback, or `row-template rollback`) validates the record, +then puts `.env` back: when the record says the block was absent, the block is +removed — and because it was *appended*, removing it returns the file to its exact +previous bytes (including a missing final newline, from `nl=`). A line the operator +added after the block is kept. A page this change created is removed, then its +directory and — only if `root_created=1` — the templates root, each only when empty. +The panel's running/stopped state is restored. A record naming any file other than +`row-template/index.html` is refused. + +**Uninstall** removes the block and the page the same way, leaves every other line of +`.env` and every other template file alone, and restarts a running panel once so it +serves its own page again. A backup made on another panel (`panel=` in the backup +metadata) is never restored onto PasarGuard. + +## 8. Verification + +**Static** (mandatory, `0` or `1`): the artifact is a PasarGuard page from this +release (context marker and `{%- autoescape true -%}`), matches its checksum; the +generated page is valid; the placed copy is Row-Template's and byte-identical to it; +`.env` selects `row-template/index.html` from the right directory. + +It then reads — **read-only** (`sqlite3 -readonly`), and only for a SQLite database — +the two settings of §1 that outrank the selection, and **warns** (never fails): + +- `N admin(s) set their own subscription page (sub_template); their users keep that page.` +- `the panel's 'disable subscription template' setting is on, so browsers get the raw subscription instead of any page.` + +A server database is not read at all; its URL is never printed. + +**Live** (Docker only; `UNAVAILABLE` otherwise): the running container's environment +has `SUBSCRIPTION_PAGE_TEMPLATE=row-template/index.html` and the container can see the +page at the same path. A panel that was not restarted fails this with "restart it". + +## 9. Rendering safety + +The page is rendered by a **non-sandboxed Jinja2 with autoescape off**. The shell +therefore wraps every interpolation in an explicit `{%- autoescape true -%}` block, +and operator branding is written into the page with `{` and `}` escaped +(`rt_json_escape` → `{`/`}`), so no branding value can open a Jinja2 +expression. Both are enforced by the shell tests against the real Jinja2 engine +(`tests/panels-pasarguard-shell.test.mjs`) with hostile usernames, notes and branding. + +## 10. What the adapter does not do + +- It does not edit the database, the compose file, or any operator line of `.env`. +- It does not support subscription "clash" or other non-page templates. +- It does not change per-admin `sub_template` or `disable_sub_template` (§8 reports them). +- It does not start a stopped panel. + +## 11. Tests + +`tests/installer-panel-pasarguard.test.mjs` drives the adapter through the real +transaction engine on a synthetic host (compose file, `.env`, data directory, a +Docker double that records restarts and the container's environment): detection, +capabilities, dotenv semantics, activation + byte-exact uninstall, operator +directory, refused directory, foreign page, rollback after a late failure, stopped +panel, idempotency, final newline, operator lines after the block, damaged block, +malformed restore record, verify on tamper, database overrides, secrets, artifact +dialect, cross-panel backup, and the full install → rebrand → switch → rollback → +uninstall lifecycle through `row-template` itself. diff --git a/docs/design/README.md b/docs/design/README.md index c4d4e54..35f7b7c 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -58,6 +58,9 @@ The management layer under `installer/`, in the order the work was done. | [INSTALLER-PANEL-INTERFACE.md](INSTALLER-PANEL-INTERFACE.md) | P3: the frozen panel adapter interface. | | [INSTALLER-TRANSACTION-DESIGN.md](INSTALLER-TRANSACTION-DESIGN.md) | P4: the transaction engine that drives a panel adapter. | | [INSTALLER-PANEL-3XUI.md](INSTALLER-PANEL-3XUI.md) | P5A: the 3X-UI panel adapter and its validation boundary. | +| [PASARGUARD-INSTALLER-AUDIT.md](PASARGUARD-INSTALLER-AUDIT.md) | 1.3.0: the PasarGuard panel adapter — `.env` managed block, placement, restart, backup, restore, uninstall — audited from source. | +| [REBECCA-INSTALLER-AUDIT.md](REBECCA-INSTALLER-AUDIT.md) | 1.3.0: the Rebecca panel adapter — `subscription_settings` row, placement, manual activation, backup, restore, uninstall — audited from source. | +| [PANEL-ON-HOLD-DECISION.md](PANEL-ON-HOLD-DECISION.md) | 1.3.0: how an `on_hold` user is shown on PasarGuard and Rebecca. | ## Documentation platform diff --git a/docs/design/REBECCA-INSTALLER-AUDIT.md b/docs/design/REBECCA-INSTALLER-AUDIT.md new file mode 100644 index 0000000..0e7e487 --- /dev/null +++ b/docs/design/REBECCA-INSTALLER-AUDIT.md @@ -0,0 +1,153 @@ +# Installer Panel Adapter — Rebecca + +**Status:** implemented in 1.3.0 (`installer/panels/rebecca.sh`). +**Companion documents:** [REBECCA-ADAPTER-AUDIT.md](REBECCA-ADAPTER-AUDIT.md) and +[REBECCA-ADAPTER-DECISIONS.md](REBECCA-ADAPTER-DECISIONS.md) (what the page renders +from), [INSTALLER-PANEL-INTERFACE.md](INSTALLER-PANEL-INTERFACE.md), +[INSTALLER-TRANSACTION-DESIGN.md](INSTALLER-TRANSACTION-DESIGN.md), +[PANEL-ON-HOLD-DECISION.md](PANEL-ON-HOLD-DECISION.md). + +Every claim below was read from the Rebecca Go source (`rebeccapanel/Rebecca`, master) +and its official installer scripts (`scripts/rebecca/rebecca.sh`, +`scripts/rebecca/rebecca-binary.sh`). Rebecca is AGPL-3.0: nothing from it is copied +into this repository; the test hosts are independent implementations. + +--- + +## 1. What selects the subscription page + +| fact | source | +|---|---| +| The page is rendered with **pongo2 v6** (`pongo2.FromString`) after `normalizeLegacySubscriptionTemplate` rewrites legacy Jinja-isms. | `internal/app/user/subscription.go`, `renderSubscriptionPageTemplate` | +| The page is looked up **on every request** by `ReadTemplateContent(ctx, "subscription_page_template", adminID)` — nothing is cached. | `internal/app/user/subscription.go`; `internal/app/settings/repository.go`, `ReadTemplateContent` | +| The selection is the **newest** row of `subscription_settings` (`ORDER BY id DESC LIMIT 1`): `subscription_page_template` (default `subscription/index.html`) and `custom_templates_directory` (default `NULL`). | `internal/app/settings/repository.go`; `internal/app/migrations/000014_subscription_settings.go` | +| A custom page is `/`, joined with `safeJoin` (no escape from the directory); when the directory is empty or the file is absent, the bundled templates are used. | `internal/app/settings/normalize.go`, `resolveCustomTemplatePath`, `resolveAppTemplatePath` | +| An admin may override both columns for their own users (`admins.subscription_settings`, JSON); the admin's page is tried first. | `repository.go`, `ReadTemplateContent` / `templateSelection`; `internal/app/settings/types.go` | +| A browser request is detected by `Accept` containing `text/html` or `application/xhtml+xml`. | `internal/app/user/subscription.go` | + +**Consequences for the installer.** Activation is a two-column update of one row. It +takes effect on the next request — **Rebecca is never restarted**. Per-admin +overrides are the operator's and are reported, never changed. + +## 2. How the official installer lays Rebecca out + +| fact | source | +|---|---| +| Application directory `/opt/rebecca`, data directory `/var/lib/rebecca`, compose file `/opt/rebecca/docker-compose.yml`. | `scripts/rebecca/rebecca.sh` (`INSTALL_DIR`, `APP_DIR`, `DATA_DIR`, `COMPOSE_FILE`) | +| Docker: `image: rebeccapanel/rebecca:latest`, `env_file: .env`, bind mount `/var/lib/rebecca:/var/lib/rebecca`. | `docker-compose.yml` | +| Binary mode runs as `rebecca.service`. | `scripts/rebecca/rebecca-binary.sh` | +| SQLite is `SQLALCHEMY_DATABASE_URL = "sqlite:////var/lib/rebecca/db.sqlite3"`; MySQL/MariaDB URLs carry the password inline. | `rebecca.sh`, `rebecca-binary.sh` | + +## 3. Detection + +Read-only, **two independent signals of four**: `/opt/rebecca/.env`; a compose file +running `rebeccapanel/rebecca` or a `rebecca.service` unit; an executable +`/usr/local/bin/rebecca` that names Rebecca; the data directory. Two or more → `OK`, +one → `FAIL` (partly installed; refused), none → `NOT_APPLICABLE`. On a host that also +runs PasarGuard or 3X-UI the installer asks which panel to serve (or honours +`RT_PANEL=`); it never picks one silently. + +## 4. Capability set + +``` +db_activation file_placement selection_read selection_write static_verify +``` + +No `service_control` (Rebecca is never restarted — §1) and no `live_verify` (the +database *is* the live state; static verify reads it). + +## 5. The change it makes + +**Place.** `/row-template/index.html`, where `` is the operator's +`custom_templates_directory` when set, else `/var/lib/rebecca/templates`. Atomic write, +mode 644, symlinks refused, a foreign file at that path never overwritten. For Docker +the directory must be inside the bind-mounted data directory, or activation is refused +before anything changes. + +**Select.** One `UPDATE` of the newest `subscription_settings` row: +`subscription_page_template = 'row-template/index.html'`, and +`custom_templates_directory = ` **only when the operator had none**. No other +column, no other row. Values are quoted by doubling `'`; the `sqlite3` CLI is run with +`-cmd '.timeout 5000'` so a panel holding the database waits rather than fails. + +**Database access.** Only SQLite, and only through the `sqlite3` command. The URL is +the one key read from `.env` (`SQLALCHEMY_DATABASE_URL`, then `DATABASE_URL`), never +printed. In Docker the path must be absolute and inside the data directory (a relative +path is inside the image, not on the host); in binary mode a relative path is resolved +against `/opt/rebecca`. The file must carry the SQLite header. + +**MySQL/MariaDB, or no `sqlite3`:** the adapter answers `UNAVAILABLE` and the installer +prints the two values to enter in Rebecca's dashboard (Settings → Subscription → Templates) — +the page itself is still placed. The same "manual activation" 3X-UI has without +`sqlite3`. No database password is ever asked for, read or printed. + +## 6. Backup + +| field | value | +|---|---| +| `selection.state` / `selection` | `subscription_page_template` (`present`, or `empty`) | +| `meta` | `mechanism=db`, `was_running` (recorded; never acted on) | +| `files` | `row-template/index.html`, only when this change creates it | +| `aux` | `dir_state=null|empty|present`, `dir=`, `root`, `root_created=1` when created | + +`NULL`, `''` and a value are three different states of `custom_templates_directory`, +and a restore needs all three: the `aux` record keeps them apart. A value containing a +newline is refused at capture (it could not be restored exactly). + +## 7. Restore and uninstall + +**Restore** writes both columns back exactly (`NULL` stays `NULL`), removes a page this +change created, then its directory and — only with `root_created=1` — the root, each +only when empty. A record naming any other file is refused. + +**Uninstall.** When the panel still selects Row-Template's page, the selection is put +back from the activation record (`panel-activation`) when there is one; without one it +returns to Rebecca's own default (`subscription/index.html`), clearing the directory +only when it is the one Row-Template itself sets. When the operator has since chosen +another page, the selection is theirs and is left alone; only our page is removed. A +backup made on another panel is never restored onto Rebecca. + +## 8. Verification + +**Static**: the artifact is a Rebecca page from this release (context marker, explicit +autoescape), matches its checksum; the generated page is valid; the placed copy is ours +and byte-identical; the newest row selects `row-template/index.html` from the right +directory. It **warns** (never fails) when admins override the page: +`N admin(s) override the subscription page for their own users; those users keep the admin's page.` + +**Live**: `UNAVAILABLE` by design — the next request reads exactly what static verify +read. + +## 9. Rendering safety + +pongo2 autoescapes by default, and the shell also states `{%- autoescape on -%}` +explicitly around every interpolation. Branding enters the page with `{` and `}` +escaped, so it can never open a pongo2 tag. `online_at` is zoneless UTC in Rebecca and +is converted with integer days-from-civil arithmetic inside the template — no +locale-dependent parsing. The shell tests render against the real pongo2 v6.1.0 +(`tools/engines/pongo2/`) with hostile usernames, notes, links and branding, and with +malformed data (missing fields, `null`s, zero and negative limits, huge values). +`on_hold` users are handled as documented in PANEL-ON-HOLD-DECISION.md. + +## 10. Limitations + +- MySQL/MariaDB: manual selection in the dashboard (the page is placed automatically). +- Per-admin overrides keep their own page (reported by verify). +- No live verification (none is possible beyond what static verify reads). + +## 11. Tests + +`tests/installer-panel-rebecca.test.mjs` runs the adapter through the real transaction +engine against a real SQLite database with Rebecca's own column definitions (via a +`sqlite3` test double that uses Python's `sqlite3`): detection; capabilities; only a +`sqlite:` database inside the shared data directory is used (a MySQL URL is never +touched); activation changes two columns of the newest row and nothing else, with no +restart; rollback and uninstall restore `NULL`, empty and a value exactly; an operator +directory is used and kept; a quote in panel data cannot break the SQL; uninstall +without an activation record; an operator who moved away keeps their choice; the +admin-override warning; a foreign page never overwritten; a restore record naming a +file the adapter never places refused; verify failing a tampered page and a moved +selection; secrets never in output or anywhere under the install root; a 3X-UI +artifact, a PasarGuard page and a pre-1.3.0 shell refused; a backup from another panel +refused; manual activation without `sqlite3`; and the full install → verify → rebrand +→ switch design → roll back → uninstall lifecycle through `row-template` itself. diff --git a/installer/panels/pasarguard.sh b/installer/panels/pasarguard.sh index 9892616..80dff2e 100644 --- a/installer/panels/pasarguard.sh +++ b/installer/panels/pasarguard.sh @@ -327,6 +327,59 @@ rt_panel_pasarguard_apply() { return 0 } +# --- what the database can still override (read-only) ------------------------ +# Two panel settings live in PasarGuard's database, not in .env, and both win +# over the selected page: an admin's own `sub_template` (their users get that +# page instead) and the subscription setting `disable_sub_template` (browsers +# get the raw subscription, no page at all). Row-Template never changes either +# -- they are the operator's choices -- but verify says when one applies, so a +# page that "does not show" is explained. Read-only, SQLite only; the database +# URL is read for its path and never printed (a server URL carries a password). + +rt_panel_pasarguard_db() { + # Echo the host path of PasarGuard's SQLite database, or fail. + local env url path rc=0 + env="$(rt_panel_pasarguard_env)" + [ -f "$env" ] && [ ! -L "$env" ] || return 1 + url="$(rt_dotenv_get "$env" SQLALCHEMY_DATABASE_URL)" || rc=$? + [ "$rc" -eq 0 ] && [ -n "$url" ] || return 1 + case "$url" in + sqlite:///*|sqlite+*:///*) path="${url#*:///}" ;; + *) return 1 ;; # PostgreSQL/MySQL: not read + esac + path="${path%%\?*}" + case "$path" in + /*) : ;; + *) [ "$(rt_panel_pasarguard_mode)" = "systemd" ] || return 1 # relative: inside the image + path="$(dirname -- "$env")/$path" ;; + esac + if [ "$(rt_panel_pasarguard_mode)" = "docker" ]; then + rt_is_within "$RT_PG_DATA_DIR" "$path" || return 1 + fi + rt_is_sqlite_db "$path" || return 1 + printf '%s' "$path" +} + +rt_panel_pasarguard_db_notes() { + # Warn (never fail) about the two overrides above. Silent when they cannot be + # read: this is advice, and verify's pass/fail does not depend on it. + local db n off + command -v sqlite3 >/dev/null 2>&1 || return 0 + db="$(rt_panel_pasarguard_db)" || return 0 + n="$(sqlite3 -readonly -cmd '.timeout 5000' "$db" \ + "SELECT COUNT(*) FROM admins WHERE sub_template IS NOT NULL AND sub_template <> '';" 2>/dev/null)" || n=0 + case "${n:-}" in ''|*[!0-9]*) n=0 ;; esac + [ "$n" = "0" ] \ + || rt_warn "panel pasarguard: $n admin(s) set their own subscription page (sub_template); their users keep that page." + off="$(sqlite3 -readonly -cmd '.timeout 5000' "$db" \ + "SELECT json_extract(subscription, '\$.disable_sub_template') FROM settings ORDER BY id LIMIT 1;" 2>/dev/null)" || off="" + case "$off" in + 1|true|True) + rt_warn "panel pasarguard: the panel's 'disable subscription template' setting is on, so browsers get the raw subscription instead of any page." ;; + esac + return 0 +} + # --- the frozen verbs -------------------------------------------------------------- rt_panel_pasarguard_detect() { @@ -486,6 +539,7 @@ rt_panel_pasarguard_verify() { || { rt_err "panel pasarguard: the placed page differs from the generated one"; return "$RT_PANEL_FAIL"; } rt_panel_pasarguard_selected \ || { rt_err "panel pasarguard: .env does not select the Row-Template page"; return "$RT_PANEL_FAIL"; } + rt_panel_pasarguard_db_notes return "$RT_PANEL_OK" } diff --git a/tests/helpers/panel-hosts.mjs b/tests/helpers/panel-hosts.mjs index 32e5efe..7df43a1 100644 --- a/tests/helpers/panel-hosts.mjs +++ b/tests/helpers/panel-hosts.mjs @@ -253,7 +253,7 @@ export const PG_ENV = [ 'JWT_SECRET = "jwt-secret-do-not-leak"', ].join('\n') + '\n'; -export function pasarguardHost(base, { running = true, env = PG_ENV, compose = true, cli = true, data = true } = {}) { +export function pasarguardHost(base, { running = true, env = PG_ENV, compose = true, cli = true, data = true, db = null } = {}) { const app = join(base, 'opt', 'pasarguard'); const dataDir = join(base, 'var', 'lib', 'pasarguard'); const cliPath = join(base, 'usr', 'local', 'bin', 'pasarguard'); @@ -276,9 +276,34 @@ export function pasarguardHost(base, { running = true, env = PG_ENV, compose = t writeFileSync(join(docker, 'image'), 'pasarguard/panel:latest'); writeFileSync(join(docker, 'name'), 'pasarguard-pasarguard-1'); if (running) writeFileSync(join(docker, 'running'), ''); - const bin = shims(base, { sqlite: false }); + // With `db`, a SQLite database holding PasarGuard's own columns for the two + // settings verify reports on: admins.sub_template, and the subscription JSON + // of the settings row. The .env then points at it, as the official installer's + // absolute sqlite+aiosqlite URL does. + let dbPath = null; + if (db) { + dbPath = join(dataDir, 'db.sqlite3'); + const statements = [ + 'CREATE TABLE admins (id INTEGER PRIMARY KEY, username VARCHAR(34), sub_template VARCHAR(1024))', + 'CREATE TABLE settings (id INTEGER PRIMARY KEY, subscription JSON NOT NULL)', + `INSERT INTO settings (subscription) VALUES ('${JSON.stringify({ allow_browser_config: true, + disable_sub_template: Boolean(db.disable) })}')`, + ]; + (db.admins || []).forEach((t, i) => statements.push( + `INSERT INTO admins (username, sub_template) VALUES ('a${i}', ${t === null ? 'NULL' : `'${t.split("'").join("''")}'`})`)); + const r = spawnSync(PYTHON, ['-c', [ + 'import sqlite3,sys,json', + 'con=sqlite3.connect(sys.argv[1])', + 'for s in json.loads(sys.argv[2]): con.execute(s)', + 'con.commit(); con.close()', + ].join('\n'), dbPath, JSON.stringify(statements)], { encoding: 'utf8' }); + if (r.status !== 0) throw new Error(r.stderr); + writeFileSync(join(app, '.env'), readFileSync(join(app, '.env'), 'utf8').replace( + /^SQLALCHEMY_DATABASE_URL = .*$/m, `SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite:///${posix(dbPath)}"`)); + } + const bin = shims(base, { sqlite: Boolean(db) }); return { - app, dataDir, cliPath, docker, bin, envFile: join(app, '.env'), + app, dataDir, cliPath, docker, bin, db: dbPath, envFile: join(app, '.env'), paths: { RT_PG_APP_DIR: app, RT_PG_DATA_DIR: dataDir, RT_PG_CLI: cliPath, RT_TEST_DOCKER: docker, RT_TEST_BIN: bin }, }; } diff --git a/tests/installer-panel-pasarguard.test.mjs b/tests/installer-panel-pasarguard.test.mjs index 80c9eaf..1a76fe3 100644 --- a/tests/installer-panel-pasarguard.test.mjs +++ b/tests/installer-panel-pasarguard.test.mjs @@ -281,6 +281,37 @@ test('verify fails a placed page that was changed, and a selection that was remo }); }); +/* Two settings in PasarGuard's database outrank the selected page: an admin's + own sub_template, and disable_sub_template (app/operation/subscription.py). + Row-Template never changes them; verify names them, never fails on them, and + reads the database without writing a byte. */ +test('verify names the database settings that outrank the page, and never writes the database', () => { + withHost({ db: { admins: [null, 'custom/admin.html', ''], disable: true } }, ({ host, run }) => { + run([SETUP, 'rt_transaction_run pasarguard "$RT_LIVE" 2>/dev/null']); + const before = sha256(readFileSync(host.db)); + const r = run('rc=0; rt_panel_verify pasarguard static || rc=$?; echo "rc=$rc"'); + assert.match(r.out, /rc=0/, 'advice, never a failure'); + assert.match(r.err, /1 admin\(s\) set their own subscription page \(sub_template\)/); + assert.match(r.err, /'disable subscription template' setting is on/); + assert.equal(sha256(readFileSync(host.db)), before, 'the database is read, never written'); + assert.equal(r.out.includes(host.db) || r.err.includes('sqlite+aiosqlite'), false, 'the database URL is never printed'); + }); + withHost({ db: { admins: [null, ''], disable: false } }, ({ run }) => { + run([SETUP, 'rt_transaction_run pasarguard "$RT_LIVE" 2>/dev/null']); + const r = run('rc=0; rt_panel_verify pasarguard static || rc=$?; echo "rc=$rc"'); + assert.match(r.out, /rc=0/); + assert.equal(/sub_template|disable subscription template/.test(r.err), false, 'nothing to say when nothing overrides'); + }); + // A server database (its URL carries a password) is never read, and never printed. + withHost({ env: PG_ENV.replace(/^SQLALCHEMY_DATABASE_URL = .*$/m, + 'SQLALCHEMY_DATABASE_URL = "postgresql+asyncpg://pg:db-pass-do-not-leak@127.0.0.1:5432/pasarguard"') }, ({ run }) => { + const r = run([SETUP, 'rt_transaction_run pasarguard "$RT_LIVE" 2>/dev/null', + 'rc=0; rt_panel_verify pasarguard static || rc=$?; echo "rc=$rc"']); + assert.match(r.out, /rc=0/); + assert.equal((r.out + r.err).includes('db-pass-do-not-leak'), false); + }); +}); + test('secrets in .env never reach output, logs or snapshots', () => { withHost({}, ({ rt, run }) => { const r = run([SETUP, 'rt_transaction_run pasarguard "$RT_LIVE"', 'rt_panel_verify pasarguard static', diff --git a/tests/installer-panel-rebecca.test.mjs b/tests/installer-panel-rebecca.test.mjs index 4571c85..f52ee2e 100644 --- a/tests/installer-panel-rebecca.test.mjs +++ b/tests/installer-panel-rebecca.test.mjs @@ -11,8 +11,10 @@ * two columns of exactly the row Rebecca reads; NULL, '' and a value are * restored exactly, by rollback and by uninstall; an operator's own directory * and page are respected; without database access activation is honestly - * manual; SQL built from panel data cannot be broken by a quote; and the full - * life cycle works end to end without Rebecca ever being restarted. + * manual; SQL built from panel data cannot be broken by a quote; a foreign + * page, a bad restore record, a page for another panel and a backup from + * another panel are all refused; secrets never leave .env; and the full life + * cycle works end to end without Rebecca ever being restarted. */ import test from 'node:test'; @@ -222,6 +224,99 @@ test('admins who override the page for their users are reported by verify', () = }); }); +/* --- refusals: a foreign page, a bad record, a tampered page -------------------- */ + +test('a page that is not Row-Template\'s is never overwritten, and the failed activation changes nothing', () => { + withHost({}, ({ host, run }) => { + const before = last(host); + const tpl = join(host.dataDir, 'templates'); + mkdirSync(join(tpl, 'row-template'), { recursive: true }); + writeFileSync(page(tpl), '

someone else

'); + const r = run([SETUP, 'rc=0; rt_transaction_run rebecca "$RT_LIVE" || rc=$?; echo "rc=$rc"']); + assert.match(r.out, /rc=1/, r.err); + assert.match(r.err, /exists and is not Row-Template's/); + assert.equal(readFileSync(page(tpl), 'utf8'), '

someone else

', 'the operator file is untouched'); + assert.deepEqual(last(host), before, 'the settings row is untouched'); + }); +}); + +test('restore refuses a record that lists a file this adapter never places', () => { + withHost({}, ({ run }) => { + const r = run([SETUP, + 'rt_transaction_stage_reset', 'rt_panel_backup_state rebecca', + 'printf "../../etc/passwd\\n" > "$RT_PANEL_STAGE/rebecca/files"', + 'snap="$(rt_backup_create v2 rebecca 2>/dev/null)" || { echo "snapshot-refused"; exit 0; }', + 'rc=0; rt_panel_restore_state rebecca "$snap" || rc=$?; echo "rc=$rc"']); + assert.match(r.out, /snapshot-refused|rc=1/, r.err); + }); +}); + +test('verify fails a placed page that was changed, and a selection that was moved away', () => { + withHost({}, ({ host, run }) => { + run([SETUP, 'rt_transaction_run rebecca "$RT_LIVE" 2>/dev/null']); + const tpl = join(host.dataDir, 'templates'); + const good = readFileSync(page(tpl)); + writeFileSync(page(tpl), good.toString().replace('Test VPN', 'Tampered')); + let r = run('rc=0; rt_panel_verify rebecca static || rc=$?; echo "rc=$rc"'); + assert.match(r.out, /rc=1/); + assert.match(r.err, /placed page differs/); + writeFileSync(page(tpl), good); + const db = `"$(cygpath -u '${host.db.split('\\').join('/')}' 2>/dev/null || printf '%s' '${host.db.split('\\').join('/')}')"`; + run(`sqlite3 ${db} "UPDATE subscription_settings SET subscription_page_template = 'subscription/index.html'"`); + r = run('rc=0; rt_panel_verify rebecca static || rc=$?; echo "rc=$rc"'); + assert.match(r.out, /rc=1/); + assert.match(r.err, /does not select the Row-Template page/); + }); +}); + +test('secrets in .env never reach output, logs or snapshots', () => { + withHost({}, ({ rt, run }) => { + const r = run([SETUP, 'rt_transaction_run rebecca "$RT_LIVE"', 'rt_panel_verify rebecca static', + 'rt_panel_verify rebecca live', 'rt_panel_status rebecca', 'rt_panel_uninstall_template rebecca']); + const secret = 'rebecca-secret-do-not-leak'; + assert.equal(r.out.includes(secret) || r.err.includes(secret), false, 'not in output'); + const walk = (d) => readdirSync(d, { withFileTypes: true }).flatMap((e) => + (e.isDirectory() ? walk(join(d, e.name)) : [join(d, e.name)])); + for (const f of existsSync(rt) ? walk(rt) : []) { + assert.equal(readFileSync(f).includes(secret), false, `not in ${f}`); + } + }); +}); + +/* --- the artifact must fit the panel ---------------------------------------- */ + +test('a 3X-UI artifact, a PasarGuard page, or a shell from before 1.3.0, is refused on Rebecca', () => { + withHost({}, ({ base, run }) => { + const old = join(base, 'old-shell.html'); + const shell = readFileSync(join(PAYLOAD, 'shells', 'rebecca', 'row', 'shell.html'), 'utf8'); + // a 1.2.x shell: the same layout without the context prelude and escaping + writeFileSync(old, shell.replace('Row-Template, Rebecca page context', '') + .replace('{%- autoescape on -%}', '').replace('{%- endautoescape %}', '')); + const u = (p) => `"$(cygpath -u ${JSON.stringify(p.split('\\').join('/'))} 2>/dev/null || printf '%s' ${JSON.stringify(p.split('\\').join('/'))})"`; + const r = run([SETUP, + 'rc=0; rt_set_dist "$PAYLOAD/template.html" 2>/dev/null || rc=$?; echo "xui=$rc"', + 'rc=0; rt_set_dist "$PAYLOAD/shells/pasarguard/row/shell.html" 2>/dev/null || rc=$?; echo "pg=$rc"', + `rc=0; rt_set_dist ${u(old)} 2>/dev/null || rc=$?; echo "old=$rc"`, + 'rc=0; rt_set_dist "$RT_TEMPLATE_STORE/editorial/template.html" || rc=$?; echo "ok=$rc"']); + assert.match(r.out, /xui=1/, 'the 3X-UI artifact is refused'); + assert.match(r.out, /pg=1/, 'a PasarGuard page is refused'); + assert.match(r.out, /old=1/, 'a shell without the prelude and escaping is refused'); + assert.match(r.out, /ok=0/, `a 1.3.0 Rebecca page is accepted\n${r.err}`); + }); +}); + +test('a backup made for another panel is never restored onto Rebecca', () => { + withHost({}, ({ run }) => { + const r = run([SETUP, + 'B="$(rt_backup_create)"', + 'sed -i "s/^panel=rebecca$/panel=pasarguard/" "$B/meta"', + 'rc=0; rt_restore_from_backup "$B" 2>/dev/null || rc=$?; echo "rc=$rc"', + 'B2="$(rt_backup_create)"; grep -c "^panel=rebecca$" "$B2/meta"']); + assert.match(r.out, /rc=1/, 'refused'); + assert.match(r.out, /\n1$/, 'and a Rebecca backup records its panel'); + }); +}); + /* --- manual activation --------------------------------------------------------- */ test('without sqlite3, activation places the page and says exactly what to set', () => { From cb0232b2c219c8205dde39a2ceb66096a025209d Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Sat, 26 Sep 2026 05:51:30 +0330 Subject: [PATCH 10/25] fix(transaction): report a verified rollback as ROLLED_BACK After restoring, the engine re-ran the forward static check ("does this panel serve Row-Template?"). A rollback restores the panel's previous selection, so that answer is no by design: every correct rollback was recorded FAILED and logged as rollback-failed. The restore now verifies itself, where interface.sh already put that obligation: each adapter's restore_state reads the state back and compares it with the record (3X-UI subThemeDir; PasarGuard's .env block and effective page key; Rebecca's two subscription_settings columns). The engine trusts that status and no longer asks the forward question. A restore that reports FAILURE or UNAVAILABLE is still a failed rollback. Tests pin both directions and the call sequence, so the forward check cannot come back after a rollback unnoticed. This change was made in this working tree by a separate Cline session; it was reviewed and the full suite run before committing. Co-Authored-By: Claude Opus 5.5 --- docs/design/INSTALLER-PANEL-3XUI.md | 43 ++++++++--- docs/design/INSTALLER-TRANSACTION-DESIGN.md | 43 ++++++++--- installer/lib/transaction.sh | 42 +++++++---- installer/panels/3xui.sh | 26 ++++++- installer/panels/pasarguard.sh | 39 ++++++++++ installer/panels/rebecca.sh | 26 ++++++- tests/installer-panel-3xui.test.mjs | 74 ++++++++++++++++--- tests/installer-transaction.test.mjs | 81 ++++++++++++++++----- 8 files changed, 305 insertions(+), 69 deletions(-) diff --git a/docs/design/INSTALLER-PANEL-3XUI.md b/docs/design/INSTALLER-PANEL-3XUI.md index e6715a6..9252b1a 100644 --- a/docs/design/INSTALLER-PANEL-3XUI.md +++ b/docs/design/INSTALLER-PANEL-3XUI.md @@ -344,15 +344,40 @@ afterwards. --- -## 13. Known limitation — P4's post-restore check is uninformative for this panel - -Recorded here rather than worked around, because it belongs to a frozen surface this phase must not change. - -**What happens.** After a rollback, P4 calls `rt_panel_verify PANEL static` as its "did the restore work?" check, and for 3X-UI that check asserts `settings.subThemeDir == RT_ROOT`. A rollback restores the **original** selection, which is by design *not* `RT_ROOT`, so the check fails and the engine ends the transaction in `FAILED` rather than `ROLLED_BACK` — even though the selection and the service were both restored correctly. - -**Why it is safe.** The engine only claims `ROLLED_BACK` after that check passes, so it never over-reports: it declines to assert a clean rollback it cannot confirm. The failure mode is a less informative outcome, not a wrong one. A caller can still tell what happened from the event stream and from the panel's actual state. - -**Why it is not fixed here.** `rt_panel_verify PANEL MODE` carries no snapshot, so it cannot express *"verify against the state you just restored"* — it can only ask the **install** question. P4's rollback wants the **restore** question. Those are different questions for any panel whose post-install and post-restore correct states differ, which will include PasarGuard and Rebecca. Fixing it means either giving P4's rollback its own post-restore verification notion (a P3 + P4 change, and a change to `INSTALLER-TRANSACTION-DESIGN.md`), or dropping the `== RT_ROOT` clause from static verification (which would weaken a P5A requirement). Neither is a P5A-scoped change, so it is reported rather than resolved. +## 13. Resolved in 1.3.0 — the rollback's post-restore check asked the wrong question + +Found as a limitation during P5A, recorded rather than worked around, and **fixed in 1.3.0**. It is kept here +because the shape of the mistake is worth remembering: it was a real defect that had been documented as +deliberate conservative behaviour, and the test suite agreed with the documentation. + +**What happened.** After a rollback, P4 called `rt_panel_verify PANEL static` as its "did the restore work?" +check. For 3X-UI that check asserts `settings.subThemeDir == RT_ROOT`. A rollback restores the **original** +selection, which is by design *not* `RT_ROOT`, so the check failed and the engine ended the transaction in +`FAILED` rather than `ROLLED_BACK` — even though the selection and the service had both been restored +correctly. Because the engine only claims `ROLLED_BACK` after that check passes, the defect looked like +caution rather than a bug. + +**Why it was still a bug.** It was not merely "a less informative outcome". A caller that cannot distinguish +"rolled back cleanly" from "left half-mutated" cannot decide whether to retry, escalate, or do nothing — and +the reported `FAILED` was wrong about the panel's actual state. A rollback that worked was being reported as +a rollback that did not. + +**The fix.** The obligation to verify a restore belongs to the layer that owns the state model, and +`interface.sh` already placed it there: a panel's `restore_state` returns SUCCESS only when the operation +completed **and its required verification passed**. So the fix does not add a P3 verb, does not touch the +frozen seven-name surface, and does not weaken static verification: + +1. Each adapter's `restore_state` now **reads the state back** and compares it with the record, so its + SUCCESS means what the contract always said it meant. `3xui` re-reads `subThemeDir` and, for `present`, + its value; PasarGuard re-reads the `.env` block and the effective page value; Rebecca re-reads both + subscription-settings columns. +2. P4's rollback no longer re-runs the forward check. It treats `restore_state`'s status as the rollback's + verification — which is exactly the "restore question" the old design could not express — and still runs + live verification afterwards as evidence, never as a gate. + +Both directions are now tested: a rollback that lands is reported `ROLLED_BACK`, and a restore that reports +FAILURE **or UNAVAILABLE** is reported as a failed rollback. `tests/installer-transaction.test.mjs` asserts +the call sequence directly, so the forward check cannot be reintroduced after a rollback without failing. --- diff --git a/docs/design/INSTALLER-TRANSACTION-DESIGN.md b/docs/design/INSTALLER-TRANSACTION-DESIGN.md index 663adf2..8277ca2 100644 --- a/docs/design/INSTALLER-TRANSACTION-DESIGN.md +++ b/docs/design/INSTALLER-TRANSACTION-DESIGN.md @@ -96,9 +96,9 @@ FAIL | +-- validate snapshot rt_transaction_snapshot_validate before anything is touched | - +-- restore rt_panel_restore_state the P3 frozen order applies - | - +-- verify static rt_panel_verify PANEL static mandatory + +-- restore rt_panel_restore_state restores the PREVIOUS panel + | state, and verifies it landed + | (the P3 frozen order applies) | +-- verify live optional; UNAVAILABLE is not a failure here | @@ -109,6 +109,8 @@ FAIL +-- STOP no second recovery attempt ``` +**There is no forward static check after the restore** (corrected in 1.3.0). See step 3 below. + **Failure BEFORE the boundary** is an abort, not a rollback: the panel has not been touched, so there is nothing to undo, and running a restore against an unmutated panel would write state that was never displaced. @@ -286,11 +288,22 @@ repair. 1. **Validate the safety snapshot.** Before anything is touched. A malformed snapshot is refused, never partially applied — a half-applied restore is worse than none. -2. **`rt_panel_restore_state PANEL SNAPSHOT`.** The panel layer owns *what* to restore and *through - which mechanism*; the recorded `mechanism` is the one that must be used, because a different - write path may not even address the same setting. -3. **Static verification — mandatory.** This is what makes "the restore worked" a checked claim - rather than an assertion. +2. **`rt_panel_restore_state PANEL SNAPSHOT`.** The panel layer owns *what* to restore, *through + which mechanism*, and *whether the restore landed*; the recorded `mechanism` is the one that must + be used, because a different write path may not even address the same setting. +3. **The restore is its own verification — and there is no forward static check here** (corrected in + 1.3.0). The engine used to run `rt_panel_verify PANEL static` after the restore and treat a + non-zero result as a failed rollback. That check asks the **install** question — *does this panel + serve Row-Template?* — while a rollback restores the panel's **previous** selection, so the honest + answer is *no* by design. The check therefore failed on every correct rollback, and a clean + rollback was reported as `rollback-failed` and recorded `FAILED` instead of `ROLLED_BACK`. + + The obligation to verify a restore belongs to the layer that owns the state model, and + `interface.sh` already places it there: `restore_state` returns `SUCCESS` only when the operation + completed **and its required verification passed**. Each adapter therefore reads the state back and + compares it with the record, and the engine checks *that* status. `FAILURE` and `UNAVAILABLE` are + both treated as a failed rollback, because in both cases the panel was not returned to its + recorded state — "we could not put it back" is not a clean rollback. 4. **Live verification — optional.** `UNAVAILABLE` and `NOT_APPLICABLE` are acceptable here, and neither is reported as a pass. @@ -298,6 +311,12 @@ repair. `rt_panel_restore_state`. Duplicating them here would create a second restore implementation that can disagree with the first, and only one of them can be right. +**Rollback takes Option A: it restores the previous panel state exactly, including the previous +template selection.** The alternative — leaving the panel pointed at Row-Template while the +installer's own state is rolled back — was rejected: if the install failed part-way, that leaves real +subscribers being served a broken or half-written page. A rollback means *undo the change*, and the +panel's selection is part of the change. + **Never:** - guess missing state — an absent record means absent, not "infer one" @@ -453,8 +472,8 @@ so they override the P3 public entry points for the duration of a run: | `rt_panel_capabilities` | any capability set, including empty, unknown-token and unavailable | | `rt_panel_backup_state` | capture success / failure | | `rt_panel_install_template` | placement success / failure | -| `rt_panel_verify` | per-mode success / failure / unavailable / not-applicable, with a **call counter** so the engine's forward check is distinguishable from the rollback's | -| `rt_panel_restore_state` | restore success / failure | +| `rt_panel_verify` | per-mode success / failure / unavailable / not-applicable, with a **call counter** so a repeat call would be visible. Static is called exactly once (the forward check); live is called twice on a rollback path, and the counter distinguishes the two | +| `rt_panel_restore_state` | restore success / failure / unavailable — all three are exercised, because a restore that did not land is a failed rollback whichever code says so | | `rt_backup_create` | snapshot success / failure, echoing a pre-built snapshot | Each double appends its name to a log, so the **order** of the engine's calls is asserted, not just @@ -539,7 +558,9 @@ properties, and P5 must not break them: | nothing before the boundary mutates | order of phases; `RT_TXN_MUTATED` raised before placement | | safety snapshot required | `rt_transaction_snapshot_validate` before placement | | snapshot validated before restore | first step of `rt_transaction_rollback` | -| static verification mandatory | `rt_transaction_static_verify`; caller treats any non-zero as failure | +| static verification mandatory on the forward path | `rt_transaction_static_verify`; caller treats any non-zero as failure | +| no forward static check after a rollback | `rt_transaction_rollback` calls only `rt_panel_restore_state`; the call sequence is asserted, so a reintroduced check fails | +| a restore that did not land is a failed rollback | `rt_transaction_rollback` treats any non-OK `restore_state` as failure, UNAVAILABLE included | | live UNAVAILABLE is not a rollback trigger | `case` arm in `rt_transaction_body` | | static verification universally required | `RT_TXN_REQUIRED_CAPABILITIES` (checked token by token) | | an apply mechanism is required, but not named | `RT_TXN_REQUIRED_APPLY_CAPABILITIES` + `rt_transaction_has_any_capability` | diff --git a/installer/lib/transaction.sh b/installer/lib/transaction.sh index 7df6be6..ba549e7 100644 --- a/installer/lib/transaction.sh +++ b/installer/lib/transaction.sh @@ -416,11 +416,29 @@ rt_transaction_rollback() { # each step protects the next: # 1. validate the snapshot -- before anything is touched; a malformed # snapshot must be refused, not half applied - # 2. rt_panel_restore_state -- the panel layer owns WHAT to restore and - # through which mechanism - # 3. static verification -- mandatory, and it is what makes "the restore - # worked" a checked claim - # 4. live verification -- optional evidence, never a failure here + # 2. rt_panel_restore_state -- the panel layer owns WHAT to restore, through + # which mechanism, and the verification that + # the restore actually landed + # 3. live verification -- optional evidence, never a failure here + # + # WHY THERE IS NO ENGINE-SIDE STATIC CHECK AFTER THE RESTORE (corrected in + # 1.3.0, and this was a real defect). The engine used to run the FORWARD + # static check here -- rt_panel_verify PANEL static -- and treat a non-zero + # result as "the rollback failed". That check answers "is Row-Template + # installed AND selected by this panel?". After a CORRECT rollback the answer + # is NO BY DESIGN: rollback takes Option A and restores the panel's PREVIOUS + # selection, so the panel deliberately stops pointing at Row-Template. The + # forward check therefore failed on every genuine rollback, the engine + # reported "rollback also failed", and a clean rollback was recorded as + # FAILED instead of ROLLED_BACK. + # + # The obligation to verify a restore belongs to the layer that owns the state + # model, and interface.sh already places it there: a panel's restore_state + # returns SUCCESS only when the operation completed AND its required + # verification passed. Every adapter therefore reads the state back and + # compares it with the record, and the engine checks THAT status. Asking the + # panel a forward question and calling a correct rollback a failure was the + # bug; re-asking it here would be the same bug again. # # This function deliberately does NOT touch selection, files or the service # itself. Those details live behind rt_panel_restore_state; duplicating them @@ -437,6 +455,12 @@ rt_transaction_rollback() { return "$RT_PANEL_FAIL" fi + # 2. The restore, and with it the rollback's required verification: a panel + # returns SUCCESS only after it has re-read the state it wrote and found it + # equal to the record. FAILURE and UNAVAILABLE both mean the panel was NOT + # returned to the recorded state, which is a failed rollback -- the two are + # not distinguished here because the recovery is the same (report, stop, + # attempt nothing further). rc=0 rt_panel_restore_state "$panel" "$snap" || rc=$? if [ "$rc" -ne "$RT_PANEL_OK" ]; then @@ -445,14 +469,6 @@ rt_transaction_rollback() { return "$RT_PANEL_FAIL" fi - rc=0 - rt_transaction_static_verify "$panel" || rc=$? - if [ "$rc" -ne "$RT_PANEL_OK" ]; then - rt_transaction_rollback_report_failure "$original" "post-restore static verification returned status $rc" - rt_transaction_state_set FAILED >/dev/null 2>&1 || true - return "$RT_PANEL_FAIL" - fi - # Live verification after a rollback is evidence, not a gate: UNAVAILABLE and # NOT_APPLICABLE are both acceptable here, and neither is reported as a pass. if rt_transaction_capability_present "${RT_TXN_CAPS:-}" live_verify; then diff --git a/installer/panels/3xui.sh b/installer/panels/3xui.sh index 07afe1d..02dc750 100644 --- a/installer/panels/3xui.sh +++ b/installer/panels/3xui.sh @@ -368,7 +368,7 @@ rt_panel_3xui_restore_state() { # # This function never calls the transaction engine, and never triggers a # second recovery. P4 owns rollback sequencing and its exactly-once rule. - local panel="$1" snap="$2" st mech was_running files rc=0 + local panel="$1" snap="$2" st mech was_running files rc=0 now nowval want # --- the record must be structurally complete and within its closed sets --- st="$(rt_backup_panel_state "$snap" "$panel")" || { @@ -422,6 +422,30 @@ rt_panel_3xui_restore_state() { || return "$RT_PANEL_FAIL" ;; esac + # --- the restore is not done until it is CHECKED --------------------------- + # interface.sh fixes the meaning of this function's SUCCESS: "operation + # completed AND its required verification passed". The transaction engine's + # rollback relies on exactly that and deliberately does NOT re-run a forward + # check of its own -- after a correct rollback this panel no longer selects + # Row-Template, so a forward check would fail by design and report a good + # rollback as a broken one. That makes the read-back below the WHOLE evidence + # that a rollback landed, so it compares the state and, for `present`, the + # value: a write that silently did not take, or took the wrong value, is a + # FAILURE and never a claim. + now="$(rt_panel_3xui_selection_state)" \ + || { rt_err "panel 3xui: cannot read the selection back after restoring"; return "$RT_PANEL_FAIL"; } + [ "$now" = "$st" ] || { + rt_err "panel 3xui: after restoring, the selection state is '$now', expected '$st'" + return "$RT_PANEL_FAIL"; } + if [ "$st" = "present" ]; then + want="$(rt_backup_panel_selection "$snap" "$panel")" + nowval="$(rt_panel_3xui_selection_value)" \ + || { rt_err "panel 3xui: cannot read the selection value back after restoring"; return "$RT_PANEL_FAIL"; } + [ "$nowval" = "$want" ] || { + rt_err "panel 3xui: after restoring, subThemeDir is not the recorded value" + return "$RT_PANEL_FAIL"; } + fi + # --- service state --------------------------------------------------------- if [ "$was_running" = "1" ]; then rt_panel_3xui_service_ensure running || return "$RT_PANEL_FAIL" diff --git a/installer/panels/pasarguard.sh b/installer/panels/pasarguard.sh index 80dff2e..a2882f6 100644 --- a/installer/panels/pasarguard.sh +++ b/installer/panels/pasarguard.sh @@ -561,6 +561,7 @@ rt_panel_pasarguard_restore_state() { # Validate the record, put .env's block back the way it was, remove the page # this change created, restore the running/stopped state. local panel="$1" snap="$2" st mech was_running files f block dir root created + local rc now_block now_page want_page st="$(rt_backup_panel_state "$snap" "$panel")" || { rt_err "panel pasarguard: malformed selection.state"; return "$RT_PANEL_FAIL"; } case "$st" in absent|empty|present) : ;; *) return "$RT_PANEL_FAIL" ;; esac rt_backup_panel_meta_check "$snap" "$panel" || { rt_err "panel pasarguard: malformed panel meta"; return "$RT_PANEL_FAIL"; } @@ -585,6 +586,44 @@ rt_panel_pasarguard_restore_state() { else rt_panel_pasarguard_env_rewrite write "$dir" || return "$RT_PANEL_FAIL" fi + + # --- the restore is not done until it is CHECKED --------------------------- + # interface.sh fixes the meaning of this function's SUCCESS: "operation + # completed AND its required verification passed". The transaction engine's + # rollback relies on exactly that and deliberately does NOT re-run a forward + # check of its own -- after a correct rollback this panel no longer selects + # Row-Template, so a forward check would fail by design and report a good + # rollback as a broken one. This comparison with the record is therefore the + # whole evidence that the rollback landed. + # + # The block's shape is checked first, then the effective value the panel will + # read for the page key. "unset" and "set to empty" are different facts (the + # P2 record distinguishes absent from empty), so the exit status is compared + # too, not only the text. + now_block="$(rt_panel_pasarguard_block_state)" + [ "$now_block" = "$block" ] || { + rt_err "panel pasarguard: after restoring, the Row-Template block is '$now_block', expected '$block'" + return "$RT_PANEL_FAIL"; } + if [ "$block" = "present" ]; then + [ "$(rt_panel_pasarguard_block_value "$RT_PG_KEY_DIR")" = "$dir" ] || { + rt_err "panel pasarguard: after restoring, the block's directory is not the recorded value" + return "$RT_PANEL_FAIL"; } + fi + want_page="" + if [ "$st" = "present" ]; then want_page="$(rt_backup_panel_selection "$snap" "$panel")"; fi + rc=0 + now_page="$(rt_panel_pasarguard_env_get "$RT_PG_KEY_PAGE")" || rc=$? + case "$st" in + absent) + [ "$rc" -eq 3 ] || { + rt_err "panel pasarguard: after restoring, $RT_PG_KEY_PAGE is set, but the record says it was unset" + return "$RT_PANEL_FAIL"; } ;; + *) + [ "$rc" -eq 0 ] && [ "$now_page" = "$want_page" ] || { + rt_err "panel pasarguard: after restoring, $RT_PG_KEY_PAGE is not the recorded value" + return "$RT_PANEL_FAIL"; } ;; + esac + if [ -n "$files" ]; then rt_panel_pasarguard_remove_page "$root" "${created:-0}" || return "$RT_PANEL_FAIL" fi diff --git a/installer/panels/rebecca.sh b/installer/panels/rebecca.sh index 71360e9..8ed99ff 100644 --- a/installer/panels/rebecca.sh +++ b/installer/panels/rebecca.sh @@ -370,8 +370,16 @@ rt_panel_rebecca_remove_page() { rt_panel_rebecca_restore_record() { # rt_panel_rebecca_restore_record SNAPSHOT PANEL -- put both columns back - # exactly as the record has them. Validates everything before writing. - local snap="$1" panel="$2" st page dstate dir + # exactly as the record has them. Validates everything before writing, then + # reads both columns BACK and compares them with the record. + # + # The read-back is what makes this function's SUCCESS mean what interface.sh + # says SUCCESS means -- "operation completed AND its required verification + # passed" -- and the transaction engine's rollback now relies on exactly that + # rather than re-running a forward check of its own. A correct rollback + # deliberately stops the panel selecting Row-Template, so a forward check + # would fail by design; the comparison with the record is the real evidence. + local snap="$1" panel="$2" st page dstate dir nowpage nowdir st="$(rt_backup_panel_state "$snap" "$panel")" || return 1 case "$st" in present) page="$(rt_backup_panel_selection "$snap" "$panel")" || return 1 ;; @@ -381,7 +389,19 @@ rt_panel_rebecca_restore_record() { dstate="$(rt_backup_panel_aux "$snap" "$panel" dir_state)" || return 1 dir="$(rt_backup_panel_aux "$snap" "$panel" dir)" || return 1 case "$dstate" in absent|empty|present) : ;; *) rt_err "panel rebecca: malformed dir_state record"; return 1 ;; esac - rt_panel_rebecca_write "$page" "$dstate" "$dir" + rt_panel_rebecca_write "$page" "$dstate" "$dir" || return 1 + + nowpage="$(rt_panel_rebecca_page_get)" \ + || { rt_err "panel rebecca: cannot read subscription_page_template back after restoring"; return 1; } + [ "$nowpage" = "$page" ] || { + rt_err "panel rebecca: after restoring, subscription_page_template is '$nowpage', expected '$page'" + return 1; } + nowdir="$(rt_panel_rebecca_dir_get)" \ + || { rt_err "panel rebecca: cannot read custom_templates_directory back after restoring"; return 1; } + [ "$nowdir" = "$dstate:$dir" ] || { + rt_err "panel rebecca: after restoring, custom_templates_directory is not the recorded value" + return 1; } + return 0 } rt_panel_rebecca_restore_state() { diff --git a/tests/installer-panel-3xui.test.mjs b/tests/installer-panel-3xui.test.mjs index 389903a..d37cf9a 100644 --- a/tests/installer-panel-3xui.test.mjs +++ b/tests/installer-panel-3xui.test.mjs @@ -907,19 +907,27 @@ test('a post-mutation failure rolls back once, restoring the selection and the s assert.equal(r.code, 0, r.err); const got = new Map(r.out.split('\n').filter(Boolean).map((l) => l.split('|'))); assert.equal(got.get('rc'), '1', 'the transaction must fail'); - /* The engine reports FAILED, not ROLLED_BACK, and that is the CONSERVATIVE, - * contract-correct outcome rather than a defect: it claims ROLLED_BACK only - * after its post-restore static check passes, and for a selection-based - * panel that check asks the INSTALL question ("does the panel serve - * Row-Template?"), to which the honest answer after a rollback is no. The - * engine therefore declines to claim a clean rollback it cannot confirm -- - * it never over-reports. The limitation is recorded in - * INSTALLER-PANEL-3XUI.md; what matters here is what the rollback DID. */ - assert.equal(got.get('state'), 'FAILED', - 'the engine must not claim a clean rollback it cannot confirm'); + /* ROLLED_BACK, and this assertion was CORRECTED in 1.3.0. The engine used to + * report FAILED here, and that was a real defect rather than the + * conservative behaviour it was documented as: the engine ran the FORWARD + * static check after the restore -- "does this panel serve Row-Template?" -- + * and a rollback takes Option A, restoring the panel's PREVIOUS selection, + * so the honest answer is no BY DESIGN. The check therefore failed on every + * correct rollback and a clean rollback was recorded as a failed one. + * + * The engine no longer asks a forward question after a rollback. The + * obligation to verify a restore belongs to the layer that owns the state + * model, and interface.sh already says a panel returns SUCCESS only when + * "the operation completed AND its required verification passed" -- so the + * adapter reads the state back and compares it with the record, and the + * engine trusts that status. The assertions below pin both halves: the + * engine must now CLAIM the clean rollback, and the panel must actually be + * back in its recorded state. */ + assert.equal(got.get('state'), 'ROLLED_BACK', + 'a verified rollback must be recorded as ROLLED_BACK, not FAILED'); assert.equal(got.get('rollback'), '1', 'rollback is attempted exactly once'); - assert.equal(got.get('rollbackfail'), '1', - 'and the engine reports the post-restore check it could not satisfy'); + assert.equal(got.get('rollbackfail'), '0', + 'a rollback whose restore landed must not be reported as a failed rollback'); const map = new Map(dbRows(fx)); assert.equal(map.get('subThemeDir'), '/original', @@ -931,6 +939,48 @@ test('a post-mutation failure rolls back once, restoring the selection and the s } }); +test('a rollback whose restore does not land is reported as a failed rollback', () => { + /* The other half of the correction: dropping the engine's forward check must + * not make the engine blind. A restore that reports FAILURE is still a failed + * rollback, and the engine must say so rather than claiming a clean one. + * + * Both writes must fail, so the injected failure is made to REPEAT. The shim + * only fires while its mark file does not exist; pointing the mark at a path + * inside a directory that does not exist keeps it permanently absent, so + * every UPDATE fails -- the install's (which triggers the rollback) and the + * restore's (which is the failure under test). Matching on UPDATE rather than + * on the value leaves every SELECT alone, so capture and the read-backs still + * see a working database. */ + const fx = makeFixture({ rows: [['subThemeDir', '/original'], ['subPort', '2096']], service: 'active' }); + try { + const r = sh(` + RT_3XUI_SQL_FAIL_ONCE='UPDATE' ; export RT_3XUI_SQL_FAIL_ONCE + RT_3XUI_SQL_FAIL_MARK="$D_WORK/absent-dir/mark" ; export RT_3XUI_SQL_FAIL_MARK + rc=0 + rt_transaction_run 3xui "$RT_ROOT/dist/template.html" >/dev/null 2>"$D_WORK/txn.log" || rc=$? + printf 'rc|%s\\n' "$rc" + printf 'state|%s\\n' "$RT_TXN_STATE" + printf 'rollback|%s\\n' "$(LC_ALL=C grep -cE 'transaction:rollback$' "$D_WORK/txn.log" || true)" + printf 'rollbackfail|%s\\n' "$(LC_ALL=C grep -c 'transaction:rollback-failed' "$D_WORK/txn.log" || true)" + exit 0 + `, { fx }); + assert.equal(r.code, 0, r.err); + const got = new Map(r.out.split('\n').filter(Boolean).map((l) => l.split('|'))); + assert.equal(got.get('rc'), '1', 'the transaction must fail'); + assert.equal(got.get('state'), 'FAILED', + 'a restore that did not land is a failed rollback, and must be reported as one'); + assert.equal(got.get('rollback'), '1', 'rollback is still attempted exactly once'); + assert.equal(got.get('rollbackfail'), '1', + 'and the engine must report it, so the two outcomes stay distinguishable'); + /* No second recovery, and no invented claim: the panel is left as the failed + * attempt left it, and the unrelated row is still untouched. */ + const map = new Map(dbRows(fx)); + assert.equal(map.get('subPort'), '2096', 'unrelated rows are never touched by a failed rollback'); + } finally { + rmSync(fx.base, { recursive: true, force: true }); + } +}); + test('the user-facing format-1 rollback path is untouched by P5A', () => { /* P5A adds an adapter. It does not re-wire the production rollback, which must * keep reading the format-1 namespace only. */ diff --git a/tests/installer-transaction.test.mjs b/tests/installer-transaction.test.mjs index 2c998c9..6b13135 100644 --- a/tests/installer-transaction.test.mjs +++ b/tests/installer-transaction.test.mjs @@ -109,9 +109,19 @@ const FLOCK_SHIM = [ * never read capabilities" and "the engine never took a snapshot". Appending to * a file survives the subshell. * - * The two verification counters let a scenario distinguish the engine's - * FORWARD static check from the ROLLBACK one, which is what makes "rollback - * exactly once" and "a failed rollback does not retry" observable. */ + * The verification counters make the CALL SEQUENCE observable, which is what + * pins "rollback exactly once" and "a failed rollback does not retry". + * + * STATIC IS CALLED EXACTLY ONCE, and that is the 1.3.0 correction rather than + * an accident: the engine used to run the forward static check a second time + * AFTER a rollback, asking "does this panel serve Row-Template?" -- a question + * whose honest answer after a correct rollback is no, because rollback restores + * the panel's PREVIOUS selection. That made every good rollback look failed. + * The rollback's verification now lives in the panel layer (restore_state + * returns SUCCESS only once it has read the state back), so D_VSTATIC drives + * the single forward check and nothing else. The live counter still has two + * call sites, because live verification runs on the forward path and again + * after a rollback as evidence. */ const DOUBLES = [ 'double() { printf "%s\\n" "$1" >> "${RT_TXN_LOGFILE:-/dev/null}"; }', 'rt_panel_detect() { double detect; return "${D_DETECT:-0}"; }', @@ -121,8 +131,7 @@ const DOUBLES = [ 'rt_panel_verify() {', ' double "verify:$2"', ' case "$2" in', - ' static) D_VS_N=$(( ${D_VS_N:-0} + 1 ))', - ' if [ "$D_VS_N" -eq 1 ]; then return "${D_VSTATIC:-0}"; else return "${D_VSTATIC2-${D_VSTATIC:-0}}"; fi ;;', + ' static) D_VS_N=$(( ${D_VS_N:-0} + 1 )); return "${D_VSTATIC:-0}" ;;', ' live) D_VL_N=$(( ${D_VL_N:-0} + 1 ))', ' if [ "$D_VL_N" -eq 1 ]; then return "${D_VLIVE:-0}"; else return "${D_VLIVE2-${D_VLIVE:-0}}"; fi ;;', ' esac', @@ -264,13 +273,14 @@ const SCENARIOS = [ ['live-fail', '3xui', ['D_VLIVE=1']], ['no-live-capability', '3xui', ['D_CAPS=file_placement static_verify']], ['install-fail', '3xui', ['D_INSTALL=1']], - ['static-fail', '3xui', ['D_VSTATIC=1', 'D_VSTATIC2=0']], - ['static-unavailable', '3xui', ['D_VSTATIC=2', 'D_VSTATIC2=0']], - ['static-not-applicable', '3xui', ['D_VSTATIC=3', 'D_VSTATIC2=0']], + ['static-fail', '3xui', ['D_VSTATIC=1']], + ['static-unavailable', '3xui', ['D_VSTATIC=2']], + ['static-not-applicable', '3xui', ['D_VSTATIC=3']], ['rollback-restore-fail', '3xui', ['D_INSTALL=1', 'D_RESTORE=1']], - /* Placement fails, so the rollback's static check is the FIRST one this - * scenario makes -- D_VSTATIC, not D_VSTATIC2. */ - ['rollback-verify-fail', '3xui', ['D_INSTALL=1', 'D_VSTATIC=1']], + /* A restore that reports UNAVAILABLE is ALSO a failed rollback. The panel was + * not returned to its recorded state, and the engine must not call that a + * clean rollback merely because the status was not FAILURE. */ + ['rollback-restore-unavailable', '3xui', ['D_INSTALL=1', 'D_RESTORE=2']], ['pasarguard-ok', 'pasarguard', []], ['rebecca-ok', 'rebecca', []], ]; @@ -293,7 +303,7 @@ function runTable() { ' # against ${rest} -- not ${row} -- is what detects that case; comparing', ' # against ${row} never matches and would export the PANEL as a variable.', ' if [ "$assigns" = "$rest" ]; then assigns=""; fi', - ' unset D_DETECT D_CAPS D_CAPS_RC D_BACKUP D_INSTALL D_VSTATIC D_VSTATIC2 D_VLIVE D_VLIVE2 D_RESTORE D_SNAP', + ' unset D_DETECT D_CAPS D_CAPS_RC D_BACKUP D_INSTALL D_VSTATIC D_VLIVE D_VLIVE2 D_RESTORE D_SNAP', ' : > "$RT_TXN_LOGFILE"', ' D_VS_N=0; D_VL_N=0', ' if [ -n "$assigns" ]; then', @@ -620,13 +630,37 @@ test('a placement failure after the mutation boundary triggers rollback exactly assert.equal(row.mutated, 1); assert.equal(countCall('install-fail', 'restore'), 1, 'exactly one restore'); assert.equal(E('install-fail').filter((e) => e === 'rollback').length, 1); - /* The FORWARD static check is never reached: placement failed first. The - * rollback's own static check does run, so the distinguishing fact is the - * call immediately following placement. */ + /* The FORWARD static check is never reached: placement failed first, so the + * call immediately following placement is the rollback. */ assert.equal(calls('install-fail')[calls('install-fail').indexOf('install') + 1], 'restore', 'placement failure must go straight to rollback, not to verification'); }); +/* THE REGRESSION GUARD for the 1.3.0 rollback correction. + * + * The engine used to run the FORWARD static check after every rollback and + * treat a non-zero result as a failed rollback. Because rollback restores the + * panel's PREVIOUS selection, that check answers "no" on every correct + * rollback, so a clean rollback was reported as a failure -- and, worse, the + * engine recorded FAILED instead of ROLLED_BACK. The panel layer now verifies + * its own restore, and the engine must NOT re-ask the forward question. + * + * This asserts the absence of the call, not merely its outcome, because the + * outcome is what made the defect invisible: with the doubles below, a second + * static call returning SUCCESS looks harmless, and it is only the call + * sequence that shows the engine asked a question it had no right to ask. */ +test('a rollback never re-runs the forward static verification', () => { + for (const label of ['install-fail', 'static-fail', 'live-fail']) { + const seq = calls(label); + const restoreAt = seq.indexOf('restore'); + assert.ok(restoreAt >= 0, `${label}: the rollback must have run`); + assert.equal(seq.slice(restoreAt).includes('verify:static'), false, + `${label}: the engine must not ask the forward question after a rollback`); + assert.equal(countCall(label, 'verify:static') <= 1, true, + `${label}: static verification has exactly one call site, on the forward path`); + } +}); + test('a rollback whose restore fails is reported, and does NOT trigger another recovery attempt', () => { const row = R('rollback-restore-fail'); assert.equal(row.rc, 1); @@ -637,12 +671,19 @@ test('a rollback whose restore fails is reported, and does NOT trigger another r assert.equal(E('rollback-restore-fail').filter((e) => e === 'rollback-failed').length, 1); }); -test('a rollback whose static verification fails is reported as a rollback failure', () => { - const row = R('rollback-verify-fail'); +test('a rollback whose restore is UNAVAILABLE is reported as a rollback failure', () => { + /* UNAVAILABLE is not FAILURE on the forward path -- there it means "cannot be + * checked here" and must not roll a good change back. On the ROLLBACK path + * the meaning is different: the panel was not returned to its recorded state, + * and "we could not put it back" is not a clean rollback. The engine + * therefore reports it, and the two outcomes stay distinguishable. */ + const row = R('rollback-restore-unavailable'); assert.equal(row.rc, 1); - assert.equal(row.state, 'FAILED'); - assert.equal(countCall('rollback-verify-fail', 'restore'), 1); - assert.equal(E('rollback-verify-fail').filter((e) => e === 'rollback-failed').length, 1); + assert.equal(row.state, 'FAILED', + 'a restore that could not be performed leaves the panel unrestored'); + assert.equal(countCall('rollback-restore-unavailable', 'restore'), 1); + assert.equal(E('rollback-restore-unavailable').filter((e) => e === 'rollback').length, 1); + assert.equal(E('rollback-restore-unavailable').filter((e) => e === 'rollback-failed').length, 1); }); test('a malformed safety snapshot is refused before restore is attempted', () => { From 2ae89f5f306a6278e3ea7943685e502a50b6fe5e Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Sat, 26 Sep 2026 05:51:30 +0330 Subject: [PATCH 11/25] =?UTF-8?q?docs:=20describe=201.3.0=20=E2=80=94=20Pa?= =?UTF-8?q?sarGuard,=20Rebecca,=20Meter=20and=20Notebook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit READMEs (five languages) and the docs site (English, Persian, Arabic) now describe all three panels as supported: what activation changes on each, the manual Rebecca step for MySQL/MariaDB, RT_PANEL on multi-panel hosts, uninstall per panel, the upgrade path from 1.1.0 and 1.2.x, and the known limits (no live refresh on PasarGuard/Rebecca, no subTitle/Clash). Every command, path and URL is byte-identical across the translations. The compatibility matrix now has the seven capability columns (detect, install, activate, verify, backup, restore, uninstall), and the test that holds it to the installer checks all seven; "Supported" needs every one. CHANGELOG gains the 1.3.0 section (1.2.1 was never released on its own and ships inside it); PROVENANCE covers the per-panel pages and where Meter and Notebook came from. The PasarGuard manual-activation text now names both keys and where to copy the page. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 138 +++++++++++++- PROVENANCE.md | 17 +- README.ar.md | 101 ++++++---- README.fa.md | 101 ++++++---- README.md | 101 ++++++---- README.ru.md | 101 ++++++---- README.zh-CN.md | 101 ++++++---- docs/src/content/docs/ar/compatibility.mdx | 176 +++++++++-------- docs/src/content/docs/ar/configuration.mdx | 32 ++-- docs/src/content/docs/ar/getting-started.mdx | 26 ++- docs/src/content/docs/ar/index.mdx | 6 +- docs/src/content/docs/ar/installation.mdx | 45 ++++- docs/src/content/docs/ar/security.mdx | 21 ++- docs/src/content/docs/compatibility.mdx | 188 +++++++++++-------- docs/src/content/docs/configuration.mdx | 40 ++-- docs/src/content/docs/fa/compatibility.mdx | 185 ++++++++++-------- docs/src/content/docs/fa/configuration.mdx | 36 ++-- docs/src/content/docs/fa/getting-started.mdx | 29 +-- docs/src/content/docs/fa/index.mdx | 6 +- docs/src/content/docs/fa/installation.mdx | 49 ++++- docs/src/content/docs/fa/security.mdx | 22 ++- docs/src/content/docs/fa/troubleshooting.mdx | 81 +++++++- docs/src/content/docs/getting-started.mdx | 27 ++- docs/src/content/docs/index.mdx | 6 +- docs/src/content/docs/installation.mdx | 52 ++++- docs/src/content/docs/security.mdx | 24 ++- docs/src/content/docs/troubleshooting.mdx | 85 ++++++++- installer/lib/row-template.sh | 7 +- tests/panel-support.test.mjs | 63 +++++-- tools/make-release.sh | 8 +- 30 files changed, 1313 insertions(+), 561 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad2daf2..b036681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,140 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.2.1] - Unreleased +## [1.3.0] - 2026-09-25 + +Row-Template now installs on **PasarGuard** and **Rebecca** as well as 3X-UI, +and ships two more designs. A minor release: nothing changes for an existing +3X-UI install except what is listed below, and Row stays the default design. +It also carries every fix prepared for 1.2.1, which was not released on its +own. + +### Added + +- **PasarGuard support.** PasarGuard is supported from this release: detect, + install, activate, verify, back up, restore and uninstall, on the official + Docker install and on a source install (`pasarguard.service`). The page is + placed at `/var/lib/pasarguard/templates/row-template/index.html` (or inside + your own `CUSTOM_TEMPLATES_DIRECTORY`) and selected by one marked block + appended to `/opt/pasarguard/.env`; a running panel is restarted once. None + of your own `.env` lines is edited, and uninstall returns the file to its + exact previous bytes. `row-template verify` also reports the two panel + settings that still take precedence over the page: an admin's own + `sub_template`, and `disable_sub_template`. +- **Rebecca support.** Rebecca is supported from this release, with the same + seven operations. The page is placed at + `/var/lib/rebecca/templates/row-template/index.html` (or inside your own + custom templates directory) and selected in the newest + `subscription_settings` row, which Rebecca reads on every request — so + nothing is ever restarted. Activation is automatic with the default SQLite + database and `sqlite3`; with MySQL/MariaDB the page is still placed and the + installer prints the two values to enter in the dashboard. `NULL`, empty and + a set templates directory are each restored exactly. +- **Panel detection and choice.** The installer finds the panel on the server + and installs for it (`/etc/3x-ui/sub_templates/row-template` for 3X-UI, + `/etc/row-template` for PasarGuard and Rebecca). A panel counts only when two + independent signals agree; a half-installed panel is refused, not guessed at. + On a server with more than one panel it asks, or reads + `RT_PANEL=3xui|pasarguard|rebecca` in a script. +- **Transactional activation on PasarGuard and Rebecca.** The panel's state is + snapshotted, changed and verified; if any step fails it is restored exactly, + and the installer says so — and shows the real cause. +- **Two new designs: Meter and Notebook.** Meter is a calm instrument + dashboard of rounded cards with a segmented traffic meter; Notebook is a + page from a dotted notebook, hand-inked. Both were contributed by the + project's author, ported onto the shared runtime, and held to the same + contract as the other fifteen — seventeen designs in all, on every panel. +- **Every design, for every panel.** Each release now carries a PasarGuard + (Jinja2) and a Rebecca (pongo2) page for every design, under `shells/`, + checksum-verified like the 3X-UI pages. + +### Fixed + +- **Rolling back to a backup taken under 1.1.0 works.** 1.2.x refused it with + "backup artifact matches no installed template". A backup that names its + design is restored as that design; one that does not (1.1.0's) is restored + as Row. +- **A successful rollback is reported as a success.** The transaction engine + checked, after restoring the panel, that the panel was still pointing at + Row-Template's directory — which is exactly the state a correct rollback has + just undone. Every rollback therefore ended in "the rollback failed" even + when the panel had been restored perfectly. The engine no longer asks that + question: the restore verifies itself. Each panel adapter now re-reads the + panel's own setting after restoring and confirms it matches the value it + recorded before changing anything, and a restore that does not land is + reported as a failed rollback with the real cause. A regression test pins + this: the engine must never re-run the forward check after a restore. +- **The manual PasarGuard instructions are complete.** When activation cannot + be done automatically, the installer printed only `SUBSCRIPTION_PAGE_TEMPLATE` + and told you to edit `.env` — but the page had not been copied anywhere the + panel could read. It now prints both the copy and the two `.env` values + (`CUSTOM_TEMPLATES_DIRECTORY` and `SUBSCRIPTION_PAGE_TEMPLATE`), and says to + keep your own templates directory if you already have one. +- All fixes prepared for 1.2.1 (below): one `row-template update` is enough to + move from 1.1.0, misplaced designs are moved back, branding works on an + install the 1.1.0 updater left incomplete, and `verify` names missing and + damaged designs. + +### Security + +- **Every value is escaped on every panel.** PasarGuard renders pages with a + non-sandboxed Jinja2 whose autoescaping is off. Every PasarGuard and Rebecca + page therefore wraps its body in an explicit autoescape block, and is tested + with the panels' real engines against hostile usernames, notes, links and + malformed data. +- **Branding can never open a template tag.** `{` and `}` in your service name, + support link or logo are written as `{` and `}`, so no branding + value can start a Jinja2 or pongo2 expression. +- **Panel secrets stay where they are.** PasarGuard's `.env` and Rebecca's + database URL are read only for the keys the installer needs, never printed, + and never copied into a backup. A MySQL/MariaDB password is never asked for + or read. +- **Backups record their panel** and are never restored onto another one. + +### Changed + +- `row-template version` shows the panel it serves; on 3X-UI it still shows the + minimum-supported and detected versions. +- `row-template uninstall` returns each panel to the page it had before + Row-Template, and leaves a page you chose afterwards alone. +- The `on_hold` state on PasarGuard and Rebecca is shown as active: with its + "starts on first connection" duration on PasarGuard, and with an unknown + expiry on Rebecca, which does not give the page that duration + (`docs/design/PANEL-ON-HOLD-DECISION.md`). + +### Known limitations + +- On PasarGuard and Rebecca the page shows the values as of when it was opened; + live refresh (`?format=info`) is 3X-UI only, because both panels serve live + status on a path suffix. +- PasarGuard's page title (`subTitle`) and Clash templates are not produced. +- Rebecca on MySQL/MariaDB needs its one setting entered in the dashboard. + +### Documentation + +- The compatibility page, installation, configuration and troubleshooting + cover all three panels, in English, Persian and Arabic; the READMEs in all + five languages describe PasarGuard and Rebecca as supported. +- `docs/design/PASARGUARD-INSTALLER-AUDIT.md` and + `docs/design/REBECCA-INSTALLER-AUDIT.md` record, from each panel's source, + what activation is and how the installer follows it. + +### Development + +- The test suite renders the PasarGuard and Rebecca pages with the real + engines, and needs Python 3 with Jinja2 as well as Go; a missing engine is a + failure, never a skip. +- `tools/make-release.sh` writes checksums in the text form on every platform. + +### Upgrading + +- From **1.2.0** or **1.1.0** on 3X-UI: run `row-template update`. From 1.1.0, + the next `row-template`, `row-template config` or `row-template verify` + completes the install. Your design, branding and panel wiring are kept. +- On **PasarGuard** or **Rebecca**: run the installer. Earlier releases did not + install on these panels. + +## [1.2.1] - Unreleased (shipped in 1.3.0) Fixes the update from 1.1.0, which could leave the manager with no designs to choose from. 3X-UI (>= 3.6.0) stays the only supported panel. @@ -227,7 +360,8 @@ First stable release. - Requires 3X-UI (MHSanaei) **>= 3.6.0**; validated against stock 3.7.0. - Recommended operating system: Ubuntu 24.04 LTS (x86_64). -[1.2.1]: https://github.com/iitzSeriZdev/Row-Template/compare/v1.2.0...main +[1.3.0]: https://github.com/iitzSeriZdev/Row-Template/releases/tag/v1.3.0 +[1.2.1]: https://github.com/iitzSeriZdev/Row-Template/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/iitzSeriZdev/Row-Template/releases/tag/v1.2.0 [1.1.0]: https://github.com/iitzSeriZdev/Row-Template/releases/tag/v1.1.0 [1.0.0]: https://github.com/iitzSeriZdev/Row-Template/releases/tag/v1.0.0 diff --git a/PROVENANCE.md b/PROVENANCE.md index 58d6a7a..02ef59b 100644 --- a/PROVENANCE.md +++ b/PROVENANCE.md @@ -13,7 +13,7 @@ Every release published on GitHub carries these assets: | ----- | ------- | | `row-template-.tar.gz` | The runtime payload — see below. | | `SHA256SUMS` | The SHA-256 checksum of the tarball above. | -| `manifest.txt` | Plain-text metadata (`name`, `version`, `artifact`, `min_xui`, `created`), parsed as data — never executed. | +| `manifest.txt` | Plain-text metadata (`name`, `version`, `artifact`, `min_xui`, `created`), parsed as data — never executed. `min_xui` applies to 3X-UI only. | | `install.sh` | The bootstrap used by the one-command installer. | The tarball expands to a single `row-template-/` directory: @@ -21,12 +21,21 @@ The tarball expands to a single `row-template-/` directory: | Path | Contents | | ---- | -------- | | `template.html` | The Row design, the page an older installed version updates against. | -| `templates//template.html` (+ `.sha256`) | Every selectable design, each with its own checksum. | -| `shells///shell.html` (+ `.sha256`) | Each design's page shell per panel, packaged for research; the installer does not place them. | +| `templates//template.html` (+ `.sha256`) | Every selectable design for 3X-UI, each with its own checksum. | +| `shells///shell.html` (+ `.sha256`) | Every design for PasarGuard (Jinja2) and Rebecca (pongo2), each with its own checksum. The installer places the one you select, and refuses a page built for another panel or by a release before 1.3.0. | | `VERSION`, `install.sh`, `lib/`, `bin/` | The version, the installer and the `row-template` manager. | -| `panels/` | The panel interface layer the manager loads; installed next to `lib/`. | +| `panels/` | The panel interface and one adapter per panel (`3xui.sh`, `pasarguard.sh`, `rebecca.sh`); installed next to `lib/`. | | `SHA256SUMS` | The checksum of every payload file, so the contents can be checked after extraction as well. | +Every design is built from this repository's own sources (`src/`). Meter and +Notebook (1.3.0) were contributed by the project's author and ported onto the +shared runtime; like every other design they contain no third-party code beyond +the bundled QR generator and font listed in the README's License section. The +PasarGuard and Rebecca pages contain no code from either panel: both panels are +AGPL-3.0, so the preludes and the test harnesses that render them with the +panels' real engines are independent implementations, written from the source +audits in `docs/design/`. + The build is deterministic: the same sources always produce a byte-identical `row-template-.tar.gz`. Anyone can rebuild it from a checkout with `tools/make-release.sh` (which needs Node.js to build the designs) and compare diff --git a/README.ar.md b/README.ar.md index 7a37137..b4ff1a9 100644 --- a/README.ar.md +++ b/README.ar.md @@ -6,7 +6,7 @@

- صفحة اشتراك مصقولة ومكتفية ذاتيًا للوحات 3X-UI — خمسة عشر تصميمًا، كلٌّ منها ملف HTML واحد، قابلة لإعادة التسمية بالكامل (white-label)، ودون أي طلبات إلى أطراف ثالثة من الصفحة التي يفتحها مشتركوك. + صفحة اشتراك مصقولة ومكتفية ذاتيًا للوحات 3X-UI وPasarGuard وRebecca — سبعة عشر تصميمًا، كلٌّ منها ملف HTML واحد، قابلة لإعادة التسمية بالكامل (white-label)، ودون أي طلبات إلى أطراف ثالثة من الصفحة التي يفتحها مشتركوك.

@@ -16,7 +16,7 @@

License Latest release - Panel + Panels Platform Documentation

@@ -33,21 +33,21 @@ ## ما هو Row-Template؟ -يستطيع 3X-UI أن يعرض على المشتركين صفحة مخصّصة بدلًا من صفحته المدمجة. وRow-Template هو تلك الصفحة: يفتح المشترك رابط اشتراكه فيرى باقته واستهلاكه وتاريخ انتهاء اشتراكه، مع طرق لإضافة الاشتراك بلمسة واحدة إلى التطبيق الذي يستخدمه. +تستطيع كلٌّ من 3X-UI وPasarGuard وRebecca أن تعرض على المشتركين صفحة مخصّصة بدلًا من صفحتها المدمجة. وRow-Template هو تلك الصفحة: يفتح المشترك رابط اشتراكه فيرى باقته واستهلاكه وتاريخ انتهاء اشتراكه، مع طرق لإضافة الاشتراك بلمسة واحدة إلى التطبيق الذي يستخدمه. -يُقدَّم كل تصميم في ملف HTML واحد مكتفٍ ذاتيًا، تُضمَّن فيه جميع الأنماط والسكربتات والخطوط ومولّد رمز QR. يثبّته أمر واحد بجوار لوحتك، ويوجّه اللوحة إليه، ويمنحك المدير `row-template` لإدارة العلامة التجارية والتحديثات والتراجع. +يُقدَّم كل تصميم في ملف HTML واحد مكتفٍ ذاتيًا، تُضمَّن فيه جميع الأنماط والسكربتات والخطوط ومولّد رمز QR، ومعه نسخة من كل تصميم بلغة قوالب كل لوحة. يكتشف أمر واحد لوحتك، ويثبّت الصفحة بجوارها، ويوجّه اللوحة إليها، ويمنحك المدير `row-template` لإدارة العلامة التجارية والتحديثات والتراجع. ## لماذا Row-Template؟ - **الخصوصية في صميم التصميم.** الصفحة التي يفتحها مشتركوك لا ترسل أي طلبات إلى أطراف ثالثة. تُولَّد رموز QR داخل الصفحة نفسها، وتُحقن علامتك التجارية كنص — لا تُنفَّذ أبدًا ولا تُرسل إلى أي مكان. - **إعادة تسمية حقيقية بالكامل.** اسم خدمتك، ورابط الدعم الخاص بك، وشعارك. لا شيء في الصفحة المعروضة يشير إلى Row-Template. -- **خمسة عشر تصميمًا، كلٌّ في ملف واحد.** اختر المظهر الذي يناسب خدمتك. جميع التصاميم تتشارك المزايا واللغات وفحوص الأمان نفسها. +- **سبعة عشر تصميمًا، كلٌّ في ملف واحد.** اختر المظهر الذي يناسب خدمتك. جميع التصاميم تتشارك المزايا واللغات وفحوص الأمان نفسها — على كل لوحة مدعومة. - **مصمَّم لمشتركيك.** عرض حيّ للاستهلاك وتاريخ الانتهاء، واستيراد بلمسة واحدة إلى التطبيقات الشائعة، وقائمة قابلة للبحث بالإعدادات الفردية لإضافة خادم واحد يدويًا. -- **آمن في التشغيل.** إصدارات يُتحقَّق من مجموعها الاختباري، وتفعيل ذرّي، وتراجع بأمر واحد. لا يعدّل 3X-UI أبدًا: الإعداد الوحيد الذي يغيّره في اللوحة هو مجلد صفحة الاشتراك (`subThemeDir`). +- **آمن في التشغيل.** إصدارات يُتحقَّق من مجموعها الاختباري، وتفعيل على هيئة معاملة يعيد اللوحة إلى حالتها بدقة إن فشلت أي خطوة، وتراجع بأمر واحد. لا يعدّل لوحتك أبدًا: في 3X-UI يغيّر إعدادًا واحدًا (`subThemeDir`)، وفي PasarGuard يضيف كتلة معلَّمة واحدة إلى `.env`، وفي Rebecca يضبط حقلين من إعدادات الاشتراك. ## التصاميم -يأتي Row-Template 1.2.0 بخمسة عشر تصميمًا، والتصميم الافتراضي هو Row. +يأتي Row-Template 1.3.0 بسبعة عشر تصميمًا، والتصميم الافتراضي هو Row. @@ -71,6 +71,10 @@ + + + +
Terminal Nova
Terminal Nova
Arcade Nova
Arcade Nova
Meter
Meter
Notebook
Notebook
المعاينات مولَّدة من بيانات المشروع النموذجية. معاينات سطح المكتب والهاتف لكل تصميم موجودة في معرض القوالب. @@ -81,7 +85,7 @@ **لمشتركيك** -- **حالة حيّة.** حالة الباقة، والبيانات المستهلكة والمتبقية، وتاريخ الانتهاء، تُحدَّث من لوحتك طالما كانت الصفحة ظاهرة. +- **حالة حيّة.** حالة الباقة، والبيانات المستهلكة والمتبقية، وتاريخ الانتهاء، تُحدَّث من لوحتك طالما كانت الصفحة ظاهرة (في 3X-UI؛ أما في PasarGuard وRebecca فتعرض الصفحة القيم لحظة فتحها). - **استيراد بلمسة واحدة** إلى التطبيقات الشائعة، مرتّبة حسب المنصة: v2rayNG وHapp وsing-box على Android؛ وStreisand وV2Box وShadowrocket على iOS؛ وClash Verge Rev وMihomo Party وv2rayN على Windows؛ وClash Verge Rev وStreisand وV2Box على macOS. - **النسخ ورمز QR.** انسخ رابط الاشتراك أو امسحه كرمز QR يُولَّد داخل الصفحة. - **مستكشف الإعدادات.** كل خادم في صف خاص به، مع علم الدولة أو شارة بالأحرف الأولى (monogram) ووسم البروتوكول (VLESS وVMess وTrojan وShadowsocks وHysteria/Hysteria2 وWireGuard وAmneziaWG وTelegram MTProto)، مع رمز QR ونسخ لكل إعداد، وبحث في القوائم الطويلة. @@ -98,17 +102,17 @@ - **لا طلبات إلى أطراف ثالثة** من الصفحة المعروضة: لا شبكات CDN، ولا خدمات خارجية لرموز QR أو تحديد الموقع، ولا قياس عن بُعد (telemetry). تأتي الحالة الحيّة من لوحتك أنت. - **تحقق SHA-256 إلزامي** لكل تنزيل لإصدار، دون أي خيار لتجاوزه. - **تفعيل ذرّي.** تُولَّد الصفحة الجديدة ويُتحقَّق منها قبل أن تحل محل الصفحة الحالية، فلا تترك خطوة فاشلة صفحة معطوبة قيد العمل. -- **كشف حذر للوحة.** إذا لم تكن قاعدة بيانات اللوحة التي يعثر عليها Row-Template قاعدة بيانات SQLite صالحة، فإنه يرفض استخدامها بدلًا من تخمين قاعدة بيانات أخرى. +- **كشف حذر للوحة.** لا تُعدّ اللوحة مثبّتة إلا حين تتفق إشارات مستقلة؛ واللوحة المثبّتة جزئيًا، أو قاعدة بيانات اللوحة التي ليست قاعدة بيانات SQLite صالحة، تُرفض بدلًا من التخمين. ## اللوحات المدعومة | اللوحة | الحالة | ملاحظات | | ----- | ------ | ----- | | [3X-UI](https://github.com/MHSanaei/3x-ui) (MHSanaei) | ✅ مدعومة | تتطلب الإصدار **>= 3.6.0** | -| [PasarGuard](https://github.com/PasarGuard/panel) | 🔬 قيد البحث | غير مدعومة؛ لا يوجد مسار تثبيت | -| [Rebecca](https://github.com/rebeccapanel/Rebecca) | 🔬 قيد البحث | غير مدعومة؛ لا يوجد مسار تثبيت | +| [PasarGuard](https://github.com/PasarGuard/panel) | ✅ مدعومة منذ 1.3.0 | التثبيت الرسمي عبر Docker أو التثبيت من المصدر (`pasarguard.service`) | +| [Rebecca](https://github.com/rebeccapanel/Rebecca) | ✅ مدعومة منذ 1.3.0 | تفعيل تلقائي مع SQLite و`sqlite3`؛ ومع MySQL/MariaDB إعداد واحد يُدخَل في لوحة التحكم | -3X-UI هي اللوحة الوحيدة المدعومة. تستخدم PasarGuard وRebecca محرّكَي قوالب مختلفين (Jinja2 وpongo2)؛ يُبنى هيكل صفحة كل تصميم لهما ويُحزَم في الإصدار لأغراض الدراسة، لكن المثبّت لا يضعه في مكانه ولا توجد تعليمات تثبيت لهما. راجع [التوافق](https://iitzseridev.github.io/Row-Template/ar/compatibility/) للاطلاع على نتائج البحث. +تستخدم اللوحات الثلاث ثلاثة محرّكات قوالب مختلفة — `html/template` في Go وJinja2 وpongo2 — لذا يُبنى كل تصميم مرة لكل لوحة، ويُختبر كل إصدار منه بعرضه بمحرّك تلك اللوحة الحقيقي. يكتشف المثبّت اللوحة الموجودة على الخادم؛ وعلى خادم فيه أكثر من لوحة يسألك (أو يقرأ `RT_PANEL`). **مدعومة** تعني توفّر القدرات السبع كلها على تلك اللوحة — الاكتشاف والتثبيت والتفعيل والتحقق والنسخ الاحتياطي والاستعادة وإلغاء التثبيت — ويختبر كلًّا منها مجموعة الاختبارات. راجع [التوافق](https://iitzseridev.github.io/Row-Template/ar/compatibility/) لتفاصيل كل لوحة. ## البنية @@ -116,14 +120,14 @@ flowchart TB subgraph build ["Build and release"] direction LR - SRC["src/
runtime, styles, locales,
15 design layouts"] --> BUILD["tools/build.mjs"] - BUILD --> ART["One self-contained
HTML file per design"] + SRC["src/
runtime, styles, locales,
17 design layouts"] --> BUILD["tools/build.mjs"] + BUILD --> ART["One self-contained
HTML file per design,
per panel"] ART --> REL["tools/make-release.sh
tarball + SHA256SUMS"] end - subgraph host ["Your 3X-UI server"] + subgraph host ["Your panel server"] direction LR - INST["install.sh / row-template
verify checksum, stage, validate,
back up, activate"] --> DIR["/etc/3x-ui/
sub_templates/row-template"] - DIR -- "subThemeDir" --> XUI["3X-UI renders the page
with the subscriber's data"] + INST["install.sh / row-template
verify checksum, detect panel,
back up, activate, verify"] --> DIR["3X-UI: subThemeDir
PasarGuard: .env block
Rebecca: subscription settings"] + DIR --> XUI["The panel renders the page
with the subscriber's data"] end build -- "GitHub Releases" --> host host -- "serves the page" --> BROWSER["Subscriber's browser"] @@ -131,15 +135,15 @@ flowchart TB ``` - **ملف واحد لكل تصميم.** يضمّن `tools/build.mjs` الشيفرة المشتركة والترجمات والخطوط ومولّد QR داخل تخطيط كل تصميم، ويرفض أي تخطيط ينقصه أيٌّ من نقاط الربط (hooks) التي تحتاجها الشيفرة. ثم يرفض `tools/verify.mjs` أي ملف يحمّل شيئًا من مصدر بعيد أو يحتوي على بنية محظورة. -- **اللوحة هي من تعرض الصفحة.** الصفحة قالب: يملأ 3X-UI بيانات المشترك فيها عند تقديمها، ثم تحدّث الصفحة حالتها من اللوحة نفسها. -- **لا يعدّل المثبّت 3X-UI أبدًا.** يكتب في مجلده الخاص ويغيّر إعدادًا واحدًا في اللوحة، هو `subThemeDir`، ليشير إليه. +- **اللوحة هي من تعرض الصفحة.** الصفحة قالب: تملأ اللوحة بيانات المشترك فيها عند تقديمها. في PasarGuard (Jinja2) وRebecca (pongo2) يُغلَّف كل تصميم بمقدّمة صغيرة تربط بيانات اللوحة نفسها بالصفحة وتهرّب (escape) كل قيمة. +- **لا يعدّل المثبّت لوحتك أبدًا.** في 3X-UI يوجّه `subThemeDir` إلى مجلده الخاص؛ وفي PasarGuard يضع الصفحة في مجلد القوالب ويُلحق كتلة معلَّمة واحدة بـ`.env`؛ وفي Rebecca يضع الصفحة ويضبط حقلَي الصفحة والمجلد في إعدادات الاشتراك. تُلتقط لقطة لكل تغيير قبل إجرائه، ويُستعاد بدقة إن فشل أي شيء. | المسار | المحتوى | | ---- | ---------------- | | `src/` | شيفرة الصفحة وأنماطها وترجماتها؛ كل تصميم في `src/templates//` | | `template/index.html` | صفحة Row المبنية، وهي مُضمَّنة في المستودع | | `tools/` | البناء والتحقق والإصدار وعارض الـ fixtures المكتوب بـ Go | -| `installer/` | `install.sh` والأمر `row-template` ومكتبته الإدارية | +| `installer/` | `install.sh` والأمر `row-template` ومكتبته الإدارية، ومحوّل لكل لوحة في `installer/panels/` | | `tests/` | مجموعات الاختبارات | | `docs/` | موقع التوثيق؛ سجلات التصميم في [`docs/design/`](docs/design/README.md) | @@ -147,9 +151,9 @@ flowchart TB > **نظام التشغيل المُوصى به: Ubuntu 24.04 LTS (x86_64).** قد تعمل توزيعات Linux الحديثة الأخرى لكنها لم تحظَ بالمستوى نفسه من تغطية التحقق. -**المتطلبات:** خادم يشغّل 3X-UI **>= 3.6.0**، وصلاحية root عليه، و`curl` و`tar` و`sha256sum` (متوفرة على جميع أنظمة Linux تقريبًا). ويحتاج التفعيل التلقائي أيضًا إلى `sqlite3`. +**المتطلبات:** خادم يشغّل 3X-UI **>= 3.6.0** أو PasarGuard أو Rebecca؛ وصلاحية root عليه؛ و`curl` و`tar` و`sha256sum` (متوفرة على جميع أنظمة Linux تقريبًا). ويحتاج التفعيل التلقائي في 3X-UI وRebecca أيضًا إلى `sqlite3`. -شغّل الأمر بصلاحية **root** على الخادم الذي يستضيف لوحة 3X-UI: +شغّل الأمر بصلاحية **root** على الخادم الذي يستضيف لوحتك: ```bash bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) @@ -159,7 +163,7 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d 1. ينزّل أحدث إصدار مستقر من GitHub. 2. يتحقق من مجموعه الاختباري SHA-256 (إلزامي — دون إمكانية التجاوز). -3. يستخرجه بأمان ويثبّته في `/etc/3x-ui/sub_templates/row-template`. +3. يكتشف لوحتك، ويستخرج الإصدار بأمان ويثبّته في `/etc/3x-ui/sub_templates/row-template` (3X-UI) أو `/etc/row-template` (PasarGuard وRebecca). 4. في التثبيت الجديد، يعرض أداة اختيار التصميم (يُبقي Enter على Row). 5. يطلب بيانات علامتك التجارية (اسم الخدمة، رابط الدعم، الشعار — وكلها اختيارية). 6. يولّد الصفحة ويتحقق منها، ثم يفعّلها في اللوحة حيثما أمكن. @@ -170,6 +174,12 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d RT_TEMPLATE=editorial bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) ``` +على خادم يشغّل أكثر من لوحة مدعومة، يسألك المثبّت عن اللوحة التي يخدمها؛ وفي سكربت، سمِّها عبر `RT_PANEL` (`3xui` أو `pasarguard` أو `rebecca`): + +```bash +RT_PANEL=pasarguard bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) +``` + إن كنت تفضّل عدم تمرير السكربت مباشرة من الشبكة، فنزّل ملفات الإصدار الأربعة (`install.sh`، `manifest.txt`، `SHA256SUMS`، `row-template-.tar.gz`) من [صفحة الإصدارات](https://github.com/iitzSeriZdev/Row-Template/releases/latest) إلى مجلد واحد، وتحقق من المجموع الاختباري بنفسك كما يشرح [PROVENANCE.md](PROVENANCE.md)، ثم وجّه المثبّت إلى ذلك المجلد: ```bash @@ -178,19 +188,37 @@ RT_RELEASE_DIR=/root/row-template-release bash /root/row-template-release/instal ### التفعيل -يُثبَّت Row-Template في مجلد تقدّمه اللوحة كصفحة اشتراك: +يعرض التثبيت التفاعلي ما سيغيّره التفعيل ويسألك أولًا. في PasarGuard وRebecca يجري التفعيل على هيئة معاملة: تُلتقط لقطة لحالة اللوحة، ثم يُطبَّق التغيير ويُتحقق منه، وإن فشلت أي خطوة تُستعاد اللوحة بدقة. + +**3X-UI.** يُثبَّت Row-Template في مجلد تقدّمه اللوحة كصفحة اشتراك: ``` /etc/3x-ui/sub_templates/row-template ``` -- **تلقائيًا:** عند توفر `sqlite3`، يضبطه Row-Template نيابةً عنك. يوقف خدمة اللوحة لفترة وجيزة، ويكتب الإعداد، ثم يشغّل الخدمة من جديد ويتحقق من القيمة. في التثبيت التفاعلي يعرض الإعداد الحالي ويسألك أولًا. +- **تلقائيًا:** عند توفر `sqlite3`، يضبطه Row-Template نيابةً عنك. يوقف خدمة اللوحة لفترة وجيزة، ويكتب الإعداد، ثم يشغّل الخدمة من جديد ويتحقق من القيمة. - **يدويًا:** خلاف ذلك، افتح **Panel Settings → Subscription → Profile → Sub Theme Directory** وأدخل القيمة التالية حرفيًا: ``` /etc/3x-ui/sub_templates/row-template ``` +**PasarGuard.** توضع الصفحة في `/var/lib/pasarguard/templates/row-template/index.html` (أو داخل `CUSTOM_TEMPLATES_DIRECTORY` الخاص بك إن كنت قد ضبطته)، وتُلحق كتلة معلَّمة بـ`/opt/pasarguard/.env`: + +``` +# >>> row-template (managed by Row-Template; do not edit) nl=0 >>> +CUSTOM_TEMPLATES_DIRECTORY = "/var/lib/pasarguard/templates" +SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" +# <<< row-template <<< +``` + +تقرأ PasarGuard ملف `.env` عند الإقلاع، لذا تُعاد تشغيل اللوحة العاملة مرة واحدة. لا يُعدَّل أي سطر من أسطرك؛ ويزيل إلغاء التثبيت الكتلة ويعيد `.env` إلى بايتاته السابقة بدقة. يبقى للمشرف الذي له قالب اشتراك خاص، أو لإعداد **disable subscription template**، الأولوية — ويخبرك `row-template verify` إن انطبق أيٌّ منهما. + +**Rebecca.** توضع الصفحة في `/var/lib/rebecca/templates/row-template/index.html` (أو داخل مجلد القوالب المخصّص الخاص بك)، وتُضبط إعدادات الاشتراك في Rebecca على `row-template/index.html`. تقرأ Rebecca هذه الإعدادات مع كل طلب، فلا حاجة إلى إعادة التشغيل. + +- **تلقائيًا** مع قاعدة بيانات SQLite الافتراضية وتثبيت `sqlite3`. +- **يدويًا** مع MySQL/MariaDB (أو دون `sqlite3`): تبقى الصفحة موضوعة في مكانها؛ في لوحة تحكم Rebecca افتح **Settings → Subscription → Templates** واضبط **Subscription page template** على `row-template/index.html` و**Custom templates directory** على `/var/lib/rebecca/templates`. + ## الاستخدام شغّل المدير دون أي وسائط في الطرفية لفتح القائمة التفاعلية: @@ -207,23 +235,23 @@ row-template | `row-template update` | تنزيل أحدث إصدار مستقر والتحقق منه وتفعيله (التحقق من المجموع الاختباري إلزامي) | | `row-template rollback` | استعادة إصدار سابق (`--auto` أو `--to `) | | `row-template verify` | فحص التثبيت وربط اللوحة والصفحة الحالية (وبصلاحيات root يعيد أيضًا التصاميم المفقودة أو الموضوعة في غير مكانها) | -| `row-template version` | عرض الإصدار المثبّت والحد الأدنى المدعوم وإصدار 3X-UI المكتشف | -| `row-template uninstall` | إزالة Row-Template وإعادة اللوحة إلى صفحتها المدمجة | +| `row-template version` | عرض الإصدار المثبّت واللوحة التي يخدمها (وفي 3X-UI أيضًا الحد الأدنى المدعوم والإصدار المكتشف) | +| `row-template uninstall` | إزالة Row-Template وإعادة اللوحة إلى الصفحة التي كانت لديها من قبل | | `row-template help` | عرض طريقة الاستخدام | يجب تشغيل الأوامر التي تغيّر النظام (`config` و`update` و`rollback` و`uninstall`) بصلاحية root. - **العلامة التجارية** تُخزَّن كبيانات، ولا تُنفَّذ أبدًا، وتُحقن في الصفحة كنص. اترك أي حقل فارغًا للحصول على صفحة بلا علامة تجارية. لا يقبل رابط الدعم إلا البروتوكولات التي ينبغي للمتصفح فتحها، مثل `https://…` أو `tg://…` أو `mailto:…`. - **التحديثات** تأتي من قناة الإصدارات المستقرة العامة. يطبّق `row-template update` دائمًا أحدث إصدار مستقر، حتى لو كان هو الإصدار المثبّت لديك؛ أما خيار **Update** في المدير فيقارن الإصدارات أولًا ويسأل قبل أي تغيير. إذا تعذّر الوصول إلى مصدر الإصدارات، لا يتغيّر شيء ولا يُعامَل تثبيتك أبدًا على أنه تالف. -- **التحديث من 1.1.0** يكفيه تشغيل `row-template update` مرة واحدة. ينسخ مُحدِّث 1.1.0 نفسه جزءًا فقط من الإصدار الجديد، لذا فإن التشغيل التالي لـ`row-template` أو `row-template config` أو `row-template verify` بصلاحيات root ينزّل أولًا بقية الإصدار نفسه — كل التصاميم، مع التحقق من checksum. -- **التراجع** يستعيد إصدارًا سابقًا من نسخة احتياطية جرى التحقق منها. تُلتقط لقطة (snapshot) للإصدار الحالي أولًا، بحيث يمكن التعافي من تراجع فاشل، وتُحفَظ علامتك التجارية. -- **إلغاء التثبيت** يزيل ملفات Row-Template. ولا يمسح `subThemeDir` في اللوحة إلا إذا كان يشير إلى Row-Template، فتعود اللوحة إلى صفحتها المدمجة؛ ولا يمسّ الواردات (inbounds) أو العملاء أو الشهادات. +- **التحديث من 1.1.0 أو 1.2.x** يكفيه تشغيل `row-template update` مرة واحدة. ينسخ مُحدِّث 1.1.0 نفسه جزءًا فقط من الإصدار الجديد، لذا فإن التشغيل التالي لـ`row-template` أو `row-template config` أو `row-template verify` بصلاحيات root ينزّل أولًا بقية الإصدار نفسه — كل التصاميم، مع التحقق من checksum. ويُحفَظ تصميمك وعلامتك التجارية وربط اللوحة. +- **التراجع** يستعيد إصدارًا سابقًا من نسخة احتياطية جرى التحقق منها. تُلتقط لقطة (snapshot) للإصدار الحالي أولًا، بحيث يمكن التعافي من تراجع فاشل، وتُحفَظ علامتك التجارية. تسجّل النسخ الاحتياطية اللوحة التي أُنشئت عليها ولا تُستعاد أبدًا على لوحة أخرى؛ والنسخة الاحتياطية من إصدار أقدم لا تسجّل اسم تصميمها تُستعاد على أنها Row. +- **إلغاء التثبيت** يزيل ملفات Row-Template ويعيد اللوحة إلى الصفحة التي كانت لديها من قبل: في 3X-UI لا يمسح `subThemeDir` إلا إذا كان يشير إلى Row-Template؛ وفي PasarGuard يزيل كتلته من `.env` وصفحته؛ وفي Rebecca يستعيد إعدادَي الاشتراك اللذين غيّرهما (ويتركهما إن كنت قد اخترت صفحة أخرى منذ ذلك الحين). ولا يمسّ المستخدمين أو الواردات (inbounds) أو العملاء أو العُقد أو الشهادات. يغطي [التوثيق](https://iitzseridev.github.io/Row-Template/ar/) الإعداد والعلامة التجارية واستكشاف الأخطاء بمزيد من التفصيل. ## التطوير -تُبنى الصفحات من مصادر مقروءة في `src/`. تحتاج إلى Node.js 22 أو أحدث، وإلى Go 1.22 أو أحدث لتشغيل الاختبارات. +تُبنى الصفحات من مصادر مقروءة في `src/`. تحتاج إلى Node.js 22 أو أحدث؛ ولتشغيل الاختبارات أيضًا إلى Go 1.22 أو أحدث وPython 3 مع Jinja2 (`pip install jinja2`)، اللذين يعرضان صفحات PasarGuard وRebecca بمحرّكَي هاتين اللوحتين الحقيقيين. ```bash npm run build # regenerate template/index.html from src/ @@ -238,7 +266,7 @@ npm run preview # preview the fixture pages at http://127.0.0.1:8787 ## الاختبار -- **`npm test`** يولّد أولًا صفحات الـ fixtures لكل التصاميم باستخدام العارض المكتوب بـ Go، ثم يشغّل مجموعات الاختبارات: سكربتات الصفحة، والبناء، والملف النهائي لكل تصميم، وحمولة الإصدار، والمثبّت — الذي تُشغَّل مكتبته الـ shell المنشورة في `bash` حقيقي على fixtures مؤقتة. +- **`npm test`** يولّد أولًا صفحات الـ fixtures لكل التصاميم باستخدام العارض المكتوب بـ Go، ثم يشغّل مجموعات الاختبارات: سكربتات الصفحة، والبناء، والملف النهائي لكل تصميم، وصفحات PasarGuard وRebecca معروضةً بـ Jinja2 وpongo2 الحقيقيين (بما في ذلك مع بيانات عدائية ومشوّهة)، وحمولة الإصدار، والمثبّت — الذي تُشغَّل مكتبته الـ shell المنشورة ومحوّل كل لوحة في `bash` حقيقي على خوادم مؤقتة مرتّبة كتثبيت كل لوحة الرسمي. - **`npm run verify`** يفحص صفحة مبنية وفق بوابات الأمان الخاصة بها، ومنها: مستند كامل، واستبدال كل علامات البناء، وتضمين كل شيء، وعدم وجود مراجع بعيدة، وعدم وجود بُنى محظورة، وسلامة الترجمات، وخلوّ المصادر من المحارف غير المرئية. - **`npm run lint:sh`** يفشل عند أي خطأ من ShellCheck؛ ويعرض `npm run lint:sh -- -S warning` التقرير الكامل. - **سير عمل Docs** يبني موقع التوثيق في كل طلب دمج (pull request) يغيّره. @@ -247,18 +275,17 @@ npm run preview # preview the fixture pages at http://127.0.0.1:8787 توجّه، لا وعود: -- **Row-Template 1.2.0** — التصاميم الخمسة عشر وأداة اختيار التصميم الموصوفة أعلاه. -- **PasarGuard وRebecca** — قيد البحث. هيكل الصفحة مبنيّ لكليهما؛ وتحتاج الحالة الحيّة إلى تغيير صغير في الشيفرة أو إلى وكيل عكسي (reverse proxy)، وقد أُرجئ هذا القرار. راجع [التوافق](https://iitzseridev.github.io/Row-Template/ar/compatibility/). -- **التثبيت على أكثر من لوحة** — البنية التحتية للمثبّت (واجهة للوحات، ومحرّك معاملات، ومحوّل لـ 3X-UI، وصيغة نسخ احتياطي جديدة) موجودة، لكن لا يستخدمها أي أمر بعد. +- **Row-Template 1.3.0** — دعم PasarGuard وRebecca، وتصميما Meter وNotebook، الموصوفة أعلاه. +- **الحالة الحيّة في PasarGuard وRebecca** — تقدّمها كلتاهما على لاحقة مسار لا على `?format=info`؛ ويحتاج ربطها إلى تغيير صغير في الشيفرة، وقد أُرجئ هذا القرار. راجع [التوافق](https://iitzseridev.github.io/Row-Template/ar/compatibility/). - **القوالب المخصّصة** — مقترح لإضافة تصميمك الخاص: [`docs/design/CUSTOM-TEMPLATES-PROPOSAL.md`](docs/design/CUSTOM-TEMPLATES-PROPOSAL.md). ## المساهمة نرحّب كثيرًا بتقارير الأخطاء والترجمات وتصحيحات التوثيق. اقرأ [CONTRIBUTING.md](CONTRIBUTING.md) قبل فتح طلب دمج، والتزم بـ[مدونة السلوك](CODE_OF_CONDUCT.md). -**الإبلاغ عن الأخطاء:** افتح تذكرة (issue) على . أدرِج إصدار Row-Template لديك (`row-template version`)، وإصدار 3X-UI، ونظام التشغيل وإصداره، ومعمارية المعالج، ومخرجات `row-template verify`، وخطوات واضحة لإعادة إنتاج المشكلة. +**الإبلاغ عن الأخطاء:** افتح تذكرة (issue) على . أدرِج إصدار Row-Template لديك (`row-template version`)، ولوحتك وإصدارها، ونظام التشغيل وإصداره، ومعمارية المعالج، ومخرجات `row-template verify`، وخطوات واضحة لإعادة إنتاج المشكلة. -> **لا تُدرِج أي أسرار.** لا تلصق إطلاقًا روابط الاشتراك، أو قيم `subId`، أو معرّفات UUID الخاصة بالعملاء، أو أسماء مستخدمي اللوحة أو كلمات مرورها، أو ملفات تعريف الارتباط (cookies)، أو الرموز (tokens)، أو `webBasePath` الخاص باللوحة، أو مفاتيح TLS، أو عناوين الخوادم الحقيقية. ونقِّح السجلات قبل مشاركتها. +> **لا تُدرِج أي أسرار.** لا تلصق إطلاقًا روابط الاشتراك، أو قيم `subId`، أو معرّفات UUID الخاصة بالعملاء، أو أسماء مستخدمي اللوحة أو كلمات مرورها، أو ملفات تعريف الارتباط (cookies)، أو الرموز (tokens)، أو `webBasePath` الخاص باللوحة، أو محتوى `.env`، أو روابط قواعد البيانات، أو مفاتيح TLS، أو عناوين الخوادم الحقيقية. ونقِّح السجلات قبل مشاركتها. ## الأمان diff --git a/README.fa.md b/README.fa.md index e9dd27a..7896b2c 100644 --- a/README.fa.md +++ b/README.fa.md @@ -7,7 +7,7 @@

- یک صفحهٔ اشتراک شکیل و خودبسنده برای پنل های 3X-UI — پانزده طرح که هر کدام یک فایل HTML است، کاملاً وایت لیبل، و بدون هیچ درخواستی به شخص ثالث از صفحه ای که مشترکان شما باز می کنند. + یک صفحهٔ اشتراک شکیل و خودبسنده برای پنل های 3X-UI، PasarGuard و Rebecca — هفده طرح که هر کدام یک فایل HTML است، کاملاً وایت لیبل، و بدون هیچ درخواستی به شخص ثالث از صفحه ای که مشترکان شما باز می کنند.

@@ -17,7 +17,7 @@

License Latest release - Panel + Panels Platform Documentation

@@ -34,21 +34,21 @@ ## Row-Template چیست؟ -3X-UI می تواند به جای صفحهٔ داخلی خود، یک صفحهٔ سفارشی به مشترکان نشان دهد. Row-Template همان صفحه است: مشترک پیوند اشتراک خود را باز می کند و پلن، میزان مصرف و تاریخ انقضای خود را می بیند، به همراه راه هایی برای افزودن اشتراک با یک لمس به برنامه ای که استفاده می کند. +3X-UI، PasarGuard و Rebecca هر کدام می توانند به جای صفحهٔ داخلی خود، یک صفحهٔ سفارشی به مشترکان نشان دهند. Row-Template همان صفحه است: مشترک پیوند اشتراک خود را باز می کند و پلن، میزان مصرف و تاریخ انقضای خود را می بیند، به همراه راه هایی برای افزودن اشتراک با یک لمس به برنامه ای که استفاده می کند. -برای هر طرح یک فایل HTML خودبسنده عرضه می شود که همهٔ استایل ها، اسکریپت ها، فونت ها و مولد کد QR درون آن گنجانده شده اند. یک دستور آن را کنار پنل شما نصب می کند، پنل را به آن اشاره می دهد و ابزار مدیریتی `row-template` را برای برندسازی، به روزرسانی و بازگردانی در اختیار شما می گذارد. +برای هر طرح یک فایل HTML خودبسنده عرضه می شود که همهٔ استایل ها، اسکریپت ها، فونت ها و مولد کد QR درون آن گنجانده شده اند، و از هر طرح نسخه ای به زبان قالب خود هر پنل. یک دستور پنل شما را شناسایی می کند، صفحه را کنار آن نصب می کند، پنل را به آن اشاره می دهد و ابزار مدیریتی `row-template` را برای برندسازی، به روزرسانی و بازگردانی در اختیار شما می گذارد. ## چرا Row-Template؟ - **محرمانه از پایه.** صفحه ای که مشترکان شما باز می کنند هیچ درخواستی به شخص ثالث نمی فرستد. کدهای QR روی خود صفحه تولید می شوند و اطلاعات برندسازی شما به صورت متن تزریق می شود — هرگز اجرا نمی شود و هرگز به هیچ جایی فرستاده نمی شود. - **واقعاً وایت لیبل.** نام سرویس، پیوند پشتیبانی و لوگوی خودتان. هیچ چیزی روی صفحهٔ ارائه شده معرف Row-Template نیست. -- **پانزده طرح، هر کدام یک فایل.** ظاهری را انتخاب کنید که به سرویس شما می آید. همهٔ طرح ها ویژگی ها، زبان ها و بررسی های ایمنی یکسانی دارند. +- **هفده طرح، هر کدام یک فایل.** ظاهری را انتخاب کنید که به سرویس شما می آید. همهٔ طرح ها ویژگی ها، زبان ها و بررسی های ایمنی یکسانی دارند — روی هر پنل پشتیبانی شده. - **ساخته شده برای مشترکان شما.** نمای زندهٔ مصرف و انقضا، ورود (import) با یک لمس به برنامه های پرکاربرد، و فهرستی قابل جستجو از پیکربندی های جداگانه برای افزودن دستی یک سرور. -- **ایمن برای بهره برداری.** نسخه هایی که مجموع کنترلی آن ها بررسی می شود، فعال سازی اتمی و بازگردانی تک دستوری. هرگز 3X-UI را وصله نمی کند: تنها تنظیمی از پنل که تغییر می دهد، دایرکتوری صفحهٔ اشتراک (`subThemeDir`) است. +- **ایمن برای بهره برداری.** نسخه هایی که مجموع کنترلی آن ها بررسی می شود، فعال سازی تراکنشی که اگر گامی شکست بخورد پنل را دقیقاً به حالت قبل برمی گرداند، و بازگردانی تک دستوری. هرگز پنل شما را وصله نمی کند: در 3X-UI یک تنظیم (`subThemeDir`) را تغییر می دهد، در PasarGuard یک بلوک نشان دار به `.env` می افزاید، و در Rebecca دو فیلد از تنظیمات اشتراک را مقدار می دهد. ## طرح ها -Row-Template 1.2.0 با پانزده طرح عرضه می شود. طرح پیش فرض Row است. +Row-Template 1.3.0 با هفده طرح عرضه می شود. طرح پیش فرض Row است. @@ -72,6 +72,10 @@ Row-Template 1.2.0 با پانزده طرح عرضه می شود. طرح پیش + + + +
Terminal Nova
Terminal Nova
Arcade Nova
Arcade Nova
Meter
Meter
Notebook
Notebook
پیش نمایش ها با داده های نمونهٔ خود پروژه ساخته شده اند. پیش نمایش دسکتاپ و موبایل همهٔ طرح ها در گالری طرح ها موجود است. @@ -82,7 +86,7 @@ Row-Template 1.2.0 با پانزده طرح عرضه می شود. طرح پیش **برای مشترکان شما** -- **وضعیت زنده.** وضعیت پلن، ترافیک مصرف شده و باقی مانده و تاریخ انقضا، که تا وقتی صفحه دیده می شود از پنل شما به روز می شود. +- **وضعیت زنده.** وضعیت پلن، ترافیک مصرف شده و باقی مانده و تاریخ انقضا، که تا وقتی صفحه دیده می شود از پنل شما به روز می شود (در 3X-UI؛ در PasarGuard و Rebecca صفحه مقادیر لحظهٔ باز شدن را نشان می دهد). - **ورود با یک لمس** به برنامه های پرکاربرد، بر اساس پلتفرم: v2rayNG، Happ و sing-box در Android؛ Streisand، V2Box و Shadowrocket در iOS؛ Clash Verge Rev، Mihomo Party و v2rayN در Windows؛ Clash Verge Rev، Streisand و V2Box در macOS. - **کپی و QR.** پیوند اشتراک را کپی کنید یا آن را به صورت کد QR که روی خود صفحه ساخته می شود اسکن کنید. - **کاوشگر پیکربندی ها.** هر سرور در یک ردیف جداگانه، با پرچم کشور یا نشان حروف (monogram) و برچسب پروتکل (VLESS، VMess، Trojan، Shadowsocks، Hysteria/Hysteria2، WireGuard، AmneziaWG، Telegram MTProto)، به همراه QR و کپی برای هر پیکربندی و جستجو برای فهرست های طولانی. @@ -99,17 +103,17 @@ Row-Template 1.2.0 با پانزده طرح عرضه می شود. طرح پیش - **بدون درخواست به شخص ثالث** از صفحهٔ ارائه شده: بدون CDN، بدون جستجوی بیرونی QR یا موقعیت جغرافیایی، بدون تله متری. وضعیت زنده از پنل خود شما می آید. - **SHA-256 الزامی** برای هر دانلود نسخه، بدون هیچ گزینه ای برای رد کردن آن. - **فعال سازی اتمی.** صفحهٔ جدید پیش از جایگزینی صفحهٔ فعال ساخته و اعتبارسنجی می شود، بنابراین یک گام ناموفق هرگز صفحه ای خراب را فعال باقی نمی گذارد. -- **شناسایی محتاطانهٔ پنل.** اگر پایگاه دادهٔ پنلی که Row-Template پیدا می کند یک پایگاه دادهٔ SQLite معتبر نباشد، به جای حدس زدن پایگاه دادهٔ دیگری، از به کار بردن آن خودداری می کند. +- **شناسایی محتاطانهٔ پنل.** یک پنل تنها وقتی نصب شده به حساب می آید که نشانه های مستقل با هم بخوانند؛ پنلی که نیمه نصب شده، یا پایگاه دادهٔ پنلی که یک پایگاه دادهٔ SQLite معتبر نیست، به جای حدس زدن رد می شود. ## پنل های پشتیبانی شده | پنل | وضعیت | یادداشت ها | | ----- | ------ | ----- | | [3X-UI](https://github.com/MHSanaei/3x-ui) (MHSanaei) | ✅ پشتیبانی شده | نیازمند نسخهٔ **>= 3.6.0** | -| [PasarGuard](https://github.com/PasarGuard/panel) | 🔬 در حال پژوهش | پشتیبانی نمی شود؛ مسیر نصبی ندارد | -| [Rebecca](https://github.com/rebeccapanel/Rebecca) | 🔬 در حال پژوهش | پشتیبانی نمی شود؛ مسیر نصبی ندارد | +| [PasarGuard](https://github.com/PasarGuard/panel) | ✅ پشتیبانی شده از 1.3.0 | نصب رسمی Docker یا نصب از سورس (`pasarguard.service`) | +| [Rebecca](https://github.com/rebeccapanel/Rebecca) | ✅ پشتیبانی شده از 1.3.0 | فعال سازی خودکار با SQLite و `sqlite3`؛ با MySQL/MariaDB یک تنظیم که باید در داشبورد وارد شود | -تنها پنل پشتیبانی شده 3X-UI است. PasarGuard و Rebecca از موتورهای قالب متفاوتی (Jinja2 و pongo2) استفاده می کنند؛ پوستهٔ صفحهٔ هر طرح برای آن ها ساخته و برای بررسی در نسخه بسته بندی می شود، اما نصب کننده آن را جایگذاری نمی کند و هیچ دستورالعمل نصبی برای آن ها وجود ندارد. برای یافته های پژوهشی، [سازگاری](https://iitzseridev.github.io/Row-Template/fa/compatibility/) را ببینید. +این سه پنل از سه موتور قالب متفاوت استفاده می کنند — `html/template` زبان Go، Jinja2 و pongo2 — پس هر طرح برای هر پنل یک بار ساخته می شود و هر نسخه با رندر شدن توسط موتور واقعی همان پنل آزموده می شود. نصب کننده تشخیص می دهد کدام پنل روی سرور است؛ روی سروری با بیش از یک پنل، از شما می پرسد (یا `RT_PANEL` را می خواند). **پشتیبانی‌شده** یعنی هر هفت توانایی روی آن پنل موجود است — تشخیص، نصب، فعال‌سازی، بررسی، پشتیبان‌گیری، بازگردانی و حذف نصب — و هر کدام توسط مجموعهٔ آزمون آزموده می شود. برای جزئیات هر پنل، [سازگاری](https://iitzseridev.github.io/Row-Template/fa/compatibility/) را ببینید. ## معماری @@ -117,14 +121,14 @@ Row-Template 1.2.0 با پانزده طرح عرضه می شود. طرح پیش flowchart TB subgraph build ["Build and release"] direction LR - SRC["src/
runtime, styles, locales,
15 design layouts"] --> BUILD["tools/build.mjs"] - BUILD --> ART["One self-contained
HTML file per design"] + SRC["src/
runtime, styles, locales,
17 design layouts"] --> BUILD["tools/build.mjs"] + BUILD --> ART["One self-contained
HTML file per design,
per panel"] ART --> REL["tools/make-release.sh
tarball + SHA256SUMS"] end - subgraph host ["Your 3X-UI server"] + subgraph host ["Your panel server"] direction LR - INST["install.sh / row-template
verify checksum, stage, validate,
back up, activate"] --> DIR["/etc/3x-ui/
sub_templates/row-template"] - DIR -- "subThemeDir" --> XUI["3X-UI renders the page
with the subscriber's data"] + INST["install.sh / row-template
verify checksum, detect panel,
back up, activate, verify"] --> DIR["3X-UI: subThemeDir
PasarGuard: .env block
Rebecca: subscription settings"] + DIR --> XUI["The panel renders the page
with the subscriber's data"] end build -- "GitHub Releases" --> host host -- "serves the page" --> BROWSER["Subscriber's browser"] @@ -132,15 +136,15 @@ flowchart TB ``` - **یک فایل برای هر طرح.** `tools/build.mjs` کد اجرایی مشترک، ترجمه ها، فونت ها و مولد QR را درون چیدمان هر طرح می گنجاند و چیدمانی را که هر یک از قلاب های (hook) مورد نیاز کد اجرایی را نداشته باشد رد می کند. سپس `tools/verify.mjs` هر فایلی را که چیزی را از راه دور بارگذاری کند یا ساختاری ممنوع داشته باشد رد می کند. -- **رندر را پنل انجام می دهد.** صفحه یک قالب است: 3X-UI هنگام ارائهٔ آن داده های مشترک را در آن قرار می دهد و سپس صفحه وضعیت خود را از همان پنل به روز می کند. -- **نصب کننده هرگز 3X-UI را ویرایش نمی کند.** دایرکتوری خودش را می نویسد و تنها یک تنظیم پنل، `subThemeDir`، را تغییر می دهد تا به آن اشاره کند. +- **رندر را پنل انجام می دهد.** صفحه یک قالب است: پنل هنگام ارائهٔ آن داده های مشترک را در آن قرار می دهد. برای PasarGuard (Jinja2) و Rebecca (pongo2) هر طرح درون یک پیش درآمد کوچک قرار می گیرد که داده های خود پنل را به صفحه نگاشت می کند و هر مقدار را escape می کند. +- **نصب کننده هرگز پنل شما را وصله نمی کند.** در 3X-UI، `subThemeDir` را به دایرکتوری خودش اشاره می دهد؛ در PasarGuard صفحه را در دایرکتوری قالب ها می گذارد و یک بلوک نشان دار به انتهای `.env` می افزاید؛ در Rebecca صفحه را می گذارد و فیلدهای صفحه و دایرکتوری تنظیمات اشتراک را مقدار می دهد. از هر تغییر پیش از انجام یک snapshot گرفته می شود و اگر چیزی شکست بخورد دقیقاً بازگردانده می شود. | مسیر | محتوا | | ---- | ---------------- | | `src/` | کد اجرایی، استایل ها و ترجمه های صفحه؛ هر طرح در `src/templates//` | | `template/index.html` | صفحهٔ ساخته شدهٔ Row، که commit شده است | | `tools/` | ساخت، اعتبارسنجی، انتشار و رندرکنندهٔ Go برای fixtureها | -| `installer/` | `install.sh`، دستور `row-template` و کتابخانهٔ مدیریتی آن | +| `installer/` | `install.sh`، دستور `row-template`، کتابخانهٔ مدیریتی آن و یک آداپتور برای هر پنل در `installer/panels/` | | `tests/` | مجموعه های آزمون | | `docs/` | سایت مستندات؛ سوابق طراحی در [`docs/design/`](docs/design/README.md) | @@ -148,9 +152,9 @@ flowchart TB > **سیستم عامل پیشنهادی: Ubuntu 24.04 LTS (x86_64).** دیگر توزیع های امروزی لینوکس نیز ممکن است کار کنند، اما پوشش اعتبارسنجی یکسانی نداشته اند. -**پیش نیازها:** سروری با 3X-UI **>= 3.6.0**، دسترسی root به آن، و `curl`، `tar` و `sha256sum` (که تقریباً روی همهٔ سیستم های لینوکس موجود است). فعال سازی خودکار به `sqlite3` هم نیاز دارد. +**پیش نیازها:** سروری با 3X-UI **>= 3.6.0**، PasarGuard یا Rebecca؛ دسترسی root به آن؛ و `curl`، `tar` و `sha256sum` (که تقریباً روی همهٔ سیستم های لینوکس موجود است). فعال سازی خودکار در 3X-UI و Rebecca به `sqlite3` هم نیاز دارد. -با کاربر **root** روی سروری که پنل 3X-UI شما را میزبانی می کند اجرا کنید: +با کاربر **root** روی سروری که پنل شما را میزبانی می کند اجرا کنید: ```bash bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) @@ -160,7 +164,7 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d 1. آخرین نسخهٔ پایدار را از GitHub دانلود می کند. 2. مجموع کنترلی SHA-256 آن را بررسی می کند (الزامی — بدون امکان دور زدن). -3. آن را به شکل ایمن استخراج می کند و در `/etc/3x-ui/sub_templates/row-template` نصب می کند. +3. پنل شما را شناسایی می کند، نسخه را به شکل ایمن استخراج می کند و در `/etc/3x-ui/sub_templates/row-template` (3X-UI) یا `/etc/row-template` (PasarGuard، Rebecca) نصب می کند. 4. در نصب تازه، انتخابگر طرح را نشان می دهد (Enter طرح Row را نگه می دارد). 5. برای برندسازی شما درخواست ورودی می دهد (نام سرویس، پیوند پشتیبانی، لوگو — همگی اختیاری). 6. صفحه را تولید و اعتبارسنجی می کند و سپس در صورت امکان آن را در پنل فعال می کند. @@ -171,6 +175,12 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d RT_TEMPLATE=editorial bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) ``` +روی سروری که بیش از یک پنل پشتیبانی شده دارد، نصب کننده می پرسد کدام را سرویس دهد؛ در یک اسکریپت، آن را با `RT_PANEL` (`3xui`، `pasarguard` یا `rebecca`) مشخص کنید: + +```bash +RT_PANEL=pasarguard bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) +``` + اگر ترجیح می دهید از طریق شبکه به صورت pipe عمل نکنید، چهار فایل نسخه (`install.sh`، `manifest.txt`، `SHA256SUMS` و `row-template-.tar.gz`) را از [صفحهٔ Releases](https://github.com/iitzSeriZdev/Row-Template/releases/latest) در یک پوشه دانلود کنید، مجموع کنترلی را خودتان همان گونه که در [PROVENANCE.md](PROVENANCE.md) توضیح داده شده بررسی کنید و نصب کننده را به آن پوشه ارجاع دهید: ```bash @@ -179,19 +189,37 @@ RT_RELEASE_DIR=/root/row-template-release bash /root/row-template-release/instal ### فعال سازی -Row-Template در دایرکتوری ای نصب می شود که پنل آن را به عنوان صفحهٔ اشتراک ارائه می دهد: +نصب تعاملی ابتدا نشان می دهد فعال سازی چه چیزی را تغییر می دهد و پیش از تغییر از شما می پرسد. در PasarGuard و Rebecca فعال سازی به صورت یک تراکنش اجرا می شود: از وضعیت پنل snapshot گرفته می شود، تغییر اعمال و بررسی می شود، و اگر گامی شکست بخورد، پنل دقیقاً به حالت قبل بازگردانده می شود. + +**3X-UI.** Row-Template در دایرکتوری ای نصب می شود که پنل آن را به عنوان صفحهٔ اشتراک ارائه می دهد: ``` /etc/3x-ui/sub_templates/row-template ``` -- **خودکار:** هنگامی که `sqlite3` در دسترس باشد، Row-Template آن را برای شما تنظیم می کند. سرویس پنل را برای مدت کوتاهی متوقف می کند، تنظیم را می نویسد، سرویس را دوباره راه اندازی می کند و مقدار را بررسی می کند. نصب تعاملی ابتدا تنظیم فعلی را نشان می دهد و پیش از تغییر از شما می پرسد. +- **خودکار:** هنگامی که `sqlite3` در دسترس باشد، Row-Template آن را برای شما تنظیم می کند. سرویس پنل را برای مدت کوتاهی متوقف می کند، تنظیم را می نویسد، سرویس را دوباره راه اندازی می کند و مقدار را بررسی می کند. - **دستی:** در غیر این صورت، **Panel Settings → Subscription → Profile → Sub Theme Directory** را باز کنید و دقیقاً این را وارد کنید: ``` /etc/3x-ui/sub_templates/row-template ``` +**PasarGuard.** صفحه در `/var/lib/pasarguard/templates/row-template/index.html` قرار می گیرد (یا درون `CUSTOM_TEMPLATES_DIRECTORY` خودتان، اگر تنظیمش کرده باشید)، و یک بلوک نشان دار به انتهای `/opt/pasarguard/.env` افزوده می شود: + +``` +# >>> row-template (managed by Row-Template; do not edit) nl=0 >>> +CUSTOM_TEMPLATES_DIRECTORY = "/var/lib/pasarguard/templates" +SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" +# <<< row-template <<< +``` + +PasarGuard فایل `.env` را هنگام راه اندازی می خواند، پس پنلی که در حال اجراست یک بار راه اندازی مجدد می شود. هیچ یک از خط های خودتان ویرایش نمی شود؛ حذف نصب بلوک را برمی دارد و `.env` را دقیقاً به بایت های قبلی اش بازمی گرداند. ادمینی که قالب اشتراک خودش را دارد، یا تنظیم **disable subscription template**، همچنان مقدم است — `row-template verify` به شما می گوید اگر یکی از آن ها برقرار باشد. + +**Rebecca.** صفحه در `/var/lib/rebecca/templates/row-template/index.html` قرار می گیرد (یا درون دایرکتوری قالب های سفارشی خودتان)، و تنظیمات اشتراک Rebecca روی `row-template/index.html` تنظیم می شود. Rebecca این تنظیمات را در هر درخواست می خواند، پس نیازی به راه اندازی مجدد نیست. + +- **خودکار** با پایگاه دادهٔ پیش فرض SQLite و نصب بودن `sqlite3`. +- **دستی** با MySQL/MariaDB (یا بدون `sqlite3`): صفحه همچنان جایگذاری می شود؛ در داشبورد Rebecca، **Settings → Subscription → Templates** را باز کنید و **Subscription page template** را `row-template/index.html` و **Custom templates directory** را `/var/lib/rebecca/templates` قرار دهید. + ## استفاده مدیر را بدون هیچ آرگومانی در ترمینال اجرا کنید تا منوی تعاملی باز شود: @@ -208,23 +236,23 @@ row-template | `row-template update` | دانلود، بررسی و فعال سازی آخرین نسخهٔ پایدار (بررسی مجموع کنترلی الزامی) | | `row-template rollback` | بازگردانی یک نسخهٔ پیشین (`--auto` یا `--to `) | | `row-template verify` | بررسی نصب، اتصال به پنل و صفحهٔ فعال (با دسترسی root، طرح های گم شده یا جابه جا شده را هم به جای خود برمی گرداند) | -| `row-template version` | نمایش نسخهٔ نصب شده، حداقل نسخهٔ پشتیبانی شده و نسخهٔ شناسایی شدهٔ 3X-UI | -| `row-template uninstall` | حذف Row-Template و بازگرداندن پنل به صفحهٔ داخلی خودش | +| `row-template version` | نمایش نسخهٔ نصب شده و پنلی که به آن سرویس می دهد (در 3X-UI، حداقل نسخهٔ پشتیبانی شده و نسخهٔ شناسایی شده را هم) | +| `row-template uninstall` | حذف Row-Template و بازگرداندن پنل به صفحه ای که پیش تر داشت | | `row-template help` | نمایش راهنمای استفاده | دستورهایی که سیستم را تغییر می دهند (`config`، `update`، `rollback`، `uninstall`) باید با root اجرا شوند. - **برندسازی** به عنوان داده ذخیره می شود، هرگز اجرا نمی شود و به صورت متن در صفحه تزریق می گردد. برای یک صفحهٔ بدون برند، فیلدی را خالی بگذارید. پیوند پشتیبانی تنها پروتکل هایی را می پذیرد که مرورگر باید باز کند، مانند `https://…`، `tg://…` یا `mailto:…`. - **به روزرسانی ها** از کانال عمومی نسخه های پایدار می آیند. `row-template update` همیشه آخرین نسخهٔ پایدار را اعمال می کند، حتی اگر همان نسخه را داشته باشید؛ گزینهٔ **Update** در منوی مدیریت ابتدا نسخه ها را مقایسه می کند و پیش از هر تغییری می پرسد. اگر منبع انتشار در دسترس نباشد، چیزی تغییر نمی کند و نصب شما هرگز آسیب دیده تلقی نمی شود. -- **به روزرسانی از 1.1.0** با یک بار اجرای `row-template update` انجام می شود. به روزرسان خود 1.1.0 فقط بخشی از نسخهٔ جدید را کپی می کند، برای همین اجرای بعدی `row-template`، `row-template config` یا `row-template verify` با دسترسی root، ابتدا بقیهٔ همان نسخه را دریافت می کند — همهٔ طرح ها، با بررسی checksum. -- **بازگردانی** یک نسخهٔ پیشین را از یک پشتیبان اعتبارسنجی شده بازیابی می کند. ابتدا از نسخهٔ فعلی یک عکس فوری (snapshot) گرفته می شود تا یک بازگردانی ناموفق قابل جبران باشد، و برندسازی شما حفظ می شود. -- **حذف نصب** فایل های Row-Template را حذف می کند. `subThemeDir` پنل را تنها در صورتی پاک می کند که به Row-Template اشاره کند، تا پنل به صفحهٔ داخلی خود بازگردد؛ به inboundها، کلاینت ها و گواهی های شما دست زده نمی شود. +- **به روزرسانی از 1.1.0 یا 1.2.x** با یک بار اجرای `row-template update` انجام می شود. به روزرسان خود 1.1.0 فقط بخشی از نسخهٔ جدید را کپی می کند، برای همین اجرای بعدی `row-template`، `row-template config` یا `row-template verify` با دسترسی root، ابتدا بقیهٔ همان نسخه را دریافت می کند — همهٔ طرح ها، با بررسی checksum. طرح، برندسازی و اتصال پنل شما حفظ می شوند. +- **بازگردانی** یک نسخهٔ پیشین را از یک پشتیبان اعتبارسنجی شده بازیابی می کند. ابتدا از نسخهٔ فعلی یک عکس فوری (snapshot) گرفته می شود تا یک بازگردانی ناموفق قابل جبران باشد، و برندسازی شما حفظ می شود. پشتیبان ها پنلی را که روی آن ساخته شده اند ثبت می کنند و هرگز روی پنل دیگری بازگردانده نمی شوند؛ پشتیبانی از یک نسخهٔ قدیمی تر که نام طرحش را ثبت نکرده، به صورت Row بازگردانده می شود. +- **حذف نصب** فایل های Row-Template را حذف می کند و پنل را به صفحه ای که پیش تر داشت بازمی گرداند: در 3X-UI، `subThemeDir` را تنها در صورتی پاک می کند که به Row-Template اشاره کند؛ در PasarGuard بلوک `.env` و صفحهٔ خودش را برمی دارد؛ در Rebecca دو تنظیم اشتراکی را که تغییر داده بازمی گرداند (و اگر از آن پس صفحهٔ دیگری انتخاب کرده باشید، به آن ها دست نمی زند). به کاربران، inboundها، کلاینت ها، نودها و گواهی های شما دست زده نمی شود. [مستندات](https://iitzseridev.github.io/Row-Template/fa/) پیکربندی، برندسازی و رفع اشکال را با جزئیات بیشتری پوشش می دهد. ## توسعه -صفحه ها از منابع خوانای موجود در `src/` ساخته می شوند. به Node.js نسخهٔ 22 یا بالاتر، و برای اجرای آزمون ها به Go نسخهٔ 1.22 یا بالاتر نیاز دارید. +صفحه ها از منابع خوانای موجود در `src/` ساخته می شوند. به Node.js نسخهٔ 22 یا بالاتر نیاز دارید؛ برای اجرای آزمون ها همچنین به Go نسخهٔ 1.22 یا بالاتر و Python 3 همراه Jinja2 (`pip install jinja2`) که صفحه های PasarGuard و Rebecca را با موتورهای واقعی همان پنل ها رندر می کنند. ```bash npm run build # regenerate template/index.html from src/ @@ -239,7 +267,7 @@ npm run preview # preview the fixture pages at http://127.0.0.1:8787 ## آزمون -- **`npm test`** ابتدا صفحه های fixture همهٔ طرح ها را با رندرکنندهٔ Go می سازد و سپس مجموعه های آزمون را اجرا می کند: اسکریپت های صفحه، فرآیند ساخت، فایل نهایی هر طرح، محتوای بستهٔ انتشار و نصب کننده — که کتابخانهٔ shell منتشرشده را در یک `bash` واقعی روی fixtureهای موقت اجرا می کند. +- **`npm test`** ابتدا صفحه های fixture همهٔ طرح ها را با رندرکنندهٔ Go می سازد و سپس مجموعه های آزمون را اجرا می کند: اسکریپت های صفحه، فرآیند ساخت، فایل نهایی هر طرح، صفحه های PasarGuard و Rebecca که با Jinja2 و pongo2 واقعی رندر می شوند (از جمله با داده های مخرب و ناقص)، محتوای بستهٔ انتشار و نصب کننده — که کتابخانهٔ shell منتشرشده و آداپتور هر پنل را در یک `bash` واقعی روی میزبان های موقتی اجرا می کند که مانند نصب رسمی هر پنل چیده شده اند. - **`npm run verify`** یک صفحهٔ ساخته شده را با دروازه های ایمنی آن می سنجد، از جمله: سند کامل، جایگزینی همهٔ نشانگرهای ساخت، گنجاندن همه چیز در فایل، نبود ارجاع راه دور، نبود ساختارهای ممنوع، سالم بودن ترجمه ها و نبود نویسه های نامرئی در منابع. - **`npm run lint:sh`** با هر خطای ShellCheck شکست می خورد؛ `npm run lint:sh -- -S warning` گزارش کامل را نشان می دهد. - **گردش کار Docs** سایت مستندات را در هر pull request که آن را تغییر دهد می سازد. @@ -248,18 +276,17 @@ npm run preview # preview the fixture pages at http://127.0.0.1:8787 جهت گیری، نه وعده: -- **Row-Template 1.2.0** — پانزده طرح و انتخابگر طرح که در بالا توضیح داده شد. -- **PasarGuard و Rebecca** — در حال پژوهش. پوستهٔ صفحه برای هر دو ساخته شده است؛ وضعیت زنده به یک تغییر کوچک در کد اجرایی یا یک reverse proxy نیاز دارد و این تصمیم به تعویق افتاده است. [سازگاری](https://iitzseridev.github.io/Row-Template/fa/compatibility/) را ببینید. -- **نصب روی بیش از یک پنل** — زیرساخت نصب کننده (یک رابط پنل، یک موتور تراکنش، یک آداپتور 3X-UI و یک قالب پشتیبان گیری جدید) آماده است، اما هنوز هیچ دستوری از آن استفاده نمی کند. +- **Row-Template 1.3.0** — پشتیبانی از PasarGuard و Rebecca، و طرح های Meter و Notebook، که در بالا توضیح داده شد. +- **وضعیت زنده در PasarGuard و Rebecca** — هر دو آن را روی یک پسوند مسیر ارائه می دهند نه `?format=info`؛ اتصال آن به یک تغییر کوچک در کد اجرایی نیاز دارد و این تصمیم به تعویق افتاده است. [سازگاری](https://iitzseridev.github.io/Row-Template/fa/compatibility/) را ببینید. - **قالب های سفارشی** — پیشنهادی برای افزودن طرح خودتان: [`docs/design/CUSTOM-TEMPLATES-PROPOSAL.md`](docs/design/CUSTOM-TEMPLATES-PROPOSAL.md). ## مشارکت گزارش اشکال، ترجمه و اصلاح مستندات بسیار استقبال می شود. پیش از باز کردن pull request، [CONTRIBUTING.md](CONTRIBUTING.md) را بخوانید و از [آیین نامهٔ رفتاری](CODE_OF_CONDUCT.md) پیروی کنید. -**گزارش اشکال:** یک issue در باز کنید. نسخهٔ Row-Template خود (`row-template version`)، نسخهٔ 3X-UI، سیستم عامل و نسخهٔ آن، معماری پردازنده، خروجی `row-template verify` و گام های روشن برای بازتولید مشکل را ذکر کنید. +**گزارش اشکال:** یک issue در باز کنید. نسخهٔ Row-Template خود (`row-template version`)، پنل شما و نسخهٔ آن، سیستم عامل و نسخهٔ آن، معماری پردازنده، خروجی `row-template verify` و گام های روشن برای بازتولید مشکل را ذکر کنید. -> **هیچ گونه اطلاعات محرمانه درج نکنید.** هرگز URLهای اشتراک، مقادیر `subId`، UUIDهای کلاینت، نام کاربری یا گذرواژهٔ پنل، کوکی ها، توکن ها، `webBasePath` پنل، کلیدهای TLS یا نشانی های واقعی سرور را وارد نکنید. پیش از اشتراک گذاری لاگ ها، آن ها را ویرایش و پاک سازی کنید. +> **هیچ گونه اطلاعات محرمانه درج نکنید.** هرگز URLهای اشتراک، مقادیر `subId`، UUIDهای کلاینت، نام کاربری یا گذرواژهٔ پنل، کوکی ها، توکن ها، `webBasePath` پنل، محتوای `.env`، URLهای پایگاه داده، کلیدهای TLS یا نشانی های واقعی سرور را وارد نکنید. پیش از اشتراک گذاری لاگ ها، آن ها را ویرایش و پاک سازی کنید. ## امنیت diff --git a/README.md b/README.md index 5f2e156..78c25d3 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

- A polished, self-contained subscription page for 3X-UI panels — fifteen designs, each a single HTML file, fully white-label, with no third-party requests from the page your subscribers open. + A polished, self-contained subscription page for 3X-UI, PasarGuard and Rebecca panels — seventeen designs, each a single HTML file, fully white-label, with no third-party requests from the page your subscribers open.

@@ -17,7 +17,7 @@

License Latest release - Panel + Panels Platform Documentation

@@ -34,21 +34,21 @@ ## What it is -3X-UI can serve a custom page to subscribers instead of its built-in one. Row-Template is that page: a subscriber opens their subscription link and sees their plan, their usage, their expiry date, and one-tap ways to add the subscription to the app they use. +3X-UI, PasarGuard and Rebecca can each serve a custom page to subscribers instead of their built-in one. Row-Template is that page: a subscriber opens their subscription link and sees their plan, their usage, their expiry date, and one-tap ways to add the subscription to the app they use. -It ships as one self-contained HTML file per design, with every style, script, font, and the QR code generator inlined. A single command installs it next to your panel, points the panel at it, and gives you a `row-template` manager for branding, updates, and rollback. +It ships as one self-contained HTML file per design, with every style, script, font, and the QR code generator inlined, and a version of each design in every panel's own template language. A single command detects your panel, installs the page next to it, points the panel at it, and gives you a `row-template` manager for branding, updates, and rollback. ## Why Row-Template? - **Private by design.** The page your subscribers open makes no third-party requests. QR codes are generated on the page, and your branding is injected as text — never executed, never sent anywhere. - **Genuinely white-label.** Your service name, your support link, your logo. Nothing on the served page identifies Row-Template. -- **Fifteen designs, one file each.** Pick the look that fits your service. Every design shares the same features, languages, and safety checks. +- **Seventeen designs, one file each.** Pick the look that fits your service. Every design shares the same features, languages, and safety checks — on every supported panel. - **Made for your subscribers.** Live usage and expiry, one-tap import into popular apps, and a searchable list of individual configurations for adding a single server by hand. -- **Safe to operate.** Checksum-verified releases, atomic activation, and one-command rollback. It never patches 3X-UI: the only panel setting it changes is the subscription page directory (`subThemeDir`). +- **Safe to operate.** Checksum-verified releases, transactional activation that restores the panel exactly if any step fails, and one-command rollback. It never patches your panel: on 3X-UI it changes one setting (`subThemeDir`), on PasarGuard it adds one marked block to `.env`, and on Rebecca it sets two fields of its subscription settings. ## Designs -Row-Template 1.2.0 ships fifteen designs. Row is the default. +Row-Template 1.3.0 ships seventeen designs. Row is the default. @@ -72,6 +72,10 @@ Row-Template 1.2.0 ships fifteen designs. Row is the default. + + + +
Terminal Nova
Terminal Nova
Arcade Nova
Arcade Nova
Meter
Meter
Notebook
Notebook
Previews are rendered from the project's own placeholder data. Desktop and mobile previews of every design are in the template gallery. @@ -82,7 +86,7 @@ Choose a design during a fresh interactive install, set `RT_TEMPLATE` for a scri **For your subscribers** -- **Live status.** Plan state, traffic used and remaining, and expiry, refreshed from your panel while the page is visible. +- **Live status.** Plan state, traffic used and remaining, and expiry, refreshed from your panel while the page is visible (3X-UI; on PasarGuard and Rebecca the page shows the values as of when it was opened). - **One-tap import** into popular apps, grouped by platform: v2rayNG, Happ and sing-box on Android; Streisand, V2Box and Shadowrocket on iOS; Clash Verge Rev, Mihomo Party and v2rayN on Windows; Clash Verge Rev, Streisand and V2Box on macOS. - **Copy and QR.** Copy the subscription link or scan it as a QR code generated on the page. - **Configuration Explorer.** Every server on its own row, with a country flag or monogram and a protocol label (VLESS, VMess, Trojan, Shadowsocks, Hysteria/Hysteria2, WireGuard, AmneziaWG, Telegram MTProto), plus per-configuration QR and copy, and search for long lists. @@ -99,17 +103,17 @@ Choose a design during a fresh interactive install, set `RT_TEMPLATE` for a scri - **No third-party requests** from the served page: no CDNs, no external QR or geolocation lookups, no telemetry. Live status comes from your own panel. - **Mandatory SHA-256** verification of every release download, with no option to skip it. - **Atomic activation.** A new page is generated and validated before it replaces the live one, so a failed step never leaves a broken page live. -- **Fail-closed panel detection.** If the panel database Row-Template finds is not a valid SQLite database, it refuses to use it rather than guessing another one. +- **Fail-closed panel detection.** A panel counts as installed only when independent signals agree; a half-installed panel, or a panel database that is not a valid SQLite database, is refused rather than guessed at. ## Supported panels | Panel | Status | Notes | | ----- | ------ | ----- | | [3X-UI](https://github.com/MHSanaei/3x-ui) (MHSanaei) | ✅ Supported | Requires version **>= 3.6.0** | -| [PasarGuard](https://github.com/PasarGuard/panel) | 🔬 Research | Not supported; no installation path | -| [Rebecca](https://github.com/rebeccapanel/Rebecca) | 🔬 Research | Not supported; no installation path | +| [PasarGuard](https://github.com/PasarGuard/panel) | ✅ Supported since 1.3.0 | The official Docker install or a source install (`pasarguard.service`) | +| [Rebecca](https://github.com/rebeccapanel/Rebecca) | ✅ Supported since 1.3.0 | Automatic activation with SQLite and `sqlite3`; with MySQL/MariaDB, one setting to enter in the dashboard | -3X-UI is the only supported panel. PasarGuard and Rebecca use different template engines (Jinja2 and pongo2); each design's page shell is built for them and packaged in the release for study, but the installer does not place it and there are no installation instructions for them. See [Compatibility](https://iitzseridev.github.io/Row-Template/compatibility/) for the research findings. +The three panels use three different template engines — Go `html/template`, Jinja2 and pongo2 — so every design is built once per panel, and each version is tested by rendering it with that panel's real engine. The installer detects which panel is on the server; on a server with more than one, it asks (or reads `RT_PANEL`). **Supported** means all seven capabilities are present on that panel — detect, install, activate, verify, backup, restore and uninstall — and each one is exercised by the test suite. See [Compatibility](https://iitzseridev.github.io/Row-Template/compatibility/) for the details of each panel. ## Architecture @@ -117,14 +121,14 @@ Choose a design during a fresh interactive install, set `RT_TEMPLATE` for a scri flowchart TB subgraph build ["Build and release"] direction LR - SRC["src/
runtime, styles, locales,
15 design layouts"] --> BUILD["tools/build.mjs"] - BUILD --> ART["One self-contained
HTML file per design"] + SRC["src/
runtime, styles, locales,
17 design layouts"] --> BUILD["tools/build.mjs"] + BUILD --> ART["One self-contained
HTML file per design,
per panel"] ART --> REL["tools/make-release.sh
tarball + SHA256SUMS"] end - subgraph host ["Your 3X-UI server"] + subgraph host ["Your panel server"] direction LR - INST["install.sh / row-template
verify checksum, stage, validate,
back up, activate"] --> DIR["/etc/3x-ui/
sub_templates/row-template"] - DIR -- "subThemeDir" --> XUI["3X-UI renders the page
with the subscriber's data"] + INST["install.sh / row-template
verify checksum, detect panel,
back up, activate, verify"] --> DIR["3X-UI: subThemeDir
PasarGuard: .env block
Rebecca: subscription settings"] + DIR --> XUI["The panel renders the page
with the subscriber's data"] end build -- "GitHub Releases" --> host host -- "serves the page" --> BROWSER["Subscriber's browser"] @@ -132,15 +136,15 @@ flowchart TB ``` - **One file per design.** `tools/build.mjs` inlines the shared runtime, the translations, the fonts, and the QR generator into a design's layout, and refuses a layout that is missing any hook the runtime needs. `tools/verify.mjs` then rejects an artifact that loads anything remote or carries a forbidden construct. -- **The panel does the rendering.** The page is a template: 3X-UI fills in the subscriber's data when it serves it, and the page then refreshes its status from the same panel. -- **The installer never edits 3X-UI.** It writes its own directory and changes one panel setting, `subThemeDir`, to point at it. +- **The panel does the rendering.** The page is a template: the panel fills in the subscriber's data when it serves it. For PasarGuard (Jinja2) and Rebecca (pongo2) each design is wrapped in a small prelude that maps the panel's own data onto the page and escapes every value. +- **The installer never patches your panel.** On 3X-UI it points `subThemeDir` at its own directory; on PasarGuard it places the page in the templates directory and appends one marked block to `.env`; on Rebecca it places the page and sets the page and directory fields of its subscription settings. Each change is snapshotted first and restored exactly if anything fails. | Path | What lives there | | ---- | ---------------- | | `src/` | The page's runtime, styles, and translations; each design in `src/templates//` | | `template/index.html` | The built Row page, committed | | `tools/` | Build, verification, release, and the Go fixture renderer | -| `installer/` | `install.sh`, the `row-template` command, and its management library | +| `installer/` | `install.sh`, the `row-template` command, its management library, and one adapter per panel in `installer/panels/` | | `tests/` | The test suites | | `docs/` | The documentation site; design records in [`docs/design/`](docs/design/README.md) | @@ -148,9 +152,9 @@ flowchart TB > **Recommended OS: Ubuntu 24.04 LTS (x86_64).** Other modern Linux distributions may work but have not had the same validation coverage. -**Requirements:** a server running 3X-UI **>= 3.6.0**, root access to it, and `curl`, `tar`, and `sha256sum` (present on virtually all Linux systems). Automatic activation also needs `sqlite3`. +**Requirements:** a server running 3X-UI **>= 3.6.0**, PasarGuard, or Rebecca; root access to it; and `curl`, `tar`, and `sha256sum` (present on virtually all Linux systems). Automatic activation on 3X-UI and Rebecca also needs `sqlite3`. -Run as **root** on the server that hosts your 3X-UI panel: +Run as **root** on the server that hosts your panel: ```bash bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) @@ -160,7 +164,7 @@ The installer: 1. Downloads the latest stable release from GitHub. 2. Verifies its SHA-256 checksum (mandatory — no bypass). -3. Extracts it safely and installs to `/etc/3x-ui/sub_templates/row-template`. +3. Detects your panel, extracts the release safely and installs to `/etc/3x-ui/sub_templates/row-template` (3X-UI) or `/etc/row-template` (PasarGuard, Rebecca). 4. On a fresh install, offers the design chooser (Enter keeps Row). 5. Prompts for your branding (service name, support link, logo — all optional). 6. Generates and validates the page, then activates it in the panel where possible. @@ -171,6 +175,12 @@ To choose a design without the chooser, for example in a script: RT_TEMPLATE=editorial bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) ``` +On a server that runs more than one supported panel, the installer asks which one to serve; in a script, name it with `RT_PANEL` (`3xui`, `pasarguard` or `rebecca`): + +```bash +RT_PANEL=pasarguard bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) +``` + If you prefer not to pipe from the network, download the four release assets (`install.sh`, `manifest.txt`, `SHA256SUMS`, and `row-template-.tar.gz`) from the [Releases page](https://github.com/iitzSeriZdev/Row-Template/releases/latest) into one folder, verify the checksum yourself as described in [PROVENANCE.md](PROVENANCE.md), and point the installer at that folder: ```bash @@ -179,19 +189,37 @@ RT_RELEASE_DIR=/root/row-template-release bash /root/row-template-release/instal ### Activation -Row-Template installs to a directory that the panel serves as its subscription page: +An interactive install shows what activation will change and asks first. On PasarGuard and Rebecca, activation runs as a transaction: the panel's state is snapshotted, changed, verified, and — if any step fails — restored exactly. + +**3X-UI.** Row-Template installs to a directory that the panel serves as its subscription page: ``` /etc/3x-ui/sub_templates/row-template ``` -- **Automatic:** when `sqlite3` is available, Row-Template sets it for you. It briefly stops the panel service, writes the setting, starts the service again, and checks the value. An interactive install shows the current setting and asks first. +- **Automatic:** when `sqlite3` is available, Row-Template sets it for you. It briefly stops the panel service, writes the setting, starts the service again, and checks the value. - **Manual:** otherwise, open **Panel Settings → Subscription → Profile → Sub Theme Directory** and enter exactly: ``` /etc/3x-ui/sub_templates/row-template ``` +**PasarGuard.** The page is placed at `/var/lib/pasarguard/templates/row-template/index.html` (or inside your own `CUSTOM_TEMPLATES_DIRECTORY`, if you set one), and a marked block is appended to `/opt/pasarguard/.env`: + +``` +# >>> row-template (managed by Row-Template; do not edit) nl=0 >>> +CUSTOM_TEMPLATES_DIRECTORY = "/var/lib/pasarguard/templates" +SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" +# <<< row-template <<< +``` + +PasarGuard reads `.env` at start-up, so a running panel is restarted once. None of your own lines are edited; uninstall removes the block and returns `.env` to its exact previous bytes. An admin with their own subscription template, or the **disable subscription template** setting, still takes precedence — `row-template verify` tells you when either applies. + +**Rebecca.** The page is placed at `/var/lib/rebecca/templates/row-template/index.html` (or inside your own custom templates directory), and Rebecca's subscription settings are set to `row-template/index.html`. Rebecca reads them on every request, so no restart is needed. + +- **Automatic** with the default SQLite database and `sqlite3` installed. +- **Manual** with MySQL/MariaDB (or without `sqlite3`): the page is still placed; in the Rebecca dashboard open **Settings → Subscription → Templates** and set **Subscription page template** to `row-template/index.html` and **Custom templates directory** to `/var/lib/rebecca/templates`. + ## Usage Run the manager with no arguments in a terminal to open the interactive menu: @@ -208,23 +236,23 @@ Or use a command directly: | `row-template update` | Download, verify, and activate the latest stable release (checksum enforced) | | `row-template rollback` | Restore a previous version (`--auto` or `--to `) | | `row-template verify` | Check the install, the panel wiring, and the live page (as root, it also puts back missing or misplaced designs) | -| `row-template version` | Show the installed, minimum-supported, and detected 3X-UI versions | -| `row-template uninstall` | Remove Row-Template and revert the panel to its built-in page | +| `row-template version` | Show the installed version and the panel it serves (on 3X-UI, also the minimum-supported and detected versions) | +| `row-template uninstall` | Remove Row-Template and return the panel to the page it had before | | `row-template help` | Show usage | Commands that change the system (`config`, `update`, `rollback`, `uninstall`) must run as root. - **Branding** is stored as data, never executed, and injected into the page as text. Leave a field blank for an unbranded page. The support link accepts only schemes a browser should open, such as `https://…`, `tg://…`, or `mailto:…`. - **Updates** come from the public stable channel. `row-template update` always applies the latest stable release, even the version you already run; the manager's **Update** compares versions first and asks before changing anything. If the release source is unreachable, nothing is changed and your installation is never treated as damaged. -- **Updating from 1.1.0** takes one `row-template update`. 1.1.0's own updater copies only part of the new release, so the next `row-template`, `row-template config`, or `row-template verify` run as root first downloads the rest of that same release — every design, checksum-verified. -- **Rollback** restores a previous version from a validated backup. The current version is snapshotted first, so a failed rollback can be recovered, and your branding is preserved. -- **Uninstall** removes Row-Template's files. It clears the panel's `subThemeDir` only if it points at Row-Template, so the panel falls back to its built-in page; your inbounds, clients, and certificates are not touched. +- **Updating from 1.1.0 or 1.2.x** takes one `row-template update`. 1.1.0's own updater copies only part of the new release, so the next `row-template`, `row-template config`, or `row-template verify` run as root first downloads the rest of that same release — every design, checksum-verified. Your design, branding, and panel wiring are kept. +- **Rollback** restores a previous version from a validated backup. The current version is snapshotted first, so a failed rollback can be recovered, and your branding is preserved. Backups record the panel they were made on and are never restored onto another; a backup from an older release that does not name its design restores as Row. +- **Uninstall** removes Row-Template's files and returns the panel to the page it had before: on 3X-UI it clears `subThemeDir` only if it points at Row-Template; on PasarGuard it removes its `.env` block and its page; on Rebecca it restores the two subscription settings it changed (leaving them alone if you have since chosen another page). Your users, inbounds, clients, nodes, and certificates are not touched. The [documentation](https://iitzseridev.github.io/Row-Template/) covers configuration, branding, and troubleshooting in more depth. ## Development -The pages are built from readable sources in `src/`. You need Node.js 22 or newer, and Go 1.22 or newer to run the tests. +The pages are built from readable sources in `src/`. You need Node.js 22 or newer; to run the tests, also Go 1.22 or newer and Python 3 with Jinja2 (`pip install jinja2`), which render the PasarGuard and Rebecca pages with those panels' real engines. ```bash npm run build # regenerate template/index.html from src/ @@ -239,7 +267,7 @@ The build is deterministic — the same sources always produce a byte-identical ## Testing -- **`npm test`** renders every design's fixture pages with the Go renderer, then runs the suites: the page's scripts, the build, every design's artifact, the release payload, and the installer — which runs the shipped shell library in real `bash` against throwaway fixtures. +- **`npm test`** renders every design's fixture pages with the Go renderer, then runs the suites: the page's scripts, the build, every design's artifact, the PasarGuard and Rebecca pages rendered by real Jinja2 and pongo2 (including hostile and malformed data), the release payload, and the installer — which runs the shipped shell library and every panel adapter in real `bash` against throwaway hosts laid out like each panel's official install. - **`npm run verify`** checks a built page against its safety gates, including: a whole document, every build marker substituted, everything inlined, no remote references, no forbidden constructs, intact translations, and no invisible characters in the sources. - **`npm run lint:sh`** fails on any ShellCheck error; `npm run lint:sh -- -S warning` shows the full report. - **The Docs workflow** builds the documentation site on every pull request that changes it. @@ -248,18 +276,17 @@ The build is deterministic — the same sources always produce a byte-identical Direction, not promises: -- **Row-Template 1.2.0** — the fifteen designs and the design chooser described above. -- **PasarGuard and Rebecca** — research. Page shells are built for both; live status needs a small runtime change or a reverse proxy, and that decision is deferred. See [Compatibility](https://iitzseridev.github.io/Row-Template/compatibility/). -- **Installing on more than one panel** — the installer groundwork (a panel interface, a transaction engine, a 3X-UI adapter, and a new backup format) is in place but not yet used by any command. +- **Row-Template 1.3.0** — PasarGuard and Rebecca support, and the Meter and Notebook designs, described above. +- **Live status on PasarGuard and Rebecca** — both serve it on a path suffix rather than `?format=info`; wiring it up needs a small runtime change, and that decision is deferred. See [Compatibility](https://iitzseridev.github.io/Row-Template/compatibility/). - **Custom templates** — a proposal for adding your own design: [`docs/design/CUSTOM-TEMPLATES-PROPOSAL.md`](docs/design/CUSTOM-TEMPLATES-PROPOSAL.md). ## Contributing Bug reports, translations, and documentation fixes are very welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request, and follow the [Code of Conduct](CODE_OF_CONDUCT.md). -**Bug reports:** open an issue at . Include your Row-Template version (`row-template version`), 3X-UI version, operating system and version, CPU architecture, the output of `row-template verify`, and clear steps to reproduce. +**Bug reports:** open an issue at . Include your Row-Template version (`row-template version`), your panel and its version, operating system and version, CPU architecture, the output of `row-template verify`, and clear steps to reproduce. -> **Do not include secrets.** Never paste subscription URLs, `subId` values, client UUIDs, panel usernames or passwords, cookies, tokens, the panel `webBasePath`, TLS keys, or real server addresses. Redact logs before sharing them. +> **Do not include secrets.** Never paste subscription URLs, `subId` values, client UUIDs, panel usernames or passwords, cookies, tokens, the panel `webBasePath`, the contents of `.env`, database URLs, TLS keys, or real server addresses. Redact logs before sharing them. ## Security diff --git a/README.ru.md b/README.ru.md index 8ec6099..182017f 100644 --- a/README.ru.md +++ b/README.ru.md @@ -7,7 +7,7 @@

- Аккуратная автономная страница подписки для панелей 3X-UI — пятнадцать дизайнов, каждый в одном HTML-файле, полностью white-label и без сторонних запросов со страницы, которую открывают ваши подписчики. + Аккуратная автономная страница подписки для панелей 3X-UI, PasarGuard и Rebecca — семнадцать дизайнов, каждый в одном HTML-файле, полностью white-label и без сторонних запросов со страницы, которую открывают ваши подписчики.

@@ -17,7 +17,7 @@

License Latest release - Panel + Panels Platform Documentation

@@ -34,21 +34,21 @@ ## Что это такое -3X-UI умеет показывать подписчикам собственную страницу вместо встроенной. Row-Template — такая страница: подписчик открывает ссылку на подписку и видит свой тариф, расход трафика, дату окончания и способы добавить подписку в своё приложение в одно касание. +3X-UI, PasarGuard и Rebecca умеют показывать подписчикам собственную страницу вместо встроенной. Row-Template — такая страница: подписчик открывает ссылку на подписку и видит свой тариф, расход трафика, дату окончания и способы добавить подписку в своё приложение в одно касание. -Каждый дизайн поставляется одним автономным HTML-файлом, в который встроены все стили, скрипты, шрифты и генератор QR-кодов. Одна команда устанавливает его рядом с панелью, направляет на него панель и даёт вам менеджер `row-template` для оформления, обновлений и отката. +Каждый дизайн поставляется одним автономным HTML-файлом, в который встроены все стили, скрипты, шрифты и генератор QR-кодов, а также версией на языке шаблонов каждой панели. Одна команда определяет вашу панель, устанавливает страницу рядом с ней, направляет на неё панель и даёт вам менеджер `row-template` для оформления, обновлений и отката. ## Почему Row-Template? - **Приватность по умолчанию.** Страница, которую открывают подписчики, не делает сторонних запросов. QR-коды генерируются прямо на странице, а ваше оформление вставляется как текст — никогда не выполняется и никуда не отправляется. - **Настоящий white-label.** Ваше название сервиса, ваша ссылка на поддержку, ваш логотип. Ничто на странице не указывает на Row-Template. -- **Пятнадцать дизайнов, каждый в одном файле.** Выберите вид, который подходит вашему сервису. У всех дизайнов одинаковые возможности, языки и проверки безопасности. +- **Семнадцать дизайнов, каждый в одном файле.** Выберите вид, который подходит вашему сервису. У всех дизайнов одинаковые возможности, языки и проверки безопасности — на каждой поддерживаемой панели. - **Сделано для ваших подписчиков.** Расход и срок действия в реальном времени, импорт в популярные приложения в одно касание и список отдельных конфигураций с поиском, чтобы добавить один сервер вручную. -- **Безопасен в эксплуатации.** Релизы с проверкой контрольной суммы, атомарная активация и откат одной командой. Row-Template никогда не патчит 3X-UI: единственная настройка панели, которую он меняет, — каталог страницы подписки (`subThemeDir`). +- **Безопасен в эксплуатации.** Релизы с проверкой контрольной суммы, транзакционная активация, которая точно восстанавливает панель при сбое любого шага, и откат одной командой. Row-Template никогда не патчит вашу панель: в 3X-UI он меняет одну настройку (`subThemeDir`), в PasarGuard добавляет один помеченный блок в `.env`, а в Rebecca задаёт два поля настроек подписки. ## Дизайны -Row-Template 1.2.0 поставляется с пятнадцатью дизайнами. Дизайн по умолчанию — Row. +Row-Template 1.3.0 поставляется с семнадцатью дизайнами. Дизайн по умолчанию — Row. @@ -72,6 +72,10 @@ Row-Template 1.2.0 поставляется с пятнадцатью дизай + + + +
Terminal Nova
Terminal Nova
Arcade Nova
Arcade Nova
Meter
Meter
Notebook
Notebook
Превью построены на демонстрационных данных самого проекта. Превью каждого дизайна для компьютера и телефона — в галерее шаблонов. @@ -82,7 +86,7 @@ Row-Template 1.2.0 поставляется с пятнадцатью дизай **Для ваших подписчиков** -- **Статус в реальном времени.** Состояние тарифа, израсходованный и оставшийся трафик и срок действия, которые обновляются из вашей панели, пока страница открыта на экране. +- **Статус в реальном времени.** Состояние тарифа, израсходованный и оставшийся трафик и срок действия, которые обновляются из вашей панели, пока страница открыта на экране (в 3X-UI; в PasarGuard и Rebecca страница показывает значения на момент открытия). - **Импорт в одно касание** в популярные приложения, сгруппированные по платформам: v2rayNG, Happ и sing-box на Android; Streisand, V2Box и Shadowrocket на iOS; Clash Verge Rev, Mihomo Party и v2rayN на Windows; Clash Verge Rev, Streisand и V2Box на macOS. - **Копирование и QR.** Скопируйте ссылку на подписку или отсканируйте её как QR-код, созданный на самой странице. - **Обозреватель конфигураций.** Каждый сервер в отдельной строке — с флагом страны или монограммой и меткой протокола (VLESS, VMess, Trojan, Shadowsocks, Hysteria/Hysteria2, WireGuard, AmneziaWG, Telegram MTProto), с QR-кодом и копированием для каждой конфигурации и поиском по длинным спискам. @@ -99,17 +103,17 @@ Row-Template 1.2.0 поставляется с пятнадцатью дизай - **Никаких сторонних запросов** со страницы: без CDN, без внешних сервисов QR и геолокации, без телеметрии. Статус в реальном времени приходит из вашей же панели. - **Обязательная проверка SHA-256** для каждой загрузки релиза, без возможности её пропустить. - **Атомарная активация.** Новая страница создаётся и проверяется до того, как заменит работающую, поэтому неудачный шаг никогда не оставляет сломанную страницу. -- **Осторожное обнаружение панели.** Если найденная база данных панели не является корректной базой SQLite, Row-Template отказывается её использовать, а не пытается угадать другую. +- **Осторожное обнаружение панели.** Панель считается установленной, только когда совпадают независимые признаки; частично установленная панель или база данных панели, не являющаяся корректной базой SQLite, отвергается, а не угадывается. ## Поддерживаемые панели | Панель | Статус | Примечания | | ----- | ------ | ----- | | [3X-UI](https://github.com/MHSanaei/3x-ui) (MHSanaei) | ✅ Поддерживается | Требуется версия **>= 3.6.0** | -| [PasarGuard](https://github.com/PasarGuard/panel) | 🔬 Исследование | Не поддерживается; установка не предусмотрена | -| [Rebecca](https://github.com/rebeccapanel/Rebecca) | 🔬 Исследование | Не поддерживается; установка не предусмотрена | +| [PasarGuard](https://github.com/PasarGuard/panel) | ✅ Поддерживается с 1.3.0 | Официальная установка в Docker или установка из исходников (`pasarguard.service`) | +| [Rebecca](https://github.com/rebeccapanel/Rebecca) | ✅ Поддерживается с 1.3.0 | Автоматическая активация с SQLite и `sqlite3`; с MySQL/MariaDB — одна настройка в панели управления | -3X-UI — единственная поддерживаемая панель. PasarGuard и Rebecca используют другие шаблонизаторы (Jinja2 и pongo2); оболочка страницы каждого дизайна собирается для них и упаковывается в релиз для изучения, но установщик её не размещает, и инструкций по установке для них нет. Результаты исследования — в разделе [Совместимость](https://iitzseridev.github.io/Row-Template/compatibility/). +Три панели используют три разных шаблонизатора — Go `html/template`, Jinja2 и pongo2, — поэтому каждый дизайн собирается отдельно для каждой панели, и каждая версия проверяется отрисовкой настоящим движком этой панели. Установщик определяет, какая панель стоит на сервере; если их несколько, он спрашивает (или читает `RT_PANEL`). **Поддерживается** означает, что для этой панели есть все семь возможностей — обнаружение, установка, активация, проверка, резервная копия, восстановление и удаление, — и каждая из них покрыта тестами. Подробности по каждой панели — в разделе [Совместимость](https://iitzseridev.github.io/Row-Template/compatibility/). ## Архитектура @@ -117,14 +121,14 @@ Row-Template 1.2.0 поставляется с пятнадцатью дизай flowchart TB subgraph build ["Build and release"] direction LR - SRC["src/
runtime, styles, locales,
15 design layouts"] --> BUILD["tools/build.mjs"] - BUILD --> ART["One self-contained
HTML file per design"] + SRC["src/
runtime, styles, locales,
17 design layouts"] --> BUILD["tools/build.mjs"] + BUILD --> ART["One self-contained
HTML file per design,
per panel"] ART --> REL["tools/make-release.sh
tarball + SHA256SUMS"] end - subgraph host ["Your 3X-UI server"] + subgraph host ["Your panel server"] direction LR - INST["install.sh / row-template
verify checksum, stage, validate,
back up, activate"] --> DIR["/etc/3x-ui/
sub_templates/row-template"] - DIR -- "subThemeDir" --> XUI["3X-UI renders the page
with the subscriber's data"] + INST["install.sh / row-template
verify checksum, detect panel,
back up, activate, verify"] --> DIR["3X-UI: subThemeDir
PasarGuard: .env block
Rebecca: subscription settings"] + DIR --> XUI["The panel renders the page
with the subscriber's data"] end build -- "GitHub Releases" --> host host -- "serves the page" --> BROWSER["Subscriber's browser"] @@ -132,15 +136,15 @@ flowchart TB ``` - **Один файл на дизайн.** `tools/build.mjs` встраивает общий код, переводы, шрифты и генератор QR в макет дизайна и отклоняет макет, в котором нет хотя бы одной нужной коду точки привязки (hook). Затем `tools/verify.mjs` отклоняет файл, который загружает что-либо извне или содержит запрещённую конструкцию. -- **Страницу отрисовывает панель.** Страница — это шаблон: 3X-UI подставляет данные подписчика при выдаче, а затем страница обновляет свой статус из той же панели. -- **Установщик никогда не редактирует 3X-UI.** Он пишет в собственный каталог и меняет одну настройку панели, `subThemeDir`, чтобы она указывала на него. +- **Страницу отрисовывает панель.** Страница — это шаблон: панель подставляет данные подписчика при выдаче. Для PasarGuard (Jinja2) и Rebecca (pongo2) каждый дизайн обёрнут в небольшую преамбулу, которая сопоставляет собственные данные панели полям страницы и экранирует каждое значение. +- **Установщик никогда не патчит вашу панель.** В 3X-UI он направляет `subThemeDir` на свой каталог; в PasarGuard кладёт страницу в каталог шаблонов и дописывает в `.env` один помеченный блок; в Rebecca кладёт страницу и задаёт поля страницы и каталога в настройках подписки. Перед каждым изменением делается снимок, и при любом сбое всё точно восстанавливается. | Путь | Содержимое | | ---- | ---------------- | | `src/` | Код, стили и переводы страницы; каждый дизайн — в `src/templates//` | | `template/index.html` | Собранная страница Row, хранится в репозитории | | `tools/` | Сборка, проверка, релизы и рендерер фикстур на Go | -| `installer/` | `install.sh`, команда `row-template` и её библиотека управления | +| `installer/` | `install.sh`, команда `row-template`, её библиотека управления и по одному адаптеру на панель в `installer/panels/` | | `tests/` | Наборы тестов | | `docs/` | Сайт документации; проектные записи — в [`docs/design/`](docs/design/README.md) | @@ -148,9 +152,9 @@ flowchart TB > **Рекомендуемая ОС: Ubuntu 24.04 LTS (x86_64).** Другие современные дистрибутивы Linux могут работать, но не проходили такого же объёма проверок. -**Требования:** сервер с 3X-UI **>= 3.6.0**, root-доступ к нему и `curl`, `tar` и `sha256sum` (есть практически в любой системе Linux). Для автоматической активации также нужен `sqlite3`. +**Требования:** сервер с 3X-UI **>= 3.6.0**, PasarGuard или Rebecca; root-доступ к нему; `curl`, `tar` и `sha256sum` (есть практически в любой системе Linux). Для автоматической активации в 3X-UI и Rebecca также нужен `sqlite3`. -Запустите от имени **root** на сервере, где работает ваша панель 3X-UI: +Запустите от имени **root** на сервере, где работает ваша панель: ```bash bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) @@ -160,7 +164,7 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d 1. Скачивает последний стабильный релиз с GitHub. 2. Проверяет его контрольную сумму SHA-256 (обязательно — без возможности обойти). -3. Безопасно распаковывает его и устанавливает в `/etc/3x-ui/sub_templates/row-template`. +3. Определяет вашу панель, безопасно распаковывает релиз и устанавливает его в `/etc/3x-ui/sub_templates/row-template` (3X-UI) или `/etc/row-template` (PasarGuard, Rebecca). 4. При новой установке предлагает выбрать дизайн (Enter оставляет Row). 5. Запрашивает ваше оформление (название сервиса, ссылка на поддержку, логотип — всё необязательно). 6. Создаёт и проверяет страницу, а затем, если возможно, активирует её в панели. @@ -171,6 +175,12 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d RT_TEMPLATE=editorial bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) ``` +На сервере, где работает несколько поддерживаемых панелей, установщик спрашивает, какую обслуживать; в скрипте укажите её через `RT_PANEL` (`3xui`, `pasarguard` или `rebecca`): + +```bash +RT_PANEL=pasarguard bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) +``` + Если вы не хотите запускать скрипт прямо из сети, скачайте четыре файла релиза (`install.sh`, `manifest.txt`, `SHA256SUMS` и `row-template-.tar.gz`) со [страницы релизов](https://github.com/iitzSeriZdev/Row-Template/releases/latest) в одну папку, проверьте контрольную сумму самостоятельно, как описано в [PROVENANCE.md](PROVENANCE.md), и укажите установщику эту папку: ```bash @@ -179,19 +189,37 @@ RT_RELEASE_DIR=/root/row-template-release bash /root/row-template-release/instal ### Активация -Row-Template устанавливается в каталог, который панель отдаёт как страницу подписки: +Интерактивная установка сначала показывает, что изменит активация, и спрашивает разрешения. В PasarGuard и Rebecca активация выполняется как транзакция: состояние панели сохраняется в снимок, изменение применяется и проверяется, а при сбое любого шага панель точно восстанавливается. + +**3X-UI.** Row-Template устанавливается в каталог, который панель отдаёт как страницу подписки: ``` /etc/3x-ui/sub_templates/row-template ``` -- **Автоматически:** если доступен `sqlite3`, Row-Template настраивает это за вас. Он ненадолго останавливает службу панели, записывает настройку, снова запускает службу и проверяет значение. Интерактивная установка сначала показывает текущее значение и спрашивает разрешения. +- **Автоматически:** если доступен `sqlite3`, Row-Template настраивает это за вас. Он ненадолго останавливает службу панели, записывает настройку, снова запускает службу и проверяет значение. - **Вручную:** иначе откройте **Panel Settings → Subscription → Profile → Sub Theme Directory** и введите в точности: ``` /etc/3x-ui/sub_templates/row-template ``` +**PasarGuard.** Страница размещается в `/var/lib/pasarguard/templates/row-template/index.html` (или в вашем `CUSTOM_TEMPLATES_DIRECTORY`, если он задан), а в `/opt/pasarguard/.env` дописывается помеченный блок: + +``` +# >>> row-template (managed by Row-Template; do not edit) nl=0 >>> +CUSTOM_TEMPLATES_DIRECTORY = "/var/lib/pasarguard/templates" +SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" +# <<< row-template <<< +``` + +PasarGuard читает `.env` при запуске, поэтому работающая панель перезапускается один раз. Ни одна ваша строка не редактируется; удаление убирает блок и возвращает `.env` точно к прежним байтам. Администратор с собственным шаблоном подписки или настройка **disable subscription template** по-прежнему имеют приоритет — `row-template verify` сообщит, если действует что-то из этого. + +**Rebecca.** Страница размещается в `/var/lib/rebecca/templates/row-template/index.html` (или в вашем собственном каталоге шаблонов), а в настройках подписки Rebecca выбирается `row-template/index.html`. Rebecca читает эти настройки при каждом запросе, поэтому перезапуск не нужен. + +- **Автоматически** — с базой SQLite по умолчанию и установленным `sqlite3`. +- **Вручную** — с MySQL/MariaDB (или без `sqlite3`): страница всё равно размещается; в панели управления Rebecca откройте **Settings → Subscription → Templates** и задайте **Subscription page template** = `row-template/index.html` и **Custom templates directory** = `/var/lib/rebecca/templates`. + ## Использование Запустите менеджер без аргументов в терминале, чтобы открыть интерактивное меню: @@ -208,23 +236,23 @@ row-template | `row-template update` | Скачивает, проверяет и активирует последний стабильный релиз (проверка контрольной суммы обязательна) | | `row-template rollback` | Восстанавливает предыдущую версию (`--auto` или `--to `) | | `row-template verify` | Проверяет установку, связь с панелью и работающую страницу (от root также возвращает на место отсутствующие или перемещённые дизайны) | -| `row-template version` | Показывает установленную, минимально поддерживаемую и обнаруженную версии 3X-UI | -| `row-template uninstall` | Удаляет Row-Template и возвращает панели встроенную страницу | +| `row-template version` | Показывает установленную версию и обслуживаемую панель (в 3X-UI — также минимально поддерживаемую и обнаруженную версии) | +| `row-template uninstall` | Удаляет Row-Template и возвращает панели страницу, которая была до него | | `row-template help` | Показывает справку | Команды, изменяющие систему (`config`, `update`, `rollback`, `uninstall`), нужно запускать от имени root. - **Оформление** хранится как данные, никогда не выполняется и вставляется в страницу как текст. Оставьте поле пустым, чтобы получить страницу без брендинга. Ссылка на поддержку принимает только схемы, которые браузер должен открывать, например `https://…`, `tg://…` или `mailto:…`. - **Обновления** берутся из публичного стабильного канала. `row-template update` всегда применяет последний стабильный релиз, даже если он уже установлен; пункт **Update** в менеджере сначала сравнивает версии и спрашивает перед любым изменением. Если источник релизов недоступен, ничего не меняется, и установка никогда не считается повреждённой. -- **Обновление с 1.1.0** выполняется одним запуском `row-template update`. Механизм обновления самой версии 1.1.0 копирует лишь часть нового релиза, поэтому следующий запуск `row-template`, `row-template config` или `row-template verify` от root сначала загружает остальную часть того же релиза — все дизайны, с проверкой контрольных сумм. -- **Откат** восстанавливает предыдущую версию из проверенной резервной копии. Сначала делается снимок (snapshot) текущей версии, поэтому неудачный откат можно исправить, а ваше оформление сохраняется. -- **Удаление** стирает файлы Row-Template. Настройку `subThemeDir` панели оно очищает, только если та указывает на Row-Template, и панель возвращается к встроенной странице; ваши inbound'ы, клиенты и сертификаты не затрагиваются. +- **Обновление с 1.1.0 или 1.2.x** выполняется одним запуском `row-template update`. Механизм обновления самой версии 1.1.0 копирует лишь часть нового релиза, поэтому следующий запуск `row-template`, `row-template config` или `row-template verify` от root сначала загружает остальную часть того же релиза — все дизайны, с проверкой контрольных сумм. Ваш дизайн, оформление и подключение к панели сохраняются. +- **Откат** восстанавливает предыдущую версию из проверенной резервной копии. Сначала делается снимок (snapshot) текущей версии, поэтому неудачный откат можно исправить, а ваше оформление сохраняется. Резервные копии запоминают панель, на которой созданы, и никогда не восстанавливаются на другую; копия из старого релиза, в которой не записан дизайн, восстанавливается как Row. +- **Удаление** стирает файлы Row-Template и возвращает панели страницу, которая была до него: в 3X-UI очищает `subThemeDir`, только если та указывает на Row-Template; в PasarGuard убирает свой блок из `.env` и свою страницу; в Rebecca восстанавливает две изменённые настройки подписки (и не трогает их, если вы с тех пор выбрали другую страницу). Ваши пользователи, inbound'ы, клиенты, ноды и сертификаты не затрагиваются. [Документация](https://iitzseridev.github.io/Row-Template/) подробнее описывает настройку, оформление и устранение неполадок. ## Разработка -Страницы собираются из читаемых исходников в `src/`. Нужен Node.js 22 или новее, а для запуска тестов — Go 1.22 или новее. +Страницы собираются из читаемых исходников в `src/`. Нужен Node.js 22 или новее; для запуска тестов — также Go 1.22 или новее и Python 3 с Jinja2 (`pip install jinja2`), которые отрисовывают страницы PasarGuard и Rebecca настоящими движками этих панелей. ```bash npm run build # regenerate template/index.html from src/ @@ -239,7 +267,7 @@ npm run preview # preview the fixture pages at http://127.0.0.1:8787 ## Тестирование -- **`npm test`** сначала создаёт страницы фикстур всех дизайнов рендерером на Go, а затем запускает наборы тестов: скрипты страницы, сборку, итоговый файл каждого дизайна, содержимое релиза и установщик — его опубликованная shell-библиотека выполняется в настоящем `bash` на временных фикстурах. +- **`npm test`** сначала создаёт страницы фикстур всех дизайнов рендерером на Go, а затем запускает наборы тестов: скрипты страницы, сборку, итоговый файл каждого дизайна, страницы PasarGuard и Rebecca, отрисованные настоящими Jinja2 и pongo2 (в том числе с враждебными и повреждёнными данными), содержимое релиза и установщик — его опубликованная shell-библиотека и адаптер каждой панели выполняются в настоящем `bash` на временных хостах, устроенных как официальная установка каждой панели. - **`npm run verify`** проверяет собранную страницу по её правилам безопасности, в том числе: цельный документ, замена всех маркеров сборки, всё встроено, нет внешних ссылок, нет запрещённых конструкций, целые переводы и отсутствие невидимых символов в исходниках. - **`npm run lint:sh`** завершается ошибкой при любой ошибке ShellCheck; `npm run lint:sh -- -S warning` показывает полный отчёт. - **Workflow Docs** собирает сайт документации в каждом pull request, который его меняет. @@ -248,18 +276,17 @@ npm run preview # preview the fixture pages at http://127.0.0.1:8787 Направление, а не обещания: -- **Row-Template 1.2.0** — пятнадцать дизайнов и выбор дизайна, описанные выше. -- **PasarGuard и Rebecca** — исследование. Оболочки страниц для обеих собраны; для статуса в реальном времени нужно небольшое изменение в коде или обратный прокси (reverse proxy), и это решение отложено. См. [Совместимость](https://iitzseridev.github.io/Row-Template/compatibility/). -- **Установка на несколько панелей** — основа установщика (интерфейс панели, движок транзакций, адаптер 3X-UI и новый формат резервных копий) готова, но пока не используется ни одной командой. +- **Row-Template 1.3.0** — поддержка PasarGuard и Rebecca и дизайны Meter и Notebook, описанные выше. +- **Статус в реальном времени в PasarGuard и Rebecca** — обе отдают его по суффиксу пути, а не через `?format=info`; для подключения нужно небольшое изменение в коде, и это решение отложено. См. [Совместимость](https://iitzseridev.github.io/Row-Template/compatibility/). - **Собственные шаблоны** — предложение о добавлении своего дизайна: [`docs/design/CUSTOM-TEMPLATES-PROPOSAL.md`](docs/design/CUSTOM-TEMPLATES-PROPOSAL.md). ## Участие в проекте Сообщения об ошибках, переводы и исправления документации очень приветствуются. Прочитайте [CONTRIBUTING.md](CONTRIBUTING.md), прежде чем открывать pull request, и соблюдайте [Кодекс поведения](CODE_OF_CONDUCT.md). -**Сообщения об ошибках:** создайте issue на . Укажите версию Row-Template (`row-template version`), версию 3X-UI, операционную систему и её версию, архитектуру процессора, вывод `row-template verify` и чёткие шаги для воспроизведения. +**Сообщения об ошибках:** создайте issue на . Укажите версию Row-Template (`row-template version`), вашу панель и её версию, операционную систему и её версию, архитектуру процессора, вывод `row-template verify` и чёткие шаги для воспроизведения. -> **Не указывайте секретные данные.** Никогда не вставляйте URL подписок, значения `subId`, UUID клиентов, имена пользователей и пароли панели, cookie, токены, панельный `webBasePath`, ключи TLS или реальные адреса серверов. Скрывайте конфиденциальные данные в логах перед тем, как ими делиться. +> **Не указывайте секретные данные.** Никогда не вставляйте URL подписок, значения `subId`, UUID клиентов, имена пользователей и пароли панели, cookie, токены, панельный `webBasePath`, содержимое `.env`, URL баз данных, ключи TLS или реальные адреса серверов. Скрывайте конфиденциальные данные в логах перед тем, как ими делиться. ## Безопасность diff --git a/README.zh-CN.md b/README.zh-CN.md index 97b9371..882cba4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,7 +6,7 @@

- 为 3X-UI 面板打造的精致、自包含的订阅页面 —— 十五种设计,每种都是一个 HTML 文件,完全白标(white-label),订阅者打开的页面不会向任何第三方发出请求。 + 为 3X-UI、PasarGuard 和 Rebecca 面板打造的精致、自包含的订阅页面 —— 十七种设计,每种都是一个 HTML 文件,完全白标(white-label),订阅者打开的页面不会向任何第三方发出请求。

@@ -16,7 +16,7 @@

License Latest release - Panel + Panels Platform Documentation

@@ -33,21 +33,21 @@ ## 这是什么 -3X-UI 可以向订阅者展示自定义页面来代替内置页面。Row-Template 就是这样一个页面:订阅者打开订阅链接,就能看到自己的套餐、用量和到期日期,以及一键把订阅添加到所用应用的方式。 +3X-UI、PasarGuard 和 Rebecca 都可以向订阅者展示自定义页面来代替内置页面。Row-Template 就是这样一个页面:订阅者打开订阅链接,就能看到自己的套餐、用量和到期日期,以及一键把订阅添加到所用应用的方式。 -每种设计都以一个自包含的 HTML 文件提供,所有样式、脚本、字体和二维码生成器都已内联其中。一条命令即可把它安装到面板旁边、让面板指向它,并为你提供 `row-template` 管理器,用于品牌设置、更新和回滚。 +每种设计都以一个自包含的 HTML 文件提供,所有样式、脚本、字体和二维码生成器都已内联其中,并为每个面板提供一个使用该面板自身模板语言的版本。一条命令即可检测你的面板、把页面安装到面板旁边、让面板指向它,并为你提供 `row-template` 管理器,用于品牌设置、更新和回滚。 ## 为什么选择 Row-Template? - **隐私优先。** 订阅者打开的页面不会向任何第三方发出请求。二维码在页面内生成,你的品牌信息以文本形式注入 —— 从不执行,也从不发送到任何地方。 - **真正的白标。** 你的服务名称、你的支持链接、你的徽标。所呈现的页面上没有任何内容标明 Row-Template。 -- **十五种设计,每种一个文件。** 选择适合你服务的外观。所有设计共享相同的功能、语言和安全检查。 +- **十七种设计,每种一个文件。** 选择适合你服务的外观。所有设计在每个受支持的面板上都共享相同的功能、语言和安全检查。 - **为你的订阅者而设计。** 实时显示用量和到期时间,一键导入常用应用,以及可搜索的单独配置列表,便于手动添加单个服务器。 -- **运维安全。** 经校验和验证的发布、原子化激活以及一条命令即可回滚。它从不修补 3X-UI:它唯一会修改的面板设置是订阅页面目录(`subThemeDir`)。 +- **运维安全。** 经校验和验证的发布、任何一步失败都会把面板精确恢复原状的事务式激活,以及一条命令即可回滚。它从不修补你的面板:在 3X-UI 上只修改一项设置(`subThemeDir`),在 PasarGuard 上向 `.env` 追加一个带标记的块,在 Rebecca 上设置订阅设置中的两个字段。 ## 设计 -Row-Template 1.2.0 提供十五种设计,默认设计为 Row。 +Row-Template 1.3.0 提供十七种设计,默认设计为 Row。 @@ -71,6 +71,10 @@ Row-Template 1.2.0 提供十五种设计,默认设计为 Row。 + + + +
Terminal Nova
Terminal Nova
Arcade Nova
Arcade Nova
Meter
Meter
Notebook
Notebook
预览图使用项目自带的示例数据渲染。每种设计的桌面端和移动端预览见模板画廊。 @@ -81,7 +85,7 @@ Row-Template 1.2.0 提供十五种设计,默认设计为 Row。 **面向你的订阅者** -- **实时状态。** 套餐状态、已用和剩余流量以及到期时间,在页面可见期间从你的面板刷新。 +- **实时状态。** 套餐状态、已用和剩余流量以及到期时间,在页面可见期间从你的面板刷新(3X-UI;在 PasarGuard 和 Rebecca 上,页面显示打开时的数值)。 - **一键导入**常用应用,按平台分组:Android 上的 v2rayNG、Happ 和 sing-box;iOS 上的 Streisand、V2Box 和 Shadowrocket;Windows 上的 Clash Verge Rev、Mihomo Party 和 v2rayN;macOS 上的 Clash Verge Rev、Streisand 和 V2Box。 - **复制与二维码。** 复制订阅链接,或扫描在页面内生成的二维码。 - **配置浏览器。** 每个服务器单独一行,带有国家旗帜或首字母徽章(monogram)以及协议标签(VLESS、VMess、Trojan、Shadowsocks、Hysteria/Hysteria2、WireGuard、AmneziaWG、Telegram MTProto),每个配置都可查看二维码和复制,长列表支持搜索。 @@ -98,17 +102,17 @@ Row-Template 1.2.0 提供十五种设计,默认设计为 Row。 - 所呈现的页面**不向第三方发出任何请求**:没有 CDN,没有外部二维码或地理定位服务,没有遥测。实时状态来自你自己的面板。 - 每次下载发布版本都**强制进行 SHA-256 校验**,且没有跳过的选项。 - **原子化激活。** 新页面在替换当前页面之前先生成并通过验证,因此失败的步骤绝不会让损坏的页面上线。 -- **谨慎的面板检测。** 如果 Row-Template 找到的面板数据库不是有效的 SQLite 数据库,它会拒绝使用,而不是去猜测另一个数据库。 +- **谨慎的面板检测。** 只有在多个独立迹象一致时才认为面板已安装;只安装了一半的面板,或不是有效 SQLite 数据库的面板数据库,都会被拒绝,而不是去猜测。 ## 支持的面板 | 面板 | 状态 | 说明 | | ----- | ------ | ----- | | [3X-UI](https://github.com/MHSanaei/3x-ui) (MHSanaei) | ✅ 已支持 | 需要 **>= 3.6.0** 版本 | -| [PasarGuard](https://github.com/PasarGuard/panel) | 🔬 研究中 | 不受支持;没有安装途径 | -| [Rebecca](https://github.com/rebeccapanel/Rebecca) | 🔬 研究中 | 不受支持;没有安装途径 | +| [PasarGuard](https://github.com/PasarGuard/panel) | ✅ 自 1.3.0 起支持 | 官方 Docker 安装或源码安装(`pasarguard.service`) | +| [Rebecca](https://github.com/rebeccapanel/Rebecca) | ✅ 自 1.3.0 起支持 | 使用 SQLite 和 `sqlite3` 时自动激活;使用 MySQL/MariaDB 时需在控制台中填写一项设置 | -3X-UI 是唯一受支持的面板。PasarGuard 和 Rebecca 使用不同的模板引擎(Jinja2 和 pongo2);每种设计都会为它们构建页面外壳并打包进发布版本以供研究,但安装程序不会部署它,也没有针对它们的安装说明。研究结果见[兼容性](https://iitzseridev.github.io/Row-Template/compatibility/)。 +三个面板使用三种不同的模板引擎 —— Go `html/template`、Jinja2 和 pongo2 —— 因此每种设计都会为每个面板分别构建,并用该面板真实的引擎渲染来测试每个版本。安装程序会检测服务器上是哪一个面板;如果有多个,它会询问你(或读取 `RT_PANEL`)。**支持**意味着该面板具备全部七项能力 —— 检测、安装、激活、校验、备份、还原和卸载 —— 并且每一项都由测试套件覆盖。各面板的详细信息见[兼容性](https://iitzseridev.github.io/Row-Template/compatibility/)。 ## 架构 @@ -116,14 +120,14 @@ Row-Template 1.2.0 提供十五种设计,默认设计为 Row。 flowchart TB subgraph build ["Build and release"] direction LR - SRC["src/
runtime, styles, locales,
15 design layouts"] --> BUILD["tools/build.mjs"] - BUILD --> ART["One self-contained
HTML file per design"] + SRC["src/
runtime, styles, locales,
17 design layouts"] --> BUILD["tools/build.mjs"] + BUILD --> ART["One self-contained
HTML file per design,
per panel"] ART --> REL["tools/make-release.sh
tarball + SHA256SUMS"] end - subgraph host ["Your 3X-UI server"] + subgraph host ["Your panel server"] direction LR - INST["install.sh / row-template
verify checksum, stage, validate,
back up, activate"] --> DIR["/etc/3x-ui/
sub_templates/row-template"] - DIR -- "subThemeDir" --> XUI["3X-UI renders the page
with the subscriber's data"] + INST["install.sh / row-template
verify checksum, detect panel,
back up, activate, verify"] --> DIR["3X-UI: subThemeDir
PasarGuard: .env block
Rebecca: subscription settings"] + DIR --> XUI["The panel renders the page
with the subscriber's data"] end build -- "GitHub Releases" --> host host -- "serves the page" --> BROWSER["Subscriber's browser"] @@ -131,15 +135,15 @@ flowchart TB ``` - **每种设计一个文件。** `tools/build.mjs` 将共享的运行时代码、翻译、字体和二维码生成器内联到设计的布局中,并拒绝缺少任何运行时所需钩子(hook)的布局。随后 `tools/verify.mjs` 会拒绝任何加载远程资源或包含禁用结构的文件。 -- **由面板负责渲染。** 页面是一个模板:3X-UI 在提供页面时填入订阅者的数据,之后页面再从同一面板刷新状态。 -- **安装程序从不修改 3X-UI。** 它只写入自己的目录,并修改一项面板设置 `subThemeDir`,使其指向该目录。 +- **由面板负责渲染。** 页面是一个模板:面板在提供页面时填入订阅者的数据。对于 PasarGuard(Jinja2)和 Rebecca(pongo2),每种设计都包在一段小的前导代码中,它把面板自身的数据映射到页面字段并对每个值进行转义。 +- **安装程序从不修补你的面板。** 在 3X-UI 上,它让 `subThemeDir` 指向自己的目录;在 PasarGuard 上,它把页面放入模板目录,并向 `.env` 追加一个带标记的块;在 Rebecca 上,它放置页面并设置订阅设置中的页面和目录字段。每次修改前都会先做快照,任何一步失败都会精确恢复。 | 路径 | 内容 | | ---- | ---------------- | | `src/` | 页面的运行时代码、样式和翻译;每种设计位于 `src/templates//` | | `template/index.html` | 构建好的 Row 页面,已提交到仓库 | | `tools/` | 构建、验证、发布以及 Go 编写的 fixture 渲染器 | -| `installer/` | `install.sh`、`row-template` 命令及其管理库 | +| `installer/` | `install.sh`、`row-template` 命令、其管理库,以及 `installer/panels/` 中每个面板各一个的适配器 | | `tests/` | 测试套件 | | `docs/` | 文档站点;设计记录位于 [`docs/design/`](docs/design/README.md) | @@ -147,9 +151,9 @@ flowchart TB > **推荐操作系统:Ubuntu 24.04 LTS (x86_64)。** 其他较新的 Linux 发行版或许也能运行,但未经过同等程度的验证覆盖。 -**环境要求:** 运行 3X-UI **>= 3.6.0** 的服务器、该服务器的 root 权限,以及 `curl`、`tar` 和 `sha256sum`(几乎所有 Linux 系统都自带)。自动激活还需要 `sqlite3`。 +**环境要求:** 运行 3X-UI **>= 3.6.0**、PasarGuard 或 Rebecca 的服务器;该服务器的 root 权限;以及 `curl`、`tar` 和 `sha256sum`(几乎所有 Linux 系统都自带)。在 3X-UI 和 Rebecca 上自动激活还需要 `sqlite3`。 -在托管 3X-UI 面板的服务器上以 **root** 身份运行: +在托管你的面板的服务器上以 **root** 身份运行: ```bash bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) @@ -159,7 +163,7 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d 1. 从 GitHub 下载最新的稳定版本。 2. 校验其 SHA-256 校验和(强制 —— 无法绕过)。 -3. 安全地解压并安装到 `/etc/3x-ui/sub_templates/row-template`。 +3. 检测你的面板,安全地解压发布包并安装到 `/etc/3x-ui/sub_templates/row-template`(3X-UI)或 `/etc/row-template`(PasarGuard、Rebecca)。 4. 全新安装时显示设计选择器(按 Enter 保留 Row)。 5. 提示你设置品牌信息(服务名称、支持链接、徽标 —— 均为可选)。 6. 生成并验证页面,然后在可能的情况下在面板中激活它。 @@ -170,6 +174,12 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d RT_TEMPLATE=editorial bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) ``` +在运行多个受支持面板的服务器上,安装程序会询问要为哪一个提供页面;在脚本中,可用 `RT_PANEL`(`3xui`、`pasarguard` 或 `rebecca`)指定: + +```bash +RT_PANEL=pasarguard bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/download/install.sh) +``` + 如果你不希望直接从网络通过管道执行,可以从 [Releases 页面](https://github.com/iitzSeriZdev/Row-Template/releases/latest)将四个发布文件(`install.sh`、`manifest.txt`、`SHA256SUMS` 和 `row-template-.tar.gz`)下载到同一个文件夹,按照 [PROVENANCE.md](PROVENANCE.md) 中的说明自行校验校验和,然后让安装程序使用该文件夹: ```bash @@ -178,19 +188,37 @@ RT_RELEASE_DIR=/root/row-template-release bash /root/row-template-release/instal ### 激活 -Row-Template 安装在一个由面板作为订阅页面提供的目录中: +交互式安装会先说明激活将修改什么,并征求你的同意。在 PasarGuard 和 Rebecca 上,激活以事务方式进行:先为面板状态做快照,再应用并验证修改;任何一步失败,面板都会被精确恢复。 + +**3X-UI。** Row-Template 安装在一个由面板作为订阅页面提供的目录中: ``` /etc/3x-ui/sub_templates/row-template ``` -- **自动:** 当 `sqlite3` 可用时,Row-Template 会替你完成设置。它会短暂停止面板服务、写入设置、重新启动服务并核对该值。交互式安装会先显示当前设置并征求你的同意。 +- **自动:** 当 `sqlite3` 可用时,Row-Template 会替你完成设置。它会短暂停止面板服务、写入设置、重新启动服务并核对该值。 - **手动:** 否则,请打开 **Panel Settings → Subscription → Profile → Sub Theme Directory** 并准确输入: ``` /etc/3x-ui/sub_templates/row-template ``` +**PasarGuard。** 页面放在 `/var/lib/pasarguard/templates/row-template/index.html`(如果你设置了自己的 `CUSTOM_TEMPLATES_DIRECTORY`,则放在其中),并向 `/opt/pasarguard/.env` 追加一个带标记的块: + +``` +# >>> row-template (managed by Row-Template; do not edit) nl=0 >>> +CUSTOM_TEMPLATES_DIRECTORY = "/var/lib/pasarguard/templates" +SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" +# <<< row-template <<< +``` + +PasarGuard 在启动时读取 `.env`,因此正在运行的面板会重启一次。你自己的任何一行都不会被修改;卸载会删除该块,并把 `.env` 精确恢复为之前的字节。拥有自己订阅模板的管理员,或 **disable subscription template** 设置,仍然优先 —— 如果其中之一生效,`row-template verify` 会告诉你。 + +**Rebecca。** 页面放在 `/var/lib/rebecca/templates/row-template/index.html`(或你自己的自定义模板目录中),并把 Rebecca 的订阅设置设为 `row-template/index.html`。Rebecca 在每次请求时读取这些设置,因此无需重启。 + +- **自动:** 使用默认的 SQLite 数据库并已安装 `sqlite3` 时。 +- **手动:** 使用 MySQL/MariaDB(或没有 `sqlite3`)时:页面仍会放好;在 Rebecca 控制台中打开 **Settings → Subscription → Templates**,把 **Subscription page template** 设为 `row-template/index.html`,把 **Custom templates directory** 设为 `/var/lib/rebecca/templates`。 + ## 使用 在终端中不带参数运行管理器以打开交互式菜单: @@ -207,23 +235,23 @@ row-template | `row-template update` | 下载、校验并激活最新的稳定版本(强制校验校验和) | | `row-template rollback` | 恢复到之前的版本(`--auto` 或 `--to `) | | `row-template verify` | 检查安装、面板连接和当前页面(以 root 运行时还会补回缺失或放错位置的设计) | -| `row-template version` | 显示已安装版本、最低支持版本以及检测到的 3X-UI 版本 | -| `row-template uninstall` | 移除 Row-Template 并让面板恢复其内置页面 | +| `row-template version` | 显示已安装版本及其服务的面板(在 3X-UI 上还显示最低支持版本和检测到的版本) | +| `row-template uninstall` | 移除 Row-Template 并让面板恢复之前使用的页面 | | `row-template help` | 显示用法 | 会修改系统的命令(`config`、`update`、`rollback`、`uninstall`)必须以 root 身份运行。 - **品牌信息**以数据形式存储,从不执行,并以文本形式注入页面。将某个字段留空即可得到无品牌的页面。支持链接只接受浏览器应当打开的协议,例如 `https://…`、`tg://…` 或 `mailto:…`。 - **更新**来自公共稳定通道。`row-template update` 总是应用最新的稳定版本,即使你已安装的就是该版本;管理器中的 **Update** 会先比较版本,并在做出任何更改前询问。如果无法访问发布源,则不会做任何更改,你的安装也绝不会因此被视为已损坏。 -- **从 1.1.0 更新**只需运行一次 `row-template update`。1.1.0 自带的更新程序只会复制新版本的一部分,因此下一次以 root 运行 `row-template`、`row-template config` 或 `row-template verify` 时,会先下载同一版本的其余部分——所有设计,并校验 checksum。 -- **回滚**会从经过验证的备份中恢复之前的版本。系统会先为当前版本创建快照(snapshot),因此失败的回滚也可以恢复,且你的品牌配置会被保留。 -- **卸载**会移除 Row-Template 的文件。只有当面板的 `subThemeDir` 指向 Row-Template 时才会将其清除,使面板恢复内置页面;你的入站(inbound)、客户端和证书都不会受到影响。 +- **从 1.1.0 或 1.2.x 更新**只需运行一次 `row-template update`。1.1.0 自带的更新程序只会复制新版本的一部分,因此下一次以 root 运行 `row-template`、`row-template config` 或 `row-template verify` 时,会先下载同一版本的其余部分——所有设计,并校验 checksum。你的设计、品牌配置和面板连接都会保留。 +- **回滚**会从经过验证的备份中恢复之前的版本。系统会先为当前版本创建快照(snapshot),因此失败的回滚也可以恢复,且你的品牌配置会被保留。备份会记录其所在的面板,绝不会恢复到另一个面板上;来自旧版本、未记录设计名称的备份会按 Row 恢复。 +- **卸载**会移除 Row-Template 的文件,并让面板恢复之前使用的页面:在 3X-UI 上,只有当 `subThemeDir` 指向 Row-Template 时才会将其清除;在 PasarGuard 上,删除它在 `.env` 中的块和它的页面;在 Rebecca 上,恢复它修改过的两项订阅设置(如果你此后已选择了其他页面,则不做改动)。你的用户、入站(inbound)、客户端、节点和证书都不会受到影响。 [文档](https://iitzseridev.github.io/Row-Template/)更详细地介绍了配置、品牌设置和故障排查。 ## 开发 -页面由 `src/` 中可读的源代码构建而成。你需要 Node.js 22 或更高版本,运行测试还需要 Go 1.22 或更高版本。 +页面由 `src/` 中可读的源代码构建而成。你需要 Node.js 22 或更高版本;运行测试还需要 Go 1.22 或更高版本,以及带 Jinja2 的 Python 3(`pip install jinja2`),它们用这两个面板真实的引擎渲染 PasarGuard 和 Rebecca 页面。 ```bash npm run build # regenerate template/index.html from src/ @@ -238,7 +266,7 @@ npm run preview # preview the fixture pages at http://127.0.0.1:8787 ## 测试 -- **`npm test`** 先用 Go 渲染器生成所有设计的 fixture 页面,然后运行各测试套件:页面脚本、构建、每种设计的最终文件、发布包内容以及安装程序 —— 其发布的 shell 库会在真实的 `bash` 中针对临时 fixture 运行。 +- **`npm test`** 先用 Go 渲染器生成所有设计的 fixture 页面,然后运行各测试套件:页面脚本、构建、每种设计的最终文件、由真实 Jinja2 和 pongo2 渲染的 PasarGuard 和 Rebecca 页面(包括恶意和畸形数据)、发布包内容以及安装程序 —— 其发布的 shell 库和每个面板的适配器会在真实的 `bash` 中,针对按各面板官方安装方式布置的临时主机运行。 - **`npm run verify`** 按照安全关卡检查构建好的页面,包括:完整的文档、所有构建标记均已替换、所有内容均已内联、没有远程引用、没有禁用结构、翻译完整,以及源代码中没有不可见字符。 - **`npm run lint:sh`** 遇到任何 ShellCheck 错误即失败;`npm run lint:sh -- -S warning` 会显示完整报告。 - **Docs 工作流**会在每个修改文档站点的 pull request 中构建该站点。 @@ -247,18 +275,17 @@ npm run preview # preview the fixture pages at http://127.0.0.1:8787 这是方向,而非承诺: -- **Row-Template 1.2.0** —— 上文介绍的十五种设计和设计选择器。 -- **PasarGuard 和 Rebecca** —— 研究中。两者的页面外壳均已构建;实时状态需要对运行时代码做一处小改动或使用反向代理(reverse proxy),这一决定已推迟。参见[兼容性](https://iitzseridev.github.io/Row-Template/compatibility/)。 -- **在多个面板上安装** —— 安装程序的基础设施(面板接口、事务引擎、3X-UI 适配器和新的备份格式)已经就绪,但尚未被任何命令使用。 +- **Row-Template 1.3.0** —— 上文介绍的 PasarGuard 和 Rebecca 支持,以及 Meter 和 Notebook 设计。 +- **PasarGuard 和 Rebecca 上的实时状态** —— 两者都通过路径后缀而不是 `?format=info` 提供它;接入需要对运行时代码做一处小改动,这一决定已推迟。参见[兼容性](https://iitzseridev.github.io/Row-Template/compatibility/)。 - **自定义模板** —— 关于添加你自己设计的提案:[`docs/design/CUSTOM-TEMPLATES-PROPOSAL.md`](docs/design/CUSTOM-TEMPLATES-PROPOSAL.md)。 ## 参与贡献 非常欢迎问题反馈、翻译和文档修正。在提交 pull request 之前请阅读 [CONTRIBUTING.md](CONTRIBUTING.md),并遵守[行为准则](CODE_OF_CONDUCT.md)。 -**问题反馈:** 请在 提交 issue。请附上你的 Row-Template 版本(`row-template version`)、3X-UI 版本、操作系统及其版本、CPU 架构、`row-template verify` 的输出,以及清晰的复现步骤。 +**问题反馈:** 请在 提交 issue。请附上你的 Row-Template 版本(`row-template version`)、你的面板及其版本、操作系统及其版本、CPU 架构、`row-template verify` 的输出,以及清晰的复现步骤。 -> **请勿包含机密信息。** 切勿粘贴订阅 URL、`subId` 值、客户端 UUID、面板用户名或密码、Cookie、令牌、面板的 `webBasePath`、TLS 密钥或真实的服务器地址。分享日志前请先对其做脱敏处理。 +> **请勿包含机密信息。** 切勿粘贴订阅 URL、`subId` 值、客户端 UUID、面板用户名或密码、Cookie、令牌、面板的 `webBasePath`、`.env` 的内容、数据库 URL、TLS 密钥或真实的服务器地址。分享日志前请先对其做脱敏处理。 ## 安全 diff --git a/docs/src/content/docs/ar/compatibility.mdx b/docs/src/content/docs/ar/compatibility.mdx index c166b39..a91b1ae 100644 --- a/docs/src/content/docs/ar/compatibility.mdx +++ b/docs/src/content/docs/ar/compatibility.mdx @@ -1,90 +1,112 @@ --- title: التوافق -description: ما هو مدعوم في بيئة الإنتاج اليوم، وما هو قيد البحث. +description: اللوحات التي يدعمها رو-تمبلت، وما يغيّره في كل منها، وما لا يزال ناقصًا. --- -## المدعوم اليوم +## اللوحات المدعومة + +| | ‎3X-UI‎ | ‎PasarGuard‎ | ‎Rebecca‎ | +|---|---|---|---| +| **مدعومة منذ** | ‎1.0.0‎ | ‎1.3.0‎ | ‎1.3.0‎ | +| **الإصدارات** | **‎3.6.0‎ أو أحدث** | ‎5.x‎ — التثبيت الرسمي عبر ‎Docker‎ أو التثبيت من المصدر | ‎1.3‎ أو أحدث — ‎Docker‎ أو ملف تنفيذي | +| **محرك القوالب** | ‎`html/template`‎ في ‎Go‎ | ‎Jinja2‎ | ‎pongo2‎ (بأسلوب ‎Django‎، في ‎Go‎) | +| **جذر التثبيت** | ‎`/etc/3x-ui/sub_templates/row-template`‎ | ‎`/etc/row-template`‎ | ‎`/etc/row-template`‎ | +| **ما يغيّره التفعيل** | ‎`subThemeDir`‎ | كتلة معلَّمة واحدة في ‎`/opt/pasarguard/.env`‎ | حقلان من أحدث صف في إعدادات الاشتراك | +| **يحتاج التفعيل التلقائي إلى** | ‎`sqlite3`‎ | لا شيء إضافي | ‎SQLite‎ و‎`sqlite3`‎ | +| **إعادة التشغيل عند التفعيل** | نعم، لفترة وجيزة | مرة واحدة، فقط إن كانت تعمل | أبدًا | +| **تحديث الحالة الحيّة** | ✅ | — (القيم لحظة فتح الصفحة) | — (القيم لحظة فتح الصفحة) | | | | |---|---| -| **اللوحة** | **‎3X-UI‎ الإصدار ‎3.6.0‎ أو أحدث** | | **المنصة** | أي لينكس يحتوي على ‎`bash`‎ و‎`coreutils`‎ و‎`curl`‎ و‎`tar`‎ و‎`sha256sum`‎ | -| **بيئة التشغيل** | لا شيء — بلا Node.js‎ وبلا Python وبلا قاعدة بيانات | +| **بيئة التشغيل** | لا شيء — بلا ‎Node.js‎ وبلا ‎Python‎ وبلا قاعدة بيانات خاصة بنا | | **اللغات** | الإنجليزية والفارسية والعربية والروسية والصينية المبسّطة | -‎3X-UI‎ هي اللوحة **الوحيدة** المدعومة في بيئة الإنتاج. تعليمات التثبيت في هذا الموقع -مخصّصة لها وحدها. - -## البحث وخريطة الطريق - -> **‎PasarGuard‎ و‎Rebecca‎ هدفان بحثيان، وليستا لوحتين مدعومتين في الإنتاج.** -> **لا توجد تعليمات تثبيت لهما، ولا ينبغي أن تحاول.** - -يجري دراستهما لتوافق مستقبلي، والنتائج مسجّلة هنا لتكون حالة العمل صريحة لا ضمنية. - -### لماذا ليست مسألة نسخ ولصق - -اللوحات الثلاث **لا تشترك في محرك قوالب واحد**: - -| اللوحة | المحرك | -|---|---| -| **‎3X-UI‎** | ‎Go `html/template`‎ | -| **‎PasarGuard‎** | ‎Jinja2‎ | -| **‎Rebecca‎** | ‎pongo2‎ (بنمط Django، بلغة Go) | - -هيكل صفحة الاشتراك مستند Go-template. والصيغة نفسها صحيحة في واحدة من الثلاث فقط. هذه -مسألة ترجمة حقيقية، لا مسألة تهيئة. - -### ما هو مشترك وما ليس كذلك - -**الطبقة البصرية CSS خالص** وقابلة للنقل بالكامل بين اللوحات الثلاث. أما الهيكل — أي HTML -وإجراءات القوالب التي يحتويها — فهو المرتبط بالمحرك. - -إذن فتوافق اللوحات مسألة **هيكل ومحرك**، لا مسألة تصميم. وهذا مشجٍّ، وهو سبب استحقاق هذا -العمل. - -### الحالة الحيّة - -تقدّم ‎3X-UI‎ الحالة الحيّة عبر ‎`?format=info`‎. أما اللوحتان الأخريان فتقدّمان المعلومة -نفسها على **لاحقة مسار** لا معامل استعلام، لذا يتطلب الوصول إليها تغييرًا صغيرًا في وقت -التشغيل أو وسيطًا عكسيًا. وقد أُجّل هذا القرار عن قصد. - -### الحالة - -ما يستطيع المثبّت فعله على كل لوحة اليوم. تتحقّق مجموعة الاختبارات -(‎`tests/panel-support.test.mjs`‎) من هذا الجدول مقابل المثبّت نفسه، فلا يمكنه أن يدّعي أكثر -ممّا تفعله الشيفرة. - -| اللوحة | الاكتشاف | التثبيت | التفعيل | التحقق | النسخ الاحتياطي والاستعادة | هيكل الصفحة | الحالة | -|---|---|---|---|---|---|---|---| -| ‎3X-UI‎ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **مدعومة** | -| ‎PasarGuard‎ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | بحث — غير مدعومة، بلا تعليمات تثبيت | -| ‎Rebecca‎ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | بحث — غير مدعومة، بلا تعليمات تثبيت | - -على خادم فيه ‎PasarGuard‎ أو ‎Rebecca‎ من دون ‎3X-UI‎، يتوقّف المثبّت بالرسالة -‎`no 3x-ui installation was detected on this host`‎ ولا يغيّر شيئًا. - -#### ما هو موجود لـ‎PasarGuard‎ و‎Rebecca‎ - -- **هيكل صفحة لكل تصميم** بلغة قوالب اللوحة نفسها (‎Jinja2‎ لـ‎PasarGuard‎ - و‎pongo2‎ لـ‎Rebecca‎). يبنيها كل إصدار ويحزمها تحت ‎`shells/`‎، ولا يضعها - المثبّت في مكانها. -- **محوّلات بيانات** تربط بيانات الاشتراك في كل لوحة بالحقول التي تستخدمها الصفحة، مختبَرة - على استجابات نموذجية كُتبت من الشيفرة المصدرية لكل لوحة — لا مأخوذة من لوحة قيد التشغيل. -- **اختبارات عرض** تملأ كل هيكل بتلك البيانات، باستخدام مُصيِّر اختباري صغير لا يفهم إلا الصيغة - التي تستخدمها الهياكل؛ ولم يُعرَض أي هيكل بعد على خادم ‎PasarGuard‎ أو ‎Rebecca‎ - حقيقي. - -#### ما يلزم قبل دعم أيٍّ منهما - -- محوّل في المثبّت: اكتشاف اللوحة وإعداداتها وخدمتها. -- وضع الهيكل حيث تقرأ اللوحة قوالبها، وتفعيله فيها، والتحقق من هذا التغيير واستعادته. -- الحالة الحيّة، التي تقدّمها اللوحتان على لاحقة مسار (انظر أعلاه). -- حالة الاشتراك ‎`on_hold`‎. تملكها اللوحتان، ولا تملك الصفحة بعد طريقة لعرضها، لذا - ترفضها المحوّلات. -- اختبار على خادم حقيقي لكلٍّ من اللوحتين. +يكتشف المثبّت اللوحة الموجودة على الخادم. وعلى خادم فيه أكثر من لوحة يسألك — أو يقرأ في +السكربت ‎`RT_PANEL=3xui|pasarguard|rebecca`‎. ولا تُعدّ اللوحة مثبّتة إلا حين تتفق إشارتان +مستقلتان (إعداداتها، أو ملف ‎compose‎ أو خدمتها، أو أداة سطر الأوامر، أو مجلد بياناتها)؛ أما +اللوحة الموجودة جزئيًا فتُرفض بدلًا من التخمين. + +## الحالة + +ما يستطيع المثبّت فعله على كل لوحة. تتحقق مجموعة الاختبارات (‎`tests/panel-support.test.mjs`‎) +من هذا الجدول مقابل المثبّت، فلا يمكنه أن يدّعي أكثر مما تفعله الشيفرة. + +| اللوحة | الاكتشاف | التثبيت | التفعيل | التحقق | النسخ الاحتياطي | الاستعادة | إلغاء التثبيت | هيكل الصفحة | الحالة | +|---|---|---|---|---|---|---|---|---|---| +| ‎3X-UI‎ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **مدعومة** | +| ‎PasarGuard‎ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **مدعومة** (‎1.3.0‎) | +| ‎Rebecca‎ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **مدعومة** (‎1.3.0‎) — ‎MySQL/MariaDB‎: اختيار يدوي | + +## لماذا يُبنى كل تصميم ثلاث مرات + +اللوحات الثلاث **لا تشترك في محرك قوالب واحد**، والصيغة نفسها صالحة في واحدة منها فقط. لذا +يُبنى كل تصميم مرة لكل لوحة: + +- في **‎3X-UI‎** الصفحة مستند ‎Go-template‎. +- في **‎PasarGuard‎** و**‎Rebecca‎** يُغلَّف التخطيط نفسه بـ**مقدّمة** صغيرة تستخرج كل قيمة تقرؤها + الصفحة من بيانات اشتراك اللوحة نفسها، ويقع المتن كله داخل كتلة **‎autoescape‎** صريحة. يعمل + ‎Jinja2‎ في ‎PasarGuard‎ والتهريب التلقائي **معطّل**، فلولا هذه الكتلة لأمكن لاسم مستخدم أو + وصف رابط أن يحقن ترميزًا؛ ومعها تُهرَّب كل قيمة على كل لوحة. + +**الطبقة المرئية ‎CSS‎ خالص** ومتطابقة على اللوحات الثلاث. وتُختبر صفحات كل لوحة بعرضها +**بمحرّكها الحقيقي** — ‎Jinja2‎ لـ‎PasarGuard‎، و‎pongo2‎ الإصدار ‎v6.1.0‎ (الإصدار المثبّت في +‎Rebecca‎) لـ‎Rebecca‎ — بما في ذلك مع بيانات عدائية ومشوّهة. + +## ‎PasarGuard‎ + +- **موضع الصفحة.** ‎`/var/lib/pasarguard/templates/row-template/index.html`‎، أو داخل + ‎`CUSTOM_TEMPLATES_DIRECTORY`‎ الخاص بك إن ضبطته. في تثبيت ‎Docker‎ يجب أن يكون ذلك المجلد + داخل ‎`/var/lib/pasarguard`‎ الذي تتشاركه الحاوية مع المضيف؛ وأي مجلد آخر يُرفض قبل أي تغيير. +- **الاختيار.** كتلة تُلحق بـ‎`/opt/pasarguard/.env`‎: + + ``` + # >>> row-template (managed by Row-Template; do not edit) nl=0 >>> + CUSTOM_TEMPLATES_DIRECTORY = "/var/lib/pasarguard/templates" + SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" + # <<< row-template <<< + ``` + + آخر إسناد للمفتاح هو الذي يُعتمد، فلا يُعدَّل أي سطر من أسطرك. ويزيل إلغاء التثبيت الكتلة + فيعود ‎`.env`‎ إلى بايتاته السابقة بدقة. +- **إعادة التشغيل.** تقرأ ‎PasarGuard‎ ملف ‎`.env`‎ عند الإقلاع، لذا تُعاد تشغيل اللوحة العاملة + مرة واحدة. ولا تُشغَّل اللوحة المتوقفة. +- **ما يبقى له الأولوية.** المشرف الذي له قالب اشتراك خاص، وإعداد **‎disable subscription + template‎**. كلاهما من اختيارك؛ ويبلّغ عنهما ‎`row-template verify`‎. +- **الأسرار.** يحتوي ‎`.env`‎ على كلمة مرور المشرف ومفتاح ‎JWT‎ ورابط قاعدة البيانات. لا يُقرأ + إلا لمفتاحَي الصفحة، ولا يُطبع أبدًا، ولا يُنسخ إلى أي نسخة احتياطية. + +## ‎Rebecca‎ + +- **موضع الصفحة.** ‎`/var/lib/rebecca/templates/row-template/index.html`‎، أو داخل مجلد القوالب + المخصّص الخاص بك. +- **الاختيار.** حقلا الصفحة والمجلد في أحدث صف من ‎`subscription_settings`‎ — الصف الذي تقرؤه + ‎Rebecca‎ مع كل طلب، فلا حاجة إلى إعادة التشغيل أبدًا. لا يُضبط المجلد إلا إن لم يكن لديك + مجلد؛ وتُستعاد كلٌّ من ‎`NULL`‎ والقيمة الفارغة والقيمة المحددة بدقة. +- **قاعدة البيانات.** يحتاج التفعيل التلقائي إلى قاعدة بيانات **‎SQLite‎** الافتراضية والأمر + ‎`sqlite3`‎. ومع **‎MySQL/MariaDB‎** تُوضع الصفحة رغم ذلك ويطبع المثبّت القيمتين اللتين تُدخلهما + في **‎Settings → Subscription → Templates‎**. لا تُطلب كلمة مرور قاعدة البيانات أبدًا ولا تُقرأ + ولا تُطبع. +- **ما يبقى له الأولوية.** إعدادات قالب الاشتراك الخاصة بمشرف ما؛ ويذكر ‎`row-template verify`‎ + عدد المشرفين الذين لديهم ذلك. + +## حدود معروفة + +- **الحالة الحيّة.** تقدّم ‎3X-UI‎ الحالة الحيّة على ‎`?format=info`‎. أما ‎PasarGuard‎ و‎Rebecca‎ + فتقدّمانها على **لاحقة مسار**، لذا تعرض الصفحة على هاتين اللوحتين القيم لحظة فتحها ولا + تحدّثها. ويحتاج ربط اللاحقة إلى تغيير صغير في الشيفرة؛ وقد أُرجئ هذا القرار عمدًا. +- **عنوان صفحة ‎PasarGuard‎** (‎`subTitle`‎) و**قوالب ‎Clash‎** لا تُنتَج؛ تُنتَج صفحة الاشتراك + فقط. +- **حالة ‎`on_hold`‎.** يظهر الاشتراك الذي يبدأ مع أول اتصال على أنه نشط. في ‎PasarGuard‎، التي + تعطي الصفحة مدة الانتظار، يُقرأ الانتهاء «يبدأ مع أول اتصال · صالح لـ N يومًا بعد ذلك»؛ وفي + ‎Rebecca‎، التي لا تعطيها، يُعرض الانتهاء مجهولًا بدلًا من التخمين. راجع سجل القرار + [‎`docs/design/PANEL-ON-HOLD-DECISION.md`‎](https://github.com/iitzSeriZdev/Row-Template/blob/main/docs/design/PANEL-ON-HOLD-DECISION.md). + +عمليات تدقيق المصدر وراء كل عبارة في هذه الصفحة موجودة في ‎`docs/design/`‎: +‎`PASARGUARD-INSTALLER-AUDIT.md`‎ و‎`REBECCA-INSTALLER-AUDIT.md`‎ و‎`PASARGUARD-ADAPTER-AUDIT.md`‎ +و‎`REBECCA-ADAPTER-AUDIT.md`‎. ## الخطوة التالية -- [التثبيت](/ar/installation/) — للوحة المدعومة -- [مرجع المطوّرين](/ar/developer/) — البنية التي يقوم عليها هذا البحث +- [التثبيت](/ar/installation/) — لكل لوحة مدعومة +- [المعمارية للمطورين](/ar/developer/) — المعمارية وراء دعم اللوحات diff --git a/docs/src/content/docs/ar/configuration.mdx b/docs/src/content/docs/ar/configuration.mdx index 2f3f92d..12970fd 100644 --- a/docs/src/content/docs/ar/configuration.mdx +++ b/docs/src/content/docs/ar/configuration.mdx @@ -18,8 +18,8 @@ row-template | ‎`row-template update`‎ | تنزيل إصدار أحدث والتحقق منه وتفعيله (checksum إلزامي) | نعم — root | | ‎`row-template rollback`‎ | استعادة إصدار سابق (‎`--auto`‎ أو ‎`--to `‎) | نعم — root | | ‎`row-template verify`‎ | فحص التثبيت وربط اللوحة والعرض الحيّ | **لا** — بصلاحيات ‎root‎ يصلح مخزن التصاميم فقط | -| ‎`row-template version`‎ | عرض الإصدار المثبَّت والحد الأدنى المدعوم وإصدار ‎3X-UI‎ المكتشف | **لا** — قراءة فقط | -| ‎`row-template uninstall`‎ | إزالة رو-تمبلت وإعادة اللوحة إلى صفحتها المدمجة | نعم — root | +| ‎`row-template version`‎ | عرض الإصدار المثبَّت واللوحة التي يخدمها (وفي ‎3X-UI‎ أيضًا الحد الأدنى المدعوم والإصدار المكتشف) | **لا** — قراءة فقط | +| ‎`row-template uninstall`‎ | إزالة رو-تمبلت وإعادة اللوحة إلى الصفحة التي كانت لديها من قبل | نعم — root | | ‎`row-template menu`‎ | فتح المدير التفاعلي صراحةً | — | | ‎`row-template help`‎ | عرض الاستخدام | — | @@ -37,7 +37,7 @@ row-template row-template version ``` -يطبع الإصدار المثبَّت والحد الأدنى المدعوم وإصدار ‎3X-UI‎ المكتشف على الخادم. +يطبع الإصدار المثبَّت واللوحة التي يخدمها؛ وفي ‎3X-UI‎ أيضًا الحد الأدنى المدعوم والإصدار المكتشف على الخادم. ```bash row-template verify @@ -53,13 +53,15 @@ row-template verify row-template update ``` -يتحقّق من قناة الإصدارات المستقرة العامة، ويعرض الإصدار المثبَّت والمتاح، ويحدّث **فقط عند -وجود إصدار مستقر أحدث**. وإذا تعذّر الوصول إلى الشبكة أو المصدر، يذكر أنه لم يستطع التحقق — -ولا يُعدّ تثبيتك تالفًا أبدًا. +ينزّل أحدث إصدار من قناة الإصدارات المستقرة العامة ويتحقق منه ويطبّقه — حتى لو كان هو +الإصدار المثبَّت لديك، مما يجعله إصلاحًا سريعًا. أما خيار **‎Update‎** في المدير فيقارن +الإصدارات أولًا ويسأل قبل أي تغيير. وإذا تعذّر الوصول إلى الشبكة أو المصدر لا يتغيّر شيء، ولا +يُعدّ تثبيتك تالفًا أبدًا. -**تُحفظ تهيئة هويتك عبر التحديثات.** +**تُحفظ هويتك والتصميم المختار وربط اللوحة عبر التحديثات.** في ‎PasarGuard‎ و‎Rebecca‎ تُستبدل +الصفحة الموضوعة في مكانها؛ ولا يُمسّ اختيار اللوحة، ولا يُعاد تشغيل شيء. -**التحديث من ‎1.1.0‎ يكفيه تشغيل ‎`row-template update`‎ مرة واحدة.** ينسخ مُحدِّث ‎1.1.0‎ +**التحديث من ‎1.1.0‎ أو ‎1.2.x‎ يكفيه تشغيل ‎`row-template update`‎ مرة واحدة.** ينسخ مُحدِّث ‎1.1.0‎ نفسه جزءًا فقط من الإصدار الجديد. وفي المرة التالية التي تفتح فيها ‎`row-template`‎، أو تشغّل ‎`row-template config`‎ أو ‎`row-template verify`‎ بصلاحيات ‎root‎، تُنزَّل بقية الإصدار نفسه — كل التصاميم، مع التحقق من ‎checksum‎ — قبل أي شيء آخر. ولا يُنزَّل إصدار أحدث أبدًا، @@ -72,7 +74,8 @@ row-template rollback ``` يستعيد الإصدار السابق من نسخة احتياطية موثّقة. ويُؤخذ أولًا نسخة من الإصدار الحالي، لذا -يمكن استدراك عملية استعادة فاشلة. +يمكن استدراك عملية استعادة فاشلة. تسجّل كل نسخة احتياطية اللوحة التي أُنشئت عليها ولا تُستعاد +أبدًا على لوحة أخرى؛ والنسخة الاحتياطية من إصدار لم يكن يسجّل تصميمه تُستعاد على أنها Row. ```bash row-template rollback --to @@ -86,8 +89,15 @@ row-template rollback --to row-template uninstall ``` -يزيل رو-تمبلت وملفاته، ويعيد اللوحة إلى صفحتها المدمجة. و**لا يمس** ‎3X-UI‎ ولا قاعدة -بياناتها ولا منافذك ولا عملاءك ولا شهاداتك. +يزيل رو-تمبلت وملفاته، ويعيد اللوحة إلى الصفحة التي كانت لديها من قبل: + +- **‎3X-UI‎:** يمسح ‎`subThemeDir`‎، فقط إن كان يشير إلى رو-تمبلت. +- **‎PasarGuard‎:** يزيل كتلته من ‎`.env`‎ — فيعود الملف إلى بايتاته السابقة بدقة — وصفحته، ثم + يعيد تشغيل اللوحة العاملة مرة واحدة. +- **‎Rebecca‎:** يستعيد إعدادَي الاشتراك اللذين غيّرهما كما كانا تمامًا؛ وإن كنت قد اخترت صفحة + أخرى منذ ذلك الحين، يبقى اختيارك كما هو. + +و**لا يمس** المستخدمين ولا المنافذ ولا العملاء ولا العُقد ولا الشهادات ولا أي إعداد آخر في اللوحة. الإزالة غير التفاعلية تتطلب ‎`RT_ASSUME_YES=1`‎. diff --git a/docs/src/content/docs/ar/getting-started.mdx b/docs/src/content/docs/ar/getting-started.mdx index abdb1a5..b71d76b 100644 --- a/docs/src/content/docs/ar/getting-started.mdx +++ b/docs/src/content/docs/ar/getting-started.mdx @@ -8,24 +8,26 @@ description: ما هو رو-تمبلت، وما يحتاجه، وما يحدث ## ما هو بالضبط -**صفحة اشتراك** — الصفحة التي يفتحها المشترك عند زيارة رابط اشتراكه. تقدّم ‎3X-UI‎ صفحة -مدمجة هناك، ويستبدلها رو-تمبلت بصفحته الخاصة. +**صفحة اشتراك** — الصفحة التي يفتحها المشترك عند زيارة رابط اشتراكه. تقدّم كلٌّ من ‎3X-UI‎ +و‎PasarGuard‎ و‎Rebecca‎ صفحة مدمجة هناك، ويستبدلها رو-تمبلت بصفحته الخاصة. ليس لوحة، ولا قالبًا لواجهة إدارة اللوحة، ولا تطبيق عميل. يغيّر فقط ما يراه المشترك. ## ما ليس هو -- **لا يعدّل ‎3X-UI‎.** لا يُرقَّع أي ملف من اللوحة. يشير المثبّت فقط إلى إعداد - *Sub Theme Directory* في اللوحة نحو مجلد ينشئه هو، وهذا كل التكامل. -- **لا يمس قاعدة بياناتك.** تبقى المنافذ والعملاء والشهادات والإعدادات كما هي تمامًا. - وإزالة التثبيت تُعيد اللوحة إلى صفحتها المدمجة. +- **لا يعدّل لوحتك.** لا يُرقَّع أي ملف من اللوحة. في ‎3X-UI‎ يوجّه المثبّت إعداد + *Sub Theme Directory* نحو مجلد ينشئه هو؛ وفي ‎PasarGuard‎ يضيف كتلة معلَّمة واحدة إلى ‎`.env`‎؛ + وفي ‎Rebecca‎ يضبط حقلَي الصفحة والمجلد في إعدادات الاشتراك. هذا كل التكامل، ويُتراجع عن كل + تغيير بدقة عند إزالة التثبيت. +- **لا يمس بياناتك.** يبقى المستخدمون والمنافذ والعملاء والعُقد والشهادات وكل إعداد آخر كما هي + تمامًا. - **لا يتصل بأي جهة خارجية.** الصفحة لا تُرسل أي طلب إلى طرف ثالث. ## ما تحتاجه | | | |---|---| -| **‎3X-UI‎** | **الإصدار ‎3.6.0‎ أو أحدث.** يرفض المثبّت المتابعة تحت ذلك ويخبرك بالإصدار المكتشف. | +| **لوحة مدعومة** | **‎3X-UI‎ الإصدار ‎3.6.0‎ أو أحدث**، أو **‎PasarGuard‎**، أو **‎Rebecca‎**. يكتشف المثبّت أيّها مثبّت؛ راجع [التوافق](/ar/compatibility/). | | **خادم لينكس** | أي توزيعة تحتوي على ‎`bash`‎ و‎`coreutils`‎ و‎`curl`‎ و‎`tar`‎ و‎`sha256sum`‎. | | **صلاحيات root** | للأوامر التي تغيّر النظام: ‎`config`‎ و‎`update`‎ و‎`rollback`‎ و‎`uninstall`‎. أما ‎`verify`‎ و‎`version`‎ فيعملان من دونها. | @@ -34,8 +36,9 @@ description: ما هو رو-تمبلت، وما يحتاجه، وما يحدث ## ما يحدث أثناء التثبيت ١. ينزّل سكربت الإقلاع الإصدار و**يتحقق من checksum**. -٢. يفكّ الأرشيف بأمان ويثبّت في - ‎`/etc/3x-ui/sub_templates/row-template`‎. +٢. يكتشف لوحتك، ويفكّ الأرشيف بأمان ويثبّت في + ‎`/etc/3x-ui/sub_templates/row-template`‎ (‎3X-UI‎) أو ‎`/etc/row-template`‎ (‎PasarGuard‎ + و‎Rebecca‎). ٣. يسأل عن هويتك — اسم الخدمة ورابط الدعم والشعار. **الثلاثة اختيارية**؛ اتركها فارغة لصفحة بلا علامة تجارية. ٤. يُنشئ الصفحة ويُفعّلها في اللوحة حيثما أمكن. @@ -49,10 +52,13 @@ description: ما هو رو-تمبلت، وما يحتاجه، وما يحدث /etc/3x-ui/sub_templates/row-template ``` -هذا المسار هو *Sub Theme Directory* في اللوحة. يضبطه المثبّت تلقائيًا عند توفّر +في ‎3X-UI‎ هذا المسار هو *Sub Theme Directory* في اللوحة. يضبطه المثبّت تلقائيًا عند توفّر ‎`sqlite3`‎؛ وإلا اضبطه مرة واحدة يدويًا — انظر [التفعيل](/ar/installation/#التفعيل). +في ‎PasarGuard‎ و‎Rebecca‎ يقع التثبيت في ‎`/etc/row-template`‎، وتوضع الصفحة التي تقدّمها +اللوحة في مجلد قوالب اللوحة نفسها، تحت ‎`row-template/index.html`‎. + ## الخطوة التالية - [التثبيت](/ar/installation/) — الأوامر الفعلية diff --git a/docs/src/content/docs/ar/index.mdx b/docs/src/content/docs/ar/index.mdx index 359b197..fe99adc 100644 --- a/docs/src/content/docs/ar/index.mdx +++ b/docs/src/content/docs/ar/index.mdx @@ -1,11 +1,11 @@ --- title: رو-تمبلت -description: نظام متعدد القوالب لصفحة الاشتراك في ‎3X-UI‎ — سبعة عشر تصميمًا في ملف واحد مستقل. +description: نظام متعدد القوالب لصفحة الاشتراك في ‎3X-UI‎ و‎PasarGuard‎ و‎Rebecca‎ — سبعة عشر تصميمًا في ملف واحد مستقل. --- import HomePreviews from "../../../components/HomePreviews.astro"; -**نظام متعدد القوالب لصفحة الاشتراك في ‎3X-UI‎.** تُشحن سبعة عشر تصميمًا مع المشروع. أيًّا +**نظام متعدد القوالب لصفحة الاشتراك في ‎3X-UI‎ و‎PasarGuard‎ و‎Rebecca‎.** تُشحن سبعة عشر تصميمًا مع المشروع. أيًّا اخترت، تكون النتيجة ملف HTML واحدًا مستقلًا تملكه أنت، ويرى مشتروك خدمتك — لا خدمتنا. [التثبيت](/ar/installation/) · [تصفّح القوالب](/ar/templates/) @@ -22,7 +22,7 @@ import HomePreviews from "../../../components/HomePreviews.astro"; | | | |---|---| -| **اللوحة** | ‎3X-UI‎ الإصدار **‎3.6.0‎** أو أحدث — اللوحة الوحيدة المدعومة في الإنتاج | +| **اللوحات** | ‎3X-UI‎ الإصدار **‎3.6.0‎** أو أحدث، و‎PasarGuard‎، و‎Rebecca‎ — راجع [التوافق](/ar/compatibility/) | | **القوالب** | **١٧**، كلها متاحة ومقفلة بـ checksum | | **المخرجات** | ملف واحد مستقل، داخل سقف ‎204,800‎ بايت | | **بيئة التشغيل** | لا شيء — بلا Node.js‎ وبلا Python وبلا قاعدة بيانات على الخادم | diff --git a/docs/src/content/docs/ar/installation.mdx b/docs/src/content/docs/ar/installation.mdx index ffe59cc..40e9706 100644 --- a/docs/src/content/docs/ar/installation.mdx +++ b/docs/src/content/docs/ar/installation.mdx @@ -1,13 +1,16 @@ --- title: التثبيت -description: ثبّت رو-تمبلت على ‎3X-UI‎ بأمر واحد، ثم تحقّق منه. +description: ثبّت رو-تمبلت على ‎3X-UI‎ أو ‎PasarGuard‎ أو ‎Rebecca‎ بأمر واحد، ثم تحقّق منه. --- ثلاث خطوات. كل خطوة تخبرك بما ينبغي أن تراه، وما تفعله إن لم تره. ## قبل أن تبدأ -- **‎3X-UI‎ الإصدار ‎3.6.0‎ أو أحدث.** تحقّق عبر ‎`x-ui`‎ أو من اللوحة نفسها. +- **لوحة مدعومة** على الخادم: **‎3X-UI‎ الإصدار ‎3.6.0‎ أو أحدث** (تحقّق عبر ‎`x-ui`‎ أو من + اللوحة نفسها)، أو **‎PasarGuard‎**، أو **‎Rebecca‎**، مثبّتة كما يثبّتها مثبّتها الرسمي. يكتشف + المثبّت أيّها موجود. وعلى خادم فيه أكثر من لوحة يسألك، أو يقرأ ‎`RT_PANEL`‎ (‎`3xui`‎ أو + ‎`pasarguard`‎ أو ‎`rebecca`‎) في السكربت. راجع [التوافق](/ar/compatibility/). - **صلاحيات root.** التثبيت يغيّر النظام. - **وصول إلى GitHub.** ينزّل سكربت الإقلاع الإصدار عبر HTTPS. للتركيب دون اتصال انظر [التثبيت دون اتصال](#التثبيت-دون-اتصال). @@ -29,13 +32,22 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d | الرسالة | المعنى | ما تفعله | |---|---|---| | ‎`3x-ui is below the required minimum 3.6.0; not installing`‎ | لوحتك أقدم من الحد الأدنى المدعوم. | حدّث ‎3X-UI‎ أولًا ثم أعد التشغيل. | +| ‎`no supported panel was detected on this host: …`‎ | لم يُعثر على ‎3X-UI‎ ولا ‎PasarGuard‎ ولا ‎Rebecca‎. | ثبّت اللوحة أولًا بمثبّتها الرسمي، ثم أعد التشغيل. | +| ‎` looks partly installed (only one of its files was found); it is not treated as present.`‎ | عُثر على إشارة واحدة فقط من اللوحة، فلا يخمّن المثبّت. | أكمل تثبيت اللوحة أو أصلحه، ثم أعد التشغيل. | +| ‎`more than one panel is installed here (…); choose one with RT_PANEL=3xui\|pasarguard\|rebecca.`‎ | تثبيت غير تفاعلي على خادم فيه عدة لوحات. | أعد التشغيل مع ضبط ‎`RT_PANEL`‎ على اللوحة المطلوبة. | | ‎`an existing install was found at …`‎ | رو-تمبلت مثبّت مسبقًا. | شغّل ‎`row-template update`‎، أو أعد التشغيل مع ‎`RT_ASSUME_YES=1`‎ للإصلاح. | | رفض بسبب checksum | التنزيل ناقص أو الملف ليس من الإصدار الرسمي. | أعد التنزيل من صفحة الإصدارات الرسمية. **لا تتجاوز هذا الفحص.** | | خطأ في الشبكة | تعذّر الوصول إلى GitHub. | أعد المحاولة أو استخدم [التثبيت دون اتصال](#التثبيت-دون-اتصال). | ## الخطوة ٢ — التفعيل -يحاول المثبّت ذلك نيابةً عنك، وينجح عند توفّر ‎`sqlite3`‎ على الخادم. +يفعل المثبّت ذلك نيابةً عنك حيثما أمكن، بعد أن يعرض ما سيتغيّر ويسألك. في ‎PasarGuard‎ +و‎Rebecca‎ يجري التفعيل على هيئة معاملة: تُلتقط لقطة لحالة اللوحة، ثم يُطبَّق التغيير ويُتحقق +منه، وإن فشلت أي خطوة تُستعاد اللوحة بدقة. + +### ‎3X-UI‎ + +تلقائي عند توفّر ‎`sqlite3`‎ على الخادم. **إن لم يُفعَّل تلقائيًا**، اضبطه مرة واحدة في اللوحة: @@ -47,7 +59,28 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d /etc/3x-ui/sub_templates/row-template ``` -**المتوقع:** عند فتح رابط الاشتراك تظهر صفحة رو-تمبلت بدلًا من صفحة اللوحة المدمجة. +### ‎PasarGuard‎ + +تلقائي دائمًا. توضع الصفحة في ‎`/var/lib/pasarguard/templates/row-template/index.html`‎ (أو داخل +‎`CUSTOM_TEMPLATES_DIRECTORY`‎ الخاص بك)، وتُلحق بـ‎`/opt/pasarguard/.env`‎ كتلة معلَّمة تختارها، +وتُعاد تشغيل اللوحة العاملة مرة واحدة. لا يُعدَّل أي سطر من أسطرك. + +### ‎Rebecca‎ + +تلقائي مع قاعدة بيانات ‎SQLite‎ الافتراضية وتثبيت ‎`sqlite3`‎: توضع الصفحة في +‎`/var/lib/rebecca/templates/row-template/index.html`‎ وتُختار في إعدادات الاشتراك في ‎Rebecca‎. +تقرأ ‎Rebecca‎ هذه الإعدادات مع كل طلب، فلا يُعاد تشغيل شيء. + +**مع ‎MySQL/MariaDB‎، أو دون ‎`sqlite3`‎،** تُوضع الصفحة رغم ذلك ويطبع المثبّت الخطوة اليدوية +الوحيدة. في لوحة تحكم ‎Rebecca‎ افتح **‎Settings → Subscription → Templates‎** واضبط: + +``` +Subscription page template: row-template/index.html +Custom templates directory: /var/lib/rebecca/templates +``` + +**المتوقع:** عند فتح رابط الاشتراك في المتصفح تظهر صفحة رو-تمبلت بدلًا من صفحة اللوحة +المدمجة. ## الخطوة ٣ — التحقق @@ -55,7 +88,9 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d row-template verify ``` -يفحص الملف المثبَّت وربط اللوحة والعرض الحيّ. وإذا شُغّل بصلاحيات ‎root‎ يصلح أولًا مخزن +يفحص الملف المثبَّت وربط اللوحة، والعرض الحيّ في ‎3X-UI‎. وفي ‎PasarGuard‎ و‎Rebecca‎ يبلّغ +أيضًا عن إعدادات اللوحة التي تبقى لها الأولوية على الصفحة، مثل قالب الاشتراك الخاص بمشرف ما. +وإذا شُغّل بصلاحيات ‎root‎ يصلح أولًا مخزن التصاميم: تُعاد التصاميم الموجودة خارج ‎`dist/templates`‎ إلى مكانها، وتُنزَّل من الإصدار نفسه التصاميمُ التي يتضمنها الإصدار المثبَّت وتنقص الخادم. ولا يغيّر شيئًا غير ذلك. diff --git a/docs/src/content/docs/ar/security.mdx b/docs/src/content/docs/ar/security.mdx index 78dbce4..4ccb448 100644 --- a/docs/src/content/docs/ar/security.mdx +++ b/docs/src/content/docs/ar/security.mdx @@ -42,11 +42,22 @@ description: ما يضمنه النموذج فعلًا — وما لا يضمن ## بلا ترقيع لكود اللوحة -**لا يعدّل رو-تمبلت ‎3X-UI‎.** لا يُرقَّع أي ملف من كود اللوحة. - -التكامل إعداد واحد فقط — *Sub Theme Directory* في اللوحة — يشير إلى مجلد ينشئه رو-تمبلت. -وإزالة التثبيت تُعيد ذلك الإعداد وتحذف ملفاته. أما قاعدة بياناتك ومنافذك وعملاؤك وشهاداتك -فلا تُلمس أبدًا. +**لا يعدّل رو-تمبلت لوحتك.** لا يُرقَّع أي ملف من كود اللوحة. + +- **‎3X-UI‎:** إعداد واحد فقط — *Sub Theme Directory* في اللوحة — يشير إلى مجلد ينشئه رو-تمبلت. +- **‎PasarGuard‎:** ملف صفحة واحد في مجلد القوالب، وكتلة معلَّمة واحدة تُلحق بـ‎`.env`‎. لا تُعدَّل + أسطرك أبدًا. يحتوي ‎`.env`‎ على أسرارك، لذا لا يُقرأ إلا لمفتاحَي الصفحة، ولا يُطبع أبدًا، ولا + يُنسخ إلى أي نسخة احتياطية. +- **‎Rebecca‎:** ملف صفحة واحد، وحقلا الصفحة والمجلد في أحدث صف من ‎`subscription_settings`‎. لا + يُكتب إلا في ‎SQLite‎، وعبر ‎`sqlite3`‎ فقط؛ ولا تُطلب كلمة مرور ‎MySQL‎ ولا تُقرأ ولا تُطبع + أبدًا. + +تُلتقط لقطة لكل تغيير أولًا ويُستعاد بدقة إن فشل التفعيل؛ وإزالة التثبيت تتراجع عنه وتحذف ملفات +رو-تمبلت نفسه. أما مستخدموك ومنافذك وعملاؤك وعُقدك وشهاداتك فلا تُلمس أبدًا. + +في ‎PasarGuard‎ و‎Rebecca‎ الصفحة قالب ‎Jinja2‎ أو ‎pongo2‎. كل قيمة تطبعها تقع داخل كتلة +‎autoescape‎ صريحة — فـ‎Jinja2‎ في ‎PasarGuard‎ لا يهرّب افتراضيًا — وتدخل هويتك الصفحة مع تهريب +‎`{`‎ و‎`}`‎، فلا يمكنها أبدًا أن تفتح وسم قالب. ## بوابات التحقق من القوالب diff --git a/docs/src/content/docs/compatibility.mdx b/docs/src/content/docs/compatibility.mdx index 3f97e82..4454cd3 100644 --- a/docs/src/content/docs/compatibility.mdx +++ b/docs/src/content/docs/compatibility.mdx @@ -1,93 +1,121 @@ --- title: Compatibility -description: What is supported in production today, and what is research. +description: The panels Row-Template supports, what it changes on each, and what is still missing. --- -## Supported today +## Supported panels + +| | 3X-UI | PasarGuard | Rebecca | +|---|---|---|---| +| **Supported since** | 1.0.0 | 1.3.0 | 1.3.0 | +| **Versions** | **3.6.0 or newer** | 5.x — the official Docker install or a source install | 1.3 or newer — Docker or binary | +| **Template engine** | Go `html/template` | Jinja2 | pongo2 (Django-style, in Go) | +| **Install root** | `/etc/3x-ui/sub_templates/row-template` | `/etc/row-template` | `/etc/row-template` | +| **What activation changes** | `subThemeDir` | one marked block in `/opt/pasarguard/.env` | two fields of the newest subscription settings row | +| **Automatic activation needs** | `sqlite3` | nothing extra | SQLite and `sqlite3` | +| **Restart on activation** | yes, briefly | once, only if running | never | +| **Live status refresh** | ✅ | — (values as of page load) | — (values as of page load) | | | | |---|---| -| **Panel** | **3X-UI 3.6.0 or newer** | | **Platform** | Any Linux with `bash`, `coreutils`, `curl`, `tar`, `sha256sum` | -| **Runtime** | None — no Node.js, no Python, no database | +| **Runtime** | None — no Node.js, no Python, no database of our own | | **Locales** | English, Persian, Arabic, Russian, Simplified Chinese | -3X-UI is the **only** panel supported in production. Installation instructions on this -site are for 3X-UI only. - -## Research and roadmap - -> **PasarGuard and Rebecca are research targets, not supported production panels.** -> **There are no installation instructions for them, and you should not attempt one.** - -Both are being studied for future compatibility. The findings so far are recorded here so -the state of the work is honest rather than implied. - -### Why it is not a copy-paste job - -The three panels do **not** share a template engine: - -| Panel | Engine | -|---|---| -| **3X-UI** | Go `html/template` | -| **PasarGuard** | Jinja2 | -| **Rebecca** | pongo2 (Django-style, in Go) | - -The subscription page's shell is a Go-template document. The same syntax is valid in -exactly one of the three. That is a genuine translation problem, not a configuration one. - -### What is shared, and what is not - -The **visual layer is pure CSS** and is fully portable across all three panels. Only the -shell — the HTML and the template actions it contains — is engine-bound. - -So panel compatibility is a **shell and engine** problem, not a design problem. That is -encouraging, and it is why the work is worth doing. - -### Live status - -3X-UI serves live status at `?format=info`. The other two expose the same information on -a **path suffix** rather than a query parameter, so reaching it needs either a small -runtime change or a reverse proxy. That decision is deliberately deferred. - -### Status - -What the installer can do on each panel today. The table is checked against the installer -by the test suite (`tests/panel-support.test.mjs`), so it cannot claim more than the code -does. - -| Panel | Detection | Install | Activation | Verification | Backup & rollback | Page shell | Status | -|---|---|---|---|---|---|---|---| -| 3X-UI | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **Supported** | -| PasarGuard | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | Research — not supported, no install instructions | -| Rebecca | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | Research — not supported, no install instructions | - -On a server with PasarGuard or Rebecca and no 3X-UI, the installer stops with -`no 3x-ui installation was detected on this host` and changes nothing. - -#### What exists for PasarGuard and Rebecca - -- **A page shell for every design**, in the panel's own template language (Jinja2 for - PasarGuard, pongo2 for Rebecca). Every release builds and ships them under `shells/`. - The installer does not place them. -- **Data adapters** that map each panel's subscription data onto the fields the page - uses, tested against sample responses written from each panel's source code — not - captured from a running panel. -- **Rendering tests** that fill each shell with that data. They use a small test renderer - that understands only the syntax the shells use; no shell has yet been rendered by a - real PasarGuard or Rebecca server. - -#### What is missing before either can be supported - -- An installer adapter: detecting the panel, its configuration and its service. -- Placing the shell where the panel loads templates, switching the panel to it, and - verifying and rolling back that change. -- Live status, which both panels serve on a path suffix (see above). -- The `on_hold` subscription state. Both panels have it, and the page has no way to show - it yet, so the adapters refuse it. -- A test on a real server of each panel. +The installer detects which panel is on the server. On a server with more than one, it +asks — or, in a script, reads `RT_PANEL=3xui|pasarguard|rebecca`. A panel counts as +installed only when two independent signals agree (its configuration, its compose file or +service, its CLI, its data directory); a panel that is only partly there is refused rather +than guessed at. + +## Status + +What the installer can do on each panel. The table is checked against the installer by the +test suite (`tests/panel-support.test.mjs`), so it cannot claim more than the code does. A panel is +marked **Supported** only when all seven capabilities are present — detection alone, or an adapter +file merely existing, is not support. + +| Panel | Detect | Install | Activate | Verify | Backup | Restore | Uninstall | Page shell | Status | +|---|---|---|---|---|---|---|---|---|---| +| 3X-UI | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **Supported** | +| PasarGuard | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **Supported** (1.3.0) | +| Rebecca | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **Supported** (1.3.0) — MySQL/MariaDB: manual selection | + +## Why every design is built three times + +The three panels do **not** share a template engine, and the same syntax is valid in exactly +one of them. So every design is built once per panel: + +- For **3X-UI** the page is a Go-template document. +- For **PasarGuard** and **Rebecca**, the same layout is wrapped in a small **prelude** that + derives every value the page reads from the panel's own subscription data, and the whole + body sits inside an explicit **autoescape** block. PasarGuard's Jinja2 runs with + autoescaping **off**, so without that block a username or a link remark could inject + markup; with it, every value is escaped on every panel. + +The **visual layer is pure CSS** and identical on all three panels. Each panel's pages are +tested by rendering them with that panel's **real engine** — Jinja2 for PasarGuard, pongo2 +v6.1.0 (Rebecca's pinned version) for Rebecca — including hostile and malformed data. + +## PasarGuard + +- **Placement.** `/var/lib/pasarguard/templates/row-template/index.html`, or inside your own + `CUSTOM_TEMPLATES_DIRECTORY` when you set one. For a Docker install that directory must be + inside `/var/lib/pasarguard`, which the container shares with the host; any other directory + is refused before anything changes. +- **Selection.** A block appended to `/opt/pasarguard/.env`: + + ``` + # >>> row-template (managed by Row-Template; do not edit) nl=0 >>> + CUSTOM_TEMPLATES_DIRECTORY = "/var/lib/pasarguard/templates" + SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" + # <<< row-template <<< + ``` + + The last assignment of a key wins, so none of your own lines is edited. Uninstall removes + the block and `.env` returns to its exact previous bytes. +- **Restart.** PasarGuard reads `.env` at start-up, so a running panel is restarted once. A + stopped panel is not started. +- **What still takes precedence.** An admin with their own subscription template, and the + **disable subscription template** setting. Both are yours; `row-template verify` reports + them. +- **Secrets.** `.env` holds your admin password, JWT secret and database URL. It is read only + for the two page keys, never printed, and never copied into a backup. + +## Rebecca + +- **Placement.** `/var/lib/rebecca/templates/row-template/index.html`, or inside your own + custom templates directory. +- **Selection.** The page and directory fields of the newest `subscription_settings` row — + the row Rebecca reads on every request, so no restart is ever needed. The directory is set + only when you had none; `NULL`, empty and a value are each restored exactly. +- **Database.** Automatic activation needs the default **SQLite** database and the `sqlite3` + command. With **MySQL/MariaDB** the page is still placed and the installer prints the two + values to enter in **Settings → Subscription → Templates**. No database password is ever + asked for, read or printed. +- **What still takes precedence.** An admin's own subscription template settings; `row-template + verify` reports how many admins have one. + +## Known limits + +- **Live status.** 3X-UI serves live status at `?format=info`. PasarGuard and Rebecca serve + it on a **path suffix** instead, so on those panels the page shows the values as of when + it was opened, and does not refresh them. Wiring the suffix up needs a small runtime change; + that decision is deliberately deferred. +- **PasarGuard's page title** (`subTitle`) and **Clash templates** are not produced; only the + subscription page is. +- **The `on_hold` state.** A subscription that starts on first connection is shown as + active. On PasarGuard, which tells the page the hold duration, the expiry reads "starts on + first connection · valid for N days after that"; on Rebecca, which does not, the expiry is + shown as unknown rather than guessed. See the decision record + [`docs/design/PANEL-ON-HOLD-DECISION.md`](https://github.com/iitzSeriZdev/Row-Template/blob/main/docs/design/PANEL-ON-HOLD-DECISION.md). + +The source audits behind every statement on this page are in `docs/design/`: +`PASARGUARD-INSTALLER-AUDIT.md`, `REBECCA-INSTALLER-AUDIT.md`, `PASARGUARD-ADAPTER-AUDIT.md` +and `REBECCA-ADAPTER-AUDIT.md`. ## Next -- [Installation](/installation/) — for the supported panel -- [Developer Reference](/developer/) — the architecture the research is based on +- [Installation](/installation/) — for every supported panel +- [Troubleshooting](/troubleshooting/) — if the page does not appear +- [Developer Reference](/developer/) — the architecture behind the panel support diff --git a/docs/src/content/docs/configuration.mdx b/docs/src/content/docs/configuration.mdx index 3770ddd..9e02294 100644 --- a/docs/src/content/docs/configuration.mdx +++ b/docs/src/content/docs/configuration.mdx @@ -18,8 +18,8 @@ row-template | `row-template update` | Download, verify and activate a newer release (checksum enforced) | Yes — root | | `row-template rollback` | Restore a previous version (`--auto` or `--to `) | Yes — root | | `row-template verify` | Check the install, panel wiring and live render | **No** — as root it only repairs the template store | -| `row-template version` | Show installed, minimum-supported and detected 3X-UI versions | **No** — read-only | -| `row-template uninstall` | Remove Row-Template and revert the panel to its built-in page | Yes — root | +| `row-template version` | Show the installed version and the panel it serves (on 3X-UI, also the minimum-supported and detected versions) | **No** — read-only | +| `row-template uninstall` | Remove Row-Template and return the panel to the page it had before | Yes — root | | `row-template menu` | Open the interactive manager explicitly | — | | `row-template help` | Show usage | — | @@ -37,8 +37,9 @@ for input. `RT_ASSUME_NONINTERACTIVE` forces that behaviour explicitly. row-template version ``` -Prints the installed version, the minimum supported 3X-UI version, and the 3X-UI version -detected on the server. Useful before reporting a problem. +Prints the installed version and the panel it serves; on 3X-UI, also the minimum +supported version and the version detected on the server. Useful before reporting a +problem. ```bash row-template verify @@ -55,14 +56,17 @@ changes nothing else. row-template update ``` -Checks the public stable release channel, shows the installed and available versions, and -updates **only when a newer stable version exists**. If the network or the release source -is unreachable it reports that it could not check — your installation is never treated as -damaged. +Downloads the latest release from the public stable channel, verifies it, and applies +it — even when it is the version you already run, which makes it a quick repair. The +manager's **Update** compares versions first and asks before changing anything. If the +network or the release source is unreachable nothing is changed, and your installation is +never treated as damaged. -**Your branding configuration is preserved across updates.** +**Your branding, your selected design and your panel wiring are preserved across +updates.** On PasarGuard and Rebecca the placed page is replaced in place; the panel's +selection is not touched, and nothing is restarted. -**Updating from 1.1.0 takes one `row-template update`.** 1.1.0's own updater copies only part +**Updating from 1.1.0 or 1.2.x takes one `row-template update`.** 1.1.0's own updater copies only part of the new release. The next time you open `row-template`, or run `row-template config` or `row-template verify` as root, it downloads the rest of that same release — every design, checksum-verified — before doing anything else. It never downloads a newer version, and it @@ -75,7 +79,9 @@ row-template rollback ``` Restores the previous version from a validated backup. The current version is -snapshotted first, so a failed rollback can itself be recovered. +snapshotted first, so a failed rollback can itself be recovered. Every backup records the +panel it was made on and is never restored onto another panel; a backup from a release +that did not record its design restores as Row. ```bash row-template rollback --to @@ -89,8 +95,16 @@ Rolls back to a specific backup instead of the previous one. row-template uninstall ``` -Removes Row-Template and its files, and reverts the panel to its built-in page. It does -**not** touch 3X-UI, its database, your inbounds, clients, or certificates. +Removes Row-Template and its files, and returns the panel to the page it had before: + +- **3X-UI:** clears `subThemeDir`, only if it points at Row-Template. +- **PasarGuard:** removes its block from `.env` — the file returns to its exact previous + bytes — and its page, then restarts a running panel once. +- **Rebecca:** restores the two subscription settings it changed, exactly as they were; + if you have since chosen another page, your choice is left alone. + +It does **not** touch your users, inbounds, clients, nodes, certificates or any other +panel setting. Non-interactive uninstall requires `RT_ASSUME_YES=1`. diff --git a/docs/src/content/docs/fa/compatibility.mdx b/docs/src/content/docs/fa/compatibility.mdx index e3a1bba..7dd20e9 100644 --- a/docs/src/content/docs/fa/compatibility.mdx +++ b/docs/src/content/docs/fa/compatibility.mdx @@ -1,92 +1,119 @@ --- title: سازگاری -description: امروز چه چیزی در محیط عملیاتی پشتیبانی می‌شود، و چه چیزی در حال پژوهش است. +description: پنل‌هایی که رو-تمپلیت پشتیبانی می‌کند، آنچه روی هرکدام تغییر می‌دهد، و آنچه هنوز کم است. --- -## پشتیبانی‌شده امروز +## پنل‌های پشتیبانی‌شده + +| | ۳X-UI | PasarGuard | Rebecca | +|---|---|---|---| +| **پشتیبانی از** | 1.0.0 | 1.3.0 | 1.3.0 | +| **نسخه‌ها** | **۳.۶.۰ یا بالاتر** | ۵.x — نصب رسمی Docker یا نصب از سورس | ۱.۳ یا بالاتر — Docker یا باینری | +| **موتور قالب** | `html/template` زبان Go | Jinja2 | pongo2 (به سبک Django، در Go) | +| **ریشهٔ نصب** | `/etc/3x-ui/sub_templates/row-template` | `/etc/row-template` | `/etc/row-template` | +| **آنچه فعال‌سازی تغییر می‌دهد** | `subThemeDir` | یک بلوک نشان‌دار در `/opt/pasarguard/.env` | دو فیلد از جدیدترین ردیف تنظیمات اشتراک | +| **نیاز فعال‌سازی خودکار** | `sqlite3` | هیچ چیز اضافه | SQLite و `sqlite3` | +| **راه‌اندازی مجدد هنگام فعال‌سازی** | بله، کوتاه | یک بار، فقط اگر در حال اجرا باشد | هرگز | +| **به‌روزرسانی زندهٔ وضعیت** | ✅ | — (مقادیر لحظهٔ بازشدن صفحه) | — (مقادیر لحظهٔ بازشدن صفحه) | | | | |---|---| -| **پنل** | **۳X-UI نسخهٔ ۳.۶.۰ یا بالاتر** | | **پلتفرم** | هر لینوکسی با `bash`، `coreutils`، `curl`، `tar` و `sha256sum` | -| **زمان اجرا** | هیچ — نه Node.js، نه پایتون، نه پایگاه‌داده | +| **زمان اجرا** | هیچ — نه Node.js، نه پایتون، نه پایگاه‌دادهٔ مخصوص به خودمان | | **زبان‌ها** | انگلیسی، فارسی، عربی، روسی، چینی ساده‌شده | -۳X-UI **تنها** پنل پشتیبانی‌شده در محیط عملیاتی است. دستورهای نصب این سایت فقط برای ۳X-UI است. - -## پژوهش و نقشهٔ راه - -> **PasarGuard و Rebecca اهدافی پژوهشی‌اند، نه پنل‌های پشتیبانی‌شدهٔ عملیاتی.** -> **هیچ دستور نصبی برای آن‌ها وجود ندارد و نباید تلاش کنید.** - -هر دو برای سازگاری آینده در حال بررسی‌اند. یافته‌ها اینجا ثبت شده تا وضعیت کار صادقانه -روشن باشد. - -### چرا کار کپی‌پیست نیست - -این سه پنل **موتور قالب یکسانی ندارند**: - -| پنل | موتور | -|---|---| -| **۳X-UI** | Go `html/template` | -| **PasarGuard** | Jinja2 | -| **Rebecca** | pongo2 (سبک جنگو، در Go) | - -پوستهٔ صفحهٔ اشتراک یک سند Go-template است. همان نحو دقیقاً در یکی از این سه معتبر است. این -یک مسئلهٔ واقعی ترجمه است، نه یک مسئلهٔ پیکربندی. - -### چه چیزی مشترک است و چه چیزی نه - -**لایهٔ بصری CSS خالص است** و کاملاً میان هر سه پنل قابل انتقال. فقط پوسته — یعنی HTML و -اکشن‌های قالبی که در آن است — به موتور وابسته است. - -پس سازگاری پنل یک مسئلهٔ **پوسته و موتور** است، نه یک مسئلهٔ طراحی. این امیدوارکننده است و -همین دلیل ارزشمند بودن این کار است. - -### وضعیت زنده - -۳X-UI وضعیت زنده را در `?format=info` ارائه می‌دهد. دو پنل دیگر همان اطلاعات را روی -**پسوند مسیر** ارائه می‌دهند نه پارامتر پرس‌وجو، بنابراین رسیدن به آن یا به یک تغییر کوچک در -زمان اجرا نیاز دارد یا به یک پروکسی معکوس. این تصمیم عمداً به تعویق افتاده است. - -### وضعیت - -آنچه نصب‌کننده امروز روی هر پنل می‌تواند انجام دهد. این جدول با مجموعهٔ آزمون‌ها -(`tests/panel-support.test.mjs`) در برابر خود نصب‌کننده بررسی می‌شود، پس نمی‌تواند بیش از -آنچه کد انجام می‌دهد ادعا کند. - -| پنل | تشخیص | نصب | فعال‌سازی | بررسی | پشتیبان‌گیری و بازگردانی | پوستهٔ صفحه | وضعیت | -|---|---|---|---|---|---|---|---| -| ۳X-UI | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **پشتیبانی‌شده** | -| PasarGuard | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | پژوهش — پشتیبانی نمی‌شود، بدون دستور نصب | -| Rebecca | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | پژوهش — پشتیبانی نمی‌شود، بدون دستور نصب | - -روی سروری که PasarGuard یا Rebecca دارد و 3X-UI ندارد، نصب‌کننده با پیام -`no 3x-ui installation was detected on this host` متوقف می‌شود و چیزی را تغییر نمی‌دهد. - -#### آنچه برای PasarGuard و Rebecca وجود دارد - -- **پوستهٔ صفحه برای هر طرح**، به زبان قالب خود پنل (Jinja2 برای PasarGuard و pongo2 برای - Rebecca). هر نسخه آن‌ها را می‌سازد و زیر `shells/` بسته‌بندی می‌کند. نصب‌کننده آن‌ها را - جایگذاری نمی‌کند. -- **مبدل‌های داده** که داده‌های اشتراک هر پنل را به فیلدهای صفحه نگاشت می‌کنند و با - پاسخ‌های نمونه‌ای آزموده شده‌اند که از روی کد منبع هر پنل نوشته شده‌اند — نه از یک پنل در - حال اجرا. -- **آزمون‌های رندر** که هر پوسته را با این داده‌ها پر می‌کنند. این آزمون‌ها از یک رندرکنندهٔ - آزمایشی کوچک استفاده می‌کنند که فقط نحوی را که پوسته‌ها به کار می‌برند می‌فهمد؛ هنوز هیچ - پوسته‌ای روی یک سرور واقعی PasarGuard یا Rebecca رندر نشده است. - -#### آنچه پیش از پشتیبانی از هر کدام لازم است - -- یک آداپتور در نصب‌کننده: تشخیص پنل، پیکربندی و سرویس آن. -- قرار دادن پوسته جایی که پنل قالب‌ها را از آن می‌خواند، فعال کردن آن در پنل، و بررسی و - بازگردانی این تغییر. -- وضعیت زنده، که هر دو پنل آن را روی یک پسوند مسیر ارائه می‌دهند (بالا را ببینید). -- وضعیت اشتراک `on_hold`. هر دو پنل آن را دارند و صفحه هنوز راهی برای نمایش آن ندارد، - برای همین مبدل‌ها آن را رد می‌کنند. -- آزمون روی یک سرور واقعی از هر پنل. +نصب‌کننده تشخیص می‌دهد کدام پنل روی سرور است. روی سروری با بیش از یک پنل، می‌پرسد — یا در +یک اسکریپت، `RT_PANEL=3xui|pasarguard|rebecca` را می‌خواند. یک پنل فقط وقتی نصب‌شده به حساب +می‌آید که دو نشانهٔ مستقل با هم بخوانند (پیکربندی‌اش، فایل compose یا سرویسش، CLI آن، پوشهٔ +داده‌اش)؛ پنلی که فقط بخشی از آن وجود دارد، به‌جای حدس زدن، رد می‌شود. + +## وضعیت + +نصب‌کننده روی هر پنل چه کاری می‌تواند بکند. این جدول توسط مجموعهٔ آزمون +(`tests/panel-support.test.mjs`) با خود نصب‌کننده مقایسه می‌شود، پس نمی‌تواند بیش از آنچه کد +انجام می‌دهد ادعا کند. + +| پنل | تشخیص | نصب | فعال‌سازی | بررسی | پشتیبان‌گیری | بازگردانی | حذف نصب | پوستهٔ صفحه | وضعیت | +|---|---|---|---|---|---|---|---|---|---| +| ۳X-UI | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **پشتیبانی‌شده** | +| PasarGuard | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **پشتیبانی‌شده** (1.3.0) | +| Rebecca | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **پشتیبانی‌شده** (1.3.0) — MySQL/MariaDB: انتخاب دستی | + +## چرا هر طرح سه بار ساخته می‌شود + +این سه پنل موتور قالب مشترکی **ندارند**، و هر نحو فقط در یکی از آن‌ها معتبر است. پس هر طرح +برای هر پنل جداگانه ساخته می‌شود: + +- برای **۳X-UI** صفحه یک سند Go-template است. +- برای **PasarGuard** و **Rebecca** همان چیدمان درون یک **پیش‌درآمد** کوچک قرار می‌گیرد که هر + مقداری را که صفحه می‌خواند از دادهٔ اشتراک خود پنل به دست می‌آورد، و کل بدنه درون یک بلوک + صریح **autoescape** است. Jinja2 در PasarGuard با autoescape **خاموش** اجرا می‌شود، پس بدون + این بلوک یک نام کاربری یا توضیح یک لینک می‌توانست markup تزریق کند؛ با آن، هر مقداری روی هر + پنل escape می‌شود. + +**لایهٔ ظاهری فقط CSS است** و روی هر سه پنل یکسان است. صفحه‌های هر پنل با رندر شدن توسط +**موتور واقعی** همان پنل آزموده می‌شوند — Jinja2 برای PasarGuard و pongo2 نسخهٔ v6.1.0 (نسخهٔ +قفل‌شدهٔ Rebecca) برای Rebecca — از جمله با داده‌های مخرب و ناقص. + +## PasarGuard + +- **جای صفحه.** `/var/lib/pasarguard/templates/row-template/index.html`، یا درون + `CUSTOM_TEMPLATES_DIRECTORY` خودتان اگر تنظیمش کرده باشید. در نصب Docker این پوشه باید درون + `/var/lib/pasarguard` باشد که container با میزبان به اشتراک می‌گذارد؛ هر پوشهٔ دیگری پیش از + هر تغییری رد می‌شود. +- **انتخاب.** بلوکی که به انتهای `/opt/pasarguard/.env` افزوده می‌شود: + + ``` + # >>> row-template (managed by Row-Template; do not edit) nl=0 >>> + CUSTOM_TEMPLATES_DIRECTORY = "/var/lib/pasarguard/templates" + SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" + # <<< row-template <<< + ``` + + آخرین مقداردهی هر کلید برنده است، پس هیچ‌کدام از خط‌های خودتان ویرایش نمی‌شود. حذف نصب + بلوک را برمی‌دارد و `.env` دقیقاً به بایت‌های قبلی‌اش برمی‌گردد. +- **راه‌اندازی مجدد.** PasarGuard فایل `.env` را هنگام راه‌اندازی می‌خواند، پس پنلی که در حال + اجراست یک بار راه‌اندازی مجدد می‌شود. پنل متوقف‌شده روشن نمی‌شود. +- **آنچه همچنان مقدم است.** ادمینی که قالب اشتراک خودش را دارد، و تنظیم **disable + subscription template**. هر دو انتخاب شما هستند؛ `row-template verify` آن‌ها را گزارش می‌کند. +- **اطلاعات محرمانه.** `.env` گذرواژهٔ ادمین، کلید JWT و آدرس پایگاه‌داده را دارد. فقط برای دو + کلید صفحه خوانده می‌شود، هرگز چاپ نمی‌شود و هرگز در پشتیبان کپی نمی‌شود. + +## Rebecca + +- **جای صفحه.** `/var/lib/rebecca/templates/row-template/index.html`، یا درون پوشهٔ قالب‌های + سفارشی خودتان. +- **انتخاب.** فیلدهای صفحه و پوشهٔ جدیدترین ردیف `subscription_settings` — ردیفی که Rebecca در + هر درخواست می‌خواند، پس هرگز به راه‌اندازی مجدد نیازی نیست. پوشه فقط وقتی تنظیم می‌شود که شما + پوشه‌ای نداشته باشید؛ `NULL`، خالی و یک مقدار هرکدام دقیقاً بازگردانده می‌شوند. +- **پایگاه‌داده.** فعال‌سازی خودکار به پایگاه‌دادهٔ پیش‌فرض **SQLite** و دستور `sqlite3` نیاز + دارد. با **MySQL/MariaDB** صفحه همچنان جایگذاری می‌شود و نصب‌کننده دو مقداری را که باید در + **Settings → Subscription → Templates** وارد کنید چاپ می‌کند. هیچ گذرواژهٔ پایگاه‌داده‌ای + هرگز پرسیده، خوانده یا چاپ نمی‌شود. +- **آنچه همچنان مقدم است.** تنظیمات قالب اشتراک اختصاصی یک ادمین؛ `row-template verify` + می‌گوید چند ادمین چنین تنظیمی دارند. + +## محدودیت‌های شناخته‌شده + +- **وضعیت زنده.** ۳X-UI وضعیت زنده را روی `?format=info` ارائه می‌دهد. PasarGuard و Rebecca آن + را روی یک **پسوند مسیر** ارائه می‌دهند، پس روی این پنل‌ها صفحه مقادیر لحظهٔ بازشدنش را نشان + می‌دهد و آن‌ها را به‌روز نمی‌کند. اتصال این پسوند به یک تغییر کوچک در کد اجرایی نیاز دارد؛ این + تصمیم عمداً به تعویق افتاده است. +- **عنوان صفحهٔ PasarGuard** (`subTitle`) و **قالب‌های Clash** تولید نمی‌شوند؛ فقط صفحهٔ اشتراک + تولید می‌شود. +- **وضعیت `on_hold`.** اشتراکی که با اولین اتصال شروع می‌شود فعال نشان داده می‌شود. در + PasarGuard که مدت انتظار را به صفحه می‌دهد، انقضا «با اولین اتصال شروع می‌شود · پس از آن N روز + اعتبار دارد» خوانده می‌شود؛ در Rebecca که این را نمی‌دهد، انقضا به‌جای حدس زدن نامعلوم نشان داده + می‌شود. سند تصمیم + [`docs/design/PANEL-ON-HOLD-DECISION.md`](https://github.com/iitzSeriZdev/Row-Template/blob/main/docs/design/PANEL-ON-HOLD-DECISION.md) + را ببینید. + +ممیزی‌های سورس پشت هر جملهٔ این صفحه در `docs/design/` هستند: +`PASARGUARD-INSTALLER-AUDIT.md`، `REBECCA-INSTALLER-AUDIT.md`، `PASARGUARD-ADAPTER-AUDIT.md` +و `REBECCA-ADAPTER-AUDIT.md`. ## قدم بعدی -- [نصب](/fa/installation/) — برای پنل پشتیبانی‌شده -- [مرجع توسعه‌دهنده](/fa/developer/) — معماری‌ای که این پژوهش بر آن بنا شده +- [نصب](/fa/installation/) — برای هر پنل پشتیبانی‌شده +- [رفع اشکال](/fa/troubleshooting/) — اگر صفحه نمایش داده نشد +- [مرجع توسعه‌دهنده](/fa/developer/) — معماری پشت پشتیبانی از پنل‌ها diff --git a/docs/src/content/docs/fa/configuration.mdx b/docs/src/content/docs/fa/configuration.mdx index cb2cde7..72ed202 100644 --- a/docs/src/content/docs/fa/configuration.mdx +++ b/docs/src/content/docs/fa/configuration.mdx @@ -18,8 +18,8 @@ row-template | `row-template update` | دانلود، بررسی و فعال‌سازی نسخهٔ جدیدتر (با الزام checksum) | بله — root | | `row-template rollback` | بازگردانی نسخهٔ پیشین (`--auto` یا `--to `) | بله — root | | `row-template verify` | بررسی نصب، اتصال پنل و رندر زنده | **نه** — با root فقط مخزن طرح‌ها را ترمیم می‌کند | -| `row-template version` | نمایش نسخهٔ نصب‌شده، حداقل نسخهٔ پشتیبانی‌شده و نسخهٔ ۳X-UI شناسایی‌شده | **نه** — فقط خواندنی | -| `row-template uninstall` | حذف رو-تمپلیت و بازگرداندن پنل به صفحهٔ داخلی | بله — root | +| `row-template version` | نمایش نسخهٔ نصب‌شده و پنلی که به آن سرویس می‌دهد (در ۳X-UI، حداقل نسخهٔ پشتیبانی‌شده و نسخهٔ شناسایی‌شده را هم) | **نه** — فقط خواندنی | +| `row-template uninstall` | حذف رو-تمپلیت و بازگرداندن پنل به صفحه‌ای که پیش‌تر داشت | بله — root | | `row-template menu` | باز کردن صریح مدیر تعاملی | — | | `row-template help` | نمایش راهنما | — | @@ -37,8 +37,8 @@ row-template row-template version ``` -نسخهٔ نصب‌شده، حداقل نسخهٔ پشتیبانی‌شدهٔ ۳X-UI و نسخهٔ شناسایی‌شدهٔ ۳X-UI روی سرور را چاپ -می‌کند. پیش از گزارش هر مشکلی، این را اجرا کنید. +نسخهٔ نصب‌شده و پنلی که به آن سرویس می‌دهد را چاپ می‌کند؛ در ۳X-UI حداقل نسخهٔ +پشتیبانی‌شده و نسخهٔ شناسایی‌شده روی سرور را هم. پیش از گزارش هر مشکلی، این را اجرا کنید. ```bash row-template verify @@ -55,13 +55,16 @@ root مخزن طرح‌ها را هم ترمیم می‌کند — طرح‌ها row-template update ``` -کانال انتشار پایدار عمومی را بررسی می‌کند، نسخهٔ نصب‌شده و موجود را نشان می‌دهد، و **فقط -وقتی نسخهٔ پایدار جدیدتری وجود دارد** به‌روزرسانی می‌کند. اگر شبکه یا منبع انتشار در دسترس -نباشد، می‌گوید که نتوانست بررسی کند — نصب شما هرگز آسیب‌دیده فرض نمی‌شود. +آخرین نسخه را از کانال انتشار پایدار عمومی دانلود و بررسی می‌کند و اعمالش می‌کند — حتی اگر +همان نسخه‌ای باشد که دارید، که آن را به راهی سریع برای تعمیر تبدیل می‌کند. گزینهٔ **Update** +در مدیر ابتدا نسخه‌ها را مقایسه می‌کند و پیش از هر تغییری می‌پرسد. اگر شبکه یا منبع انتشار +در دسترس نباشد چیزی تغییر نمی‌کند و نصب شما هرگز آسیب‌دیده فرض نمی‌شود. -**تنظیمات برند شما در به‌روزرسانی حفظ می‌شود.** +**برند، طرح انتخاب‌شده و اتصال پنل شما در به‌روزرسانی حفظ می‌شوند.** در PasarGuard و Rebecca +صفحهٔ جایگذاری‌شده سر جای خودش جایگزین می‌شود؛ به انتخاب پنل دست زده نمی‌شود و چیزی راه‌اندازی +مجدد نمی‌شود. -**به‌روزرسانی از 1.1.0 با یک بار `row-template update` انجام می‌شود.** به‌روزرسان خود 1.1.0 +**به‌روزرسانی از 1.1.0 یا 1.2.x با یک بار `row-template update` انجام می‌شود.** به‌روزرسان خود 1.1.0 فقط بخشی از نسخهٔ جدید را کپی می‌کند. دفعهٔ بعد که `row-template` را باز کنید، یا `row-template config` یا `row-template verify` را با دسترسی root اجرا کنید، بقیهٔ همان نسخه — همهٔ طرح‌ها، با بررسی checksum — پیش از هر کار دیگری دریافت می‌شود. هرگز نسخهٔ جدیدتری @@ -74,7 +77,9 @@ row-template rollback ``` نسخهٔ پیشین را از یک پشتیبان معتبر بازمی‌گرداند. ابتدا از نسخهٔ فعلی هم پشتیبان گرفته می‌شود، -بنابراین یک بازگردانی ناموفق هم قابل بازیابی است. +بنابراین یک بازگردانی ناموفق هم قابل بازیابی است. هر پشتیبان پنلی را که روی آن ساخته شده ثبت +می‌کند و هرگز روی پنل دیگری بازگردانده نمی‌شود؛ پشتیبانی از نسخه‌ای که طرحش را ثبت نمی‌کرد، +به‌صورت Row بازگردانده می‌شود. ```bash row-template rollback --to @@ -88,8 +93,15 @@ row-template rollback --to row-template uninstall ``` -رو-تمپلیت و فایل‌هایش را حذف می‌کند و پنل را به صفحهٔ داخلی خودش برمی‌گرداند. به ۳X-UI، -پایگاه‌داده، اینباندها، کلاینت‌ها یا گواهی‌های شما **دست نمی‌زند**. +رو-تمپلیت و فایل‌هایش را حذف می‌کند و پنل را به صفحه‌ای که پیش‌تر داشت برمی‌گرداند: + +- **۳X-UI:** `subThemeDir` را پاک می‌کند، فقط اگر به رو-تمپلیت اشاره کند. +- **PasarGuard:** بلوک خودش را از `.env` برمی‌دارد — فایل دقیقاً به بایت‌های قبلی‌اش برمی‌گردد — + و صفحهٔ خودش را، سپس پنلی را که در حال اجراست یک بار راه‌اندازی مجدد می‌کند. +- **Rebecca:** دو تنظیم اشتراکی را که تغییر داده دقیقاً همان‌طور که بودند بازمی‌گرداند؛ اگر از + آن پس صفحهٔ دیگری انتخاب کرده باشید، انتخاب شما دست‌نخورده می‌ماند. + +به کاربران، اینباندها، کلاینت‌ها، نودها، گواهی‌ها یا هیچ تنظیم دیگری از پنل **دست نمی‌زند**. حذف غیرتعاملی به `RT_ASSUME_YES=1` نیاز دارد. diff --git a/docs/src/content/docs/fa/getting-started.mdx b/docs/src/content/docs/fa/getting-started.mdx index 7455ff6..438fe90 100644 --- a/docs/src/content/docs/fa/getting-started.mdx +++ b/docs/src/content/docs/fa/getting-started.mdx @@ -8,25 +8,28 @@ description: رو-تمپلیت چیست، چه چیزی لازم دارد، و ## دقیقاً چیست -یک **صفحهٔ اشتراک** — همان صفحه‌ای که مشترک با باز کردن لینک اشتراکش می‌بیند. ۳X-UI یک -صفحهٔ داخلی در آن آدرس ارائه می‌دهد؛ رو-تمپلیت آن را با صفحهٔ خودش جایگزین می‌کند. +یک **صفحهٔ اشتراک** — همان صفحه‌ای که مشترک با باز کردن لینک اشتراکش می‌بیند. ۳X-UI، +PasarGuard و Rebecca هرکدام یک صفحهٔ داخلی در آن آدرس ارائه می‌دهند؛ رو-تمپلیت آن را با صفحهٔ +خودش جایگزین می‌کند. نه پنل است، نه پوسته‌ای برای بخش مدیریت پنل، و نه کلاینت. فقط چیزی را تغییر می‌دهد که مشترک می‌بیند. ## چه چیزی نیست -- **۳X-UI را تغییر نمی‌دهد.** هیچ فایلی از پنل وصله نمی‌شود. نصب‌کننده فقط تنظیم - *Sub Theme Directory* پنل را به پوشه‌ای که خودش می‌سازد اشاره می‌دهد، و کل یکپارچگی همین است. -- **به پایگاه‌دادهٔ شما دست نمی‌زند.** اینباندها، کلاینت‌ها، گواهی‌ها و تنظیمات دقیقاً همان‌طور - که بودند می‌مانند. حذف نصب، پنل را به صفحهٔ داخلی خودش برمی‌گرداند. +- **پنل شما را تغییر نمی‌دهد.** هیچ فایلی از پنل وصله نمی‌شود. در ۳X-UI نصب‌کننده تنظیم + *Sub Theme Directory* را به پوشه‌ای که خودش می‌سازد اشاره می‌دهد؛ در PasarGuard یک بلوک + نشان‌دار به `.env` می‌افزاید؛ در Rebecca فیلدهای صفحه و پوشهٔ تنظیمات اشتراک را مقدار می‌دهد. + کل یکپارچگی همین است، و هر تغییر هنگام حذف نصب دقیقاً برگردانده می‌شود. +- **به داده‌های شما دست نمی‌زند.** کاربران، اینباندها، کلاینت‌ها، نودها، گواهی‌ها و هر تنظیم دیگر + دقیقاً همان‌طور که بودند می‌مانند. - **به جایی خبر نمی‌دهد.** صفحه هیچ درخواستی به سرویس شخص ثالث نمی‌فرستد. ## چه چیزی لازم دارید | | | |---|---| -| **۳X-UI** | **نسخهٔ ۳.۶.۰ یا بالاتر.** نصب‌کننده پایین‌تر از این را نمی‌پذیرد و نسخهٔ تشخیص‌داده‌شده را به شما می‌گوید. | +| **یک پنل پشتیبانی‌شده** | **۳X-UI نسخهٔ ۳.۶.۰ یا بالاتر**، **PasarGuard** یا **Rebecca**. نصب‌کننده تشخیص می‌دهد کدام نصب است؛ [سازگاری](/fa/compatibility/) را ببینید. | | **سرور لینوکس** | هر توزیعی که `bash`، `coreutils`، `curl`، `tar` و `sha256sum` داشته باشد. | | **دسترسی root** | برای دستورهایی که سیستم را تغییر می‌دهند: `config`، `update`، `rollback` و `uninstall`. دو دستور `verify` و `version` بدون آن هم اجرا می‌شوند. | @@ -35,8 +38,9 @@ description: رو-تمپلیت چیست، چه چیزی لازم دارد، و ## هنگام نصب چه اتفاقی می‌افتد ۱. اسکریپت راه‌انداز نسخهٔ انتشار را دانلود و **checksum آن را بررسی** می‌کند. -۲. آرشیو را به‌شکل ایمن باز می‌کند و در - `/etc/3x-ui/sub_templates/row-template` نصب می‌کند. +۲. پنل شما را تشخیص می‌دهد، آرشیو را به‌شکل ایمن باز می‌کند و در + `/etc/3x-ui/sub_templates/row-template` (۳X-UI) یا `/etc/row-template` (PasarGuard، + Rebecca) نصب می‌کند. ۳. دربارهٔ برند شما می‌پرسد — نام سرویس، لینک پشتیبانی و لوگو. **هر سه اختیاری‌اند**؛ برای صفحه‌ای بی‌نام‌ونشان آن‌ها را خالی بگذارید. ۴. صفحهٔ نهایی را می‌سازد و در صورت امکان، خودش آن را در پنل فعال می‌کند. @@ -50,10 +54,13 @@ description: رو-تمپلیت چیست، چه چیزی لازم دارد، و /etc/3x-ui/sub_templates/row-template ``` -همین مسیر، همان *Sub Theme Directory* پنل است. اگر `sqlite3` روی سرور موجود باشد نصب‌کننده -خودش آن را تنظیم می‌کند؛ در غیر این صورت یک‌بار به‌صورت دستی تنظیمش کنید — بخش +در ۳X-UI همین مسیر، همان *Sub Theme Directory* پنل است. اگر `sqlite3` روی سرور موجود باشد +نصب‌کننده خودش آن را تنظیم می‌کند؛ در غیر این صورت یک‌بار به‌صورت دستی تنظیمش کنید — بخش [فعال‌سازی](/fa/installation/#فعال‌سازی) را ببینید. +در PasarGuard و Rebecca نصب در `/etc/row-template` قرار دارد، و صفحه‌ای که پنل ارائه می‌دهد در +پوشهٔ قالب‌های خود پنل، زیر `row-template/index.html`، جایگذاری می‌شود. + ## قدم بعدی - [نصب](/fa/installation/) — دستورهای واقعی diff --git a/docs/src/content/docs/fa/index.mdx b/docs/src/content/docs/fa/index.mdx index aee12d5..bf6ce80 100644 --- a/docs/src/content/docs/fa/index.mdx +++ b/docs/src/content/docs/fa/index.mdx @@ -1,11 +1,11 @@ --- title: رو-تمپلیت -description: یک سامانهٔ چند-تمپلیتی صفحهٔ اشتراک برای ۳X-UI — هفده طرح، یک فایل مستقل. +description: یک سامانهٔ چند-تمپلیتی صفحهٔ اشتراک برای ۳X-UI، PasarGuard و Rebecca — هفده طرح، یک فایل مستقل. --- import HomePreviews from "../../../components/HomePreviews.astro"; -**یک سامانهٔ چند-تمپلیتی صفحهٔ اشتراک برای ۳X-UI.** هفده طرح همراه پروژه عرضه می‌شود. +**یک سامانهٔ چند-تمپلیتی صفحهٔ اشتراک برای ۳X-UI، PasarGuard و Rebecca.** هفده طرح همراه پروژه عرضه می‌شود. هر کدام را انتخاب کنید، نتیجه یک فایل HTML مستقل است که در اختیار شماست، و مشترک شما سرویس شما را می‌بیند — نه ما را. @@ -24,7 +24,7 @@ import HomePreviews from "../../../components/HomePreviews.astro"; | | | |---|---| -| **پنل** | ۳X-UI نسخهٔ **۳.۶.۰** یا بالاتر — تنها پنل پشتیبانی‌شدهٔ عملیاتی | +| **پنل‌ها** | ۳X-UI نسخهٔ **۳.۶.۰** یا بالاتر، PasarGuard، Rebecca — [سازگاری](/fa/compatibility/) را ببینید | | **تمپلیت‌ها** | **۱۷**، همه در دسترس، همه قفل‌شده با checksum | | **خروجی** | یک فایل مستقل، در سقف ۲۰۴٬۸۰۰ بایت | | **زمان اجرا** | هیچ — نه Node.js، نه پایتون، نه پایگاه‌داده روی سرور | diff --git a/docs/src/content/docs/fa/installation.mdx b/docs/src/content/docs/fa/installation.mdx index 8ed4e87..64c940c 100644 --- a/docs/src/content/docs/fa/installation.mdx +++ b/docs/src/content/docs/fa/installation.mdx @@ -1,14 +1,17 @@ --- title: نصب -description: نصب رو-تمپلیت روی ۳X-UI با یک دستور، و سپس بررسی آن. +description: نصب رو-تمپلیت روی ۳X-UI، PasarGuard یا Rebecca با یک دستور، و سپس بررسی آن. --- سه مرحله. هر مرحله می‌گوید چه چیزی باید ببینید و اگر ندیدید چه کنید. ## پیش از شروع -- **۳X-UI نسخهٔ ۳.۶.۰ یا بالاتر.** با `x-ui` یا از خود پنل بررسی کنید. نصب‌کننده پایین‌تر - از این را نمی‌پذیرد و نسخهٔ تشخیص‌داده‌شده را گزارش می‌کند. +- **یک پنل پشتیبانی‌شده** روی سرور: **۳X-UI نسخهٔ ۳.۶.۰ یا بالاتر** (با `x-ui` یا از خود پنل + بررسی کنید)، **PasarGuard** یا **Rebecca**، به همان شکلی که نصب‌کنندهٔ رسمی‌شان نصب می‌کند. + نصب‌کننده تشخیص می‌دهد کدام‌یک موجود است. روی سروری با بیش از یک پنل می‌پرسد، یا در اسکریپت + `RT_PANEL` (`3xui`، `pasarguard` یا `rebecca`) را می‌خواند. [سازگاری](/fa/compatibility/) را + ببینید. - **دسترسی root.** نصب سیستم را تغییر می‌دهد. - **دسترسی به GitHub.** اسکریپت راه‌انداز نسخه را روی HTTPS دانلود می‌کند. برای نصب آفلاین بخش [نصب آفلاین](#نصب-آفلاین) را ببینید. @@ -30,13 +33,22 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d | پیام | معنی | کار | |---|---|---| | `3x-ui is below the required minimum 3.6.0; not installing` | پنل شما قدیمی‌تر از حداقل نسخهٔ پشتیبانی‌شده است. | ابتدا ۳X-UI را به‌روز کنید، سپس دوباره اجرا کنید. | +| `no supported panel was detected on this host: …` | نه ۳X-UI، نه PasarGuard و نه Rebecca پیدا شد. | ابتدا پنل را با نصب‌کنندهٔ رسمی‌اش نصب کنید، سپس دوباره اجرا کنید. | +| ` looks partly installed (only one of its files was found); it is not treated as present.` | فقط یک نشانه از پنل پیدا شد، پس نصب‌کننده حدس نمی‌زند. | نصب پنل را کامل یا ترمیم کنید، سپس دوباره اجرا کنید. | +| `more than one panel is installed here (…); choose one with RT_PANEL=3xui\|pasarguard\|rebecca.` | نصب غیرتعاملی روی سروری با چند پنل. | با تنظیم `RT_PANEL` روی پنل مورد نظر دوباره اجرا کنید. | | `an existing install was found at …` | رو-تمپلیت از قبل نصب است. | `row-template update` را اجرا کنید، یا با `RT_ASSUME_YES=1` نصب موجود را ترمیم کنید. | | امتناع به‌دلیل checksum | دانلود ناقص بوده یا فایل از منبع رسمی نیامده است. | از صفحهٔ رسمی انتشار دوباره دانلود کنید. **این بررسی را دور نزنید.** | | خطای شبکه | GitHub در دسترس نبوده. | دوباره تلاش کنید، یا از [نصب آفلاین](#نصب-آفلاین) استفاده کنید. | ## مرحلهٔ ۲ — فعال‌سازی -نصب‌کننده تلاش می‌کند این کار را خودش انجام دهد و در صورت وجود `sqlite3` موفق می‌شود. +نصب‌کننده هرجا بتواند این کار را خودش انجام می‌دهد، پس از آنکه نشان داد چه چیزی تغییر می‌کند +و از شما پرسید. در PasarGuard و Rebecca فعال‌سازی یک تراکنش است: از وضعیت پنل snapshot گرفته +می‌شود، تغییر اعمال و بررسی می‌شود، و اگر گامی شکست بخورد پنل دقیقاً بازگردانده می‌شود. + +### ۳X-UI + +وقتی `sqlite3` روی سرور موجود باشد، خودکار است. **اگر خودکار فعال نشد**، یک‌بار در پنل تنظیمش کنید: @@ -48,7 +60,30 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d /etc/3x-ui/sub_templates/row-template ``` -**انتظار:** با باز کردن لینک اشتراک، به‌جای صفحهٔ داخلی پنل، صفحهٔ رو-تمپلیت نمایش داده شود. +### PasarGuard + +همیشه خودکار است. صفحه در `/var/lib/pasarguard/templates/row-template/index.html` (یا درون +`CUSTOM_TEMPLATES_DIRECTORY` خودتان) قرار می‌گیرد، یک بلوک نشان‌دار که آن را انتخاب می‌کند به +انتهای `/opt/pasarguard/.env` افزوده می‌شود، و پنلی که در حال اجراست یک بار راه‌اندازی مجدد +می‌شود. هیچ‌کدام از خط‌های خودتان ویرایش نمی‌شود. + +### Rebecca + +با پایگاه‌دادهٔ پیش‌فرض SQLite و نصب بودن `sqlite3` خودکار است: صفحه در +`/var/lib/rebecca/templates/row-template/index.html` قرار می‌گیرد و در تنظیمات اشتراک Rebecca +انتخاب می‌شود. Rebecca این تنظیمات را در هر درخواست می‌خواند، پس چیزی راه‌اندازی مجدد نمی‌شود. + +**با MySQL/MariaDB، یا بدون `sqlite3`،** صفحه همچنان جایگذاری می‌شود و نصب‌کننده تنها گام دستی +را چاپ می‌کند. در داشبورد Rebecca، **Settings → Subscription → Templates** را باز کنید و این را +تنظیم کنید: + +``` +Subscription page template: row-template/index.html +Custom templates directory: /var/lib/rebecca/templates +``` + +**انتظار:** با باز کردن لینک اشتراک در مرورگر، به‌جای صفحهٔ داخلی پنل، صفحهٔ رو-تمپلیت نمایش +داده شود. ## مرحلهٔ ۳ — بررسی @@ -56,7 +91,9 @@ bash <(curl -fsSL https://github.com/iitzSeriZdev/Row-Template/releases/latest/d row-template verify ``` -فایل نصب‌شده، اتصال پنل و رندر زنده را بررسی می‌کند. اگر با دسترسی root اجرا شود، ابتدا +فایل نصب‌شده، اتصال پنل و در ۳X-UI رندر زنده را بررسی می‌کند. در PasarGuard و Rebecca +تنظیمات پنل را هم که همچنان بر صفحه مقدم‌اند گزارش می‌کند، مانند قالب اشتراک اختصاصی یک +ادمین. اگر با دسترسی root اجرا شود، ابتدا مخزن طرح‌ها را درست می‌کند: طرح‌هایی که بیرون از `dist/templates` مانده‌اند به جای خود برمی‌گردند، و طرح‌هایی که نسخهٔ نصب‌شده دارد ولی روی سرور نیستند از همان نسخه دریافت می‌شوند. جز این چیزی را تغییر نمی‌دهد. diff --git a/docs/src/content/docs/fa/security.mdx b/docs/src/content/docs/fa/security.mdx index aa82562..178bde1 100644 --- a/docs/src/content/docs/fa/security.mdx +++ b/docs/src/content/docs/fa/security.mdx @@ -45,11 +45,23 @@ CSS، جاوااسکریپت، قلم‌ها و تولیدکنندهٔ QR همه ## بدون وصله‌کردن کد پنل -رو-تمپلیت **۳X-UI را تغییر نمی‌دهد**. هیچ فایلی از کد پنل وصله نمی‌شود. - -یکپارچگی فقط یک تنظیم است — *Sub Theme Directory* پنل — که به پوشه‌ای اشاره می‌دهد که -رو-تمپلیت می‌سازد. حذف نصب آن تنظیم را برمی‌گرداند و فایل‌های خودش را پاک می‌کند. پایگاه‌داده، -اینباندها، کلاینت‌ها و گواهی‌های شما هرگز لمس نمی‌شوند. +رو-تمپلیت **پنل شما را تغییر نمی‌دهد**. هیچ فایلی از کد پنل وصله نمی‌شود. + +- **۳X-UI:** فقط یک تنظیم — *Sub Theme Directory* پنل — که به پوشه‌ای اشاره می‌دهد که + رو-تمپلیت می‌سازد. +- **PasarGuard:** یک فایل صفحه در پوشهٔ قالب‌ها، و یک بلوک نشان‌دار که به انتهای `.env` افزوده + می‌شود. خط‌های خودتان هرگز ویرایش نمی‌شوند. `.env` اطلاعات محرمانهٔ شما را دارد، پس فقط برای + دو کلید صفحه خوانده می‌شود، هرگز چاپ نمی‌شود و هرگز در پشتیبان کپی نمی‌شود. +- **Rebecca:** یک فایل صفحه، و فیلدهای صفحه و پوشهٔ جدیدترین ردیف `subscription_settings`. + فقط SQLite نوشته می‌شود، فقط با `sqlite3`؛ گذرواژهٔ MySQL هرگز پرسیده، خوانده یا چاپ نمی‌شود. + +از هر تغییر ابتدا snapshot گرفته می‌شود و اگر فعال‌سازی شکست بخورد دقیقاً بازگردانده می‌شود؛ حذف +نصب آن را برمی‌گرداند و فایل‌های خود رو-تمپلیت را پاک می‌کند. کاربران، اینباندها، کلاینت‌ها، +نودها و گواهی‌های شما هرگز لمس نمی‌شوند. + +در PasarGuard و Rebecca صفحه یک قالب Jinja2 یا pongo2 است. هر مقداری که چاپ می‌کند درون یک بلوک +صریح autoescape است — Jinja2 در PasarGuard به‌طور پیش‌فرض escape نمی‌کند — و برند شما با `{` و +`}` escape‌شده وارد صفحه می‌شود، پس هرگز نمی‌تواند یک تگ قالب باز کند. ## دروازه‌های بررسی تمپلیت diff --git a/docs/src/content/docs/fa/troubleshooting.mdx b/docs/src/content/docs/fa/troubleshooting.mdx index 8d6dcf2..8a4b973 100644 --- a/docs/src/content/docs/fa/troubleshooting.mdx +++ b/docs/src/content/docs/fa/troubleshooting.mdx @@ -81,6 +81,73 @@ description: مشکلات شناخته‌شده، معنی‌شان، و راه سپس لینک اشتراک را دوباره باز کنید. +--- + +### PasarGuard همچنان صفحهٔ خودش را نشان می‌دهد + +**معنی.** بلوک رو-تمپلیت در `/opt/pasarguard/.env` اعمال نشده، یا چیزی در پنل بر آن مقدم است. + +**راه حل.** `row-template verify` را با root اجرا کنید. علت را نام می‌برد: + +- *the running panel does not use the Row-Template page yet (restart it)* — پنل پس از + فعال‌سازی راه‌اندازی مجدد نشده است. `pasarguard restart` را اجرا کنید. +- *N admin(s) set their own subscription page (sub_template)* — کاربران آن ادمین‌ها همان صفحه را + نگه می‌دارند. اگر می‌خواهید آن‌ها هم رو-تمپلیت ببینند، قالب اشتراک آن ادمین را در PasarGuard پاک + کنید. +- *the panel's 'disable subscription template' setting is on* — PasarGuard به مرورگرها خود + اشتراک خام را می‌دهد. این تنظیم را در پنل خاموش کنید. + +--- + +### `panel pasarguard: is outside /var/lib/pasarguard, the directory the container shares with the host` + +**معنی.** `CUSTOM_TEMPLATES_DIRECTORY` شما به بیرون از پوشه‌ای اشاره می‌کند که container +PasarGuard می‌بیند، پس صفحه‌ای که آنجا گذاشته شود هرگز ارائه نمی‌شود. چیزی تغییر نکرد. + +**راه حل.** قالب‌های سفارشی‌تان را به زیر `/var/lib/pasarguard` منتقل کنید، +`CUSTOM_TEMPLATES_DIRECTORY` را به‌روز کنید و دوباره اجرا کنید. + +--- + +### `the Row-Template block in .env is damaged` + +**معنی.** فقط یکی از دو خط نشانگر بلوک در `/opt/pasarguard/.env` هست، پس نصب‌کننده نمی‌تواند +بفهمد بلوکش کجا تمام می‌شود. به‌جای حدس زدن، امتناع می‌کند. + +**راه حل.** خط باقی‌ماندهٔ `# >>> row-template` یا `# <<< row-template <<<` (و دو کلید بین +آن‌ها، اگر هست) را دستی حذف کنید، سپس دوباره اجرا کنید. + +--- + +### Rebecca: *One manual step remains* + +**معنی.** Rebecca از MySQL/MariaDB استفاده می‌کند، یا `sqlite3` نصب نیست، پس نصب‌کننده صفحه را +جایگذاری کرد اما نتوانست آن را انتخاب کند. + +**راه حل.** `sqlite3` را نصب کنید و `row-template` ← **Activate** را اجرا کنید، یا خودتان در +**Settings → Subscription → Templates** انتخابش کنید: *Subscription page template* +`row-template/index.html` و *Custom templates directory* `/var/lib/rebecca/templates`. + +--- + +### برخی کاربران Rebecca همچنان صفحهٔ دیگری می‌بینند + +**معنی.** `row-template verify` گزارش می‌دهد *N admin(s) override the subscription page for +their own users*. Rebecca به ادمین اجازه می‌دهد برای کاربران خودش صفحه‌ای انتخاب کند؛ آن +کاربران همان را نگه می‌دارند. + +**راه حل.** اگر می‌خواهید کاربران آن ادمین هم رو-تمپلیت ببینند، تنظیمات قالب اشتراک آن ادمین را +در Rebecca پاک کنید. + +--- + +### ` exists and is not Row-Template's; it was left untouched` + +**معنی.** فایلی که رو-تمپلیت ننوشته، در مسیری است که رو-تمپلیت صفحه‌اش را آنجا می‌گذارد +(`…/templates/row-template/index.html`). هرگز بازنویسی نمی‌شود و فعال‌سازی بازگردانده شد. + +**راه حل.** آن فایل را جای دیگری ببرید، سپس دوباره اجرا کنید. + ## وضعیت زنده ### `Live check could not reach the subscription endpoint.` @@ -90,6 +157,14 @@ description: مشکلات شناخته‌شده، معنی‌شان، و راه **راه حل.** این یک **هشدار است، نه شکست**. مقادیر سمت سرور همچنان کار می‌کنند. بررسی کنید پنل از خود سرور پاسخ می‌دهد، سپس `row-template verify` را دوباره اجرا کنید. +--- + +### ارقام در PasarGuard یا Rebecca به‌روز نمی‌شوند + +**معنی.** این رفتار مورد انتظار است. هر دو پنل وضعیت زنده را روی یک پسوند مسیر ارائه +می‌دهند نه `?format=info`، پس صفحه مقادیر لحظهٔ بازشدنش را نشان می‌دهد. برای به‌روز کردن آن‌ها +صفحه را دوباره بارگذاری کنید. [سازگاری](/fa/compatibility/) را ببینید. + ## ساخت و بررسی ### یک آزمون تغییر اندازه یا checksum گزارش می‌کند @@ -146,7 +221,8 @@ description: مشکلات شناخته‌شده، معنی‌شان، و راه **معنی.** حذف‌کننده پوشه را بررسی کرده و آن را متعلق به خودش نشناخته است. **راه حل.** این محافظی در برابر حذف پوشهٔ اشتباه است. تأیید کنید که ریشهٔ نصب -`/etc/3x-ui/sub_templates/row-template` است، سپس دوباره اجرا کنید. +`/etc/3x-ui/sub_templates/row-template` (۳X-UI) یا `/etc/row-template` (PasarGuard، Rebecca) +است، سپس دوباره اجرا کنید. ## مرورگر و نمایش @@ -168,4 +244,5 @@ row-template verify هنگام باز کردن issue، خروجی هر دو را ضمیمه کنید. > **اطلاعات محرمانه را نفرستید.** هرگز لینک اشتراک، مقدار `subId`، UUID کلاینت، نام کاربری -> یا گذرواژهٔ پنل، کوکی، توکن، `webBasePath`، کلیدهای TLS یا آدرس واقعی سرور را منتشر نکنید. +> یا گذرواژهٔ پنل، کوکی، توکن، `webBasePath`، محتوای `.env`، آدرس پایگاه‌داده، کلیدهای TLS یا +> آدرس واقعی سرور را منتشر نکنید. diff --git a/docs/src/content/docs/getting-started.mdx b/docs/src/content/docs/getting-started.mdx index 2dc84ed..3d4a878 100644 --- a/docs/src/content/docs/getting-started.mdx +++ b/docs/src/content/docs/getting-started.mdx @@ -9,25 +9,28 @@ my server* before you run anything. ## What it actually is A **subscription page** — the page a subscriber opens when they visit their subscription -link. 3X-UI serves a built-in page there; Row-Template replaces it with its own. +link. 3X-UI, PasarGuard and Rebecca each serve a built-in page there; Row-Template +replaces it with its own. It is not a panel, not a theme for the panel's admin interface, and not a client. It only changes what the subscriber sees. ## What it is not -- **It does not modify 3X-UI.** No panel source is patched. The installer points the - panel's *Sub Theme Directory* setting at a directory it creates, and that is the whole - integration. -- **It does not touch your database.** Inbounds, clients, certificates and settings are - left exactly as they are. Uninstalling reverts the panel to its built-in page. +- **It does not modify your panel.** No panel source is patched. On 3X-UI the installer + points the *Sub Theme Directory* setting at a directory it creates; on PasarGuard it adds + one marked block to `.env`; on Rebecca it sets the page and directory fields of its + subscription settings. That is the whole integration, and each change is undone exactly + on uninstall. +- **It does not touch your data.** Users, inbounds, clients, nodes, certificates and every + other setting are left exactly as they are. - **It does not phone home.** The page makes no third-party requests. ## What you need | | | |---|---| -| **3X-UI** | **3.6.0 or newer.** The installer refuses to proceed below this and tells you the detected version. | +| **A supported panel** | **3X-UI 3.6.0 or newer**, **PasarGuard** or **Rebecca**. The installer detects which one is installed; see [Compatibility](/compatibility/). | | **A Linux server** | Any distribution with `bash`, `coreutils`, `curl`, `tar` and `sha256sum`. These are present on virtually every Linux system. | | **Root access** | Required for the commands that change the system: `config`, `update`, `rollback`, `uninstall`. `verify` and `version` run without it. | @@ -36,8 +39,9 @@ You do **not** need Node.js, Python, a database, or any runtime on the server. ## What happens during installation 1. The bootstrap script downloads the release and **verifies its checksum**. -2. It extracts the archive safely and installs to - `/etc/3x-ui/sub_templates/row-template`. +2. It detects your panel, extracts the archive safely and installs to + `/etc/3x-ui/sub_templates/row-template` (3X-UI) or `/etc/row-template` (PasarGuard, + Rebecca). 3. It prompts for your branding — service name, support link and logo. **All three are optional**; leave them blank for an unbranded, white-label page. 4. It generates the served page and, where possible, activates it in the panel @@ -52,10 +56,13 @@ leaves your existing setup untouched. /etc/3x-ui/sub_templates/row-template ``` -That path is the panel's *Sub Theme Directory*. The installer sets it for you when +On 3X-UI that path is the panel's *Sub Theme Directory*. The installer sets it for you when `sqlite3` is available; otherwise you set it once by hand — see [Installation](/installation/#activate-it). +On PasarGuard and Rebecca the install lives in `/etc/row-template`, and the page the panel +serves is placed in the panel's own templates directory, under `row-template/index.html`. + ## Next - [Installation](/installation/) — the actual commands diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index f94db16..7a3eefa 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -1,6 +1,6 @@ --- title: Row-Template -description: A multi-template subscription page system for 3X-UI — seventeen designs, one self-contained artifact. +description: A multi-template subscription page system for 3X-UI, PasarGuard and Rebecca — seventeen designs, one self-contained artifact. --- import HomePreviews from "../../components/HomePreviews.astro"; @@ -10,7 +10,7 @@ import banner from "../../../assets/row-template-banner.png"; Row-Template -**A multi-template subscription page system for 3X-UI.** Seventeen designs ship with the +**A multi-template subscription page system for 3X-UI, PasarGuard and Rebecca.** Seventeen designs ship with the project. Whichever you choose, the result is one self-contained HTML file that you control, and your subscribers see your service — not ours. @@ -30,7 +30,7 @@ unless you enter it yourself. | | | |---|---| -| **Panel** | 3X-UI **3.6.0** or newer — the only supported production panel | +| **Panels** | 3X-UI **3.6.0** or newer, PasarGuard, Rebecca — see [Compatibility](/compatibility/) | | **Templates** | **17**, all available, all byte-locked | | **Output** | One self-contained artifact, within a 204,800-byte ceiling | | **Runtime** | None — no Node.js, no Python, no database on the server | diff --git a/docs/src/content/docs/installation.mdx b/docs/src/content/docs/installation.mdx index 346180a..c963802 100644 --- a/docs/src/content/docs/installation.mdx +++ b/docs/src/content/docs/installation.mdx @@ -1,14 +1,17 @@ --- title: Installation -description: Install Row-Template on 3X-UI in one command, then verify it. +description: Install Row-Template on 3X-UI, PasarGuard or Rebecca in one command, then verify it. --- Three steps. Each one tells you what you should see, and what to do if you do not. ## Before you start -- **3X-UI 3.6.0 or newer.** Check with `x-ui` or from the panel's own version display. - The installer refuses to proceed below this and tells you what it detected. +- **A supported panel** on the server: **3X-UI 3.6.0 or newer** (check with `x-ui` or the + panel's own version display), **PasarGuard** or **Rebecca**, installed the way their + official installers lay them out. The installer detects which one is there. On a server + with more than one it asks, or reads `RT_PANEL` (`3xui`, `pasarguard` or `rebecca`) in a + script. See [Compatibility](/compatibility/). - **Root access.** The install changes the system. - **A reachable GitHub.** The bootstrap downloads the release over HTTPS. For air-gapped or staged installs, see [Offline installation](#offline-installation). @@ -31,14 +34,22 @@ optional. | Message | What it means | What to do | |---|---|---| | `3x-ui is below the required minimum 3.6.0; not installing` | Your panel is older than the supported minimum. | Update 3X-UI first, then re-run. | +| `no supported panel was detected on this host: …` | Neither 3X-UI nor PasarGuard nor Rebecca was found. | Install the panel first, with its official installer, then re-run. | +| ` looks partly installed (only one of its files was found); it is not treated as present.` | Only one sign of the panel was found, so the installer will not guess. | Finish or repair the panel's installation, then re-run. | +| `more than one panel is installed here (…); choose one with RT_PANEL=3xui\|pasarguard\|rebecca.` | A non-interactive install on a server with several panels. | Re-run with `RT_PANEL` set to the panel to serve. | | `an existing install was found at …; re-run interactively or set RT_ASSUME_YES=1 to repair` | Row-Template is already installed. | Run `row-template update`, or re-run with `RT_ASSUME_YES=1` to repair the existing install. | | A checksum refusal | The download was incomplete or came from somewhere other than the official release. | Re-download from the official releases page and re-run. **Do not bypass the check.** | | A network error | GitHub was unreachable. | Retry, or use [Offline installation](#offline-installation). | ## Step 2 — Activate it -The installer tries to do this for you. It succeeds when `sqlite3` is available on the -server. +The installer does this for you where it can, after showing what will change and asking. +On PasarGuard and Rebecca activation is a transaction: the panel's state is snapshotted, +changed and verified, and restored exactly if any step fails. + +### 3X-UI + +Automatic when `sqlite3` is available on the server. **If it did not activate automatically**, set it once in the panel: @@ -50,8 +61,31 @@ Enter exactly: /etc/3x-ui/sub_templates/row-template ``` -**Expected:** opening your subscription link shows the Row-Template page instead of the -panel's built-in one. +### PasarGuard + +Always automatic. The page is placed at +`/var/lib/pasarguard/templates/row-template/index.html` (or inside your own +`CUSTOM_TEMPLATES_DIRECTORY`), a marked block selecting it is appended to +`/opt/pasarguard/.env`, and a running panel is restarted once. None of your own lines is +edited. + +### Rebecca + +Automatic with the default SQLite database and `sqlite3` installed: the page is placed at +`/var/lib/rebecca/templates/row-template/index.html` and selected in Rebecca's +subscription settings. Rebecca reads them on every request, so nothing is restarted. + +**With MySQL/MariaDB, or without `sqlite3`,** the page is still placed and the installer +prints the one manual step. In the Rebecca dashboard open **Settings → Subscription → +Templates** and set: + +``` +Subscription page template: row-template/index.html +Custom templates directory: /var/lib/rebecca/templates +``` + +**Expected:** opening your subscription link in a browser shows the Row-Template page +instead of the panel's built-in one. ## Step 3 — Verify @@ -59,7 +93,9 @@ panel's built-in one. row-template verify ``` -It checks the installed artifact, the panel wiring and the live render. Run as root, it first +It checks the installed artifact, the panel wiring and, on 3X-UI, the live render. On +PasarGuard and Rebecca it also reports panel settings that still take precedence over the +page, such as an admin's own subscription template. Run as root, it first puts the template store right: designs left outside `dist/templates` are moved back, and designs the installed version ships but the server lacks are downloaded from that same release. It changes nothing else. diff --git a/docs/src/content/docs/security.mdx b/docs/src/content/docs/security.mdx index a9d8a44..391bc4f 100644 --- a/docs/src/content/docs/security.mdx +++ b/docs/src/content/docs/security.mdx @@ -46,11 +46,25 @@ If a step fails, the installer stops and leaves your existing setup untouched. ## No panel source patching -Row-Template **does not modify 3X-UI**. No panel source file is patched. - -The integration is a single setting — the panel's *Sub Theme Directory* — pointed at a -directory Row-Template creates. Uninstalling reverts that setting and removes its own -files. Your database, inbounds, clients and certificates are never touched. +Row-Template **does not modify your panel**. No panel source file is patched. + +- **3X-UI:** a single setting — the panel's *Sub Theme Directory* — pointed at a directory + Row-Template creates. +- **PasarGuard:** one page file in the templates directory, and one marked block appended to + `.env`. Your own lines are never edited. `.env` holds your secrets, so it is read only for + the two page keys, never printed, and never copied into a backup. +- **Rebecca:** one page file, and the page and directory fields of the newest + `subscription_settings` row. Only SQLite is written, only through `sqlite3`; a MySQL + password is never asked for, read or printed. + +Every change is snapshotted first and restored exactly if activation fails; uninstalling +reverts it and removes Row-Template's own files. Your users, inbounds, clients, nodes and +certificates are never touched. + +On PasarGuard and Rebecca the page is a Jinja2 or pongo2 template. Every value it prints is +inside an explicit autoescape block — PasarGuard's Jinja2 does not escape by default — and +your branding enters the page with `{` and `}` escaped, so it can never open a template +tag. ## Template verification gates diff --git a/docs/src/content/docs/troubleshooting.mdx b/docs/src/content/docs/troubleshooting.mdx index c1b204a..72f8412 100644 --- a/docs/src/content/docs/troubleshooting.mdx +++ b/docs/src/content/docs/troubleshooting.mdx @@ -95,6 +95,76 @@ files are not readable. Then reload the subscription link. +--- + +### PasarGuard still shows its own page + +**What it means.** The Row-Template block in `/opt/pasarguard/.env` is not in effect, or +something in the panel takes precedence over it. + +**Fix.** Run `row-template verify` as root. It names the cause: + +- *the running panel does not use the Row-Template page yet (restart it)* — the panel was + not restarted after activation. Run `pasarguard restart`. +- *N admin(s) set their own subscription page (sub_template)* — those admins' users keep + that page. Clear the admin's subscription template in PasarGuard if you want them on + Row-Template. +- *the panel's 'disable subscription template' setting is on* — PasarGuard serves the raw + subscription to browsers. Turn the setting off in the panel. + +--- + +### `panel pasarguard: is outside /var/lib/pasarguard, the directory the container shares with the host` + +**What it means.** Your `CUSTOM_TEMPLATES_DIRECTORY` points outside the directory the +PasarGuard container can see, so a page placed there could never be served. Nothing was +changed. + +**Fix.** Move your custom templates under `/var/lib/pasarguard`, update +`CUSTOM_TEMPLATES_DIRECTORY`, and re-run. + +--- + +### `the Row-Template block in .env is damaged` + +**What it means.** Only one of the block's two marker lines is in `/opt/pasarguard/.env`, +so the installer cannot tell where its block ends. It refuses rather than guess. + +**Fix.** Remove the remaining `# >>> row-template` or `# <<< row-template <<<` line (and the +two keys between them, if any) by hand, then re-run. + +--- + +### Rebecca: *One manual step remains* + +**What it means.** Rebecca uses MySQL/MariaDB, or `sqlite3` is not installed, so the +installer placed the page but could not select it. + +**Fix.** Install `sqlite3` and run `row-template` → **Activate**, or select it yourself in +**Settings → Subscription → Templates**: *Subscription page template* +`row-template/index.html`, *Custom templates directory* `/var/lib/rebecca/templates`. + +--- + +### Some Rebecca users still see another page + +**What it means.** `row-template verify` reports *N admin(s) override the subscription page +for their own users*. Rebecca lets an admin choose a page for their own users; those users +keep it. + +**Fix.** Clear that admin's subscription template settings in Rebecca if you want their +users on Row-Template. + +--- + +### ` exists and is not Row-Template's; it was left untouched` + +**What it means.** A file that Row-Template did not write is at the path it places its page +(`…/templates/row-template/index.html`). It is never overwritten, and the activation was +rolled back. + +**Fix.** Move that file elsewhere, then re-run. + ## Live status ### `Live check could not reach the subscription endpoint.` @@ -107,6 +177,14 @@ from the server itself. **Fix.** This is a **warning, not a failure**. The server-rendered values still work. Check that the panel answers locally, then re-run `row-template verify`. +--- + +### The figures do not refresh on PasarGuard or Rebecca + +**What it means.** This is expected. Both panels serve live status on a path suffix rather +than `?format=info`, so the page shows the values as of when it was opened. Reload the page +to update them. See [Compatibility](/compatibility/#known-limits). + ## Build and verification ### A test reports a changed size or checksum @@ -168,7 +246,8 @@ created. own. **Fix.** This is a guard against deleting the wrong directory. Confirm the install root is -`/etc/3x-ui/sub_templates/row-template`, then re-run. +`/etc/3x-ui/sub_templates/row-template` (3X-UI) or `/etc/row-template` (PasarGuard, +Rebecca), then re-run. ## Browser and display @@ -191,5 +270,5 @@ row-template verify Include both outputs when you open an issue. > **Do not include secrets.** Never paste subscription URLs, `subId` values, client UUIDs, -> panel credentials, cookies, tokens, the panel `webBasePath`, TLS keys, or real server -> addresses. Redact logs before sharing them. +> panel credentials, cookies, tokens, the panel `webBasePath`, the contents of `.env`, +> database URLs, TLS keys, or real server addresses. Redact logs before sharing them. diff --git a/installer/lib/row-template.sh b/installer/lib/row-template.sh index 1cbb962..f1cdb95 100644 --- a/installer/lib/row-template.sh +++ b/installer/lib/row-template.sh @@ -2902,8 +2902,11 @@ rt_panel_manual_steps() { rt_info " ${RT_RB_DATA_DIR:-/var/lib/rebecca}/templates/row-template/ into it instead." rt_info "(Automatic activation needs the sqlite3 command and Rebecca's SQLite database.)" ;; pasarguard) - rt_info "In ${RT_PG_APP_DIR:-/opt/pasarguard}/.env set, then run 'pasarguard restart':" - rt_info " SUBSCRIPTION_PAGE_TEMPLATE = \"row-template/index.html\"" ;; + rt_info "Copy $RT_LIVE to ${RT_PG_DATA_DIR:-/var/lib/pasarguard}/templates/row-template/index.html," + rt_info "then in ${RT_PG_APP_DIR:-/opt/pasarguard}/.env set these and run 'pasarguard restart':" + rt_info " CUSTOM_TEMPLATES_DIRECTORY = \"${RT_PG_DATA_DIR:-/var/lib/pasarguard}/templates\"" + rt_info " SUBSCRIPTION_PAGE_TEMPLATE = \"row-template/index.html\"" + rt_info "(Keep your own CUSTOM_TEMPLATES_DIRECTORY if you have one, and copy the page into it.)" ;; *) rt_info "In the panel: Settings -> Subscription -> Sub Theme Directory" rt_info "Set it to exactly: $RT_ROOT" ;; diff --git a/tests/panel-support.test.mjs b/tests/panel-support.test.mjs index 68e79f5..73e3cf2 100644 --- a/tests/panel-support.test.mjs +++ b/tests/panel-support.test.mjs @@ -56,8 +56,17 @@ const VERBS = { verification: ['verify', 'static'], backup: ['backup_state'], restore: ['restore_state', '/nonexistent/snap'], + uninstall: ['uninstall_template'], }; +/* Activation is NOT one verb, so it is not checked by a return code. It is the + panel-side act of SELECTING the page, and the frozen P3 vocabulary names the + mechanisms that may perform it. A panel can be activated only when its + adapter declares at least one of them, so "Activate" is a capability question + and is asked as one. Claiming activation without a mechanism would be a + matrix cell with nothing behind it. */ +const ACTIVATION_TOKENS = ['selection_write', 'env_activation', 'db_activation']; + /* Ask the installer. For every panel id: the implementation the registry resolves to, and the return code of every operation above. */ function installerMatrix() { @@ -72,6 +81,7 @@ function installerMatrix() { for (const [col, [verb, ...args]] of Object.entries(VERBS)) { lines.push(` rc=0; rt_panel_${verb} "$p" ${args.join(' ')} >/dev/null 2>&1 || rc=$?; echo "${col}-$p=$rc"`); } + lines.push(' echo "caps-$p=$(rt_panel_capabilities "$p" 2>/dev/null | tr \'\\n\' \' \')"'); lines.push('done'); const r = bash(lines.join('\n')); assert.equal(r.code, 0, r.err); @@ -79,10 +89,16 @@ function installerMatrix() { const ids = kv.ids.split(' ').filter(Boolean); return Object.fromEntries(ids.map((p) => [p, { implemented: kv[`impl-${p}`] !== '', + caps: (kv[`caps-${p}`] || '').split(' ').filter(Boolean), rc: Object.fromEntries(Object.keys(VERBS).map((c) => [c, Number(kv[`${c}-${p}`])])), }])); } +/* The seven capabilities the status column stands for, in matrix order. A panel + is Supported only when every one of them is present -- which is what makes + "Supported" a claim about behaviour rather than about a file existing. */ +const CAPABILITY_COLUMNS = ['detection', 'install', 'activation', 'verification', 'backup', 'restore', 'uninstall']; + const MATRIX = installerMatrix(); const INSTALLABLE = Object.keys(MATRIX).filter((p) => MATRIX[p].implemented); const UNAVAILABLE = 2; @@ -95,6 +111,25 @@ test('the installer implements all three panels', () => { 'and a page shell is built for each'); }); +test('every implemented panel declares a way to activate', () => { + for (const p of INSTALLABLE) { + const mechanisms = MATRIX[p].caps.filter((c) => ACTIVATION_TOKENS.includes(c)); + assert.ok(mechanisms.length >= 1, + `${p}: activation needs a declared mechanism (one of ${ACTIVATION_TOKENS.join(', ')}), ` + + `but the adapter declares [${MATRIX[p].caps.join(', ')}]`); + } +}); + +/* Does the installer REALLY have all seven capabilities for PANEL? Activation + is answered from the declared mechanism (there is no activation verb); + everything else must have a real operation, which VERBS enumerates. */ +function hasAllCapabilities(p) { + if (!INSTALLABLE.includes(p)) return false; + return CAPABILITY_COLUMNS.every((col) => (col === 'activation' + ? MATRIX[p].caps.some((c) => ACTIVATION_TOKENS.includes(c)) + : Object.prototype.hasOwnProperty.call(MATRIX[p].rc, col))); +} + test('on a host without the panel, no operation reports success', () => { /* This test host runs none of the panels. An operation that answered SUCCESS here would be claiming work it could not have done. Detection must @@ -178,31 +213,33 @@ function tableRows(md, width) { return rows; } -/* The capability matrix. Columns 1-5 are installer capabilities, 6 is the - page shell, 7 the status -- in every language. */ +/* The capability matrix. Columns 1-7 are the seven capabilities in + CAPABILITY_COLUMNS order, 8 is the page shell, 9 the status -- in every + language. A panel may be marked Supported only when ALL SEVEN are present, so + "Supported" cannot be claimed on a partial implementation. */ const COMPAT = { en: { file: 'docs/src/content/docs/compatibility.mdx', research: 'Research' }, fa: { file: 'docs/src/content/docs/fa/compatibility.mdx', research: 'پژوهش' }, ar: { file: 'docs/src/content/docs/ar/compatibility.mdx', research: 'بحث' }, }; -const INSTALLER_COLUMNS = ['detection', 'install', 'activation', 'verification', 'backup']; for (const [lang, { file, research }] of Object.entries(COMPAT)) { test(`the ${lang} compatibility matrix matches what the installer can do`, () => { - const rows = tableRows(read(file), 8); + const rows = tableRows(read(file), 10); assert.deepEqual(Object.keys(rows).sort(), Object.keys(MATRIX).sort(), `${file}: one matrix row per panel`); for (const [p, cells] of Object.entries(rows)) { - const installable = INSTALLABLE.includes(p); - INSTALLER_COLUMNS.forEach((col, i) => { - assert.equal(cells[i + 1], installable ? '✅' : '❌', - `${file}: ${p} ${col} must be ${installable ? '✅' : '❌'} -- the installer ${installable ? 'implements' : 'does not implement'} it`); + const supported = hasAllCapabilities(p); + CAPABILITY_COLUMNS.forEach((col, i) => { + assert.equal(cells[i + 1], supported ? '✅' : '❌', + `${file}: ${p} ${col} must be ${supported ? '✅' : '❌'} -- the installer ` + + `${supported ? 'implements' : 'does not implement'} it`); }); - assert.equal(cells[6], buildablePanelIds().includes(p) ? '✅' : '❌', `${file}: ${p} page shell`); - if (installable) { - assert.ok(cells[7].startsWith('**'), `${file}: ${p} is marked supported`); + assert.equal(cells[8], buildablePanelIds().includes(p) ? '✅' : '❌', `${file}: ${p} page shell`); + if (supported) { + assert.ok(cells[9].startsWith('**'), `${file}: ${p} is marked supported`); } else { - assert.equal(cells[7].includes('**'), false, `${file}: ${p} must not be marked supported`); - assert.ok(cells[7].includes(research), `${file}: ${p} is marked as research`); + assert.equal(cells[9].includes('**'), false, `${file}: ${p} must not be marked supported`); + assert.ok(cells[9].includes(research), `${file}: ${p} is marked as research`); } } }); diff --git a/tools/make-release.sh b/tools/make-release.sh index 7b8681a..838d6cb 100755 --- a/tools/make-release.sh +++ b/tools/make-release.sh @@ -28,10 +28,10 @@ # # 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 -# host or configures a panel to use them — that is the installer's business and -# it is deliberately untouched. Shipping them makes the release honest: the -# product builds three panels' shells, so it should carry them. +# The shells are what the installer places on PasarGuard and Rebecca (1.3.0): +# it copies the selected design's shell into the panel's templates directory, +# after checking it against its .sha256 and refusing one built for another +# panel. Nothing in this script touches a host; it only builds and packages. set -Eeuo pipefail From ec25bf331364464c552b9a0c82dd269f5030d436 Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Sat, 26 Sep 2026 05:51:30 +0330 Subject: [PATCH 12/25] release: prepare v1.3.0 Co-Authored-By: Claude Opus 5.5 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 6085e94..f0bb29e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.1 +1.3.0 From cdd151a0912db4726d7f9a75631d2de7b60434bc Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Sat, 26 Sep 2026 06:08:16 +0330 Subject: [PATCH 13/25] fix(backup): two backups in the same second no longer share a directory Backup names have one-second resolution and rt_backup_create used `mkdir -p`, which reuses an existing directory. A design switch followed at once by `rollback --auto` (which snapshots the current state first) put both backups in one directory: the pre-rollback snapshot overwrote the backup it then restored, so the rollback re-applied the state it was meant to undo. Found running the suite on Linux, where the Rebecca lifecycle test is fast enough to hit it every time. The name is now claimed with a plain mkdir and a taken name waits for the next second, as the format-2 writer already did. fix(verify): the live check after config, update and rollback runs on 3X-UI Those commands printed the live check without having located the panel database, so on a real 3X-UI host (validated on 3.8.5) it always said "skipped (no test URL available without sqlite3)" with sqlite3 installed. The report now locates the database itself, read-only. And the check made right after activation no longer warns "could not reach": 3X-UI's subscription server binds a few seconds after the unit is active, so the check waits for it -- only when the unit really started in the last 30 s. Each fix has a regression test that fails without it. Co-Authored-By: Claude Opus 5.5 --- installer/lib/row-template.sh | 58 +++++++++++++++++++++--- tests/installer.test.mjs | 85 +++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 7 deletions(-) diff --git a/installer/lib/row-template.sh b/installer/lib/row-template.sh index f1cdb95..e60a28e 100644 --- a/installer/lib/row-template.sh +++ b/installer/lib/row-template.sh @@ -1044,9 +1044,27 @@ rt_backup_create() { 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" + # Names have one-second resolution. Two backups in the same second -- a design + # switch followed at once by `rollback --auto`, which snapshots the current + # state first -- used to share one directory (`mkdir -p` reuses it), so the + # second silently overwrote the first: the very backup the rollback was about + # to restore. The name is now claimed with a plain mkdir, which fails when it + # is taken, and a taken name waits for the next second -- exactly as the + # format-2 writer below does -- so names stay unique and a lexical sort stays + # chronological. + mkdir -p "$RT_BACKUPS" || return 1 + local waited=0 ts="$(date -u +%Y%m%dT%H%M%SZ)" dir="$RT_BACKUPS/${ts}__${ver}" - mkdir -p "$dir" || return 1 + until mkdir "$dir" 2>/dev/null; do + if [ -e "$dir" ] && [ "$waited" -lt 3 ]; then + waited=$((waited + 1)); sleep 1 + ts="$(date -u +%Y%m%dT%H%M%SZ)"; dir="$RT_BACKUPS/${ts}__${ver}" + continue + fi + rt_err "could not create a new backup directory under $RT_BACKUPS" + return 1 + done 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; } @@ -1999,6 +2017,18 @@ rt_smoke_derive_url() { printf 'http://127.0.0.1:%s%s%s' "$port" "$path" "$sid" } +rt_xui_just_started() { + # 0 when the 3X-UI unit entered "active" less than 30 s ago (monotonic clock, + # so a wall-clock change cannot fake it). Anything unknown answers no. + command -v systemctl >/dev/null 2>&1 || return 1 + local since up + since="$(systemctl show -p ActiveEnterTimestampMonotonic --value "${RT_XUI_UNIT:-x-ui.service}" 2>/dev/null || true)" + case "$since" in ''|0|*[!0-9]*) return 1 ;; esac + up="$(LC_ALL=C awk '{ printf "%d", $1 * 1000000 }' /proc/uptime 2>/dev/null || true)" + case "$up" in ''|*[!0-9]*) return 1 ;; esac + [ "$up" -ge "$since" ] && [ $((up - since)) -lt 30000000 ] +} + 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 @@ -2009,11 +2039,21 @@ rt_render_smoke() { [ -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 + local tries=1 + # Activation restarts 3X-UI, and its subscription server binds a few seconds + # after the unit reports active: a check made in that window reported "could + # not reach" on every fresh install. Wait for it only when the panel really + # did just start, so an endpoint that is simply unreachable still reports at + # once. + rt_xui_just_started && tries=8 + while :; do + body="$(curl -fsS -m 10 -A 'Mozilla/5.0' -H 'Accept: text/html' "$url" 2>/dev/null || true)" + if [ -z "$body" ]; then + body="$(curl -fsS -m 10 -k -A 'Mozilla/5.0' -H 'Accept: text/html' "https://${url#http://}" 2>/dev/null || true)" + fi + [ -z "$body" ] && [ "$tries" -gt 1 ] || break + tries=$((tries - 1)); sleep 2 + done [ -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 @@ -2295,11 +2335,15 @@ rt_render_report() { esac return 0 fi + # The test URL is derived from the panel database. config, update and + # rollback reach this report without having located it, so the check used to + # skip on every 3X-UI host after those commands; locate it here, read-only. + [ -n "${RT_XUI_DB:-}" ] || rt_detect_xui_db >/dev/null 2>&1 || true 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)." ;; + skip) rt_info "Live check skipped (no test URL: it needs sqlite3, the panel database and a client with a subscription ID)." ;; error) rt_warn "Live check could not reach the subscription endpoint." ;; esac rv="$(rt_render_smoke_vpn)" diff --git a/tests/installer.test.mjs b/tests/installer.test.mjs index 0bce26c..b3e7edc 100644 --- a/tests/installer.test.mjs +++ b/tests/installer.test.mjs @@ -284,6 +284,32 @@ test('backup create captures a validatable snapshot', () => { assert.match(r.out, /HASVER/); }); +/* Found running the suite on Linux (1.3.0 validation): a design switch and an + immediate `rollback --auto` created their backups in the same second. The + names have one-second resolution and `mkdir -p` reused the directory, so the + rollback's own pre-rollback snapshot overwrote the backup it then restored -- + and the "rollback" re-applied the state it was meant to undo. The `date` + double pins the clock to one second for the first two readings; the second + backup must wait for the next second rather than share the first's name. */ +test('two backups in the same second never share a directory', () => { + const r = sh( + GEN_SETUP + + 'B="$(dirname "$RT_ROOT")/bin"; mkdir -p "$B"; C="$(dirname "$RT_ROOT")/clock"; ' + + 'printf \'#!/usr/bin/env bash\\nn=$(cat "%s" 2>/dev/null || echo 0); n=$((n+1)); echo $n > "%s"\\n' + + 'if [ $n -le 2 ]; then echo 20260101T000000Z; else echo 20260101T000001Z; fi\\n\' "$C" "$C" > "$B/date"; ' + + 'chmod +x "$B/date"; PATH="$B:$PATH"; ' + + 'printf "1.3.0\\n" > "$RT_VERSION_FILE"; ' + + 'printf "first" > "$RT_DIST"; A="$(rt_backup_create)"; ' + + 'printf "second" > "$RT_DIST"; Z="$(rt_backup_create)"; ' + + 'echo "A=${A##*/} Z=${Z##*/}"; echo "A holds: $(cat "$A/template.html")"; ' + + 'echo "latest: $(basename "$(rt_backup_latest)")"', + ); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /A=20260101T000000Z__1\.3\.0 Z=20260101T000001Z__1\.3\.0/, 'the second backup gets the next second'); + assert.match(r.out, /A holds: first/, 'the first backup is not overwritten'); + assert.match(r.out, /latest: 20260101T000001Z__1\.3\.0/, 'and the newest is still the newest'); +}); + test('backup selection returns newest first and prune keeps the N newest', () => { const names = [ '20260101T000000Z__0.7.0', @@ -544,6 +570,65 @@ test('render smoke classifies a large served page as pass, not a SIGPIPE miss', assert.equal(miss.out, 'fallback', 'a large page missing the marker is a fallback'); }); +/* Found on a real 3X-UI 3.8.5 host (1.3.0 validation): config, update and + rollback print the live check without having located the panel database, so + the check could not build its test URL and reported "skipped (no test URL + available without sqlite3)" on a host that had sqlite3 and a subscription. + The report now locates the database itself. The doubles stand in for sqlite3 + and curl only; the library's own discovery and classification run. */ +test('the live check after config, update or rollback finds the panel database itself', () => { + const r = sh([ + 'B="$(dirname "$RT_ROOT")/bin"; D="$(dirname "$RT_ROOT")/db"; mkdir -p "$B" "$D"', + 'printf "SQLite format 3\\0" > "$D/x-ui.db"', + 'cat > "$B/sqlite3" <<\'EOF\'', + '#!/usr/bin/env bash', + 'case "$2" in', + ' *subPort*) echo 2096 ;;', + ' *subPath*) echo /sub/ ;;', + ' *inbounds*) printf \'{"clients":[{"email":"a","subId":"abc123"}]}\\n\' ;;', + 'esac', + 'EOF', + 'cat > "$B/curl" <<\'EOF\'', + '#!/usr/bin/env bash', + 'for a in "$@"; do case "$a" in http://127.0.0.1:2096/sub/abc123) printf \'
\'; exit 0 ;; esac; done', + 'exit 7', + 'EOF', + 'chmod +x "$B/sqlite3" "$B/curl"; PATH="$B:$PATH"', + 'RT_ACTIVE_PANEL=3xui; XUI_DB_FOLDER="$D"; unset RT_XUI_DB RT_SMOKE_URL', + 'rt_render_report', + ].join('\n')); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /Live check: a browser request renders Row-Template\./); + assert.doesNotMatch(r.out, /skipped/); +}); + +/* Found on the same host: activation restarts 3X-UI and its subscription server + binds a few seconds after the unit is active, so the check made right after a + fresh install warned "could not reach". The check now waits, but ONLY when the + unit really just started -- an endpoint that is simply down must still be + reported at once. */ +test('the live check waits for a just-restarted panel, and only for one', () => { + const fake = (since) => [ + 'B="$(dirname "$RT_ROOT")/bin"; mkdir -p "$B"', + 'cat > "$B/systemctl" < { /* Regression: rt_detect_xui matched the unit with `systemctl list-unit-files From ea28150efd9093486d63e6a44f9244a4c47d8d5c Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Sat, 26 Sep 2026 06:08:30 +0330 Subject: [PATCH 14/25] docs(changelog): record the backup-collision and live-check fixes Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b036681..fb8db6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,17 @@ own. panel could read. It now prints both the copy and the two `.env` values (`CUSTOM_TEMPLATES_DIRECTORY` and `SUBSCRIPTION_PAGE_TEMPLATE`), and says to keep your own templates directory if you already have one. +- **A rollback right after a change undoes that change.** Backup names have + one-second resolution, and two backups made in the same second — a design + switch followed at once by `row-template rollback --auto`, which snapshots + the current state first — shared one directory. The newer snapshot + overwrote the older one, so the rollback re-applied the state it was meant + to undo. A backup now waits for the next second rather than reuse a name. +- **The live check after `config`, `update` and `rollback` runs on 3X-UI.** + It always said "skipped (no test URL available without sqlite3)", even with + `sqlite3` installed, because those commands had not located the panel + database. And the check made right after activation no longer warns "could + not reach the subscription endpoint" while 3X-UI is still restarting. - All fixes prepared for 1.2.1 (below): one `row-template update` is enough to move from 1.1.0, misplaced designs are moved back, branding works on an install the 1.1.0 updater left incomplete, and `verify` names missing and From 3d89bff748325a63222ce77c8b9ea61a9b730bba Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Sat, 26 Sep 2026 06:10:56 +0330 Subject: [PATCH 15/25] fix(build): never truncate a build output in place The suite runs its test files in parallel, and two of them rebuild the committed artifacts (the release test through make-release.sh, the preview test directly) while other files read template/index.html. writeFileSync truncates before writing, so on Linux installer tests intermittently read an empty or partial page ("generated template does not begin with "), failing a different test on each run. build.mjs, build-panel.mjs and shell.mjs now write through writeIfChanged: bytes already on disk are not rewritten at all (the build is deterministic), and new bytes are renamed into place from a sibling temporary file. Co-Authored-By: Claude Opus 5.5 --- tests/build.test.mjs | 25 ++++++++++++++++++++++- tools/build-panel.mjs | 5 +++-- tools/build.mjs | 5 +++-- tools/shell.mjs | 5 +++-- tools/write-if-changed.mjs | 42 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 tools/write-if-changed.mjs diff --git a/tests/build.test.mjs b/tests/build.test.mjs index 3096a00..be5f647 100644 --- a/tests/build.test.mjs +++ b/tests/build.test.mjs @@ -4,13 +4,15 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { createHash } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { build, buildLocales, stripModuleSyntax, REQUIRED_HOOKS } from '../tools/build.mjs'; import { TEMPLATES, templateIds, coreTemplateIds, lockedTemplateIds } from '../tools/templates.mjs'; +import { writeIfChanged } from '../tools/write-if-changed.mjs'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -1114,3 +1116,24 @@ test('the frozen set is exactly the seventeen core templates, and a custom templ } } }); + +/* The build tools write through writeIfChanged, because the suite runs its + files in parallel and two of them rebuild the committed artifacts while + others read them: a truncate-then-write let a reader see half a page. */ +test('build outputs are written only when they change, and never truncated in place', () => { + const dir = mkdtempSync(join(tmpdir(), 'row-wic-')); + try { + const f = join(dir, 'index.html'); + assert.equal(writeIfChanged(f, 'a'), true, 'an absent file is written'); + assert.equal(readFileSync(f, 'utf8'), 'a'); + writeFileSync(join(dir, 'marker'), ''); + const before = statSync(f).mtimeMs; + assert.equal(writeIfChanged(f, 'a'), false, 'identical bytes are not rewritten'); + assert.equal(statSync(f).mtimeMs, before, 'so a concurrent reader is never disturbed'); + assert.equal(writeIfChanged(f, Buffer.from('b')), true, 'changed bytes are written'); + assert.equal(readFileSync(f, 'utf8'), 'b'); + assert.deepEqual(readdirSync(dir).sort(), ['index.html', 'marker'], 'no temporary file is left behind'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tools/build-panel.mjs b/tools/build-panel.mjs index a5f6947..6a74b0c 100644 --- a/tools/build-panel.mjs +++ b/tools/build-panel.mjs @@ -25,7 +25,8 @@ * in a document. */ -import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs'; +import { readFileSync, mkdirSync, rmSync, existsSync } from 'node:fs'; +import { writeIfChanged } from './write-if-changed.mjs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -64,7 +65,7 @@ export function buildPanelShell(panelId, templateId) { const out = panelShellPath(panelId, templateId); mkdirSync(dirname(out), { recursive: true }); - writeFileSync(out, html, 'utf8'); + writeIfChanged(out, html); return { panelId, diff --git a/tools/build.mjs b/tools/build.mjs index 2688063..939a4f9 100644 --- a/tools/build.mjs +++ b/tools/build.mjs @@ -8,7 +8,8 @@ * node tools/build.mjs [--no-font] [--out path] [--quiet] */ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { existsSync, readFileSync, mkdirSync } from 'node:fs'; +import { writeIfChanged } from './write-if-changed.mjs'; import { createHash } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -366,7 +367,7 @@ function buildOne(withFont, templateId, outPath, quiet) { const status = budgetStatus(total); mkdirSync(dirname(outPath), { recursive: true }); - writeFileSync(outPath, result.html); + writeIfChanged(outPath, result.html); if (!quiet) { const label = withFont ? 'with embedded font' : 'system fonts only'; diff --git a/tools/shell.mjs b/tools/shell.mjs index f3741ad..cd58ba6 100644 --- a/tools/shell.mjs +++ b/tools/shell.mjs @@ -29,7 +29,8 @@ * so the tests can prove a rendered page is correct. */ -import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { readFileSync, mkdirSync } from 'node:fs'; +import { writeIfChanged } from './write-if-changed.mjs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -244,7 +245,7 @@ export function writeShell(panelId, templateId, { withFont = true } = {}) { const sh = assembleShell(panelId, templateId, { withFont }); const out = shellOutPath(panelId, templateId); mkdirSync(dirname(out), { recursive: true }); - writeFileSync(out, sh.html, 'utf8'); + writeIfChanged(out, sh.html); return { ...sh, out }; } diff --git a/tools/write-if-changed.mjs b/tools/write-if-changed.mjs new file mode 100644 index 0000000..742ab4d --- /dev/null +++ b/tools/write-if-changed.mjs @@ -0,0 +1,42 @@ +/* Write a build output so that a concurrent reader never sees half a file. + * + * The build is deterministic, and the test suite runs its files in parallel: + * two of them rebuild the committed artifacts (the release test through + * tools/make-release.sh, the preview test directly) while others read those + * same files. writeFileSync truncates before it writes, so a reader could see + * an empty or partial page -- found on Linux, where installer tests failed with + * "generated template does not begin with ". + * + * So: bytes that are already on disk are not rewritten at all, and new bytes + * go to a sibling temporary file that is renamed over the target in one step. + * Returns true when the file was written, false when it was already current. + */ +import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; + +const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + +export function writeIfChanged(path, content) { + const next = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8'); + try { + if (readFileSync(path).equals(next)) return false; + } catch { + /* absent or unreadable: write it */ + } + const tmp = `${path}.${process.pid}.tmp`; + writeFileSync(tmp, next); + for (let attempt = 0; ; attempt += 1) { + try { + renameSync(tmp, path); + return true; + } catch (err) { + /* Windows will not replace a file another process has open; a reader + holds it only for the moment it takes to read it. */ + if (attempt < 40 && ['EPERM', 'EACCES', 'EBUSY'].includes(err.code)) { + pause(50); + continue; + } + rmSync(tmp, { force: true }); + throw err; + } + } +} From 563136dc82ee8f846ccff9cc7d9e8b7a6050b351 Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Sat, 26 Sep 2026 06:17:10 +0330 Subject: [PATCH 16/25] fix(rollback): restore an unrecognised backup page as the installed design Rolling a real 3X-UI host back to the backup its 1.1.0 install left selected Row but kept 1.1.0's own page bytes, which match no design in the 1.3.0 store. The install then failed verify ("canonical artifact does not match the selected template") and could not be switched or updated cleanly. When a backup's page matches no installed design, the selected design (the backup's recorded one, else Row) is now restored from the store, so the selection, the canonical artifact and the store agree. The backup's VERSION and the admin's current branding are handled exactly as before. Without a store (nothing to restore from) the backup's bytes are still used. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++-- installer/lib/row-template.sh | 16 +++++++++++++-- tests/installer.test.mjs | 38 +++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb8db6f..b01aee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,8 +56,9 @@ own. - **Rolling back to a backup taken under 1.1.0 works.** 1.2.x refused it with "backup artifact matches no installed template". A backup that names its - design is restored as that design; one that does not (1.1.0's) is restored - as Row. + design is restored as that design; one whose page is none of this release's + designs (1.1.0's) is restored as this release's Row, so `verify`, design + switching and updates keep working afterwards. - **A successful rollback is reported as a success.** The transaction engine checked, after restoring the panel, that the panel was still pointing at Row-Template's directory — which is exactly the state a correct rollback has diff --git a/installer/lib/row-template.sh b/installer/lib/row-template.sh index e60a28e..e516056 100644 --- a/installer/lib/row-template.sh +++ b/installer/lib/row-template.sh @@ -2626,7 +2626,8 @@ rt_restore_from_backup() { rt_err "backup $(basename "$dir") was made for $(rt_panel_label "$bpanel"), not $(rt_panel_label "$(rt_panel_current)"); refusing to restore it." return 1 fi - tpl_id="$(rt_template_id_for_artifact "$dir/template.html")" + local src="$dir/template.html" + tpl_id="$(rt_template_id_for_artifact "$src")" if [ -z "$tpl_id" ]; then tpl_id="$(rt_backup_meta template "$dir")" if [ -z "$tpl_id" ]; then @@ -2636,8 +2637,19 @@ rt_restore_from_backup() { rt_warn "backup artifact recorded template '$tpl_id' is not installed; defaulting to Row." tpl_id="row" fi + # The backup's own page is not one of this release's designs (a 1.1.0 + # backup restored under 1.3.0). Selecting Row while keeping those bytes left + # the install failing verify -- "canonical artifact does not match the + # selected template" -- and unable to be switched or updated cleanly, found + # rolling a real 3X-UI host back to its 1.1.0 backup. The selected design is + # restored from the installed store instead; the backup's VERSION and the + # admin's current branding are handled exactly as before. + if rt_template_store_has "$tpl_id"; then + src="$RT_TEMPLATE_STORE/$tpl_id/template.html" + rt_info "The backup's page is not one of this release's designs; restoring $(rt_template_display_name "$tpl_id") from the installed designs." + fi fi - rt_set_dist "$dir/template.html" || return 1 + rt_set_dist "$src" || 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." diff --git a/tests/installer.test.mjs b/tests/installer.test.mjs index b3e7edc..b6b9200 100644 --- a/tests/installer.test.mjs +++ b/tests/installer.test.mjs @@ -1118,6 +1118,44 @@ test('an Editorial backup rolls a Row install forward, and a legacy v1.1.0 backu assert.match(r.out, /live=row/); }); +/* Found rolling a real 3X-UI 3.8.5 host back to the backup its 1.1.0 install + left: the backup's page is 1.1.0's own build, byte-identical to no design in + the 1.3.0 store. The restore selected Row but kept those bytes, so verify + then failed ("canonical artifact does not match the selected template") and + the install could not be switched or updated cleanly. The selected design is + now restored FROM THE STORE, so selection, artifact and store agree; the + backup's VERSION and the admin's current branding are handled as before. */ +test('a backup whose page matches no installed design is restored from the store, consistently', () => { + const r = shRoot( + 'rt_switch_template editorial\n' + + 'legacy="$RT_BACKUPS/20260101T000000Z__1.1.0"\n' + + 'mkdir -p "$legacy"\n' + + // a structurally valid page that is not byte-identical to any store design + 'sed "s#&#" "$RT_TEMPLATE_STORE/row/template.html" > "$legacy/template.html"\n' + + 'rt_sha256 "$legacy/template.html" > "$legacy/template.html.sha256"\n' + + 'printf "1.1.0\\n" > "$legacy/VERSION"\n' + + 'printf "version=1.1.0\\n" > "$legacy/meta"\n' + + '[ -z "$(rt_template_id_for_artifact "$legacy/template.html")" ] && echo "legacy-is-unknown"\n' + + 'rt_restore_from_backup "$legacy" && rt_activate && echo RESTORED\n' + + 'printf "tpl=%s\\n" "$(rt_config_get_raw TEMPLATE)"\n' + + 'printf "id=%s\\n" "$(rt_template_id_for_artifact "$RT_DIST")"\n' + + 'cmp -s "$RT_DIST" "$RT_TEMPLATE_STORE/row/template.html" && echo "canonical-is-store-row"\n' + + 'printf "ver=%s\\n" "$(cat "$RT_VERSION_FILE")"\n' + + 'printf "name=%s\\n" "$(rt_config_get_text SERVICE_NAME_B64)"\n' + + 'grep -q "data-template" "$RT_LIVE" && echo "live=not-row" || echo "live=row"', + { prepare: prepareInstall }, + ); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /legacy-is-unknown/, 'the fixture really is a page no design matches'); + assert.match(r.out, /RESTORED/); + assert.match(r.out, /tpl=row/, 'the selection is Row'); + assert.match(r.out, /id=row/, 'and the canonical artifact is identified as Row'); + assert.match(r.out, /canonical-is-store-row/, 'because it IS the installed Row design'); + assert.match(r.out, /ver=1\.1\.0/, 'the backed-up VERSION is reinstated, as for any backup'); + assert.match(r.out, /name=Test VPN/, 'the current branding is kept'); + assert.match(r.out, /live=row/); +}); + test('a corrupt or mismatched backup is refused before anything is restored', () => { const r = shRoot( 'rt_switch_template editorial\n' + From 0cd41d61f77dbff3b786db3385b2ec07855f7ad1 Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Sat, 26 Sep 2026 13:53:04 +0330 Subject: [PATCH 17/25] fix(rebecca): refuse the 0.0.x Python edition instead of failing silently Validated on real hosts: Rebecca 1.x (the Go edition, published as a binary through rebecca-binary.sh) serves the Row-Template page exactly as designed. But Docker Hub's rebeccapanel/rebecca:latest -- what rebecca.sh's Docker install pulls -- is still v0.0.37-alpha, the Python (FastAPI + Jinja2) edition. There the selection was written and accepted, the pongo2 page could not render, and Rebecca silently served its own page: an install that reported success and changed nothing a subscriber saw. The adapter now establishes the edition first -- a binary install is 1.x; a Docker one is told apart by its image's entrypoint (rebecca-server vs a script under /code) -- and refuses anything but 1.x, naming Rebecca's own `rebecca migrate-binary` as the way forward. The installer and the manager's Activate check it before anything is written (rt_panel_preflight); capture, install_template and static verify refuse it too, and an existing install's refresh refuses an edition it knows cannot serve the page. An edition that cannot be identified fails closed at install time. Co-Authored-By: Claude Opus 5.5 --- installer/lib/row-template.sh | 4 ++ installer/panels/index.sh | 13 +++++ installer/panels/rebecca.sh | 65 +++++++++++++++++++++++++ tests/helpers/panel-hosts.mjs | 17 ++++++- tests/installer-panel-rebecca.test.mjs | 67 ++++++++++++++++++++++++++ 5 files changed, 165 insertions(+), 1 deletion(-) diff --git a/installer/lib/row-template.sh b/installer/lib/row-template.sh index e516056..1a34383 100644 --- a/installer/lib/row-template.sh +++ b/installer/lib/row-template.sh @@ -2899,6 +2899,7 @@ rt_panel_activate() { local panel rc=0 panel="$(rt_panel_current)" rt_installer_complete || { rt_err "the installer's panel components are missing; run 'row-template update'."; return 1; } + rt_panel_preflight "$panel" || return 1 if [ "$(rt_panel_status "$panel")" = "manual" ]; then rt_panel_refresh_page "$panel" "$RT_LIVE" place >/dev/null || rc=$? [ "$rc" -eq 0 ] || { rt_err "could not place the page for $(rt_panel_label "$panel")."; return 1; } @@ -2993,6 +2994,9 @@ rt_cmd_install() { # this host. This also decides the install root (rt_panel_choose). rt_panel_choose || rt_die "nothing was changed." panel="$RT_ACTIVE_PANEL" + # A panel can be present and still unable to serve this release's page + # (Rebecca's 0.0.x Python edition); refuse before anything is written. + rt_panel_preflight "$panel" || rt_die "nothing was changed." # environment discovery + hard version gate (fail closed). 3X-UI only: the # other panels are identified by their adapter, and their activation does not diff --git a/installer/panels/index.sh b/installer/panels/index.sh index 3e1adda..fad9bdd 100644 --- a/installer/panels/index.sh +++ b/installer/panels/index.sh @@ -204,6 +204,19 @@ rt_panel_refresh_page() { esac } +# Before an install or an activation changes anything: can this panel, as it is +# installed here, serve the page this release builds for it? 0 yes; 1 no, and +# the adapter has said why. (Rebecca's 0.0.x Python edition cannot.) +rt_panel_preflight() { + local panel="${1:-}" impl + rt_panel_id_ok "$panel" || return "$RT_PANEL_FAIL" + impl="$(rt_panel_impl_for "$panel")" + case "$impl" in + rebecca) rt_panel_rebecca_edition_ok ;; + *) return 0 ;; + esac +} + rt_panel_status() { local panel="${1:-}" impl rt_panel_id_ok "$panel" || return "$RT_PANEL_FAIL" diff --git a/installer/panels/rebecca.sh b/installer/panels/rebecca.sh index 8ed99ff..1aca963 100644 --- a/installer/panels/rebecca.sh +++ b/installer/panels/rebecca.sh @@ -87,6 +87,61 @@ rt_panel_rebecca_running() { esac } +# --- which Rebecca ---------------------------------------------------------------- +# Two different programs are published as Rebecca. 1.x is the Go edition: it +# renders the page with pongo2, from the page context this release's Rebecca +# page is built for, and ships as a binary (rebecca-binary.sh). But Docker Hub's +# rebeccapanel/rebecca:latest -- what rebecca.sh's Docker install pulls -- is +# still the 0.0.x Python edition (FastAPI + Jinja2, another context). Found on +# a real host (1.3.0 validation): there the selection is written and accepted, +# the page cannot render, and Rebecca silently serves its own page instead -- +# an install that reports success and changes nothing a subscriber sees. So +# the edition is established first, and anything but 1.x is refused. + +rt_panel_rebecca_image() { + # echo the Rebecca image the compose file runs, or nothing. + [ -f "$RT_RB_APP_DIR/docker-compose.yml" ] || return 0 + LC_ALL=C awk ' + { l=$0; sub(/\r$/,"",l) } + l ~ /^[ \t]*image:/ { + v=l; sub(/^[ \t]*image:[ \t]*/,"",v); gsub(/["\047]/,"",v); sub(/[ \t].*$/,"",v) + if (v ~ /(^|\/)rebeccapanel\/rebecca([:@]|$)/) { print v; exit } + }' "$RT_RB_APP_DIR/docker-compose.yml" 2>/dev/null || true +} + +rt_panel_rebecca_edition() { + # go | python | unknown. A binary install is 1.x by definition (the Python + # edition has none); a Docker one is told apart by its image's entrypoint. + local img cfg + case "$(rt_panel_rebecca_mode)" in + binary) printf 'go'; return 0 ;; + docker) : ;; + *) printf 'unknown'; return 0 ;; + esac + command -v docker >/dev/null 2>&1 || { printf 'unknown'; return 0; } + img="$(rt_panel_rebecca_image)" + [ -n "$img" ] || { printf 'unknown'; return 0; } + cfg="$(docker image inspect -f '{{json .Config.Entrypoint}} {{json .Config.Cmd}} {{.Config.WorkingDir}}' "$img" 2>/dev/null || true)" + case "$cfg" in + *rebecca-server*) printf 'go' ;; + *'/code'*) printf 'python' ;; + *) printf 'unknown' ;; + esac +} + +rt_panel_rebecca_edition_ok() { + # 0 when this Rebecca can serve the page this release builds for it; + # otherwise say why and fail. Unknown fails closed. + case "$(rt_panel_rebecca_edition)" in + go) return 0 ;; + python) + rt_err "panel rebecca: this is Rebecca 0.0.x, the Python edition (Docker image $(rt_panel_rebecca_image)). Row-Template's Rebecca page is built for Rebecca 1.x, the Go edition, which Rebecca publishes for its binary install (rebecca-binary.sh); Rebecca's own 'rebecca migrate-binary' moves a Docker install to it." ;; + *) + rt_err "panel rebecca: cannot tell which Rebecca edition this is (the Docker image could not be inspected); refusing rather than placing a page Rebecca may not be able to render." ;; + esac + return 1 +} + rt_panel_rebecca_db() { # Echo the host path of Rebecca's SQLite database, or fail. Reads ONE key of # .env and never prints it: a MySQL URL carries a password. @@ -260,6 +315,7 @@ rt_panel_rebecca_backup_state() { # aux dir_state/dir: custom_templates_directory exactly (NULL, '' or # a value), root, root_created local panel="$1" page state was_running=0 d root files=() + rt_panel_rebecca_edition_ok || return "$RT_PANEL_FAIL" rt_panel_rebecca_db_ready || return "$RT_PANEL_UNAVAILABLE" page="$(rt_panel_rebecca_page_get)" || { rt_err "panel rebecca: cannot read subscription_settings"; return "$RT_PANEL_FAIL"; } if [ -z "$page" ]; then state="empty"; else state="present"; fi @@ -303,6 +359,7 @@ rt_panel_rebecca_install_template() { [ -f "$src" ] || { rt_err "panel rebecca: SOURCE is not a regular file: $src"; return "$RT_PANEL_FAIL"; } rt_is_within "$RT_ROOT" "$src" || { rt_err "panel rebecca: SOURCE is outside $RT_ROOT"; return "$RT_PANEL_FAIL"; } rt_panel_rebecca_shell_ok "$src" || { rt_err "panel rebecca: SOURCE is not a Rebecca page this release can serve"; return "$RT_PANEL_FAIL"; } + rt_panel_rebecca_edition_ok || return "$RT_PANEL_FAIL" rt_panel_rebecca_db_ready || return "$RT_PANEL_UNAVAILABLE" root="$(rt_panel_rebecca_root)" || return "$RT_PANEL_FAIL" rt_panel_rebecca_place "$src" "$root" || return "$RT_PANEL_FAIL" @@ -344,6 +401,7 @@ rt_panel_rebecca_verify() { fi rt_validate_template "$RT_LIVE" >/dev/null 2>&1 \ || { rt_err "panel rebecca: the generated page is missing or invalid: $RT_LIVE"; return "$RT_PANEL_FAIL"; } + rt_panel_rebecca_edition_ok || return "$RT_PANEL_FAIL" rt_panel_rebecca_db_ready || { rt_err "panel rebecca: cannot read the panel selection (sqlite3 and a SQLite database are needed)"; return "$RT_PANEL_FAIL"; } root="$(rt_panel_rebecca_root)" || return "$RT_PANEL_FAIL" dest="$root/$RT_RB_PAGE" @@ -480,6 +538,13 @@ rt_panel_rebecca_refresh() { # rt_panel_rebecca_refresh SOURCE [place] local src="$1" place="${2:-}" root rt_panel_rebecca_shell_ok "$src" || return "$RT_PANEL_FAIL" + # Installing fails closed on an edition it cannot identify; refreshing an + # existing install refuses only one it KNOWS cannot serve the page, so a + # Docker daemon that is briefly unreachable does not block a rebrand. + if [ "$(rt_panel_rebecca_edition)" = python ]; then + rt_panel_rebecca_edition_ok + return "$RT_PANEL_FAIL" + fi if ! root="$(rt_panel_rebecca_page_root 2>/dev/null)"; then # A directory Row-Template cannot use holds no page of ours -- unless the # panel selects our page from it, which is a real failure to report. diff --git a/tests/helpers/panel-hosts.mjs b/tests/helpers/panel-hosts.mjs index 7df43a1..cca9706 100644 --- a/tests/helpers/panel-hosts.mjs +++ b/tests/helpers/panel-hosts.mjs @@ -167,11 +167,25 @@ case "\${1:-}" in test) shift; test "$@" ;; *) exit 1 ;; esac ;; + image) + # image inspect: the image's recorded config (entrypoint, cmd, workdir), or + # "no such image" when the host has none recorded. + [ "\${2:-}" = inspect ] && [ -f "$st/inspect" ] || { echo "Error: No such image" >&2; exit 1; } + cat "$st/inspect" ;; version) echo "Docker version 99 (test double)" ;; *) exit 0 ;; esac `; +/* The image configs `docker image inspect` reports for the two Rebecca + editions, in the adapter's format (entrypoint, cmd, workdir). Rebecca 1.x + (Go) runs rebecca-server; the 0.0.x Python edition -- still what Docker + Hub's rebeccapanel/rebecca:latest is -- runs a script under /code. */ +export const REBECCA_IMAGE = { + go: '["rebecca-server"] null /app', + python: '["/code/scripts/entrypoint.sh"] null /code', +}; + const SQLITE_SHIM = `#!/usr/bin/env bash # Test double for the sqlite3 CLI: runs the statement with Python's real # sqlite3 module. Accepts the adapter's "-cmd .timeout N" prefix. @@ -311,7 +325,7 @@ export function pasarguardHost(base, { running = true, env = PG_ENV, compose = t /* --- Rebecca -------------------------------------------------------------------- */ export function rebeccaHost(base, { running = true, sqlite = true, url, customDir = null, pageTemplate = 'subscription/index.html', - rows = 1, admins = [], compose = true, cli = true } = {}) { + rows = 1, admins = [], compose = true, cli = true, edition = 'go' } = {}) { const app = join(base, 'opt', 'rebecca'); const dataDir = join(base, 'var', 'lib', 'rebecca'); const cliPath = join(base, 'usr', 'local', 'bin', 'rebecca'); @@ -338,6 +352,7 @@ export function rebeccaHost(base, { running = true, sqlite = true, url, customDi chmodSync(cliPath, 0o755); } writeFileSync(join(docker, 'image'), 'rebeccapanel/rebecca:latest'); + if (edition) writeFileSync(join(docker, 'inspect'), `${REBECCA_IMAGE[edition]}\n`); writeFileSync(join(docker, 'name'), 'rebecca-rebecca-1'); if (running) writeFileSync(join(docker, 'running'), ''); // Rebecca's own schema for the two tables the adapter reads, plus the diff --git a/tests/installer-panel-rebecca.test.mjs b/tests/installer-panel-rebecca.test.mjs index f52ee2e..56bb97c 100644 --- a/tests/installer-panel-rebecca.test.mjs +++ b/tests/installer-panel-rebecca.test.mjs @@ -100,6 +100,73 @@ test('only a sqlite: database inside the shared data directory is ever used', () }); }); +/* --- which Rebecca -------------------------------------------------------------- + Found on a real host during 1.3.0 validation: Docker Hub's + rebeccapanel/rebecca:latest -- what Rebecca's Docker installer pulls -- is the + 0.0.x Python edition, not the 1.x Go edition this page is built for. There the + selection is written and accepted, the page cannot render, and Rebecca serves + its own page instead: an install that reported success and changed nothing a + subscriber saw. The edition is now established first. */ + +test('the Rebecca edition is told apart: 1.x (Go) from 0.0.x (Python), unknown fails closed', () => { + const cases = [[{ edition: 'go' }, 'go'], [{ edition: 'python' }, 'python'], [{ edition: null }, 'unknown']]; + for (const [opts, want] of cases) { + withHost(opts, ({ run }) => { + const r = run('rt_panel_rebecca_edition; echo; rc=0; rt_panel_rebecca_edition_ok 2>/dev/null || rc=$?; echo "ok=$rc"'); + assert.match(r.out, new RegExp(`^${want}$`, 'm'), `${JSON.stringify(opts)}\n${r.err}`); + assert.match(r.out, want === 'go' ? /ok=0/ : /ok=1/); + }); + } + withHost({ edition: null }, ({ run }) => { + const r = run('rt_panel_rebecca_mode() { printf binary; }; rt_panel_rebecca_edition'); + assert.equal(r.out, 'go', 'a binary install is 1.x: the Python edition has none'); + }); +}); + +test('on the 0.0.x Python edition, install is refused before anything is written', () => { + withHost({ edition: 'python' }, ({ host, rt, run }) => { + const before = last(host); + const r = run(['export RT_ASSUME_NONINTERACTIVE=1 RT_SERVICE_NAME="X"', 'rt_cmd_install "$PAYLOAD" { + const before = last(host); + const r = run(['export RT_ASSUME_NONINTERACTIVE=1', 'rt_cmd_install "$PAYLOAD" { + withHost({}, ({ host, run }) => { + const before = last(host); + run(SETUP); + // the image changes under an existing install (a re-pull of :latest) + writeFileSync(join(host.docker, 'inspect'), '["/code/scripts/entrypoint.sh"] null /code\n'); + const r = run('rc=0; rt_transaction_run rebecca "$RT_LIVE" || rc=$?; echo "txn=$rc"'); + assert.match(r.out, /txn=1/, 'the transaction stops at capture, before any change'); + assert.match(r.err, /Python edition/); + assert.deepEqual(last(host), before); + assert.equal(existsSync(join(host.dataDir, 'templates')), false, 'no page was placed'); + const f = run('rc=0; rt_panel_refresh_page rebecca "$RT_LIVE" || rc=$?; echo "refresh=$rc"'); + assert.match(f.out, /refresh=1/, 'and a refresh refuses it as well'); + }); + withHost({}, ({ host, run }) => { + run([SETUP, 'rt_transaction_run rebecca "$RT_LIVE" 2>/dev/null']); + writeFileSync(join(host.docker, 'inspect'), '["/code/scripts/entrypoint.sh"] null /code\n'); + const r = run('rc=0; rt_panel_verify rebecca static || rc=$?; echo "rc=$rc"'); + assert.match(r.out, /rc=1/, 'a page Rebecca cannot render is not a passing install'); + assert.match(r.err, /Python edition/); + }); +}); + /* --- activation, rollback, uninstall ------------------------------------- */ test('activation sets two columns of the newest row and nothing else, with no restart', () => { From 5ae7434c78522b8241a8a60bf43a09c05d63da40 Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Sat, 26 Sep 2026 13:54:43 +0330 Subject: [PATCH 18/25] docs: Rebecca support is Rebecca 1.x, the Go edition Rebecca publishes 1.x only for its binary install; Docker Hub's rebeccapanel/rebecca image is still the 0.0.x Python edition, which cannot render this page and is now refused. The READMEs (five languages), the compatibility, installation and troubleshooting pages, the CHANGELOG and the Rebecca installer audit say so, and name Rebecca's own `rebecca migrate-binary` as the way from Docker to 1.x. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 ++++++-- README.ar.md | 6 +++-- README.fa.md | 6 +++-- README.md | 6 +++-- README.ru.md | 6 +++-- README.zh-CN.md | 6 +++-- docs/design/REBECCA-INSTALLER-AUDIT.md | 24 +++++++++++++++++++- docs/src/content/docs/ar/compatibility.mdx | 9 +++++++- docs/src/content/docs/ar/installation.mdx | 2 +- docs/src/content/docs/compatibility.mdx | 10 +++++++- docs/src/content/docs/fa/compatibility.mdx | 9 +++++++- docs/src/content/docs/fa/installation.mdx | 2 +- docs/src/content/docs/fa/troubleshooting.mdx | 11 +++++++++ docs/src/content/docs/installation.mdx | 3 ++- docs/src/content/docs/troubleshooting.mdx | 11 +++++++++ 15 files changed, 101 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b01aee6..5850f8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,9 @@ own. exact previous bytes. `row-template verify` also reports the two panel settings that still take precedence over the page: an admin's own `sub_template`, and `disable_sub_template`. -- **Rebecca support.** Rebecca is supported from this release, with the same - seven operations. The page is placed at +- **Rebecca support.** Rebecca 1.x — the Go edition, which Rebecca publishes for + its binary install — is supported from this release, with the same seven + operations. The page is placed at `/var/lib/rebecca/templates/row-template/index.html` (or inside your own custom templates directory) and selected in the newest `subscription_settings` row, which Rebecca reads on every request — so @@ -125,6 +126,10 @@ own. status on a path suffix. - PasarGuard's page title (`subTitle`) and Clash templates are not produced. - Rebecca on MySQL/MariaDB needs its one setting entered in the dashboard. +- Rebecca's Docker image (`rebeccapanel/rebecca` on Docker Hub) is still the + 0.0.x Python edition, which cannot render this page. The installer + identifies it and refuses before changing anything; Rebecca's own + `rebecca migrate-binary` moves a Docker install to 1.x. ### Documentation diff --git a/README.ar.md b/README.ar.md index b4ff1a9..7964c42 100644 --- a/README.ar.md +++ b/README.ar.md @@ -110,7 +110,7 @@ | ----- | ------ | ----- | | [3X-UI](https://github.com/MHSanaei/3x-ui) (MHSanaei) | ✅ مدعومة | تتطلب الإصدار **>= 3.6.0** | | [PasarGuard](https://github.com/PasarGuard/panel) | ✅ مدعومة منذ 1.3.0 | التثبيت الرسمي عبر Docker أو التثبيت من المصدر (`pasarguard.service`) | -| [Rebecca](https://github.com/rebeccapanel/Rebecca) | ✅ مدعومة منذ 1.3.0 | تفعيل تلقائي مع SQLite و`sqlite3`؛ ومع MySQL/MariaDB إعداد واحد يُدخَل في لوحة التحكم | +| [Rebecca](https://github.com/rebeccapanel/Rebecca) | ✅ مدعومة منذ 1.3.0 | Rebecca الإصدار **1.x**، إصدار Go (التثبيت الثنائي لـ Rebecca). تفعيل تلقائي مع SQLite و`sqlite3`؛ ومع MySQL/MariaDB إعداد واحد يُدخَل في لوحة التحكم. صورة Docker ما زالت 0.0.x وتُرفض | تستخدم اللوحات الثلاث ثلاثة محرّكات قوالب مختلفة — `html/template` في Go وJinja2 وpongo2 — لذا يُبنى كل تصميم مرة لكل لوحة، ويُختبر كل إصدار منه بعرضه بمحرّك تلك اللوحة الحقيقي. يكتشف المثبّت اللوحة الموجودة على الخادم؛ وعلى خادم فيه أكثر من لوحة يسألك (أو يقرأ `RT_PANEL`). **مدعومة** تعني توفّر القدرات السبع كلها على تلك اللوحة — الاكتشاف والتثبيت والتفعيل والتحقق والنسخ الاحتياطي والاستعادة وإلغاء التثبيت — ويختبر كلًّا منها مجموعة الاختبارات. راجع [التوافق](https://iitzseridev.github.io/Row-Template/ar/compatibility/) لتفاصيل كل لوحة. @@ -151,7 +151,7 @@ flowchart TB > **نظام التشغيل المُوصى به: Ubuntu 24.04 LTS (x86_64).** قد تعمل توزيعات Linux الحديثة الأخرى لكنها لم تحظَ بالمستوى نفسه من تغطية التحقق. -**المتطلبات:** خادم يشغّل 3X-UI **>= 3.6.0** أو PasarGuard أو Rebecca؛ وصلاحية root عليه؛ و`curl` و`tar` و`sha256sum` (متوفرة على جميع أنظمة Linux تقريبًا). ويحتاج التفعيل التلقائي في 3X-UI وRebecca أيضًا إلى `sqlite3`. +**المتطلبات:** خادم يشغّل 3X-UI **>= 3.6.0** أو PasarGuard أو Rebecca **1.x**؛ وصلاحية root عليه؛ و`curl` و`tar` و`sha256sum` (متوفرة على جميع أنظمة Linux تقريبًا). ويحتاج التفعيل التلقائي في 3X-UI وRebecca أيضًا إلى `sqlite3`. شغّل الأمر بصلاحية **root** على الخادم الذي يستضيف لوحتك: @@ -214,6 +214,8 @@ SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" تقرأ PasarGuard ملف `.env` عند الإقلاع، لذا تُعاد تشغيل اللوحة العاملة مرة واحدة. لا يُعدَّل أي سطر من أسطرك؛ ويزيل إلغاء التثبيت الكتلة ويعيد `.env` إلى بايتاته السابقة بدقة. يبقى للمشرف الذي له قالب اشتراك خاص، أو لإعداد **disable subscription template**، الأولوية — ويخبرك `row-template verify` إن انطبق أيٌّ منهما. +يدعم Row-Template الإصدار **1.x** من Rebecca، أي إصدار Go الذي تنشره Rebecca لتثبيتها الثنائي (`rebecca-binary.sh`). أما صورة `rebeccapanel/rebecca` على Docker Hub فما زالت إصدار 0.0.x المكتوب بـ Python، الذي لا يستطيع عرض هذه الصفحة؛ لذا يرفضها المثبّت ولا يغيّر شيئًا، والأمر `rebecca migrate-binary` الخاص بـ Rebecca ينقل تثبيت Docker إلى 1.x. + **Rebecca.** توضع الصفحة في `/var/lib/rebecca/templates/row-template/index.html` (أو داخل مجلد القوالب المخصّص الخاص بك)، وتُضبط إعدادات الاشتراك في Rebecca على `row-template/index.html`. تقرأ Rebecca هذه الإعدادات مع كل طلب، فلا حاجة إلى إعادة التشغيل. - **تلقائيًا** مع قاعدة بيانات SQLite الافتراضية وتثبيت `sqlite3`. diff --git a/README.fa.md b/README.fa.md index 7896b2c..ba324b3 100644 --- a/README.fa.md +++ b/README.fa.md @@ -111,7 +111,7 @@ Row-Template 1.3.0 با هفده طرح عرضه می شود. طرح پیش فر | ----- | ------ | ----- | | [3X-UI](https://github.com/MHSanaei/3x-ui) (MHSanaei) | ✅ پشتیبانی شده | نیازمند نسخهٔ **>= 3.6.0** | | [PasarGuard](https://github.com/PasarGuard/panel) | ✅ پشتیبانی شده از 1.3.0 | نصب رسمی Docker یا نصب از سورس (`pasarguard.service`) | -| [Rebecca](https://github.com/rebeccapanel/Rebecca) | ✅ پشتیبانی شده از 1.3.0 | فعال سازی خودکار با SQLite و `sqlite3`؛ با MySQL/MariaDB یک تنظیم که باید در داشبورد وارد شود | +| [Rebecca](https://github.com/rebeccapanel/Rebecca) | ✅ پشتیبانی شده از 1.3.0 | Rebecca نسخهٔ **1.x**، نسخهٔ Go (نصب باینری Rebecca). فعال سازی خودکار با SQLite و `sqlite3`؛ با MySQL/MariaDB یک تنظیم که باید در داشبورد وارد شود. ایمیج Docker هنوز 0.0.x است و رد می شود | این سه پنل از سه موتور قالب متفاوت استفاده می کنند — `html/template` زبان Go، Jinja2 و pongo2 — پس هر طرح برای هر پنل یک بار ساخته می شود و هر نسخه با رندر شدن توسط موتور واقعی همان پنل آزموده می شود. نصب کننده تشخیص می دهد کدام پنل روی سرور است؛ روی سروری با بیش از یک پنل، از شما می پرسد (یا `RT_PANEL` را می خواند). **پشتیبانی‌شده** یعنی هر هفت توانایی روی آن پنل موجود است — تشخیص، نصب، فعال‌سازی، بررسی، پشتیبان‌گیری، بازگردانی و حذف نصب — و هر کدام توسط مجموعهٔ آزمون آزموده می شود. برای جزئیات هر پنل، [سازگاری](https://iitzseridev.github.io/Row-Template/fa/compatibility/) را ببینید. @@ -152,7 +152,7 @@ flowchart TB > **سیستم عامل پیشنهادی: Ubuntu 24.04 LTS (x86_64).** دیگر توزیع های امروزی لینوکس نیز ممکن است کار کنند، اما پوشش اعتبارسنجی یکسانی نداشته اند. -**پیش نیازها:** سروری با 3X-UI **>= 3.6.0**، PasarGuard یا Rebecca؛ دسترسی root به آن؛ و `curl`، `tar` و `sha256sum` (که تقریباً روی همهٔ سیستم های لینوکس موجود است). فعال سازی خودکار در 3X-UI و Rebecca به `sqlite3` هم نیاز دارد. +**پیش نیازها:** سروری با 3X-UI **>= 3.6.0**، PasarGuard یا Rebecca **1.x**؛ دسترسی root به آن؛ و `curl`، `tar` و `sha256sum` (که تقریباً روی همهٔ سیستم های لینوکس موجود است). فعال سازی خودکار در 3X-UI و Rebecca به `sqlite3` هم نیاز دارد. با کاربر **root** روی سروری که پنل شما را میزبانی می کند اجرا کنید: @@ -215,6 +215,8 @@ SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" PasarGuard فایل `.env` را هنگام راه اندازی می خواند، پس پنلی که در حال اجراست یک بار راه اندازی مجدد می شود. هیچ یک از خط های خودتان ویرایش نمی شود؛ حذف نصب بلوک را برمی دارد و `.env` را دقیقاً به بایت های قبلی اش بازمی گرداند. ادمینی که قالب اشتراک خودش را دارد، یا تنظیم **disable subscription template**، همچنان مقدم است — `row-template verify` به شما می گوید اگر یکی از آن ها برقرار باشد. +Row-Template از Rebecca نسخهٔ **1.x** پشتیبانی می کند، یعنی نسخهٔ Go که Rebecca برای نصب باینری خود (`rebecca-binary.sh`) منتشر می کند. ایمیج `rebeccapanel/rebecca` در Docker Hub هنوز نسخهٔ 0.0.x پایتونی است که نمی تواند این صفحه را رندر کند؛ نصب کننده آن را رد می کند و چیزی را تغییر نمی دهد، و دستور `rebecca migrate-binary` خود Rebecca یک نصب Docker را به 1.x منتقل می کند. + **Rebecca.** صفحه در `/var/lib/rebecca/templates/row-template/index.html` قرار می گیرد (یا درون دایرکتوری قالب های سفارشی خودتان)، و تنظیمات اشتراک Rebecca روی `row-template/index.html` تنظیم می شود. Rebecca این تنظیمات را در هر درخواست می خواند، پس نیازی به راه اندازی مجدد نیست. - **خودکار** با پایگاه دادهٔ پیش فرض SQLite و نصب بودن `sqlite3`. diff --git a/README.md b/README.md index 78c25d3..feb54c0 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ Choose a design during a fresh interactive install, set `RT_TEMPLATE` for a scri | ----- | ------ | ----- | | [3X-UI](https://github.com/MHSanaei/3x-ui) (MHSanaei) | ✅ Supported | Requires version **>= 3.6.0** | | [PasarGuard](https://github.com/PasarGuard/panel) | ✅ Supported since 1.3.0 | The official Docker install or a source install (`pasarguard.service`) | -| [Rebecca](https://github.com/rebeccapanel/Rebecca) | ✅ Supported since 1.3.0 | Automatic activation with SQLite and `sqlite3`; with MySQL/MariaDB, one setting to enter in the dashboard | +| [Rebecca](https://github.com/rebeccapanel/Rebecca) | ✅ Supported since 1.3.0 | Rebecca **1.x**, the Go edition (Rebecca's binary install). Automatic activation with SQLite and `sqlite3`; with MySQL/MariaDB, one setting to enter in the dashboard. The Docker image is still 0.0.x and is refused | The three panels use three different template engines — Go `html/template`, Jinja2 and pongo2 — so every design is built once per panel, and each version is tested by rendering it with that panel's real engine. The installer detects which panel is on the server; on a server with more than one, it asks (or reads `RT_PANEL`). **Supported** means all seven capabilities are present on that panel — detect, install, activate, verify, backup, restore and uninstall — and each one is exercised by the test suite. See [Compatibility](https://iitzseridev.github.io/Row-Template/compatibility/) for the details of each panel. @@ -152,7 +152,7 @@ flowchart TB > **Recommended OS: Ubuntu 24.04 LTS (x86_64).** Other modern Linux distributions may work but have not had the same validation coverage. -**Requirements:** a server running 3X-UI **>= 3.6.0**, PasarGuard, or Rebecca; root access to it; and `curl`, `tar`, and `sha256sum` (present on virtually all Linux systems). Automatic activation on 3X-UI and Rebecca also needs `sqlite3`. +**Requirements:** a server running 3X-UI **>= 3.6.0**, PasarGuard, or Rebecca **1.x**; root access to it; and `curl`, `tar`, and `sha256sum` (present on virtually all Linux systems). Automatic activation on 3X-UI and Rebecca also needs `sqlite3`. Run as **root** on the server that hosts your panel: @@ -215,6 +215,8 @@ SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" PasarGuard reads `.env` at start-up, so a running panel is restarted once. None of your own lines are edited; uninstall removes the block and returns `.env` to its exact previous bytes. An admin with their own subscription template, or the **disable subscription template** setting, still takes precedence — `row-template verify` tells you when either applies. +Row-Template supports Rebecca **1.x**, the Go edition, which Rebecca publishes for its binary install (`rebecca-binary.sh`). Docker Hub's `rebeccapanel/rebecca` image is still the 0.0.x Python edition, which cannot render this page; the installer refuses it and changes nothing, and Rebecca's own `rebecca migrate-binary` moves a Docker install to 1.x. + **Rebecca.** The page is placed at `/var/lib/rebecca/templates/row-template/index.html` (or inside your own custom templates directory), and Rebecca's subscription settings are set to `row-template/index.html`. Rebecca reads them on every request, so no restart is needed. - **Automatic** with the default SQLite database and `sqlite3` installed. diff --git a/README.ru.md b/README.ru.md index 182017f..9c8e6b2 100644 --- a/README.ru.md +++ b/README.ru.md @@ -111,7 +111,7 @@ Row-Template 1.3.0 поставляется с семнадцатью дизай | ----- | ------ | ----- | | [3X-UI](https://github.com/MHSanaei/3x-ui) (MHSanaei) | ✅ Поддерживается | Требуется версия **>= 3.6.0** | | [PasarGuard](https://github.com/PasarGuard/panel) | ✅ Поддерживается с 1.3.0 | Официальная установка в Docker или установка из исходников (`pasarguard.service`) | -| [Rebecca](https://github.com/rebeccapanel/Rebecca) | ✅ Поддерживается с 1.3.0 | Автоматическая активация с SQLite и `sqlite3`; с MySQL/MariaDB — одна настройка в панели управления | +| [Rebecca](https://github.com/rebeccapanel/Rebecca) | ✅ Поддерживается с 1.3.0 | Rebecca **1.x**, версия на Go (бинарная установка Rebecca). Автоматическая активация с SQLite и `sqlite3`; с MySQL/MariaDB — одна настройка в панели управления. Образ Docker всё ещё 0.0.x и отклоняется | Три панели используют три разных шаблонизатора — Go `html/template`, Jinja2 и pongo2, — поэтому каждый дизайн собирается отдельно для каждой панели, и каждая версия проверяется отрисовкой настоящим движком этой панели. Установщик определяет, какая панель стоит на сервере; если их несколько, он спрашивает (или читает `RT_PANEL`). **Поддерживается** означает, что для этой панели есть все семь возможностей — обнаружение, установка, активация, проверка, резервная копия, восстановление и удаление, — и каждая из них покрыта тестами. Подробности по каждой панели — в разделе [Совместимость](https://iitzseridev.github.io/Row-Template/compatibility/). @@ -152,7 +152,7 @@ flowchart TB > **Рекомендуемая ОС: Ubuntu 24.04 LTS (x86_64).** Другие современные дистрибутивы Linux могут работать, но не проходили такого же объёма проверок. -**Требования:** сервер с 3X-UI **>= 3.6.0**, PasarGuard или Rebecca; root-доступ к нему; `curl`, `tar` и `sha256sum` (есть практически в любой системе Linux). Для автоматической активации в 3X-UI и Rebecca также нужен `sqlite3`. +**Требования:** сервер с 3X-UI **>= 3.6.0**, PasarGuard или Rebecca **1.x**; root-доступ к нему; `curl`, `tar` и `sha256sum` (есть практически в любой системе Linux). Для автоматической активации в 3X-UI и Rebecca также нужен `sqlite3`. Запустите от имени **root** на сервере, где работает ваша панель: @@ -215,6 +215,8 @@ SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" PasarGuard читает `.env` при запуске, поэтому работающая панель перезапускается один раз. Ни одна ваша строка не редактируется; удаление убирает блок и возвращает `.env` точно к прежним байтам. Администратор с собственным шаблоном подписки или настройка **disable subscription template** по-прежнему имеют приоритет — `row-template verify` сообщит, если действует что-то из этого. +Row-Template поддерживает Rebecca **1.x** — версию на Go, которую Rebecca публикует для бинарной установки (`rebecca-binary.sh`). Образ `rebeccapanel/rebecca` на Docker Hub всё ещё версии 0.0.x на Python, которая не может отрисовать эту страницу; установщик отклоняет её и ничего не меняет, а собственная команда Rebecca `rebecca migrate-binary` переводит установку Docker на 1.x. + **Rebecca.** Страница размещается в `/var/lib/rebecca/templates/row-template/index.html` (или в вашем собственном каталоге шаблонов), а в настройках подписки Rebecca выбирается `row-template/index.html`. Rebecca читает эти настройки при каждом запросе, поэтому перезапуск не нужен. - **Автоматически** — с базой SQLite по умолчанию и установленным `sqlite3`. diff --git a/README.zh-CN.md b/README.zh-CN.md index 882cba4..865597d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -110,7 +110,7 @@ Row-Template 1.3.0 提供十七种设计,默认设计为 Row。 | ----- | ------ | ----- | | [3X-UI](https://github.com/MHSanaei/3x-ui) (MHSanaei) | ✅ 已支持 | 需要 **>= 3.6.0** 版本 | | [PasarGuard](https://github.com/PasarGuard/panel) | ✅ 自 1.3.0 起支持 | 官方 Docker 安装或源码安装(`pasarguard.service`) | -| [Rebecca](https://github.com/rebeccapanel/Rebecca) | ✅ 自 1.3.0 起支持 | 使用 SQLite 和 `sqlite3` 时自动激活;使用 MySQL/MariaDB 时需在控制台中填写一项设置 | +| [Rebecca](https://github.com/rebeccapanel/Rebecca) | ✅ 自 1.3.0 起支持 | Rebecca **1.x**,即 Go 版本(Rebecca 的二进制安装)。使用 SQLite 和 `sqlite3` 时自动激活;使用 MySQL/MariaDB 时需在控制台中填写一项设置。Docker 镜像仍是 0.0.x,会被拒绝 | 三个面板使用三种不同的模板引擎 —— Go `html/template`、Jinja2 和 pongo2 —— 因此每种设计都会为每个面板分别构建,并用该面板真实的引擎渲染来测试每个版本。安装程序会检测服务器上是哪一个面板;如果有多个,它会询问你(或读取 `RT_PANEL`)。**支持**意味着该面板具备全部七项能力 —— 检测、安装、激活、校验、备份、还原和卸载 —— 并且每一项都由测试套件覆盖。各面板的详细信息见[兼容性](https://iitzseridev.github.io/Row-Template/compatibility/)。 @@ -151,7 +151,7 @@ flowchart TB > **推荐操作系统:Ubuntu 24.04 LTS (x86_64)。** 其他较新的 Linux 发行版或许也能运行,但未经过同等程度的验证覆盖。 -**环境要求:** 运行 3X-UI **>= 3.6.0**、PasarGuard 或 Rebecca 的服务器;该服务器的 root 权限;以及 `curl`、`tar` 和 `sha256sum`(几乎所有 Linux 系统都自带)。在 3X-UI 和 Rebecca 上自动激活还需要 `sqlite3`。 +**环境要求:** 运行 3X-UI **>= 3.6.0**、PasarGuard 或 Rebecca **1.x** 的服务器;该服务器的 root 权限;以及 `curl`、`tar` 和 `sha256sum`(几乎所有 Linux 系统都自带)。在 3X-UI 和 Rebecca 上自动激活还需要 `sqlite3`。 在托管你的面板的服务器上以 **root** 身份运行: @@ -214,6 +214,8 @@ SUBSCRIPTION_PAGE_TEMPLATE = "row-template/index.html" PasarGuard 在启动时读取 `.env`,因此正在运行的面板会重启一次。你自己的任何一行都不会被修改;卸载会删除该块,并把 `.env` 精确恢复为之前的字节。拥有自己订阅模板的管理员,或 **disable subscription template** 设置,仍然优先 —— 如果其中之一生效,`row-template verify` 会告诉你。 +Row-Template 支持 Rebecca **1.x**,即 Rebecca 为其二进制安装(`rebecca-binary.sh`)发布的 Go 版本。Docker Hub 上的 `rebeccapanel/rebecca` 镜像仍是 0.0.x 的 Python 版本,无法渲染此页面;安装程序会拒绝它且不做任何更改,而 Rebecca 自带的 `rebecca migrate-binary` 可以把 Docker 安装迁移到 1.x。 + **Rebecca。** 页面放在 `/var/lib/rebecca/templates/row-template/index.html`(或你自己的自定义模板目录中),并把 Rebecca 的订阅设置设为 `row-template/index.html`。Rebecca 在每次请求时读取这些设置,因此无需重启。 - **自动:** 使用默认的 SQLite 数据库并已安装 `sqlite3` 时。 diff --git a/docs/design/REBECCA-INSTALLER-AUDIT.md b/docs/design/REBECCA-INSTALLER-AUDIT.md index 0e7e487..58c85bd 100644 --- a/docs/design/REBECCA-INSTALLER-AUDIT.md +++ b/docs/design/REBECCA-INSTALLER-AUDIT.md @@ -34,10 +34,31 @@ overrides are the operator's and are reported, never changed. | fact | source | |---|---| | Application directory `/opt/rebecca`, data directory `/var/lib/rebecca`, compose file `/opt/rebecca/docker-compose.yml`. | `scripts/rebecca/rebecca.sh` (`INSTALL_DIR`, `APP_DIR`, `DATA_DIR`, `COMPOSE_FILE`) | -| Docker: `image: rebeccapanel/rebecca:latest`, `env_file: .env`, bind mount `/var/lib/rebecca:/var/lib/rebecca`. | `docker-compose.yml` | +| Docker: `image: rebeccapanel/rebecca:latest`, `env_file: .env`, bind mount `/var/lib/rebecca:/var/lib/rebecca` — but see §2a: that image is not 1.x. | `docker-compose.yml` | | Binary mode runs as `rebecca.service`. | `scripts/rebecca/rebecca-binary.sh` | | SQLite is `SQLALCHEMY_DATABASE_URL = "sqlite:////var/lib/rebecca/db.sqlite3"`; MySQL/MariaDB URLs carry the password inline. | `rebecca.sh`, `rebecca-binary.sh` | +### 2a. Two editions — corrected by real-host validation + +The source audited above is Rebecca **1.x**, the Go edition (pongo2), and that is +what Rebecca publishes today through its **binary** installer, `rebecca-binary.sh` +(`/opt/rebecca/bin/rebecca-server`, `rebecca.service`). The **Docker** installer, +`rebecca.sh`, pulls `rebeccapanel/rebecca:latest` from Docker Hub — and on Docker +Hub `latest` is still `v0.0.37-alpha` (built 2026-02-18), the earlier **Python** +edition (FastAPI, Jinja2, entrypoint `/code/scripts/entrypoint.sh`). No 1.x image +is published there. + +Validated on a real host (1.3.0): on 1.x everything below holds. On the Python +edition the `subscription_settings` selection is written and accepted — Rebecca +serves a custom page chosen that way — but this release's pongo2 page cannot render +under Jinja2, and Rebecca silently falls back to its own page. That is an install +that would report success and change nothing a subscriber sees, so the adapter +**establishes the edition first** (`rt_panel_rebecca_edition`): a binary install is +1.x; a Docker install is 1.x only when its image runs `rebecca-server`. The Python +edition, and an edition that cannot be identified, are refused before anything is +written (`rt_panel_preflight`, and again in capture, `install_template`, static +verify and refresh). The message names Rebecca's own `rebecca migrate-binary`. + ## 3. Detection Read-only, **two independent signals of four**: `/opt/rebecca/.env`; a compose file @@ -131,6 +152,7 @@ malformed data (missing fields, `null`s, zero and negative limits, huge values). ## 10. Limitations +- Rebecca 1.x only. The Docker Hub image (0.0.x, Python) is refused (§2a). - MySQL/MariaDB: manual selection in the dashboard (the page is placed automatically). - Per-admin overrides keep their own page (reported by verify). - No live verification (none is possible beyond what static verify reads). diff --git a/docs/src/content/docs/ar/compatibility.mdx b/docs/src/content/docs/ar/compatibility.mdx index a91b1ae..d415fe8 100644 --- a/docs/src/content/docs/ar/compatibility.mdx +++ b/docs/src/content/docs/ar/compatibility.mdx @@ -8,7 +8,7 @@ description: اللوحات التي يدعمها رو-تمبلت، وما يغ | | ‎3X-UI‎ | ‎PasarGuard‎ | ‎Rebecca‎ | |---|---|---|---| | **مدعومة منذ** | ‎1.0.0‎ | ‎1.3.0‎ | ‎1.3.0‎ | -| **الإصدارات** | **‎3.6.0‎ أو أحدث** | ‎5.x‎ — التثبيت الرسمي عبر ‎Docker‎ أو التثبيت من المصدر | ‎1.3‎ أو أحدث — ‎Docker‎ أو ملف تنفيذي | +| **الإصدارات** | **‎3.6.0‎ أو أحدث** | ‎5.x‎ — التثبيت الرسمي عبر ‎Docker‎ أو التثبيت من المصدر | **‎1.x‎** (إصدار ‎Go‎) — التثبيت الثنائي لـ‎Rebecca‎؛ وتُرفض صورة ‎Docker‎ (‎0.0.x‎) | | **محرك القوالب** | ‎`html/template`‎ في ‎Go‎ | ‎Jinja2‎ | ‎pongo2‎ (بأسلوب ‎Django‎، في ‎Go‎) | | **جذر التثبيت** | ‎`/etc/3x-ui/sub_templates/row-template`‎ | ‎`/etc/row-template`‎ | ‎`/etc/row-template`‎ | | **ما يغيّره التفعيل** | ‎`subThemeDir`‎ | كتلة معلَّمة واحدة في ‎`/opt/pasarguard/.env`‎ | حقلان من أحدث صف في إعدادات الاشتراك | @@ -78,6 +78,13 @@ description: اللوحات التي يدعمها رو-تمبلت، وما يغ ## ‎Rebecca‎ +- **الإصدار.** يدعم رو-تمبلت الإصدار **‎1.x‎** من ‎Rebecca‎، أي إصدار ‎Go‎ الذي تنشره + ‎Rebecca‎ لتثبيتها الثنائي (‎`rebecca-binary.sh`‎، ويعمل بوصفه ‎`rebecca.service`‎). أما صورة + ‎`rebeccapanel/rebecca`‎ على ‎Docker Hub‎ فما زالت **إصدار ‎0.0.x‎ المكتوب بـ‎Python‎**، الذي + يعرض الصفحات من سياق مختلف ولا يستطيع عرض هذه الصفحة: هناك تقبل Rebecca الإعداد وتواصل بصمت + عرض صفحتها الخاصة. لذلك يحدّد المثبّت الإصدار أولًا (تثبيت ثنائي، أو صورة تشغّل + ‎`rebecca-server`‎) ويرفض أي شيء آخر قبل أن يغيّر شيئًا. والأمر ‎`rebecca migrate-binary`‎ + الخاص بـ‎Rebecca‎ ينقل تثبيت ‎Docker‎ إلى ‎1.x‎. - **موضع الصفحة.** ‎`/var/lib/rebecca/templates/row-template/index.html`‎، أو داخل مجلد القوالب المخصّص الخاص بك. - **الاختيار.** حقلا الصفحة والمجلد في أحدث صف من ‎`subscription_settings`‎ — الصف الذي تقرؤه diff --git a/docs/src/content/docs/ar/installation.mdx b/docs/src/content/docs/ar/installation.mdx index 40e9706..f01788b 100644 --- a/docs/src/content/docs/ar/installation.mdx +++ b/docs/src/content/docs/ar/installation.mdx @@ -8,7 +8,7 @@ description: ثبّت رو-تمبلت على ‎3X-UI‎ أو ‎PasarGuard‎ ## قبل أن تبدأ - **لوحة مدعومة** على الخادم: **‎3X-UI‎ الإصدار ‎3.6.0‎ أو أحدث** (تحقّق عبر ‎`x-ui`‎ أو من - اللوحة نفسها)، أو **‎PasarGuard‎**، أو **‎Rebecca‎**، مثبّتة كما يثبّتها مثبّتها الرسمي. يكتشف + اللوحة نفسها)، أو **‎PasarGuard‎**، أو **‎Rebecca‎ الإصدار ‎1.x‎** (إصدار ‎Go‎، التثبيت الثنائي)، مثبّتة كما يثبّتها مثبّتها الرسمي. يكتشف المثبّت أيّها موجود. وعلى خادم فيه أكثر من لوحة يسألك، أو يقرأ ‎`RT_PANEL`‎ (‎`3xui`‎ أو ‎`pasarguard`‎ أو ‎`rebecca`‎) في السكربت. راجع [التوافق](/ar/compatibility/). - **صلاحيات root.** التثبيت يغيّر النظام. diff --git a/docs/src/content/docs/compatibility.mdx b/docs/src/content/docs/compatibility.mdx index 4454cd3..2198526 100644 --- a/docs/src/content/docs/compatibility.mdx +++ b/docs/src/content/docs/compatibility.mdx @@ -8,7 +8,7 @@ description: The panels Row-Template supports, what it changes on each, and what | | 3X-UI | PasarGuard | Rebecca | |---|---|---|---| | **Supported since** | 1.0.0 | 1.3.0 | 1.3.0 | -| **Versions** | **3.6.0 or newer** | 5.x — the official Docker install or a source install | 1.3 or newer — Docker or binary | +| **Versions** | **3.6.0 or newer** | 5.x — the official Docker install or a source install | **1.x** (the Go edition) — Rebecca's binary install; the Docker image (0.0.x) is refused | | **Template engine** | Go `html/template` | Jinja2 | pongo2 (Django-style, in Go) | | **Install root** | `/etc/3x-ui/sub_templates/row-template` | `/etc/row-template` | `/etc/row-template` | | **What activation changes** | `subThemeDir` | one marked block in `/opt/pasarguard/.env` | two fields of the newest subscription settings row | @@ -84,6 +84,14 @@ v6.1.0 (Rebecca's pinned version) for Rebecca — including hostile and malforme ## Rebecca +- **Edition.** Row-Template supports Rebecca **1.x**, the Go edition, which Rebecca + publishes for its binary install (`rebecca-binary.sh`, run as `rebecca.service`). Docker + Hub's `rebeccapanel/rebecca` image is still the **0.0.x Python edition**, which renders + pages from a different context and cannot render this one: there, Rebecca would accept + the setting and silently keep serving its own page. The installer therefore identifies + the edition first (a binary install, or an image that runs `rebecca-server`) and refuses + anything else before changing a thing. Rebecca's own `rebecca migrate-binary` moves a + Docker install to 1.x. - **Placement.** `/var/lib/rebecca/templates/row-template/index.html`, or inside your own custom templates directory. - **Selection.** The page and directory fields of the newest `subscription_settings` row — diff --git a/docs/src/content/docs/fa/compatibility.mdx b/docs/src/content/docs/fa/compatibility.mdx index 7dd20e9..de5199e 100644 --- a/docs/src/content/docs/fa/compatibility.mdx +++ b/docs/src/content/docs/fa/compatibility.mdx @@ -8,7 +8,7 @@ description: پنل‌هایی که رو-تمپلیت پشتیبانی می‌ک | | ۳X-UI | PasarGuard | Rebecca | |---|---|---|---| | **پشتیبانی از** | 1.0.0 | 1.3.0 | 1.3.0 | -| **نسخه‌ها** | **۳.۶.۰ یا بالاتر** | ۵.x — نصب رسمی Docker یا نصب از سورس | ۱.۳ یا بالاتر — Docker یا باینری | +| **نسخه‌ها** | **۳.۶.۰ یا بالاتر** | ۵.x — نصب رسمی Docker یا نصب از سورس | **1.x** (نسخهٔ Go) — نصب باینری Rebecca؛ ایمیج Docker (0.0.x) رد می‌شود | | **موتور قالب** | `html/template` زبان Go | Jinja2 | pongo2 (به سبک Django، در Go) | | **ریشهٔ نصب** | `/etc/3x-ui/sub_templates/row-template` | `/etc/row-template` | `/etc/row-template` | | **آنچه فعال‌سازی تغییر می‌دهد** | `subThemeDir` | یک بلوک نشان‌دار در `/opt/pasarguard/.env` | دو فیلد از جدیدترین ردیف تنظیمات اشتراک | @@ -81,6 +81,13 @@ description: پنل‌هایی که رو-تمپلیت پشتیبانی می‌ک ## Rebecca +- **نسخه.** رو-تمپلیت از Rebecca نسخهٔ **1.x** پشتیبانی می‌کند، یعنی نسخهٔ Go که Rebecca برای + نصب باینری خود (`rebecca-binary.sh`، اجراشده به‌صورت `rebecca.service`) منتشر می‌کند. ایمیج + `rebeccapanel/rebecca` در Docker Hub هنوز **نسخهٔ 0.0.x پایتونی** است که صفحه‌ها را از زمینهٔ + دیگری رندر می‌کند و نمی‌تواند این صفحه را رندر کند: آنجا Rebecca تنظیم را می‌پذیرد و بی‌صدا + همچنان صفحهٔ خودش را نشان می‌دهد. پس نصب‌کننده ابتدا نسخه را تشخیص می‌دهد (نصب باینری، یا + ایمیجی که `rebecca-server` را اجرا می‌کند) و هر چیز دیگری را پیش از هر تغییری رد می‌کند. دستور + `rebecca migrate-binary` خود Rebecca یک نصب Docker را به 1.x منتقل می‌کند. - **جای صفحه.** `/var/lib/rebecca/templates/row-template/index.html`، یا درون پوشهٔ قالب‌های سفارشی خودتان. - **انتخاب.** فیلدهای صفحه و پوشهٔ جدیدترین ردیف `subscription_settings` — ردیفی که Rebecca در diff --git a/docs/src/content/docs/fa/installation.mdx b/docs/src/content/docs/fa/installation.mdx index 64c940c..d20879e 100644 --- a/docs/src/content/docs/fa/installation.mdx +++ b/docs/src/content/docs/fa/installation.mdx @@ -8,7 +8,7 @@ description: نصب رو-تمپلیت روی ۳X-UI، PasarGuard یا Rebecca ب ## پیش از شروع - **یک پنل پشتیبانی‌شده** روی سرور: **۳X-UI نسخهٔ ۳.۶.۰ یا بالاتر** (با `x-ui` یا از خود پنل - بررسی کنید)، **PasarGuard** یا **Rebecca**، به همان شکلی که نصب‌کنندهٔ رسمی‌شان نصب می‌کند. + بررسی کنید)، **PasarGuard** یا **Rebecca نسخهٔ 1.x** (نسخهٔ Go، نصب باینری Rebecca)، به همان شکلی که نصب‌کنندهٔ رسمی‌شان نصب می‌کند. نصب‌کننده تشخیص می‌دهد کدام‌یک موجود است. روی سروری با بیش از یک پنل می‌پرسد، یا در اسکریپت `RT_PANEL` (`3xui`، `pasarguard` یا `rebecca`) را می‌خواند. [سازگاری](/fa/compatibility/) را ببینید. diff --git a/docs/src/content/docs/fa/troubleshooting.mdx b/docs/src/content/docs/fa/troubleshooting.mdx index 8a4b973..4660a97 100644 --- a/docs/src/content/docs/fa/troubleshooting.mdx +++ b/docs/src/content/docs/fa/troubleshooting.mdx @@ -130,6 +130,17 @@ PasarGuard می‌بیند، پس صفحه‌ای که آنجا گذاشته ش --- +### `panel rebecca: this is Rebecca 0.0.x, the Python edition …` + +**معنی.** این Rebecca ایمیج `rebeccapanel/rebecca` از Docker Hub را اجرا می‌کند که هنوز نسخهٔ +0.0.x پایتونی است. این نسخه نمی‌تواند صفحهٔ Rebecca رو-تمپلیت را رندر کند، پس نصب‌کننده پیش از +هر تغییری امتناع کرد. + +**راه حل.** با دستور `rebecca migrate-binary` خود Rebecca (یا نصب با `rebecca-binary.sh`) به +Rebecca نسخهٔ 1.x یعنی نسخهٔ Go بروید، سپس نصب‌کننده را دوباره اجرا کنید. + +--- + ### برخی کاربران Rebecca همچنان صفحهٔ دیگری می‌بینند **معنی.** `row-template verify` گزارش می‌دهد *N admin(s) override the subscription page for diff --git a/docs/src/content/docs/installation.mdx b/docs/src/content/docs/installation.mdx index c963802..d585a5a 100644 --- a/docs/src/content/docs/installation.mdx +++ b/docs/src/content/docs/installation.mdx @@ -8,7 +8,8 @@ Three steps. Each one tells you what you should see, and what to do if you do no ## Before you start - **A supported panel** on the server: **3X-UI 3.6.0 or newer** (check with `x-ui` or the - panel's own version display), **PasarGuard** or **Rebecca**, installed the way their + panel's own version display), **PasarGuard** or **Rebecca 1.x** (the Go edition, Rebecca's + binary install), installed the way their official installers lay them out. The installer detects which one is there. On a server with more than one it asks, or reads `RT_PANEL` (`3xui`, `pasarguard` or `rebecca`) in a script. See [Compatibility](/compatibility/). diff --git a/docs/src/content/docs/troubleshooting.mdx b/docs/src/content/docs/troubleshooting.mdx index 72f8412..f1465a5 100644 --- a/docs/src/content/docs/troubleshooting.mdx +++ b/docs/src/content/docs/troubleshooting.mdx @@ -146,6 +146,17 @@ installer placed the page but could not select it. --- +### `panel rebecca: this is Rebecca 0.0.x, the Python edition …` + +**What it means.** This Rebecca runs Docker Hub's `rebeccapanel/rebecca` image, which is +still the 0.0.x Python edition. It cannot render Row-Template's Rebecca page, so the +installer refused before changing anything. + +**Fix.** Move to Rebecca 1.x, the Go edition, with Rebecca's own `rebecca migrate-binary` +(or install it with `rebecca-binary.sh`), then re-run the installer. + +--- + ### Some Rebecca users still see another page **What it means.** `row-template verify` reports *N admin(s) override the subscription page From 0866c11ac400251c35933aa125d82c19c61b8a4d Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Sat, 26 Sep 2026 14:02:37 +0330 Subject: [PATCH 19/25] fix(install): run the panel preflight only for panels that have one A 3X-UI install can run from the management library alone, before its panel adapters are installed (the v1.1.0 updater leaves exactly that). Calling rt_panel_preflight there failed with 'command not found'. 3X-UI has no preflight; the other panels always load their adapter to be detected. Co-Authored-By: Claude Opus 5.5 --- installer/lib/row-template.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/installer/lib/row-template.sh b/installer/lib/row-template.sh index 1a34383..447a50e 100644 --- a/installer/lib/row-template.sh +++ b/installer/lib/row-template.sh @@ -2995,8 +2995,12 @@ rt_cmd_install() { rt_panel_choose || rt_die "nothing was changed." panel="$RT_ACTIVE_PANEL" # A panel can be present and still unable to serve this release's page - # (Rebecca's 0.0.x Python edition); refuse before anything is written. - rt_panel_preflight "$panel" || rt_die "nothing was changed." + # (Rebecca's 0.0.x Python edition); refuse before anything is written. 3X-UI + # has no such check, and a 3X-UI install can run from the library alone + # (the panel adapters are companions it may not have yet). + if [ "$panel" != "3xui" ]; then + rt_panel_preflight "$panel" || rt_die "nothing was changed." + fi # environment discovery + hard version gate (fail closed). 3X-UI only: the # other panels are identified by their adapter, and their activation does not From 14be1e49544183faf54e7a2121aa44a8bcce8067 Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Sat, 26 Sep 2026 14:10:06 +0330 Subject: [PATCH 20/25] fix(installer): no pipe into grep -q or head can misreport a match The structural gate checked a page with `head -c 512 f | grep -qi ''`. grep -q exits on its first match; head, still writing, dies of SIGPIPE; pipefail then reports the MATCH as a failure. Measured on a loaded Linux host at about 0.7% of calls (PIPESTATUS 141 0), it made install, update and design switching refuse a valid page -- and made the suite fail a different test on each run. This is the pipefail+SIGPIPE class the project already fixed once in rt_detect_xui. Every such pipe in the installer is now pipe-free or reads its whole input: the gate's head and tail checks (tr), the control-character check and the config-permission check (bash patterns), rt_backup_latest, the smoke-URL and companion-list parsers (sed -n 1p), and PasarGuard's block-marker lookup (grep -m1). A deterministic test forces the old interleaving with doubles. Co-Authored-By: Claude Opus 5.5 --- installer/lib/row-template.sh | 29 ++++++++++++++++++++--------- installer/panels/pasarguard.sh | 5 +++-- tests/installer.test.mjs | 23 +++++++++++++++++++++++ 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/installer/lib/row-template.sh b/installer/lib/row-template.sh index 447a50e..c4b7a47 100644 --- a/installer/lib/row-template.sh +++ b/installer/lib/row-template.sh @@ -137,9 +137,12 @@ rt_trim() { 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:]]' + # A bash pattern, not `printf | grep -q`: under pipefail, grep -q exiting on + # its first match can kill the writer with SIGPIPE and turn a match into a + # failure (measured on a loaded host, 1.3.0). An embedded newline is content + # here, and [[:cntrl:]] matches it, exactly as grep -z did. + local LC_ALL=C + [[ "$1" == *[[:cntrl:]]* ]] } rt_b64_encode() { base64 | tr -d '\n'; } # stdin -> single-line base64 @@ -858,9 +861,17 @@ rt_validate_template() { 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 '' \ + # No `head | grep -q` here. Under pipefail, grep -q exits on its first match, + # head can die of SIGPIPE writing the rest, and pipefail reports the match as + # a failure: measured on a loaded Linux host (1.3.0) at ~0.7% of calls, which + # made install, update and design switching refuse a perfectly valid page. + # tr reads its whole input, so nothing in these pipelines can be cut short. + local lead trail + lead="$(LC_ALL=C head -c 512 "$f" 2>/dev/null | LC_ALL=C tr -d '\000' | LC_ALL=C tr '[:upper:]' '[:lower:]')" + [[ "$lead" == *''* ]] \ || { rt_err "generated template does not begin with "; return 1; } - LC_ALL=C tail -c 64 "$f" | LC_ALL=C grep -q '' \ + trail="$(LC_ALL=C tail -c 64 "$f" 2>/dev/null | LC_ALL=C tr -d '\000')" + [[ "$trail" == *''* ]] \ || { 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)" @@ -1107,7 +1118,7 @@ rt_backups_list() { done | LC_ALL=C sort -r } -rt_backup_latest() { rt_backups_list | head -n1; } +rt_backup_latest() { rt_backups_list | LC_ALL=C sed -n 1p; } # sed reads it all: no SIGPIPE rt_backups_prune() { # keep the KEEP newest valid backups (KEEP>=2 enforced by callers); delete the @@ -2009,7 +2020,7 @@ rt_smoke_derive_url() { [ -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 grep -oE '"subId"[[:space:]]*:[[:space:]]*"[^"]+"' | LC_ALL=C sed -n 1p \ | LC_ALL=C sed -E 's/.*"([^"]+)"$/\1/' || true)" [ -n "$sid" ] || return 1 case "$path" in /*) : ;; *) path="/$path" ;; esac @@ -2102,7 +2113,7 @@ rt_payload_companions() { 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)" + list="$(LC_ALL=C sed -n 's/^RT_INSTALLER_COMPANIONS="\(.*\)"$/\1/p' "$lib" | LC_ALL=C sed -n 1p)" # 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. @@ -3337,7 +3348,7 @@ rt_cmd_verify() { [ -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 + if [ -n "$perm" ] && [[ "$perm" =~ [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 diff --git a/installer/panels/pasarguard.sh b/installer/panels/pasarguard.sh index a2882f6..f96dfac 100644 --- a/installer/panels/pasarguard.sh +++ b/installer/panels/pasarguard.sh @@ -142,8 +142,9 @@ rt_panel_pasarguard_block_state() { if [ "${n_open:-0}" = "0" ] && [ "${n_close:-0}" = "0" ]; then printf 'absent'; return 0; fi if [ "$n_open" = "1" ] && [ "$n_close" = "1" ]; then local lo lc - lo="$(LC_ALL=C grep -Fn "$RT_PG_BLOCK_OPEN" "$env" | head -n1 | cut -d: -f1)" - lc="$(LC_ALL=C grep -Fxn "$RT_PG_BLOCK_CLOSE" "$env" | head -n1 | cut -d: -f1)" + # grep -m1 stops at the first match itself: no pipe into head to be cut short + lo="$(LC_ALL=C grep -Fn -m1 "$RT_PG_BLOCK_OPEN" "$env" | cut -d: -f1)" + lc="$(LC_ALL=C grep -Fxn -m1 "$RT_PG_BLOCK_CLOSE" "$env" | cut -d: -f1)" if [ -n "$lo" ] && [ -n "$lc" ] && [ "$lo" -lt "$lc" ]; then printf 'present'; return 0; fi fi printf 'malformed' diff --git a/tests/installer.test.mjs b/tests/installer.test.mjs index b6b9200..2639094 100644 --- a/tests/installer.test.mjs +++ b/tests/installer.test.mjs @@ -249,6 +249,29 @@ test('generation escapes a payload in the service name', () => { assert.match(r.out, /RAW=0/, 'no unescaped inside the block'); }); +/* Found running the suite on a loaded Linux host (1.3.0): the gate checked the + head of the page with `head -c 512 f | grep -qi ''`. grep -q + exits on its first match; head, still writing, dies of SIGPIPE; pipefail + reports the MATCH as a failure. Measured at ~0.7% of calls under load, it + made install, update and design switching refuse a valid page. The doubles + make that interleaving certain: a grep that, reading a pipe, exits at once + (as grep -q does on a match), and a head that writes in two chunks. */ +test('the structural gate cannot be fooled into refusing a valid page by SIGPIPE', () => { + const r = sh( + GEN_SETUP + + 'RG="$(command -v grep)"; RH="$(command -v head)"; RT="$(command -v tail)"; ' + + 'B="$(dirname "$RT_ROOT")/bin"; mkdir -p "$B"; ' + + // grep reading a pipe (no file operand) leaves at once, like grep -q on a match + 'printf \'#!/usr/bin/env bash\\nfor a in "$@"; do [ -f "$a" ] && exec "%s" "$@"; done\\nexit 0\\n\' "$RG" > "$B/grep"; ' + + // head of a file writes in two chunks, so its second write meets a closed pipe + 'printf \'#!/usr/bin/env bash\\nset -o pipefail\\nf="${@: -1}"; n="${2:-512}"\\n"%s" -c 16 "$f"; sleep 0.3; "%s" -c +17 "$f" | "%s" -c $((n-16))\\n\' "$RH" "$RT" "$RH" > "$B/head"; ' + + 'chmod +x "$B/grep" "$B/head"; PATH="$B:$PATH"; ' + + 'rt_validate_template "$RT_DIST" && echo VALID', + ); + assert.equal(r.code, 0, r.err); + assert.match(r.out, /VALID/, 'a valid page is valid however the pipe is scheduled'); +}); + test('the structural gate rejects a template it cannot trust', () => { assert.ok(!ok('printf "tiny" > "$RT_ROOT/t"; rt_validate_template "$RT_ROOT/t"'), 'too small / not a full doc'); From 24245f8812787cc11dbc27f9d2529b44972674c8 Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Sat, 26 Sep 2026 14:14:21 +0330 Subject: [PATCH 21/25] style(backup): keep rt_backup_latest a bare one-liner The backup-writer tests extract rollback's functions to prove P2 left them alone, and a trailing comment broke the extraction. The comment now sits above the function. Co-Authored-By: Claude Opus 5.5 --- installer/lib/row-template.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/installer/lib/row-template.sh b/installer/lib/row-template.sh index c4b7a47..2537734 100644 --- a/installer/lib/row-template.sh +++ b/installer/lib/row-template.sh @@ -1118,7 +1118,8 @@ rt_backups_list() { done | LC_ALL=C sort -r } -rt_backup_latest() { rt_backups_list | LC_ALL=C sed -n 1p; } # sed reads it all: no SIGPIPE +# sed reads its whole input, so the list cannot be cut short by SIGPIPE +rt_backup_latest() { rt_backups_list | LC_ALL=C sed -n 1p; } rt_backups_prune() { # keep the KEEP newest valid backups (KEEP>=2 enforced by callers); delete the From 6926e32f5c55c4b24261d00cb9fa2996f5ab0ae8 Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Sat, 26 Sep 2026 14:18:43 +0330 Subject: [PATCH 22/25] style(backup): give rt_backup_latest its comment inside the body A comment line between two functions became the tail of the previous one's extracted body in the backup-writer tests. All 36 of them pass again. Co-Authored-By: Claude Opus 5.5 --- installer/lib/row-template.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/installer/lib/row-template.sh b/installer/lib/row-template.sh index 2537734..2469beb 100644 --- a/installer/lib/row-template.sh +++ b/installer/lib/row-template.sh @@ -1118,8 +1118,10 @@ rt_backups_list() { done | LC_ALL=C sort -r } -# sed reads its whole input, so the list cannot be cut short by SIGPIPE -rt_backup_latest() { rt_backups_list | LC_ALL=C sed -n 1p; } +rt_backup_latest() { + # sed reads its whole input, so the list cannot be cut short by SIGPIPE + rt_backups_list | LC_ALL=C sed -n 1p +} rt_backups_prune() { # keep the KEEP newest valid backups (KEEP>=2 enforced by callers); delete the From 87b7c7e221d86b197e3bce9c3483ed812ac21fa0 Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Sat, 26 Sep 2026 14:21:49 +0330 Subject: [PATCH 23/25] docs(changelog): record the structural-check fix Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5850f8c..ea92f4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,13 @@ own. `sqlite3` installed, because those commands had not located the panel database. And the check made right after activation no longer warns "could not reach the subscription endpoint" while 3X-UI is still restarting. +- **A valid page is never refused under load.** The structural check before + every install, update and design switch read the page through + `head | grep -q`. On a busy server `grep -q` could stop reading before `head` + finished writing, and the shell then reported the match as a failure — + "generated template does not begin with " for a perfectly + valid page, about once in 150 checks. Every such check is now written so + that it cannot be cut short. - All fixes prepared for 1.2.1 (below): one `row-template update` is enough to move from 1.1.0, misplaced designs are moved back, branding works on an install the 1.1.0 updater left incomplete, and `verify` names missing and From dd156f28956a9a9f51fdc5648b0b1eb155911939 Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Sat, 26 Sep 2026 18:36:53 +0330 Subject: [PATCH 24/25] docs: state the upgrade limits of rollback and backup retention A rollback restores the page and the recorded version, not the manager: after rolling back to a 1.1.0 backup, `row-template version` reports 1.1.0 while the 1.3.0 manager stays, and the next update returns to 1.3.0. Only the two newest backups are kept, so the backup an update takes of the previous version is replaced after two further changes. The CHANGELOG's Upgrading section and the Rolling back docs (English, Persian, Arabic) now say so, and the 1.3.0 entry is dated for the release preparation. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 ++++++++++-- docs/src/content/docs/ar/configuration.mdx | 5 +++++ docs/src/content/docs/configuration.mdx | 6 ++++++ docs/src/content/docs/fa/configuration.mdx | 6 ++++++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea92f4d..09f27c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.3.0] - 2026-09-25 +## [1.3.0] - 2026-09-26 Row-Template now installs on **PasarGuard** and **Rebecca** as well as 3X-UI, and ships two more designs. A minor release: nothing changes for an existing @@ -160,7 +160,15 @@ own. the next `row-template`, `row-template config` or `row-template verify` completes the install. Your design, branding and panel wiring are kept. - On **PasarGuard** or **Rebecca**: run the installer. Earlier releases did not - install on these panels. + install on these panels. Rebecca must be 1.x (its binary install); a Docker + Rebecca is 0.0.x and is refused until it is moved to 1.x. +- **Rolling back after the update.** `row-template update` backs up the version + it replaces, and `row-template rollback --to ` returns to its page + and branding. A rollback restores the page and the recorded version, not the + manager itself: `row-template` stays 1.3.0 and reports the version it rolled + back to, and the next `row-template update` returns to 1.3.0. Only the two + newest backups are kept, so the pre-update backup is replaced after two + further changes (a design switch, an update or a rollback each make one). ## [1.2.1] - Unreleased (shipped in 1.3.0) diff --git a/docs/src/content/docs/ar/configuration.mdx b/docs/src/content/docs/ar/configuration.mdx index 12970fd..ba67a86 100644 --- a/docs/src/content/docs/ar/configuration.mdx +++ b/docs/src/content/docs/ar/configuration.mdx @@ -77,6 +77,11 @@ row-template rollback يمكن استدراك عملية استعادة فاشلة. تسجّل كل نسخة احتياطية اللوحة التي أُنشئت عليها ولا تُستعاد أبدًا على لوحة أخرى؛ والنسخة الاحتياطية من إصدار لم يكن يسجّل تصميمه تُستعاد على أنها Row. +تستعيد الاستعادة الصفحة والإصدار المسجَّل، لا المدير نفسه: بعد الاستعادة إلى نسخة احتياطية أُخذت تحت +‎1.1.0‎ يذكر ‎`row-template version`‎ الإصدار ‎1.1.0‎ بينما يبقى مدير ‎1.3.0‎ في مكانه، ويعيد +‎`row-template update`‎ التالي إلى ‎1.3.0‎. **لا يُحتفظ إلا بأحدث نسختين احتياطيتين** — فكل تحديث +وتغيير تصميم واستعادة يُنشئ واحدة — لذا تُستبدل النسخة التي أخذها تحديثٌ من الإصدار السابق بعد تغييرين آخرين. + ```bash row-template rollback --to ``` diff --git a/docs/src/content/docs/configuration.mdx b/docs/src/content/docs/configuration.mdx index 9e02294..536a9c5 100644 --- a/docs/src/content/docs/configuration.mdx +++ b/docs/src/content/docs/configuration.mdx @@ -83,6 +83,12 @@ snapshotted first, so a failed rollback can itself be recovered. Every backup re panel it was made on and is never restored onto another panel; a backup from a release that did not record its design restores as Row. +A rollback restores the page and the recorded version, not the manager itself: after +rolling back to a backup taken under 1.1.0, `row-template version` reports 1.1.0 while the +1.3.0 manager stays in place, and the next `row-template update` returns to 1.3.0. **Only the +two newest backups are kept** — each update, design switch and rollback makes one — so the +backup an update took of the previous version is replaced after two further changes. + ```bash row-template rollback --to ``` diff --git a/docs/src/content/docs/fa/configuration.mdx b/docs/src/content/docs/fa/configuration.mdx index 72ed202..508685c 100644 --- a/docs/src/content/docs/fa/configuration.mdx +++ b/docs/src/content/docs/fa/configuration.mdx @@ -81,6 +81,12 @@ row-template rollback می‌کند و هرگز روی پنل دیگری بازگردانده نمی‌شود؛ پشتیبانی از نسخه‌ای که طرحش را ثبت نمی‌کرد، به‌صورت Row بازگردانده می‌شود. +بازگردانی صفحه و نسخهٔ ثبت‌شده را برمی‌گرداند، نه خود مدیر را: پس از بازگردانی به پشتیبانی که +زیر 1.1.0 گرفته شده، `row-template version` نسخهٔ 1.1.0 را گزارش می‌دهد در حالی که مدیر 1.3.0 سر +جایش می‌ماند، و `row-template update` بعدی به 1.3.0 برمی‌گردد. **فقط دو پشتیبان جدیدتر نگه داشته +می‌شوند** — هر به‌روزرسانی، تغییر طرح و بازگردانی یکی می‌سازد — پس پشتیبانی که یک به‌روزرسانی از +نسخهٔ قبلی گرفته، پس از دو تغییر دیگر جایگزین می‌شود. + ```bash row-template rollback --to ``` From ea71ca20b314dece60cfa56cc9443e05ddfb3f36 Mon Sep 17 00:00:00 2001 From: iitzSeriZ Date: Sat, 26 Sep 2026 19:13:41 +0330 Subject: [PATCH 25/25] fix(activate): a failed panel refresh leaves sub.html as it was Raised in review on PR #6. rt_activate swaps sub.html and then refreshes the copy PasarGuard or Rebecca serves. When that refresh failed it returned an error with sub.html already replaced -- contradicting its own contract, so a caller reporting "nothing was changed" was wrong. The previous sub.html is now kept aside and put back on that failure; a test pins it (and fails without the fix). The installer-components check also moved ahead of the swap, so it can no longer fail after a change. PROVENANCE: shells/ carries every panel's pages, and shells/3xui/ is byte-identical to templates/, which is what 3X-UI installs use. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ PROVENANCE.md | 2 +- installer/lib/row-template.sh | 29 +++++++++++++++++++++----- tests/installer-panel-rebecca.test.mjs | 25 ++++++++++++++++++++++ 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09f27c7..4459b30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,10 @@ own. `sqlite3` installed, because those commands had not located the panel database. And the check made right after activation no longer warns "could not reach the subscription endpoint" while 3X-UI is still restarting. +- **A page change that cannot reach PasarGuard or Rebecca changes nothing.** + Regenerating the page (a rebrand, a design switch, an update) replaced + `sub.html` before copying it into the panel; if that copy failed, `sub.html` + was left newer than the page the panel serves. It is now put back. - **A valid page is never refused under load.** The structural check before every install, update and design switch read the page through `head | grep -q`. On a busy server `grep -q` could stop reading before `head` diff --git a/PROVENANCE.md b/PROVENANCE.md index 02ef59b..532c405 100644 --- a/PROVENANCE.md +++ b/PROVENANCE.md @@ -22,7 +22,7 @@ The tarball expands to a single `row-template-/` directory: | ---- | -------- | | `template.html` | The Row design, the page an older installed version updates against. | | `templates//template.html` (+ `.sha256`) | Every selectable design for 3X-UI, each with its own checksum. | -| `shells///shell.html` (+ `.sha256`) | Every design for PasarGuard (Jinja2) and Rebecca (pongo2), each with its own checksum. The installer places the one you select, and refuses a page built for another panel or by a release before 1.3.0. | +| `shells///shell.html` (+ `.sha256`) | Every design for every panel, each with its own checksum. On PasarGuard (Jinja2) and Rebecca 1.x (pongo2) the installer places the one you select, and refuses a page built for another panel or by a release before 1.3.0. `shells/3xui/` is byte-identical to `templates/`, which is what 3X-UI installs use. | | `VERSION`, `install.sh`, `lib/`, `bin/` | The version, the installer and the `row-template` manager. | | `panels/` | The panel interface and one adapter per panel (`3xui.sh`, `pasarguard.sh`, `rebecca.sh`); installed next to `lib/`. | | `SHA256SUMS` | The checksum of every payload file, so the contents can be checked after extraction as well. | diff --git a/installer/lib/row-template.sh b/installer/lib/row-template.sh index 2469beb..dfed5a9 100644 --- a/installer/lib/row-template.sh +++ b/installer/lib/row-template.sh @@ -2196,20 +2196,39 @@ rt_activate() { # own template directory; when one is placed it is replaced here too, so the # panel never serves a page older than the one just generated. The panel's # selection is not touched: that is activation's job (rt_panel_activate). - local staged dir panel rc=0 + # + # The panel copy is refreshed AFTER sub.html is swapped, so a refresh that + # fails puts the previous sub.html back before returning: the contract above + # holds for every panel, and a caller that reports "nothing was changed" is + # telling the truth. + local staged dir panel prev="" rc=0 dir="$(dirname "$RT_LIVE")" + panel="$(rt_panel_current)" + if [ "$panel" != "3xui" ]; then + rt_installer_complete || { rt_err "the installer's panel components are missing; run 'row-template update'."; return 1; } + fi 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; } + if [ "$panel" != "3xui" ] && [ -f "$RT_LIVE" ]; then + prev="$(mktemp "$dir/.prev.XXXXXX")" || { rm -f "$staged"; return 1; } + cp -- "$RT_LIVE" "$prev" || { rm -f "$staged" "$prev"; return 1; } + fi + rt_atomic_install "$staged" "$RT_LIVE" 644 || { rm -f "$staged" "$prev"; return 1; } rm -f "$staged" - panel="$(rt_panel_current)" if [ "$panel" != "3xui" ]; then - rt_installer_complete || { rt_err "the installer's panel components are missing; run 'row-template update'."; return 1; } rt_panel_refresh_page "$panel" "$RT_LIVE" || rc=$? case "$rc" in 0|3) : ;; - *) rt_err "could not update the page in $(rt_panel_label "$panel")'s template directory."; return 1 ;; + *) + if [ -n "$prev" ]; then + rt_atomic_install "$prev" "$RT_LIVE" 644 \ + || rt_err "could not put the previous page back at $RT_LIVE." + fi + rm -f "$prev" + rt_err "could not update the page in $(rt_panel_label "$panel")'s template directory." + return 1 ;; esac + rm -f "$prev" fi return 0 } diff --git a/tests/installer-panel-rebecca.test.mjs b/tests/installer-panel-rebecca.test.mjs index 56bb97c..df32888 100644 --- a/tests/installer-panel-rebecca.test.mjs +++ b/tests/installer-panel-rebecca.test.mjs @@ -384,6 +384,31 @@ test('a backup made for another panel is never restored onto Rebecca', () => { }); }); +/* rt_activate swaps sub.html and then refreshes the panel's copy. Raised in + review (PR #6): when that refresh failed it returned an error with sub.html + already replaced, contradicting its own contract, so a caller reporting + "nothing was changed" was wrong. The previous sub.html is now put back. */ +test('a failed panel refresh leaves sub.html and the placed page exactly as they were', () => { + withHost({}, ({ host, run }) => { + run([SETUP, 'rt_transaction_run rebecca "$RT_LIVE" 2>/dev/null']); + const placed = page(join(host.dataDir, 'templates')); + const beforePlaced = readFileSync(placed); + const r = run([ + 'RT_ACTIVE_PANEL=rebecca', + 'before="$(rt_sha256 "$RT_LIVE")"', + 'rt_config_write "Changed Name" "" "" ""', + 'rt_panel_refresh_page() { return 1; }', + 'rc=0; rt_activate 2>/dev/null || rc=$?; echo "rc=$rc"', + '[ "$(rt_sha256 "$RT_LIVE")" = "$before" ] && echo "live-unchanged"', + 'ls "$(dirname "$RT_LIVE")" | grep -c "^\\.prev\\." || true', + ]); + assert.match(r.out, /rc=1/, 'the failure is reported'); + assert.match(r.out, /live-unchanged/, 'sub.html is back to its previous bytes'); + assert.match(r.out, /^0$/m, 'no temporary copy is left behind'); + assert.ok(readFileSync(placed).equals(beforePlaced), 'the page Rebecca serves never changed'); + }); +}); + /* --- manual activation --------------------------------------------------------- */ test('without sqlite3, activation places the page and says exactly what to set', () => {