-
CPU{f"{cpu:.1f}%" if cpu is not None else "—"}
-
RAM{f"{ram:.1f}%" if ram is not None else "—"}
-
DSK{f"{disk:.1f}%" if disk is not None else "—"}
-
+
+
+ {get_flag_emoji(s.get("country",""))} {srv_name} {badge}
+
+ {hl}
+ {stats}
+
{inbound_cards}
+ {ru_note}
'''
- return HTMLResponse(content=f'''
-
-
-
-
{title}
-
-
-
-
-
-
-
-
-
-
-
Почему {title}
-
-
🚀
Скорость
Каналы 1–10 Gbit/s, собственные серверы, без посредников
-
🔒
Приватность
Zero-log политика — трафик не хранится и не анализируется
-
🌍
Покрытие
Серверы в Германии, Швеции и России — выбирай ближайший
-
💎
Тарифы
Бесплатный доступ к free-серверам или Premium от 150 ₽/мес
-
-
-
-
-
🖥 Состояние серверов
-
{srv_cards}
-
-
-
-
🔧 Технические детали
-
-
VLESS + XTLS Vision / Reality
-
TCP, WebSocket, gRPC
-
DDoS-защита на всех серверах
-
Zero-log политика
-
Поддержка Happ (iOS / Android / Desktop)
-
Автообновление подписки каждые 12ч
-
-
-
-
-
-
-
-
-''')
+ return HTMLResponse(content=render_template("home.html", title=title, da_url=da_url, srv_cards=srv_cards))
async def fetch_servers_status() -> list:
results = []
- now = time.time()
async with httpx.AsyncClient(timeout=8.0, verify=False) as client:
for srv in servers:
url = srv.get("status_url", "")
@@ -1309,19 +912,6 @@ async def fetch_servers_status() -> list:
entry["online"] = True
entry["server_name"] = data.get("server_name", "")
entry["checks"] = data.get("checks", {})
-
- name = srv["name"]
- if name not in _first_seen:
- _first_seen[name] = now
- uptime_secs = now - _first_seen[name]
- d = int(uptime_secs // 86400)
- h = int((uptime_secs % 86400) // 3600)
- m = int((uptime_secs % 3600) // 60)
- parts = []
- if d > 0: parts.append(f"{d}д")
- parts.append(f"{h}ч")
- parts.append(f"{m}м")
- entry["uptime"] = " ".join(parts)
else:
entry["checks"]["error"] = {"value": f"HTTP {resp.status_code}"}
except Exception as e:
@@ -1414,6 +1004,116 @@ def delete_3xui_client(username: str, sub_id: str, inbound: dict) -> dict:
except Exception as e:
return {"success": False, "error": str(e)[:100]}
+def user_exists_on_server(sub_id: str, srv: dict) -> bool:
+ for inbound in srv.get("inbounds", []):
+ api_host = inbound.get("api_host")
+ api_user = inbound.get("api_user")
+ api_pass = inbound.get("api_pass")
+ inbound_id = inbound.get("id")
+ if not all([api_host, api_user, api_pass, inbound_id]):
+ continue
+ try:
+ api = Api(host=api_host, username=api_user, password=api_pass, use_tls_verify=False)
+ api.login()
+ for ib in api.inbound.get_list():
+ if ib.id == inbound_id and ib.client_stats:
+ for client in ib.client_stats:
+ if getattr(client, 'sub_id', '') == sub_id:
+ return True
+ except:
+ pass
+ return False
+
+def fetch_server_sub_ids(srv: dict) -> set:
+ ids = set()
+ srv_name = srv.get("name", "?")
+ for inbound in srv.get("inbounds", []):
+ api_host = inbound.get("api_host")
+ api_user = inbound.get("api_user")
+ api_pass = inbound.get("api_pass")
+ inbound_id = inbound.get("id")
+ ib_name = inbound.get("name", "?")
+ if not all([api_host, api_user, api_pass, inbound_id]):
+ continue
+ try:
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout
+ def _fetch():
+ api = Api(host=api_host, username=api_user, password=api_pass, use_tls_verify=False)
+ api.login()
+ result = set()
+ for ib in api.inbound.get_list():
+ if ib.id == inbound_id and ib.client_stats:
+ for client in ib.client_stats:
+ sid = getattr(client, 'sub_id', '') or ''
+ if sid:
+ result.add(sid)
+ return result
+ with ThreadPoolExecutor(max_workers=1) as ex:
+ future = ex.submit(_fetch)
+ try:
+ fetched = future.result(timeout=20)
+ ids.update(fetched)
+ logger.info(f"fetch_server_sub_ids {srv_name}/{ib_name}: got {len(fetched)} sub_ids")
+ except FutureTimeout:
+ logger.warning(f"Timeout fetching clients from {srv_name}/{ib_name}")
+ except Exception as e:
+ logger.warning(f"fetch_server_sub_ids {srv_name}/{ib_name} error: {e}")
+ logger.info(f"fetch_server_sub_ids {srv_name}: total {len(ids)} sub_ids")
+ return ids
+
+def propagate_server_sync(server_name: str) -> dict:
+ target_srv = next((s for s in servers if s["name"] == server_name), None)
+ if not target_srv:
+ return {"error": "Server not found"}
+ conn = get_db()
+ try:
+ users = conn.execute("SELECT username, subscription_id, traffic_limit_gb, is_active FROM users WHERE is_active = 1").fetchall()
+ finally:
+ conn.close()
+ other_servers = [s for s in servers if s.get("is_active", True) and s["name"] != server_name]
+ threshold = max(1, len(other_servers) // 2 + 1)
+ target_ids = fetch_server_sub_ids(target_srv)
+ other_ids = {}
+ for s in other_servers:
+ other_ids[s["name"]] = fetch_server_sub_ids(s)
+ total = len(users)
+ already = added = skipped = failed = 0
+ results = []
+ for u in users:
+ sub_id = u["subscription_id"]
+ username = u["username"]
+ if sub_id in target_ids:
+ already += 1
+ continue
+ count = sum(1 for s in other_servers if sub_id in other_ids.get(s["name"], set()))
+ if count >= threshold:
+ ok = True
+ for inbound in target_srv.get("inbounds", []):
+ r = create_3xui_client(username, sub_id, inbound, u["traffic_limit_gb"] or 0)
+ if not r.get("success"):
+ ok = False
+ if ok:
+ added += 1
+ clear_cache(sub_id)
+ else:
+ failed += 1
+ results.append({"username": username, "added": ok})
+ else:
+ skipped += 1
+ results.append({"username": username, "skipped": True, "servers": count, "total": len(other_servers)})
+ return {"server": server_name, "total": total, "already_on_server": already,
+ "added": added, "skipped": skipped, "failed": failed,
+ "threshold": f"{threshold}/{len(other_servers)}", "results": results}
+
+@app.post("/admin/api/propagate/{server_name}")
+async def propagate_server(request: Request, server_name: str):
+ if not check_admin(request):
+ return JSONResponse({"error": "Unauthorized"}, status_code=401)
+ result = propagate_server_sync(server_name)
+ if "error" in result:
+ return JSONResponse(result, status_code=404)
+ return JSONResponse(result)
+
@app.post("/admin/api/users")
async def create_user(request: Request, data: dict):
if not check_admin(request):
@@ -1715,4 +1415,4 @@ if __name__ == "__main__":
asyncio.set_event_loop(loop)
loop.create_task(rotate_shortids())
loop.create_task(poll_donationalerts())
- uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")
\ No newline at end of file
+ uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")
diff --git a/web/admin_dashboard.html b/web/admin_dashboard.html
new file mode 100644
index 0000000..38c2adc
--- /dev/null
+++ b/web/admin_dashboard.html
@@ -0,0 +1,54 @@
+
+
+
+
+
Дашборд · {%title%}
+
+
+
+
+
+
+
+
+
+
+
+
+
Онлайн
{%online_count%}
на всех серверах
+
Выручка
{%total_revenue%}₽
всего оплат
+
+
Инбаундов
{%inbound_count%}
+
+
+
+
🖥 Серверы
+{%servers_rows%}
+
+
+
+
+
diff --git a/web/admin_login.html b/web/admin_login.html
new file mode 100644
index 0000000..f22c9e9
--- /dev/null
+++ b/web/admin_login.html
@@ -0,0 +1,28 @@
+
+
+
+
+
{%title%}
+
+
+
+
+
+
🔒
+
{%title%}
+
+{%error_msg%}
+
+
+
+
diff --git a/web/admin_users.html b/web/admin_users.html
new file mode 100644
index 0000000..338a0e4
--- /dev/null
+++ b/web/admin_users.html
@@ -0,0 +1,86 @@
+
+
+
+
+
Управление · {%title%}
+
+
+
+
+
+
+
+
+
+
+| ID | Username | Sub ID | Тариф | Дней | Оплачено | Трафик | Статус | Создан | |
+
+{%users_rows%}
+
+
+
+
+
+
+
+
+
Новый пользователь
+
+
+
+
+
+
+
+
+
+
+
Редактировать
+
+
+
+
+
+
+
+
+
diff --git a/web/css/admin.css b/web/css/admin.css
new file mode 100644
index 0000000..a084dcd
--- /dev/null
+++ b/web/css/admin.css
@@ -0,0 +1,240 @@
+@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
+
+:root {
+ --bg: #080c18;
+ --surface: rgba(255,255,255,0.03);
+ --surface-hover: rgba(255,255,255,0.06);
+ --border: rgba(255,255,255,0.06);
+ --border-hover: rgba(255,255,255,0.12);
+ --text: #fff;
+ --text-sec: rgba(255,255,255,0.5);
+ --text-ter: rgba(255,255,255,0.3);
+ --accent: #6C63FF;
+ --green: #10b981;
+ --amber: #f59e0b;
+ --rose: #f43f5e;
+ --card-rad: 16px;
+}
+
+* { margin:0; padding:0; box-sizing:border-box }
+body {
+ font-family:'Plus Jakarta Sans',sans-serif;
+ background:var(--bg);
+ color:var(--text);
+ min-height:100vh;
+ overflow-x:hidden;
+}
+a { color:var(--accent); text-decoration:none }
+a:hover { opacity:.85 }
+
+.admin-bg {
+ position:fixed;inset:0;
+ background:
+ radial-gradient(ellipse 70% 50% at 0% 30%, rgba(108,99,255,0.07) 0%, transparent 70%),
+ radial-gradient(ellipse 50% 40% at 100% 70%, rgba(16,185,129,0.05) 0%, transparent 70%);
+ pointer-events:none;
+ z-index:0;
+}
+
+.admin-wrap {
+ position:relative;z-index:1;
+ max-width:1100px;margin:0 auto;padding:40px 24px;
+ animation:fadeUp .5s ease-out both;
+}
+
+/* Header */
+.admin-header {
+ display:flex;justify-content:space-between;align-items:center;
+ margin-bottom:28px;gap:12px;flex-wrap:wrap;
+}
+.header-left { display:flex;align-items:center;gap:10px }
+.admin-header h1 { font-size:22px; font-weight:700; letter-spacing:-.02em }
+.header-count {
+ font-size:13px; font-weight:600; font-family:'JetBrains Mono',monospace;
+ padding:3px 10px; border-radius:20px;
+ background:rgba(108,99,255,0.12); color:var(--accent);
+}
+.header-right { display:flex;align-items:center;gap:10px }
+.nav-link {
+ font-size:13px; font-weight:600; padding:8px 14px;
+ border-radius:10px; transition:background .2s;
+}
+.nav-link:hover { background:var(--surface) }
+
+/* Buttons */
+.btn {
+ display:inline-flex;align-items:center;gap:6px;
+ padding:10px 20px; border:none; border-radius:10px;
+ font-size:13px; font-weight:600; font-family:'Plus Jakarta Sans',sans-serif;
+ cursor:pointer; transition:transform .25s, box-shadow .25s;
+ text-decoration:none; line-height:1;
+}
+.btn:hover { transform:translateY(-2px) }
+.btn-primary { background:var(--accent); color:#fff }
+.btn-primary:hover { box-shadow:0 6px 24px rgba(108,99,255,0.25) }
+.btn-danger { background:var(--rose); color:#fff }
+.btn-danger:hover { box-shadow:0 6px 24px rgba(244,63,94,0.25) }
+.btn-sm {
+ padding:6px 10px; border:none; border-radius:8px;
+ font-size:12px; cursor:pointer; background:var(--surface);
+ color:var(--text-sec); transition:background .2s, color .2s;
+}
+.btn-sm:hover { background:var(--surface-hover); color:var(--text) }
+.btn-sm.danger { color:var(--rose) }
+.btn-sm.danger:hover { background:rgba(244,63,94,0.12) }
+.btn-full { width:100%; justify-content:center; padding:12px }
+
+/* Table */
+.table-wrap {
+ background:var(--surface); border:1px solid var(--border);
+ border-radius:var(--card-rad);
+ backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
+ overflow-x:auto;
+ animation:fadeUp .5s .1s ease-out both;
+}
+.tbl { width:100%; border-collapse:collapse; font-size:13px }
+.tbl th {
+ text-align:left; padding:14px 16px;
+ color:var(--text-ter); font-weight:600; font-size:11px;
+ text-transform:uppercase; letter-spacing:.04em;
+ border-bottom:1px solid var(--border);
+ white-space:nowrap;
+}
+.tbl td {
+ padding:12px 16px;
+ border-bottom:1px solid var(--border);
+ color:var(--text-sec);
+ white-space:nowrap;
+}
+.tbl tr:last-child td { border-bottom:none }
+.tbl tr:hover td { background:rgba(255,255,255,0.02) }
+.tbl code { font-family:'JetBrains Mono',monospace; font-size:10px; color:var(--text-ter) }
+.tbl .actions { display:flex; gap:4px }
+
+/* Badge */
+.badge {
+ display:inline-block; padding:3px 10px; border-radius:20px;
+ font-size:11px; font-weight:600; letter-spacing:.02em;
+}
+.badge-free { background:rgba(16,185,129,0.12); color:var(--green) }
+.badge-paid { background:rgba(245,158,11,0.12); color:var(--amber) }
+.badge-test { background:rgba(108,99,255,0.12); color:var(--accent) }
+
+/* Stat grid */
+.stat-grid {
+ display:grid; grid-template-columns:repeat(auto-fill,minmax(140px,1fr));
+ gap:10px; margin-bottom:28px;
+ animation:fadeUp .5s .05s ease-out both;
+}
+.stat-card {
+ background:var(--surface); border:1px solid var(--border);
+ border-radius:var(--card-rad); padding:18px 20px;
+ backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
+ transition:border-color .3s;
+}
+.stat-card:hover { border-color:var(--border-hover) }
+.stat-lbl { font-size:12px; color:var(--text-ter); font-weight:500; margin-bottom:6px; text-transform:uppercase; letter-spacing:.03em }
+.stat-val { font-size:30px; font-weight:800; letter-spacing:-.03em }
+.stat-sub { font-size:11px; color:var(--text-ter); margin-top:4px }
+
+/* Server section */
+.srv-section { animation:fadeUp .5s .15s ease-out both }
+.srv-section h2 { font-size:16px; font-weight:600; margin-bottom:14px }
+.srv-row {
+ display:flex; justify-content:space-between; align-items:center;
+ background:var(--surface); border:1px solid var(--border);
+ border-radius:12px; padding:14px 18px; margin-bottom:8px;
+ backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
+ transition:border-color .3s;
+}
+.srv-row:hover { border-color:var(--border-hover) }
+.srv-row .srv-name { font-size:14px; font-weight:600; display:flex; align-items:center; gap:6px }
+.srv-row .srv-ibs { font-size:12px; color:var(--text-ter); font-family:'JetBrains Mono',monospace }
+
+/* Login */
+.login-wrap {
+ position:fixed;inset:0;display:flex;
+ align-items:center;justify-content:center;
+ padding:20px;
+}
+.login-card {
+ background:var(--surface); border:1px solid var(--border);
+ border-radius:var(--card-rad); padding:40px 36px;
+ max-width:380px; width:100%;
+ backdrop-filter:blur(20px); -webkit-backdrop-filter:blur(20px);
+ text-align:center;
+ animation:fadeUp .5s ease-out both;
+}
+.login-icon { font-size:36px; margin-bottom:8px }
+.login-card h1 { font-size:20px; font-weight:700; margin-bottom:24px; letter-spacing:-.02em }
+.login-err {
+ margin-top:16px; padding:10px 14px;
+ background:rgba(244,63,94,0.08);
+ border:1px solid rgba(244,63,94,0.2);
+ border-radius:10px; font-size:13px; color:var(--rose);
+}
+
+/* Forms */
+.fg { margin-bottom:16px; text-align:left }
+.fg label { display:block; margin-bottom:6px; font-size:12px; font-weight:600; color:var(--text-sec) }
+.fg input, .fg select {
+ width:100%; padding:12px 14px;
+ background:rgba(255,255,255,0.04);
+ border:1px solid var(--border);
+ border-radius:10px; color:var(--text);
+ font-size:14px; font-family:'Plus Jakarta Sans',sans-serif;
+ outline:none; transition:border-color .2s;
+}
+.fg input:focus, .fg select:focus { border-color:var(--accent) }
+.fg input::placeholder { color:var(--text-ter) }
+.fg select option { background:var(--bg) }
+.fg-check { margin-bottom:16px }
+.fg-check label { font-size:13px; color:var(--text-sec); display:flex; align-items:center; gap:8px; cursor:pointer }
+.fg-check input[type="checkbox"] { width:16px; height:16px; accent-color:var(--accent) }
+
+/* Modal */
+.modal {
+ display:none; position:fixed; inset:0;
+ z-index:1000; align-items:center; justify-content:center;
+ padding:20px;
+}
+.modal.active { display:flex }
+.modal-overlay {
+ position:absolute;inset:0;
+ background:rgba(0,0,0,0.6);
+ backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px);
+}
+.modal-card {
+ position:relative;
+ background:var(--bg); border:1px solid var(--border);
+ border-radius:var(--card-rad); padding:28px;
+ max-width:460px; width:100%;
+ animation:fadeUp .3s ease-out both;
+}
+.modal-h { display:flex; justify-content:space-between; align-items:center; margin-bottom:20px }
+.modal-h h2 { font-size:18px; font-weight:700 }
+.modal-x {
+ background:none; border:none; color:var(--text-ter);
+ font-size:24px; cursor:pointer; padding:4px; line-height:1;
+ transition:color .2s;
+}
+.modal-x:hover { color:var(--text) }
+
+@keyframes fadeUp {
+ from { opacity:0; transform:translateY(16px) }
+ to { opacity:1; transform:translateY(0) }
+}
+
+@media(max-width:700px) {
+ .admin-wrap { padding:20px 12px }
+ .admin-header { flex-direction:column; align-items:stretch }
+ .header-right { justify-content:space-between }
+ .tbl { font-size:12px }
+ .tbl th, .tbl td { padding:10px 12px }
+ .stat-card { padding:14px 14px }
+ .stat-val { font-size:22px }
+}
+@media(max-width:480px) {
+ .login-card { padding:28px 20px }
+ .modal-card { padding:20px }
+}
diff --git a/web/css/home.css b/web/css/home.css
new file mode 100644
index 0000000..dd5c239
--- /dev/null
+++ b/web/css/home.css
@@ -0,0 +1,165 @@
+@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
+
+:root {
+ --bg: #080c18;
+ --surface: rgba(255,255,255,0.03);
+ --border: rgba(255,255,255,0.06);
+ --border-hover: rgba(255,255,255,0.12);
+ --text: #fff;
+ --text-sec: rgba(255,255,255,0.5);
+ --text-ter: rgba(255,255,255,0.3);
+ --accent: #6C63FF;
+ --green: #10b981;
+ --amber: #f59e0b;
+ --rose: #f43f5e;
+ --card-rad: 16px;
+}
+
+* { margin:0; padding:0; box-sizing:border-box }
+html { scroll-behavior:smooth }
+body {
+ font-family:'Plus Jakarta Sans',sans-serif;
+ background:var(--bg);
+ color:var(--text);
+ min-height:100vh;
+ overflow-x:hidden;
+}
+body::before {
+ content:'';
+ position:fixed;inset:0;
+ background:
+ radial-gradient(ellipse 80% 50% at 10% 0%, rgba(108,99,255,0.08) 0%, transparent 70%),
+ radial-gradient(ellipse 60% 40% at 90% 100%, rgba(16,185,129,0.06) 0%, transparent 70%);
+ pointer-events:none;
+ z-index:0;
+}
+.container { position:relative; z-index:1; max-width:720px; margin:0 auto; padding:48px 20px 32px }
+
+.section { animation:fadeUp .6s ease-out var(--d,0s) both; margin-bottom:40px }
+
+/* Header */
+.header { text-align:center; --d:0.1s }
+.header h1 {
+ font-size:34px; font-weight:800; letter-spacing:-.03em;
+ background:linear-gradient(135deg,#fff 30%,rgba(255,255,255,.6));
+ -webkit-background-clip:text; -webkit-text-fill-color:transparent;
+ background-clip:text;
+}
+.header p { color:var(--text-sec); font-size:15px; margin-top:8px; line-height:1.6 }
+.header .sub { color:var(--text-ter); font-size:13px; margin-top:4px }
+
+/* Why us */
+.why { --d:0.2s }
+.why h2 { font-size:18px; font-weight:600; margin-bottom:14px }
+.why-grid { display:grid; grid-template-columns:1fr 1fr; gap:10px }
+.w-card {
+ background:var(--surface); border:1px solid var(--border);
+ border-radius:12px; padding:14px;
+ backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
+ transition:border-color .3s, transform .3s;
+ animation:fadeUp .5s ease-out calc(var(--d,0s) + var(--i,0)*0.06s) both;
+}
+.w-card:hover { border-color:var(--border-hover); transform:translateY(-2px) }
+.w-card .e { font-size:20px; margin-bottom:6px }
+.w-card h4 { font-size:13px; font-weight:600; margin-bottom:3px }
+.w-card p { font-size:11px; color:var(--text-sec); line-height:1.5 }
+
+/* Servers */
+.srv-section { --d:0.35s }
+.srv-section h2 { font-size:18px; font-weight:600; margin-bottom:14px }
+.servers { display:grid; gap:14px }
+
+/* Server card */
+.s-card {
+ background:var(--surface); border:1px solid var(--border);
+ border-left:3px solid var(--hl, transparent);
+ border-radius:var(--card-rad); padding:16px 18px;
+ backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
+ transition:border-color .4s, transform .4s, box-shadow .4s;
+ animation:fadeUp .6s ease-out var(--d,0s) both;
+}
+.s-card:hover { border-color:var(--border-hover); transform:translateY(-3px); box-shadow:0 12px 48px rgba(108,99,255,0.06) }
+.s-head { font-size:14px; font-weight:600; display:flex; align-items:center; gap:6px; flex-wrap:wrap }
+.s-head .flag { font-size:20px; line-height:1 }
+.tier { font-size:10px; font-weight:600; padding:2px 8px; border-radius:20px; letter-spacing:.02em }
+.tier.both { background:rgba(108,99,255,0.15); color:var(--accent) }
+.tier.free { background:rgba(16,185,129,0.12); color:var(--green) }
+.tier.prem { background:rgba(245,158,11,0.12); color:var(--amber) }
+.h-dot { width:8px; height:8px; border-radius:50%; display:inline-block; flex-shrink:0 }
+.h-lbl { font-size:11px; font-weight:600; font-family:'JetBrains Mono',monospace; white-space:nowrap }
+.s-stats { font-size:11px; color:var(--text-ter); font-family:'JetBrains Mono',monospace; margin-top:8px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
+.s-stats.off { color:var(--rose) }
+
+/* Inbounds inside server card */
+.ib-list { margin-top:10px; display:flex; flex-wrap:wrap; gap:6px }
+.ib-card {
+ display:inline-flex; align-items:center; gap:5px;
+ background:rgba(255,255,255,0.04);
+ border:1px solid rgba(255,255,255,0.06);
+ border-radius:8px; padding:5px 10px;
+ font-size:11px; font-family:'JetBrains Mono',monospace;
+ color:var(--text-sec);
+ transition:border-color .2s;
+}
+.ib-card:hover { border-color:var(--border-hover) }
+.ib-dot { width:5px; height:5px; border-radius:50%; background:var(--green); flex-shrink:0 }
+.ib-name { font-weight:500 }
+.ib-type { color:var(--text-ter); font-size:10px }
+
+/* RU server special note */
+.ru-note {
+ margin-top:10px; padding:8px 12px;
+ background:rgba(245,158,11,0.08);
+ border:1px solid rgba(245,158,11,0.15);
+ border-radius:8px;
+ font-size:11px; color:var(--amber);
+ display:flex; align-items:center; gap:6px;
+}
+
+/* Tech info */
+.tech { --d:0.5s }
+.tech h2 { font-size:18px; font-weight:600; margin-bottom:14px }
+.tech-grid {
+ display:grid; grid-template-columns:1fr 1fr;
+ background:var(--surface); border:1px solid var(--border);
+ border-radius:var(--card-rad); padding:20px;
+ backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
+ transition:border-color .3s;
+}
+.tech-grid:hover { border-color:var(--border-hover) }
+.t-item { font-size:12px; color:var(--text-sec); padding:8px 0; display:flex; align-items:center; gap:8px; border-bottom:1px solid var(--border) }
+.t-item:last-child { border-bottom:none }
+.t-item .t-dot { width:4px; height:4px; border-radius:2px; background:var(--accent); flex-shrink:0 }
+
+/* Buttons */
+.btns { display:flex; justify-content:center; gap:10px; margin-top:12px; animation:fadeUp .6s .55s ease-out both }
+.btn {
+ display:inline-flex; align-items:center; gap:6px;
+ padding:12px 28px; border-radius:12px;
+ font-size:14px; font-weight:600; text-decoration:none;
+ transition:transform .3s, box-shadow .3s;
+ cursor:pointer;
+}
+.btn:hover { transform:translateY(-2px); box-shadow:0 8px 32px rgba(108,99,255,0.2) }
+.btn.da { background:linear-gradient(135deg,#f43f5e,#e11d48); color:#fff }
+
+.footer { text-align:center; margin-top:40px; color:var(--text-ter); font-size:12px; animation:fadeUp .6s .6s ease-out both }
+
+@keyframes fadeUp {
+ from { opacity:0; transform:translateY(16px) }
+ to { opacity:1; transform:translateY(0) }
+}
+
+@media(max-width:520px) {
+ .container { padding:24px 12px }
+ .header h1 { font-size:24px }
+ .why-grid { grid-template-columns:1fr }
+ .s-card { padding:12px 14px }
+ .s-head { font-size:13px; gap:4px }
+ .h-lbl { font-size:10px }
+ .s-stats { font-size:10px }
+ .ib-card { font-size:10px; padding:4px 8px }
+ .ru-note { font-size:10px; padding:6px 10px }
+ .tech-grid { grid-template-columns:1fr }
+ .btns { flex-direction:column; align-items:center }
+}
diff --git a/web/css/sub.css b/web/css/sub.css
new file mode 100644
index 0000000..23ce1e5
--- /dev/null
+++ b/web/css/sub.css
@@ -0,0 +1,136 @@
+@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
+
+:root {
+ --bg: #080c18;
+ --surface: rgba(255,255,255,0.03);
+ --border: rgba(255,255,255,0.06);
+ --border-hover: rgba(255,255,255,0.12);
+ --text: #fff;
+ --text-sec: rgba(255,255,255,0.5);
+ --text-ter: rgba(255,255,255,0.3);
+ --accent: #6C63FF;
+ --green: #10b981;
+ --amber: #f59e0b;
+ --rose: #f43f5e;
+ --card-rad: 16px;
+}
+
+* { margin:0; padding:0; box-sizing:border-box }
+body {
+ font-family:'Plus Jakarta Sans',sans-serif;
+ background:var(--bg);
+ color:var(--text);
+ min-height:100vh;
+ overflow-x:hidden;
+}
+
+.sub-bg {
+ position:fixed;inset:0;
+ background:
+ radial-gradient(ellipse 70% 50% at 30% 0%, rgba(108,99,255,0.08) 0%, transparent 70%),
+ radial-gradient(ellipse 50% 40% at 70% 100%, rgba(16,185,129,0.06) 0%, transparent 70%);
+ pointer-events:none;
+ z-index:0;
+}
+
+.sub-wrap {
+ position:relative;z-index:1;
+ max-width:600px;margin:0 auto;padding:40px 20px;
+}
+
+.sub-card {
+ background:var(--surface); border:1px solid var(--border);
+ border-radius:var(--card-rad); padding:28px 24px;
+ margin-bottom:16px;
+ backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px);
+ animation:fadeUp .5s ease-out var(--d,0s) both;
+}
+
+.sub-top { text-align:center; --d:0s }
+.sub-top h1 {
+ font-size:24px; font-weight:800; letter-spacing:-.03em;
+ background:linear-gradient(135deg,#fff 30%,rgba(255,255,255,.6));
+ -webkit-background-clip:text; -webkit-text-fill-color:transparent;
+ background-clip:text;
+ margin:12px 0 8px;
+}
+
+.logo-img { width:72px; height:72px; border-radius:14px }
+.logo-emoji { font-size:48px }
+
+.tier-badge {
+ display:inline-block;
+ padding:4px 14px; border-radius:20px;
+ font-size:12px; font-weight:600; letter-spacing:.02em;
+ text-transform:uppercase;
+}
+
+.announcement {
+ background:rgba(108,99,255,0.08);
+ border:1px solid rgba(108,99,255,0.15);
+ border-left:3px solid var(--accent);
+ border-radius:12px; padding:14px 16px;
+ margin-bottom:16px; font-size:14px;
+ animation:fadeUp .5s .05s ease-out both;
+}
+
+.info-block { font-size:14px; line-height:1.8; margin-bottom:16px }
+.info-block p { display:flex; align-items:center; gap:8px; color:var(--text-sec) }
+.traffic-server { font-size:12px; margin-top:8px; padding-top:8px; border-top:1px solid var(--border) }
+.traffic-server .server-name { color:var(--text-ter); font-weight:500 }
+.traffic-server .traffic-values { color:var(--green); font-family:'JetBrains Mono',monospace }
+
+.srv-tags { display:flex; flex-wrap:wrap; gap:8px }
+.srv-tag {
+ display:inline-flex; align-items:center; gap:4px;
+ background:rgba(255,255,255,0.04);
+ border:1px solid var(--border);
+ border-radius:20px; padding:5px 12px;
+ font-size:12px; font-weight:500; color:var(--text-sec);
+}
+
+.sub-qr { text-align:center }
+.qr-box {
+ display:inline-block;
+ background:rgba(255,255,255,0.04);
+ border-radius:14px; padding:8px; margin-bottom:14px;
+}
+.qr-box img {
+ display:block;
+ border-radius:10px; width:200px; height:200px;
+}
+.qr-url {
+ background:rgba(255,255,255,0.03);
+ border:1px solid var(--border);
+ border-radius:10px; padding:10px 14px;
+ word-break:break-all; font-size:11px; color:var(--text-ter);
+ font-family:'JetBrains Mono',monospace; margin-bottom:16px;
+}
+.qr-actions { display:flex; flex-direction:column; gap:10px; align-items:center }
+
+.btn {
+ display:inline-flex; align-items:center; gap:6px;
+ padding:12px 28px; border:none; border-radius:12px;
+ font-size:14px; font-weight:600; font-family:'Plus Jakarta Sans',sans-serif;
+ cursor:pointer; text-decoration:none;
+ transition:transform .3s, box-shadow .3s;
+}
+.btn:hover { transform:translateY(-2px) }
+.btn-primary { background:var(--accent); color:#fff }
+.btn-primary:hover { box-shadow:0 8px 32px rgba(108,99,255,0.25) }
+.btn-support { background:var(--rose); color:#fff }
+.btn-support:hover { box-shadow:0 8px 32px rgba(244,63,94,0.25) }
+
+.sub-footer { text-align:center; padding:20px; color:var(--text-ter); font-size:12px; --d:0.25s }
+
+@keyframes fadeUp {
+ from { opacity:0; transform:translateY(16px) }
+ to { opacity:1; transform:translateY(0) }
+}
+
+@media(max-width:520px) {
+ .sub-wrap { padding:20px 12px }
+ .sub-card { padding:20px 16px }
+ .sub-top h1 { font-size:20px }
+ .qr-box img { width:160px; height:160px }
+}
diff --git a/web/home.html b/web/home.html
new file mode 100644
index 0000000..ef40928
--- /dev/null
+++ b/web/home.html
@@ -0,0 +1,51 @@
+
+
+
+
+
{%title%}
+
+
+
+
+
+
+
+
+
Почему {%title%}
+
+
🚀
Скорость
Каналы 1–10 Gbit/s, собственные серверы, без посредников
+
🔒
Приватность
Zero-log политика — трафик не хранится и не анализируется
+
🌎
Покрытие
Серверы в Германии, Швеции и России — выбирай ближайший
+
💎
Тарифы
Бесплатный доступ к free-серверам или Premium от 150 ₽/мес
+
+
+
+
+
🖥 Состояние серверов
+
{%srv_cards%}
+
+
+
+
🔧 Технические детали
+
+
VLESS + XTLS Vision / Reality
+
TCP, WebSocket, gRPC
+
DDoS-защита на всех серверах
+
Zero-log политика
+
Поддержка Happ (iOS / Android / Desktop)
+
Автообновление подписки каждые 12ч
+
+
+
+
+
+
+
+
+
diff --git a/web/js/admin.js b/web/js/admin.js
new file mode 100644
index 0000000..183533d
--- /dev/null
+++ b/web/js/admin.js
@@ -0,0 +1,114 @@
+function openModal() { document.getElementById('userModal').classList.add('active'); }
+function closeModal() { document.getElementById('userModal').classList.remove('active'); }
+
+function toast(msg, ok) {
+ var t = document.createElement('div');
+ t.textContent = msg;
+ Object.assign(t.style, {
+ position:'fixed', bottom:'24px', left:'50%', transform:'translateX(-50%)',
+ padding:'12px 24px', borderRadius:'12px', fontSize:'13px', fontWeight:'600',
+ background: ok ? 'rgba(16,185,129,0.15)' : 'rgba(244,63,94,0.15)',
+ border: ok ? '1px solid rgba(16,185,129,0.3)' : '1px solid rgba(244,63,94,0.3)',
+ color: ok ? 'var(--green)' : 'var(--rose)',
+ backdropFilter:'blur(12px)', zIndex:'9999',
+ animation:'fadeUp .3s ease-out both', fontFamily:'"Plus Jakarta Sans",sans-serif'
+ });
+ document.body.appendChild(t);
+ setTimeout(function() { t.style.opacity='0'; t.style.transition='opacity .4s'; setTimeout(function(){t.remove()},400) }, 3000);
+}
+
+document.addEventListener('DOMContentLoaded', function() {
+ var form = document.getElementById('userForm');
+ if (form) {
+ form.addEventListener('submit', async function(e) {
+ e.preventDefault();
+ var formData = new FormData(e.target);
+ var username = formData.get('username');
+ var btn = e.target.querySelector('button[type="submit"]');
+ var orig = btn.textContent;
+ btn.disabled = true;
+ btn.textContent = '...';
+
+ var response = await fetch('/admin/api/users', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({
+ username: username,
+ traffic_limit_gb: parseInt(formData.get('traffic_limit_gb')) || 0
+ })
+ });
+
+ var result = await response.json();
+
+ if (response.ok) {
+ var msg = 'Пользователь ' + username + ' создан!';
+ toast(msg, true);
+ setTimeout(function(){ location.reload() }, 1000);
+ } else {
+ toast('Ошибка: ' + (result.error || 'unknown'), false);
+ btn.disabled = false;
+ btn.textContent = orig;
+ }
+ });
+ }
+});
+
+function editUser(id, tier, days, traffic, active) {
+ document.getElementById('editId').value = id;
+ document.getElementById('et').value = tier;
+ document.getElementById('ed').value = days;
+ document.getElementById('etr').value = traffic;
+ document.getElementById('editActive').checked = active == 1;
+ document.getElementById('editModal').classList.add('active');
+}
+
+function closeEditModal() { document.getElementById('editModal').classList.remove('active'); }
+
+async function submitEditForm() {
+ var form = document.getElementById('editForm');
+ var formData = new FormData(form);
+ var data = {
+ id: formData.get('id'),
+ tier: formData.get('tier'),
+ tariff_days_remaining: parseInt(formData.get('tariff_days_remaining')) || 0,
+ traffic_limit_gb: formData.get('traffic_limit_gb') === '' ? 0 : parseInt(formData.get('traffic_limit_gb')),
+ is_active: formData.get('is_active') === 'on'
+ };
+ var btn = form.querySelector('button');
+ var orig = btn.textContent;
+ btn.disabled = true;
+ btn.textContent = '...';
+
+ var response = await fetch('/admin/api/users/update', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify(data)
+ });
+
+ if (response.ok) {
+ toast('Сохранено!', true);
+ setTimeout(function(){ location.reload() }, 1000);
+ } else {
+ var result = await response.json();
+ toast('Ошибка: ' + (result.error || 'unknown'), false);
+ btn.disabled = false;
+ btn.textContent = orig;
+ }
+}
+
+function deleteUser(id, username) {
+ if (confirm('Удалить пользователя ' + username + '? Это удалит его со всех серверов!')) {
+ fetch('/admin/api/users/delete', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({id: id, username: username})
+ }).then(function(r) {
+ if (r.ok) {
+ toast('Удалён!', true);
+ setTimeout(function(){ location.reload() }, 1000);
+ } else {
+ toast('Ошибка удаления', false);
+ }
+ });
+ }
+}
diff --git a/web/sub.html b/web/sub.html
new file mode 100644
index 0000000..1dedf7e
--- /dev/null
+++ b/web/sub.html
@@ -0,0 +1,40 @@
+
+
+
+
+
Подписка · {%title%}
+
+
+
+
+
+
+{%logo_html%}
+
{%title%}
+{%tier_badge%}
+
+
+{%announcement_html%}
+
+
+{%info_html%}
+
{%servers_html%}
+
+
+
+
+

+
+
{%sub_url%}
+
+
+
+
+
+
+