7d486af712607076dd939b38d6ba42669520896c
115 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7d486af712 |
fix(frontend): patch vulnerable browserslist dependency
pnpm audit по всему lock-графу остановил релизную сборку: browserslist@4.21.11 несёт high-advisory (уязвимы <= 4.28.6) и приходит транзитивно через autoprefixer и update-browserslist-db. Закрыто точечным pnpm.overrides на 4.28.7 — точной версией, а не диапазоном: security-патч обязан быть детерминированным и не тащить за собой чужой major. Обновилось только поддерево browserslist (caniuse-lite, electron-to-chromium, escalade, node-releases, update-browserslist-db); autoprefixer, Vite и остальной граф не тронуты. Проверено с pnpm 9.15.9 (contract из packageManager): pnpm why browserslist -> 4.28.7 pnpm audit --audit-level high -> 0, no known vulnerabilities pnpm install --frozen-lockfile -> lockfile is up to date typecheck + build:prod -> ok |
||
|
|
b9d3c03f8d |
fix(admin): считать достижимым только тот адрес Traffic Stats API, который админка действительно опрашивает
Проверка принимала любой ip.IsLoopback(), то есть считала рабочим и 127.0.0.5. Это неверно: слушатель на конкретном адресе принимает соединения только на него, а слой proxy обращается строго к http://127.0.0.1:<port>. bind 127.0.0.5:38712 -> dial 127.0.0.1:38712 -> connection refused bind 0.0.0.0:38713 -> dial 127.0.0.1:38713 -> connected Такой адрес выглядел локальным, ломал контур доступа целиком (лимит устройств fail-closed => не подключается никто) и не вызывал у админки ни одного возражения. Принимаются ровно 127.0.0.1, 0.0.0.0 и пустой хост. IPv6-wildcard не принимается сознательно: соединение он принял бы, но HY2XS объявлен IPv4-only, а зависеть в ответе «достучусь» от net.ipv6.bindv6only нельзя. На странице конфигурации мягкое состояние nonCanonicalLoopback убрано: прочий loopback — это ошибка, а не предупреждение. Осталось три состояния: канон профиля, wildcard, недостижим. Свойство закреплено тестом с настоящими сокетами, а гейт приёмки запрещает возврат IsLoopback() и требует негативного случая 127.0.0.5 в тестах. |
||
|
|
b315001288 |
fix(admin): различать достижимость Traffic Stats API и соответствие профилю
Признак на странице конфигурации отвечал только на вопрос «достучится ли админка», поэтому 0.0.0.0 показывался как норма — хотя внутренний control plane при нём опубликован на всех интерфейсах, а оркестратор такой конфигурации не создаёт. Состояний теперь четыре: канон профиля, wildcard, не-канонический loopback и недостижимый адрес. Backend не тронут: он по-прежнему отвечает только на вопрос достижимости — превращать лишнюю публикацию в отказ обслуживания значило бы отключить всех пиров. Исправлено ложное утверждение в его комментарии: пустой хост `:36712` в Go означает все интерфейсы, а не loopback. Удалены мёртвые фразы common.wait/enableSuccess/disableSuccess — остатки операций запуска, остановки и смены версии Hysteria, которых у панели нет. |
||
|
|
cb20d8d28f |
fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному
Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним, поэтому сессия, установленная по отозванным учётным данным, была неотличима от законной, и цикл учёта не имел признака, по которому её следовало завершить. У состояния есть путь без единой неудачи — Hysteria регистрирует соединение в Traffic Stats API только после возврата backend-auth, поэтому успешный /kick может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт по старому, пережившая сессия становится orphan. Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4, админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала лимит устройств, учёт трафика и принудительное отключение разом. Адрес зафиксирован, а расхождение файла с ним админка называет. Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним значением, а доступность Traffic Stats API выводилась из него же. Журнал Hysteria разбирается в фактическом формате upstream (time — дробное число), страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в браузер, санитайзер выгрузки следует по YAML-якорям. Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md |
||
|
|
8dcb50a07c |
fix(admin): дать отзыву доступа вторую попытку, а лимиту устройств — порядок снимков
Предыдущий проход сделал правильным порядок «сначала долговременная запись, потом разрыв сессии» и правильно запретил откат при неудаче разрыва. Способа прийти к согласованному состоянию ПОТОМ он не дал: у двух операций повтор не работал вовсе. Импорт, заменивший auth_id: после неудавшегося /kick старое значение не хранится нигде, повтор того же файла читает из базы уже новое и рвёт его, а cron пропускал незнакомый authID молча — dao.ListPeer просто не возвращала строку. Живая сессия оставалась навсегда. Снижение maxDevices: повтор формы даёт 1 < 1 -> false, разрыва больше нет. Лимит устройств в политику доступа не входит и входить не должен — это свойство сессий, — поэтому механизма схождения у него не было. enforcePeerAccess стал сверкой живых сессий: обход идёт по каждому authID из /online. Нет строки в базе -> kick; peerAccessDenied -> kick; непригодный maxDevices -> kick; устройств больше разрешённого -> kick. Отказ базы при этом не рвёт ничего. Ни таблицы отложенных операций, ни очереди retry: список живых сессий уже есть, и это /online. Отдельно закрыт второй TOCTOU лимита устройств. Учёт выданных разрешений закрыл сравнение двух одинаковых снимков, но сетевой запрос выполнялся вне блокировки, поэтому снимки приходили в резервацию в произвольном порядке и устаревший откатывал lastOnline назад, возвращая уже занятое место. Это не data race — память защищена мьютексом, и -race здесь молчит принципиально. Последовательность «прочитать /online -> занять место» выполняется под замком по authId; глобальный замок не годится, внутри идёт сетевой запрос. Учёт разрешений больше не растёт бесконечно: запись снималась только на ветке отказа, поэтому в карте копились удалённые пиры и переписанные импортом идентификаторы. Уборка идёт по фактической картине подключений. Гейты приёмки доращены под все три инварианта и проверены в обе стороны. Go 1.26.7 -> 1.26.8. Документация приведена в соответствие в двух местах, где описывала снятую архитектуру. Разбор: docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md |
||
|
|
6d1686b2be |
fix(admin): свести access-control к одному правилу и одному пути отзыва
Второй разбор того же слоя, уже по состоянию после
|
||
|
|
162759c599 |
fix(admin): достроить вторые половины отзыва доступа, лимита и журнала
Разбор кода на
|
||
|
|
c0a43ae915 |
fix(admin): закрыть обещания панели, которые продукт не выполнял
Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.
Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».
Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.
Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.
Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.
Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.
Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.
docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
|
||
|
|
a1f0db22c2 |
fix(build): гейт классификации reconfigure описывал прежнюю архитектуру
Проверка требовала литеральное `classifyReconfigureFailure(ownership)`, тогда как у функции давно два параметра. Второй появился вместе с типизированным распознаванием сработавшего guard: по владению он неотличим от обычного отказа smoke — тронут firewall, перезапущены сервисы, — но чинить надо другое, потому что сервер уже вернулся на ПРЕЖНИЙ firewall. То есть гейт утверждал не тот контракт, который назван в его же заголовке, и падал на коде, который этот контракт соблюдает. Поведенческие тесты при этом были и остаются зелёными: «текст ошибки на классификацию не влияет» и «сработавший guard опознаётся по типу ошибки». Проверка приведена к фактической форме, заголовок — к фактической архитектуре. `error instanceof FirewallGuardFiredError` намеренно не дублируется: тот же инвариант проверяется ниже, в «a fired guard forbids the durable commit», и для install, и для reconfigure. Заодно прогнаны ВСЕ гейты приёмки по текущему дереву, а не только упавший: 116 положительных литеральных проверок, 36 bun-блоков с текстовыми инвариантами и все отрицательные сканы. Кроме этого одного — расхождений нет.v1.0.0-rc1 |
||
|
|
d32811b804 |
fix(build): отрицательные сканы приёмки проверяют форму кода, а не прозу
Комментарий, объясняющий, почему чего-то больше нет, обязан называть это по
имени. Скан по голой подстроке такой комментарий от кода не отличает и падает
на документации к выполненной им же работе. Найдено три таких гейта, все на
пути ближайшей сборки:
скан иконок -> блочный комментарий в SvgIcon/sprite.ts
скан имён раннеров -> слово `systemd-run` в прозе firewall.ts
скан cancelFirewall... -> комментарий о разделении функции
Второй сломан моим же комментарием из
|
||
|
|
a1c74caa0c |
fix(build): исключить SIGPIPE из релизных гейтов под pipefail
Поиск с флагом -q прекращает чтение на первом совпадении и закрывает свой конец
канала. Продюсер, которому осталось что писать, получает SIGPIPE и завершается
кодом 141, а `set -o pipefail` делает 141 статусом всей конструкции:
совпадение НАЙДЕНО -> продюсер оборван -> статус 141 -> «не найдено»
Для утвердительных проверок это ложный FAIL. Для отрицательных — «такой
конструкции в коде нет» — ложный PASS: запрещённая конструкция найдена, а гейт
зелёный. Отрицательными проверками закреплена половина инвариантов приёмки,
включая запрет обхода тестов и запрет `pnpm audit --prod`.
Порог резкий: пока вывод продюсера помещается в буфер канала (64 KiB на Linux),
он не блокируется и успевает завершиться раньше, чем потребитель начнёт читать.
Замер, 60 прогонов на размер: до 60 KiB — 0 отказов, ровно на 64 KiB — 58/60,
от 96 KiB — 60/60. То есть проверка выглядит исправной ровно до первого
источника крупнее буфера, а такие файлы в репозитории уже есть.
- 56 мест переведены на here-string: `grep -q PATTERN <<<"$content"`;
- продюсеры-команды (ss|awk, dpkg-query, /proc/cpuinfo, systemctl
list-unit-files) сначала читаются в переменную;
- введён code_has: десять отрицательных сканов держались на `|| true` внутри
code_without_comments, гасившем 141, — то есть на побочном эффекте
подавления ошибок, а не на заявленном свойстве;
- несуществующий путь в скане больше не означает успех: `2>/dev/null || true`
превращал опечатку в пустой вывод, а пустой вывод для проверки «этого в коде
нет» — это PASS. Проверка явная, а не через set -e: в контексте `! code_has`
bash отключает errexit на весь вызов;
- возврат пайплайна запрещён отдельной приёмкой.
|
||
|
|
2259f7c847 |
firewall guard: барьер покоя fail-closed и явный контракт транзиентного таймера
Барьер, обязанный ДОКАЗАТЬ отсутствие асинхронного исполнителя, в трёх местах принимал за доказательство отсутствие наблюдения. - отказ `systemctl` больше не выдаётся за отсутствие guard: вместо `return []` введён единый наблюдатель inspectRollbackGuard с исходами quiescent/pending/ unknown и отдельным типом отказа GuardStateUnknownError; - покой перечисляется белым списком (inactive, failed): maintenance, refreshing и любое незнакомое состояние systemd блокируют операцию; - у транзиентного таймера явно заданы AccuracySec=1s (умолчание 1min превращало обещанные 45 секунд в 45-105) и RemainAfterElapse=no; барьер дополнительно опознаёт SubState=elapsed у *.timer как покой; - команда взведения строится чистой buildArmGuardArgv и выполняется новым runMutatingArgv без shell, поэтому её контракт проверяется значением, а не грепом по исходнику; - status перестал листить guard-юниты своей копией кода: без --plain, с `|| true` и с трактовкой failed как «вооружён» отчёт вечно противоречил барьеру. Добавлены rollback_guard_state и firewall_state=guard_unknown; - purge-v0.sh пропускал failed-юниты из-за маркера в первой колонке. Барьер покрыт поведенческими тестами через подставляемый SystemdUnitProbe: прежние проверки грепом по тексту функции пережили инверсию смысла - строка `return [];` была на месте, а решение стало неверным. Документация (README, docs/07, 11, 12, 13, 14, CHANGELOG) приведена к реальному окну 45-46 секунд и к новому тексту отказа. Отдельно исправлен комментарий PNPM_AUDIT_LEVEL в versions.env: гейт давно проверяет весь lock-граф. |
||
|
|
76d78ac71f |
fix(orchestrator): закрыть два остатка на стыке guard и замка операций
Оба дефекта — в механизмах, введённых предыдущими коммитами, и оба относятся к
гарантиям, ради которых эти механизмы вводились.
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) — там же.
|
||
|
|
0230f1ca99 |
fix(orchestrator): не полагаться на мангление имени транзиентного юнита
systemd-run пропускает голое имя через unit_name_mangle_with_suffix, который сначала проверяет, не заканчивается ли оно уже известным типом юнита. Ключ операции — санитизированный ISO-timestamp вида `...T12-34-56.789Z`, то есть содержит точку, и корректность имени зависела бы от того, что `.789Z` случайно не совпало ни с одним типом systemd. Имя передаётся с явным суффиксом `.service`: systemd-run берёт его как есть и создаёт рядом одноимённый `.timer`, на который и рассчитывают снятие guard и status. |
||
|
|
39139e95f7 |
docs: описать транзакционный guard и взаимное исключение операций
- docs/07: полный порядок staged apply, инвариант снятия guard, объяснение почему окно 45 секунд не обязано покрывать smoke и почему guard не трогает nftables.service, семантическая проверка эффективного firewall; - docs/11: разделы A5e/A5f для новых unit-тестов и серверные сценарии D1e (guard доходит до дедлайна), D1f (конкурентные операции), D1g (успешная операция не оставляет следов транзакции); матрица и acceptance criteria дополнены; - docs/12: разбор отказов "уже выполняется другая операция" и firewall_guard_fired; - docs/13: строки журнала guard в таблице recovery, новый раздел 8a про замок операций; - docs/14 и purge-v0.sh: очистка /run/hy2xs, замка операций и candidate-файлов firewall — /run это tmpfs, но очистка не имеет права требовать перезагрузки; - README: защита от потери доступа при смене firewall и раздел "Одна операция за раз"; - CHANGELOG: шестой проход. |
||
|
|
50ec4d9717 |
build(acceptance): закрепить границы транзакции гейтами сборки
Инварианты, добавленные двумя предыдущими коммитами, обязаны жить в сборке, а не в намерении. - скан заглушённых ошибок отката расширен на остановку rollback guard: именно этот диапазон он не покрывал, поэтому `systemctl stop ... || true` прожил дольше всех остальных `|| true` в откате. Заодно покрыт rollbackFirewallNow целиком — правая граница null означает "до конца файла"; - скрипт автоотката проверяется как текст: маркер срабатывания первым действием, отсутствие маскировки, накопление rc, отказ трогать nftables.service; - снятие guard обязано проверять маркер с обеих сторон остановки и подтверждаться ActiveState, а не кодом возврата systemctl; - сработавший guard обязан иметь собственный тип ошибки и собственную причину отказа, и классифицироваться по типу, а не по тексту; - smoke обязан сверять эффективный firewall, а не только разбирать файл, и делать это read-only раннерами: та же проверка выполняется в doctor; - порядок стадий отката firewall проверяется явно: ExecStop у nftables.service делает `nft flush ruleset`, поэтому восстановление состояния сервиса обязано идти до применения ruleset; - политика замка операций: кто берёт, кто не берёт, снятие в finally и по SIGINT/SIGTERM/SIGHUP, безопасное переиспользование замка мёртвого держателя; - формула ключа операции обязана существовать в одном файле. |
||
|
|
d72550e11f |
fix(orchestrator): сериализовать операции жизненного цикла
У оркестратора не было никакой блокировки операций: ни flock, ни mutex, ни lockfile. Вся архитектура отката при этом опиралась на невысказанное допущение, что в каждый момент выполняется ровно одна операция HY2XS. install-state.json замком не является — это запись о состоянии, а не право на изменение. Два одновременных reconfigure спокойно доходили до конца каждый по-своему, и уникальные op-id не спасали: они разделяют резервные копии, но production paths общие — /etc/hysteria/config.yaml, unit-файлы, /etc/nftables.conf, install-state.json. Дальше любая из операций могла упасть и "восстановить" состояние поверх изменений другой, отчитавшись при этом полным успехом: со своим манифестом она действительно сверилась. Отдельно опасен firewall: обе операции независимо взводят транзиентные rollback-юниты, и guard одной способен снять правила другой. Введён эксклюзивный замок /run/lock/hy2xs-orchestrator.lock через атомарное создание с O_EXCL. Не flock(2): прямого биндинга в рантайме нет, а держать замок подпроцессом означало бы сторожевой процесс на каждую операцию. - install/reconfigure/repair берут замок как мутирующие; - doctor тоже: диагностика в середине транзакции описывает промежуточное состояние и выдаёт бессмысленные ошибки; - status и diagnostics collect замок НЕ берут — они нужны в том числе во время долгой операции, — но сообщают, что операция идёт; - preflight-install отказывает сразу, до exec в install.sh. Замок снимается в finally, а также на SIGINT/SIGTERM/SIGHUP и при выходе процесса: обрыв SSH не имеет права заблокировать сервер до перезагрузки. Замок мёртвого держателя переиспользуется, но только через увод файла переименованием со сверкой nonce — снимать его на месте означало бы риск снять живой. Непонятое содержимое не снимается автоматически: оно не доказывает отсутствие операции, и сомнение трактуется в пользу отказа. |
||
|
|
4e7f54b9ff |
fix(orchestrator): сделать staged firewall guard транзакционным
Снятие автоматического отката firewall было утверждением, а не фактом:
systemctl stop <unit>.timer <unit>.service || true
-> "firewall rollback timer disarmed"
-> phase=installed
Отказ остановки стирался через `|| true`, и взведённый таймер мог вернуть
прежний firewall уже ПОСЛЕ долговечной записи успеха. Просто убрать `|| true`
нельзя: для транзиентного юнита, уже убранного systemd, `systemctl stop`
возвращает 5 — законный исход, неотличимый от успеха.
Соседний дефект того же корня: guard мог сработать ВО ВРЕМЯ успешного smoke.
Окно 45 секунд короче худшего случая smoke, а единственной проверкой firewall
был `nft -c` — разбор текущего файла, каким бы он ни был. Откатившийся прежний
ruleset проходил её зелёным, и сервер объявлялся настроенным с firewall,
который операция же и заменила.
Оба закрываются маркером /run/hy2xs/rollback/<op>/auto-rollback-fired, который
rollback-скрипт создаёт первым действием. Инвариант стал детерминированным:
маркер отсутствует И timer/service inactive => можно фиксировать успех
Остальное в том же проходе:
- auto-rollback переехал из однострочного `sh -c` в сгенерированный скрипт.
Прежний держался на склейке соседних кавычек и на том, что op-id не содержит
пробелов; теперь ключ проверяется, а скрипт покрыт тестом и shell-парсером;
- скрипт накапливает rc и уходит в failed вместо молчаливого 0 при частичном
восстановлении. Состояние nftables.service он сознательно не трогает:
ExecStop у него делает `nft flush ruleset`;
- smoke сверяет ЭФФЕКТИВНЫЙ firewall: фрагмент на диске против отрендеренного,
принадлежность entrypoint и загруженность таблицы inet hy2xs;
- откат восстанавливает enabled/active nftables.service — стадиями, идущими до
применения ruleset;
- остановка guard'а в rollbackFirewallNow стала стадией с отчётом, а не вызовом
с `|| true` внутри;
- `*.candidate` больше не остаются на диске навсегда;
- стадии восстановления reconfigure независимы по ОТДЕЛЬНОМУ ФАЙЛУ, а не по
группе;
- ключ операции считается одной функцией: install писал в маркер сырой
ISO-timestamp, и путь /run/hy2xs/rollback/<op_id> из runbook не существовал.
|
||
|
|
330a63b050 |
fix(v1): сделать надёжным нижний слой отката, а не только его запуск
Верхнеуровневый откат стал неотменяемым в прошлом проходе, и на этом фоне
проявилось, что его substrate этой надёжности не соответствует: откат
гарантированно запускался, но отдельные его шаги могли молча не выполнить
восстановление, отчитаться успехом и уничтожить резервную копию.
1. Данные для отката уничтожались ДО фиксации успеха (commit ordering).
cancelFirewallRollback снимала таймер автоотката И удаляла резервные копии
firewall, а вызывалась до долговечной записи phase=installed. Отказ этой
записи (ENOSPC/EIO/read-only ФС) приводил в обработчик ошибки, обязательный
откат честно запускался и сообщал "no HY2XS rollback markers found":
откатывать было нечем. Причём отказ записи маркера — ровно тот сценарий,
который прошлый проход специально сделал безопасным.
Разделено на disarmFirewallRollback (снять таймер, копии оставить) и
cleanupFirewallRollback (удалить копии). Порядок в install и reconfigure:
smoke_ok -> disarm -> durable installed -> cleanup best-effort.
2. Резервные копии снимались без доказательства.
И firewall, и reconfigure копировали как `cp ... || true`: отказ
игнорировался, операция шла менять систему без копии, на которую
рассчитывает откат. У firewall маркер prepared («данные для отката
существуют») выставлялся вообще ДО копирования. Копирование строгое, факт
создания проверяется, маркер ставится после.
3. Копии reconfigure смешивались между операциями.
Общий набор *.bak в /etc/hy2xs/backups не был привязан к проходу. Если у
операции B копирование падало, B всё равно менял систему, а его откат
восстанавливал файлы операции A — сервер возвращался в более старое
состояние и это выглядело успешным откатом. Копия стала операционной:
/etc/hy2xs/backups/<op-id>/ с манифестом, где отсутствие файла записано
явно ("present": false), а не выведено из неудачи cp. Разбор строгий,
включая проверку opId.
4. Ошибка восстановления скрывалась, и после неё копии удалялись.
rollbackFirewallNow выполняла cp и nft -f с `|| true`, затем безусловно
удаляла /run/hy2xs/rollback/<op>. Худшая комбинация: неудача не видна,
стадия успешна, данные для ручной починки уничтожены. Теперь копии
удаляются только после подтверждённого успеха, иначе сохраняются с
сообщением manual recovery data preserved at ...
5. Команды отката глушили собственный код возврата.
До стадийного раннера `|| true` был единственной защитой от обрыва цепочки;
после его появления стал маскировкой — стадия не могла сообщить, что
ничего не сделала. Убран; rollbackCurrentState разбита на семь независимых
стадий.
6. Долговечность записи каталога маркера.
writeTextAtomic синхронизирует файл и его каталог, но при первой установке
/var/lib/hy2xs создаётся тут же, и запись "hy2xs" в /var/lib оставалась
несинхронизированной. ensureDir сообщает о фактическом создании и
синхронизирует родителя только тогда.
Отдельно про doctor. Утверждение аудита, что doctor вызывает
UpdatePeerLastConnectionAt через успешную machine-auth, кодом не
подтверждается: проба с действующим паролем ограничена `context.mode ===
"install"`, а doctor работает в режиме reconfigure. Инвариант, однако, ничем не
охранялся — добавлены тест и приёмка. Документация уточнена: guard действует
внутри процесса, а границу «что doctor шлёт по сети» держит состав проб;
единственный остающийся след — записи в журнале админки, и это сказано прямо.
Тесты: backup-integrity.test.ts (манифест, строгий разбор, копия до мутации,
сохранение копий при неудачном восстановлении), commit-ordering.test.ts
(disarm/cleanup разделены, порядок фиксации в обеих командах). Три теста,
закреплявших прежний инвариант «каждая команда отката несёт || true»,
переписаны на обратный: команды обязаны сообщать о своих отказах.
|
||
|
|
df73459ea5 |
test(guard): закрепить создание каталога как запись под read-only guard
ensureDir — такой же примитив записи lib/fs, как writeText и writeTextAtomic, и guard обязан покрывать его наравне с ними. /var/lib/hy2xs, созданный до успешной PHASE 0, уже делает хост изменённым: следующая чистая установка опознает его содержимое как чужую установку. Покрытие guard'ом всех примитивов ФС собрано в одном файле: непокрытый примитив — это дыра в границе PHASE 0, и заметить её можно только там, где проверяется весь набор. |
||
|
|
b22b4b0d99 |
fix(v1): сделать read-only свойством doctor, а sentinel-ошибки — решением
Два свойства были описаны в документации, но не обеспечены кодом.
1. doctor «не изменяет диагностируемую систему».
Принудительный skipServiceStart закрывал ровно одну ИЗВЕСТНУЮ мутацию —
рестарт сервисов. Всё остальное в smoke держалось на том, что автор правки
выбрал правильный раннер: `test -s`, `grep -q`, `stat`, `sudo -u ... test`
и `nft -c` шли через мутирующий namespace, хотя ничего не меняют. Ожидание
между попытками выполнялось подпроцессом `sleep` через runMutatingHidden,
то есть пауза между двумя чтениями объявлялась изменением системы.
Следствие: настоящая мутация, случайно добавленная в smoke, ничем бы от них
не отличалась и была бы разрешена в doctor молча — а включить guard было
нельзя, он отказал бы на первой же читающей команде.
Команды классифицированы честно, `sleep` заменён таймером, и doctor целиком
выполняется под тем же read-only guard, что и PHASE 0 установки. Guard
снимается в finally. Диагностика при этом не сузилась: слушатели, healthz,
права, machine auth, trafficStats, версия бинаря, семантика конфига и
синтаксис nft проверяются полностью.
2. reset-admin различает «администратора нет» и «база не ответила».
Слой данных специально возвращает разные sentinel'ы, но команда склеивала их
обычным `if err != nil { создать } else { обновить }`. Опасен здесь не
только нарушенный смысл: при транзиентном отказе чтения («database is
locked») ветка создания отрабатывала успешно, и в таблице оказывались ДВЕ
учётные записи администратора. GetAdminUser берёт First() и о второй строке
не сообщает — на сервере оставалась вторая рабочая учётка с паролем, уже
напечатанным на экран, и ни один запрос об этом не говорил.
Заодно исправлено проглатывание ошибки хеширования: в ветке обновления
стояло `hash, _ := util.HashPassword(password)` внутри литерала map. При
отказе bcrypt в password_hash уезжала пустая строка, а на экран печатался
пароль, которым войти уже невозможно — VerifyPassword отклоняет всё, что не
bcrypt. Команда восстановления доступа умела молча его отобрать.
Тесты: doctor-readonly.test.ts дополнен поведенческой проверкой guard и
контролем набора раннеров в smoke; apps/cmd/reset_test.go проверяет обе ветки
на настоящей SQLite и отказ чтения при полностью работоспособной базе — ровно
тот случай, который прежний код превращал во второго администратора. Добавлена
dao.CountAdminUsers: до неё появление дубликата было ненаблюдаемым.
|
||
|
|
594525dd73 |
build: закрыть обходы релизного гейта тестов и проверять весь граф npm
Два гейта сборки проверяли не то, что обещали.
1. pnpm audit проверял production-подграф вместо всего lock-графа.
Гейт запускался с --prod под обоснованием «devDependencies в артефакт не
попадают». Для frontend build tooling это неверно по существу: vite и
rollup действительно не копируются на production-сервер как node_modules,
но они ИСПОЛНЯЮТСЯ на build-машине, читают наши исходники и порождают тот
самый production-бандл, который уезжает в артефакт.
Это не гипотеза: DOM clobbering в Rollup затрагивал именно генерируемый
бандл, и `pnpm audit --prod` его не показывал — по всему графу тот же
прогон дал 33 предупреждения против нуля. Критерий приёмки №47 в docs/11
формулировал «по всему графу» правильно ещё до того, как это стало правдой
в коде.
На текущем lock-файле полный граф на пороге high чист.
2. SKIP_TESTS позволял собрать production-артефакт без тестов.
Переменная была описана как «аварийное отключение тестов; для
release-сборок недопустимо». Недопустимость держалась исключительно на этой
фразе: ни metadata, ни финальная приёмка архива не проверяли, что тесты
запускались. То есть
SKIP_TESTS=true ./tools/build/build.sh
доходила до конца и выдавала обычный tarball с build_profile=production и
dependency_security_gate=true — артефакт, по которому невозможно отличить
проверенную сборку от непроверенной.
Глушила она при этом не только тесты: под тем же флагом пропускались
`tsc --noEmit` для оркестратора и `go vet` для админки, то есть проверка
типов и статический анализ того самого кода, который уезжает в production.
Выбран тот же строгий вариант, что уже принят для проверки зависимостей:
обхода нет. Готовый пакет объявляет tests_gate=true в metadata, и это
утверждение опирается на результат — обе функции прогона выставляют свой
флаг только после успешного завершения, а write_metadata отказывается
писать метаданные, если хотя бы один не подтверждён.
Приёмка закрепляет оба инварианта: --prod не может вернуться в гейт, SKIP_TESTS
не может вернуться ни в один модуль сборки и ни в README/docs, tests_gate=true
обязателен в metadata, а утверждение о прогоне обязано следовать за прогоном.
|
||
|
|
e84fdedc4b |
fix(v1): сделать откат неотменяемым, а маркер установки — долговечным
Три дефекта одного класса в failure path install/reconfigure. 1. Запись состояния отказа отменяла откат. Обработчик ошибки первым делом писал в install-state фазу отказа обычным await и только потом откатывался. Эта запись — mkdir, write и chown в /var/lib/hy2xs, то есть она падает ровно там, где откат нужнее всего: заполненный диск, read-only ФС, ошибка ввода-вывода. Бросок уносил управление наружу, и обязательное восстановление не выполнялось вовсе — применённый firewall и развёрнутые сервисы оставались на сервере. Необязательная телеметрия состояния стояла перед обязательным восстановлением. Для диагностики это уже было закрыто, для записи состояния — нет. 2. Откат отменял сам себя. Он был написан цепочкой await, а каждая его стадия — systemctl, cp, rm -rf и nft, то есть умеет упасть сама. Отказ первой стадии отменял все последующие. В reconfigure это означало сервер одновременно с применённым сломанным firewall И без восстановленных из /etc/hy2xs/backups конфигов. Внутри rollbackCurrentState болезнь та же: единственная команда без `|| true` (systemctl daemon-reload) отменяла перезапуск сервисов строкой ниже, и восстановленные unit-файлы не применялись. Стадии стали независимыми: выполняются все, отказавшие перечисляются в журнале, наружу уходит исходная ошибка операции. 3. У маркера установки было два писателя с разными гарантиями. install перезаписывал файл на месте (writeText), reconfigure подставлял атомарно. Слабейшая гарантия досталась команде, которая этот файл создаёт. Перезапись на месте укорачивает файл до нуля и только потом наполняет: отказ между этими моментами оставляет половину JSON, который не разбирается — reconfigure видит его как отсутствующий, clean-host как присутствующий, а хост уже изменён. Атомарности при этом мало. rename() без fsync даёт атомарность видимости без долговечности: после потери питания ext4 штатно отдаёт по этому пути нулевой файл. Для метаданных восстановления это неприемлемо, поэтому порядок теперь: права/владелец -> fsync файла -> rename -> fsync каталога. Заодно ownership-флаг переименован в stateTouched и взводится ДО записи: отказ на chown после успешного write оставлял файл на диске при невзведённом флаге, то есть давал fatal_pre_apply («ничего не изменено») при уже существующем маркере установки. Тесты: rollback-mandatory.test.ts (внедрение отказа в стадию, проводка команд), atomic-write.test.ts (замена целиком, прежний файл при отказе, отсутствие временных файлов, права, guard). Приёмка сборки закрепляет порядок шагов атомарной записи, отсутствие незащищённой записи состояния в обработчиках и отсутствие отменяемых цепочек в откате. |
||
|
|
60a1aea85e |
fix(build): не дать скану обходов security-gate поймать самого себя
Третий раз в этом файле: скан `code_mentions_in "$bypass_var" tools/build` находил строку `for bypass_var in ALLOW_VULNERABLE_DEPENDENCIES SKIP_SECURITY_SCAN` в самой приёмке. Это код, а не комментарий, поэтому code_without_comments не помогал, и проверка гарантированно падала бы на корректном дереве — снова в конце сборки. Сканируются перечисленные модули сборки, а не весь каталог. Заодно code_without_comments выбирает вид комментария по расширению: решётка отбрасывается только в shell. В шаблоне Vue строка вполне может начинаться с `#default="scope"` — это сокращение v-slot, и общий фильтр молча выбрасывал бы её из сканов по apps/frontend/src. |
||
|
|
219bb364bc |
docs: сделать проверку типов frontend release gate и убрать известное ограничение
bundle_ui запускает `pnpm run typecheck` перед сборкой bundle. И наличие шага, и его порядок закреплены приёмкой — вместе с требованием vue-tsc версии 3 и выше и с запретом снова совмещать сборку и проверку в build:prod. Из docs/02 убран раздел «Известное ограничение: проверка типов frontend почти ничего не проверяет» и заменён описанием действующего контракта. Прогноз в нём был близок, но неточен: ошибок оказалось 142, а не ~155, и класс DefaultRow/ PeerVo на Element Plus 2.3 не существовал вовсе — он появился вместе с обновлением Element Plus. docs/04 получил описание модели отображения (третий слой рядом с типизированной моделью и сырым YAML) и раздел о том, что страница Hysteria теперь read-only на всех уровнях, а не только визуально. docs/11: команды проверки frontend и dev doctor в раздел запуска, семь новых пунктов приёмки. |
||
|
|
32ff47731c |
build(frontend): убрать неподдерживаемый плагин иконок и EOL-линтеры, audit до нуля
vite-plugin-svg-icons не обновлялся с 2022 года и был единственным источником половины оставшихся предупреждений: svgo 2.8, postcss 5.2.18, braces 2.3.2 и image-size 0.5.5 — у последней advisory прямо сообщает `Patched versions: <0.0.0`, то есть исправленной версии не существует. Задача, которую он решал, заняла один модуль: import.meta.glob собирает семнадцать локальных SVG в скрытый спрайт со <symbol>. Компонентный подход (unplugin-icons) здесь не подходит — имя иконки часто вычисляется в рантайме (onlyOneChild.meta.icon, isFullscreen ? ... : ...), а поиск по id нужен именно для этого. API <svg-icon icon-class="..."> не изменился. Проверка на реальных ассетах поймала то, что иначе уехало бы в релиз: три иконки из семнадцати не объявляют viewBox, задавая только width/height. Без viewBox <use> рисует иконку в натуральную величину и обрезает её. Плагин синтезировал viewBox сам; теперь это делает sprite.ts, а инвариант закреплён приёмкой — иконка без обоих способов задать координаты роняет сборку. Отдельно: typecheck поймал две ошибки уже в самом sprite.ts (SVGSVGElement против HTMLElement из getElementById). Ровно то, ради чего он возвращался. eslint 8 объявлен EOL и оставался вторым источником предупреждений. Переход на eslint 10 потребовал flat config: eslintrc в 9 работает только через переменную окружения, а в 10 удалён, поэтому обновление версии без смены формата было бы отсрочкой на релиз. Набор правил сохранён прежним; .eslintignore свёрнут в ignores, как требует новый формат. Проверено, что flat config действительно линтит, а не молча пропускает: пробный файл с неиспользуемой переменной и необъявленным именем даёт обе ошибки и в .ts, и в .vue. Оставшиеся предупреждения приходили из графов самих eslint 10 и stylelint 17, то есть уже последних версий, — закрыты pnpm.overrides. Override для ajv ограничен `table>ajv`: глобальный ломал eslint, который использует ajv 6. pnpm audit по всему графу: 33 предупреждения -> 0. typecheck/eslint/stylelint/build: 0. go build и go test с встроенным dist: OK. |
||
|
|
ddd1020a3a |
build(frontend): Vite 4.3.1 -> 7.3.6 и уборка мёртвых редакторов конфига
Решение «Vite не трогать» пересмотрено по данным, а не по желанию обновиться.
`pnpm audit --prod` (то, на что смотрит gate сборки) был чист, но полный audit
показывал в build-цепочке два critical и rollup GHSA DOM clobbering — а он
затрагивает ГЕНЕРИРУЕМЫЙ bundle, то есть уезжает в production. Vite 4.3.1 тянул
rollup 3 без исправления.
Взята 7.3.6 — последняя линия до Rolldown. Vite 8 по-прежнему не берётся: это
смена бандлера, отдельная работа с собственной приёмкой.
Вместе с Vite обновлена цепочка плагинов (@vitejs/plugin-vue 4 -> 6,
unplugin-* и unocss с версий 2022-2023 годов) и линтеры (@typescript-eslint
5 -> 8, prettier 2 -> 3, stylelint 15 -> 17). Новый @typescript-eslint нашёл 7
настоящих замечаний — исправлены по существу, а не подавлением правил; среди
них подавление несуществующего правила ban-types и `{}` вместо `object`.
Отдельно — три мёртвых редактора. Outbounds уже был разобран, здесь то же
самое для ImputMultiple (ACME-домены, inline ACL) и MapAdd (параметры ACME DNS,
заголовки masquerade): оба редактировали конфиг Hysteria в форме с
:disabled="true", получали значения без v-model и эмитили события, которых
никто не слушает, на странице без единого маршрута записи.
Цена ImputMultiple была измеримой. vuedraggable поставляется UMD-сборкой,
поэтому её require("vue") разрешался в vue/dist/vue.cjs.prod.js — полную сборку
Vue с рантайм-компилятором шаблонов. В бандл уезжало ~500 КБ исходников
(vuedraggable + sortablejs + compiler-core + compiler-dom) ради перетаскивания
тегов в недоступной для редактирования форме.
Разбор bundle через sourcemap нашёл и вторую потерю: @vueuse/core собирался
дважды — наш 13.9.0 и 14.4.0 из element-plus. Версии сведены.
bundle: 2 813 525 против 2 404 202 на исходной базовой линии (+17% за Vue 3.5,
Element Plus 2.14, echarts 6 и rollup 4). Промежуточное состояние без этих двух
исправлений было 3 046 172.
pnpm audit --prod: чисто. typecheck/eslint/stylelint: 0. go build с dist: OK.
|
||
|
|
7f4cc10d25 |
build(frontend): обновить UI-зависимости внутри текущих major и закрыть все advisory
Vue Router 4.1.6 -> 4.6.4, Pinia 2.0.33 -> 2.3.1, @vueuse/core 9 -> 13.9.0, vue-i18n 9.0.0 -> 9.14.5, echarts 5.6 -> 6.1.0, vue-echarts 7 -> 8.1.0. Pinia 3, Vue Router 5 и Vite 8 сознательно не берутся: ни один из них не даёт проекту ничего, кроме номера версии, а Vite 8 — это переезд на Rolldown. vue-i18n и echarts обновлены не ради свежести: `pnpm audit --prod` на базовой линии показывал 4 moderate, из них GHSA-x8qp-wqqm-57ph (vue-i18n, исправлено в 9.14.5) и GHSA-fgmj-fm8m-jvvx (echarts XSS, исправлено в 6.1.0). Теперь `pnpm audit --prod` чист. Четвёртая advisory — GHSA-5m5x-9j46-h678 в el-link — исправленной версии не имеет, но в продукте недостижима: el-link не используется ни в одном представлении. pnpm audit анализа достижимости не делает, в отличие от govulncheck, поэтому это приходится проверять руками. VueUse 13 сломал сборку: `[auto-import] identifier toRef already defined with vue`. Причина — авто-импорт всего @vueuse/core, то есть пятисот с лишним имён, среди которых реэкспорты toRef и toValue. Список сужен до двух функций, которые действительно используются без явного импорта; src/types/auto-imports.d.ts сократился с 526 строк до 124. Заодно включена генерация .eslintrc-auto-import.json: файл подключён через extends в .eslintrc.cjs, но не генерировался, то есть перечислял глобальные имена, которых в авто-импорте давно нет. Устаревший список молча отключает предупреждение eslint об обращении к несуществующему имени. bundle: 2 604 924 против 2 404 202 на базовой линии (+8%). typecheck: 0, eslint: 0, go build с встроенным dist: OK. |
||
|
|
d2dea2debf |
build(frontend): Element Plus 2.3.1 -> 2.14.5, sass 1.58 -> 1.103, icons 1.x -> 2.3.2
Element Plus с 2.8.5 требует sass >= 1.79, поэтому обновление идёт классом:
element-plus + sass + @element-plus/icons-vue, отдельным коммитом от Vue и
typechecker — UI-регрессию так проще локализовать.
Обновление внесло единственный новый класс ошибок типов — тот самый
DefaultRow/PeerVo, который аудит ожидал увидеть сразу: до 2.14 слоты таблицы
типизировались слабее и ошибки не давали. 10 ошибок в семи колонках списка
пиров.
el-table обобщён по типу строки, но el-table-column — отдельный компонент, и
тип из :data родительской таблицы в его слот не попадает: scope всегда
{ row: DefaultRow }. Аннотировать слот нельзя — DefaultRow не сужается до PeerVo
контравариантно. Поэтому переход нужен, и вопрос только в том, где он стоит:
здесь он ровно один (peerRow), назван и объяснён, вместо семи `as any` в
шаблоне, каждый из которых глушил бы и остальное выражение.
PeerVo переведён с interface на псевдоним типа: неявную индексную сигнатуру
TypeScript даёт литеральным типам, но не интерфейсам, поэтому раньше PeerVo и
DefaultRow не были совместимы ни в одну сторону и переход требовал `as unknown
as` — утверждения, которое компилятор не проверяет вообще.
typecheck: 0, eslint: 0, build: OK.
|
||
|
|
02ea33520d |
feat(frontend): вернуть проверку типов SFC-шаблонов и закрыть все 142 ошибки
Vue 3.2.45 -> 3.5.42, TypeScript 4.9.3 -> 5.9.3, vue-tsc 0.35.0 -> 3.3.11. Element Plus, Vue Router, Pinia, Vite и VueUse не тронуты: ни один из них не является предусловием работающего typechecker. vue-tsc 0.35 был не просто инертен — на Vue 3.5 он ломается сам (TS7026: нет JSX.IntrinsicElements), потому что не знает vue/jsx-runtime. То есть проверка, которая ничего не находила, ещё и не пережила бы обновление Vue. Современный vue-tsc даёт 142 ошибки, а не ~155, и картина однороднее ожидаемой: 141 x TS18048 и 1 x TS2322, всё в двух файлах представления Hysteria. Предсказанного класса DefaultRow/PeerVo в таблице пиров не оказалось вовсе. Все 142 — одно и то же: шаблон обращается к необязательным секциям конфига (dataForm.tls.cert, dataForm.acme.dns.config, dataForm.resolver.https.sni). Необязательны они правильно: так устроен upstream YAML. Инвариант «секция есть всегда» существовал, но держался на порядке присваиваний внутри компонента. Закрыто одним преобразованием на границе API вместо 141 `?.` или `as any`: api/config/hysteriaViewModel.ts даёт Hysteria2ServerConfigView, где присутствие каждой секции — свойство типа, и normalizeHysteriaViewModel(). types.ts остаётся описанием того, что приходит по сети. Побочно закрыто мёртвое UI: редактор outbounds (кнопка «+», диалог создания, удаление тегов, emit update:outbounds) не мог ничего сохранить — страница отрисована с :disabled="true", родитель передаёт :outbounds без v-model, а маршрутов записи серверного конфига в API нет. Оператор мог добавить outbound и уйти в уверенности, что изменил конфигурацию сервера. package.json: typecheck и build:prod разделены, verify запускает их по порядку. Раньше `vite build && vue-tsc` сначала тратил время на production bundle и только потом сообщал о типовой ошибке. |
||
|
|
cf094f6e6f |
fix(v1): сделать отзыв доступа, бэкап и диагностику соответствующими своим именам
Проход по операциям, которые делают не то, что обещает их имя. P0. Удаление bootstrap-admin-peer не было отзывом доступа. Признаком «создавать пир или нет» служило наличие строки в таблице, а HY2XS_ADMIN_CON_PASS продолжает жить в /etc/hy2xs/hy2xs.env — его читает systemd-юнит. Оператор удалял пира, доступ исчезал, и ближайший restart возвращал того же пира с тем же секретом. Молча. Признаком стала отметка BOOTSTRAP_PEER_SEEDED в таблице config: «создавался когда-либо», а не «существует сейчас». Отметка и пир пишутся одной транзакцией. P1. Резервная копия с includeSecrets=true проглатывала и ошибку расшифровки, и отсутствие шифртекста, отдавая пира с пустым secret и успешный ответ. Теперь недоступный секрет любого пира отклоняет весь запрос с указанием имени. P1. DecryptPeerSecret возвращала содержимое колонки как расшифрованный секрет, если оно не начиналось с v1: — остаток поколения с открытыми секретами. P1. doctor перезапускал hysteria-server и hy2xs-admin: диагностика подозрения на проблему обрывала все живые соединения. P1. Админка сама генерировала HYSTERIA2_TRAFFIC_STATS_SECRET, записать который в /etc/hysteria/config.yaml она не может. Сервис объявлял себя здоровым, а machine auth переставал совпадать. P1. Обходы проверки зависимостей (accepted-risk/skipped) не могли произвести артефакт: приёмка требует dependency_security_gate=true. Удалены из сборки и документации, отсутствие проверяется приёмкой. P2. UPDATE по отсутствующей строке config считался успехом, и cron перепланировался при несохранённом значении. Решение по RowsAffected. P2. Слой данных не отличал «записи нет» от «база не ответила»: sentinel-значения ErrPeerNotFound / ErrAdminUserNotFound / ErrConfigNotFound / ErrStorage. P2. Удалены алиасы /:id/client-url и /:id/qr. Контракт разработки: apps/go.mod объявляет toolchain go1.26.7 (директива go — языковой baseline, а не выбор компилятора), tools/dev/doctor.sh|.ps1 сверяют среду с versions.env. |
||
|
|
e30fdaa004 |
docs: зафиксировать известное ограничение проверки типов frontend
vue-tsc 0.35.0 (2022) шаблоны Vue практически не типизирует: build:prod проходит зелёной, не давая гарантии, которую обещает. Замер записан — на паре typescript@5.9 + vue-tsc@2.2 тот же код даёт около 155 ошибок в четырёх файлах. Обновление typechecker'а тянет vue 3.2 -> 3.5, а за ним element-plus, pinia и vue-router, поэтому вынесено в отдельную работу. На безопасность не влияет: уязвимые пакеты обновляются движением lockfile внутри объявленных диапазонов. |
||
|
|
b99be7d514 |
fix(v1): разблокировать сборку, починить жизненный цикл cron и закрыть каналы утечки
Сборка не собиралась: два контракта приёмки роняли её на корректном коде.
verify_api_namespace_contract искал возвращение legacy-пространства имён
через grep по '/hui' и находил router_test.go, который ПЕРЕЧИСЛЯЕТ этот
префикс, чтобы доказать отсутствие маршрута, и сам versions.sh, где строка
стоит в тексте проверки. Падение приходило шестым шагом из четырнадцати, до
резолва Hysteria. За ним прятался второй такой же: проверка транзакционности
импорта пиров брала файл от начала applyPeerImportEntry и до конца, захватывая
объявленные ниже ExistPeerName и UpdatePeerLastConnectionAt.
Обе проверки теперь смотрят на код, а не на упоминания: добавлены помощники
code_without_comments и code_mentions_in, а отсутствие legacy-маршрута
доказывает тест на таблице маршрутов собранного роутера.
Планировщик стал собственностью процесса. InitCron вызывался из runServer и
на каждом вызове создавал новый cron.New(), не сохраняя ссылку; cron.Stop()
не вызывался нигде. Смена RESET_TRAFFIC_CRON выполняла StopServer(), точка
входа крутила for { runServer() } — и каждая правка добавляла целый
дублирующий набор джоб, а старое расписание сброса продолжало работать.
Фиксированные джобы регистрируются один раз, расписание переносится на месте
по EntryID, HTTP-сервер не трогается. Добавлено штатное завершение по SIGTERM.
Выражение проверяется до записи в базу тем же парсером (cron.ParseStandard),
которым его разбирает планировщик: раньше невалидная строка сохранялась, API
отвечал успехом, а сброс трафика молча исчезал.
updateConfigs стал атомарным: полная проверка партии, одна транзакция,
применение к рантайму. Прежний тест ставил запрещённый ключ первым и не
смотрел в базу — поймать частичное применение он был неспособен.
Удалены четыре ключа таблицы config без единого потребителя: HYSTERIA2_ENABLE,
HYSTERIA2_CONFIG (второй источник истины, читался первым), HYSTERIA2_TRAFFIC_TIME
и HYSTERIA2_CONFIG_REMARK. Имя профиля в share URI выводится из имени пира.
Безопасность:
- bootstrap-пароль администратора больше не генерируется и не пишется в журнал,
который отдаётся кнопкой выгрузки; отсутствие env — отказ старта;
- собственный журнал админки санитизируется наравне с чужим;
- golang-jwt/jwt v3 -> v5: GO-2025-3553 не имеет исправленной версии в v3 и
достижима с неаутентифицированного запроса; набор алгоритмов подписи
зафиксирован через WithValidMethods;
- удалён вход по несолёному SHA-224 из предыдущего поколения;
- убран modulo bias в util.RandomString — единственном генераторе секретов;
- пир установщика защищён во всех путях записи, а не только в импорте;
- удалена латентная паника в service.GetToken и недостижимая ветка GetAdminInfo,
проверявшая меньше, чем middleware.
Toolchain: Go 1.21.13 -> 1.26.7, Node 20.19.0 (EOL) -> 24.20.0. На прежнем
графе govulncheck находил 21 вызываемую уязвимость, 17 из них в stdlib,
попадающей в production-бинарь. Сейчас — ноль. Добавлен обязательный шаг
проверки зависимостей (govulncheck + pnpm audit) с записью результата в
metadata пакета.
|
||
|
|
672d455467 |
fix: закрыть каналы утечки секретов и сделать PHASE 1 владением оркестратора
Hardening-проход перед первой сборкой на Debian. Три из найденного не воспроизводились ни на одном dry-run и проявились бы только на живом сервере. Установка * preflight внутри install вызывался дважды и оба раза проверял clean-host. Ко второму вызову на диске лежал собственный /var/lib/hy2xs/install-state.json, записанный после первого preflight, и опознавался как маркер посторонней установки: КАЖДАЯ чистая установка падала сразу после apt-get с fatal_post_apply и оставляла сервер наполовину настроенным. Чистота хоста — условие входа в операцию, возможности платформы проверяются уже внутри PHASE 1, поэтому checkCleanHost стал отдельным параметром без умолчания. * PHASE 1 начиналась в install.sh: shell сам создавал /usr/local/lib/hy2xs, ставил бинарник, вешал symlink и копировал runtime-пакет, и только потом запускал оркестратор с его собственным preflight. Отказ того preflight объявлялся fatal_pre_apply — «на сервере ничего не изменено» — при уже созданном каталоге оркестратора. Отследить владение мутацией невозможно, пока мутируют двое: install.sh больше не изменяет ничего, раскладку выполняет steps/bootstrap.ts под ownership.bootstrapTouched, пути попали в owned_paths. Как следствие удалено деление clean-host на фазы. * diagnosticsCollect стояла перед rollback обычным await в install и в reconfigure. На заполненном диске она падает сама и отменяла откат целиком. Диагностика — best effort, откат — обязателен. * reconfigure/repair выбирали записываемую фазу отказа регулярным выражением по тексту ошибки. Переведено на ownership-флаги. Секреты * Журнал админки писал RequestURI, то есть путь вместе с query. Hysteria обращается к /internal/hysteria/auth?access_token=<секрет> при каждом подключении пира, поэтому действующий machine token оседал открытым текстом в hy2xs-admin.log, который отдаётся через ExportLog и попадает в diagnostics-бандл. Логируется путь; значения query не пишутся, имена — пишутся. Канала было два: gin.Default() печатает path?query в stdout, оттуда в journald и в тот же бандл, — панель переведена на gin.New() + Recovery(). Журналы внутри бандла и журнал Hysteria из ExportLog теперь проходят санитайз. Сравнение токена — constant time. * Config API позволял прочитать и подменить ключи приложения: getConfig и listConfig принимали произвольный ключ, а проверка записи была denylist'ом из трёх ключей оркестратора. Запрос ?key=PEER_SECRET_ENCRYPTION_KEY отдавал master-key шифрования секретов пиров. Доступ переведён на allowlist, маршрут getConfig удалён целиком — потребителей у него не было ни одного. Пиры * Импорт применялся по одной записи вне транзакции, вопреки собственному контракту. Валидация не знает, что уже лежит в базе: cross-conflict по UNIQUE(name) оставлял часть файла применённой. Применение выполняется одной транзакцией, криптоматериал считается до её открытия. * Файл импорта мог содержать хвостовой JSON-документ, который молча не применялся. После разбора проверяется io.EOF. * Экспорт разделён на «Экспорт настроек» и «Резервная копия» с секретами и подтверждением: обычный экспорт выдаёт пирам новые секреты при импорте, и прежние клиентские ссылки после переноса переставали работать. Сборка * Два stale-грепа в приёмке роняли build.sh в самом конце, внутри verify_archive. Первый искал в smoke.ts исчезнувший литерал URL, второй совпадал с router_test.go, который перечисляет удалённые маршруты, потому что проверяет их отсутствие: добавление регрессионного теста ломало сборку. * verify_archive требовал наличия мутирующей строки в install.sh. Инвариант перевёрнут: их не должно быть ни одной. Очистка * Удалены entity.LegacyAccount, миграции 002/003 и мёртвые хелперы listSQLMigrationFiles и envInt: v1 не мигрирует базу 0.x ни при каком сценарии. Номера оставшихся миграций сохранены. H UI-словарь убран из обычных доков, в docs/14 он остаётся — там это имена объектов для удаления. * Список непубличных IPv4 приведён к IANA Special-Purpose Address Registry: 203.0.113.5 из RFC-примеров считался публичным адресом сервера. Отказ резолвера отделён от отсутствия A-записи. Проверено: bun test 233, go test 71, tsc/vue-tsc, bash -n 11 скриптов, приёмка прогнана против дерева. |
||
|
|
5574b7c89a |
test(router): закрепить пространства имён API тестом регистрации маршрутов
Ошибка в регистрации маршрутов проявляется паникой при старте сервиса, а не ответом с кодом: конфликт с wildcard фронтенда или дублирующая регистрация обнаружились бы только на живом сервере. Смена namespace относится ровно к этому классу изменений. |
||
|
|
086b5d6624 |
build: закрепить новые инварианты приёмкой и документацией
verify_versions_contract получил сверку API namespace. Путь machine-auth
записывается в /etc/hysteria/config.yaml и в post-install.env, то есть по нему
Hysteria обращается к админке. Пока строка была продублирована в шаблонах,
smoke, тестах, приёмке и e2e, расхождение обнаруживалось только на живом
сервере. Теперь Go-константы, API_BASE фронтенда и оба шаблона сверяются
против значений, скомпилированных в оркестратор.
Приёмка проверяет, что:
- fatal_pre_apply недостижим после записи install-state;
- каждый ownership-флаг взводится раньше своего шага;
- у read-only фазы нет универсального раннера, через который можно
проскользнуть;
- инвариант публичного endpoint живёт в preflight и не обращается к внешним
сервисам определения IP;
- purge-v0.sh и clean-host описывают одну границу;
- секреты не попадают в персистентный файл экспорта;
- импорт пиров валидируется так же строго, как их создание;
- удалённые exportConfig/importConfig не вернулись.
Захардкоженная схема =2 в приёмке заменена на значение из versions.env: при
переходе на schema 3 пришлось бы помнить ещё и про эту строку.
Документация: контракт раннеров и ownership в 08, инвариант публичного
endpoint в 08/09/12/13 и README, сетевая идентичность панели и удалённые
export/import в 04, сценарии D1 (отказ сразу после PHASE 0) и D2 (устаревший
DNS после смены IPv4) в 11, версии package.json как не-версия продукта в 02.
|
||
|
|
a88268b0cd |
fix(install): сделать границу «хост изменён» настоящим инвариантом
fatal_pre_apply мог означать «хост уже изменён». install-state.json пишется сразу после успешного preflight, до установки пакетов, но классификация отказа его не учитывала. Падение apt-get объявлялось как «на сервере ничего не изменено»: откат и обработка состояния пропускались, а маркер оставался на диске и ломал следующую установку по clean-host контракту. Ownership-флаги переформулированы с «шаг успешно завершился» на «операция могла начать менять систему» и взводятся перед мутирующим вызовом: apt-get умеет изменить систему и упасть. fatal_pre_apply теперь недостижим ни при одном взведённом флаге, включая stateWritten. Read-only guard PHASE 0 можно было обойти. Guard стоял на writeText, writeTextAtomic, runVisible, runHidden и runRawVisible, но не на универсальном run, через который в коде проходили и наблюдение (ss, systemctl is-active), и настоящие мутации (useradd, install -d, mkdir, cp -a, tar). Универсального раннера больше нет: runReadOnly/runReadOnlySecret без guard'а и runMutating* под guard'ом, выбор — явное решение на месте вызова. 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 создаёт между фазами, помечены как созданные установщиком, иначе PHASE 1 отказала бы на собственном оркестраторе. purge-v0.sh --keep-hysteria-binary противоречил установщику: скрипт сохранял /usr/local/bin/hysteria и сообщал «хост чист для установки HY2XS v1», хотя clean-host считает этот бинарник legacy-маркером. Флаг удалён. DNS проверялся на существование A-записи, но не на то, куда она ведёт. После принудительной смены IPv4 провайдером doctor отвечал успехом, хотя клиентская ссылка отправляла людей на чужую машину. Проверялся при этом HY2XS_DOMAIN, тогда как в hysteria2:// уезжает HY2XS_PUBLIC_HOST. Добавлен инвариант публичного endpoint: A-записи обязаны принадлежать множеству публичных IPv4, назначенных интерфейсам этого сервера. Проверка живёт в общем preflight, поэтому действует в install, reconfigure и doctor. Адрес определяется локально, без внешних сервисов определения IP. Строгость управляется HY2XS_PUBLIC_ENDPOINT_POLICY (strict по умолчанию); отсутствие A-записи фатально при любом значении. TS-санитайзер приведён к той же формулировке, что и Go: URL-значение определяется по самому значению, а не по имени ключа. |
||
|
|
b52fac1394 |
fix(admin): убрать каналы утечки секретов и остатки H UI из runtime
Экспорт в панели формировался через 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: кнопка Export выгружала их открытым текстом, импорт позволял подменить. Для PEER_SECRET_ENCRYPTION_KEY подмена ломает расшифровку секретов уже существующих пиров. Production-сценария у этой пары не было. Импорт пиров шёл мимо всей валидации, которую проходит обычное создание пира: в базу попадало имя любой длины и с любыми символами, disabled с произвольным числом, отрицательные счётчики. Файл применялся построчно, поэтому ошибка в середине оставляла список наполовину изменённым, а импорт мог перезаписать bootstrap-admin-peer, чей секрет продублирован в bootstrap-admin.secret. Партия проверяется целиком до первой записи, неизвестные поля отклоняются. Убран слой сетевых настроек панели: H_UI_WEB_PORT, H_UI_WEB_CONTEXT, H_UI_CRT_PATH, H_UI_KEY_PATH и собственный TLS. Оркестратор передавал порт аргументом, админка писала его в SQLite и тут же читала обратно, а UI показывал поля disabled — второй источник истины, из которого ничего нельзя было изменить. HUI_DATA/HUI_LOG заменены на HY2XS_DATA_DIR/HY2XS_LOG_DIR, база переименована в hy2xs-admin.db, reference-схема — в schema.sql. API namespace разделён по природе маршрутов: операторский API на /api, machine-auth Hysteria на /internal/hysteria/auth. Путь machine-auth — runtime-контракт, он уезжает в config.yaml и post-install.env, поэтому объявлен одной константой на компонент. Go-санитайзер экспорта вырезал секреты из URL только у ключей url/addr: будущее upstream-поле с другим именем уносило учётные данные и access_token целиком, а URL внутри списков не обрабатывались вовсе. Граница определяется значением, а не именем ключа — как в TS-санитайзере оркестратора. Заодно индикатор загрузки и цвета 401/404 переведены на брендовый токен: NProgress приходил со своим #29d и был единственным элементом вне палитры. |
||
|
|
3a4ce9c751 |
docs: clean-install-only, versions.env и очистка предыдущего поколения
Новый docs/14-legacy-cleanup.md: как выглядит отказ установщика, полный список маркеров чужой установки, что сохранить перед очисткой, работа purge-v0.sh, ручная процедура и отдельно - случай незавершённой установки текущего поколения, где нужен repair, а не очистка. Обновлено под фактическое поведение: - README и package/docs: установка описана как две фазы, PHASE 0 ничего не меняет; добавлен troubleshooting по отказу clean-host; версии toolchain больше не передаются через окружение; - 02-build-layer: раздел про versions.env (что в нём есть и чего нет и почему), verify_versions_contract, проверка происхождения артефакта по upstream hashes.txt; - 08-orchestrator-spec: двухфазный контракт, read-only guard, идентификация поколения в install-state, ownership-aware rollback, расширенная семантическая проверка конфига, структурная редакция; - 04-admin-panel: таблица удалённых маршрутов и почему они удалены, а не оставлены заглушками; сужена формулировка гарантии санитайза; - 11-testing: новые unit-наборы, полный список инвариантов конфига, раздел про одну реализацию URI вместо двух, сценарий проверки границы установки на живом сервере; - 12-operations и 13-runbook: диагностика отказов по поколению, поведение diagnostics-бандла; - tools/build/README: контракт версий, обе суммы Bun, hashes.txt. CHANGELOG: раздел Unreleased с разбором каждого исправленного дефекта. |
||
|
|
42db78c6a0 |
feat(tools): purge-v0 и acceptance-проверки политики clean-install-only
Раз v1 принципиально не мигрирует состояние 0.x, политика должна быть операционно завершённой: у оператора обязан быть явный способ привести сервер в состояние, которое установщик примет. tools/legacy/purge-v0.sh делает это отдельной осознанной операцией: - по умолчанию печатает план и НЕ меняет ничего; - выполнение требует --apply вместе с --yes-i-know; - снимает таймеры отката firewall hy2xs-fw-rollback-*, которые переживают неудачную установку и иначе продолжили бы менять ruleset уже после очистки; - из /etc/nftables.conf убирает только include HY2XS: остальной ruleset принадлежит оператору; - в конце проверяет чистоту хоста по тому же контракту, что и установщик. Из install.sh он не вызывается никогда: встроенная очистка вернула бы destructive migration logic обратно в путь свежей установки - ровно то, от чего мы ушли. Acceptance-набор дополнен проверками, которые не дают инвариантам тихо развалиться: порядок фаз в install.sh, наличие read-only guard, preflight раньше первой записи install-state, ownership-aware rollback, отказ по отсутствующей схеме, проверка поколения в reconfigure/repair, структурная редакция, отсутствие удалённых маршрутов, e2e на production-генераторе, сверка с upstream hashes.txt, контрольные суммы в versions.env, версия админки из контракта. |
||
|
|
920a78fdae |
test(e2e): подключаться по ссылке из production-генератора share URI
Внутри e2e-hysteria.sh жила вторая реализация hysteria2:// URI на bash. Go-юнит-тесты проверяли production-генератор, e2e проверял свою функцию - и дрейф любой из двух реализаций оставлял обе группы тестов зелёными. Фраза "реальный клиент подключается именно по ссылке, которую выдаёт HY2XS" была неточной. Билдер ссылки вынесен в экспортируемую service.BuildHysteria2ShareURI, production-путь Hysteria2Url стал её тонкой обёрткой. Новая тестовая утилита apps/tools/share-uri печатает ссылку тем же кодом; в production-бинарь админки она не входит. Единственное расхождение с пользовательской ссылкой - insecure=1: e2e работает на самоподписанном сертификате. Расхождение ограничено с трёх сторон: - e2e отдельно печатает и проверяет production-вариант ссылки (insecure=0, корректные obfs и sni); - TestBuildHysteria2ShareURI_InsecureDiffersOnlyInThatParam доказывает, что кроме этого параметра ссылки совпадают; - TestBuildHysteria2Url_ProductionPathNeverDisablesVerification фиксирует, что production-путь никогда не передаёт insecure=1. Для запуска e2e теперь нужен Go (GO_BIN). |
||
|
|
19ffc80130 |
refactor(admin): удалить мёртвый updater/config-write API и его UI
Маршруты, операциями которых продукт не владеет, отвечали заглушкой "managed by orchestrator" или пустым списком: POST /hysteria2ChangeVersion GET /listRelease POST /config/updateHysteria2Config POST /config/importHysteria2Config POST /config/restartServer POST /config/uploadCertFile GET /config/hysteria2AcmePath (не имел потребителя вовсе) Они удалены, а не оставлены заглушками. Причины две. API-контракт не должен обещать updater, которого у продукта принципиально нет: маршрут, всегда возвращающий отказ, вводит в заблуждение. И это лишняя attack surface плюс технический мусор от прежней архитектуры. Вместе с маршрутами убраны мёртвые сервисы (StartHysteria2, StopHysteria2, RestartHysteria2, SetHysteria2Config, UpdateHysteria2Config, GetAuthHttpUrl, Hysteria2AcmePath), неиспользуемые типы и клиентские функции фронтенда. Отдельно - кнопки. "Перезапустить панель" и загрузка сертификатов обращались к заглушкам, то есть гарантированно возвращали ошибку. Кнопка, которая всегда падает, - не точка расширения на будущее, а дефект UX. Удалены вместе со строками i18n. Конфигурация Hysteria остаётся доступной панели на чтение и на выгрузку: getHysteria2Config и exportHysteria2Config. |
||
|
|
10d2c48cf0 |
build: сверка артефакта Hysteria с upstream hashes.txt
SHA-256 считался локально от уже скачанного файла. Это защищает target от последующей подмены, но не доказывает, что builder скачал именно ожидаемый upstream artifact: сумма фиксирует то, что пришло, каким бы оно ни было. То есть trust-on-first-use, а не проверка происхождения. Upstream публикует контрольные суммы релиза ассетом hashes.txt: 6493dfff...f94 build/hysteria-linux-amd64 f24f63be...189 build/hysteria-linux-amd64-avx Теперь резолвер отдаёт и URL этого ассета, сборка скачивает его, берёт оттуда ожидаемую сумму и сверяет с ней бинарник - и только после этого записывает SHA-256 в HY2XS lock и metadata. Сопоставление идёт по базовому имени и строго на равенство: build/ - часть пути, а hysteria-linux-amd64-avx - другой артефакт, который не должен совпасть по префиксу. Разбор вынесен в parseUpstreamHashes и покрыт тестами, включая форму sha256:<hex>, верхний регистр, противоречивые и отсутствующие записи. Релиз без hashes.txt для production-сборки непригоден и отклоняется. Источник ожидаемой суммы фиксируется в metadata как hysteria_sha_source. |
||
|
|
9da61c9482 |
build: versions.env как единый контракт продукта, платформы и toolchain
Версии были размазаны: PACKAGE_VERSION в build.sh, схема конфигурации в profile.ts и в hy2xs.env, версии toolchain в deps.sh, Debian 13 в нескольких местах. Расхождение уже перестало быть теоретическим - пакет 1.0.0 сообщал "HY2XS admin version v0.0.22". Введён корневой versions.env: версия продукта, линия релиза, схема конфигурации, целевая платформа, версии и контрольные суммы Go/Bun/Node/pnpm, политика выбора Hysteria. Чего в нём нет намеренно: 1. Прикладных зависимостей - у них есть pnpm-lock.yaml, bun.lock, go.sum. Второй слой неизбежно разъедется с настоящим графом. 2. Конкретной версии Hysteria - здесь только политика HYSTERIA_CHANNEL, результат резолва живёт в hysteria-lock.env. Пин версии здесь вернул бы ручное обновление. Подход - проверка, а не генерация. profile.ts, hy2xs.env и packageManager в двух package.json остаются обычными файлами, чтобы bun test, tsc и go test работали из чистого чекаута до сборки. Новый шаг verify_versions_contract роняет сборку до создания tarball при расхождении. Контракт оркестратора сверяется не grep'ом по исходникам, а выводом print-contract.ts: это доказывает, что в бинарь попало то же значение. Версия админки перестала быть константой и приезжает через ldflags; собранный бинарь проверяется запуском hy2xs-admin version. Контрольные суммы toolchain больше не передаются через окружение. Для Bun зафиксированы обе суммы: артефакт выбирается по наличию AVX2, поэтому одной архитектурно недостаточно. Production-сборка снова запускается одной командой. |
||
|
|
abdcb881f3 |
fix(security): структурная редакция секретов и строгая проверка конфига
Diagnostics-бандл уносил machine token наружу. Построчное правило `.replace(/(auth:\s*).*/gi, ...)` подставляло маркер в заголовок mapping'а и оставляло нетронутым вложенный auth.http.url: http://127.0.0.1:8080/hui/hysteria2/auth?access_token=<секрет> Это тот же trafficStats secret, который открывает и traffic API, и auth-endpoint. Бандл собирается автоматически при любом падении install/reconfigure и предназначен для передачи наружу. Редакция YAML переписана структурно: документ разбирается и обходится как дерево. Значение секрета может лежать где угодно, поэтому обходить нужно дерево, а не строки. Для неразбираемого документа остаётся консервативный построчный fallback. В env-артефактах секрет теперь вырезается и из URL-значений: HY2_AUTH_URL в post-install.env не подходит ни под один маркер имени ключа, но несёт access_token в значении. Семантическая проверка сгенерированного конфига: - добавлен quic.maxIdleTimeout - он был в production-профиле, но не проверялся, и конфиг с уехавшим idle timeout проходил проверку; - auth.http.url сверяется целиком (host/port/path/token), а не по наличию подстроки access_token=. Это единственный канал допуска пиров, уехавший порт или путь остались бы незамеченными; - сообщение об ошибке auth.http.url не печатает сам токен: текст уходит в логи и в diagnostics-бандл; - добавлены auth.http.insecure, поля ACME и запрет посторонних секций верхнего уровня. Маркеры секретных имён в Go-санитайзере расширены и синхронизированы с оркестратором. Формулировка гарантии сужена до честной: известные секреты и неизвестные поля с секретоподобным именем. |
||
|
|
2b4a2cb2d5 |
fix(install): двухфазная установка, clean-host контракт и проверка поколения
Установщик мог повредить работающий сервер до того, как откажется его
трогать: install.sh переписывал /usr/local/lib/hy2xs, раскладывал
runtime-пакет и перезаписывал install-state.json, и только потом
запускал clean-host preflight. При ошибочном запуске поверх старой
установки rollback дополнительно делал stop и disable для работающих
hysteria-server и hy2xs-admin.
Установка разделена на две фазы с жёсткой границей:
PHASE 0 - read only: права, checksums пакета, clean-host preflight
из распакованного архива (новая команда preflight-install)
PHASE 1 - mutation: раскладка оркестратора и сама установка
Граница держится не соглашением, а read-only guard: под ним writeText,
writeTextAtomic и мутирующие раннеры lib/process кидают ошибку.
Внутри install() preflight выполняется раньше первой записи состояния.
Остальное в этом же инварианте:
- clean-host контракт расширен с двух маркеров до четырнадцати, пути
установки и данных берутся из конфигурации, а не захардкожены;
- отсутствие HY2XS_CONFIG_SCHEMA_VERSION трактуется как legacy, а не
как текущая схема: до v1 этого поля не существовало. Тест,
закреплявший прежнее поведение, инвертирован;
- install-state несёт идентификацию поколения (product, release_line,
config_schema_version); reconfigure и repair проверяют её до всего
остального, потому что installed: true мог остаться и от 0.x;
- repair требует явного --allow-partial-state;
- классификация отказа опирается на ownership-флаги, а не на текст
ошибки: раньше сообщение со словом nftables приводило к откату
чужого firewall. stop/disable выполняется только для юнитов,
развёрнутых текущей операцией, а fatal_pre_apply не делает
системного отката и не собирает diagnostics-бандл.
|
||
|
|
ddf0ddf71e |
feat(v1): Gecko-обфускация, latest-stable Hysteria на сборке и forward-compatible admin
Сквозная миграция HY2XS на современную Hysteria (2.12.2) и переход на v1. Build: - версия Hysteria резолвится на этапе сборки из HyNetworks/hysteria и замораживается в metadata пакета (version + immutable url + sha256); - compatibility gate: реальный бинарник должен принять канонический конфиг HY2XS для gecko и salamander до создания пакета; - сборка прогоняет тесты оркестратора и админки. Конфигурационный контракт: - HY2XS_CONFIG_SCHEMA_VERSION=2, чужая схема отклоняется fail-fast; - obfs стал настоящим union gecko|salamander, gecko — default; - obfs-блок рендерится оркестратором целиком, два подтипа одновременно структурно невозможны; - современный baseline: congestion bbr/standard, disableLossCompensation=false, disableStatelessReset=false, полный quic-блок. Исправления: - share URI для gecko: генератор был завязан на Obfs.Salamander.Password и выдавал нерабочую ссылку при любой другой обфускации; - SNI брался только из ACME-блока и уходил пустым при HY2XS_TLS_MODE=file; - экспорт конфига выносил trafficStats.secret, access_token и obfs-пароль; - экспорт терял неизвестные upstream-поля при round-trip через типизированную модель; - renderRuntimeEnv печатал тип обфускации литералом, расходясь с конфигом; - namedotcom удалён из ACME-реестра (нет в Hysteria с 2.11.0). Тесты: - 95 тестов оркестратора: env, рендер, семантика профиля, резолвер, rollover; - тесты URI и экспорта в Go; - tools/test/e2e-hysteria.sh с реальным клиентом Hysteria. UX: - подсказки и примеры в форме создания пира. Прочее: CHANGELOG.md, .gitattributes (LF для target-side файлов), документация на русском. |
||
|
|
0205334cd8 |
tools/build/README: исправлены две битые ссылки
- ссылка на lib/deps.sh резолвилась как tools/build/tools/build/lib/deps.sh - удалена ссылка на несуществующий fix17.txt рядом с /proc/cpuinfo |
||
|
|
b903a09fb1 |
Позиционирование как самостоятельного продукта и переход на AGPL-3.0-only
HY2XS больше не описывается как форк H UI. Из README, docs, сообщений builder'а и post-install metadata убрана вся fork/H UI терминология. Лицензия: - LICENSE: MIT заменён на полный текст AGPL-3.0-only - README: бейдж и раздел лицензии, подпись Flamy Studio - orchestrator/package.json, apps/frontend/package.json: license - package.sh: LICENSE кладётся в install package, license=AGPL-3.0-only в metadata - verify.sh, acceptance.sh: проверки корневой AGPL и metadata Документация: - 04-admin-panel-h-ui-fork.md -> 04-admin-panel.md, переписан вокруг модели Hysteria2 = external runtime dependency, HY2XS admin = native HY2XS component - docs 01, 02, 03, 08, 09, 11, 12, README: единая терминология HY2XS admin post-install.env: - блок HUI_* заменён на HY2XS_ADMIN_*, HUI_FORK_REF -> HY2XS_ADMIN_SOURCE Внутренний legacy namespace (H_UI_* ключи SQLite, HUI_DATA/HUI_LOG, API /hui, h_ui_db.sql) намеренно не тронут: он требует отдельной миграции БД и выносится в отдельный этап. |
||
|
|
656cc7ca5b | Обновить README.md |