ladon

module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 16, 2026 License: MIT

README

Ladon

🩸 Ladon

Автоматический split-tunneling для VPN-шлюзов в сетях с DPI

CI Release Go

Ladon наблюдает трафик клиентов шлюза, проверяет домены на достижимость и строит список из ip-адресов, которые нужно пустить через VPN. За доли секунды. Ничего не нужно размечать руками — движок учится сам на поведении пира и реакции сети.

Задуман для WireGuard-шлюзов с dnsmasq и апстрим-туннелем наружу, но легко адаптируется под любой стек с fwmark-routing и ipset.


⚡ Производительность

От первого DNS-запроса до правила в kernel ipset — полсекунды в среднем. В постоянный список попадает только то, что подтвердилось ≥50 раз за сутки — моргания провайдера не доезжают.

Метрика Значение
Реакция на новый блок 0.3 – 1.1 с (≈0.5 с)
Пропускная способность ~65 доменов/с на 2 CPU
Накладные расходы пайплайна ~50 мс сверх сети
RSS ~20 МБ
Почему быстро и точно
  • Нет split-brain с клиентом — probe бьёт по IP из dns_cache, тем же, что dnsmasq отдал клиенту.
  • Параллельные TCP-dials — худший случай max(timeout) вместо sum(timeout).
  • Event-driven ipset — probe сигналит syncer сразу после вердикта, без ожидания тикера.
  • eTLD+1 агрегация — ≥2 подтверждённых поддомена тянут в ipset всю семью (CDN с UUID-поддоменами не утекают через direct).
Как воспроизвести числа локально
go test -run TestPipeline ./internal/engine/

Тест бьёт в TEST-NET-1 (192.0.2.1) — зарезервированный IP, пакеты в который отбрасываются на апстрим-роутерах; получается реалистичный «тихий drop» без опоры на внешнюю сеть.

Разброс 0.3 – 1.1 с:

  • Нижняя граница — DPI присылает мгновенный TCP RST. Probe падает сразу, цикл замыкается почти только за счёт чтения лога и записи в БД.
  • Верхняя граница — DPI молчит (drop без ответа). Probe ждёт полный таймаут 800 мс, общая задержка = таймаут + накладные.

💡 Как это работает

Привычные режимы на обычном VPN упираются в один и тот же выбор - «Всё через туннель» ломает гео-привязки российских сервисов — банки и Госуслуги отказываются пускать с иностранного IP. «Всё напрямую» не решает задачу, ради которой VPN и поднимали.

Ladon держит середину: пир ходит напрямую по умолчанию, а движок в фоне наблюдает его трафик и сам переключает через туннель только то, что реально заблокировано

Путь одного домена

Предположим, клиент первый раз открывает instagram.com.

  1. Наблюдение. Запрос прилетает в dnsmasq; тот пишет в лог пары строк — query[A] instagram.com from 10.10.0.2 и reply instagram.com is 157.240.20.174. Tailer ловит обе через kernel-события fsnotify: первая уходит в таблицу domains (состояние new), IP-ы из второй складываются в dns_cache. Это ровно те адреса, которые увидел клиент — важно, чтобы движок бил именно по ним, а не по собственному resolve: на geo-routed CDN движок и клиент иначе видят разные IP.

  2. Проверка. Как только watcher записал новый домен, в ту же миллисекунду запускается inline-проба: параллельные TCP-коннекты на :443 по IP из dns_cache (берёт первый успешный), затем TLS-handshake с SNI. Сертификат не верифицируется — движку нужна только достижимость, а не доверие. Для старых доменов, у которых истёк 5-минутный cooldown, отдельный batch-воркер раз в 2 секунды делает то же самое по батчу кандидатов.

  3. Вердикт. Если TCP или TLS упали — домен переходит в hot на 24 часа, и параллельно через буферный канал будится ipset-syncer: IP из dns_cache атомарно добавляются в kernel ipset prod. iptables mangle-правило --match-set prod dst -j MARK 0x1 ловит следующий пакет клиента и разворачивает его в туннель. От первой DNS-записи до правила в ядре — в среднем полсекунды. Если прямой путь сработал — домен в ignore, никаких действий.

  4. Память. Раз в 10 минут scorer проходится по hot_entries. Если домен за последние 24 часа упал ≥50 раз — он переезжает в cache_entries без TTL. Это разделение важно: короткие сетевые штормы (провайдер моргнул на 30 секунд) живут только в hot и сами исчезают через сутки, а стабильно заблокированные ресурсы оседают в постоянной памяти и перестают зря пробиваться.

  5. Маршрутизация. ipset-syncer держит kernel ipset синхронным с union'ом hot ∪ cache ∪ manual-allow. Работает event-driven: каждое новое Hot-событие будит его немедленно, а safety-тикер раз в 30 секунд ловит всё, что могло потеряться. Дополнительно для доменов с ≥2 подтверждёнными сиблингами по eTLD+1 в ipset подтягиваются IP-ы всего корня — Meta раздаёт новые UUID-поддомены ежеминутно, и один упавший сразу уводит в туннель остальных.

