The install script now checks for a foreign proxy (xray directory, docker marzban/xray) and busy ports before adding the management key or writing anything, and tells the panel the node is active only after xray has stayed up with its port listening for three checks in a row.
599 lines
29 KiB
YAML
599 lines
29 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
|
|
bash -n tests/test_mbs_update.sh
|
|
|
|
- name: Smoke test mbs update and mirror (manual edits on the server, url mirror, unsafe urls, rollback)
|
|
run: bash tests/test_mbs_update.sh
|
|
|
|
- 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 "PREFLIGHT_FAIL" in script and "443 2053 2087" in script, "node install must refuse a non-empty server before touching it"
|
|
assert script.index("PREFLIGHT_FAIL") < script.index("authorized_keys"), "preflight must run before the management key is added"
|
|
assert "OK=$((OK+1))" in script and "STATUS=failed" in script, "node must report active only after xray stays up"
|
|
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("bk1", "Node One", "managed", "1.1.1.1", 443, "pub1", "sid1", "sni1", "xtls-rprx-vision")
|
|
|
|
sub_a = db.create_subscription(111, "bk1", 30, "1m", source="bot")
|
|
sub_b = db.create_subscription(222, "bk1", 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, "bk1", 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
|
|
|
|
- name: Smoke test server chains (xray config generation, relay clients, subscription entries, audit log, old-db migration)
|
|
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 json
|
|
import urllib.parse
|
|
|
|
import chains
|
|
import db
|
|
import links
|
|
import nodeprov
|
|
import xray_manager
|
|
|
|
transports = nodeprov.build_transports("a.example.com", 443, "www.microsoft.com", "PUBA", include_ws=False)
|
|
entry_cfg = json.loads(nodeprov._build_config_json(transports, "PRIVA", "a.example.com"))
|
|
exit_cfg = json.loads(nodeprov._build_config_json(
|
|
nodeprov.build_transports("b.example.com", 443, "www.microsoft.com", "PUBB"), "PRIVB", "b.example.com"))
|
|
|
|
wanted = {"uuid-1": "uuid-1", "uuid-2": "uuid-2"}
|
|
chain = {"code": "cabc12", "port": 10443, "short_id": "1234567890abcdef", "exit_node": "chb", "relay_uuid": "relay-uuid-1"}
|
|
exit_nodes = {"chb": {
|
|
"address": "b.example.com", "port": 443, "sni": "www.microsoft.com",
|
|
"public_key": "PUBB", "short_id": "ffff", "kind": "managed", "shared_uuid": None,
|
|
}}
|
|
|
|
base_before = json.dumps([ib for ib in entry_cfg["inbounds"] if ib["tag"] in chains.BASE_TAGS], sort_keys=True)
|
|
|
|
changed, problems = chains.sync_config(entry_cfg, wanted, {}, [chain], exit_nodes)
|
|
assert changed and not problems, problems
|
|
tags = [ib["tag"] for ib in entry_cfg["inbounds"]]
|
|
assert "chain-cabc12" in tags, tags
|
|
ci = chains.find_inbound(entry_cfg, "chain-cabc12")
|
|
assert ci["port"] == 10443
|
|
assert ci["streamSettings"]["realitySettings"]["shortIds"] == ["1234567890abcdef"]
|
|
assert ci["streamSettings"]["realitySettings"]["privateKey"] == "PRIVA"
|
|
assert [c["id"] for c in ci["settings"]["clients"]] == ["uuid-1", "uuid-2"]
|
|
assert all(c["flow"] == "xtls-rprx-vision" for c in ci["settings"]["clients"])
|
|
out = [o for o in entry_cfg["outbounds"] if o["tag"] == "chain-cabc12-out"]
|
|
assert len(out) == 1
|
|
vn = out[0]["settings"]["vnext"][0]
|
|
assert vn["address"] == "b.example.com" and vn["port"] == 443 and vn["users"][0]["id"] == "relay-uuid-1"
|
|
assert out[0]["streamSettings"]["realitySettings"]["publicKey"] == "PUBB"
|
|
rules = [r for r in entry_cfg["routing"]["rules"] if r.get("outboundTag") == "chain-cabc12-out"]
|
|
assert len(rules) == 1 and rules[0]["inboundTag"] == ["chain-cabc12"]
|
|
assert entry_cfg["routing"]["rules"][0]["outboundTag"] == "api"
|
|
assert entry_cfg["outbounds"][0]["tag"] == "direct", "default outbound must stay first"
|
|
print("entry config: chain inbound/outbound/rule built OK")
|
|
|
|
changed2, problems2 = chains.sync_config(entry_cfg, wanted, {}, [chain], exit_nodes)
|
|
assert not changed2 and not problems2, "second pass must be a no-op"
|
|
print("idempotent OK")
|
|
|
|
wanted3 = {"uuid-2": "uuid-2", "uuid-3": "uuid-3"}
|
|
changed3, _ = chains.sync_config(entry_cfg, wanted3, {}, [chain], exit_nodes)
|
|
assert changed3
|
|
ci = chains.find_inbound(entry_cfg, "chain-cabc12")
|
|
assert [c["id"] for c in ci["settings"]["clients"]] == ["uuid-2", "uuid-3"]
|
|
for tag in ("vless-tcp-reality", "vless-grpc-reality", "vless-xhttp-reality"):
|
|
assert [c["id"] for c in chains.find_inbound(entry_cfg, tag)["settings"]["clients"]] == ["uuid-2", "uuid-3"]
|
|
print("clients follow the active set on every inbound incl. chain OK")
|
|
|
|
changed4, _ = chains.sync_config(entry_cfg, {"uuid-9": "uuid-9"}, {}, [], exit_nodes, apply_chains=False)
|
|
assert changed4
|
|
assert chains.find_inbound(entry_cfg, "chain-cabc12") is not None, "apply_chains=False must not drop chains"
|
|
assert [c["id"] for c in chains.find_inbound(entry_cfg, "chain-cabc12")["settings"]["clients"]] == ["uuid-9"]
|
|
print("clients-only fallback keeps existing chains and still syncs their clients OK")
|
|
|
|
chains.sync_config(entry_cfg, wanted, {}, [], exit_nodes)
|
|
assert chains.find_inbound(entry_cfg, "chain-cabc12") is None
|
|
assert not [o for o in entry_cfg["outbounds"] if o["tag"].startswith("chain-")]
|
|
assert not [r for r in entry_cfg["routing"]["rules"] if str(r.get("outboundTag", "")).startswith("chain-")]
|
|
base_after = json.dumps([ib for ib in entry_cfg["inbounds"] if ib["tag"] in chains.BASE_TAGS], sort_keys=True)
|
|
assert json.loads(base_after) != [] and len(json.loads(base_after)) == len(json.loads(base_before))
|
|
print("removing the chain cleans inbound/outbound/rule OK")
|
|
|
|
changed5, p5 = chains.sync_config(exit_cfg, wanted, {"relay-uuid-1": "relay-cabc12"}, [], {})
|
|
assert changed5 and not p5
|
|
tcp_ids = [c["id"] for c in chains.find_inbound(exit_cfg, "vless-tcp-reality")["settings"]["clients"]]
|
|
grpc_ids = [c["id"] for c in chains.find_inbound(exit_cfg, "vless-grpc-reality")["settings"]["clients"]]
|
|
assert "relay-uuid-1" in tcp_ids and "relay-uuid-1" not in grpc_ids
|
|
relay_entry = [c for c in chains.find_inbound(exit_cfg, "vless-tcp-reality")["settings"]["clients"] if c["id"] == "relay-uuid-1"][0]
|
|
assert relay_entry["flow"] == "xtls-rprx-vision" and relay_entry["email"] == "relay-cabc12"
|
|
changed6, _ = chains.sync_config(exit_cfg, wanted, {"relay-uuid-1": "relay-cabc12"}, [], {})
|
|
assert not changed6
|
|
print("exit node keeps the relay client only on the TCP inbound and survives sync OK")
|
|
|
|
busy_cfg = json.loads(nodeprov._build_config_json(transports, "PRIVA", "a.example.com"))
|
|
usable, skipped = chains.split_busy_chains(busy_cfg, [chain], {10443})
|
|
assert usable == [] and len(skipped) == 1
|
|
usable2, skipped2 = chains.split_busy_chains(busy_cfg, [chain], set())
|
|
assert usable2 == [chain] and skipped2 == []
|
|
chains.sync_config(busy_cfg, wanted, {}, [chain], exit_nodes)
|
|
usable3, skipped3 = chains.split_busy_chains(busy_cfg, [chain], {10443})
|
|
assert usable3 == [chain], "an already-applied chain is not a new port, busy check must ignore it"
|
|
print("busy port handling OK")
|
|
|
|
ext_nodes = {"chb": dict(exit_nodes["chb"], kind="external", shared_uuid="shared-1")}
|
|
ext_chain = dict(chain, relay_uuid=None)
|
|
cfg_e = json.loads(nodeprov._build_config_json(transports, "PRIVA", "a.example.com"))
|
|
ch, pr = chains.sync_config(cfg_e, wanted, {}, [ext_chain], ext_nodes)
|
|
assert ch and not pr
|
|
assert [o for o in cfg_e["outbounds"] if o["tag"] == "chain-cabc12-out"][0]["settings"]["vnext"][0]["users"][0]["id"] == "shared-1"
|
|
no_key = dict(ext_nodes["chb"], shared_uuid=None)
|
|
cfg_f = json.loads(nodeprov._build_config_json(transports, "PRIVA", "a.example.com"))
|
|
ch, pr = chains.sync_config(cfg_f, wanted, {}, [ext_chain], {"chb": no_key})
|
|
assert pr and chains.find_inbound(cfg_f, "chain-cabc12") is None
|
|
print("external exit uses shared uuid, missing key is reported OK")
|
|
|
|
assert chains.latency_level(10) == "low" and chains.latency_level(80) == "medium" and chains.latency_level(300) == "high"
|
|
assert chains.latency_level(None) == "unknown"
|
|
assert chains.median_ms([-1, -1]) is None and chains.median_ms([30, 10, -1]) == 30
|
|
print("latency helpers OK")
|
|
|
|
db.init_db()
|
|
db.create_node("cha", "🇫🇮 Финляндия", "managed", "fi.example.com", 443, "PUBFI", "sidfi", "www.microsoft.com", "xtls-rprx-vision")
|
|
db.create_node("chb", "🇳🇱 Нидерланды", "managed", "nl.example.com", 443, "PUBNL", "sidnl", "www.microsoft.com", "xtls-rprx-vision")
|
|
db.create_node("chx", "Внешняя", "external", "ex.example.com", 443, "PUBEX", "sidex", "www.microsoft.com", "xtls-rprx-vision", shared_uuid="shared-ex")
|
|
|
|
c1 = db.create_chain("Финка → Голландия", "cha", "chb", "relay-1")
|
|
assert c1["port"] == 10443 and len(c1["short_id"]) == 16 and c1["code"].startswith("c")
|
|
c2 = db.create_chain("Финка → Внешняя", "cha", "chx", None)
|
|
assert c2["port"] == 10444
|
|
try:
|
|
db.create_chain("dup", "cha", "chb", "x")
|
|
assert False
|
|
except ValueError:
|
|
pass
|
|
try:
|
|
db.delete_node("chb")
|
|
assert False, "node used in chain must not be deletable"
|
|
except ValueError as e:
|
|
assert "chain" in str(e)
|
|
assert [c["code"] for c in db.list_chains()] == [c1["code"], c2["code"]]
|
|
assert len(db.list_chains(enabled_only=True)) == 2
|
|
db.update_chain(c2["code"], enabled=0)
|
|
assert len(db.list_chains(enabled_only=True)) == 1
|
|
assert db.stats()["chains"] == 1
|
|
print("db chains CRUD, port allocation, node-delete guard OK")
|
|
|
|
sub = db.create_subscription(500, "cha", 30, "1m", source="bot")
|
|
text = base64.b64decode(links.build_subscription_text([sub])).decode()
|
|
lines = text.split("\n")
|
|
chain_lines = [l for l in lines if ":10443?" in l]
|
|
assert len(chain_lines) == 1, lines
|
|
assert "10444" not in text, "disabled chain must not leak into the subscription"
|
|
parsed = urllib.parse.urlparse(chain_lines[0])
|
|
assert parsed.hostname == "fi.example.com" and parsed.port == 10443
|
|
qs = urllib.parse.parse_qs(parsed.query)
|
|
assert qs["sid"] == [c1["short_id"]] and qs["pbk"] == ["PUBFI"] and qs["flow"] == ["xtls-rprx-vision"]
|
|
assert urllib.parse.unquote(parsed.fragment) == "🇫🇮 Финляндия → 🇳🇱 Нидерланды"
|
|
assert parsed.username == sub["uuid"]
|
|
print("subscription text carries the chain entry for the entry node's subscribers OK")
|
|
|
|
other = db.create_subscription(501, "chb", 30, "1m", source="bot")
|
|
text2 = base64.b64decode(links.build_subscription_text([other])).decode()
|
|
assert ":10443?" not in text2, "subscribers of the exit node must not get the entry node's chain"
|
|
print("chain is only offered to entry-node subscribers OK")
|
|
|
|
db.update_node("cha", enabled=0)
|
|
text3 = base64.b64decode(links.build_subscription_text([sub])).decode()
|
|
assert text3.strip() == ""
|
|
db.update_node("cha", enabled=1)
|
|
|
|
db.update_chain(c2["code"], enabled=1)
|
|
node_n1 = db.get_node("cha")
|
|
w, relay, entry_chains, exit_n = xray_manager.desired_state(node_n1)
|
|
assert sub["uuid"] in w and [c["code"] for c in entry_chains] == [c1["code"], c2["code"]] and relay == {}
|
|
node_n2 = db.get_node("chb")
|
|
w2, relay2, entry2, exit2 = xray_manager.desired_state(node_n2)
|
|
assert relay2 == {"relay-1": chains.relay_email(c1["code"])} and entry2 == []
|
|
node_ex = db.get_node("chx")
|
|
w3, relay3, entry3, exit3 = xray_manager.desired_state(node_ex)
|
|
assert relay3 == {}
|
|
db.update_node("chb", enabled=0)
|
|
w4, relay4, entry4, exit4 = xray_manager.desired_state(node_n1)
|
|
assert [c["code"] for c in entry4] == [c2["code"]], "chain whose exit is disabled must drop out"
|
|
print("desired_state: entry/relay/disabled-node logic OK")
|
|
|
|
db.add_audit("admin", "node.add", "/admin/api/nodes", "1.2.3.4")
|
|
db.add_audit(None, "login.failed", "", "5.6.7.8")
|
|
rows = db.list_audit(10)
|
|
assert rows[0]["action"] == "login.failed" and rows[1]["admin"] == "admin"
|
|
print("audit log OK")
|
|
|
|
with db.get_conn() as conn:
|
|
conn.execute("DROP TABLE chains")
|
|
conn.execute("DROP TABLE audit_log")
|
|
db.init_db()
|
|
assert db.list_chains() == [] and db.list_audit() == []
|
|
print("init_db recreates chain/audit tables on an old database OK")
|
|
|
|
print("chains: all smoke tests passed")
|
|
PYEOF
|