Верхняя граница пароля была объявлена в 64 СИМВОЛА и обоснована пределом
bcrypt в 72 БАЙТА. Обоснование верно только для ASCII: у 64 символов длина от
64 до 256 байт. golang.org/x/crypto@v0.55.0 (bcrypt.go:96) отвечает на пароль
длиннее 72 байт ErrPasswordTooLong, а не «молча отбрасывает остаток», как
утверждал комментарий, — так вела себя редакция пакета до v0.28.
Следствие: пароль из 64 кириллических букв (128 байт) проходил панель,
оркестратор и DTO, а отказ приходил из хеширования — системной ошибкой на
штатной смене пароля, а при установке падением старта админки, то есть
сервером без администратора после INSTALL EXIT CODE: 0. Хуже самого дефекта
было то, что тест закреплял это значение как ожидаемое.
Вместе с ним закрыты три соседних расхождения того же контракта.
Пароль триммился вопреки собственному контракту. util.HashPassword вёл
проверку len(strings.TrimSpace(password)) < 6, а bootstrap читал
strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")). Значение
"abcde " принимали все двери продукта и не мог захешировать никто, а первая
учётная запись создавалась не с тем паролем, который оператор записал в
hy2xs.env.
Панель считала длину в единицах UTF-16. Element Plus делегирует правила формы
async-validator, а он сравнивает min/max с String.prototype.length: пароль из
трёх эмодзи имел length 6, проходил минимум формы и получал отказ сервера,
который панель не могла объяснить.
hy2xs.env не был форматом. Значения писались интерполяцией, а читались
split("=") с trim(); при этом файл читает не только оркестратор — он объявлен
EnvironmentFile= в юните hy2xs-admin, и у незакавыченного значения systemd
срезает краевые пробелы и трактует обратный слеш как escape.
Что сделано:
- контракт переехал в leaf-пакет apps/credential: его зовут util.HashPassword
и dao, а service импортирует util — обратный импорт был бы циклическим, и
именно поэтому HashPassword завёл собственную копию правила;
- AdminPasswordMaxBytes = 72 объявлен отдельной константой и зеркально в
оркестраторе и панели; сверяется тестами, читающими Go-исходник;
- одно правило adminPassword вместо min=6,max=64 в тегах DTO (границу в
байтах тегом валидатора не выразить) и код причины admin_password_format,
называющий обе границы;
- TrimSpace убран из хеширования и из bootstrap-пути; bootstrap проверяет
контракт сам и падает с текстом, называющим переменную и файл;
- панель считает code points и UTF-8 байты общим adminPasswordFormRule на
обеих формах вместо встроенных min/max;
- orchestrator/src/lib/envFile.ts — порт конечного автомата
parse_env_file_internal из systemd и обратный ему кодировщик; экранируются
только обратный слеш и двойная кавычка, оба из SHELL_NEED_ESCAPE. Обычные
значения остаются без кавычек, поэтому релизные гейты не меняются. Тем же
кодировщиком пишется bootstrap-admin.secret;
- управляющие символы запрещены контрактом: формат KEY=VALUE их не несёт, а
ввести такой пароль в форму входа всё равно нельзя;
- отрицательная проба smoke сверяет конверт отказа (code 50000,
invalid_credentials, отсутствие accessToken) вместо HTTP 200, а пароль
генерирует, а не берёт из литерала;
- положительная проба читает bootstrap-секрет парсером формата вместо
grep | cut -d= -f2- с trim() — третьего по счёту слоя, срезавшего пробелы.
Тесты: граничная таблица (36 x «я», 37 x «я», 18 и 19 эмодзи, 64 x «я»,
«abcde ») прогоняется в четырёх слоях; тест с 64 кириллическими буквами
инвертирован; round-trip env-формата на значениях с кавычками, слешами и
краевыми пробелами; bootstrap-путь на настоящей SQLite. 14 новых гейтов
приёмки.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RC2 на чистом Debian 13 завершался INSTALL EXIT CODE: 0 при полностью
недоступной панели. На LoginDto.Username стоял тег `validateStr` — правило с
таким именем не регистрировалось: при переименовании в `credentialStr` правка
не доехала до одного файла, оставив мёртвую регистрацию и живую ссылку на
несуществующее имя. go-playground/validator на неизвестный тег ПАНИКУЕТ при
разборе структуры, то есть до всякой проверки логина и пароля, а gin.Recovery
превращал панику в HTTP 500 на каждый POST /api/auth/login.
Дефект пережил 311 Go-тестов, и это главное, что здесь чинится. Проверялся сам
регексп, в обход валидатора, а обработчика входа не касался ни один тест.
Очевидная замена не помогла бы: цепочка правил поля обрывается на первом
несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного
тега не доходит. Теперь TestEveryValidationTagIsRegistered обходит исходники
apps/model/**, вытаскивает каждый тег `validate:"…"` и предъявляет его
валидатору отдельно — незарегистрированное правило паникует так же, как в бою,
но на сборке. Барьер проверен возвратом исходного тега.
Установка тоже не отвечала на вопрос, ради которого проверялась. Smoke считал
панель работающей по трём признакам — юнит активен, порт в LISTEN, /healthz
отвечает ok, — и все три были истинны. Теперь smoke выполняет настоящий вход
bootstrap-учётными данными и требует конверт успеха с непустым токеном: по коду
HTTP это неотличимо, админка отвечает 200 OK и на отказ. Отрицательная проба
идёт в любом режиме операции и от актуальности пароля не зависит.
Рядом лежали три расхождения того же класса, найденные при разборе.
Оркестратор не знал контракта, который сам порождает: HY2XS_ADMIN_USER по
умолчанию был `admin` — пять символов при минимуме панели в шесть, — и такая
установка проходила целиком, создавая учётную запись, под которой невозможно
войти. Про одно имя существовало три расходящихся умолчания. Оба значения
теперь проверяются при разборе окружения — той стороной, которая их порождает:
отказ, пришедший установщику, чинится строкой в hy2xs.env, а неработающий вход
на готовом сервере — переустановкой.
Панель была строже сервера. Форма входа ограничивала пароль 32 символами при
серверном пределе в 64, а форма смены пароля назначала до 64: пароль,
назначенный штатной операцией, после этого не вводился. Набор символов на
пароле отвергал значение, которое сервер принял бы, — сервер его не
ограничивает нигде. Контракт учётных данных объявлен один раз в
service/admin_credentials.go, копии в панели и оркестраторе сверяются с ним
тестами, читающими Go-исходник.
Класс символов логина был записан диапазоном по опечатке: неэкранированный
дефис превращал `+-=` в диапазон, впускающий `, - . / 0-9 : ; < =`. С серверным
набором это совпадало только потому, что обе стороны несли одну опечатку. Набор
записан явно и НЕ сужен — он уже действует на установленных серверах.
Визуально: красная рамка отказа обводила не то, что видит оператор. Element Plus
рисует состояние ошибки на el-input__wrapper селектором из четырёх классов, а
форма входа рисует видимую рамку поля на el-form-item — внутрь поля кладутся
иконка, ввод и переключатель видимости — и гасила чужую тень селектором из трёх,
проигрывая по специфичности. Рамка ложилась вокруг одного лишь ввода: у логина
начиналась после иконки, у пароля обрывалась перед «глазом». Индикация
перенесена на элемент, который оператор и видит полем; чужая тень гасится
селектором, повторяющим её собственный и добавляющим атрибут scoped-стиля, —
конкретностью, а не !important. Остальные формы панели проверены: собственная
рамка на el-form-item есть только на форме входа.
Заодно: `last_login_at` объявлен в схеме и в entity, а писать его было некому —
UpdateAdminLastLoginAt не вызывался ниоткуда. Отметка ставится в service.Login
сразу после успешной проверки пароля; отказ записи вход не отменяет, но
попадает в журнал. Обработчик входа переехал из controller/peer.go в
controller/auth.go: стек в journal указывал на управление пирами.
Требование теперь называется, а не сообщается фактом нарушения. «Неверный
формат логина» и «Некорректное значение» не давали оператору способа узнать,
что от него хотят: набор символов приходит из hy2xs.env и в панели нигде не
показан. Фразы форм и серверная причина credential_format перечисляют границы
и набор.
Гейт сборки run_admin_login_acceptance удерживает барьеры от тихого удаления —
по той же причине, что и гейт детектора гонок. Каждое из его утверждений
проверено мутационной пробой на реальный отказ; две первые редакции оказались
вакуумными и переписаны.
Прогнано: go vet + go test ./... , bun test оркестратора (427) и контрактов
панели (66), vue-tsc --noEmit, production-сборка frontend, гейт приёмки
целиком. `go test -race` не прогонялся — на машине нет C-компилятора, это
релизный гейт сборщика.
Прогон задокументирован в
docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md.
Снятие автоматического отката 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 не существовал.
Два свойства были описаны в документации, но не обеспечены кодом.
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: до неё появление дубликата было ненаблюдаемым.
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-значение
определяется по самому значению, а не по имени ключа.
Сквозная миграция 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 файлов),
документация на русском.