Пользователи: kit — один пользователь сразу на всех протоколах

- scripts/kit.sh (команда kit на сервере): user add/list/link/limit/off/on/del;
  общие трафик, лимит, срок и число устройств на все протоколы
- Установщик создаёт подключения без клиентов и первого пользователя сразу во все,
  имя подписки случайное (чужую подписку не угадать по имени)
- AmneziaWG: у записи 3X-UI одна пара ключей WireGuard, поэтому ко второму AmneziaWG
  подключается запись-«двойник» с подпиской «-awg»; kit работает с обеими как с одной
- kit-sub фильтрует по содержимому: Clash-приложениям без AmneziaWG он вырезается,
  приложениям со ссылками не отдаются vpn:// и tg://

Проверено: три пользователя — Mihomo по подписке 10/10 у каждого, официальный клиент
AmneziaWG — оба варианта у каждого; выключение, лимиты и удаление действуют на все записи.
This commit is contained in:
itsnotkubrick
2026-09-27 19:00:32 +03:00
parent 8ff22e1239
commit e13ae7f42f
4 changed files with 399 additions and 26 deletions
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env bash
# kit — пользователи 3X-UI + Hysteria2 Kit: один пользователь сразу на всех протоколах.
# https://github.com/itsnotkubrick/Reality_Hysteria2
#
# kit user add имя [--gb 50] [--days 30] [--devices 3]
# kit user list | link имя | limit имя [--gb N] [--days N] | off имя | on имя | del имя
set -Eeuo pipefail
export LC_ALL=C.UTF-8 # ширина колонок по символам, а не байтам
XUI_ENV=/etc/x-ui/install-result.env
KIT_ENV=/etc/kit/kit.env
if [[ -t 1 ]]; then
G=$'\e[32m'; Y=$'\e[33m'; R=$'\e[31m'; B=$'\e[1m'; D=$'\e[2m'; N=$'\e[0m'
else
G=; Y=; R=; B=; D=; N=
fi
say() { printf '%s\n' "${G}==>${N} $*"; }
die() { printf '%s\n' "${R}✗${N} $*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || die "Запустите от root: sudo -i, затем команду ещё раз."
[[ -f $XUI_ENV && -f $KIT_ENV ]] || die "Не найдена установка — сначала поставьте сервер скриптом 3x-ui.sh."
# shellcheck disable=SC1090
. "$XUI_ENV"; . "$KIT_ENV"
API=""
for scheme in https http; do
API="$scheme://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH/panel/api"
curl -fsk -m 5 -o /dev/null -H "Authorization: Bearer $XUI_API_TOKEN" "$API/server/getNewUUID" 2>/dev/null && break
done
api() { # METHOD path [json]
local out
if [[ $1 == GET ]]; then
out=$(curl -sSk -m 20 -H "Authorization: Bearer $XUI_API_TOKEN" "$API/$2")
else
out=$(curl -sSk -m 20 -H "Authorization: Bearer $XUI_API_TOKEN" -H 'Content-Type: application/json' -X "$1" -d "${3:-{\}}" "$API/$2")
fi
[[ $(jq -r '.success' <<<"$out" 2>/dev/null) == true ]] || die "Панель ответила ошибкой: $(jq -r '.msg // .' <<<"$out" 2>/dev/null | head -c 300)"
jq -c '.obj' <<<"$out"
}
# В 3X-UI 3.x у клиента одна запись и в ней одна пара ключей WireGuard и один адрес. Если
# клиент подключён к двум AmneziaWG, в подписку для обоих уходят ключ и адрес одного из них,
# и второй сервер клиента не узнаёт (проверено 2026-09-27). Поэтому к первому AmneziaWG
# подключаем основную запись, а ко второму — запись-«двойник» «имя-awg» с подпиской «<id>-awg»
# (subId в 3X-UI обязан быть уникальным) и теми же лимитами; kit-sub подмешивает её в Clash.
awg_ids() { api GET inbounds/list | jq -r '[.[] | select(.protocol == "amneziawg") | .id] | sort | .[]'; }
non_awg_ids() { api GET inbounds/list | jq -c '[.[] | select(.protocol != "amneziawg") | .id]'; }
awg_attach() { # имя subId [лимит-байт] [срок-мс] [устройств]
local name=$1 sid=$2 total=${3:-0} exp=${4:-0} lim=${5:-0} n=1 id email have
have=$(api GET clients/list | jq -c 'if type == "array" then . else .clients end')
for id in $(awg_ids); do
local esid=$sid
if ((n == 1)); then email=$name
elif ((n == 2)); then email="$name-awg"; esid="$sid-awg"
else email="$name-awg$n"; esid="$sid-awg$n"; fi
n=$((n + 1))
if jq -e --arg e "$email" --argjson i "$id" 'any(.[]; .email == $e and ((.inboundIds // []) | index($i)))' <<<"$have" >/dev/null; then
continue
elif jq -e --arg e "$email" 'any(.[]; .email == $e)' <<<"$have" >/dev/null; then
api POST "clients/$email/attach" "$(jq -nc --argjson i "$id" '{inboundIds: [$i]}')" >/dev/null
else
api POST clients/add "$(jq -nc --arg e "$email" --arg s "$esid" --argjson t "$total" --argjson x "$exp" --argjson l "$lim" --argjson i "$id" \
'{client: {email: $e, subId: $s, totalGB: $t, expiryTime: $x, limitIp: $l, enable: true, comment: "kit"}, inboundIds: [$i]}')" >/dev/null
fi
done
}
clients() { api GET clients/list | jq -c 'if type == "array" then . else .clients end'; }
client() { clients | jq -c --arg e "$1" 'map(select(.email == $e))[0] // empty'; }
# Все записи пользователя: основная и «двойники» для AmneziaWG («имя-awgN»).
emails_of() { clients | jq -r --arg e "$1" '.[] | select(.email == $e or (.email | test("^" + $e + "-awg[0-9]*$"))) | .email'; }
valid_name() { [[ $1 =~ ^[A-Za-z0-9_.-]{1,32}$ ]] || die "Имя: латиница, цифры, _ . - (до 32 символов)."; }
rand_id() { openssl rand -base64 48 | tr -dc 'a-z0-9' | head -c 16; }
gb_bytes() { [[ $1 =~ ^[0-9]+$ ]] || die "--gb: целое число гигабайт"; echo $(($1 * 1073741824)); }
days_ms() { [[ $1 =~ ^[0-9]+$ ]] || die "--days: целое число дней"; ((${1} == 0)) && { echo 0; return; }; echo $((($(date +%s) + $1 * 86400) * 1000)); }
human() { # байты → «1.2 ГБ»
awk -v b="$1" 'BEGIN { split("Б КБ МБ ГБ ТБ", u, " "); i = 1; while (b >= 1024 && i < 5) { b /= 1024; i++ }
printf (i == 1 ? "%d %s" : "%.1f %s"), b, u[i] }'
}
sub_url() { echo "${SUB_BASE}$1"; }
show_link() { # имя subId
local url
url=$(sub_url "$2")
echo
echo "Подписка ${B}$1${N} — все протоколы одной ссылкой. Вставьте в Happ, Hiddify, Karing,"
echo "v2rayN, Clash Verge или FlClash:"
echo
echo "$url"
echo
command -v qrencode >/dev/null && qrencode -t ANSIUTF8 -m 1 "$url"
echo "${D}AmneziaVPN и Telegram: kit user link $1 --all — отдельные ссылки vpn:// и tg://${N}"
}
cmd_add() {
local name=${1:-} gb=0 days=0 devices=0
valid_name "$name"; shift
while [[ $# -gt 0 ]]; do
case $1 in
--gb) gb=$2; shift 2 ;;
--days) days=$2; shift 2 ;;
--devices) devices=$2; shift 2 ;;
*) die "Неизвестный параметр: $1" ;;
esac
done
[[ -z $(client "$name") ]] || die "Пользователь $name уже есть. Ссылка: kit user link $name"
local ids sid body
ids=$(non_awg_ids)
[[ $ids != "[]" ]] || die "На сервере нет подключений."
sid=$(rand_id)
body=$(jq -nc --arg e "$name" --arg s "$sid" --argjson t "$(gb_bytes "$gb")" --argjson x "$(days_ms "$days")" \
--argjson ip "$devices" --argjson ids "$ids" '{client: {email: $e, subId: $s, totalGB: $t, expiryTime: $x,
limitIp: $ip, enable: true, comment: "kit"}, inboundIds: $ids}')
api POST clients/add "$body" >/dev/null
awg_attach "$name" "$sid" "$(gb_bytes "$gb")" "$(days_ms "$days")" "$devices"
say "Пользователь $name добавлен во все протоколы ($(api GET inbounds/list | jq length))$( ((gb)) && echo ", лимит $gb ГБ")$( ((days)) && echo ", на $days дн")."
show_link "$name" "$sid"
}
cmd_link() {
local name=${1:-} all=${2:-} c
valid_name "$name"
c=$(client "$name"); [[ -n $c ]] || die "Нет пользователя $name"
show_link "$name" "$(jq -r '.subId' <<<"$c")"
if [[ $all == --all ]]; then
local sid raw out="" suffix
sid=$(jq -r '.subId' <<<"$c")
for suffix in "" -awg; do
raw=$(curl -fsSk -m 10 -A "v2rayN/7" -H "Host: $HOST" "http://127.0.0.1:$SUB_INTERNAL$SUB_PATH$sid$suffix" 2>/dev/null || true)
grep -q '://' <<<"$raw" || raw=$(base64 -d <<<"$raw" 2>/dev/null || true)
out+=$(grep -E '^(vpn|tg)://' <<<"$raw" || true)$'\n'
done
echo; grep . <<<"$out" || echo "Отдельных ссылок нет."
fi
}
cmd_list() {
local now
now=$(($(date +%s) * 1000))
clients | jq -r --argjson now "$now" '
map(select(.subId != null)) | group_by(.subId | sub("-awg[0-9]*$"; "")) | map(sort_by(.email | length) as $g | $g[0] + {used: ([$g[] | (.traffic.up // 0) + (.traffic.down // 0)] | add),
seen: ([$g[] | .traffic.lastOnline // 0] | max)}) | sort_by(.email)[]
| [.email, .used, (.totalGB // 0), (.expiryTime // 0), .enable, .seen] | @tsv' |
{
printf "${B}%-18s %-22s %-14s %-10s %s${N}\n" "Пользователь" "Трафик" "До" "Статус" "Был в сети"
while IFS=$'\t' read -r email used total exp en last; do
local tr till st seen
tr="$(human "$used")"; ((total > 0)) && tr="$tr / $(human "$total")"
if ((exp > 0)); then till=$(date -d "@$((exp / 1000))" +%d.%m.%Y); else till="бессрочно"; fi
if [[ $en != true ]]; then st="${R}выключен${N}"
elif ((exp > 0 && exp < $(date +%s) * 1000)); then st="${Y}истёк${N}"
elif ((total > 0 && used >= total)); then st="${Y}лимит${N}"
else st="${G}активен${N}"; fi
if ((last > 0)); then seen=$(date -d "@$((last / 1000))" '+%d.%m %H:%M'); else seen="—"; fi
printf "%-18s %-22s %-14s %-19s %s\n" "$email" "$tr" "$till" "$st" "$seen"
done
}
}
# Меняет все записи пользователя (основную и «двойников» AmneziaWG), каждую — от её собственных данных.
update_user() { # имя jq-фильтр [аргументы jq...]
local name=$1 filter=$2 e rec body
shift 2
for e in $(emails_of "$name"); do
rec=$(client "$e")
body=$(jq -c "$@" "{email, subId, totalGB, expiryTime, limitIp, enable, comment} | $filter" <<<"$rec")
api POST "clients/update/$e" "$body" >/dev/null
done
}
cmd_limit() {
local name=${1:-} f="." gb="" days="" dev=""
valid_name "$name"; shift
[[ -n $(client "$name") ]] || die "Нет пользователя $name"
while [[ $# -gt 0 ]]; do
case $1 in
--gb) gb=$(gb_bytes "$2"); f+=" | .totalGB = \$gb"; shift 2 ;;
--days) days=$(days_ms "$2"); f+=" | .expiryTime = \$days"; shift 2 ;;
--devices) [[ $2 =~ ^[0-9]+$ ]] || die "--devices: целое число"; dev=$2; f+=" | .limitIp = \$dev"; shift 2 ;;
*) die "Неизвестный параметр: $1" ;;
esac
done
update_user "$name" "$f" --argjson gb "${gb:-0}" --argjson days "${days:-0}" --argjson dev "${dev:-0}"
say "Лимиты $name обновлены (0 — без ограничений)."
}
cmd_toggle() { # имя true|false
valid_name "$1"
[[ -n $(client "$1") ]] || die "Нет пользователя $1"
update_user "$1" ".enable = \$v" --argjson v "$2"
if [[ $2 == true ]]; then say "Пользователь $1 включён."; else say "Пользователь $1 выключен — подписка и подключения не работают."; fi
}
cmd_del() {
local name=${1:-} ans=""
valid_name "$name"
[[ -n $(client "$name") ]] || die "Нет пользователя $name"
if [[ ${2:-} != -y && -t 0 ]]; then
read -rp "Удалить $name со всех протоколов? [y/N] " ans
[[ $ans =~ ^[yYдД]$ ]] || { echo "Отменено."; return; }
fi
local e
for e in $(emails_of "$name"); do api POST "clients/del/$e" >/dev/null; done
say "Пользователь $name удалён, его подписка больше не работает."
}
usage() {
cat <<EOF
${B}kit${N} — пользователи: один пользователь сразу на всех протоколах
kit user add имя [--gb 50] [--days 30] [--devices 3] добавить и показать подписку
kit user list трафик, срок, статус
kit user link имя [--all] подписка и QR; --all — ещё vpn:// и tg://
kit user limit имя [--gb N] [--days N] [--devices N] изменить лимиты (0 — без ограничений)
kit user off имя / kit user on имя выключить и включить
kit user del имя удалить
EOF
}
case "${1:-} ${2:-}" in
"user add") shift 2; cmd_add "$@" ;;
"user list") cmd_list ;;
"user link") shift 2; cmd_link "$@" ;;
"user limit") shift 2; cmd_limit "$@" ;;
"user off") cmd_toggle "${3:-}" false ;;
"user on") cmd_toggle "${3:-}" true ;;
"user del") shift 2; cmd_del "$@" ;;
*) usage ;;
esac