# 578 — Пресет Tailscale и язык шаблона: `for_each`, `@node`, `#tpl` | Поле | Значение | |------|----------| | Статус | Implemented — `for_each`/`@node`/`#tpl` в `if_engine.dart`, пресет `tailscale` в шаблоне; тесты `test/contract/template_for_each_corpus_test.dart`, `test/services/builder/preset_for_each_test.dart`, `test/builder/tailscale_preset_build_test.dart`; выпущено в v2.25.8 | | Дата старта | 2026-09-27 | | Дата завершения | — | | Коммиты | — | | Контракт | 1.1.86 (995a0e3b) — три возможности языка шаблона, пресет `tailscale`, поле записи `skip_presets`, D-120 подтверждён | | Связанные spec'ы | [§575](575-remove-node-sections.md) (выходит в одном релизе), [§435](435-node-sections-tailscale.md), [§437](437-tailscale-bundle-import.md), [features/120](../tasks/120F-template-engine-typed-vars-and-if/spec.md) (движок `#if`) | ## Проблема Узел Tailscale без маршрута и DNS бесполезен: tailnet поднимается, но ни имена, ни адреса в него не идут. Сегодня связку узел носит в своих секциях. Секции упраздняются (§575). ## Решение владельца (27.09.2026) - Связку даёт пресет шаблона. - Пресет обслуживает все узлы Tailscale, а не один. - Исключение узла из пресета задаётся на узле, полем «пропустить». - Узел передаёт в пресет свои настройки: пресет читает запись и тело узла. - Составная строка записывается конструкцией с ключом: `{"#tpl": "…"}`. ## Язык шаблона: три возможности ### 1. `for_each` у пресета ```json "for_each": { "node_type": "tailscale", "as": "node", "filter": { "#not": "@node.skip_presets" } } ``` | Ключ | Обязателен | Значение | |---|---|---| | `node_type` | да | значение поля `type` тела узла | | `as` | да | имя, под которым узел виден в теле пресета | | `filter` | нет | условие на языке `#if`; нет ключа, значит истина | Тело пресета (`rules`, `dns_servers`, `dns_rules`) повторяется для каждого узла, для которого выполнены все условия: 1. поле `type` тела равно `node_type`; 2. узел включён; 3. узел попал в конфиг: не снят гейтом реестра, гейтом ядра, деградацией; 4. `filter` истинен. Порядок повторов равен порядку узлов в конфиге. Узлов нет, значит пресет ничего не выдаёт. Контейнер узла значения не имеет: свой сервер, член папки, узел подписки. Пресет без `for_each` работает как раньше. ### 2. Доступ к узлу | Запись | Значение | Источник | |---|---|---| | `@node` | финальный тег узла в конфиге | сборка | | `@node.<поле>` | поле записи узла | запись | | `@node.body.<поле>` | поле тела узла, путь через точку | тело | `node` здесь это имя из `as`. Поля записи, видимые пресету: `skip_presets`. Перечень закрытый, расширяется контрактом. Отсутствующее поле даёт пустое значение: в условии ложь, в теле пресета ключ или элемент снимается, как у необязательной переменной. У узла подписки записи нет. `@node.skip_presets` у него всегда ложь. ### 3. `#tpl` ```json { "#tpl": "@{node}-dns" } ``` | Вопрос | Правило | |---|---| | где допустима | на месте значения: поле объекта, элемент массива, в том числе внутри `#value` | | где недопустима | в ключах, в `default_value` переменной | | вставка | `@{имя}` и `@{имя.путь}`, любое число раз | | `@имя` без скобок внутри строки | литерал | | результат | строка | | имя неизвестно или значение пусто | значение снимается целиком: ключ или элемент исчезает | | объект с `#tpl` и другими ключами | ошибка шаблона | Обычные строки вне `#tpl` ведут себя как раньше. ## Пресет ```json { "preset_id": "tailscale", "for_each": { "node_type": "tailscale", "as": "node", "filter": { "#not": "@node.skip_presets" } }, "ui": { "label": "Tailscale networks", "description": "Route each tailnet through its Tailscale node and resolve its names with the node's own DNS.", "default": true, "num": 945, "isSortable": true }, "vars": [ { "name": "dns_enable", "type": "bool", "default_value": "true", "title": "DNS", "tooltip": "Resolve tailnet names with the node's own DNS." } ], "rules": [ { "#if": { "#and": ["@dns_enable"], "#value": { "preferred_by": ["@node"], "action": "resolve", "server": { "#tpl": "@{node}-dns" } } } }, { "preferred_by": ["@node"], "outbound": "@node" } ], "dns_servers": [ { "#if": { "#and": ["@dns_enable"], "#value": { "type": "tailscale", "tag": { "#tpl": "@{node}-dns" }, "endpoint": "@node", "description": "MagicDNS of the tailnet" } } } ], "dns_rules": [ { "#if": { "#and": ["@dns_enable"], "#value": { "preferred_by": [ { "#tpl": "@{node}-dns" } ], "server": { "#tpl": "@{node}-dns" } } } } ] } ``` Что попадает в конфиг для узлов `home-ts` и `work-ts`: | Куда | Запись | |---|---| | `route.rules`, позиция 945 | `preferred_by: [home-ts]`, `action: resolve`, `server: home-ts-dns` | | `route.rules` | `preferred_by: [home-ts]`, `outbound: home-ts` | | `route.rules` | то же для `work-ts`, две записи | | `dns.servers` | `type: tailscale`, `tag: home-ts-dns`, `endpoint: home-ts` | | `dns.servers` | то же для `work-ts` | | `dns.rules` | `preferred_by: [home-ts-dns]`, `server: home-ts-dns` | | `dns.rules` | то же для `work-ts` | Поведение по переключателю DNS: | `dns_enable` | DNS-серверы | DNS-правила | Правило `resolve` | Правило маршрута | |---|---|---|---|---| | включён | есть | есть | есть | есть | | выключен | нет | нет | нет | есть | ### Условие `preferred_by` Ядро спрашивает у узла, считает ли он адрес или имя своим. Узел Tailscale отвечает по живому состоянию tailnet: имена машин, адреса машин, принятые подсети (`accept_routes`). Поддержка в ядре проверена по исходникам (`protocol/tailscale/endpoint.go`, `PreferredDomain` и `PreferredAddress`), поле есть в правилах маршрута и в DNS-правилах. Тег в этих правилах разный: см. «Исправление: DNS-правило называет сервер». Постоянные подсети `100.64.0.0/10`, `fd7a:115c:a1e0::/48` и суффикс `.ts.net` в пресете не используются. Это подтверждение решения лаунчера D-120. ## Поле записи `skip_presets` | Поле | Тип | Носитель | Нет поля | |---|---|---|---| | `skip_presets` | bool | свой сервер, член папки | узел обслуживается пресетами | - В хранилище пишется только значение `true`. - Едет в резервной копии. Импорт: совпавший по телу узел получает `true` из файла; отсутствие поля своё значение не сбрасывает (хранится только `true`, отсутствие неотличимо от `false`). - Debug API отдаёт поле в записи узла. - Действует на все пресеты с `for_each`, чей `filter` его читает. ## Интерфейс Переключатель на экране узла. | | | |---|---| | подпись | `Skip presets` | | пояснение | `Presets will not add routing or DNS rules for this node.` | | по умолчанию | выключен | | виден | у своего сервера и члена папки, когда в шаблоне есть пресет с `for_each`, чей `node_type` равен типу узла | Строка пресета на экранах маршрутов и DNS показывает, какие узлы он обслуживает: теги через запятую. Серверы пресета видны на экране DNS тем же способом, что серверы остальных пресетов. ### Реализация интерфейса (фаза 2, 27.09.2026) - Список узлов для экранов: `presetNodesForView` (`app/lib/services/preset_nodes_view.dart`). Экрану конфиг не нужен: список строится из источников контроллера тем же отбором, что у сборки (узел включён, отметка `disabled_hashes` подписки, `skip_presets` из записи). Тег — финальный тег последней сборки (`lastEmittedTagMap`), если узел в ней был, иначе отображаемый. Гейты реестра и ядра экрану не видны: до сборки строка может назвать узел, который сборка снимет (допущение, как у строк правил узлов §435). - Выборка узлов пресета — общая функция `presetForEachNodes` (`preset_expand.dart`), ею же пользуется `expandPreset`. - Экран маршрутов: подпись строки пресета с `for_each` — теги через запятую или `No matching nodes`; редактор правила (View) получает тот же список. - Экран DNS: `DnsController.load(presetNodes:)`; подпись строки пресета — та же. Без списка узлов пресет не давал серверов, и загрузка экрана снимала записи хранения его серверов как сироты. - Переключатель `Skip presets`: `skipPresetsToggleVisible`, тип узла — `NodeSpec.protocol`; запись — `SubscriptionController.setSkipPresets` (через `_persist`, конфиг помечается грязным). Блок построения отдельный (`_buildSkipPresetsBlock`), стоит перед блоком Sections. **Решение по владельцу DNS-сервера `for_each` (пункт C).** Запись хранения по-прежнему без `preset_id`. `DnsController.load` передаёт в `resolveDnsServersList` `preset_id` только для тегов в пространстве пресета (`:…`), как сборка в `custom_rules.dart`. Владельца на экране даёт пометка `_preset_id`/`_preset_label` раскрытого тела: резолвер экрана берёт её, когда у записи `preset_id` пуст. Модель хранения и сборка не менялись. **Перевод описания сервера в `#if`.** Обходчик оверлея (`TemplateOverlay._walk`) разворачивает элемент `dns_servers[]` вида `{"#if": {…, "#value": X, "#else": Y}}` (`conditionalBranches`) и берёт отображаемые поля из `X` и `Y`. Ключ перевода — сам английский текст, поэтому тег-`#tpl` адресации не мешает; в имени обхода такой сервер адресуется индексом элемента. ### Исправление: DNS-правило называет сервер Первая версия пресета (контракт 1.1.86) писала в DNS-правило `"preferred_by": ["@node"]`, тег узла. Ядро разбирает `preferred_by` по-разному. В правиле маршрута тег ищется через менеджер outbound, который видит и endpoint'ы (`route/rule/rule_item_preferred_by.go` в `sing-box-lx`), и тег узла там верен. В DNS-правиле тег ищется через менеджер DNS-серверов (`route/rule/rule_item_preferred_by_dns.go`, функция `Start`), и на теге узла ядро отказывает `DNS server not found: <тег>`: с пресетом по умолчанию ядро не стартовало (подтверждено на эмуляторе). Исправление (контракт 1.1.90): в DNS-правиле `preferred_by` равен `{"#tpl": "@{node}-dns"}`, тому же серверу, что в `server`. `#tpl` внутри элемента массива движок раскрывает без доработок. Тесты сборки проверяют, что `preferred_by` DNS-правила равен его `server` и что сервер с этим тегом есть в `dns.servers` (`app/test/builder/tailscale_preset_build_test.dart`, `app/test/services/builder/preset_for_each_test.dart`). ## Сборка `for_each` раскрывается, когда состав узлов конфига окончателен и финальные теги назначены. Место (фаза 1, 27.09.2026): `build_config.dart`, сразу после записи `config['endpoints']`, в той же точке, где собираются секции узлов (`_collectNodeSections`). К этому моменту уже применены снятия detour-прохода (`ctx.dropEntries`), гейта реестра и гейта ядра (`ctx.dropRegistryEntries`), теги назначены аллокатором. `_collectPresetNodes` берёт записи узлов из `ctx.outbounds`, затем `ctx.endpoints` (порядок конфига), только те, у которых есть финальный тег в `emittedTagByNode`: выключенный или снятый узел туда не попадает. Список уходит в `applyAllCustomRules(presetNodes:)` → `expandPreset(nodes:)`; тело пресета повторяется по узлам внутри `expandPreset`, дальше пресет идёт общим путём (`num`, `dns_enable`, DNS-серверы и правила). Проба (`probe_config.dart`) пресеты не раскрывает вовсе: у неё свой минимальный `route` и DNS. `for_each` в пробе не раскрывается. Теги пресета с `for_each` не получают пространство пресета (`:<тег>`, §103 C7): уникальность даёт тег узла, и тег сервера остаётся `<тег узла>-dns`. Запись хранения такого DNS-сервера держит `ref` равным тегу, без `preset_id`: модель `DnsServerPreset` с `preset_id` достроила бы тег до `tailscale:<тег>-dns`, и сервер разошёлся бы с правилами. Совпадение тега: у пользователя есть свой DNS-сервер с тегом `<тег узла>-dns`. Действует общее правило дедупа тегов DNS: первый побеждает, дубль даёт предупреждение. ## Обновление у существующих пользователей Пресет включён по умолчанию. Проверено (фаза 1): сам по себе новый пресет с `default: true` у пользователя с сохранённым состоянием НЕ включается. Дефолтные пресеты сеются один раз на установку (`_seedDefaultPresets` экрана маршрутов под флагом `presets_migrated`); сборка досевает только несортируемые (`seedRequiredPresets`, `isSortable: false`), а `tailscale` сортируемый. Добавлен разовый шаг `SettingsStorage.seedLateDefaultPresets`: - список поздних дефолтов `kLateDefaultPresetIds` = `{tailscale}`, только растёт; - шаг идёт перед чтением правил в сборке (`SubscriptionController`, перед `BuildSettings`) и на экране маршрутов (перед `getCustomRules`); - если `presets_migrated == true`, пресета в правилах нет и шаблон объявляет его `default: true`, пресет добавляется включённым с `num` шаблона; - id отмечается в ключе хранения `late_presets_seeded` и больше не добавляется: удалённый пользователем пресет не возвращается; - свежая установка (`presets_migrated` не выставлен) не трогается: первый seed берёт пресет вместе с остальными дефолтами и сразу отмечает все поздние id как пройденные. Секции старых узлов Tailscale снимает §575. Связка из пресета заменяет их без участия пользователя. ## Запрос в контракт | Что | Норма | |---|---| | язык шаблона | `for_each`, доступ `@node`, `#tpl` по разделам выше | | пресет `tailscale` | одинаковый текст в шаблонах обеих сторон | | поле записи `skip_presets` | хранение, резервная копия | | D-120 | подтверждено: маршрут через `preferred_by` | | `preferred_by` в DNS-правиле связки | новое относительно D-120 | | код `tailscale_from_subscription` | удаляется: узел подписки получает связку | | корпус | случаи на `for_each` с нулём, одним и двумя узлами; `#tpl` с пустым значением; `filter` | ## Риски и edge cases - **Условие работает только у запущенного узла.** Пока tailnet не поднялся, правило не совпадает и трафик идёт по остальным правилам. - **Два узла в одном tailnet.** Оба считают своими одни и те же машины, срабатывает правило первого по порядку. - **Совпадение подсетей.** Подсеть за tailnet совпала с домашней. Правило пресета стоит раньше правила локальных адресов (945 против 950). Решение у пользователя: `accept_routes: false` в теле узла. - **Ссылки пользователя на `<тег>-dns`** остаются рабочими: тег сервера прежний. - **Правленная руками связка** теряется вместе с секциями. ## Верификация Тесты (гонять только затронутые файлы): - движок: `for_each` с нулём, одним, двумя узлами; узел выключен; узел снят гейтом; `filter` ложен; - движок: `#tpl` с одной и двумя вставками, с путём, с пустым значением, с лишним ключом; - движок: `@node.body.<поле>` для существующего и отсутствующего поля; - сборка: конфиг с двумя узлами Tailscale содержит записи из таблицы выше; - сборка: состояние без узлов Tailscale даёт конфиг байт в байт прежний; - кодек записи: `skip_presets` читается и пишется, значение `false` не пишется. DEVICE-PENDING: проверка на эмуляторе с настоящим tailnet. Нужен ключ авторизации от владельца. Сценарий для Sonnet-агента: узел поднят, имя машины tailnet разрешается, адрес машины отвечает, переключатель Skip presets убирает записи из конфига. Критерии приёмки: 1. Новый узел Tailscale работает без действий пользователя. 2. Узел Tailscale из подписки получает связку. 3. `grep -rn "100.64.0.0/10" app/lib app/assets/wizard_template.json` пуст. ## Docs to update | Файл | Что | |---|---| | `docs/TEMPLATE.md` | `for_each`, `@node`, `#tpl`, пресет `tailscale` | | `docs/STORAGE.md` | поле `skip_presets` | | `docs/PROTOCOLS.md` | Tailscale: связка из пресета, `preferred_by` | | `docs/api/debug-api-reference.md` | поле `skip_presets` | | `docs/l10n.md` | нет | | `CHANGELOG.md` | запись в Unreleased | ## Реализация: синк 1.1.86 Пункт §83 `TASKS_LXBOX.md` («За LxBox после синка — сверить»), контракт синхронизирован на коммит лаунчера `995a0e3b` (версия 1.1.86). 1. **Корпус `corpus/template/for_each/`.** Новый раннер `app/test/contract/template_for_each_corpus_test.dart` — формат кейса `.preset.json` + `.vars.json` + `.expected.json` (иной, чем у тройки `subst`/`tpl`/…), см. `corpus/template/README.md`. Раздел `corpus/template/tpl/` (включая новые кейсы `extra_key_is_error` и `undeclared_name_drops`) уже покрывается существующим раннером тройки `template_contract_test.dart` — правки не потребовались, оба кейса прошли без изменений движка. `tpl/extra_key_is_error` добавлен в список `_mustRejectOnLoad` в `template_load_reject_test.dart` (load: reject по README). 2. **`tpl/extra_key_is_error` / `tpl/undeclared_name_drops`.** Вердикты совпадают с контрактом: `#tpl` с лишним ключом отвергается на load (`validateIfConstructs`), необъявленное имя во вставке `#tpl` — рантайм терпит (значение снимается, `template_var_undeclared`), load не нормирован (`either`). 3. **`for_each/missing_as_rejected`.** Поведение LxBox изменено: `validateTemplateConstructs` (`app/lib/services/template_loader.dart`) больше не бросает на весь шаблон при `for_each` без `node_type`/`as` — такая запись вырезается из `raw['selectable_rules']`, уходит предупреждение в `AppLog` (`app` warning), а `TemplateLoader._loadFor` пересобирает `WizardTemplate` из отфильтрованного JSON, так что остальные пресеты (включая боевой `tailscale`) загружаются как обычно. Функция теперь возвращает `List` снятых `preset_id`. Тест `preset_for_each_test.dart` → «for_each без as — снимается только этот пресет (§83)» обновлён под новое поведение (было: ожидание throw на весь шаблон). 4. **`for_each/filter_body_field`.** Уже реализовано и покрыто — `presetNodeResolver`/`_forEachMatches` возвращают `Dropped` для отсутствующего поля тела, что делает `#notEmpty` ложным; отдельный тест `preset_for_each_test.dart` → «filter по полю тела…» и кейс корпуса в новом раннере проходят без правок кода. 5. **`skip_presets` в бэкапе.** Кодек (`source_record.dart`) уже пишет поле только при `true`; merge по совпадению тела (`lx_backup.dart::mergeBackupServers`/`_mergeFolderMember`) уже не сбрасывает `true`, если у входящей записи флаг ложный/отсутствует (`skip = srv.skipPresets && !local.skipPresets`). Проверки на этот сценарий не было — добавлены два теста в `app/test/contract/lx_backup_test.dart` (одиночный узел и член папки). 6. **Текст пресета `tailscale`.** Сверено побайтово: `for_each`, `rules`, `dns_servers`, `dns_rules` в `app/assets/wizard_template.json` совпадают с телом из `TEMPLATE_LANG.md` §6.7 символ в символ (разница только в оболочке — `default_value` вместо `default`, что и предписано §6.7). Расхождений не найдено, правка не потребовалась. ## Нерешённое / follow-up - Такой же пресет для WireGuard: ядро отвечает на `preferred_by` разрешёнными адресами пиров. Не входит в задачу.