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) => ` ${s.username ? "@" + esc(s.username) : "tg" + s.tg_id}${esc(s.node_label)}${esc(s.plan_label)} - ${fmtDate(s.expires_at)}${statusBadge(s.active, s.days_left)} + ${fmtDate(s.expires_at)}${statusBadge(s.active, s.days_left, s.held_at)} `).join("") : '
Пока нет подписок
'; } @@ -1411,9 +1417,12 @@ async function loadSubscriptions() { const body = document.getElementById("subs-body"); body.innerHTML = subs.length ? subs.map((s, i) => ` ${s.username ? "@" + esc(s.username) : "tg" + s.tg_id}${esc(s.node_label)}${esc(s.plan_label)} - ${fmtDate(s.created_at)}${fmtDate(s.expires_at)}${statusBadge(s.active, s.days_left)} + ${fmtDate(s.created_at)}${fmtDate(s.expires_at)}${statusBadge(s.active, s.days_left, s.held_at)} + ${s.active ? (s.held_at + ? `` + : ``) : ""} ${s.active ? `` : ""} `).join("") : '
Пока нет подписок
'; @@ -1445,15 +1454,21 @@ function switchUserTab(tab) { document.getElementById("uc-tab-subs").style.display = tab === "subs" ? "block" : "none"; document.getElementById("uc-tab-devices").style.display = tab === "devices" ? "block" : "none"; } +function refreshUserCard() { + openUserCard(devicesTgId, document.getElementById("devices-title").textContent.replace("Карточка: ", "")); +} function renderUcSubs(subs) { const list = document.getElementById("uc-subs-list"); list.innerHTML = subs.length ? subs.map((s, i) => `
${esc(s.node_label)} — ${esc(s.plan_label)}
-
до ${fmtDate(s.expires_at)} · ${statusBadge(s.active, s.days_left)}
+
до ${fmtDate(s.expires_at)} · ${statusBadge(s.active, s.days_left, s.held_at)}
- ${s.active ? `` : ""} + ${s.active ? (s.held_at + ? `` + : ``) : ""} + ${s.active ? `` : ""}
`).join("") : '
Пока нет подписок
'; } @@ -1492,6 +1507,16 @@ async function revokeSub(uuid) { loadSubscriptions(); } +async function holdSub(uuid) { + await api(`/admin/api/subscriptions/${uuid}/hold`, { method: "POST" }); + loadSubscriptions(); +} + +async function resumeSub(uuid) { + await api(`/admin/api/subscriptions/${uuid}/resume`, { method: "POST" }); + loadSubscriptions(); +} + let plansCache = null, nodesCache = null; async function loadGifts() { diff --git a/api.py b/api.py index 8693078..b1fbd69 100644 --- a/api.py +++ b/api.py @@ -51,9 +51,10 @@ def require_admin(request: Request): raise HTTPException(401, "unauthorized") -def _days_left(expires_at: str) -> int: - exp = datetime.datetime.fromisoformat(expires_at) - delta = exp - datetime.datetime.utcnow() +def _days_left(sub: dict) -> int: + exp = datetime.datetime.fromisoformat(sub["expires_at"]) + reference = datetime.datetime.fromisoformat(sub["held_at"]) if sub.get("held_at") else datetime.datetime.utcnow() + delta = exp - reference return max(0, delta.days) @@ -263,7 +264,7 @@ def cabinet(token: str): "plan": s["plan"], "plan_label": plan["label"] if plan else s["plan"], "expires_at": s["expires_at"], - "days_left": _days_left(s["expires_at"]), + "days_left": _days_left(s), }) return { "username": user["username"], @@ -871,7 +872,7 @@ def admin_subscriptions(request: Request, limit: int = 200): **s, "node_label": node["label"] if node else s["node"], "plan_label": plan["label"] if plan else s["plan"], - "days_left": _days_left(s["expires_at"]), + "days_left": _days_left(s), }) return out @@ -889,6 +890,32 @@ def admin_revoke_subscription(uuid: str, request: Request): return {"ok": True} +@app.post("/admin/api/subscriptions/{uuid}/hold") +def admin_hold_subscription(uuid: str, request: Request): + require_admin(request) + sub = db.get_subscription(uuid) + if not sub: + raise HTTPException(404, "not found") + if not db.hold_subscription(uuid): + raise HTTPException(400, "подписка уже на паузе, истекла или отозвана") + node = db.get_node(sub["node"]) + if node: + xray_manager.remove_client_from_node(node, uuid) + return {"ok": True} + + +@app.post("/admin/api/subscriptions/{uuid}/resume") +def admin_resume_subscription(uuid: str, request: Request): + require_admin(request) + resumed = db.resume_subscription(uuid) + if not resumed: + raise HTTPException(400, "подписка не на паузе") + node = db.get_node(resumed["node"]) + if node: + xray_manager.add_client_to_node(node, uuid, email=uuid) + return {"ok": True, "expires_at": resumed["expires_at"]} + + @app.post("/admin/api/subscriptions/{uuid}/reset-traffic") def admin_reset_traffic(uuid: str, request: Request): require_admin(request) @@ -919,7 +946,7 @@ def admin_user_card(tg_id: int, request: Request): **s, "node_label": node["label"] if node else s["node"], "plan_label": plan["label"] if plan else s["plan"], - "days_left": _days_left(s["expires_at"]), + "days_left": _days_left(s), }) return { "tg_id": user["tg_id"], diff --git a/db.py b/db.py index e36f054..89ff292 100644 --- a/db.py +++ b/db.py @@ -143,6 +143,10 @@ _NEW_ADMIN_COLUMNS = { "totp_secret": "TEXT", } +_NEW_SUBSCRIPTION_COLUMNS = { + "held_at": "TEXT", +} + def _migrate(): with get_conn() as conn: @@ -163,6 +167,10 @@ def _migrate(): for name, decl in _NEW_ADMIN_COLUMNS.items(): if name not in acols: conn.execute(f"ALTER TABLE admins ADD COLUMN {name} {decl}") + subcols = {r["name"] for r in conn.execute("PRAGMA table_info(subscriptions)").fetchall()} + for name, decl in _NEW_SUBSCRIPTION_COLUMNS.items(): + if name not in subcols: + conn.execute(f"ALTER TABLE subscriptions ADD COLUMN {name} {decl}") if needs_sort_order_backfill: rows = conn.execute( "SELECT code FROM nodes ORDER BY (code='de1') DESC, created_at ASC" @@ -381,7 +389,7 @@ def create_subscription(tg_id: int, node: str, plan_days: int, plan_code: str, s def list_active_subscriptions(tg_id: int | None = None, node: str | None = None): - q = "SELECT * FROM subscriptions WHERE active=1 AND expires_at > ?" + q = "SELECT * FROM subscriptions WHERE active=1 AND held_at IS NULL AND expires_at > ?" params = [now_iso()] if tg_id is not None: q += " AND tg_id=?" @@ -597,6 +605,34 @@ def revoke_subscription(client_uuid: str): conn.execute("UPDATE subscriptions SET active=0 WHERE uuid=?", (client_uuid,)) +def hold_subscription(client_uuid: str) -> bool: + with get_conn() as conn: + cur = conn.execute( + "UPDATE subscriptions SET held_at=? WHERE uuid=? AND active=1 AND held_at IS NULL AND expires_at > ?", + (now_iso(), client_uuid, now_iso()), + ) + return cur.rowcount > 0 + + +def resume_subscription(client_uuid: str): + with get_conn() as conn: + row = conn.execute( + "SELECT * FROM subscriptions WHERE uuid=? AND held_at IS NOT NULL", (client_uuid,) + ).fetchone() + if not row: + return None + held_at = datetime.datetime.fromisoformat(row["held_at"]) + shift = datetime.datetime.utcnow() - held_at + new_expires = (datetime.datetime.fromisoformat(row["expires_at"]) + shift).isoformat() + cur = conn.execute( + "UPDATE subscriptions SET expires_at=?, held_at=NULL WHERE uuid=? AND held_at IS NOT NULL", + (new_expires, client_uuid), + ) + if cur.rowcount == 0: + return None + return get_subscription(client_uuid) + + def get_subscription(client_uuid: str): with get_conn() as conn: row = conn.execute(