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>
This commit is contained in:
parent
f6e4e52b65
commit
670579fccd
6 changed files with 48 additions and 3 deletions
|
|
@ -21,6 +21,15 @@ PANEL_DOMAIN=panel.example.com
|
||||||
SUB_DOMAIN=sub.example.com
|
SUB_DOMAIN=sub.example.com
|
||||||
SITE_DOMAIN=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:
|
# Reality identity for the local node (this same box). Generate with:
|
||||||
# /usr/local/bin/xray x25519
|
# /usr/local/bin/xray x25519
|
||||||
# XRAY_PUBLIC_KEY is the "Password (PublicKey)" line; keep the matching
|
# XRAY_PUBLIC_KEY is the "Password (PublicKey)" line; keep the matching
|
||||||
|
|
|
||||||
32
.github/workflows/ci.yml
vendored
32
.github/workflows/ci.yml
vendored
|
|
@ -352,3 +352,35 @@ jobs:
|
||||||
|
|
||||||
print("backup/restore correctly round-trips branding, live settings and held_at together OK")
|
print("backup/restore correctly round-trips branding, live settings and held_at together OK")
|
||||||
PYEOF
|
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
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
- **Проверка конфига Xray перед рестартом** — `xray run -test` плюс проверка что серты реально читаемы юзером, под которым крутится Xray, до того как что-то применится и уронит сервис.
|
- **Проверка конфига Xray перед рестартом** — `xray run -test` плюс проверка что серты реально читаемы юзером, под которым крутится Xray, до того как что-то применится и уронит сервис.
|
||||||
- **Drag-n-drop ноды** — порядок нод в списке настраивается мышкой, как у Remnawave.
|
- **Drag-n-drop ноды** — порядок нод в списке настраивается мышкой, как у Remnawave.
|
||||||
- **Rate-limit на вход** — по IP, отдельно на пароль и на 2FA-код.
|
- **Rate-limit на вход** — по IP, отдельно на пароль и на 2FA-код.
|
||||||
|
- **Свой путь входа** — страницу логина можно увести с дефолтного `/admin` на любой другой (`ADMIN_PATH` в `.env`), доп. слой поверх rate-limit и 2FA — у Remnawave это в списке заявленных мер безопасности, у Marzban нет вообще.
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -600,6 +600,7 @@
|
||||||
<p>Пароль админ-панели меняется командой <code>mbs pass</code> на сервере (без аргумента — сгенерит случайный). Панель физически откажется стартовать, если в <code>.env</code> стоит "change-me"/"admin"/что-то короче 8 символов — так что пропустить это не выйдет по-тихому.</p>
|
<p>Пароль админ-панели меняется командой <code>mbs pass</code> на сервере (без аргумента — сгенерит случайный). Панель физически откажется стартовать, если в <code>.env</code> стоит "change-me"/"admin"/что-то короче 8 символов — так что пропустить это не выйдет по-тихому.</p>
|
||||||
<p>Сессия логина живёт в httpOnly-куке, опционально поверх пароля — 2FA (TOTP). На <code>/admin/api/login</code> и <code>/admin/api/login/totp</code> висит rate-limit (10 попыток за 15 минут на пароль, 10 за 5 минут на код — с одного IP). SSH-доступ на сервер — сам по себе, панель на него не влияет; отдельно стоит подумать про отключение root-логина по паролю в пользу ключей, если этого ещё не сделано.</p>
|
<p>Сессия логина живёт в httpOnly-куке, опционально поверх пароля — 2FA (TOTP). На <code>/admin/api/login</code> и <code>/admin/api/login/totp</code> висит rate-limit (10 попыток за 15 минут на пароль, 10 за 5 минут на код — с одного IP). SSH-доступ на сервер — сам по себе, панель на него не влияет; отдельно стоит подумать про отключение root-логина по паролю в пользу ключей, если этого ещё не сделано.</p>
|
||||||
<p>Логинов может быть несколько (Настройки → Админы) — у каждого свой пароль и своя 2FA, нельзя удалить последнего оставшегося админа или себя самого, пока залогинен под этим аккаунтом.</p>
|
<p>Логинов может быть несколько (Настройки → Админы) — у каждого свой пароль и своя 2FA, нельзя удалить последнего оставшегося админа или себя самого, пока залогинен под этим аккаунтом.</p>
|
||||||
|
<p>Страницу входа можно увести с дефолтного <code>/admin</code> на свой путь — переменная <code>ADMIN_PATH</code> в <code>.env</code> на сервере (не через UI — это единственная настройка, которая намеренно не в панели, чтобы нельзя было опечататься и остаться без доступа без SSH). Требует <code>mbs restart</code>. Это доп. слой поверх rate-limit и 2FA, не замена — сам <code>/admin/api/*</code> не двигается, он и так защищён логином.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="doc-block">
|
<div class="doc-block">
|
||||||
|
|
|
||||||
7
api.py
7
api.py
|
|
@ -21,7 +21,7 @@ import settings
|
||||||
import totp
|
import totp
|
||||||
import webhooks
|
import webhooks
|
||||||
import xray_manager
|
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}$")
|
HWID_RE = re.compile(r"^[a-zA-Z0-9=-]{10,64}$")
|
||||||
ENV_PATH = os.path.join(BASE_DIR, ".env")
|
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)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def root(request: Request):
|
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 FileResponse(ADMIN_HTML_PATH, headers=_NO_CACHE)
|
||||||
return legal.render_site_page("index.html")
|
return legal.render_site_page("index.html")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin")
|
@app.get(f"/{ADMIN_PATH}")
|
||||||
def admin_page():
|
def admin_page():
|
||||||
return FileResponse(ADMIN_HTML_PATH, headers=_NO_CACHE)
|
return FileResponse(ADMIN_HTML_PATH, headers=_NO_CACHE)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ PANEL_DOMAIN = env("PANEL_DOMAIN", required=True)
|
||||||
SUB_DOMAIN = env("SUB_DOMAIN", required=True)
|
SUB_DOMAIN = env("SUB_DOMAIN", required=True)
|
||||||
SITE_DOMAIN = env("SITE_DOMAIN", required=True)
|
SITE_DOMAIN = env("SITE_DOMAIN", required=True)
|
||||||
BRAND_NAME = env("BRAND_NAME", "MBS Panel")
|
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")
|
DB_PATH = os.path.join(BASE_DIR, "mbs.db")
|
||||||
XRAY_CONFIG_PATH = "/usr/local/etc/xray/config.json"
|
XRAY_CONFIG_PATH = "/usr/local/etc/xray/config.json"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue