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:
parent
7b5fd8dceb
commit
fe579430bd
6 changed files with 184 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 напрямую.
|
||||
|
|
|
|||
66
admin.html
66
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 @@
|
|||
</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()">×</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
45
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):
|
||||
|
|
|
|||
|
|
@ -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
58
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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue