feat: добавить аддон splitgw — прозрачный split-tunnel gateway (sing-box + Hysteria2 TPROXY)

- Роль: defaults, tasks (main/deploy-host/deploy-k8s), handlers, templates (sing-box config, iptables setup/teardown, systemd, K8s DaemonSet/ConfigMap/Secret)
- Режимы: systemd (host) и K8s DaemonSet с hostNetwork + privileged init-container
- Маршрутизация: YouTube/Google → Hysteria2, RU (geoip/geosite) → прямой, остальное → прямой
- DNS без утечек: protocol=dns перехватывается TPROXY, per-domain DNS серверы
- Интеграция: inventory [splitgw], addons.yml flag, Makefile target, playbooks/addons.yml
- Документация: README.md, docs/addons.md, README.md (счётчик 36)
This commit is contained in:
Sergey Antropoff
2026-04-26 06:56:17 +03:00
parent fb5dcbc3af
commit 07fdc9a994
22 changed files with 1505 additions and 3 deletions

528
addons/splitgw/README.md Normal file
View File

@@ -0,0 +1,528 @@
# Split Gateway
Прозрачный split-tunnel шлюз для домашней сети. Трафик Android TV (или любого другого устройства) автоматически разделяется на два потока — без какой-либо настройки на самом устройстве.
## Архитектура
```
Android TV
│ (любой трафик — TV ничего не знает о прокси)
Keenetic (policy route: from TV_IP → gateway node)
K3S нода / Linux хост (gateway node)
│ iptables TPROXY → sing-box :7893
┌──────────────────────────────────────────────────┐
│ sing-box │
│ │
│ DNS: YouTube домены → 8.8.8.8 via Hysteria2 │
│ RU домены → 192.168.1.1 (роутер) │
│ │
│ Routing: │
│ geosite:youtube ──► Hysteria2 ──► VPS ──► 🌍 │
│ geoip:ru + .ru ──► direct ──► 🇷🇺 │
│ всё остальное ──► direct │
└──────────────────────────────────────────────────┘
```
**Ключевые свойства:**
- Прозрачный TPROXY — TV ничего не настраивается
- YouTube (TCP + UDP/QUIC) — через Hysteria2
- RU сервисы (Plex, Кинопоиск, Яндекс, ВК) — прямой маршрут
- DNS без утечек — раздельные DNS серверы по типу домена
- geoip + geosite БД — автообновление каждые 7 дней
## Компоненты
| Компонент | Роль |
|---|---|
| **sing-box** | Routing engine: TPROXY inbound + split routing |
| **Hysteria2 outbound** | Встроен в sing-box — отдельный процесс не нужен |
| **iptables TPROXY** | Перехват трафика от TV на порт sing-box |
| **splitgw-rules.service** | systemd: применяет iptables при старте |
| **singbox.service** | systemd: запускает sing-box |
---
## Установка
### Шаг 1 — Добавить узел-шлюз в inventory
```ini
# inventory/hosts.ini
[splitgw]
# Вариант А: отдельный Linux хост как шлюз
gateway ansible_host=192.168.1.10 ansible_user=root
# Вариант Б: K3S нода как шлюз (та же нода что и master01)
# gateway ansible_host=192.168.1.10 ansible_user=devops
```
### Шаг 2 — Настроить переменные
```yaml
# group_vars/all/main.yml или host_vars/gateway/main.yml
splitgw_tv_sources:
- "192.168.1.100/32" # IP Android TV (узнать: Keenetic → Устройства)
splitgw_router_ip: "192.168.1.1" # IP роутера Keenetic
```
### Шаг 3 — Добавить Hysteria2 в vault
Если уже используешь mediaserver аддон — vault уже настроен, перейди к шагу 4.
```bash
make vault-edit
```
```yaml
# group_vars/all/vault.yml
# Способ 1 — URL из Shadowrocket/NekoBox:
vault_hysteria2_url: "hysteria2://mypassword@vps.example.com:443?insecure=1"
# Способ 2 — по отдельности:
vault_hysteria2_server: "vps.example.com:443"
vault_hysteria2_auth: "mypassword"
```
### Шаг 4 — Деплой
```bash
# С SSH-ключом:
make addon-splitgw
# С SSH-паролем:
make addon-splitgw ARGS="-k"
# С SSH + sudo паролем:
make addon-splitgw ARGS="-k -K"
```
### Шаг 5 — Настроить Keenetic
Смотри раздел [Настройка Keenetic](#настройка-keenetic).
---
## Настройка Keenetic
Keenetic выполняет **policy-based routing**: трафик от TV направляется на узел-шлюз вместо стандартного маршрута.
### В веб-интерфейсе Keenetic
**1. Узнать IP Android TV**
`Мои сети и Wi-Fi` → список подключённых устройств → найти TV → скопировать IP.
Зафиксировать IP (чтобы не менялся):
`Мои сети и Wi-Fi` → устройство → `Постоянный IP-адрес` → задать статический IP.
**2. Создать политику маршрутизации**
`Интернет``Другие подключения``Маршруты``Добавить маршрут`.
Или через `Управление``Политика маршрутизации` (зависит от версии прошивки).
В KeeneticOS 4.x: `Интернет``Доступ в интернет``Политика маршрутизации``+`.
**3. Параметры правила**
```
Тип: Маршрут источника (source-based routing)
Источник: 192.168.1.100 ← IP Android TV
Через: 192.168.1.10 ← IP узла-шлюза (gateway node)
Метрика: 10 (ниже = приоритетнее)
```
**4. Через CLI Keenetic (точнее)**
Подключись к Keenetic по SSH:
```bash
# Добавить статический маршрут для TV через шлюз:
ip route 192.168.1.100/32 192.168.1.10 auto
# Или через policy routing (если поддерживается):
ip policy-route source 192.168.1.100 nexthop 192.168.1.10
```
Сохранить: `system configuration save`
**5. Альтернатива — DHCP опция Gateway**
Если TV получает IP по DHCP — настроить на роутере выдачу специфического шлюза для TV:
`Мои сети и Wi-Fi``Домашняя сеть``Настройки DHCP` → найти устройство → `Шлюз: 192.168.1.10`
Это заставит TV слать трафик через 192.168.1.10 (gateway node) вместо роутера.
---
## Полные конфигурационные файлы
### sing-box config (config.json)
Генерируется автоматически шаблоном. Полная структура:
```json
{
"log": { "level": "info", "timestamp": true },
"dns": {
"servers": [
{ "tag": "dns-proxy", "address": "tls://8.8.8.8", "detour": "proxy" },
{ "tag": "dns-direct", "address": "udp://8.8.4.4", "detour": "direct" },
{ "tag": "dns-ru", "address": "udp://192.168.1.1", "detour": "direct" }
],
"rules": [
{ "outbound": "any", "server": "dns-direct" },
{ "rule_set": ["geosite-youtube", "geosite-google"], "server": "dns-proxy" },
{ "rule_set": ["geosite-category-ru"], "server": "dns-ru" },
{ "domain_suffix": [".ru", ".рф", ".su"], "server": "dns-ru" }
],
"final": "dns-direct",
"independent_cache": true,
"reverse_mapping": true
},
"inbounds": [
{
"type": "tproxy",
"tag": "tproxy-in",
"listen": "::",
"listen_port": 7893,
"tcp_fast_open": true,
"udp_fragment": true,
"sniff": true,
"sniff_override_destination": true,
"domain_strategy": "prefer_ipv4",
"udp_timeout": "5m"
}
],
"outbounds": [
{
"type": "hysteria2",
"tag": "proxy",
"server": "vps.example.com",
"server_port": 443,
"password": "YOUR_PASSWORD",
"obfs": { "type": "salamander", "salamander": { "password": "OBFS" } },
"tls": { "enabled": true, "insecure": false }
},
{ "type": "direct", "tag": "direct" },
{ "type": "block", "tag": "block" },
{ "type": "dns", "tag": "dns-out" }
],
"route": {
"rule_set": [
{ "tag": "geosite-youtube", "type": "remote", "format": "binary",
"url": "https://github.com/SagerNet/sing-geosite/releases/latest/download/geosite-youtube.srs" },
{ "tag": "geosite-google", "type": "remote", "format": "binary",
"url": "https://github.com/SagerNet/sing-geosite/releases/latest/download/geosite-google.srs" },
{ "tag": "geosite-category-ru", "type": "remote", "format": "binary",
"url": "https://github.com/SagerNet/sing-geosite/releases/latest/download/geosite-category-ru.srs" },
{ "tag": "geoip-ru", "type": "remote", "format": "binary",
"url": "https://github.com/SagerNet/sing-geoip/releases/latest/download/geoip-ru.srs" }
],
"rules": [
{ "protocol": "dns", "outbound": "dns-out" },
{ "ip_is_private": true, "outbound": "direct" },
{
"type": "logical", "mode": "or",
"rules": [
{ "rule_set": ["geosite-youtube"] },
{ "domain_keyword": ["youtube", "googlevideo", "ytimg", "ggpht"] }
],
"outbound": "proxy"
},
{
"type": "logical", "mode": "or",
"rules": [
{ "rule_set": ["geosite-category-ru", "geoip-ru"] },
{ "domain_suffix": [".ru", ".рф", ".su"] },
{ "domain_keyword": ["yandex", "kinopoisk", "vk.com"] }
],
"outbound": "direct"
}
],
"final": "direct",
"auto_detect_interface": true
}
}
```
### iptables TPROXY правила
```bash
# Таблица маршрутизации: помеченные пакеты → локальные
ip rule add fwmark 0x1 lookup 100
ip route add local 0.0.0.0/0 dev lo table 100
# Цепочка SPLITGW
iptables -t mangle -N SPLITGW
iptables -t mangle -A SPLITGW -i lo -j RETURN
iptables -t mangle -A SPLITGW -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
iptables -t mangle -A SPLITGW -d 127.0.0.0/8 -j RETURN
iptables -t mangle -A SPLITGW -d 10.0.0.0/8 -j RETURN
iptables -t mangle -A SPLITGW -d 172.16.0.0/12 -j RETURN
iptables -t mangle -A SPLITGW -d 192.168.0.0/16 -j RETURN
iptables -t mangle -A SPLITGW -p tcp -j TPROXY --on-port 7893 --tproxy-mark 0x1/0x1
iptables -t mangle -A SPLITGW -p udp -j TPROXY --on-port 7893 --tproxy-mark 0x1/0x1
# Применить к трафику от TV
iptables -t mangle -A PREROUTING -s 192.168.1.100/32 -j SPLITGW
```
---
## Kubernetes DaemonSet (альтернатива systemd)
Используй когда gateway node — нода K3S кластера.
```bash
# 1. Пометить ноду
kubectl label node <gateway-node> splitgw=true
# 2. Деплой
make addon-splitgw ARGS="-e splitgw_deploy_mode=k8s"
```
DaemonSet запускает sing-box с `hostNetwork: true`. Init-контейнер (privileged) применяет iptables правила на хосте. Конфиг — в ConfigMap.
---
## Параметры
```yaml
# group_vars/all/main.yml
# TV устройства (один или несколько IP/подсетей)
splitgw_tv_sources:
- "192.168.1.100/32" # Android TV
- "192.168.1.101/32" # SmartTV на кухне
splitgw_router_ip: "192.168.1.1" # Keenetic (для RU DNS)
splitgw_tproxy_port: 7893 # порт sing-box
splitgw_singbox_log_level: "info" # trace|debug|info|warn|error
# Hysteria2 (из vault — те же что используются в mediaserver)
# vault_hysteria2_url: "hysteria2://..." ← уже в vault
# Режим деплоя
splitgw_deploy_mode: "host" # host | k8s
```
---
## Проверка работы
### 1. Статус сервисов
```bash
# На gateway node:
systemctl status singbox
systemctl status splitgw-rules
# Логи sing-box в реальном времени:
journalctl -u singbox -f
# Проверить конфиг:
sing-box check --config /etc/sing-box/config.json
```
### 2. iptables правила
```bash
# Посмотреть цепочку SPLITGW:
iptables -t mangle -L SPLITGW -v -n
# Посмотреть rule для fwmark:
ip rule list | grep 0x1
# Посмотреть таблицу 100:
ip route show table 100
```
### 3. Трафик с TV
```bash
# На gateway node — смотреть трафик с TV:
tcpdump -i any -nn "src host 192.168.1.100" -c 20
# Проверить что TPROXY работает (пакеты приходят в sing-box):
ss -tnlp | grep 7893
# Реальный тест: с Android TV открыть YouTube, затем:
conntrack -L -s 192.168.1.100 | grep ESTABLISHED | head -20
```
### 4. Проверка маршрутов
```bash
# Проверить что YouTube идёт через прокси:
# Подключиться к TV по ADB (Android Debug Bridge):
adb connect 192.168.1.100
adb shell curl -s https://ifconfig.me # должен вернуть IP VPS
# Проверить что ВКонтакте идёт напрямую:
adb shell curl -s https://api.vk.com # должен работать с RU IP
```
### 5. DNS проверка
```bash
# Проверить что DNS для YouTube резолвится через прокси:
# На gateway node:
dig youtube.com @8.8.8.8 +short
# Должен вернуть зарубежный IP (не российский CDN)
# Лог DNS запросов в sing-box (включить debug):
sing-box run --config /etc/sing-box/config.json 2>&1 | grep -i dns
```
---
## Диагностика проблем
### YouTube не работает через прокси
```bash
# 1. Проверить что sing-box запущен:
systemctl status singbox
# 2. Проверить iptables:
iptables -t mangle -L PREROUTING -v -n | grep SPLITGW
# 3. Проверить подключение к Hysteria2 серверу:
sing-box run --config /etc/sing-box/config.json 2>&1 | grep -E "(hysteria|proxy|connect)"
# 4. Включить debug логи временно:
sed -i 's/"level": "info"/"level": "debug"/' /etc/sing-box/config.json
systemctl restart singbox
journalctl -u singbox -f | grep -i youtube
```
### RU сервисы идут через прокси (не должны)
```bash
# Проверить что geoip-ru БД загружена:
ls -la /var/lib/sing-box/*.srs
# Перезапустить sing-box (загружает БД при старте):
systemctl restart singbox
# Проверить routing rule для yandex.ru:
sing-box run --config /etc/sing-box/config.json 2>&1 | grep yandex
```
### TV не видит интернет
```bash
# 1. Включить IP forwarding на gateway:
sysctl net.ipv4.ip_forward # должно быть 1
# 2. Проверить что TV трафик доходит:
tcpdump -i any -nn "src host TV_IP"
# 3. Проверить маршрут на шлюзе:
ip route get 8.8.8.8 from TV_IP # должен показать интерфейс
# 4. Проверить что sing-box отвечает:
curl -x socks5h://127.0.0.1:1080 https://ifconfig.me # если включён SOCKS5
```
### Keenetic не маршрутизирует трафик через шлюз
```bash
# С самого TV (если есть ADB):
adb shell ip route # проверить default gateway
# На роутере через CLI:
show ip route
# Должен быть маршрут: 192.168.1.100/32 via 192.168.1.10
# Попробовать ping от роутера до шлюза:
ping 192.168.1.10
```
---
## Мониторинг
### Логи (по умолчанию info)
```bash
# Текущие логи:
journalctl -u singbox -n 100
# Режим слежения:
journalctl -u singbox -f
# Фильтр ошибок:
journalctl -u singbox | grep -E "(ERR|WARN)"
```
### Статистика трафика
```bash
# Счётчики iptables цепочки SPLITGW:
iptables -t mangle -L SPLITGW -v -n
# Активные соединения через sing-box:
cat /proc/net/nf_conntrack | grep "src=192.168.1.100" | wc -l
```
### Prometheus (если установлен prometheus-stack)
sing-box не экспортирует метрики нативно. Для мониторинга используй:
```yaml
# Prometheus node-exporter метрики iptables (через iptables-exporter или вручную):
# В ServiceMonitor добавить textfile collector с iptables счётчиками
```
---
## Обновление
```bash
# Обновить sing-box до последней версии:
make addon-splitgw
# Обновить только конфиг (изменил переменные):
make addon-splitgw ARGS="--tags config"
# Добавить новый TV:
# group_vars/all/main.yml:
# splitgw_tv_sources:
# - "192.168.1.100/32"
# - "192.168.1.105/32" ← новый TV
make addon-splitgw
```
---
## Деинсталляция
```bash
# На gateway node:
systemctl disable --now singbox splitgw-rules
rm -f /etc/systemd/system/{singbox,splitgw-rules}.service
rm -rf /etc/sing-box /var/lib/sing-box /var/log/sing-box
rm -f /usr/local/bin/sing-box
systemctl daemon-reload
# Удалить iptables правила:
iptables -t mangle -D PREROUTING -s 192.168.1.100/32 -j SPLITGW
iptables -t mangle -F SPLITGW
iptables -t mangle -X SPLITGW
ip rule del fwmark 0x1 lookup 100
ip route flush table 100
```

View File

@@ -0,0 +1,7 @@
---
- name: Install Split Gateway (sing-box + Hysteria2 TPROXY)
hosts: splitgw
gather_facts: true
become: true
roles:
- role: "{{ playbook_dir }}/role"

View File

@@ -0,0 +1,77 @@
---
# ─── Split Gateway — прозрачный split-tunnel через Hysteria2 + sing-box ───────
# YouTube → Hysteria2 (зарубежный IP)
# RU сервисы → direct
# Всё остальное → direct
# ─── IP устройств, трафик которых нужно проксировать ─────────────────────────
# Можно указать один IP или несколько подсетей
splitgw_tv_sources:
- "192.168.1.100/32" # Android TV (ЗАМЕНИ на реальный IP)
# ─── IP роутера Keenetic (для RU DNS) ─────────────────────────────────────────
splitgw_router_ip: "192.168.1.1"
# ─── Hysteria2 — берётся из vault (те же переменные что и в mediaserver) ──────
# Приоритет: URL → отдельные поля
splitgw_hysteria2_url: "{{ vault_hysteria2_url | default('') }}"
splitgw_hysteria2_server: "{{ vault_hysteria2_server | default('') }}"
splitgw_hysteria2_auth: "{{ vault_hysteria2_auth | default('') }}"
splitgw_hysteria2_insecure: false
splitgw_hysteria2_obfs_type: ""
splitgw_hysteria2_obfs_password: ""
# ─── Sing-box ─────────────────────────────────────────────────────────────────
splitgw_singbox_version: "" # пусто = автоопределение последней
splitgw_singbox_config_dir: /etc/sing-box
splitgw_singbox_data_dir: /var/lib/sing-box # geoip/geosite БД
splitgw_singbox_log_dir: /var/log/sing-box
splitgw_singbox_log_level: "info" # trace|debug|info|warn|error
# ─── TPROXY параметры ─────────────────────────────────────────────────────────
splitgw_tproxy_port: 7893
splitgw_tproxy_mark: "0x1"
splitgw_tproxy_table: 100
# ─── Маршрутизация ────────────────────────────────────────────────────────────
# Дополнительные домены для YouTube (кроме geosite-youtube)
splitgw_youtube_extra_keywords:
- "youtube"
- "googlevideo"
- "ytimg"
- "ggpht"
- "googleusercontent"
- "youtu.be"
- "gvt1.com"
- "youtube-nocookie"
# Дополнительные RU домены/ключевые слова (кроме geosite-category-ru + geoip-ru)
splitgw_ru_extra_suffixes:
- ".ru"
- ".рф"
- ".su"
splitgw_ru_extra_keywords:
- "yandex"
- "kinopoisk"
- "mail.ru"
- "ok.ru"
- "vk.com"
- "sberbank"
- "tinkoff"
- "gosuslugi"
- "mos.ru"
- "avito"
- "hh.ru"
- "wildberries"
- "ozon"
# ─── Режим деплоя ─────────────────────────────────────────────────────────────
# host — systemd сервис прямо на хосте (рекомендуется)
# k8s — DaemonSet в K3S кластере
splitgw_deploy_mode: "host"
# ─── K8s режим (splitgw_deploy_mode: k8s) ────────────────────────────────────
splitgw_k8s_namespace: "splitgw"
# Метка на ноде-шлюзе: kubectl label node <node> splitgw=true
splitgw_k8s_node_label: "splitgw=true"

View File

@@ -0,0 +1,17 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
become: true
- name: Restart splitgw-rules
ansible.builtin.systemd:
name: splitgw-rules
state: restarted
become: true
- name: Restart singbox
ansible.builtin.systemd:
name: singbox
state: restarted
become: true

View File

@@ -0,0 +1,217 @@
---
# ── Определить архитектуру и install_dir ─────────────────────────────────────
- name: Set sing-box install dir
ansible.builtin.set_fact:
splitgw_singbox_install_dir: /usr/local/bin
_splitgw_arch: "{{ 'arm64' if ansible_architecture == 'aarch64' else 'amd64' }}"
# ── Получить последнюю версию sing-box ───────────────────────────────────────
- name: Get latest sing-box release from GitHub
ansible.builtin.uri:
url: https://api.github.com/repos/SagerNet/sing-box/releases/latest
return_content: true
headers:
Accept: "application/vnd.github.v3+json"
register: _singbox_release
when: splitgw_singbox_version == ""
delegate_to: localhost
become: false
run_once: true
- name: Set sing-box version fact
ansible.builtin.set_fact:
_singbox_version: >-
{{ (splitgw_singbox_version != '')
| ternary(splitgw_singbox_version,
_singbox_release.json.tag_name | regex_replace('^v', '')) }}
run_once: true
- name: Show sing-box version
ansible.builtin.debug:
msg: "Installing sing-box v{{ _singbox_version }} ({{ _splitgw_arch }})"
# ── Зависимости ───────────────────────────────────────────────────────────────
- name: Install dependencies
ansible.builtin.package:
name:
- iptables
- iproute2
- ca-certificates
- curl
state: present
become: true
# ── Скачать sing-box ──────────────────────────────────────────────────────────
- name: Set sing-box download URL
ansible.builtin.set_fact:
_singbox_url: >-
https://github.com/SagerNet/sing-box/releases/download/v{{ _singbox_version
}}/sing-box-{{ _singbox_version }}-linux-{{ _splitgw_arch }}.tar.gz
- name: Download sing-box archive
ansible.builtin.get_url:
url: "{{ _singbox_url }}"
dest: /tmp/sing-box.tar.gz
mode: "0644"
become: true
register: _singbox_downloaded
- name: Extract sing-box binary
ansible.builtin.unarchive:
src: /tmp/sing-box.tar.gz
dest: /tmp/
remote_src: true
creates: "/tmp/sing-box-{{ _singbox_version }}-linux-{{ _splitgw_arch }}/sing-box"
become: true
when: _singbox_downloaded.changed
- name: Install sing-box binary
ansible.builtin.copy:
src: "/tmp/sing-box-{{ _singbox_version }}-linux-{{ _splitgw_arch }}/sing-box"
dest: "{{ splitgw_singbox_install_dir }}/sing-box"
mode: "0755"
owner: root
group: root
remote_src: true
become: true
notify: Restart singbox
# ── Директории ────────────────────────────────────────────────────────────────
- name: Create sing-box directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- "{{ splitgw_singbox_config_dir }}"
- "{{ splitgw_singbox_data_dir }}"
- "{{ splitgw_singbox_log_dir }}"
become: true
# ── Конфиги ───────────────────────────────────────────────────────────────────
- name: Template sing-box config
ansible.builtin.template:
src: singbox-config.json.j2
dest: "{{ splitgw_singbox_config_dir }}/config.json"
owner: root
group: root
mode: "0640"
become: true
notify: Restart singbox
- name: Template iptables setup script
ansible.builtin.template:
src: setup-iptables.sh.j2
dest: "{{ splitgw_singbox_config_dir }}/setup-iptables.sh"
owner: root
group: root
mode: "0750"
become: true
- name: Template iptables teardown script
ansible.builtin.template:
src: teardown-iptables.sh.j2
dest: "{{ splitgw_singbox_config_dir }}/teardown-iptables.sh"
owner: root
group: root
mode: "0750"
become: true
# ── sysctl persist ────────────────────────────────────────────────────────────
- name: Persist IP forwarding in sysctl
ansible.posix.sysctl:
name: net.ipv4.ip_forward
value: "1"
state: present
sysctl_set: true
reload: true
become: true
# ── Systemd сервисы ───────────────────────────────────────────────────────────
- name: Template splitgw-rules systemd service
ansible.builtin.template:
src: splitgw-rules.service.j2
dest: /etc/systemd/system/splitgw-rules.service
mode: "0644"
become: true
notify:
- Reload systemd
- Restart splitgw-rules
- name: Template singbox systemd service
ansible.builtin.template:
src: singbox.service.j2
dest: /etc/systemd/system/singbox.service
mode: "0644"
become: true
notify:
- Reload systemd
- Restart singbox
- name: Enable and start splitgw-rules
ansible.builtin.systemd:
name: splitgw-rules
enabled: true
state: started
daemon_reload: true
become: true
- name: Enable and start sing-box
ansible.builtin.systemd:
name: singbox
enabled: true
state: started
daemon_reload: true
become: true
# ── Проверка ─────────────────────────────────────────────────────────────────
- name: Wait for sing-box to start (TPROXY port)
ansible.builtin.wait_for:
port: "{{ splitgw_tproxy_port }}"
host: "0.0.0.0"
timeout: 30
- name: Verify sing-box config is valid
ansible.builtin.command: >
sing-box check --config {{ splitgw_singbox_config_dir }}/config.json
become: true
changed_when: false
register: _singbox_check
failed_when: _singbox_check.rc != 0
- name: Show iptables SPLITGW rules
ansible.builtin.command: iptables -t mangle -L SPLITGW -v -n
become: true
changed_when: false
register: _iptables_rules
- name: "=== Split Gateway Ready ==="
ansible.builtin.debug:
msg:
- "╔══════════════════════════════════════════════════════════════╗"
- "║ Split Gateway (sing-box) установлен ║"
- "╚══════════════════════════════════════════════════════════════╝"
- ""
- " Режим: systemd (host)"
- " Sing-box: v{{ _singbox_version }}"
- " TPROXY порт: {{ splitgw_tproxy_port }}"
- " TV источники: {{ splitgw_tv_sources | join(', ') }}"
- " Прокси: {{ _splitgw_hy2_host }}:{{ _splitgw_hy2_port }}"
- ""
- " YouTube → Hysteria2 ({{ _splitgw_hy2_host }})"
- " RU + geoip:ru → прямой маршрут"
- " Всё остальное → прямой маршрут"
- ""
- " Логи: journalctl -u singbox -f"
- " Статус: systemctl status singbox splitgw-rules"

View File

@@ -0,0 +1,74 @@
---
- name: Create splitgw namespace
ansible.builtin.command: >
k3s kubectl create namespace {{ splitgw_k8s_namespace }}
--dry-run=client -o yaml | k3s kubectl apply -f -
become: true
delegate_to: "{{ groups['k3s_master'][0] }}"
run_once: true
changed_when: false
- name: Template K8s ConfigMap
ansible.builtin.template:
src: k8s/configmap.yaml.j2
dest: /tmp/splitgw-configmap.yaml
mode: "0644"
delegate_to: "{{ groups['k3s_master'][0] }}"
run_once: true
- name: Template K8s Secret
ansible.builtin.template:
src: k8s/secret.yaml.j2
dest: /tmp/splitgw-secret.yaml
mode: "0600"
delegate_to: "{{ groups['k3s_master'][0] }}"
run_once: true
- name: Template K8s DaemonSet
ansible.builtin.template:
src: k8s/daemonset.yaml.j2
dest: /tmp/splitgw-daemonset.yaml
mode: "0644"
delegate_to: "{{ groups['k3s_master'][0] }}"
run_once: true
- name: Apply ConfigMap, Secret, DaemonSet
kubernetes.core.k8s:
src: "{{ item }}"
state: present
loop:
- /tmp/splitgw-configmap.yaml
- /tmp/splitgw-secret.yaml
- /tmp/splitgw-daemonset.yaml
become: true
delegate_to: "{{ groups['k3s_master'][0] }}"
run_once: true
environment:
KUBECONFIG: "{{ k3s_kubeconfig_path }}"
- name: Wait for DaemonSet pods to be ready
ansible.builtin.command: >
k3s kubectl -n {{ splitgw_k8s_namespace }}
rollout status daemonset/splitgw --timeout=120s
become: true
delegate_to: "{{ groups['k3s_master'][0] }}"
run_once: true
changed_when: false
- name: Show pod status
ansible.builtin.command: >
k3s kubectl -n {{ splitgw_k8s_namespace }} get pods -o wide
become: true
delegate_to: "{{ groups['k3s_master'][0] }}"
run_once: true
register: _splitgw_pods
changed_when: false
- name: "=== Split Gateway K8s Ready ==="
ansible.builtin.debug:
msg:
- "Split Gateway DaemonSet deployed to namespace: {{ splitgw_k8s_namespace }}"
- "Убедись что нода помечена: kubectl label node <node> splitgw=true"
- ""
- "{{ _splitgw_pods.stdout_lines }}"
run_once: true

View File

@@ -0,0 +1,75 @@
---
# ── Парсинг Hysteria2 URL ─────────────────────────────────────────────────────
- name: Parse Hysteria2 URL into components
ansible.builtin.command: >-
python3 -c "
import sys, json
from urllib.parse import urlparse, parse_qs, unquote
url = sys.argv[1]
p = urlparse(url)
qs = parse_qs(p.query, keep_blank_values=True)
host = p.hostname or ''
port = p.port or 443
print(json.dumps({
'host': host,
'port': port,
'password': unquote(p.username or ''),
'insecure': qs.get('insecure', ['0'])[0] == '1',
'obfs_type': qs.get('obfs', [''])[0],
'obfs_password': qs.get('obfs-password', [''])[0],
}))
" "{{ splitgw_hysteria2_url }}"
register: _splitgw_url_parsed
changed_when: false
delegate_to: localhost
become: false
run_once: true
when: splitgw_hysteria2_url != ""
- name: Set Hysteria2 facts from URL
ansible.builtin.set_fact:
_splitgw_hy2_host: "{{ (_splitgw_url_parsed.stdout | from_json).host }}"
_splitgw_hy2_port: "{{ (_splitgw_url_parsed.stdout | from_json).port }}"
_splitgw_hy2_password: "{{ (_splitgw_url_parsed.stdout | from_json).password }}"
_splitgw_hy2_insecure: "{{ (_splitgw_url_parsed.stdout | from_json).insecure }}"
_splitgw_hy2_obfs_type: "{{ (_splitgw_url_parsed.stdout | from_json).obfs_type }}"
_splitgw_hy2_obfs_password: "{{ (_splitgw_url_parsed.stdout | from_json).obfs_password }}"
when: splitgw_hysteria2_url != "" and _splitgw_url_parsed.rc == 0
- name: Set Hysteria2 facts from individual vars (fallback)
ansible.builtin.set_fact:
_splitgw_hy2_host: "{{ splitgw_hysteria2_server | regex_replace(':.*', '') }}"
_splitgw_hy2_port: "{{ splitgw_hysteria2_server | regex_replace('.*:', '') | default('443') }}"
_splitgw_hy2_password: "{{ splitgw_hysteria2_auth }}"
_splitgw_hy2_insecure: "{{ splitgw_hysteria2_insecure }}"
_splitgw_hy2_obfs_type: "{{ splitgw_hysteria2_obfs_type }}"
_splitgw_hy2_obfs_password: "{{ splitgw_hysteria2_obfs_password }}"
when: splitgw_hysteria2_url == ""
- name: Assert Hysteria2 credentials are set
ansible.builtin.assert:
that:
- _splitgw_hy2_host != ""
- _splitgw_hy2_password != ""
fail_msg: |
Задай Hysteria2 credentials — в vault.yml:
vault_hysteria2_url: "hysteria2://password@host:port?insecure=1"
ИЛИ:
vault_hysteria2_server: "host:443"
vault_hysteria2_auth: "password"
run_once: true
- name: Show Hysteria2 connection target
ansible.builtin.debug:
msg: "sing-box → Hysteria2: {{ _splitgw_hy2_host }}:{{ _splitgw_hy2_port }} (insecure={{ _splitgw_hy2_insecure }}, obfs={{ _splitgw_hy2_obfs_type | default('none') }})"
# ── Деплой ───────────────────────────────────────────────────────────────────
- name: Deploy to host (systemd mode)
ansible.builtin.include_tasks: deploy-host.yml
when: splitgw_deploy_mode == "host"
- name: Deploy to K3S (DaemonSet mode)
ansible.builtin.include_tasks: deploy-k8s.yml
when: splitgw_deploy_mode == "k8s"

