#!/usr/bin/env bash set -Eeuo pipefail APP_NAME="xtui" STATE_DIR="${HOME}/.xtui" CONFIG_FILE="${STATE_DIR}/config" SUB_JSON="${STATE_DIR}/sub.json" BACKUP_DIR="${STATE_DIR}/backups" CACHE_TTL_SECONDS=3600 DEFAULT_XRAY_CONFIG="/etc/xray/config.json" DEFAULT_XRAY_SERVICE="xray.service" # jq helper: normalize common JSON subscription containers to an array of profiles. PROFILES_DEF=' def profiles: if type == "array" then . elif type == "object" and ((.configs? | type) == "array") then .configs elif type == "object" and ((.profiles? | type) == "array") then .profiles elif type == "object" and ((.servers? | type) == "array") then .servers elif type == "object" and ((.items? | type) == "array") then .items elif type == "object" and (has("outbounds") or has("protocol")) then [.] else [] end; ' if [[ -t 1 ]]; then C_RESET=$'\033[0m' C_BOLD=$'\033[1m' C_DIM=$'\033[2m' C_RED=$'\033[31m' C_GREEN=$'\033[32m' C_YELLOW=$'\033[33m' C_BLUE=$'\033[34m' C_MAGENTA=$'\033[35m' C_CYAN=$'\033[36m' C_INV=$'\033[7m' else C_RESET=""; C_BOLD=""; C_DIM=""; C_RED=""; C_GREEN=""; C_YELLOW=""; C_BLUE=""; C_MAGENTA=""; C_CYAN=""; C_INV="" fi say() { printf '%b\n' "$*"; } info() { say "${C_CYAN}›${C_RESET} $*"; } ok() { say "${C_GREEN}✓${C_RESET} $*"; } warn() { say "${C_YELLOW}!${C_RESET} $*"; } die() { say "${C_RED}✗${C_RESET} $*" >&2; exit 1; } usage() { cat </dev/null 2>&1; then ELEVATE_CMD="$cmd" ELEVATE_NAME="$cmd" return 0 fi done ELEVATE_CMD="" ELEVATE_NAME="" return 1 } run_elevated() { if ! elevate_available; then die "Нет доступного инструмента для повышения привилегий (doas/run0/pkexec/sudo)." fi "$ELEVATE_CMD" "$@" } check_deps() { local missing=() cmd for cmd in bash curl jq systemctl date stat mktemp sha256sum cmp install dirname basename cut mkdir chmod cp cat printf; do command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd") done if ((${#missing[@]})); then say "${C_RED}Не хватает зависимостей:${C_RESET} ${missing[*]}" say "Для Arch Linux обычно достаточно:" say " sudo pacman -S --needed bash curl jq coreutils systemd" say "" say "Если конфиг Xray или служба требуют root-прав, понадобится один из:" say " doas, run0, pkexec, sudo" exit 1 fi } ensure_state_dir() { mkdir -p "$STATE_DIR" "$BACKUP_DIR" chmod 700 "$STATE_DIR" "$BACKUP_DIR" 2>/dev/null || true } expand_path() { local p="$1" [[ "$p" == "~" ]] && p="$HOME" [[ "$p" == "~/"* ]] && p="${HOME}/${p#~/}" printf '%s\n' "$p" } cfg_exists() { [[ -s "$CONFIG_FILE" ]] && jq -e 'type == "object" and (.sub_url // "") != "" and (.xray_config // "") != "" and (.service // "") != ""' "$CONFIG_FILE" >/dev/null 2>&1 } cfg_get() { local key="$1" jq -r --arg key "$key" '.[$key] // empty' "$CONFIG_FILE" 2>/dev/null || true } cfg_set() { local key="$1" value="$2" tmp tmp="$(mktemp)" jq --arg key "$key" --arg value "$value" '.[$key] = $value' "$CONFIG_FILE" >"$tmp" install -m 600 "$tmp" "$CONFIG_FILE" rm -f "$tmp" } write_cfg() { local sub_url="$1" xray_config="$2" service="$3" tmp tmp="$(mktemp)" jq -n \ --arg sub_url "$sub_url" \ --arg xray_config "$xray_config" \ --arg service "$service" \ '{sub_url:$sub_url, xray_config:$xray_config, service:$service, selected_index:"", selected_fp:"", selected_name:""}' >"$tmp" install -m 600 "$tmp" "$CONFIG_FILE" rm -f "$tmp" } profile_count_file() { local file="$1" jq -r "${PROFILES_DEF} profiles | length" "$file" } profile_count() { profile_count_file "$SUB_JSON" } validate_subscription_file() { local file="$1" count jq -e . "$file" >/dev/null || return 1 count="$(profile_count_file "$file")" || return 1 [[ "$count" =~ ^[0-9]+$ ]] && (( count > 0 )) } fetch_subscription() { local url="$1" tmp count tmp="$(mktemp)" info "Скачиваю подписку…" if ! curl -fLsS --connect-timeout 10 --max-time 45 --retry 2 --retry-delay 1 \ -H 'Accept: application/json, */*' \ -A 'xtui/1.2' \ "$url" -o "$tmp"; then rm -f "$tmp" return 1 fi if ! validate_subscription_file "$tmp"; then rm -f "$tmp" return 1 fi install -m 600 "$tmp" "$SUB_JSON" rm -f "$tmp" count="$(profile_count)" ok "Подписка валидна, профилей: ${count}" } maybe_refresh_subscription() { local url="$1" now mtime age if [[ ! -s "$SUB_JSON" ]]; then fetch_subscription "$url" || die "Не удалось скачать и валидировать подписку." return fi now="$(date +%s)" mtime="$(stat -c %Y "$SUB_JSON")" age=$(( now - mtime )) if (( age > CACHE_TTL_SECONDS )); then fetch_subscription "$url" || warn "Не удалось обновить подписку; оставляю старый кэш." fi } normalize_service_name() { local svc="$1" svc="${svc%.service}.service" printf '%s\n' "$svc" } validate_service() { local svc="$1" systemctl --no-ask-password cat "$svc" >/dev/null 2>&1 } ensure_target_parent() { local path="$1" dir dir="$(dirname "$path")" [[ -d "$dir" ]] && return 0 if mkdir -p "$dir" 2>/dev/null; then return 0 fi elevate_available || return 1 run_elevated mkdir -p "$dir" } validate_target_path() { local path="$1" dir [[ -n "$path" ]] || return 1 [[ "$path" = /* ]] || return 1 ensure_target_parent "$path" || return 1 dir="$(dirname "$path")" if [[ -e "$path" ]]; then [[ -f "$path" ]] || return 1 if [[ -w "$path" ]]; then return 0 fi elevate_available && run_elevated test -w "$path" return $? fi if [[ -w "$dir" ]]; then return 0 fi elevate_available && run_elevated test -w "$dir" } prompt_nonempty() { local prompt="$1" default="${2:-}" value while true; do if [[ -n "$default" ]]; then read -r -p "${prompt} [${default}]: " value value="${value:-$default}" else read -r -p "${prompt}: " value fi [[ -n "$value" ]] && { printf '%s\n' "$value"; return 0; } done } wizard() { ensure_state_dir say "${C_BOLD}${C_MAGENTA}Первоначальная настройка ${APP_NAME}${C_RESET}" say "" local sub_url xray_config service while true; do sub_url="$(prompt_nonempty 'Ссылка на JSON-подписку')" if [[ ! "$sub_url" =~ ^https?:// ]]; then warn "Нужна http(s)-ссылка." continue fi if fetch_subscription "$sub_url"; then break fi warn "Не удалось скачать или распознать JSON-подписку. Проверьте URL." done while true; do xray_config="$(prompt_nonempty 'Путь к JSON-конфигу Xray для выбранного сервера' "$DEFAULT_XRAY_CONFIG")" xray_config="$(expand_path "$xray_config")" if validate_target_path "$xray_config"; then ok "Путь доступен для записи: ${xray_config}" break fi warn "Нет прав записи или путь некорректен. Укажите другой путь или настройте sudo." done while true; do service="$(prompt_nonempty 'Название systemd-службы Xray' "$DEFAULT_XRAY_SERVICE")" service="$(normalize_service_name "$service")" if validate_service "$service"; then ok "Служба найдена: ${service}" break fi warn "Служба ${service} не найдена через systemctl cat." done write_cfg "$sub_url" "$xray_config" "$service" ok "Настройки сохранены в ${CONFIG_FILE}" } profile_summary() { local idx="$1" jq -r --argjson i "$idx" "${PROFILES_DEF}"' def outbounds($x): if (($x.outbounds? | type) == "array") then $x.outbounds else [$x] end; def addr($o): $o.settings.vnext[0].address? // $o.settings.servers[0].address? // $o.settings.peers[0].endpoint? // $o.address? // $o.server? // ""; def port($o): $o.settings.vnext[0].port? // $o.settings.servers[0].port? // $o.port? // ""; profiles | .[$i] as $p | (outbounds($p) | map(select(((.protocol? // "") != "freedom") and ((.protocol? // "") != "blackhole"))) | .[0] // {}) as $o | ($p.remarks? // $p.remark? // $p.name? // $p.ps? // $p.tag? // $o.tag? // ("Профиль " + (($i + 1) | tostring))) as $name | [$name, ($o.protocol? // "?"), (addr($o)), ((port($o)) | tostring)] | @tsv ' "$SUB_JSON" } profile_fingerprint() { local idx="$1" jq -c --argjson i "$idx" "${PROFILES_DEF}"' profiles | .[$i] | {remarks:(.remarks? // .remark? // .name? // .ps? // null), outbounds:(.outbounds? // [.])} ' "$SUB_JSON" | sha256sum | cut -d ' ' -f1 } find_saved_index() { local saved_index saved_fp count i fp saved_index="$(cfg_get selected_index)" saved_fp="$(cfg_get selected_fp)" count="$(profile_count)" if [[ "$saved_index" =~ ^[0-9]+$ ]] && (( saved_index < count )); then echo "$saved_index" return fi if [[ -n "$saved_fp" ]]; then for ((i=0; i/dev/null } build_selected_config() { local idx="$1" out="$2" jq -e --argjson i "$idx" "${PROFILES_DEF}"' def allowed_top: ["log", "api", "dns", "routing", "policy", "inbounds", "outbounds", "transport", "stats", "reverse", "fakedns", "metrics", "observatory", "burstObservatory", "geodata", "version", "browserForwarder"]; def clean_full: with_entries(select(.key as $k | allowed_top | index($k))); def clean_outbound: del(.remarks, .remark, .name, .ps) + {tag:(.tag // "proxy")}; profiles | .[$i] as $p | if (($p | type) != "object") then error("selected profile is not an object") elif ($p | has("outbounds")) then ($p | clean_full) elif ($p | has("protocol")) then { log: {loglevel: "warning"}, inbounds: [ {listen: "127.0.0.1", port: 10808, protocol: "socks", settings: {udp: true}}, {listen: "127.0.0.1", port: 10809, protocol: "http"} ], outbounds: [ ($p | clean_outbound), {protocol: "freedom", tag: "direct"}, {protocol: "blackhole", tag: "block"} ] } else error("selected profile has neither outbounds nor protocol") end | if ((.outbounds | type) == "array" and (.outbounds | length) > 0) then . else error("resulting Xray config has no outbounds") end ' "$SUB_JSON" >"$out" } install_xray_config() { local src="$1" dest="$2" dir ensure_target_parent "$dest" || die "Не удалось создать каталог для ${dest}" if [[ -e "$dest" ]]; then if [[ -w "$dest" ]]; then install -m 0644 "$src" "$dest" else elevate_available || die "Нет прав записи в ${dest}, не найден doas/run0/pkexec/sudo." run_elevated install -m 0644 "$src" "$dest" fi else dir="$(dirname "$dest")" if [[ -w "$dir" ]]; then install -m 0644 "$src" "$dest" else elevate_available || die "Нет прав записи в ${dir}, не найден doas/run0/pkexec/sudo." run_elevated install -m 0644 "$src" "$dest" fi fi } restart_xray_service() { local service="$1" info "Перезапускаю ${service}…" if systemctl --no-ask-password restart "$service" 2>/dev/null; then ok "Служба ${service} перезапущена." return 0 fi elevate_available || die "Не удалось перезапустить ${service}: нет прав и не найден doas/run0/pkexec/sudo." run_elevated systemctl --no-ask-password restart "$service" ok "Служба ${service} перезапущена." } apply_selection() { local idx="$1" dest service tmp fp old_index name proto addr port changed=1 service_state dest="$(cfg_get xray_config)" service="$(cfg_get service)" old_index="$(cfg_get selected_index)" fp="$(profile_fingerprint "$idx")" IFS=$'\t' read -r name proto addr port < <(profile_summary "$idx") tmp="$(mktemp)" if ! build_selected_config "$idx" "$tmp"; then rm -f "$tmp" die "Не удалось извлечь выбранный профиль в формат Xray config JSON." fi jq -e . "$tmp" >/dev/null || { rm -f "$tmp"; die "Сгенерированный JSON невалиден."; } if [[ -r "$dest" ]] && cmp -s "$tmp" "$dest"; then changed=0 fi if (( changed )); then install_xray_config "$tmp" "$dest" ok "Выбранный сервер записан в ${dest}: ${name}" else ok "В ${dest} уже записан этот конфиг: ${name}" fi cfg_set selected_index "$idx" cfg_set selected_fp "$fp" cfg_set selected_name "$name" rm -f "$tmp" service_state="$(service_state_raw "$service")" if (( changed )) || [[ "$old_index" != "$idx" ]]; then if [[ "$service_state" == "active" || "$service_state" == "activating" ]]; then restart_xray_service "$service" else info "Служба ${service} не запущена, перезапуск не требуется." fi else info "Сервер не изменился, службу не перезапускаю." fi } clear_screen() { printf '\033[H\033[2J'; } hide_cursor() { [[ -t 1 ]] && printf '\033[?25l' || true; } show_cursor() { [[ -t 1 ]] && printf '\033[?25h' || true; } goto_col() { printf '\033[%dG' "$1"; } format_epoch() { local epoch="${1:-0}" if [[ "$epoch" =~ ^[0-9]+$ ]] && (( epoch > 0 )); then date -d "@${epoch}" '+%Y-%m-%d %H:%M:%S %Z' else printf 'никогда' fi } format_age() { local seconds="${1:-0}" d h m (( seconds < 0 )) && seconds=0 d=$(( seconds / 86400 )) h=$(( (seconds % 86400) / 3600 )) m=$(( (seconds % 3600) / 60 )) if (( d > 0 )); then printf '%d д %d ч назад' "$d" "$h" elif (( h > 0 )); then printf '%d ч %d мин назад' "$h" "$m" elif (( m > 0 )); then printf '%d мин назад' "$m" else printf 'только что' fi } subscription_update_info() { local mtime now age if [[ -s "$SUB_JSON" ]]; then mtime="$(stat -c %Y "$SUB_JSON" 2>/dev/null || echo 0)" now="$(date +%s)" age=$(( now - mtime )) printf '%s • %s' "$(format_epoch "$mtime")" "$(format_age "$age")" else printf 'никогда' fi } xray_socks_endpoint() { local config="$1" if [[ ! -r "$config" ]]; then printf 'недоступен' return fi jq -r ' first( .inbounds[]? | select((.protocol? // "") == "socks") | [(.listen? // "0.0.0.0"), (.port? // empty)] | select(.[1] != null and .[1] != "") | "\(.[0]):\(.[1])" ) // "не найден" ' "$config" 2>/dev/null || printf 'не найден' } service_state_raw() { local service="$1" systemctl --no-ask-password is-active "$service" 2>/dev/null || true } service_enabled_raw() { local service="$1" systemctl --no-ask-password is-enabled "$service" 2>/dev/null || true } service_state_colored() { local state="$1" case "$state" in active) printf '%bactive%b' "$C_GREEN" "$C_RESET" ;; activating) printf '%bactivating%b' "$C_YELLOW" "$C_RESET" ;; inactive) printf '%binactive%b' "$C_DIM" "$C_RESET" ;; failed) printf '%bfailed%b' "$C_RED" "$C_RESET" ;; deactivating) printf '%bdeactivating%b' "$C_YELLOW" "$C_RESET" ;; *) printf '%bunknown%b' "$C_RED" "$C_RESET" ;; esac } service_enabled_colored() { local enabled="$1" case "$enabled" in enabled) printf '%benabled%b' "$C_GREEN" "$C_RESET" ;; disabled) printf '%bdisabled%b' "$C_YELLOW" "$C_RESET" ;; static) printf '%bstatic%b' "$C_BLUE" "$C_RESET" ;; masked) printf '%bmasked%b' "$C_RED" "$C_RESET" ;; *) printf '%b%s%b' "$C_DIM" "${enabled:-unknown}" "$C_RESET" ;; esac } pause_screen() { say "" read -rsn1 -p "Нажмите любую клавишу, чтобы вернуться в меню…" _ || true } run_service_action() { local action="$1" service="$2" show_cursor say "" info "systemctl ${action} ${service}" if systemctl --no-ask-password "$action" "$service" 2>/dev/null; then ok "Готово: ${service} → ${action}." pause_screen hide_cursor return 0 fi elevate_available || { warn "Не удалось выполнить действие без root-прав, а doas/run0/pkexec/sudo не найдены." pause_screen hide_cursor return 1 } if run_elevated systemctl --no-ask-password "$action" "$service"; then ok "Готово: ${service} → ${action}." else warn "systemctl ${action} завершился с ошибкой. Проверьте статус службы." fi pause_screen hide_cursor } show_service_status_screen() { local service="$1" show_cursor clear_screen say "${C_BOLD}${C_MAGENTA}✦ Статус службы ${service}${C_RESET}" say "" if ! systemctl --no-ask-password --no-pager --full status "$service" -n 18; then warn "Не удалось прочитать статус через systemctl." fi pause_screen hide_cursor } shorten() { local s="$1" max="$2" if ((${#s} > max)); then printf '%s…' "${s:0:max-1}" else printf '%s' "$s" fi } render_table_header() { printf '%b' "$C_DIM" printf ' № Сервер' goto_col 46; printf 'Протокол' goto_col 58; printf 'Адрес' goto_col 79; printf 'Порт' printf '%b\n' "$C_RESET" } render_profile_row() { local idx="$1" cursor="$2" saved_index="$3" local name proto addr port pointer mark row_style name_short addr_short IFS=$'\t' read -r name proto addr port < <(profile_summary "$idx") pointer=" " mark=" " row_style="" [[ "$idx" -eq "$cursor" ]] && { pointer="➤"; row_style="$C_INV"; } [[ "$saved_index" =~ ^[0-9]+$ && "$idx" -eq "$saved_index" ]] && mark="✓" name_short="$(shorten "$name" 31)" addr_short="$(shorten "$addr" 20)" printf '%b' "$row_style" printf '%b%-2s%b %b%-2s%b %2d. %s' "$C_CYAN" "$pointer" "$C_RESET$row_style" "$C_GREEN" "$mark" "$C_RESET$row_style" "$((idx + 1))" "$name_short" goto_col 46; printf '%b%s%b' "$C_BLUE" "$proto" "$C_RESET$row_style" goto_col 58; printf '%b%s%b' "$C_DIM" "$addr_short" "$C_RESET$row_style" goto_col 79; printf '%b%s%b' "$C_DIM" "$port" "$C_RESET$row_style" printf '%b\n' "$C_RESET" } render_menu() { local cursor="$1" count saved_index i cache_info selected_name local service service_state service_enabled xray_config socks_endpoint count="$(profile_count)" saved_index="$(cfg_get selected_index)" selected_name="$(cfg_get selected_name)" service="$(cfg_get service)" xray_config="$(cfg_get xray_config)" socks_endpoint="$(xray_socks_endpoint "$xray_config")" cache_info="$(subscription_update_info)" service_state="$(service_state_raw "$service")" service_enabled="$(service_enabled_raw "$service")" clear_screen say "${C_BOLD}${C_MAGENTA}✦ Xray JSON TUI${C_RESET}" say "${C_DIM}Подписка:${C_RESET} ${SUB_JSON}" say "${C_DIM}Последнее обновление:${C_RESET} ${C_CYAN}${cache_info}${C_RESET}" say "${C_DIM}Конфиг Xray:${C_RESET} ${xray_config}" say "${C_DIM}SOCKS:${C_RESET} ${C_GREEN}${socks_endpoint}${C_RESET}" say "${C_DIM}Служба:${C_RESET} ${service} • состояние: $(service_state_colored "$service_state") • автозапуск: $(service_enabled_colored "$service_enabled")" say "${C_DIM}Текущий выбор:${C_RESET} ${selected_name:-нет}" say "" say "${C_CYAN}↑/↓${C_RESET} или ${C_CYAN}j/k${C_RESET} — навигация • ${C_GREEN}Enter${C_RESET} — выбрать сервер • ${C_YELLOW}r${C_RESET} — обновить подписку • ${C_MAGENTA}q${C_RESET} — выйти" say "" say "${C_BOLD}${C_MAGENTA}Управление ядром${C_RESET}" say "${C_GREEN}s${C_RESET} — start • ${C_RED}o${C_RESET} — stop • ${C_YELLOW}e${C_RESET} — restart • ${C_BLUE}v${C_RESET} — status службы" say "" render_table_header for ((i=0; i= 8)) && break done fi printf '%s' "$key" } menu() { local count cursor key sub_url service count="$(profile_count)" (( count > 0 )) || die "В подписке нет профилей." cursor="$(find_saved_index)" sub_url="$(cfg_get sub_url)" service="$(cfg_get service)" hide_cursor trap 'show_cursor' EXIT while true; do render_menu "$cursor" key="$(read_key || true)" case "$key" in $'\e[A'|$'\eOA'|$'\e['*A|k|K) if (( cursor > 0 )); then cursor=$((cursor - 1)) else cursor=$((count - 1)) fi ;; $'\e[B'|$'\eOB'|$'\e['*B|j|J) if (( cursor < count - 1 )); then cursor=$((cursor + 1)) else cursor=0 fi ;; r|R) show_cursor say "" fetch_subscription "$sub_url" || warn "Обновление не удалось." count="$(profile_count)" (( cursor >= count )) && cursor=$((count - 1)) pause_screen hide_cursor ;; s|S) run_service_action start "$service" ;; o|O) run_service_action stop "$service" ;; e|E) run_service_action restart "$service" ;; v|V) show_service_status_screen "$service" ;; q|Q) clear_screen show_cursor exit 0 ;; ""|$'\n'|$'\r') show_cursor say "" apply_selection "$cursor" pause_screen hide_cursor ;; esac done } main() { check_deps ensure_state_dir detect_elevate case "${1:-}" in --help|-h) usage exit 0 ;; --setup) wizard ;; --refresh) cfg_exists || wizard migrate_saved_index fetch_subscription "$(cfg_get sub_url)" || die "Не удалось обновить подписку." exit 0 ;; "") cfg_exists || wizard ;; *) usage >&2 exit 2 ;; esac migrate_saved_index maybe_refresh_subscription "$(cfg_get sub_url)" validate_subscription_file "$SUB_JSON" || die "Кэш подписки повреждён: ${SUB_JSON}. Запустите ${0##*/} --refresh" menu } main "$@"