From 906b1f5842b2384aef36e32ed9971d6385322d03 Mon Sep 17 00:00:00 2001 From: savsis Date: Mon, 14 Sep 2026 00:28:20 +0500 Subject: [PATCH] feat: pause/resume a subscription without losing paid time (Remnawave-style hold) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the original night's low-priority backlog item ("user on hold status") — the only lever admin had for cutting a customer's access was Revoke, which is permanent: the subscription's remaining days are just gone, and restoring access means manually granting a brand-new one and eyeballing how many days to give back. No way to say "block this for a few days, then give the exact remaining time back." db.py: new held_at column on subscriptions (same ALTER-TABLE migration pattern as every other column added this week). hold_subscription() sets it, guarded to only fire on a subscription that's currently active, not already held, not expired — returns False instead of silently no-opping so the caller can tell holding didn't happen. resume_subscription() shifts expires_at forward by exactly how long it was held (now - held_at) and clears held_at, so a subscription paused for 3 days comes back with 3 days added, not 3 days lost. The part that actually mattered for correctness: list_active_subscriptions() now also requires held_at IS NULL. This function is what xray_manager's periodic sync (every 90s) uses to decide which clients belong in Xray's config — without this exclusion, holding a subscription would look like it worked for about 90 seconds and then the next sync would silently re-add the client, since the row still has active=1 and a future expires_at. Found this by actually tracing sync_from_db()/sync_all() before writing the hold logic, not after debugging a live failure. api.py: POST .../hold and .../resume routes, mirroring the existing revoke route (fetch the sub, touch the node's xray client immediately rather than waiting for the next periodic sync, same as revoke already does). _days_left() now takes the whole subscription row instead of just expires_at, so it can use held_at as the reference point instead of "now" for a held subscription — otherwise the admin UI would show the days counter silently ticking down while the customer isn't even able to use the service. admin.html: Пауза/Возобновить buttons next to Отозвать in both the main Подписки table and the per-user card, a "на паузе" badge, and a doc-block explaining the hold-vs-revoke distinction. Also fixed a latent race while touching this code: the old inline revoke handler in the user card fired openUserCard() immediately alongside revokeSub() without waiting for it, so the card could refresh before the revoke's own API call had finished; switched to .then() so hold/resume/revoke all correctly wait for the action before refreshing the card. Verification: db.py has no fastapi/aiogram dependency so this was fully testable locally, unlike most of tonight's api.py/bot.py-touching work. 16 checks against a real isolated sqlite db: hold/resume round-trip, the exclude-from-active-list behavior the xray sync depends on, the exact hours-shift math (simulated a 5h hold by rewriting held_at directly, verified the resumed expires_at landed within 6 minutes of the expected shift), and edge cases — double-hold, double-resume, holding an expired or already-revoked subscription, nonexistent uuid. AST-extracted the updated _days_left() out of api.py (still can't import the module directly) and ran it against hand-built held/active subscription dicts. Added the same hold/resume sequence to the existing CI "TOTP/backup/reorder" step and ran that step's exact full script locally end to end before committing — all six of its sections pass together, not just the new one in isolation. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 18 ++++++++++++++++++ admin.html | 35 ++++++++++++++++++++++++++++++----- api.py | 39 +++++++++++++++++++++++++++++++++------ db.py | 38 +++++++++++++++++++++++++++++++++++++- 4 files changed, 118 insertions(+), 12 deletions(-) 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(