View File

@@ -0,0 +1,15 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
name: splitgw-config
namespace: {{ splitgw_k8s_namespace }}
data:
config.json: |
{{ lookup('template', '../singbox-config.json.j2') | indent(4, True) }}
setup-iptables.sh: |
{{ lookup('template', '../setup-iptables.sh.j2') | indent(4, True) }}
teardown-iptables.sh: |
{{ lookup('template', '../teardown-iptables.sh.j2') | indent(4, True) }}

View File

@@ -0,0 +1,104 @@
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: splitgw
namespace: {{ splitgw_k8s_namespace }}
labels:
app: splitgw
spec:
selector:
matchLabels:
app: splitgw
updateStrategy:
type: RollingUpdate
template:
metadata:
labels:
app: splitgw
spec:
hostNetwork: true # Необходим для перехвата хостового трафика
dnsPolicy: ClusterFirstWithHostNet
nodeSelector:
splitgw: "true" # kubectl label node <node> splitgw=true
# Приоритет — чтобы pod не вытесняли
priorityClassName: system-node-critical
tolerations:
- operator: Exists # Запускать на любых нодах с меткой (включая tainted)
initContainers:
# Init: установить iptables TPROXY правила на хосте
- name: init-iptables
image: alpine:latest
securityContext:
privileged: true # Нужен для изменения iptables и ip route хоста
command:
- /bin/sh
- -c
- |
apk add -q iptables ip6tables iproute2
chmod +x /scripts/setup-iptables.sh
/scripts/setup-iptables.sh
volumeMounts:
- name: config
mountPath: /scripts/setup-iptables.sh
subPath: setup-iptables.sh
containers:
- name: singbox
image: ghcr.io/sagernet/sing-box:latest
imagePullPolicy: IfNotPresent
args:
- run
- --config
- /etc/sing-box/config.json
- --directory
- /var/lib/sing-box
securityContext:
capabilities:
add:
- NET_ADMIN
- NET_RAW
- NET_BIND_SERVICE
ports:
- name: tproxy
containerPort: {{ splitgw_tproxy_port }}
protocol: TCP
- name: tproxy-udp
containerPort: {{ splitgw_tproxy_port }}
protocol: UDP
volumeMounts:
- name: config
mountPath: /etc/sing-box/config.json
subPath: config.json
- name: data
mountPath: /var/lib/sing-box
livenessProbe:
exec:
command:
- /bin/sh
- -c
- "sing-box check --config /etc/sing-box/config.json"
initialDelaySeconds: 10
periodSeconds: 30
failureThreshold: 3
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
# Очистка правил при удалении пода
terminationGracePeriodSeconds: 10
volumes:
- name: config
configMap:
name: splitgw-config
defaultMode: 0755
- name: data
emptyDir: {}

