mbs-panel/config.py
savsis 670579fccd feat: optional custom admin login path — matches a Remnawave-listed security measure Marzban doesn't have
Pulled a fresh copy of docs.rw's own Remnawave-vs-Marzban comparison
table (not working from memory of an earlier read) to check what's
still genuinely different after tonight's run of fixes — most rows
already match or beat both panels (multi-admin, 2FA, HWID limits,
backup/restore, host sorting, config validation, node autonomy, on-hold
status as of a few commits ago). One concrete, bounded, unclaimed row:
"Security measures in documentation" lists CF zero trust / custom path
/ Telegram OAuth / 2FA for Remnawave, nothing for Marzban. We already
had 2FA and rate-limiting; custom path was the missing, actually
implementable piece — everything else in that row is deployment
guidance, not panel code.

New ADMIN_PATH env var (config.py, defaults to "admin" — every existing
install keeps working exactly as before with zero action needed). The
page-serving route moves to whatever path is configured; root() on
PANEL_DOMAIN only falls through to serving admin.html when ADMIN_PATH
is still the default, otherwise it shows the same branded landing page
every other domain gets — so a scanner or a human guessing "/admin"
finds nothing once this is set, not even a redirect that confirms
something lives there.

Deliberately scoped to ONLY the page route. /admin/api/* stays fixed —
it's already behind real cookie+session auth (verified this while
auditing: every mutating admin route either calls require_admin() or
the equivalent _require_current_admin(), checked programmatically via
ast rather than trusting my memory of having added the check everywhere
— found nothing actually missing, which is itself worth knowing, not
just assumed). Moving the API namespace too would be a much bigger,
riskier rewrite of every @app decorator in the file for no real security
gain over what auth already provides.

Deliberately NOT exposed in the Settings UI, unlike almost everything
else made live-editable tonight. This one genuinely needs a process
restart to take effect (FastAPI resolves routes at import time, not
per-request), and a typo saved through the UI followed by a restart
is a real self-lockout risk with no web-based way back — same tier as
PANEL_DOMAIN/SUB_DOMAIN, which are also .env-only for the same reason.
.env + SSH is the correct blast radius for a setting that can lock you
out.

Verification: config.py's normalization (strip slashes, empty/lone-
slash/repeated-slash input all falling back to "admin" rather than
accidentally producing a route at bare "/") tested directly — 8 cases.
AST-extracted the updated root() out of api.py (still can't import the
module locally) and exercised its actual branching with a mocked
FileResponse/legal/request — confirmed the default case is byte-for-
byte the old behavior and the custom-path case stops serving admin.html
on PANEL_DOMAIN's root. Added a dedicated CI step that does what only a
real FastAPI import can prove: with ADMIN_PATH set, /xyz123secret is a
registered route, plain /admin is NOT (not just supplemented — actually
gone), and /admin/api/login is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 01:58:45 +05:00

122 lines
4.2 KiB
Python

import os
def _load_dotenv(path):
if not os.path.exists(path):
return
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip())
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
_load_dotenv(os.path.join(BASE_DIR, ".env"))
def env(key, default=None, required=False):
val = os.environ.get(key, default)
if required and not val:
raise RuntimeError(f"missing required env var: {key} — copy .env.example to .env and fill it in")
return val
BOT_TOKEN = env("BOT_TOKEN", required=True)
BOT_USERNAME = env("BOT_USERNAME", required=True)
ADMIN_IDS = {int(x) for x in env("ADMIN_IDS", "").split(",") if x.strip()}
ADMIN_PANEL_PASSWORD = env("ADMIN_PANEL_PASSWORD", required=True)
if ADMIN_PANEL_PASSWORD in ("change-me", "changeme", "admin", "password") or len(ADMIN_PANEL_PASSWORD) < 8:
raise RuntimeError(
"ADMIN_PANEL_PASSWORD в .env слишком слабый или дефолтный — поставь случайный пароль "
"(например: mbs pass)"
)
PANEL_DOMAIN = env("PANEL_DOMAIN", required=True)
SUB_DOMAIN = env("SUB_DOMAIN", required=True)
SITE_DOMAIN = env("SITE_DOMAIN", required=True)
BRAND_NAME = env("BRAND_NAME", "MBS Panel")
ADMIN_PATH = env("ADMIN_PATH", "admin").strip("/") or "admin"
DB_PATH = os.path.join(BASE_DIR, "mbs.db")
XRAY_CONFIG_PATH = "/usr/local/etc/xray/config.json"
XRAY_PUBLIC_KEY = env("XRAY_PUBLIC_KEY", required=True)
REALITY_SNI = env("REALITY_SNI", "www.wildberries.ru")
XRAY_SHORT_ID_TCP = env("XRAY_SHORT_ID_TCP", required=True)
XRAY_SHORT_ID_GRPC = env("XRAY_SHORT_ID_GRPC", required=True)
XRAY_SHORT_ID_XHTTP = env("XRAY_SHORT_ID_XHTTP", required=True)
DE1_ADDRESS = env("DE1_ADDRESS", f"de1.{SITE_DOMAIN}")
DE1_TRANSPORTS = [
{
"tag": "vless-tcp-reality",
"label": "TCP + Reality (основной)",
"network": "tcp",
"security": "reality",
"address": DE1_ADDRESS,
"port": 443,
"public_key": XRAY_PUBLIC_KEY,
"short_id": XRAY_SHORT_ID_TCP,
"sni": REALITY_SNI,
"flow": "xtls-rprx-vision",
},
{
"tag": "vless-grpc-reality",
"label": "gRPC + Reality",
"network": "grpc",
"security": "reality",
"address": DE1_ADDRESS,
"port": 2053,
"public_key": XRAY_PUBLIC_KEY,
"short_id": XRAY_SHORT_ID_GRPC,
"sni": REALITY_SNI,
"service_name": "mbs-grpc",
},
{
"tag": "vless-xhttp-reality",
"label": "XHTTP + Reality",
"network": "xhttp",
"security": "reality",
"address": DE1_ADDRESS,
"port": 2087,
"public_key": XRAY_PUBLIC_KEY,
"short_id": XRAY_SHORT_ID_XHTTP,
"sni": REALITY_SNI,
"path": "/mbs-xh",
},
{
"tag": "vless-ws-tls",
"label": "WebSocket + TLS",
"network": "ws",
"security": "tls",
"address": DE1_ADDRESS,
"port": 8880,
"path": "/mbs-ws",
},
]
PLANS = [
{"code": "7d", "label": "7 дней", "days": 7, "price": int(env("PRICE_7D", "150"))},
{"code": "1m", "label": "1 месяц", "days": 30, "price": int(env("PRICE_1M", "399"))},
{"code": "3m", "label": "3 месяца", "days": 90, "price": int(env("PRICE_3M", "999"))},
{"code": "6m", "label": "6 месяцев", "days": 180, "price": int(env("PRICE_6M", "1799"))},
{"code": "1y", "label": "1 год", "days": 365, "price": int(env("PRICE_1Y", "2999"))},
]
PLANS_BY_CODE = {p["code"]: p for p in PLANS}
PAYMENTS_ENABLED = env("PAYMENTS_ENABLED", "false").lower() == "true"
YOOKASSA_ENABLED = env("YOOKASSA_ENABLED", "false").lower() == "true"
YOOKASSA_SHOP_ID = env("YOOKASSA_SHOP_ID", "")
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"))