From 4431f52447ac72bd02d202ad14f94b9f983919b3 Mon Sep 17 00:00:00 2001 From: fkzys Date: Tue, 15 Sep 2026 00:40:35 +0300 Subject: [PATCH 1/5] chore: add .omo into gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ad15c9d..7eddc5f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ certs/.certstore/ graphify-out/cache/ **.graphify_*.json **.graphify_python +.omo From ce998093f55aee3b88eecbcadaeed55f4de71d20 Mon Sep 17 00:00:00 2001 From: fkzys Date: Tue, 15 Sep 2026 00:41:11 +0300 Subject: [PATCH 2/5] sing-box: add whitelist-bypass --- .github/workflows/ci.yml | 2 + sing-box/deploy-relay.py | 57 +++- sing-box/secrets/secrets.enc.yaml | 12 +- sing-box/templates/server_inbounds.json.j2 | 9 + sing-box/templates/wlb_bot.container.j2 | 27 ++ sing-box/templates/wlb_cookies_yandex.json.j2 | 2 + tests/test_deploy_relay.py | 250 ++++++++++++++++++ 7 files changed, 350 insertions(+), 9 deletions(-) create mode 100644 sing-box/templates/wlb_bot.container.j2 create mode 100644 sing-box/templates/wlb_cookies_yandex.json.j2 create mode 100644 tests/test_deploy_relay.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c96a41d..2808024 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,11 +4,13 @@ on: push: paths: - 'lib/**' + - 'sing-box/**' - 'tests/*.py' - '.github/workflows/ci.yml' pull_request: paths: - 'lib/**' + - 'sing-box/**' - 'tests/*.py' - '.github/workflows/ci.yml' diff --git a/sing-box/deploy-relay.py b/sing-box/deploy-relay.py index 688c01a..79e2247 100644 --- a/sing-box/deploy-relay.py +++ b/sing-box/deploy-relay.py @@ -6,19 +6,44 @@ BASE = Path(__file__).parent +# Keys in the wlb secrets block mapping 1:1 to bot env vars — normalized to canonical +# VK_TOKEN/VK_GROUP_ID/etc. so the template never depends on case in secrets. +_WLB_ENV_KEYS = {'vk_token', 'vk_group_id', 'vk_user_ids', 'resources'} + + +def _resolve_wlb(secrets, instance_name): + if secrets['relay_instances'][instance_name].get('wlb'): + shared = secrets.get('wlb') + if not isinstance(shared, dict): + raise ValueError( + f"relay instance '{instance_name}' has wlb enabled, but no wlb: config block " + f"at the top level of the secrets file" + ) + return shared + return None + + +def _normalize_wlb(wlb): + if not isinstance(wlb, dict): + return None + return {k.upper() if k.lower() in _WLB_ENV_KEYS else k: v for k, v in wlb.items()} + def build_context(secrets, instance_name): relay = secrets['relay_instances'][instance_name] basename = secrets['common']['basename'] image = relay.get('image') or secrets['common']['image'] + volume_path = relay.get('volume_path') or secrets['common']['volume_path'] inbound_users = secrets.get('users', []) return { **secrets, 'basename': basename, + 'volume_path': volume_path, 'reality': relay['reality'], + 'wlb': _normalize_wlb(_resolve_wlb(secrets, instance_name)), 'current_instance': { **relay, 'image': image, @@ -34,32 +59,50 @@ def make_files(secrets, instance_name): basename = secrets['common']['basename'] volume_path = relay.get('volume_path') or secrets['common']['volume_path'] settings_dir = f'{volume_path}/settings' - return [ + files = [ ('relay_main.json.j2', f'{settings_dir}/main.json'), ('server_inbounds.json.j2', f'{settings_dir}/inbounds.json'), ('server_ruleset.json.j2', f'{settings_dir}/ruleset.json'), ('server_container.j2', f'/etc/containers/systemd/{basename}.container'), ('server_pod.j2', f'/etc/containers/systemd/{basename}.pod'), ] + if _resolve_wlb(secrets, instance_name): + files += [ + ('wlb_bot.container.j2', f'/etc/containers/systemd/{basename}-wlb.container'), + ('wlb_cookies_yandex.json.j2', f'{volume_path}/cookies/cookies-yandex.json', + {'owner': '999:999', 'mode': '600'}), + ] + return files def make_setup_dirs(secrets, instance_name): relay = secrets['relay_instances'][instance_name] volume_path = relay.get('volume_path') or secrets['common']['volume_path'] settings_dir = f'{volume_path}/settings' - return [f'{settings_dir}', f'{volume_path}/cache'] + dirs = [f'{settings_dir}', f'{volume_path}/cache'] + if _resolve_wlb(secrets, instance_name): + dirs += [f'{volume_path}/cookies', f'{volume_path}/wlb-sessions'] + return dirs def restart_cmd(secrets, instance_name): relay = secrets['relay_instances'][instance_name] image = relay.get('image') or secrets['common']['image'] basename = secrets['common']['basename'] - volume_path = relay.get('volume_path') or secrets['common']['volume_path'] + + pulls = [f'podman pull {image}'] + units = f'{basename} {basename}-pod' + restart_units = basename + wlb = _resolve_wlb(secrets, instance_name) + if wlb: + pulls.append(f"podman pull {wlb.get('image') or 'ghcr.io/kulikov0/whitelist-bypass-bot:latest'}") + units += f' {basename}-wlb' + restart_units = f'{basename} {basename}-wlb' return ( - f'podman pull {image}' - f' && systemctl daemon-reload' - f' && systemctl reset-failed {basename} {basename}-pod 2>/dev/null;' - f' systemctl restart {basename}' + ' && '.join(pulls) + + f' && systemctl daemon-reload' + + f' && systemctl reset-failed {units} 2>/dev/null;' + + f' systemctl restart {restart_units}' ) diff --git a/sing-box/secrets/secrets.enc.yaml b/sing-box/secrets/secrets.enc.yaml index 270c8dd..10f87d5 100644 --- a/sing-box/secrets/secrets.enc.yaml +++ b/sing-box/secrets/secrets.enc.yaml @@ -28,6 +28,13 @@ common: - ip_cidr: - ENC[AES256_GCM,data:s72OXG3gTaXYLbL0dmA=,iv:YwzMoJGJ1q3VllKcDT7/Wk9+9vlbErp/cFJEfkci9Fk=,tag:hytfmkwwvWYiYZkn8oeTpA==,type:str] outbound: ENC[AES256_GCM,data:NX/uMPNj,iv:3X3m5s2GjbHJX021bvIrPx9WqgnKVXx463jAWx/4Kyo=,tag:5nlZoINmcm7Q8iNJ/Y/+cQ==,type:str] +wlb: + image: ENC[AES256_GCM,data:Jcyng3YvmXEzFch+F0KrSLrObV1CxgzMnKc+drVfGBup+f6BW/thuA88iv4=,iv:Is11QxkB17fHe5onUzbLe1O/uuiqi5FtyhcXMm3Prwk=,tag:1LuHoQ4lVs4Ek5FOwbIwLg==,type:str] + vk_token: ENC[AES256_GCM,data:ocKIvuQW1Fv0KeG7Gww0ZUQOVHvCuc25rvCDpCwJWTUQyGGWqx0tWchBXGYSoY2tFkWAQ77tbw52mycydGowktJS505HFiYwYc8hGcE4IbMIee8DiQ9Rx/V83ePbvhMzhSYvDz3CjgF+xBLLX5hXqH3ItRe0mn1hvIH1yglX6/B1i8xnQPG8bGJb9ITVd13IfI+/xIuKbtwbltOiSKB1/PnKj9i3YugMOggpKZZyBRHs1itVRSLdHRqTCsWZu286vkG+mpzw3Tf42SVdpnUKdDlEC+lZO7SNe6K7i+iyV4jfaUts1L57vmvQ3Ld9mrPfiQ==,iv:lp8sN7rBSvEQsQlfU/KEZApPcZLng448iyzQioYaVx0=,tag:O/cqM/SPAes9AXzC2w3taA==,type:str] + vk_group_id: ENC[AES256_GCM,data:WdAJznKpQluh,iv:qizwPOy5QRtYVrLSgIe6Oz62VS+iRi8XOpoc8afCOsU=,tag:0B/4hVNV0VBEoScGJfvaIw==,type:int] + vk_user_ids: ENC[AES256_GCM,data:BAzzekOuYMk3/iOn,iv:dzF/MlcQNa/GPk/Yn7mW00liukuJmxhmpS0i3BItB7I=,tag:AEk/40jgXsqyJUlExoIocQ==,type:int] + resources: ENC[AES256_GCM,data:sDT0X5RC0Q==,iv:6QlYLBkSqWhvTN3KQjySJNuSgvsKcsV+he8EHLJMVpw=,tag:7/3V8lTFu+6km1ai/DsGAA==,type:str] + cookies_yandex: ENC[AES256_GCM,data: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,iv:BqfPQuqJB7V1sQuWvcWCI8+RZLFqEwR83zANKSiB0Ds=,tag:5ZYsuFm9i7k4xy3aecyboA==,type:str] instances: instance1: host: ENC[AES256_GCM,data:PmxnzcmfUA==,iv:3Ygr6I+mniVaNFWhSrOees2+X0KAqJcUEyetx7dU/3c=,tag:AN21D5G44RPAky4yvpa2xA==,type:str] @@ -46,6 +53,7 @@ relay_instances: password: ENC[AES256_GCM,data:F2U0tthfFtI4Kg3FIRrvwP1RL8zv0KQ8NhszRlQo2lg=,iv:QmqFM6Vzk/cP9d05H1scYTwypaguKdZHTyIN+5VYUnQ=,tag:yKPsa3dTrBcWkZmCaB3A0Q==,type:str] uuid: ENC[AES256_GCM,data:BHcmoGb8laGbRQu30kWOXU6ve15d80PK+XLHosN0owQ4mK1t,iv:biK31Ghr75p67euQLqUqRhENpkQ+kqQLNTbP/KXQZrs=,tag:C0I1Va/pZANsHN2Bh3tUvw==,type:str] short_id: ENC[AES256_GCM,data:5GiQMArDgLBQRjqlYExuMA==,iv:axkkH4U2w9tnF/LdpHnX1xc4nROChOtR8sQ/Gtp6TGw=,tag:kBzT1pFuIepsYRdzUEtGBw==,type:str] + wlb: ENC[AES256_GCM,data:PDksIw==,iv:z2luUQoW2J7Y5+nc0AGfAWnxWtsdzQWbAGwNiyzzT74=,tag:YSuJuAFB0CTIexRtNM+bUA==,type:bool] public_key: ENC[AES256_GCM,data:M+YBsRWmgMRQv/KQhLKeMLUQ4i3fvtw7eK2ZD1x13HM0w9prdoiykQYEfg==,iv:uOgimEIggQzMRTOGv5k+qLjHpQitdVGVC2957kj4Kyc=,tag:2Desi0vZdV+rgyBZMxS8mQ==,type:str] reality: server_name: ENC[AES256_GCM,data:/wzJtmq3qstfmy1H2bM=,iv:zQNw33vDuACbDn6jglSFVZ47C8r/PWS/SjWwMqDrEj4=,tag:YoTCu5ZoKDCzaC1KKbfgzw==,type:str] @@ -184,7 +192,7 @@ sops: KwU7jQaQro/2uRJnXLsNw/xJN2DBGD4d0tQ7ppNqvtD/5T7NXmKFlg== -----END AGE ENCRYPTED FILE----- recipient: age1p9r276rvjh98r93nsvqhzuhlqnawur0j0hn34qmeangcd94mlchqgvm820 - lastmodified: "2026-08-08T10:52:33Z" - mac: ENC[AES256_GCM,data:KYDoHwRvcSl043rZ/e+Ovo3Kd+FiI91BXB+N+3fAPuWb6kg/+iO4bHpbWC3jUsVHUbLb4Th/GViY9PHjl15g1yWUmNs2ZlgbqJEtZvikBeP/LFFW1DAvE5hXHTSaUkXKekOsT/G0R1/HNsIG43ixGG4aZ3qu6QJvj0lXhrlAQ4Y=,iv:CkfSuKhTDm/Yx3LorRXzmFQmxikhsv+bc24/Kgw95P0=,tag:OCceAS9CdJh6vEKev/Zj3g==,type:str] + lastmodified: "2026-09-14T21:13:15Z" + mac: ENC[AES256_GCM,data:hNup1BVHqMeVVVy/6C6Xl8uwrgQsYGh7R0dM/Bc1CReFhORp02Fpjh4G7LLJgT1AnSuSUvZXUHwCRi+SATPE3cBZw1Mg4FIRpHqkCpIm4LNIjeQfa2lITIC3aVB/K2cjGVbYSnY7QjrpCn1ljsm5crchYCk+Q4By5QgrAlbgnSQ=,iv:Zaq88GzEENhEqm6w7/6kRO+rmsSzepuypL8O1QHjnZc=,tag:jpVUFKMVRyEuYhGyro9eYA==,type:str] unencrypted_suffix: _unencrypted version: 3.13.3 diff --git a/sing-box/templates/server_inbounds.json.j2 b/sing-box/templates/server_inbounds.json.j2 index ed711e8..52f7032 100644 --- a/sing-box/templates/server_inbounds.json.j2 +++ b/sing-box/templates/server_inbounds.json.j2 @@ -19,5 +19,14 @@ "key_path": "/etc/ssl/private/{{ common.domain }}.key" } } +{% if current_instance.wlb %} + , + { + "type": "socks", + "tag": "wlb_socks_in", + "listen": "127.0.0.1", + "listen_port": 1080 + } +{% endif %} ] } diff --git a/sing-box/templates/wlb_bot.container.j2 b/sing-box/templates/wlb_bot.container.j2 new file mode 100644 index 0000000..495970c --- /dev/null +++ b/sing-box/templates/wlb_bot.container.j2 @@ -0,0 +1,27 @@ +[Unit] +Description=whitelist-bypass bot +Requires={{ basename }}.pod +After={{ basename }}.pod +After={{ basename }}.container + +[Container] +Image={{ wlb.image | default('ghcr.io/kulikov0/whitelist-bypass-bot:latest') }} +ContainerName={{ basename }}-wlb +Pod={{ basename }}.pod +Environment=VK_TOKEN={{ wlb.VK_TOKEN }} +Environment=VK_GROUP_ID={{ wlb.VK_GROUP_ID }} +Environment=TM_COOKIES=/data/cookies-yandex.json +Environment=RESOURCES={{ wlb.RESOURCES | default('default') }} +Environment=UPSTREAM_SOCKS=127.0.0.1:1080 +{% if wlb.VK_USER_IDS %} +Environment=VK_USER_IDS={{ wlb.VK_USER_IDS }} +{% endif %} +Volume={{ volume_path }}/cookies/cookies-yandex.json:/data/cookies-yandex.json:ro +Volume={{ volume_path }}/wlb-sessions:/data/sessions:U +AutoUpdate=registry + +[Service] +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/sing-box/templates/wlb_cookies_yandex.json.j2 b/sing-box/templates/wlb_cookies_yandex.json.j2 new file mode 100644 index 0000000..f0bbf08 --- /dev/null +++ b/sing-box/templates/wlb_cookies_yandex.json.j2 @@ -0,0 +1,2 @@ +{# templates/wlb_cookies_yandex.json.j2 #} +{{ wlb.cookies_yandex }} \ No newline at end of file diff --git a/tests/test_deploy_relay.py b/tests/test_deploy_relay.py new file mode 100644 index 0000000..12f6341 --- /dev/null +++ b/tests/test_deploy_relay.py @@ -0,0 +1,250 @@ +"""Tests for sing-box/deploy-relay.py — pure logic + template rendering, no externals.""" + +import copy +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +REPO = Path(__file__).resolve().parent.parent + + +def _load_deploy_relay(): + path = REPO / "sing-box" / "deploy-relay.py" + spec = importlib.util.spec_from_file_location("sing_box_deploy_relay", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _secrets(with_wlb=False, vk_user_ids=True): + relay_instances = { + "relay-eu": { + "host": "server3", + "password": "relaypw", + "reality": {"server_name": "relay-sni.com"}, + "wlb": bool(with_wlb), + }, + } + secrets = { + "common": { + "basename": "singbox", + "volume_path": "/opt/podman/singbox", + "image": "ghcr.io/sagernet/sing-box:latest", + }, + "instances": { + "proxy1": {"host": "server1", "reality": {"server_name": "proxy-sni.com"}}, + }, + "relay_instances": relay_instances, + "users": [{"name": "alice", "password": "pw1"}], + } + if with_wlb: + wlb = { + "image": "ghcr.io/kulikov0/whitelist-bypass-bot:latest", + "vk_token": "vk1.a.TOKEN", + "vk_group_id": "123456789", + "resources": "default", + "cookies_yandex": '[{"name": "sessionid", "value": "abc"}]', + } + if vk_user_ids: + wlb["vk_user_ids"] = "12345,67890" + secrets["wlb"] = wlb + return secrets + + +def _render(secrets, instance_name, template): + from lib.jinja import create_jinja_env + mod = _load_deploy_relay() + env = create_jinja_env(REPO / "sing-box" / "templates") + return env.get_template(template).render(**mod.build_context(secrets, instance_name)) + + +# ═══════════════════════════════════════════════════ +# build_context +# ═══════════════════════════════════════════════════ + + +class TestBuildContext: + def test_flag_false_disables(self): + mod = _load_deploy_relay() + ctx = mod.build_context(_secrets(with_wlb=False), "relay-eu") + assert ctx["wlb"] is None + + def test_flag_true_loads_top_level_config(self): + mod = _load_deploy_relay() + ctx = mod.build_context(_secrets(with_wlb=True), "relay-eu") + assert ctx["wlb"]["VK_TOKEN"] == "vk1.a.TOKEN" + assert ctx["wlb"]["VK_GROUP_ID"] == "123456789" + assert ctx["wlb"]["VK_USER_IDS"] == "12345,67890" + assert ctx["wlb"]["RESOURCES"] == "default" + assert ctx["wlb"]["image"] == "ghcr.io/kulikov0/whitelist-bypass-bot:latest" + assert ctx["wlb"]["cookies_yandex"] == '[{"name": "sessionid", "value": "abc"}]' + + def test_env_keys_normalized_to_canonical_case(self): + mod = _load_deploy_relay() + secrets = copy.deepcopy(_secrets(with_wlb=True)) + secrets["wlb"]["VK_TOKEN"] = secrets["wlb"].pop("vk_token") + ctx = mod.build_context(secrets, "relay-eu") + assert ctx["wlb"]["VK_TOKEN"] == "vk1.a.TOKEN" + + def test_flag_true_without_config_raises(self): + mod = _load_deploy_relay() + secrets = _secrets(with_wlb=True) + del secrets["wlb"] + with pytest.raises(ValueError, match="wlb enabled"): + mod.build_context(secrets, "relay-eu") + + def test_volume_path_used_as_is(self): + mod = _load_deploy_relay() + ctx = mod.build_context(_secrets(), "relay-eu") + assert ctx["volume_path"] == "/opt/podman/singbox" + + +# ═══════════════════════════════════════════════════ +# make_files / make_setup_dirs +# ═══════════════════════════════════════════════════ + + +class TestMakeFiles: + def test_flag_false_only_base_files(self): + mod = _load_deploy_relay() + files = mod.make_files(_secrets(with_wlb=False), "relay-eu") + assert [f[0] for f in files] == [ + "relay_main.json.j2", + "server_inbounds.json.j2", + "server_ruleset.json.j2", + "server_container.j2", + "server_pod.j2", + ] + + def test_flag_true_adds_unit_and_cookies(self): + mod = _load_deploy_relay() + files = mod.make_files(_secrets(with_wlb=True), "relay-eu") + templates = [f[0] for f in files] + assert "wlb_bot.container.j2" in templates + assert "/etc/containers/systemd/singbox-wlb.container" in [f[1] for f in files] + + def test_cookies_file_permissions(self): + mod = _load_deploy_relay() + files = mod.make_files(_secrets(with_wlb=True), "relay-eu") + cookies = [f for f in files if f[0] == "wlb_cookies_yandex.json.j2"] + assert len(cookies) == 1 + assert cookies[0][1] == "/opt/podman/singbox/cookies/cookies-yandex.json" + assert cookies[0][2] == {"owner": "999:999", "mode": "600"} + + +class TestMakeSetupDirs: + def test_flag_false(self): + mod = _load_deploy_relay() + assert mod.make_setup_dirs(_secrets(with_wlb=False), "relay-eu") == [ + "/opt/podman/singbox/settings", + "/opt/podman/singbox/cache", + ] + + def test_flag_true_adds_cookies_and_sessions(self): + mod = _load_deploy_relay() + assert mod.make_setup_dirs(_secrets(with_wlb=True), "relay-eu") == [ + "/opt/podman/singbox/settings", + "/opt/podman/singbox/cache", + "/opt/podman/singbox/cookies", + "/opt/podman/singbox/wlb-sessions", + ] + + +# ═══════════════════════════════════════════════════ +# restart_cmd +# ═══════════════════════════════════════════════════ + + +class TestRestartCmd: + def test_flag_false_unchanged(self): + mod = _load_deploy_relay() + assert mod.restart_cmd(_secrets(with_wlb=False), "relay-eu") == ( + "podman pull ghcr.io/sagernet/sing-box:latest" + " && systemctl daemon-reload" + " && systemctl reset-failed singbox singbox-pod 2>/dev/null;" + " systemctl restart singbox" + ) + + def test_flag_true_pulls_bot_and_restarts_both_units(self): + mod = _load_deploy_relay() + cmd = mod.restart_cmd(_secrets(with_wlb=True), "relay-eu") + assert "podman pull ghcr.io/kulikov0/whitelist-bypass-bot:latest" in cmd + assert "singbox singbox-pod singbox-wlb" in cmd + assert cmd.endswith("systemctl restart singbox singbox-wlb") + + def test_flag_true_custom_bot_image(self): + mod = _load_deploy_relay() + secrets = _secrets(with_wlb=True) + secrets["wlb"]["image"] = "ghcr.io/me/bot:1.2.3" + assert "podman pull ghcr.io/me/bot:1.2.3" in mod.restart_cmd(secrets, "relay-eu") + + +# ═══════════════════════════════════════════════════ +# template rendering (real templates, fake secrets) +# ═══════════════════════════════════════════════════ + + +class TestInboundsRender: + def test_flag_false_single_anytls_inbound(self): + rendered = _render(_secrets(with_wlb=False), "relay-eu", "server_inbounds.json.j2") + data = json.loads(rendered) + assert [i["tag"] for i in data["inbounds"]] == ["anytls_in"] + + def test_flag_true_adds_socks_inbound(self): + rendered = _render(_secrets(with_wlb=True), "relay-eu", "server_inbounds.json.j2") + data = json.loads(rendered) + socks = [i for i in data["inbounds"] if i.get("tag") == "wlb_socks_in"] + assert len(socks) == 1 + assert socks[0]["type"] == "socks" + assert socks[0]["listen"] == "127.0.0.1" + assert socks[0]["listen_port"] == 1080 + assert "udp" not in socks[0] + + +class TestWlbUnitRender: + def test_flag_true_sets_env_from_top_level_config(self): + rendered = _render(_secrets(with_wlb=True), "relay-eu", "wlb_bot.container.j2") + assert "Requires=singbox.pod" in rendered + assert "Pod=singbox.pod" in rendered + assert "Environment=VK_TOKEN=vk1.a.TOKEN" in rendered + assert "Environment=VK_GROUP_ID=123456789" in rendered + assert "Environment=TM_COOKIES=/data/cookies-yandex.json" in rendered + assert "Environment=RESOURCES=default" in rendered + assert "Environment=UPSTREAM_SOCKS=127.0.0.1:1080" in rendered + assert "Environment=VK_USER_IDS=12345,67890" in rendered + assert "/opt/podman/singbox/cookies/cookies-yandex.json:/data/cookies-yandex.json:ro" in rendered + assert "/opt/podman/singbox/wlb-sessions:/data/sessions:U" in rendered + assert "AutoUpdate=registry" in rendered + assert "[Service]\nRestart=always" in rendered + + def test_image_default_when_absent(self): + secrets = _secrets(with_wlb=True) + del secrets["wlb"]["image"] + rendered = _render(secrets, "relay-eu", "wlb_bot.container.j2") + assert "Image=ghcr.io/kulikov0/whitelist-bypass-bot:latest" in rendered + assert "podman pull ghcr.io/kulikov0/whitelist-bypass-bot:latest" in _load_deploy_relay().restart_cmd(secrets, "relay-eu") + + def test_unit_waits_for_singbox_container(self): + rendered = _render(_secrets(with_wlb=True), "relay-eu", "wlb_bot.container.j2") + assert "After=singbox.container" in rendered + + def test_vk_user_ids_omitted_when_absent(self): + rendered = _render(_secrets(with_wlb=True, vk_user_ids=False), "relay-eu", "wlb_bot.container.j2") + assert "VK_USER_IDS=" not in rendered + + def test_resources_default_when_absent(self): + secrets = _secrets(with_wlb=True) + del secrets["wlb"]["resources"] + rendered = _render(secrets, "relay-eu", "wlb_bot.container.j2") + assert "Environment=RESOURCES=default" in rendered + + +class TestCookiesRender: + def test_outputs_cookie_json_verbatim(self): + rendered = _render(_secrets(with_wlb=True), "relay-eu", "wlb_cookies_yandex.json.j2") + assert rendered.strip() == '[{"name": "sessionid", "value": "abc"}]' \ No newline at end of file From 23b49a55f1d00d007ea77a6d1703669c35fa3c40 Mon Sep 17 00:00:00 2001 From: fkzys Date: Tue, 15 Sep 2026 00:57:11 +0300 Subject: [PATCH 3/5] sing-box: add option to make wlb variable a dict --- sing-box/deploy-relay.py | 15 ++++++++++---- tests/test_deploy_relay.py | 41 +++++++++++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/sing-box/deploy-relay.py b/sing-box/deploy-relay.py index 79e2247..9e5ab59 100644 --- a/sing-box/deploy-relay.py +++ b/sing-box/deploy-relay.py @@ -12,15 +12,22 @@ def _resolve_wlb(secrets, instance_name): - if secrets['relay_instances'][instance_name].get('wlb'): - shared = secrets.get('wlb') + wlb = secrets['relay_instances'][instance_name].get('wlb') + if wlb is None or wlb is False: + return None + shared = secrets.get('wlb') + if wlb is True: if not isinstance(shared, dict): raise ValueError( - f"relay instance '{instance_name}' has wlb enabled, but no wlb: config block " + f"relay instance '{instance_name}' has wlb: true enabled, but no wlb: config block " f"at the top level of the secrets file" ) return shared - return None + if isinstance(wlb, dict): + return {**(shared or {}), **wlb} + raise ValueError( + f"relay instance '{instance_name}' has wlb: {wlb!r} — expected true/false or a dict of overrides" + ) def _normalize_wlb(wlb): diff --git a/tests/test_deploy_relay.py b/tests/test_deploy_relay.py index 12f6341..6893317 100644 --- a/tests/test_deploy_relay.py +++ b/tests/test_deploy_relay.py @@ -95,9 +95,48 @@ def test_flag_true_without_config_raises(self): mod = _load_deploy_relay() secrets = _secrets(with_wlb=True) del secrets["wlb"] - with pytest.raises(ValueError, match="wlb enabled"): + with pytest.raises(ValueError, match="wlb: true"): mod.build_context(secrets, "relay-eu") + def test_dict_overrides_shared(self): + mod = _load_deploy_relay() + secrets = _secrets(with_wlb=True) + secrets["relay_instances"]["relay-eu"]["wlb"] = {"RESOURCES": "custom"} + ctx = mod.build_context(secrets, "relay-eu") + assert ctx["wlb"]["RESOURCES"] == "custom" + assert ctx["wlb"]["VK_TOKEN"] == "vk1.a.TOKEN" + + def test_dict_only_without_shared_block(self): + mod = _load_deploy_relay() + secrets = _secrets(with_wlb=True) + del secrets["wlb"] + secrets["relay_instances"]["relay-eu"]["wlb"] = {"VK_TOKEN": "own", "vk_group_id": "555"} + ctx = mod.build_context(secrets, "relay-eu") + assert ctx["wlb"]["VK_TOKEN"] == "own" + assert ctx["wlb"]["VK_GROUP_ID"] == "555" + + def test_dict_empty_uses_shared(self): + mod = _load_deploy_relay() + secrets = _secrets(with_wlb=True) + secrets["relay_instances"]["relay-eu"]["wlb"] = {} + ctx = mod.build_context(secrets, "relay-eu") + assert ctx["wlb"]["VK_TOKEN"] == "vk1.a.TOKEN" + + def test_wlb_invalid_type_raises(self): + mod = _load_deploy_relay() + secrets = _secrets(with_wlb=True) + secrets["relay_instances"]["relay-eu"]["wlb"] = "enabled" + with pytest.raises(ValueError, match="expected true/false or a dict"): + mod.build_context(secrets, "relay-eu") + + def test_dict_form_custom_image_used_in_restart(self): + mod = _load_deploy_relay() + secrets = _secrets(with_wlb=True) + secrets["relay_instances"]["relay-eu"]["wlb"] = {"image": "ghcr.io/me/bot:2.0"} + assert "podman pull ghcr.io/me/bot:2.0" in mod.restart_cmd(secrets, "relay-eu") + ctx = mod.build_context(secrets, "relay-eu") + assert ctx["wlb"]["image"] == "ghcr.io/me/bot:2.0" + def test_volume_path_used_as_is(self): mod = _load_deploy_relay() ctx = mod.build_context(_secrets(), "relay-eu") From 47553a64590ecc20a684157ab5df77c4b5a31117 Mon Sep 17 00:00:00 2001 From: fkzys Date: Tue, 15 Sep 2026 00:58:45 +0300 Subject: [PATCH 4/5] docs: sync with wlb and tests --- README.md | 26 ++++++++++++++++++++++++++ tests/README.md | 18 +++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d093f89..0af71eb 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,19 @@ relay_instances: # relay nodes (deployed via deploy-relay.py) image: ghcr.io/sagernet/sing-box:latest-testing # per-instance override password: "..." # relay→proxy authentication short_id: "..." + wlb: true # whitelist-bypass bot on this relay (see below) + +wlb: # shared whitelist-bypass config (top level) + image: ghcr.io/kulikov0/whitelist-bypass-bot:latest + vk_token: "..." + vk_group_id: "..." + vk_user_ids: "12345,67890" # optional — space-separated VK IDs allowed to use the bot + resources: default # optional, defaults to "default" + cookies_yandex: | + [ + {"name": "Session_id", "value": "..."}, + {"name": "ys", "value": "..."} + ] users: # same credentials for both relay and proxy inbounds - name: alice @@ -270,6 +283,19 @@ users: # same credentials for both relay and proxy in **Architecture:** Users connect to relay instances, relay proxies traffic to proxy nodes, proxy nodes route through WARP. +**whitelist-bypass bot:** Adding `wlb: true` to a relay instance deploys the bot container in the relay's pod. It polls VK for allowed-user requests and creates Yandex Telemost conferences (via an HTTP API — no UDP/WebRTC needed, so the local SOCKS inbound has no `udp`). The bot reaches VK/Telemost through the relay's sing-box SOCKS (`127.0.0.1:1080` in the pod). It needs **fresh Yandex cookies** (`Session_id` is mandatory) exported from the desktop creator app (`Export Cookies` → `cookies.zip` → `cookies-yandex.json`) — paste the JSON into `wlb.cookies_yandex` verbatim; do not re-format it with shell tools like `xargs` (they strip the JSON quotes and the creator fails with "Cannot parse cookies"). Cookie file is written `999:999, 600` and mounted read-only; the bot session volume is mounted with `:U`. + +`wlb` per instance can also be a **dict** — a merge on top of the shared `wlb:` block (instance keys win), e.g. to give one relay its own cookies or bot image: + +```yaml +relay_instances: + relay-eu: + wlb: + cookies_yandex: '[...]' # own session for this relay, everything else shared +``` + +`wlb: true` without a top-level `wlb:` block is an error; a dict form works standalone. `wlb: false` (or absent) leaves the relay untouched. + **Direct outbound rules:** Client configs bypass the proxy for BitTorrent traffic (rejected) and route `qbittorrent`/`i2pd` processes and the `i2pd` user directly — these services need uncapped bandwidth or unfiltered connectivity. **Removing relay:** If `relay_instances` is removed from secrets, clients connect directly to proxy nodes and proxy inbounds accept `users` credentials — no code changes needed. diff --git a/tests/README.md b/tests/README.md index 786102a..47979a0 100644 --- a/tests/README.md +++ b/tests/README.md @@ -5,6 +5,7 @@ | File | Framework | What it tests | |------|-----------|---------------| | `test_lib.py` | pytest | `lib/` — Jinja2 environment setup, SOPS decryption, SSH/rsync remote operations, Cloudflare KV client, `ServiceDeployer` config rendering/diffing/deployment logic | +| `test_deploy_relay.py` | pytest | `sing-box/deploy-relay.py` — wlb contract resolution (`true`/`false`/dict-override, shared-block merge, normalization), file/permission/deploy-dir wiring, restart command, and rendering of the real relay templates (`server_inbounds.json.j2`, `wlb_bot.container.j2`, `wlb_cookies_yandex.json.j2`) | ## Running @@ -49,9 +50,24 @@ Standard pytest suite. No network access, no SSH, no real secrets — all subpro - `render`: outputs rendered template content with variables and file options for both single and multi-instance modes, supports callable `files` - `deploy`: triggers restart command on file change, skips restart when unchanged, respects `--no-restart` flag, creates setup directories via `mkdir -p`, applies owner/mode options, calls secrets hooks with correct arguments, supports callable `restart_cmd` with instance-specific parameters, supports callable `files` and `setup_dirs` with static fallback +### `test_deploy_relay.py` + +Same approach — no network, no SSH, no real secrets. The relay deployer module is loaded from source (`importlib`) and rendered with fake secrets against the **real** templates in `sing-box/templates/`. + +**wlb contract** (`build_context` → `ctx["wlb"]`): +- `wlb: false`/absent → `None`, relay untouched +- `wlb: true` → uses the top-level `wlb:` block; missing block is a `ValueError` +- `wlb: {dict}` → per-instance overrides merged over the shared block (instance keys win), works standalone without a shared block, `{}` falls back to shared +- invalid type (e.g. a string) → `ValueError` +- env keys normalized to canonical case (`vk_token` → `VK_TOKEN`, etc.), `image`/`cookies_yandex` pass through + +**Wiring:** `make_files`/`make_setup_dirs`/`restart_cmd` gated on `wlb` — cookie file perms `999:999/600`, cookies `:ro` mount, sessions `:U`, restart pulls bot image and restarts both units, `After=singbox.container` ordering. + +**Template rendering:** inbounds JSON stays valid and byte-identical when `wlb` is off; with `wlb` on it gets the socks inbound on `127.0.0.1:1080` (no `udp` — creator uses HTTP only); the bot unit carries `VK_TOKEN`/`VK_GROUP_ID`/`VK_USER_IDS`/`RESOURCES`/`UPSTREAM_SOCKS` env; cookies file renders verbatim. + ## Test environment - No root privileges required - No real servers, SSH connections, or Cloudflare API calls - All filesystem operations use pytest's `tmp_path` fixture -- CI runs on push/PR when `lib/`, `tests/test_lib.py`, or `.github/workflows/ci.yml` change (see `.github/workflows/ci.yml`) +- CI runs on push/PR when `lib/`, `sing-box/`, `tests/*.py`, or `.github/workflows/ci.yml` change (see `.github/workflows/ci.yml`) From 0c0ceb80cbaac6bbea111805a45d151177e70bdf Mon Sep 17 00:00:00 2001 From: fkzys Date: Tue, 15 Sep 2026 01:05:57 +0300 Subject: [PATCH 5/5] sing-box: validate WLB config at render time, extract DEFAULT_WLB_IMAGE constant --- README.md | 2 + sing-box/deploy-relay.py | 47 +++++++++++++++++++++-- sing-box/templates/wlb_bot.container.j2 | 2 +- tests/README.md | 2 + tests/test_deploy_relay.py | 50 ++++++++++++++++++++++++- 5 files changed, 98 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0af71eb..9862c4f 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,8 @@ relay_instances: `wlb: true` without a top-level `wlb:` block is an error; a dict form works standalone. `wlb: false` (or absent) leaves the relay untouched. +**Config validation (at render time, before anything is deployed):** the resolved wlb config must contain `vk_token`, `vk_group_id` and a `cookies_yandex` that is a **non-empty JSON array of `{"name", "value"}` objects** — missing fields, unparseable JSON, a bare object or empty array all fail the deploy with a clear error instead of writing a broken file. Missing `image` falls back to `ghcr.io/kulikov0/whitelist-bypass-bot:latest`. + **Direct outbound rules:** Client configs bypass the proxy for BitTorrent traffic (rejected) and route `qbittorrent`/`i2pd` processes and the `i2pd` user directly — these services need uncapped bandwidth or unfiltered connectivity. **Removing relay:** If `relay_instances` is removed from secrets, clients connect directly to proxy nodes and proxy inbounds accept `users` credentials — no code changes needed. diff --git a/sing-box/deploy-relay.py b/sing-box/deploy-relay.py index 9e5ab59..9239c68 100644 --- a/sing-box/deploy-relay.py +++ b/sing-box/deploy-relay.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import json import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) @@ -6,6 +7,8 @@ BASE = Path(__file__).parent +DEFAULT_WLB_IMAGE = 'ghcr.io/kulikov0/whitelist-bypass-bot:latest' + # Keys in the wlb secrets block mapping 1:1 to bot env vars — normalized to canonical # VK_TOKEN/VK_GROUP_ID/etc. so the template never depends on case in secrets. _WLB_ENV_KEYS = {'vk_token', 'vk_group_id', 'vk_user_ids', 'resources'} @@ -22,7 +25,7 @@ def _resolve_wlb(secrets, instance_name): f"relay instance '{instance_name}' has wlb: true enabled, but no wlb: config block " f"at the top level of the secrets file" ) - return shared + return dict(shared) if isinstance(wlb, dict): return {**(shared or {}), **wlb} raise ValueError( @@ -36,6 +39,38 @@ def _normalize_wlb(wlb): return {k.upper() if k.lower() in _WLB_ENV_KEYS else k: v for k, v in wlb.items()} +def _validate_wlb(wlb, instance_name): + for key in ('VK_TOKEN', 'VK_GROUP_ID'): + if not wlb.get(key): + raise ValueError( + f"relay instance '{instance_name}' has wlb enabled, but the resolved config " + f"is missing '{key}'" + ) + raw_cookies = wlb.get('cookies_yandex') + if not raw_cookies: + raise ValueError( + f"relay instance '{instance_name}' has wlb enabled, but the resolved config " + f"is missing 'cookies_yandex'" + ) + try: + cookies = json.loads(raw_cookies) + except (TypeError, ValueError) as e: + raise ValueError( + f"relay instance '{instance_name}' has invalid cookies_yandex JSON: {e}" + ) from e + if not isinstance(cookies, list) or not cookies: + raise ValueError( + f"relay instance '{instance_name}' has invalid cookies_yandex — expected a " + f"non-empty JSON array, got {type(cookies).__name__}" + ) + for c in cookies: + if not isinstance(c, dict) or not c.get('name') or c.get('value') is None: + raise ValueError( + f"relay instance '{instance_name}' has invalid cookies_yandex — every entry " + f"must be an object with 'name' and 'value'" + ) + + def build_context(secrets, instance_name): relay = secrets['relay_instances'][instance_name] @@ -45,12 +80,18 @@ def build_context(secrets, instance_name): inbound_users = secrets.get('users', []) + wlb = _resolve_wlb(secrets, instance_name) + if wlb is not None: + wlb.setdefault('image', DEFAULT_WLB_IMAGE) + wlb = _normalize_wlb(wlb) + _validate_wlb(wlb, instance_name) + return { **secrets, 'basename': basename, 'volume_path': volume_path, 'reality': relay['reality'], - 'wlb': _normalize_wlb(_resolve_wlb(secrets, instance_name)), + 'wlb': wlb, 'current_instance': { **relay, 'image': image, @@ -102,7 +143,7 @@ def restart_cmd(secrets, instance_name): restart_units = basename wlb = _resolve_wlb(secrets, instance_name) if wlb: - pulls.append(f"podman pull {wlb.get('image') or 'ghcr.io/kulikov0/whitelist-bypass-bot:latest'}") + pulls.append(f"podman pull {wlb.get('image') or DEFAULT_WLB_IMAGE}") units += f' {basename}-wlb' restart_units = f'{basename} {basename}-wlb' return ( diff --git a/sing-box/templates/wlb_bot.container.j2 b/sing-box/templates/wlb_bot.container.j2 index 495970c..2bd7fff 100644 --- a/sing-box/templates/wlb_bot.container.j2 +++ b/sing-box/templates/wlb_bot.container.j2 @@ -5,7 +5,7 @@ After={{ basename }}.pod After={{ basename }}.container [Container] -Image={{ wlb.image | default('ghcr.io/kulikov0/whitelist-bypass-bot:latest') }} +Image={{ wlb.image }} ContainerName={{ basename }}-wlb Pod={{ basename }}.pod Environment=VK_TOKEN={{ wlb.VK_TOKEN }} diff --git a/tests/README.md b/tests/README.md index 47979a0..cfcb539 100644 --- a/tests/README.md +++ b/tests/README.md @@ -60,6 +60,8 @@ Same approach — no network, no SSH, no real secrets. The relay deployer module - `wlb: {dict}` → per-instance overrides merged over the shared block (instance keys win), works standalone without a shared block, `{}` falls back to shared - invalid type (e.g. a string) → `ValueError` - env keys normalized to canonical case (`vk_token` → `VK_TOKEN`, etc.), `image`/`cookies_yandex` pass through +- **config validation:** resolved config must have `VK_TOKEN`, `VK_GROUP_ID` and a non-empty `cookies_yandex` JSON array of `{"name","value"}` — missing field, unparseable JSON, non-array and entry-without-value all raise at render time +- missing `image` falls back to `DEFAULT_WLB_IMAGE` (single constant shared with the template default) **Wiring:** `make_files`/`make_setup_dirs`/`restart_cmd` gated on `wlb` — cookie file perms `999:999/600`, cookies `:ro` mount, sessions `:U`, restart pulls bot image and restarts both units, `After=singbox.container` ordering. diff --git a/tests/test_deploy_relay.py b/tests/test_deploy_relay.py index 6893317..4baf2fd 100644 --- a/tests/test_deploy_relay.py +++ b/tests/test_deploy_relay.py @@ -110,10 +110,15 @@ def test_dict_only_without_shared_block(self): mod = _load_deploy_relay() secrets = _secrets(with_wlb=True) del secrets["wlb"] - secrets["relay_instances"]["relay-eu"]["wlb"] = {"VK_TOKEN": "own", "vk_group_id": "555"} + secrets["relay_instances"]["relay-eu"]["wlb"] = { + "VK_TOKEN": "own", + "vk_group_id": "555", + "cookies_yandex": '[{"name": "sessionid", "value": "abc"}]', + } ctx = mod.build_context(secrets, "relay-eu") assert ctx["wlb"]["VK_TOKEN"] == "own" assert ctx["wlb"]["VK_GROUP_ID"] == "555" + assert ctx["wlb"]["cookies_yandex"] == '[{"name": "sessionid", "value": "abc"}]' def test_dict_empty_uses_shared(self): mod = _load_deploy_relay() @@ -129,6 +134,49 @@ def test_wlb_invalid_type_raises(self): with pytest.raises(ValueError, match="expected true/false or a dict"): mod.build_context(secrets, "relay-eu") + def test_missing_vk_token_raises(self): + mod = _load_deploy_relay() + secrets = _secrets(with_wlb=True) + del secrets["wlb"]["vk_token"] + with pytest.raises(ValueError, match="missing 'VK_TOKEN'"): + mod.build_context(secrets, "relay-eu") + + def test_missing_cookies_yandex_raises(self): + mod = _load_deploy_relay() + secrets = _secrets(with_wlb=True) + del secrets["wlb"]["cookies_yandex"] + with pytest.raises(ValueError, match="missing 'cookies_yandex'"): + mod.build_context(secrets, "relay-eu") + + def test_dict_form_missing_vk_fields_raises(self): + mod = _load_deploy_relay() + secrets = _secrets(with_wlb=True) + del secrets["wlb"] + secrets["relay_instances"]["relay-eu"]["wlb"] = {"cookies_yandex": '[{"name": "sessionid", "value": "abc"}]'} + with pytest.raises(ValueError, match="missing 'VK_TOKEN'"): + mod.build_context(secrets, "relay-eu") + + def test_invalid_cookies_json_raises(self): + mod = _load_deploy_relay() + secrets = _secrets(with_wlb=True) + secrets["wlb"]["cookies_yandex"] = "{name: sessionid}" + with pytest.raises(ValueError, match="invalid cookies_yandex JSON"): + mod.build_context(secrets, "relay-eu") + + def test_non_array_cookies_raises(self): + mod = _load_deploy_relay() + secrets = _secrets(with_wlb=True) + secrets["wlb"]["cookies_yandex"] = '{"name": "sessionid"}' + with pytest.raises(ValueError, match="non-empty JSON array"): + mod.build_context(secrets, "relay-eu") + + def test_cookie_without_value_field_raises(self): + mod = _load_deploy_relay() + secrets = _secrets(with_wlb=True) + secrets["wlb"]["cookies_yandex"] = '[{"name": "sessionid"}]' + with pytest.raises(ValueError, match="'name' and 'value'"): + mod.build_context(secrets, "relay-eu") + def test_dict_form_custom_image_used_in_restart(self): mod = _load_deploy_relay() secrets = _secrets(with_wlb=True)