View File

@@ -0,0 +1,7 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: {{ splitgw_k8s_namespace }}
labels:
name: {{ splitgw_k8s_namespace }}

View File

@@ -0,0 +1,10 @@
---
apiVersion: v1
kind: Secret
metadata:
name: splitgw-hy2-credentials
namespace: {{ splitgw_k8s_namespace }}
type: Opaque
stringData:
hy2-server: "{{ _splitgw_hy2_host }}:{{ _splitgw_hy2_port }}"
hy2-password: "{{ _splitgw_hy2_password }}"

View File

@@ -0,0 +1,77 @@
#!/bin/sh
# ─── Split Gateway — iptables TPROXY setup ────────────────────────────────────
# Перехватывает TCP+UDP от TV через TPROXY → sing-box (порт {{ splitgw_tproxy_port }})
# DNS (порт 53) перехватывается тоже — sing-box обрабатывает как dns-out
#
# Источники трафика: {{ splitgw_tv_sources | join(', ') }}
# TPROXY порт: {{ splitgw_tproxy_port }}
# Метка пакетов: {{ splitgw_tproxy_mark }}
# Таблица маршрутизации: {{ splitgw_tproxy_table }}
set -e
TPROXY_PORT="{{ splitgw_tproxy_port }}"
TPROXY_MARK="{{ splitgw_tproxy_mark }}"
TPROXY_TABLE="{{ splitgw_tproxy_table }}"
log() { echo "[splitgw] $*"; }
# ── Включить IP forwarding ────────────────────────────────────────────────────
log "Enabling IP forwarding..."
sysctl -w net.ipv4.ip_forward=1
sysctl -w net.ipv6.conf.all.forwarding=1
# Сохранить в sysctl.conf для перезагрузки
grep -q "net.ipv4.ip_forward" /etc/sysctl.conf 2>/dev/null \
&& sed -i 's/^#\?net\.ipv4\.ip_forward.*/net.ipv4.ip_forward = 1/' /etc/sysctl.conf \
|| echo "net.ipv4.ip_forward = 1" >> /etc/sysctl.conf
# ── Таблица маршрутизации для помеченных пакетов ──────────────────────────────
log "Setting up policy routing table ${TPROXY_TABLE}..."
# Удалить старые правила если есть
ip rule del fwmark "$TPROXY_MARK" lookup "$TPROXY_TABLE" 2>/dev/null || true
# Добавить: помеченные пакеты → таблица 100
ip rule add fwmark "$TPROXY_MARK" lookup "$TPROXY_TABLE" prio 100
# В таблице 100 все адреса = локальные (чтобы ядро не отбросило пакет)
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
ip route add local 0.0.0.0/0 dev lo table "$TPROXY_TABLE"
# ── Создать цепочку SPLITGW в таблице mangle ─────────────────────────────────
log "Creating SPLITGW iptables chain..."
iptables -t mangle -N SPLITGW 2>/dev/null || iptables -t mangle -F SPLITGW
# Пропускаем loopback (не трогаем системный трафик)
iptables -t mangle -A SPLITGW -i lo -j RETURN
# Пропускаем уже установленные соединения (TPROXY только для новых)
iptables -t mangle -A SPLITGW -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
# Пропускаем RFC 1918 приватные диапазоны (LAN трафик идёт напрямую)
iptables -t mangle -A SPLITGW -d 127.0.0.0/8 -j RETURN
iptables -t mangle -A SPLITGW -d 10.0.0.0/8 -j RETURN
iptables -t mangle -A SPLITGW -d 172.16.0.0/12 -j RETURN
iptables -t mangle -A SPLITGW -d 192.168.0.0/16 -j RETURN
iptables -t mangle -A SPLITGW -d 224.0.0.0/4 -j RETURN
iptables -t mangle -A SPLITGW -d 240.0.0.0/4 -j RETURN
# TPROXY TCP → sing-box
iptables -t mangle -A SPLITGW -p tcp \
-j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
# TPROXY UDP → sing-box (включая YouTube QUIC / DNS)
iptables -t mangle -A SPLITGW -p udp \
-j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
# ── Применить цепочку к трафику от TV ─────────────────────────────────────────
log "Applying SPLITGW to TV sources..."
{% for source in splitgw_tv_sources %}
iptables -t mangle -A PREROUTING -s {{ source }} -j SPLITGW
log " + source {{ source }}"
{% endfor %}
log "TPROXY rules applied. sing-box listens on port ${TPROXY_PORT}."