🔌 Схема пайплайна (клик, чтобы развернуть)
flowchart TB
    subgraph observe["🔎 Наблюдение"]
        direction LR
        DNS[/"dnsmasq log<br/>log-queries=extra"/]
        TAIL["tailer<br/><i>fsnotify, kernel events</i>"]
        WATCH["watcher<br/><i>нормализация + ingest</i>"]
        DOMS[("domains")]
        CACHE[("dns_cache")]
        DNS -->|строка лога| TAIL
        TAIL -->|"query[A] X from peer"| WATCH
        TAIL -->|"reply X is IP"| CACHE
        WATCH --> DOMS
    end

    subgraph decide["🩺 Проверка и вердикт"]
        direction LR
        WORKER["probe-worker<br/><i>batch раз в 2с</i>"]
        PROBE["prober<br/><i>TCP + TLS-SNI<br/>параллельные dials</i>"]
        DEC{{"decision"}}
        HOT[("hot_entries<br/>TTL 24ч")]
        IGN["state = ignore"]
        WORKER -->|"кандидаты из domains"| PROBE
        PROBE --> DEC
        DEC -->|"TCP/TLS fail"| HOT
        DEC -->|"direct OK"| IGN
    end

    subgraph promote["🧠 Долгосрочная память"]
        direction LR
        SCORE["scorer<br/><i>раз в 10 мин</i>"]
        CEN[("cache_entries<br/>без TTL")]
        SCORE -->|"≥50 fails / 24ч"| CEN
    end

    subgraph apply["⚙️ Применение (kernel)"]
        direction LR
        SYNC["ipset-syncer<br/><i>event-driven + 30с safety</i>"]
        MAN[("manual-allow")]
        PROD[("kernel ipset «prod»")]
        IPT["iptables mangle<br/>WG_ROUTE"]
        TUN["→ upstream tunnel"]
        DIR["→ direct egress"]
        SYNC -->|"ipset add / del"| PROD
        MAN --> SYNC
        PROD --> IPT
        IPT -->|"dst ∈ prod<br/>MARK 0x1"| TUN
        IPT -->|"dst ∉ prod"| DIR
    end

    TAIL -. "inline fast-path<br/>(для нового домена)" .-> PROBE
    DOMS --> WORKER
    CACHE -->|"точные IP"| PROBE
    HOT --> SCORE
    HOT --> SYNC
    CEN --> SYNC

    classDef store fill:#e8f4fd,stroke:#1976d2,color:#0d47a1
    classDef proc fill:#fff3e0,stroke:#ef6c00,color:#3e2723
    classDef decisionNode fill:#fce4ec,stroke:#c2185b,color:#880e4f
    classDef kernelNode fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20
    classDef source fill:#f3e5f5,stroke:#6a1b9a,color:#4a148c
    class DNS source
    class TAIL,WATCH,WORKER,PROBE,SCORE,SYNC proc
    class DOMS,CACHE,HOT,CEN,MAN store
    class DEC decisionNode
    class PROD,IPT,TUN,DIR,IGN kernelNode
Состояния домена — справочник

Состояние домена хранится в колонке domains.state; «живые» списки для роутинга — в hot_entries и cache_entries. Переходы следуют из путя выше, вот таблица на случай если надо быстро свериться:

Состояние Что значит Как попадает Как уходит
new Видели DNS-query, но ещё не пробовали коннектиться Первая ingest-строка После первого probe
ignore Прямой путь работает, туннель не нужен Probe прошёл TCP+TLS Следующий probe может вернуть в цикл, если начнёт падать
hot Probe обнаружил блок — домен временно в ipset Probe упал на TCP или TLS Запись в hot_entries снимается через 24 ч после последнего fail; при ≥50 подтверждениях за окно scorer переводит в cache
cache Стабильно заблокирован, в ipset навсегда Scorer: ≥50 fails за 24 ч Только вручную оператором (cache-demotion на обратном пробе — в бэклоге)
Manual override-ы

Автоматика закрывает ~95% случаев, но остаются две ниши, где оператор хочет явно задать поведение:

  • manual-allow.txt — домен попадает в ipset без probe. Полезно, когда блок проявляется на слое, который наш probe не видит (HTTP content filtering, SNI-спуфинг), или когда нужно принудительно загнать домен в туннель по приватным соображениям.
  • manual-deny.txt — домен никогда не пробуется и не туннелируется. Для внутренних LAN-сервисов, гео-fenced российских сервисов (Госуслуги, банки), которые ломаются через иностранный exit, и для шумного мониторинга, который не хочется засорять probe-логами.

Файлы перечитываются при запуске сервиса; правка требует systemctl restart ladon.


📦 Установка

Требования
  • Linux (Debian 11+ / Ubuntu 22.04+ или аналогичный).
  • iptables (legacy или nft-режим).
  • ipset, iptables-persistent (apt install ipset iptables-persistent).
  • dnsmasq с log-queries=extra и log-facility в файл.
  • Рут-права (нужен доступ к ipset и к логу dnsmasq).
  • Работающий шлюз с fwmark-routing и upstream-туннелем (WireGuard, Hysteria, любой кастомный cascade).
Quickstart
# 1. Скачать последний релиз (или зафиксировать версию: TAG=v0.2.0)
TAG=$(curl -sSL "https://api.github.com/repos/belotserkovtsev/ladon/releases/latest" \
  | grep '"tag_name":' | head -1 | cut -d'"' -f4)
curl -L "https://github.com/belotserkovtsev/ladon/releases/download/${TAG}/ladon-linux-amd64.tar.gz" \
  | sudo tar -xz -C /opt

sudo mv /opt/ladon-linux-amd64-${TAG} /opt/ladon
sudo mkdir -p /opt/ladon/state /etc/ladon

# 2. Примеры manual-списков
sudo cp /opt/ladon/manual-allow.txt.example /etc/ladon/manual-allow.txt
sudo cp /opt/ladon/manual-deny.txt.example  /etc/ladon/manual-deny.txt

# 3. Создать ipset и правило в iptables mangle
sudo ipset create prod hash:ip family inet maxelem 65536
sudo iptables -t mangle -A WG_ROUTE -m set --match-set prod dst \
  -j MARK --set-mark 0x1
sudo ipset save > /etc/iptables/ipsets   # чтобы переживало reboot

# 4. Инициализировать БД и поставить сервис
sudo /opt/ladon/ladon \
  -db /opt/ladon/state/engine.db init-db
sudo install -m 0644 /opt/ladon/ladon.service \
  /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now ladon

# 5. Проверить
systemctl status ladon
journalctl -u ladon -f

Подробнее — см. release/INSTALL.md.


🛠 Конфигурация

Основной способ — YAML-файл, путь которого передаётся флагом -config. Без файла движок едет на дефолтах из internal/engine/engine.go.

Пример /etc/ladon/config.yaml:

logfile: /var/log/dnsmasq.log
manual_allow: /etc/ladon/manual-allow.txt
manual_deny: /etc/ladon/manual-deny.txt

probe:
  mode: local         # local | exit-compare
  timeout: 800ms
  cooldown: 5m
  concurrency: 8

scorer:
  interval: 10m
  window: 24h
  fail_threshold: 50

ipset:
  name: prod
  interval: 30s

hot_ttl: 24h
dns_freshness: 6h

Все поля опциональны — пропущенные берутся из дефолтов:

Параметр Значение Смысл
probe.timeout 800 мс Максимум на TCP/TLS dial
probe.cooldown 5 мин Минимальный интервал между probe одного домена
probe.concurrency 8 Семафор для inline probe из tailer
hot_ttl 24 ч Срок жизни записи в hot_entries
ipset.interval 30 с Safety-реконсил ipset (помимо event-driven)
dns_freshness 6 ч Возраст, после которого IP из dns_cache устаревает
scorer.window 24 ч Окно для подсчёта fails
scorer.fail_threshold 50 Порог fails для промоушна hot → cache
scorer.interval 10 мин Как часто scorer проходится
CLI-флаги

Дополнение к YAML — для простых случаев и для разовых override'ов:

ladon -db <path> [-config <path>] run [-from-start] [-manual-allow <path>] [-manual-deny <path>] <dnsmasq-log-path>

Пути (-manual-allow, -manual-deny) перебивают одноимённые поля YAML, если заданы оба. Тонкие knobs задаются только через файл.

Очистка состояния — ladon prune

Иногда нужно сбросить накопленные данные руками — например, после смены логики probe (включили exit-compare, и cache мог содержать FP, которые новая логика отсеяла бы), или просто отрезать старую историю проб для размера БД.

# Что бы удалилось (без выполнения)
ladon -db /opt/ladon/state/engine.db prune -cache -dry-run

# Удалить весь cache
ladon -db /opt/ladon/state/engine.db prune -cache

# Удалить probe-ряды старше конкретной даты (RFC3339)
ladon -db /opt/ladon/state/engine.db prune -probes -before 2026-04-16T11:14:00Z

# Полная очистка трёх таблиц до какой-то отметки
ladon -db /opt/ladon/state/engine.db prune -cache -hot -probes -before 2026-04-16T11:14:00Z

Флаги:

флаг действие
-cache удаляет cache_entries
-hot удаляет hot_entries
-probes удаляет probes
-before <RFC3339> фильтр по дате; без флага удаляет всё
-dry-run показать счётчики без выполнения

После prune движок автоматически сбрасывает state в new для доменов, у которых не осталось ни hot, ни cache записи — на следующем DNS-запросе домен пройдёт пайплайн заново.

Почему нет авто-prune при upgrade: cache-записи зарабатываются дорого (≥50 fails / 24ч), и тихая чистка при каждом релизе создавала бы UX-провалы. Делать prune — осознанное решение оператора.

Exit-compare через внешний пробинг-сервер

Локальная проба видит мир глазами шлюза. Это хорошо ловит DPI-блоки, которые цепляются ровно к тому пути, по которому идёт клиент. Но даёт ложные срабатывания, когда сам домен не отвечает на :443 — imap.gmail.com живёт на :993, bgp.he.net на 8080, и т.д. Локальный probe в таких случаях видит «TCP fail» и тащит домен в hot, хотя блока нет.

mode: exit-compare решает это, добавляя вторую точку зрения: HTTP-сервер на твоей стороне, который пробит тот же домен из другого vantage point (residential ISP, 4G-модем, офшорная VPS, что угодно).

probe:
  mode: exit-compare
  remote:
    url: https://my-probe-server.example.com/probe
    timeout: 2s
    auth_header: Authorization
    auth_value: Bearer mysecrettoken

Логика вердикта на batch-перепробе:

local remote вердикт
OK (не запускается) Ignore — direct работает
FAIL OK Hot — настоящий DPI-блок, снаружи домен живой
FAIL FAIL Ignore — methodological FP (порт не тот / мёртвый сервер / domain не отвечает ниоткуда)
FAIL unavailable Hot — твой proб-сервер недоступен (timeout / non-200 / network) → не overrule'им, остаёмся с локальным вердиктом. Reason помечен remote:unavailable:...

Последняя строка важна: outage твоего proб-сервера не должен тихо снимать ipset с реально-заблокированных доменов. Поэтому транспортная ошибка интерпретируется как «нет мнения», а не как «remote сказал FAIL».

Inline fast-path всегда использует только локальную пробу — гонять remote round-trip на каждом первом запросе клиента сломало бы 0.5-секундный бюджет. Если inline ошибся — batch-перепроба с exit-compare его поправит и удалит запись из ipset.

HTTP-контракт описан в docs/probe-api.md, референсная имплементация на Go — в examples/probe-server/.


🔍 Наблюдаемость

Всё состояние живёт в SQLite. Полезные запросы:

DB=/opt/ladon/state/engine.db

# Распределение по состояниям
sqlite3 "$DB" "SELECT state, COUNT(*) FROM domains GROUP BY state"

# Топ-15 «горячих» доменов по количеству визитов
sqlite3 -column "$DB" \
  "SELECT domain, hit_count, state FROM domains
   WHERE state IN ('hot','cache')
   ORDER BY hit_count DESC LIMIT 15"

# Сколько IP сейчас в kernel ipset
sudo ipset list prod -t | grep entries

# Причины попадания в hot
sqlite3 -column "$DB" \
  "SELECT d.domain, p.failure_reason, p.latency_ms
   FROM domains d JOIN probes p ON p.id = d.last_probe_id
   WHERE d.state = 'hot' ORDER BY p.created_at DESC LIMIT 20"

# Промоушны в cache за последний час
sqlite3 -column "$DB" \
  "SELECT domain, promoted_at, reason FROM cache_entries
   WHERE promoted_at > datetime('now','-1 hour')"

Live-логи: journalctl -u ladon -f.


🏗 Разработка

# Unit + race-тесты (быстро, без сети)
go test -race -short ./...

# End-to-end пайплайн-перфтесты (живые TCP-timeout на RFC 5737 192.0.2.1)
go test -v -run TestPipeline ./internal/engine/

# Кросс-компиляция под Linux
GOOS=linux GOARCH=amd64 go build -o dist/ladon ./cmd/ladon
Структура пакетов
Путь Ответственность
cmd/ladon/ CLI: init-db, run, probe, observe, list, hot, tail
internal/tail/ fsnotify-based follower для файла лога
internal/dnsmasq/ Парсер log-строк (query / reply / cached / forwarded)
internal/watcher/ Нормализация и ingest DNS-событий
internal/storage/ SQLite access layer + embedded schema
internal/etld/ Обёртка над golang.org/x/net/publicsuffix
internal/prober/ Probe: LocalProber (TCP + TLS-SNI) и RemoteProber (HTTP к внешнему сервису) за общим Prober-интерфейсом
internal/config/ Загрузка и валидация YAML-конфига
internal/decision/ Классификация probe → {Ignore, Watch, Hot}
internal/scorer/ Промоушн hot → cache по количеству fails в окне
internal/manual/ Загрузчик allow/deny-списков из файлов
internal/ipset/ Обёртка над CLI ipset (Add / Del / Reconcile / Save)
internal/publisher/ Atomic-write текстового файла с hot-доменами
internal/engine/ Оркестровка: 6 горутин, каналы, lifecycle
CI

GitHub Actions workflow прогоняет на каждый push в main и на каждый PR:

  • go build ./...
  • go vet ./...
  • go test -race -short ./... — unit-тесты с race-детектором.
  • go test -run TestPipeline ./internal/engine/ — end-to-end перфтесты.

📜 Лицензия

MIT. Делайте что хотите — форкайте, встраивайте, коммерческое использование, всё разрешено. Требуется только сохранить упоминание автора в копиях.

Directories

Path Synopsis
cmd
ladon command
ladon CLI.
ladon CLI.
examples
probe-server command
Package main is a reference implementation of the probe-server contract that ladon's RemoteProber speaks.
Package main is a reference implementation of the probe-server contract that ladon's RemoteProber speaks.
internal
config
Package config loads ladon's YAML config file and hands back an engine.Config plus a probe backend chosen by the file.
Package config loads ladon's YAML config file and hands back an engine.Config plus a probe backend chosen by the file.
decision
Package decision classifies probe outcomes into engine states.
Package decision classifies probe outcomes into engine states.
dnsmasq
Package dnsmasq parses dnsmasq log lines emitted with log-queries=extra.
Package dnsmasq parses dnsmasq log lines emitted with log-queries=extra.
engine
Package engine wires all pipeline stages (tail → ingest → probe → decide) into a single long-running process.
Package engine wires all pipeline stages (tail → ingest → probe → decide) into a single long-running process.
etld
Package etld computes the effective-TLD-plus-one (registrable root) for a domain, using the public suffix list.
Package etld computes the effective-TLD-plus-one (registrable root) for a domain, using the public suffix list.
ipset
Package ipset wraps the `ipset` CLI for atomic set mutations.
Package ipset wraps the `ipset` CLI for atomic set mutations.
manual
Package manual loads allow/deny domain lists from plain text files into manual_entries.
Package manual loads allow/deny domain lists from plain text files into manual_entries.
prober
Package prober runs staged network probes (DNS / TCP:443 / TLS-SNI) against a domain.
Package prober runs staged network probes (DNS / TCP:443 / TLS-SNI) against a domain.
publisher
Package publisher renders the live runtime set (cache ∪ hot ∪ manual) into a flat artifact a downstream consumer can ingest.
Package publisher renders the live runtime set (cache ∪ hot ∪ manual) into a flat artifact a downstream consumer can ingest.
scorer
Package scorer promotes hot domains into cache once repeated-failure evidence accumulates.
Package scorer promotes hot domains into cache once repeated-failure evidence accumulates.
storage
Package storage is the SQLite access layer for ladon.
Package storage is the SQLite access layer for ladon.
tail
Package tail follows a log file like `tail -F`, surviving truncation, rotation (inode change), and brief disappearance.
Package tail follows a log file like `tail -F`, surviving truncation, rotation (inode change), and brief disappearance.
watcher
Package watcher ingests DNS query events and persists them as observations.
Package watcher ingests DNS query events and persists them as observations.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL