diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6d0ae9d..0af3c41 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -210,6 +210,24 @@ jobs:
assert db.count_recent_login_attempts(ip, "password", minutes=15) == 0
print("rate-limit counters OK")
+ import datetime as dt
+
+ hold_sub = db.create_subscription(999, "n1", 30, "1m", source="bot")
+ original_expires = dt.datetime.fromisoformat(hold_sub["expires_at"])
+ assert db.hold_subscription(hold_sub["uuid"])
+ assert db.hold_subscription(hold_sub["uuid"]) is False
+ assert len(db.list_active_subscriptions(tg_id=999)) == 0, "held sub must not count as active"
+ with db.get_conn() as conn:
+ simulated = (dt.datetime.utcnow() - dt.timedelta(hours=5)).isoformat()
+ conn.execute("UPDATE subscriptions SET held_at=? WHERE uuid=?", (simulated, hold_sub["uuid"]))
+ resumed = db.resume_subscription(hold_sub["uuid"])
+ assert resumed["held_at"] is None
+ shift_hours = (dt.datetime.fromisoformat(resumed["expires_at"]) - original_expires).total_seconds() / 3600
+ assert 4.9 <= shift_hours <= 5.1, f"expected ~5h shift, got {shift_hours}"
+ assert len(db.list_active_subscriptions(tg_id=999)) == 1, "resumed sub must count as active again"
+ assert db.resume_subscription(hold_sub["uuid"]) is None
+ print("subscription hold/resume OK")
+
print("all v1.1.0 feature smoke tests passed")
PYEOF
diff --git a/admin.html b/admin.html
index e442907..fee37e7 100644
--- a/admin.html
+++ b/admin.html
@@ -620,6 +620,11 @@
Настройки → «Лимит устройств» — глобальный рубильник и лимит по умолчанию (как у Remnawave: клиент шлёт заголовок x-hwid при запросе конфига, панель запоминает первые N уникальных устройств на юзера и отказывает новым сверх лимита). У конкретного юзера лимит можно переопределить отдельно — в его карточке (Подписки → кнопка «Карточка» → таб «Устройства»), это имеет приоритет над глобальным значением по умолчанию.
+
+
Пауза подписки
+
Кнопка «Пауза» у активной подписки (в Подписках и в карточке юзера) — не то же самое, что «Отозвать». Пауза сразу убирает клиента из Xray (доступ пропадает), но остаток срока сохраняется: сколько дней было на паузе — ровно столько добавится к expires_at при нажатии «Возобновить». «Отозвать», наоборот, необратимо — новую подписку тогда выдаёт только «Карточка» → ручная выдача.
+
+
fail2ban
install.sh ставит и включает fail2ban автоматически (дефолтный jail — защита SSH от перебора паролей). Проверить, что работает:
@@ -1383,7 +1388,8 @@ async function restoreBackup() {
}
function fmtDate(iso) { return iso ? iso.slice(0, 10) : "—"; }
-function statusBadge(active, daysLeft) {
+function statusBadge(active, daysLeft, held) {
+ if (held) return 'на паузе';
if (!active) return 'истекла';
if (daysLeft <= 2) return '' + daysLeft + ' дн.';
return '' + daysLeft + ' дн.';
@@ -1402,7 +1408,7 @@ async function loadDashboard() {
const body = document.getElementById("recent-subs-body");
body.innerHTML = recent.length ? recent.map((s, i) => `