Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ certs/.certstore/
graphify-out/cache/
**.graphify_*.json
**.graphify_python
.omo
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -270,6 +283,21 @@ 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.

**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.
Expand Down
105 changes: 98 additions & 7 deletions sing-box/deploy-relay.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,97 @@
#!/usr/bin/env python3
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from lib.deploy import ServiceDeployer

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'}


def _resolve_wlb(secrets, instance_name):
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: true enabled, but no wlb: config block "
f"at the top level of the secrets file"
)
return dict(shared)
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):
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 _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]

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', [])

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': wlb,
'current_instance': {
**relay,
'image': image,
Expand All @@ -34,32 +107,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 DEFAULT_WLB_IMAGE}")
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}'
)


Expand Down
12 changes: 10 additions & 2 deletions sing-box/secrets/secrets.enc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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
9 changes: 9 additions & 0 deletions sing-box/templates/server_inbounds.json.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
]
}
27 changes: 27 additions & 0 deletions sing-box/templates/wlb_bot.container.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
[Unit]
Description=whitelist-bypass bot
Requires={{ basename }}.pod
After={{ basename }}.pod
After={{ basename }}.container

[Container]
Image={{ wlb.image }}
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
2 changes: 2 additions & 0 deletions sing-box/templates/wlb_cookies_yandex.json.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{# templates/wlb_cookies_yandex.json.j2 #}
{{ wlb.cookies_yandex }}
20 changes: 19 additions & 1 deletion tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -49,9 +50,26 @@ 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
- **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.

**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`)
Loading