Files
HY2XS_flamy/CHANGELOG.md
T
founder 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 скриптов,
приёмка прогнана против дерева.
2026-08-28 05:27:10 +05:00

534 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Изменения HY2XS
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/).
Проект использует [семантическое версионирование](https://semver.org/lang/ru/).
Версия относится к самому HY2XS, а не к Hysteria: версия Hysteria выбирается
на этапе сборки пакета и фиксируется в его metadata.
## [Unreleased]
Hardening-проход перед релизом `1.0.0`. Основная тема — сделать политику
«только чистая установка» настоящим системным инвариантом, а не строчкой в
документации.
### Исправлено
- **Каждая чистая установка падала сразу после `apt-get`.** Внутри `install`
`preflight()` вызывался дважды, и оба раза проверял контракт чистого хоста.
Ко второму вызову на диске уже лежал собственный
`/var/lib/hy2xs/install-state.json`, записанный после первого preflight, — и
он опознавался как маркер посторонней установки. Отказ приходил уже как
`fatal_post_apply`: сервер оставался наполовину настроенным, а повторный
запуск упирался в тот же маркер.
Причина в том, что clean-host и проверка возможностей платформы ехали одним
параметром, хотя отвечают на разные вопросы: чистота хоста — условие **входа**
в операцию, а `systemd-run`/`nftables`/OpenSSL 3 проверяются уже после
`installDeps`, то есть внутри PHASE 1. `preflight()` теперь принимает
`checkCleanHost` явно и **без значения по умолчанию** в режиме install: любое
умолчание здесь неверно, решение обязано приниматься на месте вызова.
- **PHASE 1 начиналась вне зоны ответственности оркестратора.** `install.sh`
сам создавал `/usr/local/lib/hy2xs`, ставил туда бинарник, вешал symlink в
`/usr/local/bin` и копировал runtime-пакет — и только потом запускал
оркестратор, у которого дальше шёл собственный preflight. Если тот отказывал
(сменился DNS, занялся порт, не ответил резолвер), ни один ownership-флаг не
был взведён: отказ классифицировался как `fatal_pre_apply`, и оператор читал
«на сервере ничего не изменено» при уже созданном каталоге оркестратора.
Следующий запуск упирался в эти пути как в маркеры чужой установки.
Отследить владение мутацией невозможно, пока мутируют двое. Теперь
`install.sh` не изменяет на сервере **ничего**: он проверяет и передаёт
управление через `exec`. Раскладку выполняет сам оркестратор — шаг
`steps/bootstrap.ts` под флагом `ownership.bootstrapTouched`, а сами пути
попадают в `owned_paths` install-state наравне с остальными. Сборка проверяет
структурно, что в установщике не осталось ни одной мутирующей команды.
Побочный эффект: у списка clean-host маркеров больше нет «мягкой» версии для
PHASE 1. Она существовала только затем, чтобы установка не отказала на путях,
которые shell создал между фазами.
- **Machine token утекал в обычные логи при каждом подключении пира.** Hysteria
обращается к машинному endpoint'у как
`/internal/hysteria/auth?access_token=<секрет>`, а журнал админки писал
`c.Request.RequestURI` — то есть путь вместе с query string. Действующий
токен оседал открытым текстом в `/var/log/hy2xs/hy2xs-admin.log`, который
отдаётся оператору через `ExportLog` и попадает в diagnostics-бандл. Вся
структурная редакция, сделанная для конфигов и env, этот канал не закрывала.
Логируется путь; значения query-параметров не пишутся вовсе, имена —
пишутся (`reqQueryKeys`). Поле `reqUri` удалено из модели журнала.
Каналов было два: `gin.Default()` подключает `gin.Logger()`, который печатает
путь вместе с query в stdout, откуда он уходит в journald, а оттуда — в
diagnostics-бандл. Панель запускается через `gin.New()` + `gin.Recovery()`,
и HTTP-логгер у продукта остался ровно один.
Дополнительно: журналы внутри diagnostics-бандла (`journal-admin.log`,
`journal-hysteria.log`, вывод `systemctl status`) больше не копируются как
есть, а проходят санитайз; тот же проход применяется к журналу Hysteria,
который админка отдаёт через `ExportLog`. Сравнение machine token переведено
на `subtle.ConstantTimeCompare`.
- **Config API позволял прочитать и подменить криптографические ключи
приложения.** Generic export/import таблицы `config` удалили, но точечный API
остался прежним: `getConfig`/`listConfig` принимали произвольный ключ, а
проверка записи работала denylist'ом из трёх ключей оркестратора. Запрос
`?key=PEER_SECRET_ENCRYPTION_KEY` отдавал master-key шифрования секретов
пиров, а `updateConfigs` позволял подменить `JWT_SECRET` и оба peer-ключа.
Доступ переведён на **allowlist**: наружу открыты только
`HYSTERIA2_TRAFFIC_TIME`, `RESET_TRAFFIC_CRON` (чтение и запись) и
`HYSTERIA2_CONFIG_REMARK` (только чтение). Denylist требует, чтобы автор
каждого нового ключа вспомнил про этот файл; при allowlist забытый ключ
закрыт. Маршрут `GET /api/config/getConfig` удалён целиком — потребителей у
него не было ни одного, а фильтр на неиспользуемой двери остаётся дверью.
- **Импорт пиров не был атомарным, вопреки собственному контракту.** Партия
проверялась целиком до первой записи, но применялась по одной записи, каждая
своим оператором. Валидация ничего не знает о том, что уже лежит в базе:
пусть есть `A(auth_id=aaa, name=alice1)` и `B(auth_id=bbb, name=bob123)`, а
файл несёт `(auth_id=aaa, name=bob123)` — поиск найдёт A по `auth_id` и
попытается переименовать её в `bob123`, прямо в `UNIQUE(name)`. Всё, что шло
в файле до конфликтной строки, оставалось применённым, и откатить это
оператор уже не мог.
Применение выполняется одной транзакцией (`dao.WithPeerTx`). Криптоматериал
считается до её открытия: digest и шифрование читают ключи из той же таблицы
`config`, и держать на ней открытую запись во время AES по каждой из тысяч
записей незачем.
- **Файл импорта мог содержать хвост, который молча не применялся.**
`json.Decoder` читает первый документ и останавливается, поэтому файл вида
`[{...}]\n{"что-то":"ещё"}` принимался целиком: оператор видел «импорт
выполнен» и не узнавал, что применилась половина. После разбора проверяется
`io.EOF`.
- **Отказ сбора диагностики отменял откат.** В `install` и `reconfigure`
`diagnosticsCollect()` стояла перед rollback обычным `await`. Она создаёт
каталог, копирует файлы и упаковывает tar — на заполненном диске падает сама,
и тогда худший сценарий отказа установки гарантированно лишался единственного
механизма восстановления. Диагностика — best effort, откат — обязателен.
- **`fatal_pre_apply` мог означать «хост уже изменён».** `install-state.json`
пишется сразу после успешного preflight, до установки пакетов, но
классификация отказа его не учитывала. Падение `apt-get update` или
`apt-get install` объявлялось как «на сервере ничего не изменено»: откат и
обработка состояния пропускались, а маркер оставался на диске и ломал
следующую установку по clean-host контракту.
Ownership-флаги переформулированы с «шаг успешно завершился» на «операция
могла начать менять систему» и взводятся **перед** мутирующим вызовом:
`apt-get` умеет изменить систему и упасть. `fatal_pre_apply` теперь
недостижим ни при одном взведённом флаге, включая запись состояния.
- **Экспорт в админке оставлял секреты на диске навсегда.** `ExportPeer` и
выгрузка системного конфига шли через `os.Create` в
`/var/lib/hy2xs-admin/export/`, и файл там не удалялся. При
`?includeSecrets=true` это означало расшифрованные секреты пиров — фактические
учётные данные доступа — в открытом виде, накапливающиеся с каждым нажатием
кнопки. Экспорт формируется в памяти; каталога `export/` больше нет.
- **Generic export/import таблицы `config` выгружал и позволял подменить
криптографические ключи приложения.** Выгрузка исключала только сырой Hysteria
YAML, а в той же таблице лежат `JWT_SECRET`, `PEER_SECRET_KEY`,
`PEER_SECRET_ENCRYPTION_KEY` и `HYSTERIA2_TRAFFIC_STATS_SECRET`. Импорт их не
блокировал: подмена `PEER_SECRET_ENCRYPTION_KEY` ломает расшифровку секретов
уже существующих пиров. Оба маршрута и их UI удалены — production-сценария у
них не было, перенос пиров делают `peer-import`/`peer-export`.
- **Импорт пиров шёл мимо всей валидации.** Обычное создание пира проходит через
`dto.PeerSaveDto`, импорт JSON — нет: в базу попадало имя любой длины и с
любыми символами, `disabled` с произвольным числом, отрицательные счётчики.
Файл применялся построчно, поэтому ошибка в середине оставляла список пиров
наполовину изменённым, а импорт мог перезаписать `bootstrap-admin-peer`, чей
секрет продублирован в `/etc/hy2xs/bootstrap-admin.secret`. Партия теперь
проверяется целиком до первой записи, неизвестные поля отклоняются,
bootstrap-пир защищён.
- **DNS проверялся на существование A-записи, но не на то, куда она ведёт.**
После принудительной смены IPv4 провайдером `doctor` отвечал успехом, хотя
клиентская ссылка отправляла людей на чужую машину. Проверялся при этом
`HY2XS_DOMAIN`, тогда как в `hysteria2://` уезжает `HY2XS_PUBLIC_HOST`.
Добавлен инвариант публичного endpoint: A-записи обязаны принадлежать
множеству публичных IPv4, назначенных интерфейсам этого сервера. Проверка
живёт в общем `preflight`, поэтому действует в `install`, `reconfigure` и
`doctor`. Адрес определяется локально, без внешних сервисов определения IP.
Строгость управляется `HY2XS_PUBLIC_ENDPOINT_POLICY` (`strict` по умолчанию).
- **Read-only guard PHASE 0 можно было обойти.** Guard стоял на `writeText`,
`writeTextAtomic`, `runVisible`, `runHidden` и `runRawVisible`, но не на
универсальном `run`, через который в коде проходили и наблюдение (`ss`,
`systemctl is-active`), и настоящие мутации (`useradd`, `install -d`,
`mkdir`, `cp -a`, `tar`). Универсального раннера больше нет: есть
`runReadOnly*` без guard'а и `runMutating*` под guard'ом, а выбор — явное
решение на месте вызова.
- **Go-санитайзер конфига вырезал секреты из URL только у ключей `url`/`addr`.**
Будущее upstream-поле с другим именем (`endpoint:`) уносило встроенные
учётные данные и `access_token` наружу целиком; URL внутри списков не
обрабатывались вовсе. Граница определяется значением, а не именем ключа —
как в TS-санитайзере оркестратора; обе реализации покрыты зеркальными тестами.
- **`purge-v0.sh --keep-hysteria-binary` противоречил установщику.** Скрипт
сохранял `/usr/local/bin/hysteria` и сообщал «хост чист для установки
HY2XS v1», хотя clean-host контракт считает этот бинарник legacy-маркером и
следующая установка отказалась бы. Флаг удалён.
- **clean-host не замечал часть того, что удаляет purge.** `/var/lib/hysteria`
(ACME-состояние и сертификаты Hysteria), `/var/log/hy2xs`,
`/usr/local/lib/hy2xs` и `/usr/local/bin/hy2xs-orchestrator` не были
маркерами: сервер, где остался только старый runtime-state Hysteria, проходил
проверку и получал свежую установку поверх чужого состояния. Оба списка
теперь описывают одну границу, и приёмка это проверяет.
- **Установщик мог повредить работающий сервер до того, как откажется его
трогать.** `install.sh` переписывал `/usr/local/lib/hy2xs`, раскладывал
runtime-пакет и перезаписывал `/var/lib/hy2xs/install-state.json`, и лишь
потом запускал clean-host preflight. При ошибочном запуске поверх старого
сервера rollback дополнительно выполнял `stop` и `disable` для работающих
`hysteria-server` и `hy2xs-admin`.
Установка разделена на две фазы с жёсткой границей: **PHASE 0 — read only**,
**PHASE 1 — mutation**. Read-only проверка выполняется новой командой
`hy2xs-orchestrator preflight-install` из распакованного архива, а граница
держится runtime-guard'ом, а не соглашением.
- **Отсутствие `HY2XS_CONFIG_SCHEMA_VERSION` считалось текущей схемой.** До v1
этого поля не существовало, поэтому именно пустое значение — самый вероятный
признак конфигурации `0.x`. Теперь оно отклоняется как legacy с указанием на
чистую установку. Тест, закреплявший прежнее поведение, инвертирован.
- **`reconfigure` и `repair` работали поверх любого маркера установки.**
Проверялся только флаг `installed`, который мог остаться и от `0.x`.
Маркер получил идентификацию поколения (`product`, `release_line`,
`config_schema_version`), и обе команды проверяют её до всего остального.
- **Классификация отказа шла по тексту сообщения об ошибке.** Ошибка
preflight со словом `nftables` классифицировалась как отказ firewall и
приводила к откату чужого ruleset. Теперь классификация опирается на то, что
операция реально успела применить. `systemctl stop/disable` выполняется
только для юнитов, развёрнутых текущей операцией, а `fatal_pre_apply` по
определению не выполняет системный откат и не собирает diagnostics-бандл.
- **Diagnostics-бандл уносил machine token наружу.** Построчное правило
редакции `auth:` подставляло маркер в заголовок mapping'а и оставляло
нетронутым вложенный `auth.http.url` с `access_token=<секрет>` — тем самым,
что открывает и trafficStats API, и auth-endpoint. Редакция YAML переписана
структурно; в env-файлах секреты теперь вырезаются и из URL-значений
(`HY2_AUTH_URL` не подходил ни под один маркер имени).
- **`quic.maxIdleTimeout` не проверялся** семантической проверкой конфига, хотя
присутствовал в production-профиле. Заодно `auth.http.url` теперь сверяется
целиком (host/port/path/token), а не по наличию подстроки `access_token=`;
добавлены проверки `auth.http.insecure`, полей ACME и отсутствия посторонних
секций верхнего уровня.
- **Версия админки разъехалась с версией пакета**: пакет `1.0.0` сообщал
`HY2XS admin version v0.0.22`. Константа заменена переменной, которую
проставляет сборка через ldflags из `versions.env`.
- **Кнопки в панели, которые всегда возвращали ошибку.** «Перезапустить панель»
и загрузка сертификатов обращались к заглушкам. Маршруты и UI удалены.
### Добавлено
- **`versions.env`** — единственный источник истины для контракта
«продукт / платформа / toolchain»: версия продукта, линия релиза, схема
конфигурации, целевая платформа, версии и контрольные суммы Go/Bun/Node/pnpm,
политика выбора Hysteria. Прикладные зависимости и конкретная версия
Hysteria сюда намеренно не переносятся: у них есть собственные lock-механизмы.
- **Шаг сборки `verify_versions_contract`.** Роняет сборку до создания tarball,
если разошлись `PACKAGE_VERSION`, `packageManager` в двух `package.json`,
схема в `package/config/hy2xs.env`, константы, скомпилированные в
оркестратор, директива `go` в `apps/go.mod`, metadata пакета или версия,
которую сообщает собранный `hy2xs-admin`.
- **Контрольные суммы toolchain в контракте**, включая **обе** сборки Bun
(`bun-linux-x64` и `bun-linux-x64-baseline`): артефакт выбирается по наличию
AVX2, поэтому одной суммы архитектурно недостаточно. Передавать суммы через
окружение больше не нужно — production-сборка запускается одной командой.
- **Проверка происхождения артефакта Hysteria.** Ожидаемый SHA-256 берётся из
upstream-ассета `hashes.txt` и сверяется со скачанным бинарником до записи в
HY2XS lock. Раньше сумма считалась локально от уже скачанного файла, то есть
была trust-on-first-use.
- **Полный clean-host контракт.** Список маркеров чужой установки расширен с
двух до четырнадцати: состояние, runtime-пакет, конфиги, бинарник Hysteria,
фрагмент nftables, systemd-юниты, база админки и наследие `0.x`. Пути
установки и данных берутся из конфигурации, а не захардкожены.
- **`tools/legacy/purge-v0.sh`** и [docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md) —
явная очистка сервера от предыдущего поколения. По умолчанию скрипт
показывает план и ничего не делает; выполнение требует
`--apply --yes-i-know`. Из установщика он не вызывается никогда: это вернуло
бы destructive migration logic в путь свежей установки.
- **Явный флаг `--allow-partial-state` для `repair`.** Прежде согласие на
работу поверх незавершённой установки подразумевалось молча.
- **`HY2XS_PUBLIC_ENDPOINT_POLICY`** (`strict` | `warn` | `off`, по умолчанию
`strict`) — строгость проверки того, что публичный endpoint ведёт на этот
сервер. Ослабление предназначено для топологий вне baseline: NAT, floating IP,
anycast. Отсутствие A-записи фатально при любом значении.
- **Раздельные API подпроцессов в оркестраторе**: `runReadOnly` /
`runReadOnlySecret` для наблюдения и `runMutating*` под read-only guard'ом.
- **Сверка API namespace на сборке.** Путь machine-auth и базовый префикс
админского API объявлены по одной константе на компонент, а
`verify_versions_contract` сверяет Go, фронтенд и шаблоны против значений,
скомпилированных в оркестратор.
### Изменено
- **Пространства имён HTTP API.** Операторский и auth API переехали с `/hui` на
`/api`, machine-auth endpoint Hysteria — на `/internal/hysteria/auth`. Прежний
общий префикс был наследием H UI: под ним лежали и machine-to-machine auth, и
JWT-защищённый админский API, хотя middleware у них не пересекаются. Момент
выбран до первого clean-install релиза: после `1.0.0` эти строки стали бы
частью фактического v1 compatibility contract.
- **Сетевая идентичность админки принадлежит оркестратору.** Ключи
`H_UI_WEB_PORT`, `H_UI_WEB_CONTEXT`, `H_UI_CRT_PATH`, `H_UI_KEY_PATH` удалены
из схемы, seed и интерфейса вместе с собственным TLS-слоем панели. Раньше
оркестратор передавал порт аргументом, админка записывала его в SQLite и тут
же читала обратно, а UI показывал поля в disabled-виде: второй источник истины,
из которого ничего нельзя было изменить. Панель всегда монтируется в `/`.
- **`HUI_DATA`/`HUI_LOG``HY2XS_DATA_DIR`/`HY2XS_LOG_DIR`.** Мост в
systemd-юните, перекладывавший canonical env HY2XS в имена старого H UI,
удалён.
- **Экспорт пиров разделён на два явных режима.** «Экспорт настроек» — без
секретов, «Резервная копия» — с ними, через подтверждение с описанием риска.
Кнопка была одна и всегда звала маршрут без `includeSecrets`, хотя
документация называла эту пару механизмом переноса пиров. Записи с пустым
секретом при импорте получают **новые** секреты, поэтому перенос обычным
экспортом восстанавливал пиров, но все существующие клиентские ссылки после
него переставали работать. Разница продуктовая, и оставлять её неявной нельзя.
- **`reconfigure`/`repair` больше не классифицируют отказ по тексту ошибки.**
Записываемая фаза выбиралась регулярным выражением
`/firewall|nft|ssh port check failed/i` по сообщению — тот же приём, который
уже убрали из `install`. Классификация переведена на ownership-флаги, а откат
firewall выполняется только если эта операция его трогала.
- **Список непубличных IPv4 приведён к IANA Special-Purpose Address Registry.**
Функция называлась «маршрутизируемый публичный IPv4», а исключения покрывали
только приватные диапазоны: `203.0.113.5` (TEST-NET-3 из RFC-примеров)
считался нормальным публичным адресом сервера. Добавлены документационные
(`192.0.2/24`, `198.51.100/24`, `203.0.113/24`), benchmarking (`198.18/15`),
6to4-anycast и IETF protocol assignments.
- **Отказ DNS-резолвера отличается от отсутствия A-записи.** Любая ошибка
`resolve4` печаталась как «has no A-record», поэтому при сломанном
`/etc/resolv.conf` оператор шёл править запись, которая была на месте.
`ENODATA`/`ENOTFOUND`/`NXDOMAIN` — это «нет записи», всё остальное —
«резолвер не ответил», с отдельным текстом. Фатальны оба: без ответа
резолвера проверка не выполнена, а не «выполнена с замечанием».
- **База админки — `hy2xs-admin.db`** вместо `h_ui.db`; reference-схема —
`apps/docs/sql/schema.sql` вместо `h_ui_db.sql`. Совместимость сохранять не
требуется: v1 ставится только с нуля. Историческое имя `h_ui.db` остаётся в
[docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md) — там это имя чужого
артефакта, который очистка должна найти.
- **Индикатор загрузки и legacy-цвета переведены на брендовый токен.**
NProgress приходил со своим `#29d` и был единственным элементом интерфейса вне
палитры HY2XS; страницы `401`/`404` и подсветка выбранной строки таблицы несли
цвета исходного admin-шаблона. Все они привязаны к `--el-color-primary`, а не
переписаны вторым литералом.
- **Приёмка сверяет схему конфигурации с `versions.env`**, а не с числом `2`
в тексте проверки.
- **E2E подключается по ссылке из production-кода.** Внутри
`tools/test/e2e-hysteria.sh` жила вторая реализация `hysteria2://` URI на
bash: дрейф любой из двух реализаций оставлял обе группы тестов зелёными.
Теперь ссылку выдаёт `service.BuildHysteria2ShareURI` через
`apps/tools/share-uri`. Единственное расхождение — `insecure=1` для
самоподписанного сертификата, и оно ограничено тестами с двух сторон.
- **Формулировка гарантии санитайза экспорта.** Вместо «любой будущий секрет
будет удалён» — «известные секреты и неизвестные поля с секретоподобным
именем». Список маркеров расширен (`apiKey`, `privateKey`, `authorization`,
`cookie`, `bearer`, `passphrase`, `signature`, …) и синхронизирован между
Go-админкой и оркестратором.
### Удалено
- Маршруты, операциями которых продукт не владеет:
`POST /hysteria2ChangeVersion`, `GET /listRelease`,
`POST /config/updateHysteria2Config`, `POST /config/importHysteria2Config`,
`POST /config/restartServer`, `POST /config/uploadCertFile`,
`GET /config/hysteria2AcmePath`. Вместе с ними — соответствующие сервисы,
клиентские функции фронтенда, кнопки и строки i18n.
Маршруты удалены, а не оставлены отвечающими «feature disabled»: API-контракт
не должен обещать updater, которого у продукта нет, а неиспользуемый маршрут
остаётся attack surface.
- `POST /config/exportConfig` и `POST /config/importConfig` — generic-выгрузка и
загрузка таблицы `config` вместе с криптографическими ключами приложения.
Вместе с ними — кнопки Import/Export в настройках, клиентские функции и
строки i18n.
- Персистентный каталог выгрузок `/var/lib/hy2xs-admin/export/` и
файловый helper `util.ExportFile`. Артефакт, который покидает сервер, не
должен существовать на сервере дольше самого запроса.
- Флаг `purge-v0.sh --keep-hysteria-binary`.
- Мёртвые строки i18n, оставшиеся от H UI: `noHttpsTip`, `defaultPassTip`,
`hui*`, `useHysteria2Cert`, `invalidWebContext`, `mustBeInteger`.
- `GET /api/config/getConfig` — точечное чтение произвольного ключа таблицы
`config`. Потребителей у маршрута не было ни одного, а список ключей в этой
таблице включает `JWT_SECRET`, `PEER_SECRET_KEY` и
`PEER_SECRET_ENCRYPTION_KEY`. Вместе с ним удалены `dto.ConfigDto`,
клиентская функция `getConfigApi` и её тип.
- Compatibility-слой аккаунтов предыдущего поколения: сущность
`entity.LegacyAccount`, миграции `002_migrate_legacy_accounts` и
`003_archive_legacy_account`, а также мёртвые helpers `listSQLMigrationFiles`
и `envInt`. HY2XS v1 не мигрирует базу `0.x` ни при каком сценарии, и
clean-host контракт отказывает ещё до создания базы — живого пути, по
которому таблица `account` могла бы оказаться в `hy2xs-admin.db`, не
существует. Номера оставшихся миграций сохранены: перенумерация заставила бы
их примениться повторно.
В `docs/14-legacy-cleanup.md` имена предыдущего поколения остаются — там они
обозначают реальные объекты, которые нужно удалить с сервера. Из остальных
v1-доков этот словарь убран.
## [1.0.0] — 2026-08-27
Первый релиз линейки `v1`.
> **Обновление с `0.x` не поддерживается.**
> Между `0.x` и `1.0.0` изменились схема конфигурации, тип обфускации
> по умолчанию и контракт выбора версии Hysteria. Сервер, установленный
> из пакета `0.x`, нужно поднимать заново: очистка и установка с нуля.
> Подробности — в разделе «Миграция с 0.x» ниже.
### Добавлено
- **Разрешение версии Hysteria на этапе сборки.** Builder по умолчанию сам
определяет последний стабильный upstream-релиз, скачивает артефакт,
вычисляет SHA-256 и замораживает `version` + `url` + `sha256` в metadata
пакета. Target-сервер по-прежнему скачивает конкретный неизменяемый
артефакт и никогда не обращается к moving `latest`.
- **Compatibility gate в сборке.** До создания release-пакета builder
рендерит канонический конфиг HY2XS тем же кодом, что и оркестратор, и
запускает с ним реальный бинарник Hysteria — для обоих профилей
обфускации. Несовместимый upstream ломает сборку, а не сервер оператора.
- **Поддержка Gecko-обфускации** (Hysteria 2.9.2+) со сквозной интеграцией:
оркестратор, шаблон конфига, модель админки, генерация `hysteria2://` URI,
типы и формы фронтенда.
- **Версия схемы конфигурации** `HY2XS_CONFIG_SCHEMA_VERSION=2`. Пакет
отказывается работать с конфигурацией неизвестной схемы вместо того,
чтобы молча применить чужие значения.
- **Современный серверный baseline** в генерируемом конфиге:
`congestion.type: bbr` + `bbrProfile: standard`,
`bandwidth.disableLossCompensation: false`,
`quic.disableStatelessReset: false`, а также
`maxIdleTimeout`, `maxIncomingStreams`, `disablePathMTUDiscovery`.
- **Модель современной схемы Hysteria в админке**: `obfs.gecko`, `ech`,
`congestion`, `mimic`, `realm`, `tls.clientCA`,
`quic.disableStatelessReset`, `bandwidth.disableLossCompensation`,
`masquerade.proxy.xForwarded`. Поля читаются и отображаются, даже если
HY2XS не включает их в default-профиль.
- **Тесты оркестратора** (`bun test`): разбор env, рендер конфига,
семантические инварианты профиля, резолвер upstream-релизов,
release rollover.
- **E2E-проверка с реальным клиентом Hysteria** — `tools/test/e2e-hysteria.sh`:
TLS и obfs handshake, HTTP auth (допуск и отказ), TCP и UDP forwarding,
trafficStats, per-peer accounting, переподключение после перезапуска
сервера и подключение клиента именно по сгенерированной ссылке.
- **`CHANGELOG.md`** в корне репозитория.
### Изменено
- **Обфускация по умолчанию для новых установок — Gecko.** Salamander
остаётся полностью поддержанным режимом совместимости и выбирается через
`HY2XS_HYSTERIA_OBFS_TYPE=salamander`.
- **Gecko использует upstream-defaults `512/1200`** и не выносит размеры
пакетов в env: официальная URI-схема не умеет их передавать, поэтому
нестандартные значения сделали бы клиентскую ссылку неполной.
- **Тип обфускации больше не собирается внутри статического YAML.**
Оркестратор формирует проверенный `obfs`-блок целиком, поэтому
комбинация вида `type: gecko` рядом с блоком `salamander` структурно
невозможна.
- **Экспорт конфига Hysteria работает от исходного YAML**, а не от
типизированной модели: поля, о которых HY2XS ещё не знает, переживают
выгрузку.
- **Smoke-проверки разбирают YAML** и сверяют его с production-профилем,
вместо поиска подстрок.
- **Канонический upstream-репозиторий** — `HyNetworks/hysteria`
(вместо устаревших ссылок на `apernet`).
- **Реестр ACME DNS-провайдеров во фронтенде** приведён к актуальному
upstream: добавлены `namecheap`, `njalla`, `porkbun`.
- **Дефолты формы Hysteria во фронтенде** отражают baseline HY2XS
(50/50 Mbps, Let's Encrypt, каталог ACME), а не пример из upstream-доки.
- Сборка запускает тесты оркестратора и админки до упаковки.
### Исправлено
- **`hysteria2://` для Gecko.** Генератор ссылок был завязан на
`Obfs.Salamander.Password`, поэтому при любой другой обфускации выдавал
формально корректную, но неработающую ссылку без параметров `obfs`.
- **SNI в клиентской ссылке при файловых сертификатах.** SNI брался только
из ACME-блока, поэтому при `HY2XS_TLS_MODE=file` уходил пустым. Теперь
источник — ACME-домен, затем `HY2XS_DOMAIN`, затем `HY2XS_PUBLIC_HOST`;
IP-адрес в качестве SNI не используется.
- **Утечка секретов в экспорте конфига.** Выгружаемый оператору YAML
содержал `trafficStats.secret`, `access_token` в auth-URL и пароль
обфускации. Секреты вырезаются, включая поля, о которых HY2XS ещё
не знает.
- **Расхождение runtime-конфига с разобранным.** `renderRuntimeEnv`
печатал тип обфускации и режим auth литералами, игнорируя фактическую
конфигурацию, — из-за чего запись `/etc/hy2xs/hy2xs.env` могла разойтись
с тем, что реально применено.
- **Смешение веток `obfs` в UI.** Форма склеивала дефолт с ответом API и
показывала блок обфускации, которого нет в конфиге сервера.
- **`namedotcom` в списке ACME DNS-провайдеров.** Провайдер удалён из
Hysteria в 2.11.0; конфигурация с ним больше не запускается.
- **Отсутствие подсказок в форме создания пира.** У полей «Пир»,
«Комментарий» и «Секрет» не было ни примеров, ни пояснений: оператор не
мог понять без документации, что секрет необязателен и генерируется
автоматически.
### Безопасность
- Переход на Hysteria 2.12.2 закрывает исправления, вышедшие после 2.8.2,
включая обход UDP ACL, возможный OOM через sniff и обход ACL через
домены с завершающей точкой (2.9.2).
- Экспорт конфига больше не выносит секреты за пределы сервера.
### Миграция с 0.x
Автоматическая миграция не предусмотрена и не планируется.
Порядок перехода:
1. Выпишите с работающего сервера список пиров и их секреты.
2. Очистите сервер: `tools/legacy/purge-v0.sh` или ручная процедура из
[docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md).
3. Разверните `1.0.0` на чистом Debian 13 из release-пакета.
4. Заведите пиров заново и раздайте новые клиентские ссылки.
Установщик `1.0.0` обнаружит остатки предыдущей установки на шаге PHASE 0,
откажется работать и **не изменит на сервере ничего**.
Клиентские ссылки `0.x` в любом случае перестанут работать: смена
обфускации — это изменение wire-совместимости.
[Unreleased]: https://git.flamy.studio/prod/HY2XS_flamy/compare/v1.0.0...HEAD
[1.0.0]: https://git.flamy.studio/prod/HY2XS_flamy/releases/tag/v1.0.0