Приёмка требовала, чтобы страница Hysteria содержала жёсткий список ACME DNS-провайдеров (cloudflare … vultr) и не содержала удалённого upstream namedotcom. Это имело смысл, пока панель ПРЕДЛАГАЛА выбор провайдера: список в UI был вторым экземпляром upstream-реестра и мог от него отстать — ровно так namedotcom и пришлось выпиливать вручную. После перевода страницы в read-only диагностику реестра нет и быть не должно: имя провайдера читается из фактического конфига и показывается как есть, поэтому новый upstream-провайдер отображается без правок панели. Возврат списка ради прохождения grep-а создал бы фиктивный реестр, существующий только для гейта. Гейт проверяет действующий контракт: провайдер приходит строкой и рисуется как значение, параметры DNS отдаются только именами, селектора на странице нет. Проверено положительно и на трёх нарушениях (провайдер перестал показываться, на странице появился селектор, тип стал перечислением) — гейт падает на каждом. Контрактный тест панели дополнен обратной проверкой: ни одно из восьми имён провайдеров не должно встречаться в исходнике страницы. Та же формулировка поправлена в матрице приёмки. Дополнительно прогнаны целиком все восемь функций приёмки, которым не нужен распакованный пакет: других устаревших утверждений нет.
24 KiB
D, E. Негативные тесты и матрица приёмки
Часть набора проверок HY2XS. Карта всех частей — docs/testing/README.md.
D. Negative tests
- не Debian 13
- порт уже занят
- старое конфликтующее состояние уже существует
- домен / SNI заданы некорректно
- bundled UI отсутствует в пакете
- Hysteria upstream недоступен
- firewall применился частично
- install flow прерван посередине
- попытка использовать
HY2XS_IPV6_ENABLED=true HY2XS_PUBLIC_HOST=0.0.0.0- неизвестный
HY2XS_HYSTERIA_OBFS_TYPE - конфигурация со схемой
HY2XS_CONFIG_SCHEMA_VERSIONиз линейки0.x - upstream
latestнесовместим с шаблоном HY2XS — падает сборка, не установка HY2XS_PUBLIC_HOSTрезолвится не на этот серверHY2XS_DOMAINрезолвится не на этот сервер при отличном от негоPUBLIC_HOST- A-запись содержит правильный адрес и чужой одновременно
- неизвестное значение
HY2XS_PUBLIC_ENDPOINT_POLICY - импорт пиров с невалидной записью — файл не применяется частично
- импорт пиров, пытающийся перезаписать
bootstrap-admin-peer HY2XS_HYSTERIA_TRAFFIC_STATS_HOSTне равен127.0.0.1— install/reconfigure отказывают:0.0.0.0, другой адрес loopback, адрес LAN, публичный адресtrafficStats.listenв уже установленном/etc/hysteria/config.yamlуказывает на не-loopback адрес — админка отказывает с сообщением, называющим адрес и способ починки, а не молча обращается к127.0.0.1
E. Fix20 production matrix (обязательные сценарии)
-
Clean Debian 13 minimal:
- только SSH, без ручной установки зависимостей;
- default
/etc/nftables.confstub; - install проходит полностью;
doctor/statusпоказывают рабочее состояние.
-
Non-systemd container:
- fail-fast до destructive шагов;
- диагностическое сообщение с причиной capability/systemd.
-
Foreign nftables:
- при
HY2XS_FIREWALL_MODE=managedinstall/reconfigure блокируются; - при
HY2XS_FIREWALL_MODE=takeoverсоздаются backup/rollback guard и apply проходит.
- при
-
Rollback guard cleanup (сценарий D1g):
- после успешного apply/smoke не остаются
hy2xs-fw-rollback-*.timer/.service; /run/hy2xs/rollback/,/run/lock/hy2xs-orchestrator.lockи*.candidateне переживают успешную операцию.
- после успешного apply/smoke не остаются
4a. Guard доходит до дедлайна (сценарий D1e):
auto-rollback-firedсоздан, операция завершается отказом сphase: firewall_guard_fired;installed: trueне записан, даже если smoke успел сойтись.
4b. Конкурентные операции (сценарий D1f):
- вторая операция отказывает до снятия резервной копии и первой мутации;
status/diagnosticsне блокируются и сообщают об идущей операции;- замок не переживает своего держателя.
4c. Аварийная смерть с вооружённым guard (сценарий D1h):
- новая операция отказывает, пока
hy2xs-fw-rollback-*ещё активен, в том числе когда замка не осталось вовсе; - после срабатывания guard
repairпроходит.
-
Partial install + repair:
- состояние
install-stateфиксирует промежуточную фазу; repairзавершает граф доinstalled=true.
- состояние
-
AAAA при IPv4-only:
- policy строго валидируется preflight;
- soft warning path не используется в production baseline.
-
Slow-start admin readiness:
- install не падает на race после restart;
- readiness waiters дожидаются listener/healthz.
-
Отказ между PHASE 0 и первой мутацией (сценарий D1):
install-state.jsonчестно показываетfailed;- установщик не заявляет, что хост не изменён.
-
Устаревший DNS после смены IPv4 (сценарий D2):
doctorиreconfigureотказывают;- в выводе присутствуют оба адреса.
Acceptance criteria
Система принимается, если:
- production builder на Debian 13 amd64 выдаёт переносимый install package
- target server не выполняет build step
- Hysteria2 получена из official upstream
- HY2XS admin поставлен из install package
post-install.envотражает фактическое deploy-состояние- оркестратор зафиксирован как Bun/TypeScript stack и поставляется как готовый install-артефакт
- оркестратор не требует standalone update / rollback / uninstall subcommands
- bounded rollback в install/reconfigure корректно отрабатывает failure-сценарии firewall/systemd/config/smoke, и ни один его собственный отказ не отменяет остальные стадии
- Telegram/access layer не требуется для прохождения install acceptance
- отсутствует production path для port hopping
- UI не запускается от root
- клиентские endpoint не зависят от request
Host/hostname - production build verify падает, если
config/hy2xs.envсодержит placeholder-значения - production build verify падает при dirty git tree (кроме
ALLOW_DIRTY_BUILD=true) - metadata содержит
source_git_commit,dirty_tree,build_profile=production - builder без override на сегодняшний день автоматически выбирает последнюю стабильную версию Hysteria
- собранный пакет содержит точные версию, URL и SHA-256
- выход новой версии Hysteria после сборки не меняет содержимое старого пакета
- новая установка генерирует Gecko
- Gecko использует
512/1200 - установленная Hysteria реально принимает сгенерированный YAML
- сервис запускается под существующим непривилегированным пользователем
hysteria - созданный пользователь получает
hysteria2://сobfs=geckoиobfs-password - совместимый клиент Hysteria подключается напрямую по этой ссылке
- после перезапуска Hysteria клиент быстро восстанавливает соединение
- режим
HY2XS_HYSTERIA_OBFS_TYPE=salamanderполностью работоспособен - admin читает Gecko-конфиг без ошибок
- экспорт не уничтожает современные и неизвестные upstream-поля
- экспорт не содержит секретов
- frontend отображает Gecko
- панель показывает фактическое имя ACME DNS-провайдера из конфига и не содержит собственного списка провайдеров: страница Hysteria — read-only диагностика, выбирать провайдера она не предлагает, поэтому новый upstream-провайдер отображается без правок панели
- документация нигде не утверждает, что Salamander — фиксированный инвариант
- документация не фиксирует конкретный номер версии как «текущую версию», а объясняет latest-stable build policy
- форма создания пира содержит примеры значений и пояснения для полей «Пир», «Комментарий» и «Секрет»
hy2xs-orchestrator doctorне перезапускает сервисы и не рвёт живые соединения, и это обеспечено read-only guard'ом, а не соглашением о выборе раннера- удаление
bootstrap-admin-peerпереживаетsystemctl restartиreboot: пир не воскресает - отключённый
bootstrap-admin-peerостаётся отключённым после перезапуска - резервная копия с
includeSecrets=trueзавершается ошибкой целиком, если секрет хотя бы одного пира недоступен - админка не генерирует
HYSTERIA2_TRAFFIC_STATS_SECRETсама: пустой env при пустой базе — отказ старта - проверка зависимостей на уязвимости не имеет обходов ни в сборке, ни в документации, и покрывает весь lock-граф frontend
apps/go.modобъявляетtoolchain, совпадающий сGO_VERSIONизversions.envtools/dev/doctor.sh/doctor.ps1показывают расхождение среды разработки сversions.env- маршруты-алиасы
/:id/client-urlи/:id/qrудалены и не входят в публичный API v1 pnpm run typecheck(vue-tsc --noEmit) проходит без ошибок и является обязательным шагом сборки- проверка типов идёт до сборки bundle, а не после
vue-tscверсии 3 и выше: 0.x проверку шаблонов не выполняетpnpm auditпо всему графу зависимостей frontend не находит уязвимостей- локальные SVG-иконки собираются спрайтом из репозитория, без
vite-plugin-svg-icons - каждая иконка задаёт систему координат:
viewBoxлибо параwidth/height - страница конфига Hysteria не содержит элементов управления, которые ничего не сохраняют
- невозможность записать состояние отказа не отменяет откат: восстановление выполняется, в журнале остаётся отметка о неудавшейся записи
install-state.jsonпишется одним писателем, атомарно и сfsyncфайла и каталога: после потери питания на диске лежит либо прежний полный документ, либо новый полный- ownership-флаг маркера установки взводится до записи, поэтому отказ на
chownне даётfatal_pre_applyпри уже созданном файле - тесты и проверка типов не имеют обходов ни в сборке, ни в документации;
metadata/package.envсодержитtests_gate=true, и это утверждение опирается на фактический прогон reset-adminпри недоступной базе отказывает, а не создаёт вторую учётную запись администратора; ошибка хеширования не приводит к пустомуpassword_hash- данные для отката переживают долговечную фиксацию успеха: снятие таймера автоотката и удаление резервных копий разделены записью
phase: installed - резервная копия снимается строго и до первой мутации; несозданная копия останавливает операцию, а не игнорируется
- копия привязана к операции: откат восстанавливает состояние непосредственно перед текущим проходом, а не сохранённое предыдущим
- ни одна команда отката не глушит свой код возврата; отказавшие стадии перечисляются, а артефакты восстановления удаляются только после подтверждённого успеха
doctorне выполняет проб, изменяющих данные в админке: авторизация действующим паролем пира ограничена режимомinstall- снятие rollback guard доказывается, а не объявляется: отсутствие маркера
auto-rollback-firedиActiveState=inactiveобоих юнитов — предусловие записиphase: installed - сработавший guard запрещает фиксацию успеха, каким бы ни был результат smoke, и получает собственную причину отказа
firewall_guard_fired - smoke сверяет эффективный firewall с конфигурацией операции, а не только разбирает
/etc/nftables.conf - автоматический откат firewall сообщает о частичном восстановлении отказом юнита, а не молчаливым кодом 0, и сохраняет данные восстановления
- откат восстанавливает
enabled/activeсостояниеnftables.service, а не только файлы правил - операции жизненного цикла сериализованы эксклюзивным замком: вторая операция отказывает до первой мутации, а
status/diagnosticsне блокируются - замок снимается при любом завершении держателя, включая
Ctrl+C, SIGTERM и обрыв SSH; замок мёртвого держателя переиспользуется безопасно - новая операция не начинается, пока у предыдущей остаётся вооружённый rollback guard: условие старта — «у предыдущей нет исполнителей, способных изменить систему», а не «её PID мёртв»
- отказ записи маркера
auto-rollback-firedне может привести к фиксации успеха: он переводит юнит guard вfailed, аfailedфиксацию запрещает - восстановление
UnitFileStateуnftables.serviceне обещает точности, которой не даёт: восстанавливаютсяenabled/disabled, остальные состояния называются оператору и не трогаются - отказ запроса к systemd не выдаётся за покой: барьер обязан доказать отсутствие исполнителей предыдущей операции, а при невозможности получить доказательство отказывает с
GuardStateUnknownError, а не разрешает операцию - покой guard перечисляется белым списком (
inactive,failed): незнакомое состояние systemd блокирует операцию, а не проходит молча по принципу «его нет в списке опасных» - отработавший таймер не блокирует операцию навсегда:
RemainAfterElapse=noвыгружает его, а барьер дополнительно опознаётSubState=elapsedу*.timerкак покой - обещанное окно отката — контракт systemd, а не намерение: у транзиентного таймера явно задан
AccuracySec=1s, иначе умолчаниеAccuracySec=1minпревращало «45 секунд» в 45–105 - состояние guard читает один наблюдатель:
statusберёт его у того же кода, что и барьер, и сообщаетunknownвместо тихого «guard'ов нет» при отказе systemd - ни один релизный гейт не подаёт вывод в поиск с флагом
-qчерез пайплайн: подset -o pipefailоборванный продюсер отдаёт 141, и «совпадение найдено» превращается в ненулевой код — для отрицательных проверок это ложный PASS. Сравнение идёт через here-string, и возврат пайплайна запрещён отдельной приёмкой - отрицательные сканы по дереву исходников формулируют синтаксическую форму, а не подстроку: вызов — имя со скобкой или обратной кавычкой, импорт —
importсо спецификатором, зависимость — ключ вpackage.json. Прозаическое упоминание удалённой вещи разрешено, иначе гейт запрещает документировать собственную работу
Почему отрицательный скан не ищет подстроку
Комментарий, объясняющий, почему чего-то больше нет, обязан называть это по
имени. Скан по голой подстроке такой комментарий не отличает от кода и падает
ровно на документации к выполненной им же работе. В этом файле урок оплачен
пять раз: скан versions contract ловил сам себя на /hui; dead-route скан
падал на router_test.go, который перечисляет удалённые маршруты, чтобы
доказать их отсутствие; скан иконок — на блочном комментарии о замене плагина;
скан прежних имён раннеров — на слове systemd-run в прозе; скан
cancelFirewallRollback — на комментарии о её разделении.
code_has отбрасывает строчные комментарии (//, #), но не блочные
/* … */. Блок-парсер сознательно не заводится: наивный стриппер спотыкается
о /* внутри строк и регулярных выражений и может вычистить настоящий код —
а это ложный PASS, то есть лекарство хуже болезни. Для файлов с блочными
комментариями формулируется синтаксическая форма либо утверждение опирается на
более сильный гейт.
Пример последнего: литерального скана по virtual:svg-icons-register больше
нет. Его роль исполняет production vite build, который проходит раньше:
активный import "virtual:svg-icons-register" при отсутствующем плагине не
разрешается резолвером, и сборка bundle падает. Проверяется исполняемый импорт,
а не совпадение подстроки.
Почему пайплайн в grep -q запрещён
Поиск с флагом -q прекращает чтение на первом совпадении и закрывает свой
конец канала. Продюсер, которому осталось что писать, получает SIGPIPE и
завершается кодом 141, а set -o pipefail делает 141 статусом всей
конструкции. Смысл инвертируется:
совпадение НАЙДЕНО -> продюсер оборван -> статус 141 -> «не найдено»
Для утвердительной проверки это ложный FAIL — гейт отвергает корректный артефакт. Для отрицательной («такой конструкции в коде нет») — ложный PASS: запрещённая конструкция найдена, а гейт зелёный.
Порог измерим и резкий. Пока весь вывод продюсера помещается в буфер канала — 64 KiB на Linux, — он записывает всё, не блокируясь, и успевает завершиться раньше, чем потребитель вообще начнёт читать. Замер на 60 прогонах каждого размера:
4 KiB … 60 KiB отказов 0
64 KiB отказов 58/60
96 KiB и больше отказов 60/60
Поэтому такая проверка годами выглядит исправной, а переворачивается на первом
источнике крупнее буфера. В этом репозитории файлы такого размера уже есть
(tools/build/lib/acceptance.sh — 123 KiB, orchestrator/src/steps/firewall.ts
— 67 KiB).
Правильная форма — here-string, у которого пайплайна нет вовсе:
grep -q 'PATTERN' <<<"$content" || fail "..."
Для содержимого файла есть code_has FILE [флаги] -- PATTERN: он читает код без
комментариев в переменную отдельным оператором и сравнивает через
here-string. Отдельный оператор важен: в контексте ! code_has … bash отключает
errexit на весь вызов, поэтому неудачное чтение проверяется явно, а не
рассчитывает на set -e.