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
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ instances: # proxy nodes (deployed via deploy.py)
host: server1
reality:
server_name: "proxy-sni.com"
wlb: true # whitelist-bypass bot on this proxy (optional, see below)
instance2:
host: server2
reality:
Expand All @@ -259,7 +260,7 @@ 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: true # whitelist-bypass bot on this relay (optional, see below)

wlb: # shared whitelist-bypass config (top level)
image: ghcr.io/kulikov0/whitelist-bypass-bot:latest
Expand All @@ -283,20 +284,22 @@ 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`.
**whitelist-bypass bot:** `wlb` is an option for ANY instance — proxy nodes (`instances`, deploy.py) and relay nodes (`relay_instances`, deploy-relay.py) alike. Adding `wlb: true` deploys the bot container in that instance'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's own HTTP calls to VK API/Telemost go out directly from its own IP; with upstream SOCKS enabled (default) the joiner's tunneled traffic is routed through the instance's sing-box SOCKS (`127.0.0.1:1080` in the pod), where the instance's route rules/filters apply. 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:
`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 instance its own cookies or bot image:

```yaml
relay_instances:
relay-eu:
instances:
instance1:
wlb:
cookies_yandex: '[...]' # own session for this relay, everything else shared
cookies_yandex: '[...]' # own session for this instance, 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.
`wlb.direct: true` (bool, optional) disables `UPSTREAM_SOCKS` on the bot — the creator then opens the joiner's connections straight from its own IP, without going through the local sing-box tunnel (and the `wlb_socks_in` inbound is not added to `inbounds.json` either). Default (`false`/absent) keeps `UPSTREAM_SOCKS=127.0.0.1:1080`.

`wlb: true` without a top-level `wlb:` block is an error; a dict form works standalone. `wlb: false` (or absent) leaves the instance 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`.
**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. `direct`, when present, must be a boolean. 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.

Expand Down
108 changes: 108 additions & 0 deletions lib/wlb.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Shared whitelist-bypass (wlb) bot contract for sing-box instance deployers.

`wlb` is a per-instance option available on ANY instance group — proxy nodes
(`instances`, deploy.py) and relay nodes (`relay_instances`, deploy-relay.py)
alike. Per instance:

- false/absent → bot disabled, instance untouched,
- true → use the top-level `wlb:` block from the secrets file (error if missing),
- {dict} → merge on top of the shared block (instance keys win), works standalone.

The resolved block is normalized (env-var keys uppercased) and validated before
anything is rendered. An extra `direct` flag (boolean, default false) toggles
`UPSTREAM_SOCKS` on the bot: `true` makes the creator open the joiner's
connections straight from its own IP without going through the instance's
local sing-box SOCKS; `false`/absent keeps `UPSTREAM_SOCKS=127.0.0.1:1080` so
user traffic lands in the instance's sing-box filters/route rules.
"""

import json

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, instances_key):
"""Raw resolved wlb dict (un-normalized, no default image) or None when disabled."""
wlb = secrets[instances_key][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"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"instance '{instance_name}' has wlb: {wlb!r} — expected true/false or a dict of overrides"
)


def normalize_wlb(wlb):
"""Canonical-case env-var keys (vk_token → VK_TOKEN); `direct` stays lowercase."""
if not isinstance(wlb, dict):
return None
normalized = {}
for k, v in wlb.items():
if k.lower() in _WLB_ENV_KEYS:
normalized[k.upper()] = v
elif k.lower() == 'direct':
normalized['direct'] = v
else:
normalized[k] = v
return normalized


def validate_wlb(wlb, instance_name):
for key in ('VK_TOKEN', 'VK_GROUP_ID'):
if not wlb.get(key):
raise ValueError(
f"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"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"instance '{instance_name}' has invalid cookies_yandex JSON: {e}"
) from e
if not isinstance(cookies, list) or not cookies:
raise ValueError(
f"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"instance '{instance_name}' has invalid cookies_yandex — every entry "
f"must be an object with 'name' and 'value'"
)
direct = wlb.get('direct')
if direct is not None and not isinstance(direct, bool):
raise ValueError(
f"instance '{instance_name}' has invalid wlb.direct: {direct!r} — expected true/false"
)


def prepare_wlb(secrets, instance_name, instances_key):
"""Resolved + default-image + normalized + validated wlb dict, or None when disabled."""
wlb = resolve_wlb(secrets, instance_name, instances_key)
if wlb is None:
return None
wlb.setdefault('image', DEFAULT_WLB_IMAGE)
wlb = normalize_wlb(wlb)
validate_wlb(wlb, instance_name)
return wlb
91 changes: 12 additions & 79 deletions sing-box/deploy-relay.py
Original file line number Diff line number Diff line change
@@ -1,97 +1,30 @@
#!/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
from lib.wlb import prepare_wlb, resolve_wlb

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'"
)
INSTANCES_KEY = 'relay_instances'


def build_context(secrets, instance_name):
relay = secrets['relay_instances'][instance_name]
relay = secrets[INSTANCES_KEY][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,
'wlb': prepare_wlb(secrets, instance_name, INSTANCES_KEY),
'current_instance': {
**relay,
'image': image,
Expand All @@ -103,7 +36,7 @@ def build_context(secrets, instance_name):


def make_files(secrets, instance_name):
relay = secrets['relay_instances'][instance_name]
relay = secrets[INSTANCES_KEY][instance_name]
basename = secrets['common']['basename']
volume_path = relay.get('volume_path') or secrets['common']['volume_path']
settings_dir = f'{volume_path}/settings'
Expand All @@ -114,7 +47,7 @@ def make_files(secrets, instance_name):
('server_container.j2', f'/etc/containers/systemd/{basename}.container'),
('server_pod.j2', f'/etc/containers/systemd/{basename}.pod'),
]
if _resolve_wlb(secrets, instance_name):
if resolve_wlb(secrets, instance_name, INSTANCES_KEY):
files += [
('wlb_bot.container.j2', f'/etc/containers/systemd/{basename}-wlb.container'),
('wlb_cookies_yandex.json.j2', f'{volume_path}/cookies/cookies-yandex.json',
Expand All @@ -124,26 +57,26 @@ def make_files(secrets, instance_name):


def make_setup_dirs(secrets, instance_name):
relay = secrets['relay_instances'][instance_name]
relay = secrets[INSTANCES_KEY][instance_name]
volume_path = relay.get('volume_path') or secrets['common']['volume_path']
settings_dir = f'{volume_path}/settings'
dirs = [f'{settings_dir}', f'{volume_path}/cache']
if _resolve_wlb(secrets, instance_name):
if resolve_wlb(secrets, instance_name, INSTANCES_KEY):
dirs += [f'{volume_path}/cookies', f'{volume_path}/wlb-sessions']
return dirs


def restart_cmd(secrets, instance_name):
relay = secrets['relay_instances'][instance_name]
relay = secrets[INSTANCES_KEY][instance_name]
image = relay.get('image') or secrets['common']['image']
basename = secrets['common']['basename']

pulls = [f'podman pull {image}']
units = f'{basename} {basename}-pod'
restart_units = basename
wlb = _resolve_wlb(secrets, instance_name)
wlb = prepare_wlb(secrets, instance_name, INSTANCES_KEY)
if wlb:
pulls.append(f"podman pull {wlb.get('image') or DEFAULT_WLB_IMAGE}")
pulls.append(f"podman pull {wlb['image']}")
units += f' {basename}-wlb'
restart_units = f'{basename} {basename}-wlb'
return (
Expand All @@ -158,7 +91,7 @@ def restart_cmd(secrets, instance_name):
'templates_dir': BASE / 'templates',
'secrets_file': BASE / 'secrets' / 'secrets.enc.yaml',
'multi_instance': True,
'instances_key': 'relay_instances',
'instances_key': INSTANCES_KEY,
'context_builder': build_context,
'files': make_files,
'setup_dirs': make_setup_dirs,
Expand Down
Loading