mirror of
https://github.com/karen07/openwrt-mesh-builder.git
synced 2026-10-19 07:23:32 +00:00
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
This commit is contained in:
parent
8fd57bf3ea
commit
610097ed42
29 changed files with 735 additions and 305 deletions
110
README.md
110
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_<name>_node` - overlay alias.
|
||||
- `server_<name>` - public/bootstrap alias.
|
||||
|
|
|
|||
16
config.json
16
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"
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue