From 610097ed42082e14539d91d4dce15339a95b2a49 Mon Sep 17 00:00:00 2001 From: Karen Khachatryan Date: Fri, 31 Jul 2026 23:25:37 +0300 Subject: [PATCH] Add per-device routing policies - Support per-device WAN, split, and exit routing modes - Move exit policy marks and routing tables to IDs starting at 10000 - Generate and validate matching firewall and network rules - Add the --details option - Improve generation logs - Update Watchcat defaults --- README.md | 110 ++++++- config.json | 16 + generate_configs.py | 15 +- .../example/files/etc/config/firewall_part | 21 +- routers/example/files/etc/config/network_part | 8 +- routers/example/files/etc/config/watchcat | 6 +- .../example/files/etc/scripts/exit-route.sh | 2 +- tools/common.py | 2 + tools/config_builder.py | 10 + tools/config_exit_model.py | 107 ++++++- tools/config_model.py | 8 + tools/config_schema.py | 13 + tools/default.py | 16 +- tools/ensure_ssh_keys.py | 4 +- tools/generate.py | 8 +- tools/generate_exit.py | 39 +++ tools/generate_firewall.py | 154 +++++----- tools/generate_network.py | 24 +- tools/generate_router_misc.py | 3 +- tools/generate_runtime.py | 2 +- tools/managed_blocks.py | 42 +++ tools/network_config.py | 55 +--- tools/show_unmanaged_collect.py | 8 +- tools/validate.py | 3 + tools/validate_firewall.py | 275 ++++++++++++------ tools/validate_network.py | 78 +++-- tools/validate_runtime_files.py | 2 +- tools/validate_tunnel_files.py | 2 +- tools/validate_uci.py | 7 - 29 files changed, 735 insertions(+), 305 deletions(-) diff --git a/README.md b/README.md index ee7c4dd..9016f03 100644 --- a/README.md +++ b/README.md @@ -147,9 +147,9 @@ Firewall model остается явной: За выбор активного egress отвечает `exit-route.sh`. -Он раз в 5 секунд проверяет, какой exit marker prefix виден через Babel, выбирает первый reachable exit из `exit_order` и синхронизирует с ним default route в table `200`. +Он раз в 5 секунд проверяет, какой exit marker prefix виден через Babel, выбирает первый reachable exit из `exit_order` и синхронизирует с ним default route в table `10000`. -Если ни один exit не анонсируется через Babel, скрипт оставляет UCI секцию `network.exit200`, но ставит: +Если ни один exit не анонсируется через Babel, скрипт оставляет UCI секцию `network.exit10000`, но ставит: ```text disabled=1 @@ -190,14 +190,14 @@ WAN underlay реальная сеть провайдера, NAT, public encrypted overlay p2p AWG/WG линки между router, spine и exit routing plane Babel поверх tunnel интерфейсов exit data-plane IPIP от роутеров до выбранного exit -policy plane fwmark, uid rule и routing table 200 +policy plane fwmark, uid rule и routing table 10000 ``` AWG/WG линки дают защищенную связность и транспорт для Babel. Babel отвечает за достижимость overlay узлов и service prefixes. -IPIP используется отдельно как data-plane до exit сервера. Пользовательский трафик, который должен выйти через exit, направляется в table `200` и уходит через активный IPIP интерфейс. +IPIP используется отдельно как data-plane до exit сервера. Пользовательский трафик, который должен выйти через exit, направляется в table `10000` и уходит через активный IPIP интерфейс. ## Адресация без ручного IPAM @@ -343,6 +343,7 @@ access пользовательские WG/AWG/OpenVPN входы н - `allow_to_router` - к каким target роутерам разрешен INPUT на сам роутер - `allow_to_lan` - к каким target роутерам разрешен FORWARD в их LAN - `exit_order` - индивидуальный порядок выбора exit серверов +- `routing_rules` - выбор маршрутизации для отдельного IPv4-устройства `allow_to_router` и `allow_to_lan` описывают исходящее разрешение от source сети текущего роутера или access группы к target роутерам. @@ -641,21 +642,87 @@ Node IP выбирается стабильно по имени exit, а не п Per-router `exit_order` может содержать только часть exit серверов. В этом случае роутер выбирает только из этого списка и не дополняет его глобальным порядком. +Для отдельного устройства можно выбрать один из трех режимов маршрутизации: + +```json +"routing_rules": [ + { + "src_ip": "10.101.1.50/32", + "mode": "wan" + }, + { + "src_ip": "10.101.1.51/32", + "mode": "split", + "exit": "EGR02" + }, + { + "src_ip": "10.101.1.52/32", + "mode": "exit", + "exit": "EGR02" + } +] +``` + +Режимы: + +- `wan` - ставится служебная ненулевая mark `9999`; отдельные policy rule и + routing table для нее не создаются, поэтому стандартный RPDB доходит до + таблицы `main`, которая выбирает WAN или внутренний маршрут +- `split` - для destination вне ipset `direct` ставится mark выбранного exit +- `exit` - mark выбранного exit ставится для всего маршрутизируемого трафика + устройства + +Для `split` и `exit` поле `exit` обязательно. Для `wan` поле `exit` запрещено. + +`src_ip` должен указывать ровно одно устройство: принимается IPv4-адрес без +маски или строгий `/32`. Сети вроде `/24` для `routing_rules` запрещены. Один +адрес нельзя указать более одного раза на одном роутере. + +Для общей exit-политики используется единый policy ID `10000`: он одновременно +является firewall mark и номером routing table. Для каждого exit, реально +используемого режимом `split` или `exit`, назначается следующий policy ID: +`10001`, `10002` и далее. Порядок соответствует порядку exit в `exit_hubs`. + +Индивидуальные правила `Routing-*` генерируются в managed-части до marker. +Общие `Exitlan`, `ExitTrustedAccess`, `ExitTransitAccess` остаются +после marker в `routers/example` и содержат условие `option mark '0'`. Поэтому +они ставят общую mark `10000` только пакету, который не был помечен ранее: +первая ненулевая mark сохраняется. `Routing-WAN-*` ставит `9999`, +`Routing-Split-*` ставит mark выбранного exit только для `!direct`, а +`Routing-Exit-*` ставит ее независимо от destination. +`ExitIPIPClearMark` использует `option set_mark '0'` и полностью очищает +служебную mark перед выходом в зону `ExitIPIP`. + +Генератор изменяет только часть до marker. Строка marker и весь хвост после нее +дописываются побайтно без нормализации. Хвостом владеет только `sync_rules.py`, +который берет его из `routers/example`. Валидация проверяет итоговый конфиг +каждого роутера и до, и после marker, но общий хвост не переписывает. Если +router-specific правило `Routing-*` обнаружено после marker, генератор +завершится с ошибкой, но не станет менять общий хвост. + +На каждый выбранный exit, используемый роутером, генерируются один UCI +`config rule` (`mark -> lookup` той же таблицы) и один default `config route` +через соответствующий IPIP-интерфейс. Явного `blackhole` и fallback нет. Exit, +указанный только в `routing_rules`, все равно добавляется в IPIP-конфигурацию и +firewall-зону этого роутера. Для режима `wan` не создаются ни отдельная routing +table, ни policy rule: mark `9999` только защищает пакет от общих `Exit*`, а +затем стандартные правила RPDB приводят его к таблице `main`. + На роутере `exit-route.sh` периодически проверяет достижимость exit marker prefix через Babel и переключает активный выход. Traffic steering делается через policy routing: ```text -fwmark 0x25 -> table 200 -uid 4453 -> table 200 +fwmark 10000 -> table 10000 +uid 4453 -> table 10000 ``` -Через table `200` идут: +Через table `10000` идут: - помеченный пользовательский трафик - DoH bootstrap трафик пользователя `https-dns-proxy` -Если ни один exit не достижим через Babel, `exit-route.sh` оставляет UCI секцию `network.exit200`, но ставит: +Если ни один exit не достижим через Babel, `exit-route.sh` оставляет UCI секцию `network.exit10000`, но ставит: ```text disabled=1 @@ -692,7 +759,7 @@ DIRECT_ASNS='32590' `update-ipsets.sh` читает `/etc/ipsets/direct-static.txt`, добавляет country/ASN lists, атомарно обновляет `/etc/ipsets/direct.txt` и перезагружает firewall только если итоговый список изменился. -Трафик к direct destination не получает mark `0x25`, поэтому не уходит через exit table. +Трафик к direct destination не получает mark `10000`, поэтому не уходит через exit table. На exit серверах direct lists используются как обратная защита. @@ -741,7 +808,9 @@ server guard rule -> если direct destination все же пришел на routers/example/files/etc/config/firewall_part ``` -состоит из generated managed части и общей tail части после marker: +задает общую tail-часть после marker. В router-specific `firewall_part` часть +до marker генерируется отдельно, а часть начиная с marker целиком копируется +из `example` через `sync_rules.py`: ```text # Unique part up to this line @@ -870,13 +939,19 @@ customization() { - generated managed блоки редактируются через `config.json` и генератор - ручная логика живет рядом в `customization()` или в неменеджеренных UCI блоках до marker +- старые UCI-блоки с другой идентичностью автоматически не удаляются и остаются видимыми как unmanaged `tools/show_unmanaged.py` скрывает generated блоки только при byte-exact совпадении с тем, что выводит генератор. -Для проверки неожиданных unmanaged частей: +Для генерации конфигов и одновременного просмотра полного отчета по unmanaged частям используйте: + +```sh +./generate_configs.py --details +``` + +Без `--details` после генерации выводится только общий SHA-256 отчета. Если конфиги уже сгенерированы и нужно повторно посмотреть отчет без запуска генератора, можно вызвать диагностический инструмент напрямую: ```sh -./tools/show_unmanaged.py ./tools/show_unmanaged.py --details ``` @@ -977,7 +1052,7 @@ xn--p1ai Он не задает маршрут для `google.com`, а только определяет, какой домен проверяется на каждом DNS endpoint. -DoH процесс работает под uid `4453`, а network rule отправляет uidrange `4453-4453` в table `200`. +DoH процесс работает под uid `4453`, а network rule отправляет uidrange `4453-4453` в table `10000`. Это позволяет DoH bootstrap трафику идти через выбранный exit так же, как помеченному пользовательскому трафику. @@ -1376,6 +1451,7 @@ Profile name является безопасным ASCII identifier. ./generate_configs.py --skip-awg-download --skip-package-sync ./generate_configs.py --skip-hooks ./generate_configs.py --force +./generate_configs.py --details ``` Что делает: @@ -1392,6 +1468,8 @@ Profile name является безопасным ASCII identifier. `--force` передается в `tools/generate.py` и пересоздает mesh/exit WG/AWG keys. Access secrets сохраняются. +`--details` после генерации печатает не только SHA-256, но и полный отчет по unmanaged sections/files. Это основной удобный способ проверить результат генерации; отдельно запускать `tools/show_unmanaged.py` обычно не требуется. + `--skip-hooks` пропускает запуск: - `tools.generate` @@ -1749,10 +1827,10 @@ python3 -m py_compile *.py tools/*.py python3 -m tools.validate ``` -Отчет по unmanaged sections/files: +Генерация с полным отчетом по unmanaged sections/files: ```sh -./tools/show_unmanaged.py --details +./generate_configs.py --details ``` Remote versions: @@ -1776,7 +1854,7 @@ Failed systemd units на exit: - `allow_to_router` разрешает INPUT на target роутер. - `allow_to_lan` разрешает FORWARD в LAN target роутера. - `exit_order` задает приоритет выхода, но не адресацию. -- Если все exit недоступны, `exit-route.sh` ставит `network.exit200.disabled=1`, и трафик возвращается на main default path. +- Если все exit недоступны, `exit-route.sh` ставит `network.exit10000.disabled=1`, и трафик возвращается на main default path. - Reverse exit без `listen_ip` первично деплоится руками, а после bootstrap доступен через generated node IP. - `server__node` - overlay alias. - `server_` - public/bootstrap alias. diff --git a/config.json b/config.json index a8a879b..fae5f7e 100644 --- a/config.json +++ b/config.json @@ -42,6 +42,22 @@ "REV01", "REV02" ], + "routing_rules": [ + { + "src_ip": "10.101.1.50/32", + "mode": "wan" + }, + { + "src_ip": "10.101.1.51/32", + "mode": "split", + "exit": "EGR02" + }, + { + "src_ip": "10.101.1.52/32", + "mode": "exit", + "exit": "EGR02" + } + ], "wifi_2g": { "ssid": "Example-Spine01-2G", "key": "Example-Spine01-2G" diff --git a/generate_configs.py b/generate_configs.py index c916ee9..279e9c4 100755 --- a/generate_configs.py +++ b/generate_configs.py @@ -221,6 +221,11 @@ def main() -> None: "WireGuard/AmneziaWG keys; access secrets are preserved" ), ) + ap.add_argument( + "--details", + action="store_true", + help="print full unmanaged report after its sha256 hash when unmanaged content exists", + ) args = ap.parse_args() @@ -247,6 +252,8 @@ def main() -> None: for router in routers: sync_router(EXAMPLE_ROUTER_DIR, router) + print("OK: router synchronization finished successfully") + if not args.skip_hooks: config_arg = str(Path(args.config)) @@ -257,9 +264,15 @@ def main() -> None: generate_args.append("--skip-direct-downloads") generate_main(generate_args) + print("OK: generation finished successfully") + ensure_ssh_keys_main(["--config", config_arg]) validate_main(["--config", config_arg]) - show_unmanaged_main(["--config", config_arg]) + + show_unmanaged_args = ["--config", config_arg] + if args.details: + show_unmanaged_args.append("--details") + show_unmanaged_main(show_unmanaged_args) if __name__ == "__main__": diff --git a/routers/example/files/etc/config/firewall_part b/routers/example/files/etc/config/firewall_part index 10a98ad..63cd6f6 100644 --- a/routers/example/files/etc/config/firewall_part +++ b/routers/example/files/etc/config/firewall_part @@ -36,8 +36,9 @@ config rule option src 'lan' option dest '*' option ipset '!direct' + option mark '0' option target 'MARK' - option set_mark '0x25' + option set_mark '10000' option family 'ipv4' list proto 'all' @@ -46,8 +47,9 @@ config rule option src 'TrustedAccess' option dest '*' option ipset '!direct' + option mark '0' option target 'MARK' - option set_mark '0x25' + option set_mark '10000' option family 'ipv4' list proto 'all' @@ -56,27 +58,18 @@ config rule option src 'TransitAccess' option dest '*' option ipset '!direct' + option mark '0' option target 'MARK' - option set_mark '0x25' + option set_mark '10000' option family 'ipv4' list proto 'all' -config rule - option name 'ExitOutput' - option dest '*' - option ipset '!direct' - option target 'MARK' - option set_mark '0x25' - option family 'ipv4' - list proto 'all' - option enabled '0' - config rule option name 'ExitIPIPClearMark' option src '*' option dest 'ExitIPIP' option target 'MARK' - option set_mark '0x0/0xffffffff' + option set_mark '0' option family 'ipv4' list proto 'all' diff --git a/routers/example/files/etc/config/network_part b/routers/example/files/etc/config/network_part index 02273f0..e82e099 100644 --- a/routers/example/files/etc/config/network_part +++ b/routers/example/files/etc/config/network_part @@ -3,10 +3,10 @@ config rule option priority '90' - option lookup '200' - option uidrange '4453-4453' + option lookup '10000' + option uidrange '4453' config rule option priority '100' - option lookup '200' - option mark '0x25' + option lookup '10000' + option mark '10000' diff --git a/routers/example/files/etc/config/watchcat b/routers/example/files/etc/config/watchcat index 106ec49..d20a022 100644 --- a/routers/example/files/etc/config/watchcat +++ b/routers/example/files/etc/config/watchcat @@ -2,8 +2,8 @@ config watchcat option mode 'ping_reboot' option period '5m' - option pinghosts '195.208.4.1' - option addressfamily 'any' - option pingperiod '1m' + option pinghosts '195.208.4.1 77.88.8.8' + option addressfamily 'ipv4' + option pingperiod '30s' option pingsize 'standard' option forcedelay '1m' diff --git a/routers/example/files/etc/scripts/exit-route.sh b/routers/example/files/etc/scripts/exit-route.sh index 5cda481..56d29aa 100755 --- a/routers/example/files/etc/scripts/exit-route.sh +++ b/routers/example/files/etc/scripts/exit-route.sh @@ -9,7 +9,7 @@ fi # shellcheck disable=SC1091 . /etc/router-autoinstall.env -TABLE="${EXIT_ROUTE_TABLE:-200}" +TABLE="${EXIT_ROUTE_TABLE:-10000}" INTERVAL="${EXIT_ROUTE_INTERVAL:-5}" ROUTE_SECTION="exit${TABLE}" diff --git a/tools/common.py b/tools/common.py index f22dd01..c62b43b 100755 --- a/tools/common.py +++ b/tools/common.py @@ -176,6 +176,7 @@ try: ConfigData, DeviceProfile, ExitDirectConfig, + RoutingRule, ExitExitLinkParams, ExitHub, FirewallAllow, @@ -196,6 +197,7 @@ except ImportError: ConfigData, DeviceProfile, ExitDirectConfig, + RoutingRule, ExitExitLinkParams, ExitHub, FirewallAllow, diff --git a/tools/config_builder.py b/tools/config_builder.py index f07b3dc..3ea2ccd 100755 --- a/tools/config_builder.py +++ b/tools/config_builder.py @@ -67,6 +67,7 @@ def build_config_data(raw_cfg: dict[str, object]) -> ConfigData: exit_hubs_by_name = {h.name: h for h in exit_hubs} exit_order = [name for group in exit_order_groups for name in group] exit_order_by_router: dict[str, list[str]] = {} + routing_rules_by_router: dict[str, list[RoutingRule]] = {} exit_direct = load_exit_direct_config() access: dict[str, list[AccessGroup]] = {name: [] for name in router_names} @@ -97,6 +98,14 @@ def build_config_data(raw_cfg: dict[str, object]) -> ConfigData: ) if router_exit_order: exit_order_by_router[router_name] = router_exit_order + router_routing_rules = load_router_routing_rules( + raw.get(CONFIG_KEY_ROUTING_RULES), + f"routers[{router_name}].routing_rules", + router=router, + exit_hubs_by_name=exit_hubs_by_name, + ) + if router_routing_rules: + routing_rules_by_router[router_name] = router_routing_rules wifi[router_name] = load_wifi_config(raw, f"routers[{router_name}]") for key, kind in ( (CONFIG_KEY_ALLOW_TO_ROUTER, FIREWALL_ALLOW_KIND_ROUTER), @@ -250,6 +259,7 @@ def build_config_data(raw_cfg: dict[str, object]) -> ConfigData: exit_hubs_by_name=exit_hubs_by_name, exit_order=exit_order, exit_order_by_router=exit_order_by_router, + routing_rules_by_router=routing_rules_by_router, exit_direct=exit_direct, access=access, firewall_allows=firewall_allows, diff --git a/tools/config_exit_model.py b/tools/config_exit_model.py index 055e884..ac371d2 100755 --- a/tools/config_exit_model.py +++ b/tools/config_exit_model.py @@ -5,7 +5,13 @@ import re try: from .process import die from .default import * - from .config_model import ConfigData, ExitDirectConfig, ExitHub, RouterDef + from .config_model import ( + ConfigData, + ExitDirectConfig, + ExitHub, + RoutingRule, + RouterDef, + ) from .net_model import ( exit_announce_network, generated_exit_announce_network, @@ -15,7 +21,13 @@ try: except ImportError: from process import die # type: ignore from default import * # type: ignore - from config_model import ConfigData, ExitDirectConfig, ExitHub, RouterDef # type: ignore + from config_model import ( # type: ignore + ConfigData, + ExitDirectConfig, + ExitHub, + RouterDef, + RoutingRule, + ) from net_model import ( # type: ignore exit_announce_network, generated_exit_announce_network, @@ -141,6 +153,97 @@ def router_exit_order_hubs( return [cfg.exit_hubs_by_name[name] for name in order] +def load_router_routing_rules( + value: object, + where: str, + *, + router: RouterDef, + exit_hubs_by_name: dict[str, ExitHub], +) -> list[RoutingRule]: + if value is None: + return [] + if not isinstance(value, list): + die(f"{where} must be a list") + + router_net = ipaddress.IPv4Network(router.subnet, strict=True) + rules: list[RoutingRule] = [] + seen_sources: set[str] = set() + + for idx, raw in enumerate(value, start=1): + rule_where = f"{where}[{idx}]" + if not isinstance(raw, dict): + die(f"{rule_where} must be an object") + + src_value = raw.get(CONFIG_KEY_SRC_IP) + if not isinstance(src_value, str) or not src_value.strip(): + die(f"{rule_where}.src_ip must be a non-empty IPv4 address or /32") + try: + src_net = ipaddress.IPv4Network(src_value.strip(), strict=True) + except ValueError as e: + die(f"{rule_where}.src_ip must be an IPv4 address or strict /32: {e}") + if src_net.prefixlen != 32: + die( + f"{rule_where}.src_ip must identify exactly one device (/32): " + f"{src_net}" + ) + if not src_net.subnet_of(router_net): + die( + f"{rule_where}.src_ip {src_net} is outside router subnet " + f"{router_net}" + ) + src_ip = str(src_net) + if src_ip in seen_sources: + die(f"{where}: duplicate src_ip: {src_ip}") + seen_sources.add(src_ip) + + mode = raw.get(CONFIG_KEY_MODE) + if not isinstance(mode, str) or mode not in ROUTING_MODES: + allowed = ", ".join(sorted(ROUTING_MODES)) + die(f"{rule_where}.mode must be one of: {allowed}") + + exit_name: str | None = None + if mode == ROUTING_MODE_WAN: + if CONFIG_KEY_EXIT in raw: + die(f"{rule_where}.exit is not allowed for mode {ROUTING_MODE_WAN}") + else: + exit_value = raw.get(CONFIG_KEY_EXIT) + if not isinstance(exit_value, str) or not exit_value: + die(f"{rule_where}.exit must be a non-empty exit hub name") + if exit_value not in exit_hubs_by_name: + die(f"{rule_where}.exit references unknown exit hub: {exit_value}") + exit_name = exit_value + + rules.append(RoutingRule(src_ip=src_ip, mode=mode, exit_name=exit_name)) + + return rules + + +def routing_exit_policy_ids(cfg: ConfigData) -> dict[str, int]: + used_names = { + rule.exit_name + for rules in cfg.routing_rules_by_router.values() + for rule in rules + if rule.exit_name is not None + } + return { + hub.name: EXIT_POLICY_BASE + idx + 1 + for idx, hub in enumerate( + hub for hub in cfg.exit_hubs if hub.name in used_names + ) + } + + +def router_required_exit_hubs(cfg: ConfigData, router_name: str) -> list[ExitHub]: + names = [hub.name for hub in router_exit_order_hubs(cfg, router_name)] + seen = set(names) + for rule in cfg.routing_rules_by_router.get(router_name, []): + if rule.exit_name is None or rule.exit_name in seen: + continue + names.append(rule.exit_name) + seen.add(rule.exit_name) + return [cfg.exit_hubs_by_name[name] for name in names] + + def validate_exit_announce_set(hubs: list[ExitHub]) -> None: networks = [exit_announce_network(hub) for hub in hubs] if not networks: diff --git a/tools/config_model.py b/tools/config_model.py index 05bb7bd..5fd68bb 100755 --- a/tools/config_model.py +++ b/tools/config_model.py @@ -102,6 +102,13 @@ class ExitHub: port_range: PortRange +@dataclass(frozen=True) +class RoutingRule: + src_ip: str + mode: str + exit_name: str | None + + @dataclass(frozen=True) class ExitDirectConfig: subnets: list[str] @@ -223,6 +230,7 @@ class ConfigData: exit_hubs_by_name: dict[str, ExitHub] exit_order: list[str] exit_order_by_router: dict[str, list[str]] + routing_rules_by_router: dict[str, list[RoutingRule]] exit_direct: ExitDirectConfig access: dict[str, list[AccessGroup]] firewall_allows: list[FirewallAllow] diff --git a/tools/config_schema.py b/tools/config_schema.py index 3467b18..fb14e2e 100755 --- a/tools/config_schema.py +++ b/tools/config_schema.py @@ -45,6 +45,7 @@ ROUTER_KEYS = { CONFIG_KEY_SUBNET, CONFIG_KEY_PACKAGES, CONFIG_KEY_EXIT_ORDER, + CONFIG_KEY_ROUTING_RULES, CONFIG_KEY_ALLOW_TO_LAN, CONFIG_KEY_ALLOW_TO_ROUTER, CONFIG_KEY_WIFI_2G, @@ -72,6 +73,8 @@ ACCESS_POLICIES = {ACCESS_POLICY_TRUSTED, ACCESS_POLICY_TRANSIT} FIREWALL_ALLOW_KIND_ROUTER = "router" FIREWALL_ALLOW_KIND_LAN = "lan" +ROUTING_RULE_KEYS = {CONFIG_KEY_SRC_IP, CONFIG_KEY_MODE, CONFIG_KEY_EXIT} + ACCESS_KEYS = { CONFIG_KEY_NAME, CONFIG_KEY_PROTOCOL, @@ -212,6 +215,16 @@ def validate_config_known_keys(raw_cfg: dict[str, object]) -> None: f"{where}.exit_order", ) + routing_rules = raw.get(CONFIG_KEY_ROUTING_RULES) + if routing_rules is not None: + if not isinstance(routing_rules, list): + die(f"{where}.routing_rules must be a list") + for rule_idx, rule in enumerate(routing_rules, start=1): + rule_where = f"{where}.routing_rules[{rule_idx}]" + if not isinstance(rule, dict): + die(f"{rule_where} must be an object") + require_known_keys(rule, rule_where, ROUTING_RULE_KEYS) + packages = raw.get(CONFIG_KEY_PACKAGES) if packages is not None: validate_config_package_list( diff --git a/tools/default.py b/tools/default.py index 8ca5add..90da7df 100755 --- a/tools/default.py +++ b/tools/default.py @@ -35,6 +35,10 @@ CONFIG_KEY_ROUTERS = "routers" CONFIG_KEY_MESH_HUBS = "mesh_hubs" CONFIG_KEY_EXIT_HUBS = "exit_hubs" CONFIG_KEY_EXIT_ORDER = "exit_order" +CONFIG_KEY_ROUTING_RULES = "routing_rules" +CONFIG_KEY_EXIT = "exit" +CONFIG_KEY_MODE = "mode" +CONFIG_KEY_SRC_IP = "src_ip" CONFIG_KEY_ACCESS = "access" CONFIG_KEY_BOARD = "board" @@ -382,7 +386,17 @@ CHECK_DOH_RESOLV = "/tmp/resolv.conf.d/resolv.conf.auto" CHECK_DOH_RESOLV_WAIT_MAX = 300 CHECK_DOH_PROVIDER_DOMAINS = ["ru", "xn--p1ai"] -EXIT_ROUTE_TABLE = 200 +ROUTING_MODE_WAN = "wan" +ROUTING_MODE_SPLIT = "split" +ROUTING_MODE_EXIT = "exit" +ROUTING_MODES = {ROUTING_MODE_WAN, ROUTING_MODE_SPLIT, ROUTING_MODE_EXIT} + +EXIT_POLICY_BASE = 10000 +EXIT_RULE_PRIORITY = 80 +EXIT_RULE_SECTION_PREFIX = "owmb_exit_rule_" +EXIT_RULE_ROUTE_SECTION_PREFIX = "owmb_exit_route_" +ROUTING_WAN_MARK_TEXT = "9999" +ROUTING_FIREWALL_RULE_PREFIX = "Routing-" EXIT_ROUTE_INTERVAL = 5 RUNTIME_IPSETS_DIR = "/etc/ipsets" diff --git a/tools/ensure_ssh_keys.py b/tools/ensure_ssh_keys.py index 059fc63..534b0a0 100755 --- a/tools/ensure_ssh_keys.py +++ b/tools/ensure_ssh_keys.py @@ -234,7 +234,6 @@ def write_ssh_config( text = build_ssh_config(cfg_data, key_dir) atomic_write_text(ssh_config_path, text) ssh_config_path.chmod(PRIVATE_KEY_FILE_MODE) - print(f"Writing SSH config: {ssh_config_path}") def main(argv: list[str] | None = None) -> None: @@ -260,12 +259,13 @@ def main(argv: list[str] | None = None) -> None: key_dir = deploy_ssh_dir_from_config(cfg) ssh_config_path = deploy_ssh_config_path_from_config(cfg) ensure_dir(key_dir, PRIVATE_SSH_DIR_MODE) - print(f"Using local SSH key dir: {key_dir}") process_routers(cfg_data, key_dir) process_servers(cfg_data, key_dir) write_ssh_config(cfg_data, ssh_config_path, key_dir) + print("OK: SSH keys and config prepared successfully") + if __name__ == "__main__": main() diff --git a/tools/generate.py b/tools/generate.py index dc803c7..f1987c4 100755 --- a/tools/generate.py +++ b/tools/generate.py @@ -21,6 +21,7 @@ try: build_exit_in_network_interface_block, build_exit_ipip_interface_block, build_exit_out_network_interface_block, + build_exit_rule_network_blocks, build_material_for_exit, build_material_for_exit_exit, build_material_for_exit_reverse, @@ -61,6 +62,7 @@ except ImportError: build_exit_in_network_interface_block, build_exit_ipip_interface_block, build_exit_out_network_interface_block, + build_exit_rule_network_blocks, build_material_for_exit, build_material_for_exit_exit, build_material_for_exit_reverse, @@ -359,9 +361,10 @@ def main(argv: list[str] | None = None) -> None: ).strip() ipip_text = "\n".join( router_exit_ipip_blocks[router_name][hub.name].strip() - for hub in router_exit_order_hubs(cfg, router_name) + for hub in router_required_exit_hubs(cfg, router_name) if hub.name in router_exit_ipip_blocks[router_name] ).strip() + exit_rule_text = build_exit_rule_network_blocks(cfg, router_name).strip() access_text = access_blocks.get(router_name, "").strip() update_network_part( @@ -370,6 +373,7 @@ def main(argv: list[str] | None = None) -> None: mesh_text=mesh_text, exit_text=exit_text, ipip_text=ipip_text, + exit_rule_text=exit_rule_text, access_text=access_text, access_names=access_names, ) @@ -381,7 +385,7 @@ def main(argv: list[str] | None = None) -> None: ) active_exit_ipip_ifaces = [ router_exit_ipip_iface_name(hub.name) - for hub in router_exit_order_hubs(cfg, router_name) + for hub in router_required_exit_hubs(cfg, router_name) ] update_firewall_part( cfg=cfg, diff --git a/tools/generate_exit.py b/tools/generate_exit.py index 11a9876..730bc78 100755 --- a/tools/generate_exit.py +++ b/tools/generate_exit.py @@ -237,6 +237,45 @@ def build_exit_ipip_interface_block( ) +def build_exit_rule_network_blocks(cfg: ConfigData, router_name: str) -> str: + rules = cfg.routing_rules_by_router.get(router_name, []) + if not rules: + return "" + + policy_ids = routing_exit_policy_ids(cfg) + blocks: list[str] = [] + + for exit_name in dict.fromkeys( + rule.exit_name for rule in rules if rule.exit_name is not None + ): + policy_id = policy_ids[exit_name] + section_suffix = exit_name.lower() + blocks.append( + uci_block( + "rule", + f"{EXIT_RULE_SECTION_PREFIX}{section_suffix}", + options={ + "priority": str(EXIT_RULE_PRIORITY), + "mark": str(policy_id), + "lookup": str(policy_id), + }, + ) + ) + blocks.append( + uci_block( + "route", + f"{EXIT_RULE_ROUTE_SECTION_PREFIX}{section_suffix}", + options={ + "interface": router_exit_ipip_iface_name(exit_name), + "target": "0.0.0.0/0", + "table": str(policy_id), + }, + ) + ) + + return "\n\n".join(blocks) + ("\n" if blocks else "") + + def build_server_direct_conf( client_alias: str, hub: ExitHub, diff --git a/tools/generate_firewall.py b/tools/generate_firewall.py index ba89f1f..2cad678 100755 --- a/tools/generate_firewall.py +++ b/tools/generate_firewall.py @@ -5,14 +5,24 @@ sys.dont_write_bytecode = True try: from .common import * - from .managed_blocks import render_marked_uci_text + from .managed_blocks import ( + generated_uci_management_keys, + render_marked_uci_text, + uci_management_key, + ) + from .uci import parse_uci_block, split_uci_blocks from .tunnel_model import ( exit_reverse_firewall_rule_name, router_exit_listen_port, ) except ImportError: from common import * # type: ignore - from managed_blocks import render_marked_uci_text # type: ignore + from managed_blocks import ( # type: ignore + generated_uci_management_keys, + render_marked_uci_text, + uci_management_key, + ) + from uci import parse_uci_block, split_uci_blocks # type: ignore from tunnel_model import ( # type: ignore exit_reverse_firewall_rule_name, router_exit_listen_port, @@ -107,57 +117,63 @@ def build_rule_allow_ssh_from_exit_to_router() -> str: ) -def managed_firewall_rule_names( - cfg: ConfigData, - router_name: str, - access_groups_for_router: list[AccessGroup], -) -> set[str]: - names: set[str] = { - TRANSIT_ACCESS_DNS_RULE_NAME, - "Allow-SSH-From-Exit-To-Router", - } - - if router_name in cfg.mesh_hubs_by_name: - names.add(FIREWALL_RULE_ALLOW_MESH) - hub = cfg.mesh_hubs_by_name[router_name] - for _hub_name, target_name in mesh_link_specs_for_hub(cfg, router_name): - names.add(mesh_firewall_rule_name(hub.name, target_name)) - for exit_hub in cfg.exit_hubs: - names.add(exit_reverse_firewall_rule_name(exit_hub.name)) - - for group in access_groups_for_router: - names.add(f"Allow-{group.name}") - - for allow in cfg.firewall_allows: - for target_name in expand_firewall_targets(cfg, allow): - if target_name == router_name: - names.add( - firewall_allow_rule_name(allow.source_name, target_name, allow.kind) - ) - - return names +def routing_firewall_rule_name(rule: RoutingRule) -> str: + src = rule.src_ip.split("/", 1)[0].replace(".", "_") + if rule.mode == ROUTING_MODE_WAN: + return f"{ROUTING_FIREWALL_RULE_PREFIX}WAN-{src}" + assert rule.exit_name is not None + mode = rule.mode.capitalize() + return f"{ROUTING_FIREWALL_RULE_PREFIX}{mode}-{rule.exit_name}-{src}" -def managed_firewall_zone_names() -> set[str]: - return set(MANAGED_FIREWALL_ZONES) | {ZONE_EXIT_IPIP} +def build_routing_firewall_blocks(cfg: ConfigData, router_name: str) -> list[str]: + policy_ids = routing_exit_policy_ids(cfg) + blocks: list[str] = [] + + for rule in cfg.routing_rules_by_router.get(router_name, []): + src_ip = rule.src_ip.split("/", 1)[0] + options = { + "name": routing_firewall_rule_name(rule), + "src": FIREWALL_ZONE_LAN, + "src_ip": src_ip, + "dest": "*", + "target": "MARK", + "family": "ipv4", + } + + if rule.mode == ROUTING_MODE_WAN: + options["set_mark"] = ROUTING_WAN_MARK_TEXT + else: + assert rule.exit_name is not None + options["set_mark"] = str(policy_ids[rule.exit_name]) + if rule.mode == ROUTING_MODE_SPLIT: + options["ipset"] = "!direct" + + blocks.append( + uci_block( + "rule", + None, + options=options, + lists={"proto": ["all"]}, + ).strip() + ) + + return blocks -def is_managed_firewall_block( - parsed: dict[str, object], - *, - rule_names: set[str], - zone_names: set[str], -) -> bool: - typ = str(parsed.get("type", "")) - options = parsed.get("options", {}) +def validate_firewall_shared_tail(marker_and_tail: str, path: Path) -> None: + for block in split_uci_blocks(marker_and_tail): + parsed = parse_uci_block(block) + if parsed.get("type") != "rule": + continue - if typ == "zone" and str(options.get("name", "")) in zone_names: - return True - - if typ == "rule" and str(options.get("name", "")) in rule_names: - return True - - return False + name = str(parsed.get("options", {}).get("name", "")) + if name.startswith(ROUTING_FIREWALL_RULE_PREFIX): + die( + f"router-specific firewall rule {name!r} found after " + f"{FIREWALL_MARKER!r} in {path}; the shared tail is owned " + "by routers/example" + ) def build_firewall_blocks( @@ -276,6 +292,8 @@ def build_firewall_blocks( ).strip() ) + blocks.extend(build_routing_firewall_blocks(cfg, router_name)) + return blocks @@ -291,17 +309,7 @@ def update_firewall_part( original = read(path) before_marker, marker_and_tail = split_text_by_marker(original, path) - rule_names = managed_firewall_rule_names(cfg, router_name, access_groups_for_router) - zone_names = managed_firewall_zone_names() - - def keep_block(parsed: dict[str, object]) -> bool: - return not is_managed_firewall_block( - parsed, - rule_names=rule_names, - zone_names=zone_names, - ) - - preserved_before = filter_preserved_before_marker(before_marker, keep_block) + validate_firewall_shared_tail(marker_and_tail, path) blocks = build_firewall_blocks( cfg=cfg, router_name=router_name, @@ -310,18 +318,30 @@ def update_firewall_part( exit_ipip_ifaces=exit_ipip_ifaces, access_groups_for_router=access_groups_for_router, ) + managed_keys = generated_uci_management_keys(blocks) - # Keep the shared section after FIREWALL_MARKER byte-stable with - # routers/example. sync_rules.py owns that tail; normalizing the whole - # file here makes sync_rules.py and generate.py rewrite firewall_part on - # every run. Keep the historical leading blank line too: existing - # firewall_part files intentionally start with an empty first line, and - # sync_rules.py preserves the generated part byte-for-byte. - updated = render_marked_uci_text( + def keep_block(parsed: dict[str, object]) -> bool: + return uci_management_key(parsed) not in managed_keys + + preserved_before = filter_preserved_before_marker(before_marker, keep_block) + + # Render only the router-specific part. The marker and everything after + # it are owned by routers/example and sync_rules.py, so append that text + # exactly as it was read instead of passing it through a UCI renderer. + rendered_before = render_marked_uci_text( blocks, preserved_before, - marker_and_tail, + "", leading_newline=True, normalize_result=False, ) + if rendered_before: + updated = rendered_before.rstrip("\n") + "\n\n" + marker_and_tail + else: + updated = marker_and_tail + + _updated_before, updated_tail = split_text_by_marker(updated, path) + if updated_tail != marker_and_tail: + die(f"internal error: shared firewall tail changed for {path}") + write(path, updated) diff --git a/tools/generate_network.py b/tools/generate_network.py index ebd41d2..1df2e92 100755 --- a/tools/generate_network.py +++ b/tools/generate_network.py @@ -5,10 +5,18 @@ sys.dont_write_bytecode = True try: from .common import * - from .managed_blocks import render_marked_uci_text + from .managed_blocks import ( + generated_uci_management_keys, + render_marked_uci_text, + uci_management_key, + ) except ImportError: from common import * # type: ignore - from managed_blocks import render_marked_uci_text # type: ignore + from managed_blocks import ( # type: ignore + generated_uci_management_keys, + render_marked_uci_text, + uci_management_key, + ) def update_network_part( @@ -17,6 +25,7 @@ def update_network_part( mesh_text: str, exit_text: str, ipip_text: str, + exit_rule_text: str, access_text: str, access_names: set[str], ) -> None: @@ -25,19 +34,16 @@ def update_network_part( before_marker, marker_and_tail = split_text_by_marker(original, path) - mesh_exit_names = managed_mesh_exit_ifaces(cfg, router_name) + generated_parts = [access_text, mesh_text, exit_text, ipip_text, exit_rule_text] + managed_keys = generated_uci_management_keys(generated_parts) def keep_block(parsed: dict[str, object]) -> bool: - if is_managed_network(parsed, mesh_exit_names): - return False - if is_managed_access(parsed, access_names): - return False - return True + return uci_management_key(parsed) not in managed_keys preserved_before = filter_preserved_before_marker(before_marker, keep_block) updated = render_marked_uci_text( - [access_text, mesh_text, exit_text, ipip_text], + generated_parts, preserved_before, marker_and_tail, ) diff --git a/tools/generate_router_misc.py b/tools/generate_router_misc.py index 0f075e9..3582e4b 100755 --- a/tools/generate_router_misc.py +++ b/tools/generate_router_misc.py @@ -218,11 +218,10 @@ def update_openvpn_babeld_hotplug_block( cfg: ConfigData, router_name: str, ) -> str: - body = remove_openvpn_babeld_hotplug_block(body) - if not router_has_openvpn_access(cfg, router_name): return body + body = remove_openvpn_babeld_hotplug_block(body) body = body.rstrip() if body: body += "\n\n" diff --git a/tools/generate_runtime.py b/tools/generate_runtime.py index 09cea3d..7b91055 100755 --- a/tools/generate_runtime.py +++ b/tools/generate_runtime.py @@ -122,7 +122,7 @@ def build_runtime_env(cfg: ConfigData, router_name: str | None = None) -> str: "CHECK_DOH_RESOLV": CHECK_DOH_RESOLV, "CHECK_DOH_RESOLV_WAIT_MAX": str(CHECK_DOH_RESOLV_WAIT_MAX), "CHECK_DOH_PROVIDER_DOMAINS": " ".join(CHECK_DOH_PROVIDER_DOMAINS), - "EXIT_ROUTE_TABLE": str(EXIT_ROUTE_TABLE), + "EXIT_ROUTE_TABLE": str(EXIT_POLICY_BASE), "EXIT_ROUTE_INTERVAL": str(EXIT_ROUTE_INTERVAL), "UPDATE_IPSETS_CURL_CONNECT_TIMEOUT": str(UPDATE_IPSETS_CURL_CONNECT_TIMEOUT), "UPDATE_IPSETS_CURL_MAX_TIME": str(UPDATE_IPSETS_CURL_MAX_TIME), diff --git a/tools/managed_blocks.py b/tools/managed_blocks.py index 98bfa7f..3854f5b 100755 --- a/tools/managed_blocks.py +++ b/tools/managed_blocks.py @@ -130,6 +130,48 @@ def collect_unmanaged_uci_blocks( return out +def uci_management_key( + parsed: dict[str, object], +) -> tuple[str, str, str] | None: + """Return the stable identity of a generated UCI block. + + Only blocks with an identity present in the current generated output are + replaced. Blocks left from an older configuration keep a different key, + remain above the marker and are reported by show_unmanaged. + """ + typ = str(parsed.get("type", "")) + name = str(parsed.get("name", "")) + options = parsed.get("options", {}) + + if name == typ: + option_name = str(options.get("name", "")) + if option_name: + return typ, "option:name", option_name + + if typ.startswith(("wireguard_", "amneziawg_")): + description = str(options.get("description", "")) + if description: + return typ, "option:description", description + + if name != typ: + return typ, "section", name + + return None + + +def generated_uci_management_keys(parts: Iterable[str]) -> set[tuple[str, str, str]]: + keys: set[tuple[str, str, str]] = set() + for part in parts: + for block in split_uci_blocks(part): + parsed = parse_uci_block(block) + if not parsed: + continue + key = uci_management_key(parsed) + if key is not None: + keys.add(key) + return keys + + def normalize_uci_part(text: str) -> str: return normalize_uci(text).strip("\n") diff --git a/tools/network_config.py b/tools/network_config.py index ab4fbf1..cb0e5f0 100755 --- a/tools/network_config.py +++ b/tools/network_config.py @@ -3,7 +3,11 @@ from pathlib import Path try: from .process import die - from .default import FIREWALL_MARKER, PROTOCOL_AMNEZIAWG, PROTOCOL_WIREGUARD + from .default import ( + FIREWALL_MARKER, + PROTOCOL_AMNEZIAWG, + PROTOCOL_WIREGUARD, + ) from .config_model import ConfigData from .layout import router_path from .link_model import ( @@ -21,7 +25,11 @@ try: from .config_builder import router_exit_ipip_iface_name except ImportError: from process import die # type: ignore - from default import FIREWALL_MARKER, PROTOCOL_AMNEZIAWG, PROTOCOL_WIREGUARD # type: ignore + from default import ( # type: ignore + FIREWALL_MARKER, + PROTOCOL_AMNEZIAWG, + PROTOCOL_WIREGUARD, + ) # type: ignore from config_model import ConfigData # type: ignore from layout import router_path # type: ignore from link_model import ( # type: ignore @@ -66,49 +74,6 @@ def current_mesh_exit_ifaces(cfg: ConfigData, router_name: str) -> set[str]: return names -def managed_mesh_exit_ifaces(cfg: ConfigData, router_name: str) -> set[str]: - return current_mesh_exit_ifaces(cfg, router_name) - - -def is_managed_network( - parsed: dict[str, object], - mesh_exit_ifaces: set[str] | None = None, -) -> bool: - typ = str(parsed.get("type", "")) - name = str(parsed.get("name", "")) - mesh_exit_ifaces = mesh_exit_ifaces or set() - - # Current mesh/exit generated sections. - if typ == "interface" and name in mesh_exit_ifaces: - return True - if name in {f"amneziawg_{iface}" for iface in mesh_exit_ifaces} | { - f"wireguard_{iface}" for iface in mesh_exit_ifaces - }: - return True - - return False - - -def is_managed_access(parsed: dict[str, object], access_names: set[str]) -> bool: - typ = str(parsed.get("type", "")) - name = str(parsed.get("name", "")) - - if typ == "interface" and name in access_names: - return True - - if typ.startswith("wireguard_") and typ == name: - iface = typ.removeprefix("wireguard_") - if iface in access_names: - return True - - if typ.startswith("amneziawg_") and typ == name: - iface = typ.removeprefix("amneziawg_") - if iface in access_names: - return True - - return False - - def find_access_peer_block( cfg_by_name: dict[str, dict[str, object]], iface_name: str, diff --git a/tools/show_unmanaged_collect.py b/tools/show_unmanaged_collect.py index 32b7fa2..511c659 100755 --- a/tools/show_unmanaged_collect.py +++ b/tools/show_unmanaged_collect.py @@ -22,6 +22,7 @@ try: build_exit_in_network_interface_block, build_exit_ipip_interface_block, build_exit_out_network_interface_block, + build_exit_rule_network_blocks, build_material_for_exit, build_material_for_exit_reverse, ) @@ -54,6 +55,7 @@ except ImportError: build_exit_in_network_interface_block, build_exit_ipip_interface_block, build_exit_out_network_interface_block, + build_exit_rule_network_blocks, build_material_for_exit, build_material_for_exit_reverse, ) @@ -169,10 +171,11 @@ def expected_router_generation_state( ).strip() ipip_text = "\n".join( router_exit_ipip_blocks[router_name][hub.name].strip() - for hub in router_exit_order_hubs(cfg, router_name) + for hub in router_required_exit_hubs(cfg, router_name) if hub.name in router_exit_ipip_blocks[router_name] ).strip() + exit_rule_text = build_exit_rule_network_blocks(cfg, router_name).strip() network_text = "\n\n".join( part for part in ( @@ -180,6 +183,7 @@ def expected_router_generation_state( mesh_blocks[router_name].strip(), exit_text, ipip_text, + exit_rule_text, ) if part ) @@ -188,7 +192,7 @@ def expected_router_generation_state( active_exit_ipip_ifaces = sorted( { router_exit_ipip_iface_name(hub.name) - for hub in router_exit_order_hubs(cfg, router_name) + for hub in router_required_exit_hubs(cfg, router_name) } ) firewall_text = "\n\n".join( diff --git a/tools/validate.py b/tools/validate.py index f1bbb52..b737c4f 100755 --- a/tools/validate.py +++ b/tools/validate.py @@ -26,6 +26,7 @@ try: ) from .validate_network import ( validate_current_network_objects, + validate_exit_rule_network_objects, validate_link_is_31_pair, validate_link_local_matches_ipv4, validate_subnet_isolation, @@ -71,6 +72,7 @@ except ImportError: ) from validate_network import ( # type: ignore validate_current_network_objects, + validate_exit_rule_network_objects, validate_link_is_31_pair, validate_link_local_matches_ipv4, validate_subnet_isolation, @@ -199,6 +201,7 @@ def main(argv: list[str] | None = None) -> None: validate_exit_pair_confs(cfg, existing) validate_access_links(cfg, existing) validate_current_network_objects(cfg, existing) + validate_exit_rule_network_objects(cfg, existing) vprint("=== FIREWALL VALIDATION ===") validate_firewall(cfg) diff --git a/tools/validate_firewall.py b/tools/validate_firewall.py index 50ecd6d..4329e9a 100755 --- a/tools/validate_firewall.py +++ b/tools/validate_firewall.py @@ -6,6 +6,7 @@ from pathlib import Path try: from .common import * + from .generate_firewall import routing_firewall_rule_name from .tunnel_model import ( exit_hub_is_public, exit_reverse_firewall_rule_name, @@ -15,11 +16,11 @@ try: from .validate_uci import ( find_firewall_rule_by_name, parse_uci_file, - require_firewall_rule_absent, require_firewall_rule_port, ) except ImportError: from common import * # type: ignore + from generate_firewall import routing_firewall_rule_name # type: ignore from tunnel_model import ( # type: ignore exit_hub_is_public, exit_reverse_firewall_rule_name, @@ -29,7 +30,6 @@ except ImportError: from validate_uci import ( # type: ignore find_firewall_rule_by_name, parse_uci_file, - require_firewall_rule_absent, require_firewall_rule_port, ) @@ -50,8 +50,6 @@ def require_firewall_zone_networks( ) -> None: block = find_firewall_zone_by_name(parsed_blocks, name) if not expected: - if block is not None: - die(f"{path}: stale firewall zone {name}") return if block is None: die(f"{path}: missing firewall zone {name}") @@ -176,14 +174,136 @@ def require_firewall_ssh_from_exit_rule( die(f"{path}: firewall rule {name}: bad target") -def validate_firewall(cfg: ConfigData) -> None: - expected_rule_names_by_router: dict[str, set[str]] = { - name: set() for name in cfg.router_names +def require_routing_firewall_rule( + parsed_blocks: list[dict[str, object]], + path: Path, + rule: RoutingRule, + policy_ids: dict[str, int], +) -> None: + name = routing_firewall_rule_name(rule) + block = find_firewall_rule_by_name(parsed_blocks, name) + if block is None: + die(f"{path}: missing routing firewall rule {name}") + + opts = block.get("options", {}) + lists = block.get("lists", {}) + expected = { + "src": FIREWALL_ZONE_LAN, + "src_ip": rule.src_ip.split("/", 1)[0], + "dest": "*", + "target": "MARK", + "family": "ipv4", } + if rule.mode == ROUTING_MODE_WAN: + expected["set_mark"] = ROUTING_WAN_MARK_TEXT + else: + assert rule.exit_name is not None + expected["set_mark"] = str(policy_ids[rule.exit_name]) + if rule.mode == ROUTING_MODE_SPLIT: + expected["ipset"] = "!direct" + + for key, value in expected.items(): + if opts.get(key) != value: + die( + f"{path}: routing firewall rule {name}: " + f"bad {key}; expected {value!r}, got {opts.get(key)!r}" + ) + + if rule.mode != ROUTING_MODE_SPLIT and "ipset" in opts: + die(f"{path}: routing firewall rule {name}: unexpected ipset") + if lists.get("proto", []) != ["all"]: + die(f"{path}: routing firewall rule {name}: bad proto") + + +def require_shared_exit_mark_rules( + parsed_blocks: list[dict[str, object]], path: Path +) -> None: + mark_specs = { + "Exitlan": FIREWALL_ZONE_LAN, + "ExitTrustedAccess": ZONE_TRUSTED_ACCESS, + "ExitTransitAccess": ZONE_TRANSIT_ACCESS, + } + + for name, src in mark_specs.items(): + matches = [ + block + for block in parsed_blocks + if block.get("type") == "rule" + and block.get("options", {}).get("name") == name + ] + if len(matches) != 1: + die( + f"{path}: expected exactly one firewall rule {name}, got {len(matches)}" + ) + opts = matches[0].get("options", {}) + lists = matches[0].get("lists", {}) + expected = { + "src": src, + "dest": "*", + "ipset": "!direct", + "mark": "0", + "target": "MARK", + "set_mark": str(EXIT_POLICY_BASE), + "family": "ipv4", + } + for key, value in expected.items(): + if opts.get(key) != value: + die( + f"{path}: firewall rule {name}: bad {key}; " + f"expected {value!r}, got {opts.get(key)!r}" + ) + if lists.get("proto", []) != ["all"]: + die(f"{path}: firewall rule {name}: bad proto") + + clear_matches = [ + block + for block in parsed_blocks + if block.get("type") == "rule" + and block.get("options", {}).get("name") == "ExitIPIPClearMark" + ] + if len(clear_matches) != 1: + die( + f"{path}: expected exactly one firewall rule ExitIPIPClearMark, " + f"got {len(clear_matches)}" + ) + clear_opts = clear_matches[0].get("options", {}) + clear_lists = clear_matches[0].get("lists", {}) + clear_expected = { + "src": "*", + "dest": ZONE_EXIT_IPIP, + "target": "MARK", + "set_mark": "0", + "family": "ipv4", + } + for key, value in clear_expected.items(): + if clear_opts.get(key) != value: + die( + f"{path}: firewall rule ExitIPIPClearMark: bad {key}; " + f"expected {value!r}, got {clear_opts.get(key)!r}" + ) + if "mark" in clear_opts: + die(f"{path}: firewall rule ExitIPIPClearMark: unexpected mark condition") + if clear_lists.get("proto", []) != ["all"]: + die(f"{path}: firewall rule ExitIPIPClearMark: bad proto") + + +def validate_firewall(cfg: ConfigData) -> None: + policy_ids = routing_exit_policy_ids(cfg) + for router_name in cfg.router_names: path = router_path(cfg, router_name, "firewall") + text = read(path) + _before_marker, marker_and_tail = split_text_by_marker(text, path) parsed = parse_uci_file(path) + shared_parsed = [ + parsed_block + for parsed_block in ( + parse_uci_block(block) for block in split_uci_blocks(marker_and_tail) + ) + if parsed_block + ] + require_shared_exit_mark_rules(shared_parsed, path) mesh_ifaces = mesh_iface_names_for_router(cfg, router_name) @@ -196,7 +316,7 @@ def validate_firewall(cfg: ConfigData) -> None: exit_ifaces |= {exit_in_iface_name(hub.name) for hub in cfg.exit_hubs} exit_ipip_ifaces = { router_exit_ipip_iface_name(hub.name) - for hub in router_exit_order_hubs(cfg, router_name) + for hub in router_required_exit_hubs(cfg, router_name) } trusted_access = { g.name @@ -219,86 +339,79 @@ def validate_firewall(cfg: ConfigData) -> None: parsed, path, ZONE_TRANSIT_ACCESS, transit_access ) - require_firewall_zone_policy( - parsed, - path, - ZONE_MESH, - input_policy=FIREWALL_TARGET_REJECT, - output_policy=FIREWALL_TARGET_ACCEPT, - forward_policy=FIREWALL_TARGET_ACCEPT, - mtu_fix=True, - ) - require_firewall_zone_policy( - parsed, - path, - ZONE_EXIT, - input_policy=FIREWALL_TARGET_REJECT, - output_policy=FIREWALL_TARGET_ACCEPT, - forward_policy=FIREWALL_TARGET_ACCEPT, - mtu_fix=True, - ) - require_firewall_zone_policy( - parsed, - path, - ZONE_EXIT_IPIP, - input_policy=FIREWALL_TARGET_REJECT, - output_policy=FIREWALL_TARGET_ACCEPT, - forward_policy=FIREWALL_TARGET_ACCEPT, - mtu_fix=True, - ) + if mesh_ifaces: + require_firewall_zone_policy( + parsed, + path, + ZONE_MESH, + input_policy=FIREWALL_TARGET_REJECT, + output_policy=FIREWALL_TARGET_ACCEPT, + forward_policy=FIREWALL_TARGET_ACCEPT, + mtu_fix=True, + ) + if exit_ifaces: + require_firewall_zone_policy( + parsed, + path, + ZONE_EXIT, + input_policy=FIREWALL_TARGET_REJECT, + output_policy=FIREWALL_TARGET_ACCEPT, + forward_policy=FIREWALL_TARGET_ACCEPT, + mtu_fix=True, + ) + if exit_ipip_ifaces: + require_firewall_zone_policy( + parsed, + path, + ZONE_EXIT_IPIP, + input_policy=FIREWALL_TARGET_REJECT, + output_policy=FIREWALL_TARGET_ACCEPT, + forward_policy=FIREWALL_TARGET_ACCEPT, + mtu_fix=True, + ) # ExitIPIP rule/forwarding entries live in the shared firewall tail. # Do not require exact names here: this allows switching between # mark-to-main and clear-mark variants without touching validate.py. - require_firewall_zone_policy( - parsed, - path, - ZONE_TRUSTED_ACCESS, - input_policy=FIREWALL_TARGET_ACCEPT, - output_policy=FIREWALL_TARGET_ACCEPT, - forward_policy=FIREWALL_TARGET_ACCEPT, - mtu_fix=True, - ) - require_firewall_zone_policy( - parsed, - path, - ZONE_TRANSIT_ACCESS, - input_policy=FIREWALL_TARGET_REJECT, - output_policy=FIREWALL_TARGET_ACCEPT, - forward_policy=FIREWALL_TARGET_ACCEPT, - mtu_fix=True, - ) + if trusted_access: + require_firewall_zone_policy( + parsed, + path, + ZONE_TRUSTED_ACCESS, + input_policy=FIREWALL_TARGET_ACCEPT, + output_policy=FIREWALL_TARGET_ACCEPT, + forward_policy=FIREWALL_TARGET_ACCEPT, + mtu_fix=True, + ) + if transit_access: + require_firewall_zone_policy( + parsed, + path, + ZONE_TRANSIT_ACCESS, + input_policy=FIREWALL_TARGET_REJECT, + output_policy=FIREWALL_TARGET_ACCEPT, + forward_policy=FIREWALL_TARGET_ACCEPT, + mtu_fix=True, + ) if transit_access: - expected_rule_names_by_router[router_name].add(TRANSIT_ACCESS_DNS_RULE_NAME) require_firewall_dns_transit_access_rule(parsed, path) - else: - require_firewall_rule_absent(parsed, path, TRANSIT_ACCESS_DNS_RULE_NAME) - if config_has_allow_to_router_all(cfg): - require_firewall_rule_absent(parsed, path, "Allow-SSH-From-Exit-To-Router") - else: - expected_rule_names_by_router[router_name].add( - "Allow-SSH-From-Exit-To-Router" - ) + if exit_ifaces and not config_has_allow_to_router_all(cfg): require_firewall_ssh_from_exit_rule(parsed, path) - require_firewall_rule_absent(parsed, path, FIREWALL_RULE_ALLOW_MESH) - if router_name in cfg.mesh_hubs_by_name: hub = cfg.mesh_hubs_by_name[router_name] for _hub_name, target_name in mesh_link_specs_for_hub(cfg, router_name): link = compute_mesh_link_params(cfg, hub, target_name) rule_name = mesh_firewall_rule_name(hub.name, target_name) - expected_rule_names_by_router[router_name].add(rule_name) require_firewall_rule_port( parsed, path, rule_name, link.port, TRANSPORT_UDP ) for exit_hub in cfg.exit_hubs: rule_name = exit_reverse_firewall_rule_name(exit_hub.name) - expected_rule_names_by_router[router_name].add(rule_name) require_firewall_rule_port( parsed, path, @@ -309,7 +422,6 @@ def validate_firewall(cfg: ConfigData) -> None: for group in cfg.access.get(router_name, []): rule_name = f"Allow-{group.name}" - expected_rule_names_by_router[router_name].add(rule_name) require_firewall_rule_port( parsed, path, @@ -328,7 +440,6 @@ def validate_firewall(cfg: ConfigData) -> None: rule_name = firewall_allow_rule_name( allow.source_name, router_name, allow.kind ) - expected_rule_names_by_router[router_name].add(rule_name) require_firewall_mesh_source_rule( parsed, path, @@ -337,28 +448,6 @@ def validate_firewall(cfg: ConfigData) -> None: FIREWALL_ZONE_LAN if allow.kind == FIREWALL_ALLOW_KIND_LAN else None, ) - managed_zones = set(MANAGED_FIREWALL_ZONES) | {ZONE_EXIT_IPIP} - seen_zones: set[str] = set() - for block in parsed: - if block.get("type") == "zone": - zone_name = str(block.get("options", {}).get("name", "")) - if zone_name in managed_zones: - if zone_name in seen_zones: - die(f"{path}: duplicate managed firewall zone {zone_name}") - seen_zones.add(zone_name) - - if block.get("type") == "rule": - rule_name = str(block.get("options", {}).get("name", "")) - looks_managed = ( - rule_name.startswith("Allow-Mesh-") - or rule_name.startswith("Allow-Exit-Reverse-") - or (rule_name.startswith("Allow-") and "-To-" in rule_name) - or rule_name == TRANSIT_ACCESS_DNS_RULE_NAME - or rule_name - in {f"Allow-{g.name}" for g in cfg.access.get(router_name, [])} - ) - if ( - looks_managed - and rule_name not in expected_rule_names_by_router[router_name] - ): - die(f"{path}: stale managed firewall rule {rule_name}") + for rule in cfg.routing_rules_by_router.get(router_name, []): + rule_name = routing_firewall_rule_name(rule) + require_routing_firewall_rule(parsed, path, rule, policy_ids) diff --git a/tools/validate_network.py b/tools/validate_network.py index 9218650..66fbf10 100755 --- a/tools/validate_network.py +++ b/tools/validate_network.py @@ -97,44 +97,60 @@ def expected_managed_ifaces(cfg: ConfigData, router_name: str) -> set[str]: def validate_current_network_objects( cfg: ConfigData, existing: dict[str, dict[str, dict[str, object]]] ) -> None: + # Extra objects from an older configuration are intentionally allowed. + # Current generated objects are validated by the dedicated validators; + # leftovers are reported by show_unmanaged instead of being rejected here. + _ = cfg, existing + + +def validate_exit_rule_network_objects( + cfg: ConfigData, + existing: dict[str, dict[str, dict[str, object]]], +) -> None: + policy_ids = routing_exit_policy_ids(cfg) + for router_name in cfg.router_names: - expected_ifaces = expected_managed_ifaces(cfg, router_name) parsed = existing[router_name] + rules = cfg.routing_rules_by_router.get(router_name, []) + expected_exit_names = { + rule.exit_name for rule in rules if rule.exit_name is not None + } + for exit_name in expected_exit_names: + policy_id = policy_ids[exit_name] + suffix = exit_name.lower() - for block in parsed.values(): - typ = str(block.get("type", "")) - name = str(block.get("name", "")) - opts = block.get("options", {}) - - if typ == "interface": - proto = str(opts.get("proto", "")) - if ( - proto in {PROTOCOL_AMNEZIAWG, PROTOCOL_WIREGUARD} - and name not in expected_ifaces - ): + rule_name = f"{EXIT_RULE_SECTION_PREFIX}{suffix}" + rule_block = parsed.get(rule_name) + if rule_block is None or rule_block.get("type") != "rule": + die(f"router {router_name}: missing routing exit rule {rule_name}") + rule_opts = rule_block.get("options", {}) + rule_expected = { + "priority": str(EXIT_RULE_PRIORITY), + "mark": str(policy_id), + "lookup": str(policy_id), + } + for key, value in rule_expected.items(): + if rule_opts.get(key) != value: die( - f"router {router_name}: " - f"unexpected managed tunnel interface {name}" - ) - continue - - if typ.startswith("amneziawg_"): - iface = typ.removeprefix("amneziawg_") - expected = expected_mesh_exit_ifaces( - cfg, router_name - ) | expected_amneziawg_access_ifaces(cfg, router_name) - if iface not in expected: - die( - f"router {router_name}: " - f"unexpected AmneziaWG peer section {typ}" + f"router {router_name}: routing exit rule {rule_name}: " + f"bad {key}; expected {value!r}, got {rule_opts.get(key)!r}" ) - if typ.startswith("wireguard_"): - iface = typ.removeprefix("wireguard_") - if iface not in expected_wireguard_access_ifaces(cfg, router_name): + route_name = f"{EXIT_RULE_ROUTE_SECTION_PREFIX}{suffix}" + route_block = parsed.get(route_name) + if route_block is None or route_block.get("type") != "route": + die(f"router {router_name}: missing routing exit route {route_name}") + route_opts = route_block.get("options", {}) + route_expected = { + "interface": router_exit_ipip_iface_name(exit_name), + "target": "0.0.0.0/0", + "table": str(policy_id), + } + for key, value in route_expected.items(): + if route_opts.get(key) != value: die( - f"router {router_name}: " - f"unexpected WireGuard peer section {typ}" + f"router {router_name}: routing exit route {route_name}: " + f"bad {key}; expected {value!r}, got {route_opts.get(key)!r}" ) diff --git a/tools/validate_runtime_files.py b/tools/validate_runtime_files.py index 48d5d80..30c32df 100755 --- a/tools/validate_runtime_files.py +++ b/tools/validate_runtime_files.py @@ -374,7 +374,7 @@ def expected_runtime_env_values( "CHECK_DOH_RESOLV": CHECK_DOH_RESOLV, "CHECK_DOH_RESOLV_WAIT_MAX": str(CHECK_DOH_RESOLV_WAIT_MAX), "CHECK_DOH_PROVIDER_DOMAINS": " ".join(CHECK_DOH_PROVIDER_DOMAINS), - "EXIT_ROUTE_TABLE": str(EXIT_ROUTE_TABLE), + "EXIT_ROUTE_TABLE": str(EXIT_POLICY_BASE), "EXIT_ROUTE_INTERVAL": str(EXIT_ROUTE_INTERVAL), "UPDATE_IPSETS_CURL_CONNECT_TIMEOUT": str(UPDATE_IPSETS_CURL_CONNECT_TIMEOUT), "UPDATE_IPSETS_CURL_MAX_TIME": str(UPDATE_IPSETS_CURL_MAX_TIME), diff --git a/tools/validate_tunnel_files.py b/tools/validate_tunnel_files.py index 89ee21e..98a82ed 100755 --- a/tools/validate_tunnel_files.py +++ b/tools/validate_tunnel_files.py @@ -165,7 +165,7 @@ def validate_exit_ipip_iface( hub: ExitHub, router_parsed: dict[str, dict[str, object]], ) -> None: - active_exit_names = {h.name for h in router_exit_order_hubs(cfg, router_name)} + active_exit_names = {h.name for h in router_required_exit_hubs(cfg, router_name)} if hub.name not in active_exit_names: return diff --git a/tools/validate_uci.py b/tools/validate_uci.py index e6e99d5..9a442ec 100755 --- a/tools/validate_uci.py +++ b/tools/validate_uci.py @@ -52,10 +52,3 @@ def require_firewall_rule_port( die(f"{path}: firewall rule {name}: bad proto") if options.get("target") != FIREWALL_TARGET_ACCEPT: die(f"{path}: firewall rule {name}: bad target") - - -def require_firewall_rule_absent( - parsed_blocks: list[dict[str, object]], path: Path, name: str -) -> None: - if find_firewall_rule_by_name(parsed_blocks, name) is not None: - die(f"{path}: stale firewall rule {name}")