Оба дефекта — в механизмах, введённых предыдущими коммитами, и оба относятся к
гарантиям, ради которых эти механизмы вводились.
1. Отказ записи `auto-rollback-fired` оставался незамеченным.
Инвариант фиксации "маркера нет и юниты inactive => guard не сработал" верен
только при дополнительном условии "guard способен записать маркер". Пока `rc=0`
стояло ПОСЛЕ создания маркера, отказ записи (заполненный tmpfs /run, read-only
ФС) не влиял ни на что: скрипт успешно восстанавливал прежний firewall,
завершался кодом 0, юнит уходил в inactive, маркера не было — и операция
фиксировала успех после реально сработавшего отката.
`rc` объявляется до первой операции, включая создание маркера, а ранний выход
возвращает его вместо жёсткого `exit 0`. У факта срабатывания появилось два
независимых канала: маркер и отказ юнита, потому что на пути фиксации успеха
допустим ровно один ActiveState — inactive.
Заодно маркер создаётся `touch`, а не `: >file`: двоеточие — special builtin
POSIX, ошибка перенаправления на нём обязана завершить неинтерактивный shell
целиком, и в dash скрипт умер бы ДО восстановления firewall.
2. Новая операция могла начаться, пока guard предыдущей ещё вооружён.
Замок действует, пока жив процесс-держатель. Guard — отдельный объект systemd,
переживающий свой процесс:
A берёт замок -> применяет firewall -> вооружает guard на 45s
A аварийно умирает
B берёт замок и начинает менять production paths
guard A срабатывает и возвращает firewall, который был ДО A
Случай SIGTERM/SIGHUP хуже, чем kill -9: обработчик снимает замок сам, поэтому
проверка живости держателя не видит вообще ничего, а таймер остаётся.
Введён барьер покоя `assertNoPendingRollbackGuard`, через который проходит
каждый захват замка — дважды, до и после, потому что между ними умирающая
операция успевает вооружить guard, — и PHASE 0 установщика. Непокоем считаются
active/activating/deactivating/reloading; `failed` и `inactive` — покой, иначе
барьер блокировал бы `repair`, которым чинят последствия.
Плюс P1: восстановление UnitFileState у nftables.service больше не обещает
точности, которой не даёт. `enable --runtime` не удаляет постоянную ссылку,
поэтому "восстановление" enabled-runtime оставляло юнит включённым в обоих
scope. Восстанавливаются enabled/disabled — то, что операция реально меняет, —
остальные состояния называются оператору и не трогаются.
Тесты: поведенческая проверка раннего пути rollback-скрипта настоящим shell
(ветка заканчивается до первой команды восстановления и безопасна для запуска),
проверка двойного вызова барьера и снятия замка при его отказе, структурные
инварианты. Приёмка и docs (D1h, уточнение D1f) — там же.
104 KiB
Изменения HY2XS
Формат основан на Keep a Changelog. Проект использует семантическое версионирование.
Версия относится к самому HY2XS, а не к Hysteria: версия Hysteria выбирается на этапе сборки пакета и фиксируется в его metadata.
Unreleased
Hardening-проход перед релизом 1.0.0. Основная тема — сделать политику
«только чистая установка» настоящим системным инвариантом, а не строчкой в
документации.
Второй проход закрывает то, что осталось: два контракта приёмки, гарантированно
ронявшие сборку на корректном коде; жизненный цикл планировщика; мёртвое
состояние в таблице config; каналы утечки bootstrap-учётных данных; возраст
графа зависимостей.
Третий проход — операции, которые делают не то, что обещает их имя: отзыв доступа, не переживающий перезапуск; резервная копия, молча получающаяся неполной; диагностика, обрывающая соединения; аварийный выход сборки, которым невозможно воспользоваться.
Четвёртый проход — failure path и релизные гейты: восстановление после
неудачной установки, которое умело отменить само себя, два гейта сборки,
проверявшие не то, что обещали, и два свойства, которые были описаны, но не
обеспечены — read-only у doctor и различение отказа базы у reset-admin.
Пятый проход — нижний слой того же механизма. Верхнеуровневый откат стал надёжным раньше, чем его storage/firewall substrate: откат гарантированно запускался, но отдельные его шаги могли молча не выполнить восстановление, отчитаться успехом и уничтожить резервную копию.
Шестой проход — управление самой транзакцией, а не копированием файлов. Предыдущие проходы сделали надёжными шаги операции; здесь закрываются два допущения, на которых держалась операция целиком: что снятие защиты от отката действительно произошло и что операция на сервере ровно одна.
Исправлено — границы транзакции
-
Снятие rollback guard было утверждением, а не фактом. Порядок фиксации успеха выглядел так:
systemctl stop <unit>.timer <unit>.service || true -> "firewall rollback timer disarmed" -> phase=installedМежду «мы думаем, что guard снят» и «guard действительно снят» не было ни одной проверки:
|| trueстирал код возврата, и взведённый таймер мог вернуть прежний firewall уже ПОСЛЕ долговечной записи успеха. Просто убрать|| trueбыло нельзя — для транзиентного юнита, уже убранного systemd,systemctl stopвозвращает 5, и этот исход неотличим от успеха.Введён маркер
/run/hy2xs/rollback/<op-id>/auto-rollback-fired, который rollback-скрипт создаёт первым действием. Снятие guard стало доказательством: маркер отсутствует,ActiveStateобоих юнитов равенinactive, и только после этого записываетсяphase: installed. -
Автоматический откат мог сработать во время успешного smoke, и операция этого не замечала. Окно guard — 45 секунд — заведомо короче худшего случая smoke, а единственной проверкой firewall в smoke был
nft -c: разбор текущего файла, каким бы он ни был. Откатившийся прежний ruleset проходил её зелёным, и сервер объявлялся успешно настроенным с предыдущим firewall — особенно дорого при смене порта Hysteria, SSH или ACME.Лечится не увеличением окна: сработавший guard теперь запрещает фиксацию успеха независимо от результата smoke и даёт собственную причину отказа
firewall_guard_fired. Дополнительно smoke сверяет эффективный firewall с конфигурацией операции — фрагмент правил, принадлежность entrypoint и фактически загруженную таблицуinet hy2xs. -
У оркестратора не было блокировки операций. Ни
flock, ни mutex, ни lockfile — при том что вся архитектура отката опиралась на невысказанное допущение об одной операции за раз.install-state.jsonзамком не является: это запись о состоянии, а не право на изменение. Два одновременныхreconfigureдоходили до конца каждый по-своему, и уникальныеop-idне спасали — они разделяют резервные копии, но production paths общие. Дальше любая из операций могла упасть и «восстановить» состояние поверх изменений другой, отчитавшись полным успехом.Введён эксклюзивный замок
/run/lock/hy2xs-orchestrator.lock.install/reconfigure/repair/doctorберут его и отказывают до первой мутации;status/diagnosticsне берут, но сообщают об идущей операции;preflight-installотказывает до собственных проверок. Замок снимается при любом завершении держателя, включая обрыв SSH. -
Автоматический откат маскировал собственные ошибки. Внутри
systemd-runоставалисьcp ... || trueиnft -f ... || true, поэтому при частичном восстановлении юнит завершался кодом 0 — ровно в сценарии, где guard является последней линией защиты от потери SSH. Скрипт переписан: независимые стадии, накопление кода возврата,failedс диагностикой в journal. -
Откат не восстанавливал состояние
nftables.service.applyFirewallвыполняетsystemctl enable --now nftables, но копия хранила только файлы правил. После отката неудачной первой установки сервис оставался включённым в автозапуск, хотя до неё был выключен. Состояние снимается вместе с файлами и восстанавливается стадиями, идущими до применения ruleset: уnftables.serviceExecStop=nft flush ruleset, и обратный порядок стёр бы восстановленные правила. -
/etc/nftables.conf.candidateне удалялся никогда. Успешная установка оставляла его на сервере навсегда. Candidate-файлы убираются после успеха и best-effort при откате;purge-v0.shтоже их знает. -
Скрипт автоотката собирался однострочником внутри
sh -c '...'. Интерполяции проходили через shell-квотирование и подставлялись внутрь уже закавыченной строки: корректность держалась на склейке соседних кавычек и на том, что op-id не содержит пробелов. Скрипт вынесен в отдельную чистую функцию, ключ операции проверяется, а результат покрыт тестом и разбирается настоящим shell-парсером. -
Ключ операции считался в двух местах и разошёлся.
installписал в маркер сырой ISO-timestamp с двоеточиями, тогда как каталог отката назывался санитизированным ключом: путь/run/hy2xs/rollback/<op_id>, который runbook предлагает открыть, на сервере не существовал. -
Стадии восстановления
reconfigureбыли независимы по группе, а не по файлу. Отказcpдляhy2xs-admin.serviceотменял восстановлениеhysteria-server.service: внешняя стадия честно попадала в список отказавших, но принцип «восстановить максимум» на уровне файлов не выполнялся. -
Отказ записи маркера
auto-rollback-firedоставался незамеченным. Инвариант фиксации — «маркера нет и юнитыinactive⇒ guard не сработал» — верен только при дополнительном условии «guard способен записать маркер». Покаrc=0стояло ПОСЛЕ создания маркера, отказ записи (заполненный tmpfs/run, read-only ФС) не влиял ни на что: скрипт успешно восстанавливал прежний firewall, завершался кодом 0, юнит уходил вinactive, маркера не было — и операция фиксировала успех после реально сработавшего отката. Теперь у факта срабатывания два независимых канала: маркер и отказ юнита.Отдельно: маркер создаётся
touch, а не: >file. Двоеточие — special builtin POSIX, и ошибка перенаправления на нём обязана завершить неинтерактивный shell целиком; в dash, который на Debian и есть/bin/sh, скрипт умер бы ДО восстановления firewall. -
Новая операция могла начаться, пока guard предыдущей ещё вооружён. Замок и guard вводились по отдельности и оставляли дыру на своём стыке. Замок действует, пока жив процесс-держатель; guard — отдельный объект systemd, который свой процесс переживает:
A берёт замок -> применяет firewall -> вооружает guard на 45 секунд A аварийно умирает B берёт замок и начинает менять production paths guard A срабатывает и возвращает firewall, который был ДО AСлучай с
SIGTERM/SIGHUPпри этом хуже, чемkill -9: обработчик снимает замок сам, поэтому проверка живости держателя не видит вообще ничего, а таймер остаётся. Введён барьер покоя, через который проходит каждый захват замка — и PHASE 0 установщика тоже. Условие старта стало «у предыдущей операции не осталось исполнителей, способных изменить систему». -
Восстановление
UnitFileStateобещало точность, которой не давало.systemctl enable --runtimeне удаляет постоянную ссылку, поэтому «восстановление» состоянияenabled-runtimeоставляло юнит включённым в обоих scope'ах. Теперь восстанавливаютсяenabledиdisabled— состояния, которые операция реально меняет, — а остальные явно называются оператору и не трогаются.
Исправлено — целостность отката
-
Данные для отката уничтожались до фиксации успеха. Успешный install заканчивался вызовом, который снимал таймер автоотката и удалял резервные копии firewall, — и стоял этот вызов ДО долговечной записи
phase: installed. Если запись падала (ENOSPC,EIO, read-only ФС), управление уходило в обработчик ошибки, обязательный откат честно запускался и сообщалno HY2XS rollback markers found. Откат нельзя было отменить, но откатывать ему было нечем — причём отказ записи маркера это ровно тот сценарий, который был специально сделан безопасным прошлым проходом.Операция разделена на
disarmFirewallRollback(снять таймер, копии оставить) иcleanupFirewallRollback(удалить копии). Порядок теперь:smoke_ok→ disarm → durableinstalled→ cleanup best-effort. То же вreconfigure. -
Резервные копии снимались без доказательства. И firewall, и
reconfigureкопировали файлы какcp ... || true, поэтому отказ копирования игнорировался, а операция начинала менять систему, не имея копии, на которую рассчитывает откат. У firewall маркерprepared(«данные для отката существуют») выставлялся вообще до копирования. Копирование стало строгим, факт создания проверяется, маркер ставится после. -
Копии
reconfigureсмешивались между операциями. Они лежали одним общим набором*.bakв/etc/hy2xs/backups, не привязанным к проходу. Если у операции B копирование падало, B всё равно менял систему, а его откат восстанавливал файлы, сохранённые операцией A: сервер возвращался не в состояние «до B», а в более старое — и это выглядело успешным откатом. Копия стала операционной:/etc/hy2xs/backups/<op-id>/с манифестом, где отсутствие файла — записанный факт ("present": false), а не вывод из неудачиcp. Разбор манифеста строгий, включая проверкуopId. -
Ошибка восстановления скрывалась, а копии после неё удалялись.
rollbackFirewallNowвыполнялаcpиnft -fс|| true, а затем безусловно удаляла/run/hy2xs/rollback/<op>. Худшая возможная комбинация: неудача восстановления не видна, стадия отчитывается успешной, а данные, по которым оператор мог бы поднять firewall вручную, уничтожены. Теперь копии удаляются только после подтверждённого успеха, иначе сохраняются с сообщениемmanual recovery data preserved at …. -
Команды отката глушили собственный код возврата.
systemctl stop,disable,reset-failed,cp,nft -f,daemon-reload,restart— все несли|| true. До появления стадийного раннера это была единственная защита от обрыва цепочки; после его появления — маскировка: стадия физически не могла сообщить, что ничего не сделала, и обещание «отказавшие стадии перечисляются» для них не выполнялось никогда.|| trueубран, непрерывность обеспечивает раннер,rollbackCurrentStateразбита на семь независимых стадий. -
Долговечность записи каталога маркера.
writeTextAtomicсинхронизирует файл и каталог, в котором файл лежит, но при первой установке/var/lib/hy2xsсоздаётся тут же, и запись «hy2xs» в/var/libоставалась несинхронизированной: после потери питания мог исчезнуть весь каталог вместе с маркером.ensureDirсообщает о фактическом создании и синхронизирует родителя только тогда.
Изменено — свойства, ставшие инвариантами
-
doctorread-only по инварианту рантайма, а не по соглашению. ПринудительныйskipServiceStartзакрывал ровно одну ИЗВЕСТНУЮ мутацию — рестарт сервисов. Всё остальное вsmokeдержалось на том, что автор правки выбрал правильный раннер, а читающие команды (test -s,grep -q,stat,sudo -u ... test,nft -c) шли через мутирующий namespace. То есть настоящая мутация, случайно добавленная вsmoke, ничем бы от них не отличалась и была бы разрешена вdoctorмолча.Эти команды классифицированы честно, ожидание между попытками перестало быть подпроцессом
sleepчерез мутирующий раннер, а самdoctorцеликом выполняется под тем же read-only guard, что и PHASE 0 установки. Диагностика при этом не сузилась.
Исправлено — reset-admin
-
Отказ базы трактовался как «администратора нет». Слой данных специально различает
ErrAdminUserNotFoundиErrStorage, но команда восстановления доступа склеивала их обычнымif err != nil { создать } else { обновить }. Опасен здесь не только нарушенный смысл sentinel'ов: при транзиентном отказе чтения («database is locked») ветка создания отрабатывала успешно, и в таблице оказывались ДВЕ учётные записи администратора.GetAdminUserберётFirst()и о второй строке не сообщает — то есть на сервере оставалась вторая рабочая учётка с паролем, уже напечатанным на экран, и ни один запрос об этом не говорил. -
Ошибка хеширования пароля проглатывалась. В ветке обновления стояло
hash, _ := util.HashPassword(password)внутри литерала map. При отказе bcrypt вpassword_hashуезжала пустая строка, а на экран печатался пароль, которым войти уже невозможно:VerifyPasswordотклоняет всё, что не является bcrypt-хешем. Команда восстановления доступа умела молча его отобрать.
Исправлено — восстановление после неудачной операции
-
Запись состояния отказа отменяла откат. Обработчик ошибки в
installиreconfigureпервым делом писал вinstall-state.jsonфазу отказа обычнымawaitи только потом откатывался. Эта запись —mkdir,writeиchownв/var/lib/hy2xs, то есть она падает ровно там, где откат нужнее всего: заполненный диск, read-only ФС, ошибка ввода-вывода. Бросок уносил управление наружу, и обязательное восстановление не выполнялось вовсе — применённый firewall и развёрнутые сервисы оставались на сервере.Необязательная телеметрия состояния стояла перед обязательным восстановлением. Для сбора диагностики это уже было закрыто прошлым проходом, для записи состояния — нет. Теперь запись обёрнута так же: неудача попадает в журнал строкой
failed to persist failure state, continuing with the mandatory rollback, а откат продолжается. -
Откат отменял сам себя. Он был написан цепочкой
await, а каждая его стадия —systemctl,cp,rm -rfилиnft, то есть умеет упасть сама. Отказ первой стадии отменял все последующие. Вreconfigureэто означало сервер одновременно с применённым сломанным firewall и без восстановленных из/etc/hy2xs/backupsконфигов — худший сценарий отказа лишался обеих половин восстановления сразу.Внутри
rollbackCurrentStateболезнь была та же: единственная команда без|| true(systemctl daemon-reload) отменяла перезапуск сервисов строкой ниже, и восстановленные unit-файлы так и не применялись.Стадии стали независимыми: выполняются все и в объявленном порядке, отказавшие перечисляются в журнале, наружу уходит исходная ошибка операции.
-
У маркера установки было два писателя с разными гарантиями.
installперезаписывал файл на месте,reconfigureподставлял атомарно; слабейшая гарантия досталась команде, которая этот файл создаёт. Перезапись на месте укорачивает файл до нуля и только потом наполняет — отказ между этими моментами оставляет половину JSON, которая не разбирается:reconfigureвидит такой маркер как отсутствующий, clean-host — как присутствующий, а хост к этому моменту уже изменён.Атомарности при этом было бы мало:
rename()безfsyncдаёт атомарность видимости без долговечности, и после потери питания ext4 штатно отдаёт по этому пути нулевой файл. Порядок теперь: права и владелец →fsyncфайла →rename→fsyncкаталога. -
Ownership-флаг маркера отвечал не на тот вопрос. Он назывался
stateWrittenи взводился ПОСЛЕ успешной записи, хотя запись — это три операции. Отказ наchownоставлял файл на диске при невзведённом флаге, то есть давал классификациюfatal_pre_apply— «на сервере ничего не изменено» — при уже существующем/var/lib/hy2xs/install-state.json, который ломал следующую чистую установку. Флаг переименован вstateTouchedи взводится до первой операции записи, как все остальные.
Изменено — релизные гейты сборки
-
pnpm auditпроверяет весь lock-граф, а не production-подграф. Гейт запускался с--prodпод обоснованием «devDependencies в артефакт не попадают». Для frontend build tooling это неверно по существу:viteиrollupне копируются на сервер, но исполняются на build-машине и порождают тот самый production-бандл. Ровно такой случай и был найден в этом же релизном цикле — DOM clobbering в Rollup затрагивал генерируемый бандл, а--prodего не показывал; по всему графу тот же прогон дал 33 предупреждения против нуля. Критерий приёмки №47 вdocs/11формулировал это правильно ещё до того, как стало правдой в коде. -
Удалён
SKIP_TESTS. Переменная была описана как «аварийное отключение тестов; для release-сборок недопустимо». Недопустимость держалась исключительно на этой фразе: ни metadata, ни финальная приёмка архива не проверяли, что тесты запускались, поэтомуSKIP_TESTS=true ./tools/build/build.shдоходила до конца и выдавала обычный tarball сbuild_profile=productionиdependency_security_gate=true— артефакт, по которому невозможно отличить проверенную сборку от непроверенной. Глушила она при этом не только тесты, но иtsc --noEmitсgo vet.Выбран тот же строгий вариант, что и для проверки зависимостей: обхода нет, а готовый пакет объявляет
tests_gate=trueвmetadata/package.env. Поле опирается на фактический прогон —write_metadataотказывается писать метаданные, если хотя бы один из двух прогонов не подтверждён. Для локальной работы обходить нечего:bun test,tsc --noEmit,go vetиgo testзапускаются напрямую и tarball не создают.
Исправлено — операции, не выполняющие обещанного
-
Удаление
bootstrap-admin-peerне было отзывом доступа. Признаком «создавать пир или нет» служило наличие строки в таблице пиров, аHY2XS_ADMIN_CON_PASSпродолжает жить в/etc/hy2xs/hy2xs.env— его читает systemd-юнит. Поэтому оператор удалял пира, доступ действительно исчезал, а ближайшийsystemctl restart hy2xs-adminили перезагрузка сервера возвращали того же пира с тем же секретом. Молча: ни строки в журнале, в списке пиров запись просто снова есть.Признаком стала отметка
BOOTSTRAP_PEER_SEEDEDв таблицеconfig: она отвечает на вопрос «пир КОГДА-ЛИБО создавался», а не «существует сейчас». Отметка и сам пир пишутся одной транзакцией — раздельная запись вернула бы прежнее поведение при падении процесса между двумя операциями. Удаление осталось разрешённым и стало необратимым; отключение (Disabled = 1) остаётся вторым, обратимым способом. -
Резервная копия с секретами могла молча оказаться неполной.
ListExportPeer(true)проглатывала и ошибку расшифровки, и отсутствие шифртекста, отдавая пира с пустым полемsecretи успешный ответ. Оператор, СПЕЦИАЛЬНО выбравший режим «копия с действующими credentials», получал файл, выглядящий полным, и узнавал о потере после импорта на новом сервере — по отвалившимся клиентам. Теперь недоступный секрет хотя бы одного пира отклоняет весь запрос с указанием имени; файл не создаётся. Безопасная выгрузка (includeSecrets=false) не изменилась. -
DecryptPeerSecretвозвращала содержимое колонки как расшифрованный секрет, если оно не начиналось сv1:. Ветка досталась от поколения, где секреты пиров лежали открытым текстом; при clean-install-only политике такой строки существовать не может, а вред оставался: повреждённая колонка уходила в клиентскую ссылку и в резервную копию как учётные данные. Формат хранения теперь ровно один, всё остальное — ошибка. Тот же класс, что и удалённый SHA-224 fallback при входе. -
hy2xs-orchestrator doctorперезапускал оба сервиса. Команда собирала контекст с параметрами по умолчанию и звала общий smoke, который начинается сsystemctl restart hysteria-server hy2xs-admin. То есть диагностика, которую runbook предлагает запускать при подозрении на проблему, гарантированно обрывала все живые VPN-соединения — включая случай, когда с сервисом всё в порядке. Диагностика, меняющая то, что диагностирует, отвечает не на заданный вопрос.doctorпринудительно выставляетskipServiceStart; остальные проверки smoke выполняются полностью. -
Админка сама придумывала
HYSTERIA2_TRAFFIC_STATS_SECRET. При пустом env и пустой базе она генерировала случайный токен, записать который в/etc/hysteria/config.yamlне может — файл принадлежит оркестратору и доступен ей только на чтение. Сервис объявлял себя здоровым, а machine auth переставал совпадать, потому что Hysteria продолжала слать прежнее значение. Тот же принцип, что уже действует дляHY2XS_ADMIN_INITIAL_PASSWORD: пустой env при пустой базе — отказ старта; уже согласованный токен в базе принимается. -
Обходы проверки зависимостей существовали только на бумаге. Документированные
dependency_security_gate=accepted-riskиskippedне могли произвести артефакт: финальная приёмка архива требует буквальноtrue, поэтому сборка с ними проходила весь цикл и падала на последнем шаге. Продукт документировал операцию, которую сам же запрещал. Обе переменные удалены из сборки и документации; их отсутствие проверяется приёмкой. Контракт стал однозначным: релизный артефакт невозможно собрать с непройденной проверкой. -
UPDATEпо отсутствующей строкеconfigсчитался успехом.updateConfigOnсмотрел только наtx.Error, а UPDATE без совпавших строк ошибкой SQL не является. СледомapplyRuntimeConfigUpdatesприменял значение к планировщику, поэтому оператор дважды получал подтверждение изменения, которого не произошло, а перезапуск сервиса возвращал прежний cron. Решение принимается поRowsAffected— как в соседнейUpsertConfigValue, где эта же ошибка уже была разобрана. -
Слой данных не отличал «записи нет» от «база не ответила». Каждый
Get*возвращал свежийerrors.Newсо строкой, поэтому отказ SQLite был неотличим от отсутствия записи, а решения на этом принимались серьёзные: «пира нет» означало «создать заново», «по auth_id не нашли» — «искать по имени и, не найдя, создать», «ошибка» вExistPeerName— «имя свободно». При недоступной базе продукт не отказывал, а трактовал отказ как разрешение действовать. Введены sentinel-значенияErrPeerNotFound,ErrAdminUserNotFound,ErrConfigNotFoundиErrStorage. -
Алиасы
/:id/client-urlи/:id/qrудалены. Они были оставлены «на один миграционный релиз», которого у clean-install-only продукта не существует; дожив до1.0.0, они стали бы частью публичного API v1.
Добавлено — контракт разработки
-
apps/go.modобъявляетtoolchain go1.26.7. Директиваgo— языковой baseline модуля, а не выбор компилятора: с ней одной локальныйgo buildна другой минорной линии проходил успешно, хотя релизный бинарь собирается на 1.26.7 и наследует её stdlib. Разработчик и сборка проверяли разный код. Совпадение сversions.envпроверяетverify_go_toolchain_contract. -
tools/dev/doctor.shиtools/dev/doctor.ps1сверяют Go, Node, pnpm, Bun и директивуtoolchainсversions.env. Собственных значений версий у них нет — второй список неизбежно разъехался бы с контрактом. Сборка соблюдалаversions.envи раньше; машина разработчика не проверялась никак, и расхождение обнаруживалось на Debian, внутри release-сборки.
Изменено — модернизация frontend
Известное ограничение «проверка типов frontend почти ничего не проверяет» закрыто и удалено из документации.
-
vue-tscстал настоящим release gate. Он был версии0.35.0(2022 год) и шаблоны Vue не типизировал: проверка проходила зелёной, не давая обещанной гарантии. На Vue 3.5 она к тому же ломается сама, не знаяvue/jsx-runtime, — то есть пережить обновление Vue не могла в любом случае.Современный
vue-tsc 3.3на том же коде дал 142 ошибки, а не ~155 из прогноза, и картина оказалась однороднее: 141 ×TS18048и однаTS2322, всё в двух файлах представления Hysteria. Ожидавшегося класса «DefaultRowнесовместим сPeerVo» на Element Plus 2.3 не существовало — он появился вместе с обновлением Element Plus до 2.14.Скрипты разделены:
typecheck,build:prod,verify. Сборка запускает проверку типов до bundle; раньшеvite build && vue-tscсначала тратил время на production bundle и только потом сообщал о типовой ошибке. -
Нормализация конфига Hysteria на границе API. Все 142 ошибки — обращения к необязательным секциям конфига в шаблоне. Необязательны они правильно: так устроен upstream YAML. Инвариант «секция есть всегда» существовал, но держался на порядке присваиваний внутри компонента и типом не выражался. Введён
Hysteria2ServerConfigView, выводимый из модели ответа типом, а не вторым списком полей, — вместо 141 оператора?.илиas any. -
Убраны три редактора, которые ничего не сохраняли. Outbounds, список значений и словарь «ключ — значение» на странице конфига Hysteria: страница отрисована с
:disabled="true", значения передаются безv-model, маршрутов записи серверного конфига в API нет. Оператор мог добавить outbound, увидеть его в списке и уйти в уверенности, что изменил конфигурацию сервера.У одного цена была ещё и измеримой:
vuedraggableпоставляется UMD-сборкой, поэтому еёrequire("vue")разрешался в полную сборку Vue с рантайм-компилятором — около полумегабайта в bundle ради перетаскивания тегов в недоступной для редактирования форме. -
pnpm auditпо всему графу: 33 предупреждения → 0. Из них четыре затрагивали production-зависимости (vue-i18n,echarts), остальные — build-цепочку. Среди последних был rollup GHSA DOM clobbering, а он затрагивает генерируемый bundle, то есть уезжает в production:pnpm audit --prod, на который смотрит gate сборки, его не показывал.Обновлены: Vue 3.2 → 3.5, TypeScript 4.9 → 5.9, Element Plus 2.3 → 2.14, Vue Router 4.1 → 4.6, Pinia 2.0 → 2.3, VueUse 9 → 14, echarts 5 → 6, Vite 4.3 → 7.3, eslint 8 → 10 (с переходом на flat config), stylelint 15 → 17. Pinia 3, Vue Router 5 и Vite 8 сознательно не берутся: Vite 8 — это переезд на Rolldown, остальные не дают проекту ничего, кроме номера версии.
-
vite-plugin-svg-iconsзаменён собственным спрайтом. Плагин не обновлялся с 2022 года и тянулsvgo 2.8,postcss 5.2.18иimage-size 0.5.5, у которой advisory сообщаетPatched versions: <0.0.0— исправленной версии не существует. Проверка на реальных ассетах поймала то, что иначе уехало бы в релиз: три иконки из семнадцати не объявляютviewBox, и без его синтеза изwidth/heightотрисовывались бы обрезанными. -
Разбор bundle через sourcemap нашёл вторую потерю:
@vueuse/coreсобирался дважды — наш и тот, что тянет Element Plus. Версии сведены.Итог по размеру: 2 819 722 байта против 2 404 202 на исходной базовой линии. Рост в 17% — цена Vue 3.5, Element Plus 2.14, echarts 6 и rollup 4; промежуточное состояние до двух исправлений выше было 3 046 172.
Исправлено — сборка не собиралась
-
build.shдетектировал сам себя и падал шестым шагом из четырнадцати.verify_api_namespace_contractискал возвращение legacy-пространства имён черезgrep -rlF '/hui'по списку каталогов, в который входилиtools/buildи тесты. Поиск находил два файла:apps/router/router_test.go, который ПЕРЕЧИСЛЯЕТ legacy-префикс, чтобы доказать отсутствие маршрута, и самversions.sh, где эта строка стоит в тексте проверки. То есть добавление теста, закрепляющего очистку, ломало сборку, а до резолва Hysteria дело не доходило вовсе.Скан теперь идёт только по runtime production sources и по тому, что уезжает в пакет, с исключением
*_test.go. Гарантия не ослабла, а переехала на слой, где она сильнее: отсутствие маршрута доказываетTestRouterHasNoLegacyNamespaceна таблице маршрутов собранного роутера, и существование этого теста само стало частью контракта. -
Второй такой же контракт прятался за первым. Проверка «импорт пиров не выходит за транзакцию» брала
source.slice(start)— файл от началаapplyPeerImportEntryи до конца, — захватывая объявленные нижеExistPeerNameиUpdatePeerLastConnectionAt. Это обычные операции вне импорта, которым глобальное соединение положено, поэтому проверка падала на корректном коде. Замечена не была только потому, что сборка до неё не доходила. Границей тела функции теперь служит следующее объявление верхнего уровня.Отсюда общее правило и помощники
code_without_comments/code_mentions_inвacceptance.sh: приёмка проверяет код, а не упоминания.
Исправлено — runtime
-
Смена расписания сброса трафика размножала планировщики.
middleware.InitCron()вызывался изrunServerи на каждом вызове создавал новыйcron.New(), нигде не сохраняя ссылку;cron.Stop()не вызывался нигде. При этом сменаRESET_TRAFFIC_CRONвыполнялаStopServer(), а точка входа крутилаfor { runServer() }и поднимала сервис заново.Каждая правка добавляла целый дублирующий набор джоб — учёт трафика, сбор метрик, уборка статистики, — а старое расписание сброса продолжало работать. После двух правок на процессе висели три планировщика и три разных расписания одновременно. Плюс окно, в котором джобы старого планировщика били в уже закрытое SQLite-соединение:
releaseResource()отрабатывал раньше, чем следующийrunServerуспевал открыть базу.Планировщик теперь принадлежит процессу: фиксированные джобы регистрируются один раз, расписание сброса переносится на месте по своему
EntryID, HTTP-сервер к смене настройки отношения не имеет. Цикл перезапуска в точке входа удалён — перезапуском упавшего юнита занимается systemd. -
Невалидное cron-выражение принималось API и молча отключало сброс трафика. Поле в панели —
el-selectсallow-create, то есть строка произвольная; backend принимал её как строку до 128 символов, а ошибкаAddFuncпри следующем старте только логировалась. Оператор получал успех, панель работала, автоматический сброс исчезал.Выражение проверяется до записи в базу тем же парсером (
cron.ParseStandard), которым его потом разбирает планировщик. Невалидное значение — отказ, база не меняется. Пустое значение легально и означает «сброс выключен». -
updateConfigsприменял партию частично. Валидация и запись шли в одном цикле, поэтому партия «разрешённый ключ + запрещённый» применяла первый и возвращала ошибку на втором. Существовавший тест ставил запрещённый ключ первым и не смотрел в базу — поймать это он был неспособен по построению.Теперь: полная проверка партии → одна транзакция (
dao.WithConfigTx) → применение к рантайму. Тест переписан на обратный порядок ключей и проверяет состояние базы на настоящей SQLite. -
Сервис не завершался штатно.
SIGTERMот systemd убивал процесс на середине: соединения обрывались, SQLite закрывался вместе с процессом, джобы могли быть остановлены посреди записи. Добавлено штатное завершение — планировщик глушится и дожидается запущенных джоб, затем закрывается база. -
Ложные ERROR в журнале на каждой первой загрузке. Создание секретов шло по схеме «сначала INSERT, при ошибке UPDATE», а строки ключей уже существовали из
seedBaseConfig: три записиduplicated key not allowedуровня error на совершенно здоровом старте. Зеркальная схема «сначала UPDATE, при ошибке INSERT» в других местах была хуже — она тихо не делала ничего, если строки не было: UPDATE без совпавших строк не ошибка, поэтому ветка INSERT не выполнялась, а вызывающий получал сгенерированный секрет как сохранённый. ДляJWT_SECRETэто означало бы подпись токенов ключом, которого нет в базе. Обе схемы заменены наdao.UpsertConfigValue, решающий поRowsAffected. -
Дублирующая реализация генерации ключей шифрования. В
service/peer_secret.goлежали построчные копииgetOrCreateConfigKeyиgetPeerSecretEncryptionKeyизdao/sqlite.go: две функции в двух пакетах, порождающие один и тот же материал шифрования. Расхождение между ними означало бы, что секреты пиров шифруются одним ключом, а расшифровываются другим. Осталась одна реализация вdao.
Безопасность
-
Bootstrap-пароль администратора писался в журнал открытым текстом. При отсутствии
HY2XS_ADMIN_INITIAL_PASSWORDадминка генерировала пароль сама и печатала его двумяlogrus.Warnfв/var/log/hy2xs/hy2xs-admin.log— файл, который отдаётся кнопкой выгрузки и попадает в diagnostics-бандл. Такой пароль к тому же не знал никто, кроме журнала.Отсутствие переменной теперь отказ старта с объяснением причины. То же для
HY2XS_ADMIN_CON_PASSпри создании пира установщика: его секрет продублирован в/etc/hy2xs/bootstrap-admin.secret, откуда его читает проверка machine-auth, и придуманный админкой секрет разошёлся бы с файлом. -
Собственный журнал админки выгружался без санитайза, хотя чужой (журнал Hysteria) — с санитайзом. Теперь оба проходят
SanitizeLogText, и во вкладке просмотра тоже. -
golang-jwt/jwtv3 в пути аутентификации. У v3.2.2 есть GO-2025-3553, у которой нет исправленной версии в ветке v3 (Fixed in: N/A), а уязвимый код достигается изParseToken, то есть с неаутентифицированного запроса. Выполнен переход наjwt/v5.Заодно закрыт тихий недостаток:
keyfuncвозвращал ключ, не проверяя алгоритм подписи, — набор допустимых алгоритмов фактически задавал сам токен. Разбор ограниченjwt.WithValidMethods, проверяютсяissuerи обязательное наличие срока жизни; пустойJWT_SECRETсчитается повреждённым состоянием, а не ключом нулевой длины. -
Вход по несолёному SHA-224 больше невозможен.
VerifyPasswordпринимала такой хеш как «legacy»-формат предыдущего поколения. В v1 он недостижим: миграции таблицыaccountудалены, установка возможна только на чистый хост, конфигурация 0.x отклоняется по схеме. Compatibility-ветка пережила слой совместимости, ради которого существовала, и осталась запасным путём проверки пароля слабым алгоритмом в обработчике логина. -
Modulo bias в генераторе секретов.
util.RandomStringбрала остаток байта от деления на длину алфавита (62): первые восемь символов выпадали примерно на четверть чаще остальных. Через эту функцию проходятJWT_SECRET,PEER_SECRET_KEY,PEER_SECRET_ENCRYPTION_KEY, секрет trafficStats API, секреты иauth_idпиров. Добавлена отбраковка (rejection sampling). -
Пир установщика был защищён только в импорте. Обычный CRUD панели позволял переподписать или переименовать
bootstrap-admin-peer, молча рассинхронизировав базу с/etc/hy2xs/bootstrap-admin.secret. Защита распространена на все пути записи; удаление и отключение остаются разрешёнными — это осознанные действия оператора, не создающие расхождения. -
Латентная паника в разборе токена.
service.GetTokenдоставала токен черезstrings.SplitN(header, " ", 2)[1]и падала на заголовке без пробела. Единственный потребитель — резервная веткаGetAdminInfo, недостижимая и проверявшая меньше, чем middleware (ни статус учётной записи, ни версию токена). Оба удалены: разбор токена у продукта ровно один. -
reset-adminгенерировал 6-символьные логин и пароль — нижняя граница, которую пропускаетHashPassword. Увеличено до 12 и 24.
Изменено
-
Toolchain переведён на поддерживаемые линии.
GO_VERSION1.21.13 → 1.26.7,NODE_VERSION20.19.0 (EOL) → 24.20.0. Go компилируетhy2xs-admin, поэтому его stdlib целиком попадает в production-бинарь: на прежнем графеgovulncheck ./...находил 21 вызываемую уязвимость, из них 17 в stdlib. После перехода и обновления зависимостей — ноль.Bun намеренно оставлен на 1.3.13: оркестратор собирается через
bun build --compile, то есть Bun runtime входит в исполняемый файл, и смена его версии требует отдельного прохода по всей матрице проверок. -
Добавлен обязательный шаг проверки зависимостей (
tools/build/lib/security.sh):govulncheck ./...для Go-графа и stdlib с анализом достижимости иpnpm audit --prodдля frontend. Версияgovulncheckпиньтся вversions.env, база уязвимостей подтягивается на каждом запуске. Аварийный выход —ALLOW_VULNERABLE_DEPENDENCIES=true; результат уезжает в metadata пакета полемdependency_security_gate. -
Обновлены зависимости frontend, попадающие в браузерный бандл:
axios1.3.4 → 1.20.0, плюсlodash/lodash-esчерезpnpm.overridesдо 4.18.1. Прямые зависимости и их диапазоны не менялись — двинулся только lockfile. В production-графе не осталось уязвимостей уровня high и critical.
Удалено
-
Четыре ключа таблицы
configбез единого потребителя —HYSTERIA2_ENABLE(жизненным циклом Hysteria владеет systemd),HYSTERIA2_CONFIG(второй источник истины рядом с/etc/hysteria/config.yaml, причём читался первым),HYSTERIA2_TRAFFIC_TIME(настройка «период учёта трафика», которую не читал никто: интервал сбора метрик задан в коде) иHYSTERIA2_CONFIG_REMARK(пустая read-only строка). Строки удаляются миграцией006_drop_dead_config_keys.Настоящую замену получил только последний: имя профиля в клиентской ссылке теперь выводится из имени пира, а при его отсутствии — из публичного хоста.
После очистки панель владеет ровно одной настройкой —
RESET_TRAFFIC_CRON.
Исправлено — предыдущий проход
-
Каждая чистая установка падала сразу после
apt-get. Внутриinstallpreflight()вызывался дважды, и оба раза проверял контракт чистого хоста. Ко второму вызову на диске уже лежал собственный/var/lib/hy2xs/install-state.json, записанный после первого preflight, — и он опознавался как маркер посторонней установки. Отказ приходил уже какfatal_post_apply: сервер оставался наполовину настроенным, а повторный запуск упирался в тот же маркер.Причина в том, что clean-host и проверка возможностей платформы ехали одним параметром, хотя отвечают на разные вопросы: чистота хоста — условие входа в операцию, а
systemd-run/nftables/OpenSSL 3 проверяются уже послеinstallDeps, то есть внутри PHASE 1.preflight()теперь принимаетcheckCleanHostявно и без значения по умолчанию в режиме install: любое умолчание здесь неверно, решение обязано приниматься на месте вызова. -
PHASE 1 начиналась вне зоны ответственности оркестратора.
install.shсам создавал/usr/local/lib/hy2xs, ставил туда бинарник, вешал symlink в/usr/local/binи копировал runtime-пакет — и только потом запускал оркестратор, у которого дальше шёл собственный preflight. Если тот отказывал (сменился DNS, занялся порт, не ответил резолвер), ни один ownership-флаг не был взведён: отказ классифицировался какfatal_pre_apply, и оператор читал «на сервере ничего не изменено» при уже созданном каталоге оркестратора. Следующий запуск упирался в эти пути как в маркеры чужой установки.Отследить владение мутацией невозможно, пока мутируют двое. Теперь
install.shне изменяет на сервере ничего: он проверяет и передаёт управление черезexec. Раскладку выполняет сам оркестратор — шагsteps/bootstrap.tsпод флагомownership.bootstrapTouched, а сами пути попадают вowned_pathsinstall-state наравне с остальными. Сборка проверяет структурно, что в установщике не осталось ни одной мутирующей команды.Побочный эффект: у списка clean-host маркеров больше нет «мягкой» версии для PHASE 1. Она существовала только затем, чтобы установка не отказала на путях, которые shell создал между фазами.
-
Machine token утекал в обычные логи при каждом подключении пира. Hysteria обращается к машинному endpoint'у как
/internal/hysteria/auth?access_token=<секрет>, а журнал админки писалc.Request.RequestURI— то есть путь вместе с query string. Действующий токен оседал открытым текстом в/var/log/hy2xs/hy2xs-admin.log, который отдаётся оператору черезExportLogи попадает в diagnostics-бандл. Вся структурная редакция, сделанная для конфигов и env, этот канал не закрывала.Логируется путь; значения query-параметров не пишутся вовсе, имена — пишутся (
reqQueryKeys). ПолеreqUriудалено из модели журнала.Каналов было два:
gin.Default()подключаетgin.Logger(), который печатает путь вместе с query в stdout, откуда он уходит в journald, а оттуда — в diagnostics-бандл. Панель запускается черезgin.New()+gin.Recovery(), и HTTP-логгер у продукта остался ровно один.Дополнительно: журналы внутри diagnostics-бандла (
journal-admin.log,journal-hysteria.log, выводsystemctl status) больше не копируются как есть, а проходят санитайз; тот же проход применяется к журналу Hysteria, который админка отдаёт черезExportLog. Сравнение machine token переведено наsubtle.ConstantTimeCompare. -
Config API позволял прочитать и подменить криптографические ключи приложения. Generic export/import таблицы
configудалили, но точечный API остался прежним:getConfig/listConfigпринимали произвольный ключ, а проверка записи работала denylist'ом из трёх ключей оркестратора. Запрос?key=PEER_SECRET_ENCRYPTION_KEYотдавал master-key шифрования секретов пиров, аupdateConfigsпозволял подменитьJWT_SECRETи оба peer-ключа.Доступ переведён на allowlist: наружу открыты только
HYSTERIA2_TRAFFIC_TIME,RESET_TRAFFIC_CRON(чтение и запись) иHYSTERIA2_CONFIG_REMARK(только чтение). Denylist требует, чтобы автор каждого нового ключа вспомнил про этот файл; при allowlist забытый ключ закрыт. МаршрутGET /api/config/getConfigудалён целиком — потребителей у него не было ни одного, а фильтр на неиспользуемой двери остаётся дверью. -
Импорт пиров не был атомарным, вопреки собственному контракту. Партия проверялась целиком до первой записи, но применялась по одной записи, каждая своим оператором. Валидация ничего не знает о том, что уже лежит в базе: пусть есть
A(auth_id=aaa, name=alice1)иB(auth_id=bbb, name=bob123), а файл несёт(auth_id=aaa, name=bob123)— поиск найдёт A поauth_idи попытается переименовать её вbob123, прямо вUNIQUE(name). Всё, что шло в файле до конфликтной строки, оставалось применённым, и откатить это оператор уже не мог.Применение выполняется одной транзакцией (
dao.WithPeerTx). Криптоматериал считается до её открытия: digest и шифрование читают ключи из той же таблицыconfig, и держать на ней открытую запись во время AES по каждой из тысяч записей незачем. -
Файл импорта мог содержать хвост, который молча не применялся.
json.Decoderчитает первый документ и останавливается, поэтому файл вида[{...}]\n{"что-то":"ещё"}принимался целиком: оператор видел «импорт выполнен» и не узнавал, что применилась половина. После разбора проверяетсяio.EOF. -
Отказ сбора диагностики отменял откат. В
installиreconfigurediagnosticsCollect()стояла перед rollback обычнымawait. Она создаёт каталог, копирует файлы и упаковывает tar — на заполненном диске падает сама, и тогда худший сценарий отказа установки гарантированно лишался единственного механизма восстановления. Диагностика — best effort, откат — обязателен. -
fatal_pre_applyмог означать «хост уже изменён».install-state.jsonпишется сразу после успешного preflight, до установки пакетов, но классификация отказа его не учитывала. Падениеapt-get updateилиapt-get installобъявлялось как «на сервере ничего не изменено»: откат и обработка состояния пропускались, а маркер оставался на диске и ломал следующую установку по clean-host контракту.Ownership-флаги переформулированы с «шаг успешно завершился» на «операция могла начать менять систему» и взводятся перед мутирующим вызовом:
apt-getумеет изменить систему и упасть.fatal_pre_applyтеперь недостижим ни при одном взведённом флаге, включая запись состояния. -
Экспорт в админке оставлял секреты на диске навсегда.
ExportPeerи выгрузка системного конфига шли черезos.Createв/var/lib/hy2xs-admin/export/, и файл там не удалялся. При?includeSecrets=trueэто означало расшифрованные секреты пиров — фактические учётные данные доступа — в открытом виде, накапливающиеся с каждым нажатием кнопки. Экспорт формируется в памяти; каталогаexport/больше нет. -
Generic export/import таблицы
configвыгружал и позволял подменить криптографические ключи приложения. Выгрузка исключала только сырой Hysteria YAML, а в той же таблице лежатJWT_SECRET,PEER_SECRET_KEY,PEER_SECRET_ENCRYPTION_KEYиHYSTERIA2_TRAFFIC_STATS_SECRET. Импорт их не блокировал: подменаPEER_SECRET_ENCRYPTION_KEYломает расшифровку секретов уже существующих пиров. Оба маршрута и их UI удалены — production-сценария у них не было, перенос пиров делаютpeer-import/peer-export. -
Импорт пиров шёл мимо всей валидации. Обычное создание пира проходит через
dto.PeerSaveDto, импорт JSON — нет: в базу попадало имя любой длины и с любыми символами,disabledс произвольным числом, отрицательные счётчики. Файл применялся построчно, поэтому ошибка в середине оставляла список пиров наполовину изменённым, а импорт мог перезаписатьbootstrap-admin-peer, чей секрет продублирован в/etc/hy2xs/bootstrap-admin.secret. Партия теперь проверяется целиком до первой записи, неизвестные поля отклоняются, bootstrap-пир защищён. -
DNS проверялся на существование A-записи, но не на то, куда она ведёт. После принудительной смены IPv4 провайдером
doctorотвечал успехом, хотя клиентская ссылка отправляла людей на чужую машину. Проверялся при этомHY2XS_DOMAIN, тогда как вhysteria2://уезжаетHY2XS_PUBLIC_HOST.Добавлен инвариант публичного endpoint: A-записи обязаны принадлежать множеству публичных IPv4, назначенных интерфейсам этого сервера. Проверка живёт в общем
preflight, поэтому действует вinstall,reconfigureиdoctor. Адрес определяется локально, без внешних сервисов определения IP. Строгость управляетсяHY2XS_PUBLIC_ENDPOINT_POLICY(strictпо умолчанию). -
Read-only guard PHASE 0 можно было обойти. Guard стоял на
writeText,writeTextAtomic,runVisible,runHiddenиrunRawVisible, но не на универсальномrun, через который в коде проходили и наблюдение (ss,systemctl is-active), и настоящие мутации (useradd,install -d,mkdir,cp -a,tar). Универсального раннера больше нет: естьrunReadOnly*без guard'а иrunMutating*под guard'ом, а выбор — явное решение на месте вызова. -
Go-санитайзер конфига вырезал секреты из URL только у ключей
url/addr. Будущее upstream-поле с другим именем (endpoint:) уносило встроенные учётные данные иaccess_tokenнаружу целиком; URL внутри списков не обрабатывались вовсе. Граница определяется значением, а не именем ключа — как в TS-санитайзере оркестратора; обе реализации покрыты зеркальными тестами. -
purge-v0.sh --keep-hysteria-binaryпротиворечил установщику. Скрипт сохранял/usr/local/bin/hysteriaи сообщал «хост чист для установки HY2XS v1», хотя clean-host контракт считает этот бинарник legacy-маркером и следующая установка отказалась бы. Флаг удалён. -
clean-host не замечал часть того, что удаляет purge.
/var/lib/hysteria(ACME-состояние и сертификаты Hysteria),/var/log/hy2xs,/usr/local/lib/hy2xsи/usr/local/bin/hy2xs-orchestratorне были маркерами: сервер, где остался только старый runtime-state Hysteria, проходил проверку и получал свежую установку поверх чужого состояния. Оба списка теперь описывают одну границу, и приёмка это проверяет. -
Установщик мог повредить работающий сервер до того, как откажется его трогать.
install.shпереписывал/usr/local/lib/hy2xs, раскладывал runtime-пакет и перезаписывал/var/lib/hy2xs/install-state.json, и лишь потом запускал clean-host preflight. При ошибочном запуске поверх старого сервера rollback дополнительно выполнялstopиdisableдля работающихhysteria-serverиhy2xs-admin.Установка разделена на две фазы с жёсткой границей: PHASE 0 — read only, PHASE 1 — mutation. Read-only проверка выполняется новой командой
hy2xs-orchestrator preflight-installиз распакованного архива, а граница держится runtime-guard'ом, а не соглашением. -
Отсутствие
HY2XS_CONFIG_SCHEMA_VERSIONсчиталось текущей схемой. До v1 этого поля не существовало, поэтому именно пустое значение — самый вероятный признак конфигурации0.x. Теперь оно отклоняется как legacy с указанием на чистую установку. Тест, закреплявший прежнее поведение, инвертирован. -
reconfigureиrepairработали поверх любого маркера установки. Проверялся только флагinstalled, который мог остаться и от0.x. Маркер получил идентификацию поколения (product,release_line,config_schema_version), и обе команды проверяют её до всего остального. -
Классификация отказа шла по тексту сообщения об ошибке. Ошибка preflight со словом
nftablesклассифицировалась как отказ firewall и приводила к откату чужого ruleset. Теперь классификация опирается на то, что операция реально успела применить.systemctl stop/disableвыполняется только для юнитов, развёрнутых текущей операцией, аfatal_pre_applyпо определению не выполняет системный откат и не собирает diagnostics-бандл. -
Diagnostics-бандл уносил machine token наружу. Построчное правило редакции
auth:подставляло маркер в заголовок mapping'а и оставляло нетронутым вложенныйauth.http.urlсaccess_token=<секрет>— тем самым, что открывает и trafficStats API, и auth-endpoint. Редакция YAML переписана структурно; в env-файлах секреты теперь вырезаются и из URL-значений (HY2_AUTH_URLне подходил ни под один маркер имени). -
quic.maxIdleTimeoutне проверялся семантической проверкой конфига, хотя присутствовал в production-профиле. Заодноauth.http.urlтеперь сверяется целиком (host/port/path/token), а не по наличию подстрокиaccess_token=; добавлены проверкиauth.http.insecure, полей ACME и отсутствия посторонних секций верхнего уровня. -
Версия админки разъехалась с версией пакета: пакет
1.0.0сообщалHY2XS admin version v0.0.22. Константа заменена переменной, которую проставляет сборка через ldflags изversions.env. -
Кнопки в панели, которые всегда возвращали ошибку. «Перезапустить панель» и загрузка сертификатов обращались к заглушкам. Маршруты и UI удалены.
Добавлено
-
versions.env— единственный источник истины для контракта «продукт / платформа / toolchain»: версия продукта, линия релиза, схема конфигурации, целевая платформа, версии и контрольные суммы Go/Bun/Node/pnpm, политика выбора Hysteria. Прикладные зависимости и конкретная версия Hysteria сюда намеренно не переносятся: у них есть собственные lock-механизмы. -
Шаг сборки
verify_versions_contract. Роняет сборку до создания tarball, если разошлисьPACKAGE_VERSION,packageManagerв двухpackage.json, схема вpackage/config/hy2xs.env, константы, скомпилированные в оркестратор, директиваgoвapps/go.mod, metadata пакета или версия, которую сообщает собранныйhy2xs-admin. -
Контрольные суммы toolchain в контракте, включая обе сборки Bun (
bun-linux-x64иbun-linux-x64-baseline): артефакт выбирается по наличию AVX2, поэтому одной суммы архитектурно недостаточно. Передавать суммы через окружение больше не нужно — production-сборка запускается одной командой. -
Проверка происхождения артефакта Hysteria. Ожидаемый SHA-256 берётся из upstream-ассета
hashes.txtи сверяется со скачанным бинарником до записи в HY2XS lock. Раньше сумма считалась локально от уже скачанного файла, то есть была trust-on-first-use. -
Полный clean-host контракт. Список маркеров чужой установки расширен с двух до четырнадцати: состояние, runtime-пакет, конфиги, бинарник Hysteria, фрагмент nftables, systemd-юниты, база админки и наследие
0.x. Пути установки и данных берутся из конфигурации, а не захардкожены. -
tools/legacy/purge-v0.shи docs/14-legacy-cleanup.md — явная очистка сервера от предыдущего поколения. По умолчанию скрипт показывает план и ничего не делает; выполнение требует--apply --yes-i-know. Из установщика он не вызывается никогда: это вернуло бы destructive migration logic в путь свежей установки. -
Явный флаг
--allow-partial-stateдляrepair. Прежде согласие на работу поверх незавершённой установки подразумевалось молча. -
HY2XS_PUBLIC_ENDPOINT_POLICY(strict|warn|off, по умолчаниюstrict) — строгость проверки того, что публичный endpoint ведёт на этот сервер. Ослабление предназначено для топологий вне baseline: NAT, floating IP, anycast. Отсутствие A-записи фатально при любом значении. -
Раздельные API подпроцессов в оркестраторе:
runReadOnly/runReadOnlySecretдля наблюдения иrunMutating*под read-only guard'ом. -
Сверка API namespace на сборке. Путь machine-auth и базовый префикс админского API объявлены по одной константе на компонент, а
verify_versions_contractсверяет Go, фронтенд и шаблоны против значений, скомпилированных в оркестратор.
Изменено
-
Пространства имён HTTP API. Операторский и auth API переехали с
/huiна/api, machine-auth endpoint Hysteria — на/internal/hysteria/auth. Прежний общий префикс был наследием H UI: под ним лежали и machine-to-machine auth, и JWT-защищённый админский API, хотя middleware у них не пересекаются. Момент выбран до первого clean-install релиза: после1.0.0эти строки стали бы частью фактического v1 compatibility contract. -
Сетевая идентичность админки принадлежит оркестратору. Ключи
H_UI_WEB_PORT,H_UI_WEB_CONTEXT,H_UI_CRT_PATH,H_UI_KEY_PATHудалены из схемы, seed и интерфейса вместе с собственным TLS-слоем панели. Раньше оркестратор передавал порт аргументом, админка записывала его в SQLite и тут же читала обратно, а UI показывал поля в disabled-виде: второй источник истины, из которого ничего нельзя было изменить. Панель всегда монтируется в/. -
HUI_DATA/HUI_LOG→HY2XS_DATA_DIR/HY2XS_LOG_DIR. Мост в systemd-юните, перекладывавший canonical env HY2XS в имена старого H UI, удалён. -
Экспорт пиров разделён на два явных режима. «Экспорт настроек» — без секретов, «Резервная копия» — с ними, через подтверждение с описанием риска.
Кнопка была одна и всегда звала маршрут без
includeSecrets, хотя документация называла эту пару механизмом переноса пиров. Записи с пустым секретом при импорте получают новые секреты, поэтому перенос обычным экспортом восстанавливал пиров, но все существующие клиентские ссылки после него переставали работать. Разница продуктовая, и оставлять её неявной нельзя. -
reconfigure/repairбольше не классифицируют отказ по тексту ошибки. Записываемая фаза выбиралась регулярным выражением/firewall|nft|ssh port check failed/iпо сообщению — тот же приём, который уже убрали изinstall. Классификация переведена на ownership-флаги, а откат firewall выполняется только если эта операция его трогала. -
Список непубличных IPv4 приведён к IANA Special-Purpose Address Registry. Функция называлась «маршрутизируемый публичный IPv4», а исключения покрывали только приватные диапазоны:
203.0.113.5(TEST-NET-3 из RFC-примеров) считался нормальным публичным адресом сервера. Добавлены документационные (192.0.2/24,198.51.100/24,203.0.113/24), benchmarking (198.18/15), 6to4-anycast и IETF protocol assignments. -
Отказ DNS-резолвера отличается от отсутствия A-записи. Любая ошибка
resolve4печаталась как «has no A-record», поэтому при сломанном/etc/resolv.confоператор шёл править запись, которая была на месте.ENODATA/ENOTFOUND/NXDOMAIN— это «нет записи», всё остальное — «резолвер не ответил», с отдельным текстом. Фатальны оба: без ответа резолвера проверка не выполнена, а не «выполнена с замечанием». -
База админки —
hy2xs-admin.dbвместоh_ui.db; reference-схема —apps/docs/sql/schema.sqlвместоh_ui_db.sql. Совместимость сохранять не требуется: v1 ставится только с нуля. Историческое имяh_ui.dbостаётся в docs/14-legacy-cleanup.md — там это имя чужого артефакта, который очистка должна найти. -
Индикатор загрузки и legacy-цвета переведены на брендовый токен. NProgress приходил со своим
#29dи был единственным элементом интерфейса вне палитры HY2XS; страницы401/404и подсветка выбранной строки таблицы несли цвета исходного admin-шаблона. Все они привязаны к--el-color-primary, а не переписаны вторым литералом. -
Приёмка сверяет схему конфигурации с
versions.env, а не с числом2в тексте проверки. -
E2E подключается по ссылке из production-кода. Внутри
tools/test/e2e-hysteria.shжила вторая реализацияhysteria2://URI на bash: дрейф любой из двух реализаций оставлял обе группы тестов зелёными. Теперь ссылку выдаётservice.BuildHysteria2ShareURIчерезapps/tools/share-uri. Единственное расхождение —insecure=1для самоподписанного сертификата, и оно ограничено тестами с двух сторон. -
Формулировка гарантии санитайза экспорта. Вместо «любой будущий секрет будет удалён» — «известные секреты и неизвестные поля с секретоподобным именем». Список маркеров расширен (
apiKey,privateKey,authorization,cookie,bearer,passphrase,signature, …) и синхронизирован между Go-админкой и оркестратором.
Удалено
-
Маршруты, операциями которых продукт не владеет:
POST /hysteria2ChangeVersion,GET /listRelease,POST /config/updateHysteria2Config,POST /config/importHysteria2Config,POST /config/restartServer,POST /config/uploadCertFile,GET /config/hysteria2AcmePath. Вместе с ними — соответствующие сервисы, клиентские функции фронтенда, кнопки и строки i18n.Маршруты удалены, а не оставлены отвечающими «feature disabled»: API-контракт не должен обещать updater, которого у продукта нет, а неиспользуемый маршрут остаётся attack surface.
-
POST /config/exportConfigиPOST /config/importConfig— generic-выгрузка и загрузка таблицыconfigвместе с криптографическими ключами приложения. Вместе с ними — кнопки Import/Export в настройках, клиентские функции и строки i18n. -
Персистентный каталог выгрузок
/var/lib/hy2xs-admin/export/и файловый helperutil.ExportFile. Артефакт, который покидает сервер, не должен существовать на сервере дольше самого запроса. -
Флаг
purge-v0.sh --keep-hysteria-binary. -
Мёртвые строки i18n, оставшиеся от H UI:
noHttpsTip,defaultPassTip,hui*,useHysteria2Cert,invalidWebContext,mustBeInteger. -
GET /api/config/getConfig— точечное чтение произвольного ключа таблицыconfig. Потребителей у маршрута не было ни одного, а список ключей в этой таблице включаетJWT_SECRET,PEER_SECRET_KEYиPEER_SECRET_ENCRYPTION_KEY. Вместе с ним удаленыdto.ConfigDto, клиентская функцияgetConfigApiи её тип. -
Compatibility-слой аккаунтов предыдущего поколения: сущность
entity.LegacyAccount, миграции002_migrate_legacy_accountsи003_archive_legacy_account, а также мёртвые helperslistSQLMigrationFilesиenvInt. HY2XS v1 не мигрирует базу0.xни при каком сценарии, и clean-host контракт отказывает ещё до создания базы — живого пути, по которому таблицаaccountмогла бы оказаться вhy2xs-admin.db, не существует. Номера оставшихся миграций сохранены: перенумерация заставила бы их примениться повторно.В
docs/14-legacy-cleanup.mdимена предыдущего поколения остаются — там они обозначают реальные объекты, которые нужно удалить с сервера. Из остальных v1-доков этот словарь убран.
1.0.0 — 2026-08-27
Первый релиз линейки v1.
Обновление с
0.xне поддерживается. Между0.xи1.0.0изменились схема конфигурации, тип обфускации по умолчанию и контракт выбора версии Hysteria. Сервер, установленный из пакета0.x, нужно поднимать заново: очистка и установка с нуля. Подробности — в разделе «Миграция с 0.x» ниже.
Добавлено
- Разрешение версии Hysteria на этапе сборки. Builder по умолчанию сам
определяет последний стабильный upstream-релиз, скачивает артефакт,
вычисляет SHA-256 и замораживает
version+url+sha256в metadata пакета. Target-сервер по-прежнему скачивает конкретный неизменяемый артефакт и никогда не обращается к movinglatest. - Compatibility gate в сборке. До создания release-пакета builder рендерит канонический конфиг HY2XS тем же кодом, что и оркестратор, и запускает с ним реальный бинарник Hysteria — для обоих профилей обфускации. Несовместимый upstream ломает сборку, а не сервер оператора.
- Поддержка Gecko-обфускации (Hysteria 2.9.2+) со сквозной интеграцией:
оркестратор, шаблон конфига, модель админки, генерация
hysteria2://URI, типы и формы фронтенда. - Версия схемы конфигурации
HY2XS_CONFIG_SCHEMA_VERSION=2. Пакет отказывается работать с конфигурацией неизвестной схемы вместо того, чтобы молча применить чужие значения. - Современный серверный baseline в генерируемом конфиге:
congestion.type: bbr+bbrProfile: standard,bandwidth.disableLossCompensation: false,quic.disableStatelessReset: false, а такжеmaxIdleTimeout,maxIncomingStreams,disablePathMTUDiscovery. - Модель современной схемы Hysteria в админке:
obfs.gecko,ech,congestion,mimic,realm,tls.clientCA,quic.disableStatelessReset,bandwidth.disableLossCompensation,masquerade.proxy.xForwarded. Поля читаются и отображаются, даже если HY2XS не включает их в default-профиль. - Тесты оркестратора (
bun test): разбор env, рендер конфига, семантические инварианты профиля, резолвер upstream-релизов, release rollover. - E2E-проверка с реальным клиентом Hysteria —
tools/test/e2e-hysteria.sh: TLS и obfs handshake, HTTP auth (допуск и отказ), TCP и UDP forwarding, trafficStats, per-peer accounting, переподключение после перезапуска сервера и подключение клиента именно по сгенерированной ссылке. CHANGELOG.mdв корне репозитория.
Изменено
- Обфускация по умолчанию для новых установок — Gecko. Salamander
остаётся полностью поддержанным режимом совместимости и выбирается через
HY2XS_HYSTERIA_OBFS_TYPE=salamander. - Gecko использует upstream-defaults
512/1200и не выносит размеры пакетов в env: официальная URI-схема не умеет их передавать, поэтому нестандартные значения сделали бы клиентскую ссылку неполной. - Тип обфускации больше не собирается внутри статического YAML.
Оркестратор формирует проверенный
obfs-блок целиком, поэтому комбинация видаtype: geckoрядом с блокомsalamanderструктурно невозможна. - Экспорт конфига Hysteria работает от исходного YAML, а не от типизированной модели: поля, о которых HY2XS ещё не знает, переживают выгрузку.
- Smoke-проверки разбирают YAML и сверяют его с production-профилем, вместо поиска подстрок.
- Канонический upstream-репозиторий —
HyNetworks/hysteria(вместо устаревших ссылок наapernet). - Реестр ACME DNS-провайдеров во фронтенде приведён к актуальному
upstream: добавлены
namecheap,njalla,porkbun. - Дефолты формы Hysteria во фронтенде отражают baseline HY2XS (50/50 Mbps, Let's Encrypt, каталог ACME), а не пример из upstream-доки.
- Сборка запускает тесты оркестратора и админки до упаковки.
Исправлено
hysteria2://для Gecko. Генератор ссылок был завязан наObfs.Salamander.Password, поэтому при любой другой обфускации выдавал формально корректную, но неработающую ссылку без параметровobfs.- SNI в клиентской ссылке при файловых сертификатах. SNI брался только
из ACME-блока, поэтому при
HY2XS_TLS_MODE=fileуходил пустым. Теперь источник — ACME-домен, затемHY2XS_DOMAIN, затемHY2XS_PUBLIC_HOST; IP-адрес в качестве SNI не используется. - Утечка секретов в экспорте конфига. Выгружаемый оператору YAML
содержал
trafficStats.secret,access_tokenв auth-URL и пароль обфускации. Секреты вырезаются, включая поля, о которых HY2XS ещё не знает. - Расхождение runtime-конфига с разобранным.
renderRuntimeEnvпечатал тип обфускации и режим auth литералами, игнорируя фактическую конфигурацию, — из-за чего запись/etc/hy2xs/hy2xs.envмогла разойтись с тем, что реально применено. - Смешение веток
obfsв UI. Форма склеивала дефолт с ответом API и показывала блок обфускации, которого нет в конфиге сервера. namedotcomв списке ACME DNS-провайдеров. Провайдер удалён из Hysteria в 2.11.0; конфигурация с ним больше не запускается.- Отсутствие подсказок в форме создания пира. У полей «Пир», «Комментарий» и «Секрет» не было ни примеров, ни пояснений: оператор не мог понять без документации, что секрет необязателен и генерируется автоматически.
Безопасность
- Переход на Hysteria 2.12.2 закрывает исправления, вышедшие после 2.8.2, включая обход UDP ACL, возможный OOM через sniff и обход ACL через домены с завершающей точкой (2.9.2).
- Экспорт конфига больше не выносит секреты за пределы сервера.
Миграция с 0.x
Автоматическая миграция не предусмотрена и не планируется.
Порядок перехода:
- Выпишите с работающего сервера список пиров и их секреты.
- Очистите сервер:
tools/legacy/purge-v0.shили ручная процедура из docs/14-legacy-cleanup.md. - Разверните
1.0.0на чистом Debian 13 из release-пакета. - Заведите пиров заново и раздайте новые клиентские ссылки.
Установщик 1.0.0 обнаружит остатки предыдущей установки на шаге PHASE 0,
откажется работать и не изменит на сервере ничего.
Клиентские ссылки 0.x в любом случае перестанут работать: смена
обфускации — это изменение wire-совместимости.