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_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
|
||||||
|
|
|
||||||
|
|
@ -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 напрямую.
|
||||||
|
|
|
||||||
66
admin.html
66
admin.html
|
|
@ -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()">×</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
45
api.py
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
58
db.py
|
|
@ -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))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue