diff --git a/.env.example b/.env.example index 73d19c1..8237c62 100644 --- a/.env.example +++ b/.env.example @@ -54,3 +54,10 @@ YOOKASSA_SECRET_KEY= PLATEGA_ENABLED=false PLATEGA_MERCHANT_ID= PLATEGA_SECRET= + +# Device limit (HWID) — off by default. Requires the VPN client app to send an +# x-hwid header on subscription fetch (Happ/v2rayTun-class apps do this); clients +# that don't send it get refused once enabled, so only flip this on if your users' +# apps actually support it. +HWID_LIMIT_ENABLED=false +HWID_FALLBACK_LIMIT=3 diff --git a/README.md b/README.md index 9be6e18..962c553 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,12 @@ bash <(curl -Ls https://panel.example.com/install/ТОКЕН.sh) Заполни реальными данными `site/offer.html` (публичная оферта) и `site/privacy.html` (политика конфиденциальности) перед подачей заявки в ЮKassa — они нужны для их проверки, шаблоны уже на сайте (`/offer.html`, `/privacy.html`), но с плейсхолдерами вместо твоих реквизитов. +## Лимит устройств (HWID) + +Как в Remnawave — ограничение, сколько разных устройств может использовать одну подписку. Работает не через сам VPN-протокол (Xray физически не видит "железо" клиента), а на уровне выдачи самой подписки: современные клиенты (Happ, v2rayTun и т.д.) при запросе `/sub/{token}` шлют заголовок `x-hwid` — уникальный ID устройства. Панель запоминает первые N увиденных hwid на юзера; при попытке добавить N+1-е устройство — отказ (404 + заголовок `x-hwid-max-devices-reached`). + +Выключено по умолчанию (`HWID_LIMIT_ENABLED=false`) — клиенты, которые не шлют `x-hwid`, при включённом лимите вообще не получат подписку, так что включай только если знаешь, что твои пользователи сидят на приложениях с поддержкой этого заголовка. `HWID_FALLBACK_LIMIT` — лимит по умолчанию для всех, в админке (Подписки → кнопка «Устройства» у юзера) можно посмотреть/удалить привязанные устройства и задать индивидуальный лимит. + ## Разработка / вклад Код простой — без сборки фронта, без ORM, без лишних абстракций. `admin.html` — один файл, vanilla JS. Питон-часть — обычные функции, SQLite напрямую. diff --git a/admin.html b/admin.html index 1f3af6f..068947d 100644 --- a/admin.html +++ b/admin.html @@ -237,6 +237,13 @@ .modal-close:hover { color: var(--text); background: var(--card2-tint); } .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 10px; } + .device-row { + display: flex; align-items: center; justify-content: space-between; gap: 10px; + padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; + font-size: 13.5px; + } + .devices-list { max-height: 260px; overflow-y: auto; margin: 4px 0 16px; } + @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } .reveal, .login-card, .splash-mark, .splash-title, .splash-tagline, .modal-overlay, .modal-card { opacity: 1 !important; transform: none !important; filter: none !important; } @@ -411,6 +418,23 @@ + +
Трафик
Суммарно по всем нодам, live через Xray Stats API
@@ -706,10 +730,50 @@ async function loadSubscriptions() { body.innerHTML = subs.length ? subs.map((s) => ` ${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)} - ${s.active ? `` : ""} + + + ${s.active ? `` : ""} + `).join("") : '
Пока нет подписок
'; } +let devicesTgId = null; +async function openDevices(tgId, label) { + devicesTgId = tgId; + document.getElementById("devices-title").textContent = `Устройства: ${label}`; + document.getElementById("devices-list").innerHTML = '
Загрузка…
'; + document.getElementById("devices-overlay").classList.add("show"); + const data = await api(`/admin/api/users/${tgId}/devices`); + document.getElementById("devices-limit").value = data.limit || ""; + document.getElementById("devices-limit-hint").textContent = `По умолчанию (если пусто): ${data.fallback_limit}`; + renderDevices(data.devices); +} +function closeDevices() { + document.getElementById("devices-overlay").classList.remove("show"); +} +function renderDevices(devices) { + const list = document.getElementById("devices-list"); + list.innerHTML = devices.length ? devices.map((d) => ` +
+
+
${esc(d.device_model || d.device_os || "Неизвестное устройство")}
+
${esc(d.device_os || "")} · с ${fmtDate(d.first_seen)}
+
+ +
+ `).join("") : '
Нет привязанных устройств
'; +} +async function deleteDevice(deviceId) { + await api(`/admin/api/users/${devicesTgId}/devices/${deviceId}`, { method: "DELETE" }); + const data = await api(`/admin/api/users/${devicesTgId}/devices`); + renderDevices(data.devices); +} +async function saveHwidLimit() { + const val = document.getElementById("devices-limit").value.trim(); + await api(`/admin/api/users/${devicesTgId}/hwid-limit`, { method: "POST", body: JSON.stringify({ limit: val || null }) }); + closeDevices(); +} + async function revokeSub(uuid) { if (!confirm("Отозвать подписку?")) return; await api(`/admin/api/subscriptions/${uuid}/revoke`, { method: "POST" }); diff --git a/api.py b/api.py index eebf73c..b42c9d8 100644 --- a/api.py +++ b/api.py @@ -1,6 +1,7 @@ import datetime import json import os +import re from fastapi import FastAPI, HTTPException, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi import Body @@ -14,8 +15,11 @@ import xray_manager from config import ( PLANS, PLANS_BY_CODE, SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN, ADMIN_PANEL_PASSWORD, BOT_USERNAME, BOT_TOKEN, + HWID_LIMIT_ENABLED, HWID_FALLBACK_LIMIT, ) +HWID_RE = re.compile(r"^[a-zA-Z0-9=-]{10,64}$") + db.init_db() app = FastAPI(title="mbs-api") @@ -210,6 +214,22 @@ def get_subscription(token: str, request: Request): return HTMLResponse(SUB_PAGE_EXPIRED_TEMPLATE.format(bot_username=BOT_USERNAME)) sub_url = f"https://{SUB_DOMAIN}/sub/{token}" return HTMLResponse(SUB_PAGE_TEMPLATE.format(sub_url=sub_url)) + + if HWID_LIMIT_ENABLED: + hwid = request.headers.get("x-hwid", "") + if not HWID_RE.match(hwid): + raise HTTPException(404, "hwid required") + if not db.get_device(user["tg_id"], hwid): + limit = user["hwid_limit"] or HWID_FALLBACK_LIMIT + if db.count_devices(user["tg_id"]) >= limit: + raise HTTPException(404, "device limit reached", headers={"x-hwid-max-devices-reached": "true"}) + db.add_device( + user["tg_id"], hwid, + request.headers.get("x-device-os"), + request.headers.get("x-device-model"), + ua, + ) + content = links.build_subscription_text(subs) return Response(content=content, media_type="text/plain") @@ -458,6 +478,31 @@ def admin_revoke_subscription(uuid: str, request: Request): return {"ok": True} +@app.get("/admin/api/users/{tg_id}/devices") +def admin_list_devices(tg_id: int, request: Request): + require_admin(request) + return { + "devices": db.list_devices(tg_id), + "limit": db.get_or_create_user(tg_id, None).get("hwid_limit"), + "fallback_limit": HWID_FALLBACK_LIMIT, + } + + +@app.delete("/admin/api/users/{tg_id}/devices/{device_id}") +def admin_delete_device(tg_id: int, device_id: int, request: Request): + require_admin(request) + db.delete_device(device_id) + return {"ok": True} + + +@app.post("/admin/api/users/{tg_id}/hwid-limit") +def admin_set_hwid_limit(tg_id: int, request: Request, body: dict = Body(...)): + require_admin(request) + limit = body.get("limit") + db.set_user_hwid_limit(tg_id, int(limit) if limit else None) + return {"ok": True} + + @app.get("/admin/api/gift-codes") def admin_gift_codes(request: Request): diff --git a/config.py b/config.py index 94e0e2a..02e38a0 100644 --- a/config.py +++ b/config.py @@ -115,3 +115,6 @@ YOOKASSA_SECRET_KEY = env("YOOKASSA_SECRET_KEY", "") PLATEGA_ENABLED = env("PLATEGA_ENABLED", "false").lower() == "true" PLATEGA_MERCHANT_ID = env("PLATEGA_MERCHANT_ID", "") PLATEGA_SECRET = env("PLATEGA_SECRET", "") + +HWID_LIMIT_ENABLED = env("HWID_LIMIT_ENABLED", "false").lower() == "true" +HWID_FALLBACK_LIMIT = int(env("HWID_FALLBACK_LIMIT", "3")) diff --git a/db.py b/db.py index 209ec5a..d9c22d3 100644 --- a/db.py +++ b/db.py @@ -13,6 +13,17 @@ CREATE TABLE IF NOT EXISTS users ( created_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tg_id INTEGER NOT NULL, + hwid TEXT NOT NULL, + device_os TEXT, + device_model TEXT, + user_agent TEXT, + first_seen TEXT NOT NULL, + UNIQUE(tg_id, hwid) +); + CREATE TABLE IF NOT EXISTS subscriptions ( uuid TEXT PRIMARY KEY, tg_id INTEGER NOT NULL, @@ -86,6 +97,10 @@ _NEW_NODE_COLUMNS = { "hysteria_obfs_password": "TEXT", } +_NEW_USER_COLUMNS = { + "hwid_limit": "INTEGER", +} + def _migrate(): with get_conn() as conn: @@ -93,6 +108,10 @@ def _migrate(): for name, decl in _NEW_NODE_COLUMNS.items(): if name not in cols: conn.execute(f"ALTER TABLE nodes ADD COLUMN {name} {decl}") + ucols = {r["name"] for r in conn.execute("PRAGMA table_info(users)").fetchall()} + for name, decl in _NEW_USER_COLUMNS.items(): + if name not in ucols: + conn.execute(f"ALTER TABLE users ADD COLUMN {name} {decl}") def now_iso(): @@ -410,3 +429,42 @@ def list_payments(limit: int = 200): "SELECT * FROM payments ORDER BY created_at DESC LIMIT ?", (limit,) ).fetchall() return [dict(r) for r in rows] + + +def list_devices(tg_id: int): + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM devices WHERE tg_id=? ORDER BY first_seen ASC", (tg_id,) + ).fetchall() + return [dict(r) for r in rows] + + +def count_devices(tg_id: int) -> int: + with get_conn() as conn: + return conn.execute("SELECT COUNT(*) c FROM devices WHERE tg_id=?", (tg_id,)).fetchone()["c"] + + +def get_device(tg_id: int, hwid: str): + with get_conn() as conn: + row = conn.execute("SELECT * FROM devices WHERE tg_id=? AND hwid=?", (tg_id, hwid)).fetchone() + return dict(row) if row else None + + +def add_device(tg_id: int, hwid: str, device_os: str | None, device_model: str | None, user_agent: str | None): + with get_conn() as conn: + conn.execute( + "INSERT OR IGNORE INTO devices (tg_id, hwid, device_os, device_model, user_agent, first_seen) " + "VALUES (?,?,?,?,?,?)", + (tg_id, hwid, device_os, device_model, user_agent, now_iso()), + ) + return get_device(tg_id, hwid) + + +def delete_device(device_id: int): + with get_conn() as conn: + conn.execute("DELETE FROM devices WHERE id=?", (device_id,)) + + +def set_user_hwid_limit(tg_id: int, limit: int | None): + with get_conn() as conn: + conn.execute("UPDATE users SET hwid_limit=? WHERE tg_id=?", (limit, tg_id))