hwid: per-user device limit like Remnawave (x-hwid header, opt-in)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Savsis? 2026-09-10 22:06:15 +05:00
parent 7b5fd8dceb
commit fe579430bd
6 changed files with 184 additions and 1 deletions

View file

@ -54,3 +54,10 @@ YOOKASSA_SECRET_KEY=
PLATEGA_ENABLED=false PLATEGA_ENABLED=false
PLATEGA_MERCHANT_ID= PLATEGA_MERCHANT_ID=
PLATEGA_SECRET= 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

View file

@ -100,6 +100,12 @@ bash <(curl -Ls https://panel.example.com/install/ТОКЕН.sh)
Заполни реальными данными `site/offer.html` (публичная оферта) и `site/privacy.html` (политика конфиденциальности) перед подачей заявки в ЮKassa — они нужны для их проверки, шаблоны уже на сайте (`/offer.html`, `/privacy.html`), но с плейсхолдерами вместо твоих реквизитов. Заполни реальными данными `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 напрямую. Код простой — без сборки фронта, без ORM, без лишних абстракций. `admin.html` — один файл, vanilla JS. Питон-часть — обычные функции, SQLite напрямую.

View file

@ -237,6 +237,13 @@
.modal-close:hover { color: var(--text); background: var(--card2-tint); } .modal-close:hover { color: var(--text); background: var(--card2-tint); }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 10px; } .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) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } *, *::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; } .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 @@
</div> </div>
</div> </div>
<div id="devices-overlay" class="modal-overlay" onclick="if(event.target===this) closeDevices()">
<div class="modal-card">
<div class="modal-head">
<h2 id="devices-title">Устройства</h2>
<button class="modal-close" onclick="closeDevices()">&times;</button>
</div>
<div id="devices-list" class="devices-list"></div>
<label class="f">Лимит устройств для этого юзера</label>
<input type="text" id="devices-limit" placeholder="по умолчанию">
<p class="page-sub" id="devices-limit-hint" style="margin:6px 0 0"></p>
<div class="modal-actions">
<button class="btn ghost" onclick="closeDevices()">Закрыть</button>
<button class="btn" onclick="saveHwidLimit()">Сохранить лимит</button>
</div>
</div>
</div>
<div id="view-traffic" class="view"> <div id="view-traffic" class="view">
<div class="page-title">Трафик</div> <div class="page-title">Трафик</div>
<div class="page-sub">Суммарно по всем нодам, live через Xray Stats API</div> <div class="page-sub">Суммарно по всем нодам, live через Xray Stats API</div>
@ -706,10 +730,50 @@ async function loadSubscriptions() {
body.innerHTML = subs.length ? subs.map((s) => ` body.innerHTML = subs.length ? subs.map((s) => `
<tr><td>${s.username ? "@" + esc(s.username) : "tg" + s.tg_id}</td><td>${esc(s.node_label)}</td><td>${esc(s.plan_label)}</td> <tr><td>${s.username ? "@" + esc(s.username) : "tg" + s.tg_id}</td><td>${esc(s.node_label)}</td><td>${esc(s.plan_label)}</td>
<td>${fmtDate(s.created_at)}</td><td>${fmtDate(s.expires_at)}</td><td>${statusBadge(s.active, s.days_left)}</td> <td>${fmtDate(s.created_at)}</td><td>${fmtDate(s.expires_at)}</td><td>${statusBadge(s.active, s.days_left)}</td>
<td>${s.active ? `<button class="muted-btn" onclick="revokeSub('${s.uuid}')">Отозвать</button>` : ""}</td></tr> <td>
<button class="muted-btn" onclick="openDevices(${s.tg_id}, '${esc(s.username ? '@' + s.username : 'tg' + s.tg_id)}')">Устройства</button>
${s.active ? `<button class="muted-btn" onclick="revokeSub('${s.uuid}')">Отозвать</button>` : ""}
</td></tr>
`).join("") : '<tr><td colspan="7"><div class="empty">Пока нет подписок</div></td></tr>'; `).join("") : '<tr><td colspan="7"><div class="empty">Пока нет подписок</div></td></tr>';
} }
let devicesTgId = null;
async function openDevices(tgId, label) {
devicesTgId = tgId;
document.getElementById("devices-title").textContent = `Устройства: ${label}`;
document.getElementById("devices-list").innerHTML = '<div class="empty">Загрузка…</div>';
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) => `
<div class="device-row">
<div>
<div>${esc(d.device_model || d.device_os || "Неизвестное устройство")}</div>
<div class="page-sub" style="margin:2px 0 0">${esc(d.device_os || "")} · с ${fmtDate(d.first_seen)}</div>
</div>
<button class="muted-btn" onclick="deleteDevice(${d.id})">Удалить</button>
</div>
`).join("") : '<div class="empty">Нет привязанных устройств</div>';
}
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) { async function revokeSub(uuid) {
if (!confirm("Отозвать подписку?")) return; if (!confirm("Отозвать подписку?")) return;
await api(`/admin/api/subscriptions/${uuid}/revoke`, { method: "POST" }); await api(`/admin/api/subscriptions/${uuid}/revoke`, { method: "POST" });

45
api.py
View file

@ -1,6 +1,7 @@
import datetime import datetime
import json import json
import os import os
import re
from fastapi import FastAPI, HTTPException, Request, Response from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi import Body from fastapi import Body
@ -14,8 +15,11 @@ import xray_manager
from config import ( from config import (
PLANS, PLANS_BY_CODE, SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN, PLANS, PLANS_BY_CODE, SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN,
ADMIN_PANEL_PASSWORD, BOT_USERNAME, BOT_TOKEN, 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() db.init_db()
app = FastAPI(title="mbs-api") 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)) return HTMLResponse(SUB_PAGE_EXPIRED_TEMPLATE.format(bot_username=BOT_USERNAME))
sub_url = f"https://{SUB_DOMAIN}/sub/{token}" sub_url = f"https://{SUB_DOMAIN}/sub/{token}"
return HTMLResponse(SUB_PAGE_TEMPLATE.format(sub_url=sub_url)) 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) content = links.build_subscription_text(subs)
return Response(content=content, media_type="text/plain") return Response(content=content, media_type="text/plain")
@ -458,6 +478,31 @@ def admin_revoke_subscription(uuid: str, request: Request):
return {"ok": True} 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") @app.get("/admin/api/gift-codes")
def admin_gift_codes(request: Request): def admin_gift_codes(request: Request):

View file

@ -115,3 +115,6 @@ YOOKASSA_SECRET_KEY = env("YOOKASSA_SECRET_KEY", "")
PLATEGA_ENABLED = env("PLATEGA_ENABLED", "false").lower() == "true" PLATEGA_ENABLED = env("PLATEGA_ENABLED", "false").lower() == "true"
PLATEGA_MERCHANT_ID = env("PLATEGA_MERCHANT_ID", "") PLATEGA_MERCHANT_ID = env("PLATEGA_MERCHANT_ID", "")
PLATEGA_SECRET = env("PLATEGA_SECRET", "") PLATEGA_SECRET = env("PLATEGA_SECRET", "")
HWID_LIMIT_ENABLED = env("HWID_LIMIT_ENABLED", "false").lower() == "true"
HWID_FALLBACK_LIMIT = int(env("HWID_FALLBACK_LIMIT", "3"))

58
db.py
View file

@ -13,6 +13,17 @@ CREATE TABLE IF NOT EXISTS users (
created_at TEXT NOT NULL 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 ( CREATE TABLE IF NOT EXISTS subscriptions (
uuid TEXT PRIMARY KEY, uuid TEXT PRIMARY KEY,
tg_id INTEGER NOT NULL, tg_id INTEGER NOT NULL,
@ -86,6 +97,10 @@ _NEW_NODE_COLUMNS = {
"hysteria_obfs_password": "TEXT", "hysteria_obfs_password": "TEXT",
} }
_NEW_USER_COLUMNS = {
"hwid_limit": "INTEGER",
}
def _migrate(): def _migrate():
with get_conn() as conn: with get_conn() as conn:
@ -93,6 +108,10 @@ def _migrate():
for name, decl in _NEW_NODE_COLUMNS.items(): for name, decl in _NEW_NODE_COLUMNS.items():
if name not in cols: if name not in cols:
conn.execute(f"ALTER TABLE nodes ADD COLUMN {name} {decl}") 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(): def now_iso():
@ -410,3 +429,42 @@ def list_payments(limit: int = 200):
"SELECT * FROM payments ORDER BY created_at DESC LIMIT ?", (limit,) "SELECT * FROM payments ORDER BY created_at DESC LIMIT ?", (limit,)
).fetchall() ).fetchall()
return [dict(r) for r in rows] 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))