View File

@@ -0,0 +1,166 @@
{
"log": {
"level": "{{ splitgw_singbox_log_level }}",
"timestamp": true
},
"dns": {
"servers": [
{
"tag": "dns-proxy",
"address": "tls://8.8.8.8",
"address_resolver": "dns-direct",
"detour": "proxy"
},
{
"tag": "dns-direct",
"address": "udp://8.8.4.4",
"detour": "direct"
},
{
"tag": "dns-ru",
"address": "udp://{{ splitgw_router_ip }}",
"detour": "direct"
}
],
"rules": [
{
"outbound": "any",
"server": "dns-direct"
},
{
"rule_set": ["geosite-youtube", "geosite-google"],
"server": "dns-proxy"
},
{
"rule_set": ["geosite-category-ru"],
"server": "dns-ru"
},
{
"domain_suffix": {{ splitgw_ru_extra_suffixes | to_json }},
"server": "dns-ru"
}
],
"final": "dns-direct",
"independent_cache": true,
"reverse_mapping": true
},
"inbounds": [
{
"type": "tproxy",
"tag": "tproxy-in",
"listen": "::",
"listen_port": {{ splitgw_tproxy_port }},
"tcp_fast_open": true,
"udp_fragment": true,
"sniff": true,
"sniff_override_destination": true,
"domain_strategy": "prefer_ipv4",
"udp_timeout": "5m"
}
],
"outbounds": [
{
"type": "hysteria2",
"tag": "proxy",
"server": "{{ _splitgw_hy2_host }}",
"server_port": {{ _splitgw_hy2_port }},
"password": "{{ _splitgw_hy2_password }}",
{% if _splitgw_hy2_obfs_type != "" %}
"obfs": {
"type": "{{ _splitgw_hy2_obfs_type }}",
"{{ _splitgw_hy2_obfs_type }}": {
"password": "{{ _splitgw_hy2_obfs_password }}"
}
},
{% endif %}
"tls": {
"enabled": true,
"insecure": {{ _splitgw_hy2_insecure | lower }},
"server_name": "{{ _splitgw_hy2_host }}"
}
},
{
"type": "direct",
"tag": "direct"
},
{
"type": "block",
"tag": "block"
},
{
"type": "dns",
"tag": "dns-out"
}
],
"route": {
"rule_set": [
{
"type": "remote",
"tag": "geosite-youtube",
"format": "binary",
"url": "https://github.com/SagerNet/sing-geosite/releases/latest/download/geosite-youtube.srs",
"download_detour": "direct",
"update_interval": "7d"
},
{
"type": "remote",
"tag": "geosite-google",
"format": "binary",
"url": "https://github.com/SagerNet/sing-geosite/releases/latest/download/geosite-google.srs",
"download_detour": "direct",
"update_interval": "7d"
},
{
"type": "remote",
"tag": "geosite-category-ru",
"format": "binary",
"url": "https://github.com/SagerNet/sing-geosite/releases/latest/download/geosite-category-ru.srs",
"download_detour": "direct",
"update_interval": "7d"
},
{
"type": "remote",
"tag": "geoip-ru",
"format": "binary",
"url": "https://github.com/SagerNet/sing-geoip/releases/latest/download/geoip-ru.srs",
"download_detour": "direct",
"update_interval": "7d"
}
],
"rules": [
{
"protocol": "dns",
"outbound": "dns-out"
},
{
"ip_is_private": true,
"outbound": "direct"
},
{
"type": "logical",
"mode": "or",
"rules": [
{ "rule_set": ["geosite-youtube"] },
{ "domain_keyword": {{ splitgw_youtube_extra_keywords | to_json }} }
],
"outbound": "proxy"
},
{
"type": "logical",
"mode": "or",
"rules": [
{ "rule_set": ["geosite-category-ru", "geoip-ru"] },
{ "domain_suffix": {{ splitgw_ru_extra_suffixes | to_json }} },
{ "domain_keyword": {{ splitgw_ru_extra_keywords | to_json }} }
],
"outbound": "direct"
}
],
"final": "direct",
"auto_detect_interface": true
}
}

View File

@@ -0,0 +1,27 @@
[Unit]
Description=sing-box transparent proxy (split gateway)
Documentation=https://sing-box.sagernet.org/
After=network.target network-online.target splitgw-rules.service
Wants=network-online.target
Requires=splitgw-rules.service
[Service]
Type=simple
User=root
ExecStart={{ splitgw_singbox_install_dir }}/sing-box run \
--config {{ splitgw_singbox_config_dir }}/config.json \
--directory {{ splitgw_singbox_data_dir }}
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5s
LimitNOFILE=1048576
# CAP_NET_ADMIN нужен для TPROXY
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE
StandardOutput=append:{{ splitgw_singbox_log_dir }}/singbox.log
StandardError=append:{{ splitgw_singbox_log_dir }}/singbox.log
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Split Gateway iptables TPROXY rules
After=network.target
Before=singbox.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart={{ splitgw_singbox_config_dir }}/setup-iptables.sh
ExecStop={{ splitgw_singbox_config_dir }}/teardown-iptables.sh
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,23 @@
#!/bin/sh
# ─── Split Gateway — iptables TPROXY teardown ────────────────────────────────
# Удаляет правила SPLITGW и таблицу маршрутизации
TPROXY_MARK="{{ splitgw_tproxy_mark }}"
TPROXY_TABLE="{{ splitgw_tproxy_table }}"
log() { echo "[splitgw] $*"; }
log "Removing SPLITGW iptables rules..."
{% for source in splitgw_tv_sources %}
iptables -t mangle -D PREROUTING -s {{ source }} -j SPLITGW 2>/dev/null || true
{% endfor %}
iptables -t mangle -F SPLITGW 2>/dev/null || true
iptables -t mangle -X SPLITGW 2>/dev/null || true
log "Removing policy routing..."
ip rule del fwmark "$TPROXY_MARK" lookup "$TPROXY_TABLE" 2>/dev/null || true
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
log "Teardown complete."