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>
386 lines
16 KiB
YAML
386 lines
16 KiB
YAML
name: CI
|
|
|
|
on:
|
|
push:
|
|
pull_request:
|
|
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: actions/setup-python@v5
|
|
with:
|
|
python-version: "3.12"
|
|
|
|
- name: Install dependencies
|
|
run: pip install -r requirements.txt
|
|
|
|
- name: Compile check all Python files
|
|
run: python -m compileall -q .
|
|
|
|
- name: Shell syntax check
|
|
run: |
|
|
bash -n install.sh
|
|
bash -n mbs
|
|
|
|
- name: Smoke test install-script rendering
|
|
env:
|
|
BOT_TOKEN: "x"
|
|
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"
|
|
run: |
|
|
python - << 'PYEOF'
|
|
import json
|
|
import nodeprov
|
|
|
|
transports = nodeprov.build_transports("fi2.example.com", 443, "www.microsoft.com", "PUBKEY", include_ws=True)
|
|
node = {
|
|
"provision_token": "TESTTOKEN",
|
|
"address": "fi2.example.com",
|
|
"sni": "www.microsoft.com",
|
|
"private_key": "PRIVKEY",
|
|
"transports_json": json.dumps(transports),
|
|
"hysteria_enabled": 1,
|
|
"hysteria_port": 443,
|
|
"hysteria_password": "hypass",
|
|
"hysteria_obfs_password": "obfspass",
|
|
}
|
|
script = nodeprov.render_install_script(node)
|
|
assert "PRIVKEY" in script
|
|
assert len(script) > 500
|
|
print("node install script rendered OK,", len(script), "bytes")
|
|
PYEOF
|
|
|
|
- name: Smoke test app wiring + payments + HWID logic
|
|
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"
|
|
PAYMENTS_ENABLED: "true"
|
|
YOOKASSA_ENABLED: "true"
|
|
YOOKASSA_SHOP_ID: "123"
|
|
YOOKASSA_SECRET_KEY: "xxx"
|
|
PLATEGA_ENABLED: "true"
|
|
PLATEGA_MERCHANT_ID: "abc"
|
|
PLATEGA_SECRET: "yyy"
|
|
HWID_LIMIT_ENABLED: "true"
|
|
run: |
|
|
python - << 'PYEOF'
|
|
import hashlib
|
|
import hmac
|
|
|
|
import api
|
|
import bot
|
|
import payments
|
|
import db
|
|
|
|
assert set(payments.available_providers()) == {"yookassa", "platega"}
|
|
|
|
good_sig = hmac.new(b"yyy", b'{"a":1}', hashlib.sha256).hexdigest()
|
|
assert payments.verify_platega_signature(b'{"a":1}', good_sig)
|
|
assert not payments.verify_platega_signature(b'{"a":1}', "wrong")
|
|
|
|
db.init_db()
|
|
db.create_payment("pid1", 1, "de1", "1m", "yookassa", 399)
|
|
assert db.mark_payment_paid("pid1")["status"] == "paid"
|
|
assert db.mark_payment_paid("pid1") is None
|
|
|
|
db.get_or_create_user(1, "tester")
|
|
db.add_device(1, "hwid-aaaaaaaaaa", "android", "Pixel", "ua")
|
|
assert db.count_devices(1) == 1
|
|
assert db.get_device(1, "hwid-aaaaaaaaaa") is not None
|
|
|
|
print("app wiring + payments + HWID logic OK")
|
|
|
|
import legal
|
|
import settings
|
|
|
|
assert settings.get_brand_name() == "MBS Panel"
|
|
legal.update_env_var("BRAND_NAME", "CI Test Brand")
|
|
assert settings.get_brand_name() == "CI Test Brand"
|
|
|
|
index_html = legal.render_site_page("index.html")
|
|
assert "CI Test Brand" in index_html
|
|
assert "MBS Panel" not in index_html
|
|
assert "example.com" not in index_html
|
|
assert "YourBot_robot" not in index_html
|
|
assert "{{" not in index_html and "}}" not in index_html
|
|
|
|
cabinet_html = legal.render_site_page("cabinet.html")
|
|
assert "CI Test Brand" in cabinet_html
|
|
assert "{{" not in cabinet_html and "}}" not in cabinet_html
|
|
|
|
fake_request = type("FakeRequest", (), {"headers": {}})()
|
|
root_resp = api.root(fake_request)
|
|
assert "CI Test Brand" in root_resp
|
|
|
|
plans_resp = api.public_plans()
|
|
assert plans_resp["plans"][0]["code"] == "7d"
|
|
|
|
branding_resp = api.public_branding()
|
|
assert branding_resp["brand_name"] == "CI Test Brand"
|
|
|
|
print("branding: site templates + public routes render live, no restart OK")
|
|
PYEOF
|
|
|
|
- name: Smoke test TOTP, backup/restore, node reorder, multi-admin, rate-limit
|
|
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"
|
|
run: |
|
|
python - << 'PYEOF'
|
|
import base64
|
|
import db
|
|
import totp
|
|
|
|
raw_key = b"12345678901234567890"
|
|
secret = base64.b32encode(raw_key).decode("ascii").rstrip("=")
|
|
expected = ["755224","287082","359152","969429","338314","254676","287922","162583","399871","520489"]
|
|
for counter, exp in enumerate(expected):
|
|
assert totp._hotp(secret, counter) == exp, f"RFC 4226 vector failed at counter={counter}"
|
|
print("TOTP: all 10 RFC 4226 test vectors pass")
|
|
|
|
db.init_db()
|
|
|
|
db.create_node("n1", "Node One", "managed", "1.1.1.1", 443, "pub1", "sid1", "sni1", "xtls-rprx-vision")
|
|
db.create_node("n2", "Node Two", "managed", "2.2.2.2", 443, "pub2", "sid2", "sni2", "xtls-rprx-vision")
|
|
order = [n["code"] for n in db.list_nodes()]
|
|
assert order == ["de1", "n1", "n2"], order
|
|
db.reorder_nodes(["n2", "de1", "n1"])
|
|
assert [n["code"] for n in db.list_nodes()] == ["n2", "de1", "n1"]
|
|
try:
|
|
db.reorder_nodes(["n2", "de1"])
|
|
assert False, "should reject incomplete reorder list"
|
|
except ValueError:
|
|
pass
|
|
print("node reorder OK")
|
|
|
|
import backup
|
|
data = backup.create_backup()
|
|
db.create_node("n3", "Node Three", "managed", "3.3.3.3", 443, "pub3", "sid3", "sni3", "xtls-rprx-vision")
|
|
assert len(db.list_nodes()) == 4
|
|
backup.restore_backup(data)
|
|
assert len(db.list_nodes()) == 3, "restore should have reverted the extra node"
|
|
print("backup/restore round-trip OK")
|
|
|
|
admin = db.verify_admin_login("admin", "ci-test-password-not-real")
|
|
assert admin is not None
|
|
second = db.create_admin("second", "another-strong-password")
|
|
assert len(db.list_admins()) == 2
|
|
try:
|
|
db.delete_admin(admin["id"])
|
|
db.delete_admin(second["id"])
|
|
assert False, "should refuse deleting the last admin"
|
|
except ValueError:
|
|
pass
|
|
print("multi-admin OK")
|
|
|
|
ip = "203.0.113.9"
|
|
for _ in range(10):
|
|
db.record_login_attempt(ip, "password")
|
|
assert db.count_recent_login_attempts(ip, "password", minutes=15) >= 10
|
|
db.clear_login_attempts(ip, "password")
|
|
assert db.count_recent_login_attempts(ip, "password", minutes=15) == 0
|
|
print("rate-limit counters OK")
|
|
|
|
import datetime as dt
|
|
|
|
hold_sub = db.create_subscription(999, "n1", 30, "1m", source="bot")
|
|
original_expires = dt.datetime.fromisoformat(hold_sub["expires_at"])
|
|
assert db.hold_subscription(hold_sub["uuid"])
|
|
assert db.hold_subscription(hold_sub["uuid"]) is False
|
|
assert len(db.list_active_subscriptions(tg_id=999)) == 0, "held sub must not count as active"
|
|
with db.get_conn() as conn:
|
|
simulated = (dt.datetime.utcnow() - dt.timedelta(hours=5)).isoformat()
|
|
conn.execute("UPDATE subscriptions SET held_at=? WHERE uuid=?", (simulated, hold_sub["uuid"]))
|
|
resumed = db.resume_subscription(hold_sub["uuid"])
|
|
assert resumed["held_at"] is None
|
|
shift_hours = (dt.datetime.fromisoformat(resumed["expires_at"]) - original_expires).total_seconds() / 3600
|
|
assert 4.9 <= shift_hours <= 5.1, f"expected ~5h shift, got {shift_hours}"
|
|
assert len(db.list_active_subscriptions(tg_id=999)) == 1, "resumed sub must count as active again"
|
|
assert db.resume_subscription(hold_sub["uuid"]) is None
|
|
print("subscription hold/resume OK")
|
|
|
|
print("all v1.1.0 feature smoke tests passed")
|
|
PYEOF
|
|
|
|
- name: Smoke test live settings (.env-backed plans/toggles/HWID/credentials, no restart)
|
|
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"
|
|
run: |
|
|
python - << 'PYEOF'
|
|
import hashlib
|
|
import hmac
|
|
|
|
with open(".env", "a", encoding="utf-8") as f:
|
|
f.write("PLATEGA_SECRET=old_secret\n")
|
|
f.write("PLATEGA_ENABLED=true\n")
|
|
f.write("PLATEGA_MERCHANT_ID=m1\n")
|
|
|
|
import legal
|
|
import settings
|
|
import payments
|
|
|
|
plans = settings.get_plans_by_code()
|
|
assert plans["1m"]["price"] > 0, "default price should come from config before any .env override"
|
|
|
|
settings.set_plan_prices({"1m": 4242})
|
|
assert settings.get_plans_by_code()["1m"]["price"] == 4242, "price edit should apply live, no reimport"
|
|
assert settings.get_plans_by_code()["7d"]["price"] != 4242, "unrelated plan must stay untouched"
|
|
|
|
assert settings.get_hwid_settings()["enabled"] is False
|
|
legal.update_env_var("HWID_LIMIT_ENABLED", "true")
|
|
legal.update_env_var("HWID_FALLBACK_LIMIT", "9")
|
|
hwid = settings.get_hwid_settings()
|
|
assert hwid["enabled"] is True and hwid["fallback_limit"] == 9, "HWID settings should apply live"
|
|
|
|
body = b'{"transactionId":"t1","status":"CONFIRMED"}'
|
|
sig_old = hmac.new(b"old_secret", body, hashlib.sha256).hexdigest()
|
|
assert payments.verify_platega_signature(body, sig_old), "signature must verify against the current secret"
|
|
|
|
legal.update_env_var("PLATEGA_SECRET", "rotated_secret")
|
|
assert not payments.verify_platega_signature(body, sig_old), "OLD signature must be rejected right after rotation, same process, no restart"
|
|
sig_new = hmac.new(b"rotated_secret", body, hashlib.sha256).hexdigest()
|
|
assert payments.verify_platega_signature(body, sig_new), "NEW signature must verify immediately after rotation, same process, no restart"
|
|
|
|
for _ in range(5):
|
|
legal.update_env_var("HWID_FALLBACK_LIMIT", "9")
|
|
with open(".env", encoding="utf-8") as f:
|
|
lines = [l for l in f.readlines() if l.startswith("HWID_FALLBACK_LIMIT=")]
|
|
assert len(lines) == 1, "repeated writes to the same key must not duplicate .env lines"
|
|
|
|
print("live settings: prices/HWID/credential-rotation all apply with zero reimport OK")
|
|
PYEOF
|
|
|
|
- name: Smoke test backup/restore round-trip covers branding + live settings + held subscriptions
|
|
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"
|
|
run: |
|
|
python - << 'PYEOF'
|
|
import backup
|
|
import db
|
|
import legal
|
|
import settings
|
|
|
|
db.init_db()
|
|
db.create_node("n1", "Node One", "managed", "1.1.1.1", 443, "pub1", "sid1", "sni1", "xtls-rprx-vision")
|
|
|
|
sub_a = db.create_subscription(111, "n1", 30, "1m", source="bot")
|
|
sub_b = db.create_subscription(222, "n1", 30, "1m", source="bot")
|
|
|
|
legal.update_env_var("BRAND_NAME", "SnapshotBrand")
|
|
settings.set_plan_prices({"1m": 555})
|
|
legal.update_env_var("HWID_LIMIT_ENABLED", "true")
|
|
legal.update_env_var("HWID_FALLBACK_LIMIT", "4")
|
|
assert db.hold_subscription(sub_a["uuid"])
|
|
|
|
assert settings.get_brand_name() == "SnapshotBrand"
|
|
assert settings.get_plans_by_code()["1m"]["price"] == 555
|
|
assert settings.get_hwid_settings() == {"enabled": True, "fallback_limit": 4}
|
|
assert len(db.list_active_subscriptions(tg_id=111)) == 0, "held sub excluded pre-backup"
|
|
assert len(db.list_active_subscriptions(tg_id=222)) == 1
|
|
|
|
snapshot = backup.create_backup()
|
|
|
|
legal.update_env_var("BRAND_NAME", "MutatedAfterBackup")
|
|
settings.set_plan_prices({"1m": 999})
|
|
legal.update_env_var("HWID_LIMIT_ENABLED", "false")
|
|
assert db.resume_subscription(sub_a["uuid"])["held_at"] is None
|
|
sub_c = db.create_subscription(333, "n1", 30, "1m", source="bot")
|
|
assert settings.get_brand_name() == "MutatedAfterBackup"
|
|
assert len(db.list_active_subscriptions(tg_id=111)) == 1
|
|
|
|
result = backup.restore_backup(snapshot)
|
|
assert result["restored_env"] is True
|
|
|
|
assert settings.get_brand_name() == "SnapshotBrand", "brand must revert to snapshot value"
|
|
assert settings.get_plans_by_code()["1m"]["price"] == 555, "price override must revert"
|
|
assert settings.get_hwid_settings() == {"enabled": True, "fallback_limit": 4}, "hwid settings must revert"
|
|
|
|
restored_sub_a = db.get_subscription(sub_a["uuid"])
|
|
assert restored_sub_a["held_at"] is not None, "held_at must round-trip through backup/restore"
|
|
assert len(db.list_active_subscriptions(tg_id=111)) == 0, "sub_a held again after restore"
|
|
assert len(db.list_active_subscriptions(tg_id=222)) == 1, "sub_b untouched"
|
|
assert db.get_subscription(sub_c["uuid"]) is None, "sub_c created after backup point must be gone"
|
|
|
|
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
|