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_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

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`), но с плейсхолдерами вместо твоих реквизитов.
## Лимит устройств (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 напрямую.

View file

@ -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 @@
</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 class="page-title">Трафик</div>
<div class="page-sub">Суммарно по всем нодам, live через Xray Stats API</div>
@ -706,10 +730,50 @@ async function loadSubscriptions() {
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>
<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>';
}
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) {
if (!confirm("Отозвать подписку?")) return;
await api(`/admin/api/subscriptions/${uuid}/revoke`, { method: "POST" });

45
api.py
View file

@ -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):

View file

@ -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"))

58
db.py
View file

@ -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))