From 670579fccd289216281cab63d0bd3667cf4d754e Mon Sep 17 00:00:00 2001 From: savsis Date: Mon, 14 Sep 2026 01:58:45 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20optional=20custom=20admin=20login=20pat?= =?UTF-8?q?h=20=E2=80=94=20matches=20a=20Remnawave-listed=20security=20mea?= =?UTF-8?q?sure=20Marzban=20doesn't=20have?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 9 +++++++++ .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++++ README.md | 1 + admin.html | 1 + api.py | 7 ++++--- config.py | 1 + 6 files changed, 48 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 1e90b35..755d1d3 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,15 @@ PANEL_DOMAIN=panel.example.com SUB_DOMAIN=sub.example.com SITE_DOMAIN=example.com +# Optional: move the admin login off the well-known /admin path (e.g. to a +# random string) so it doesn't show up to anyone scanning for /admin, +# /login etc. Leave unset for the default. This is on top of the existing +# rate-limiting and 2FA, not instead of them. Requires `mbs restart` to +# take effect (it's a route, not a setting the running process can pick up +# live) — write it down somewhere before you restart, there's no UI for +# this on purpose, only .env + SSH can get you back in if you forget it. +ADMIN_PATH=admin + # Reality identity for the local node (this same box). Generate with: # /usr/local/bin/xray x25519 # XRAY_PUBLIC_KEY is the "Password (PublicKey)" line; keep the matching diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d2446e..ef440a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -352,3 +352,35 @@ jobs: print("backup/restore correctly round-trips branding, live settings and held_at together OK") PYEOF + + - name: Smoke test custom ADMIN_PATH actually moves the login page, not just adds a copy + env: + BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + BOT_USERNAME: "x" + ADMIN_IDS: "1" + ADMIN_PANEL_PASSWORD: "ci-test-password-not-real" + PANEL_DOMAIN: "panel.test" + SUB_DOMAIN: "sub.test" + SITE_DOMAIN: "test" + XRAY_PUBLIC_KEY: "x" + XRAY_SHORT_ID_TCP: "x" + XRAY_SHORT_ID_GRPC: "x" + XRAY_SHORT_ID_XHTTP: "x" + ADMIN_PATH: "xyz123secret" + run: | + python - << 'PYEOF' + import api + + paths = {r.path for r in api.app.routes} + assert "/xyz123secret" in paths, "custom ADMIN_PATH must be registered as a route" + assert "/admin" not in paths, "the default /admin page route must be GONE once a custom path is set, not just supplemented" + assert "/admin/api/login" in paths, "the API namespace must stay fixed regardless of ADMIN_PATH" + + fake_request = type("FakeRequest", (), {"headers": {"host": "panel.test"}})() + root_response = api.root(fake_request) + assert isinstance(root_response, str), \ + f"root() on PANEL_DOMAIN must return the rendered site page (a string), not admin.html, once ADMIN_PATH is customized — got {type(root_response)}" + assert "admin.html" not in root_response + + print("custom ADMIN_PATH: old /admin route gone, new path registered, root() no longer leaks the panel OK") + PYEOF diff --git a/README.md b/README.md index 8c04012..5d4bc40 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ - **Проверка конфига Xray перед рестартом** — `xray run -test` плюс проверка что серты реально читаемы юзером, под которым крутится Xray, до того как что-то применится и уронит сервис. - **Drag-n-drop ноды** — порядок нод в списке настраивается мышкой, как у Remnawave. - **Rate-limit на вход** — по IP, отдельно на пароль и на 2FA-код. +- **Свой путь входа** — страницу логина можно увести с дефолтного `/admin` на любой другой (`ADMIN_PATH` в `.env`), доп. слой поверх rate-limit и 2FA — у Remnawave это в списке заявленных мер безопасности, у Marzban нет вообще. ## Архитектура diff --git a/admin.html b/admin.html index a426e69..abc4080 100644 --- a/admin.html +++ b/admin.html @@ -600,6 +600,7 @@

Пароль админ-панели меняется командой mbs pass на сервере (без аргумента — сгенерит случайный). Панель физически откажется стартовать, если в .env стоит "change-me"/"admin"/что-то короче 8 символов — так что пропустить это не выйдет по-тихому.

Сессия логина живёт в httpOnly-куке, опционально поверх пароля — 2FA (TOTP). На /admin/api/login и /admin/api/login/totp висит rate-limit (10 попыток за 15 минут на пароль, 10 за 5 минут на код — с одного IP). SSH-доступ на сервер — сам по себе, панель на него не влияет; отдельно стоит подумать про отключение root-логина по паролю в пользу ключей, если этого ещё не сделано.

Логинов может быть несколько (Настройки → Админы) — у каждого свой пароль и своя 2FA, нельзя удалить последнего оставшегося админа или себя самого, пока залогинен под этим аккаунтом.

+

Страницу входа можно увести с дефолтного /admin на свой путь — переменная ADMIN_PATH в .env на сервере (не через UI — это единственная настройка, которая намеренно не в панели, чтобы нельзя было опечататься и остаться без доступа без SSH). Требует mbs restart. Это доп. слой поверх rate-limit и 2FA, не замена — сам /admin/api/* не двигается, он и так защищён логином.

diff --git a/api.py b/api.py index 55b3b69..1b64994 100644 --- a/api.py +++ b/api.py @@ -21,7 +21,7 @@ import settings import totp import webhooks import xray_manager -from config import SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN, BASE_DIR +from config import SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN, ADMIN_PATH, BASE_DIR HWID_RE = re.compile(r"^[a-zA-Z0-9=-]{10,64}$") ENV_PATH = os.path.join(BASE_DIR, ".env") @@ -1193,12 +1193,13 @@ _NO_CACHE = {"Cache-Control": "no-cache, must-revalidate"} @app.get("/", response_class=HTMLResponse) def root(request: Request): - if request.headers.get("host", "").split(":")[0] == PANEL_DOMAIN: + host = request.headers.get("host", "").split(":")[0] + if host == PANEL_DOMAIN and ADMIN_PATH == "admin": return FileResponse(ADMIN_HTML_PATH, headers=_NO_CACHE) return legal.render_site_page("index.html") -@app.get("/admin") +@app.get(f"/{ADMIN_PATH}") def admin_page(): return FileResponse(ADMIN_HTML_PATH, headers=_NO_CACHE) diff --git a/config.py b/config.py index 2731cea..7f67c5c 100644 --- a/config.py +++ b/config.py @@ -39,6 +39,7 @@ 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"