63 Commits

Author SHA1 Message Date
Crimson 95f81c2858 docs(release): уточнить фактическую схему promotion 2026-09-15 02:30:39 +05:00
Crimson 041f88b6a2 docs(release): зафиксировать stable promotion 2026-09-15 02:19:24 +05:00
Crimson 5c1dd24111 docs(release): зафиксировать финальную приёмку rc5 2026-09-15 01:44:28 +05:00
Crimson b7e478f014 fix(rc5): harden frontend runtime and diagnostics 2026-09-08 05:22:06 +05:00
Crimson 079094591b fix(installer): harden admin smoke and rollback cleanup 2026-09-07 22:39:30 +05:00
Crimson bf10810cfc fix(build): remove pipefail-unsafe matchers 2026-09-07 20:34:50 +05:00
Crimson 6c286d1ff5 fix(build): align runtime env acceptance gate 2026-09-07 20:21:37 +05:00
Crimson a3445f79c5 fix(security): harden diagnostics storage boundary 2026-09-07 19:52:11 +05:00
Crimson af9f476658 fix(utf8): закрыть lossy-границы конфигурации и API 2026-09-07 17:25:27 +05:00
Crimson ab788725cf fix(env): контракт был шире домена, который принимает systemd
Разбор предыдущего прохода со сверкой по исходникам systemd v257.13 — той самой
линии, что стоит на Debian 13. Тема та же и слоем глубже: контракт, объявленный
шире, чем его принимает чужая сторона. Прошлый проход сделал транспорт lossless
для значений, которые systemd принимает, но не спросил, какие значения он
принимает вообще.

1. Домен значений файла окружения

Перед тем как принять пару, systemd прогоняет ключ и значение через
utf8_is_valid (src/basic/env-file.c, check_utf8ness_and_warn), и отказ там
возвращает -EINVAL — то есть НЕзагруженный EnvironmentFile= и юнит, который не
стартует, а не предупреждение. unichar_is_valid (src/basic/utf8.c) отвергает
суррогаты, U+FDD0..U+FDEF и все code points вида *FFFE/*FFFF, а сам
utf8_is_valid — встроенный NUL и невалидный UTF-8.

Пароль "abcde" + U+FDD0 — шесть символов, восемь байт, ни одного управляющего —
проходил панель, оркестратор, DTO и хеширование, записывался в hy2xs.env, и
после этого админка не поднималась. Тот же класс дефекта, ради уничтожения
которого контракт и существует, только слоем ниже.

Введён IsEnvTransportableText (Go) / isEnvTransportable (TS), повторяющий
множество systemd точно — не шире и не уже. Отдельно отвергаются одиночные
суррогаты: строка JavaScript вправе их содержать, а TextEncoder молча заменяет
непарный суррогат на U+FFFD, то есть без проверки в файл уехал бы ДРУГОЙ
секрет, а не отказ.

Заодно разделены домен транспорта и политика продукта. Проверка отвергала C0 и
DEL с формулировкой «формат управляющих символов не несёт» — неправда: внутри
двойных кавычек перевод строки накапливается как обычный байт и переживает
round-trip. Именно эта подмена и позволила проверке не знать про noncharacters.
Политика HY2XS теперь запрещает категорию Cc целиком (была шире кода ровно на
C1) плюс U+FEFF — последний отдельным решением продукта, а не форматом:
0xFEFF & 0xFFFE это 0xFEFE, и systemd такое значение принимает.

2. Рецепт восстановления выполнял env-файл как код

В docs/operations/12, раздел «Забыт пароль администратора», стояло
`set -a; . /etc/hy2xs/hy2xs.env; set +a`. Строка стала опасной ровно тогда,
когда файл научился нести произвольные значения. Для systemd
HY2XS_ADMIN_INITIAL_PASSWORD="$(...)" — буквальное значение: подстановок в
EnvironmentFile= нет вовсе. Но `.` обрабатывает файл bash, а bash внутри
двойных кавычек выполняет подстановку команд — от root, прямо в рецепте
восстановления доступа. Соседний раздел той же страницы при этом уже правильно
запрещал source/eval для bootstrap-admin.secret: документ запрещал действие и
тут же его предлагал.

Рецепт читает нужные значения как ДАННЫЕ. Поставлен гейт приёмки, запрещающий
возврат source/./eval над этими файлами в командах документации и в скриптах;
гейт смотрит только внутрь ```-блоков, чтобы объяснение, называющее убранную
конструкцию по имени, его не роняло.

3. Отказ приходил после мутаций хоста

Проверка транспорта жила только внутри renderRuntimeEnv, то есть срабатывала на
шаге «write runtime env» — уже после bootstrap оркестратора, установки пакетов
и раскладки файловой системы, — а read-only preflight-install говорил PASS: он
зовёт parseRuntimeEnv и ничего не рендерит. Детерминированно известная ошибка
конфигурации роняла операцию, оставив за собой изменённый хост, что прямо
противоречит контракту PHASE 0.

validateRuntimeEnvTransport вызывается теперь из parseRuntimeEnv и проходит по
ВСЕМ парам runtimeEnvEntries: ограничение принадлежит формату, а не полю
пароля, и HY2XS_ADMIN_CON_PASS сломал бы загрузку юнита так же.

4. Точность порта автомата и его описания

- в состоянии DOUBLE_QUOTE_VALUE_ESCAPE systemd пишет `c != '\n'`, а не
  проверку на любой перевод строки (в VALUE_ESCAPE — наоборот,
  strchr(NEWLINE, c)). Порт съедал и \<LF>, и \<CR>;
- комментарий обещал одно намеренное расхождение с systemd, а их два: кроме
  строки без `=`, HY2XS отказывает и на незакрытой кавычке в конце файла.
  Оба fail-closed и теперь названы оба.

Тесты: граничная таблица во всех слоях дополнена значениями вне домена
(U+FDD0, U+FDEF, U+FFFE, U+FFFF, U+1FFFF, U+10FFFF, невалидный UTF-8),
соседями диапазонов (U+FDCF, U+FDF0, U+FFFD, U+10FFFD), C1 и U+FEFF, одиночным
суррогатом. Добавлены TestEnvTransportDomainMatchesSystemd (домен не шире и не
уже) и TestProductPolicyIsWiderThanTransportDomain (домен и политика
различимы), а также проверки fail-closed порядка: parseRuntimeEnv отвергает
непригодную конфигурацию, проверяются все значения файла, запись и проверка
ходят по одному списку пар.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:38:57 +05:00
Crimson 65042ee335 fix(auth): контракт пароля администратора расходился с bcrypt в четырёх местах
Верхняя граница пароля была объявлена в 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>
2026-09-06 18:38:04 +05:00
founder a8407cf16b fix(admin): вход в панель падал на теге правила, пережившего переименование
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.
2026-09-04 02:32:50 +05:00
founder 82e5ca40cc fix(build): гейт ACME описывал снятую архитектуру панели
Приёмка требовала, чтобы страница Hysteria содержала жёсткий список ACME
DNS-провайдеров (cloudflare … vultr) и не содержала удалённого upstream
namedotcom. Это имело смысл, пока панель ПРЕДЛАГАЛА выбор провайдера: список в
UI был вторым экземпляром upstream-реестра и мог от него отстать — ровно так
namedotcom и пришлось выпиливать вручную.

После перевода страницы в read-only диагностику реестра нет и быть не должно:
имя провайдера читается из фактического конфига и показывается как есть, поэтому
новый upstream-провайдер отображается без правок панели. Возврат списка ради
прохождения grep-а создал бы фиктивный реестр, существующий только для гейта.

Гейт проверяет действующий контракт: провайдер приходит строкой и рисуется как
значение, параметры DNS отдаются только именами, селектора на странице нет.
Проверено положительно и на трёх нарушениях (провайдер перестал показываться,
на странице появился селектор, тип стал перечислением) — гейт падает на каждом.

Контрактный тест панели дополнен обратной проверкой: ни одно из восьми имён
провайдеров не должно встречаться в исходнике страницы. Та же формулировка
поправлена в матрице приёмки.

Дополнительно прогнаны целиком все восемь функций приёмки, которым не нужен
распакованный пакет: других устаревших утверждений нет.
2026-09-03 23:07:19 +05:00
founder 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
2026-09-03 22:39:25 +05:00
founder 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 в тестах.
2026-09-03 03:53:46 +05:00
founder b315001288 fix(admin): различать достижимость Traffic Stats API и соответствие профилю
Признак на странице конфигурации отвечал только на вопрос «достучится ли
админка», поэтому 0.0.0.0 показывался как норма — хотя внутренний control plane
при нём опубликован на всех интерфейсах, а оркестратор такой конфигурации не
создаёт. Состояний теперь четыре: канон профиля, wildcard, не-канонический
loopback и недостижимый адрес.

Backend не тронут: он по-прежнему отвечает только на вопрос достижимости —
превращать лишнюю публикацию в отказ обслуживания значило бы отключить всех
пиров. Исправлено ложное утверждение в его комментарии: пустой хост `:36712` в
Go означает все интерфейсы, а не loopback.

Удалены мёртвые фразы common.wait/enableSuccess/disableSuccess — остатки
операций запуска, остановки и смены версии Hysteria, которых у панели нет.
2026-09-03 03:39:42 +05:00
founder 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
2026-09-02 23:24:01 +05:00
founder 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
2026-09-02 07:15:43 +05:00
founder 6d1686b2be fix(admin): свести access-control к одному правилу и одному пути отзыва
Второй разбор того же слоя, уже по состоянию после 162759c. Тема: границы между
частями access-control. Прошлый проход починил одну операцию отзыва доступа и
оставил остальные; правило доступа при этом продолжало существовать в двух
экземплярах. Проведены три границы: состояние пира -> решение о доступе,
сохранённое изменение -> живая сессия, планировщик -> принадлежащая ему работа.

Правило доступа. Оно было записано двумя разными SQL-условиями: одним в выборке
Hysteria2Auth, другим в выборке cron. Второе не является отрицанием первого, и
расхождение приходилось ровно на границы — quota=0, usage=quota, now=expiresAt,
now=bannedUntil: авторизация отказывала, cron сессию не рвал. Условие cron
требовало СТРОГОГО превышения квоты, а счётчики растут порциями по ответу
Traffic Stats API, поэтому точное равенство — обычный исход очередного сбора.
Пир с исчерпанной квотой не пускался заново, но его живая сессия не разрывалась
никогда. Политика вынесена в peerAccessDenied; авторизация ищет пира только по
secret_digest, cron применяет ту же функцию. quota=-1 — единственный безлимит,
quota=0 — ноль байтов, bannedUntil=now — блокировка уже закончилась. Строка без
решающего поля трактуется как повреждённая и ведёт к отказу.

Операции, оставлявшие живую сессию. DeletePeer состоял из одного dao.DeletePeer:
строка исчезала вместе с auth_id, то есть вместе с единственным, чем эту сессию
можно было завершить, — состояние становилось невосстановимым. Разрыв при
изменении выполнялся только при disabled=1, поэтому мимо проходили смена
секрета, урезание квоты ниже израсходованного, перенос срока в прошлое и
снижение maxDevices. Импорт переписывает auth_id, секрет, квоту, срок и disabled
целиком и не трогал сессий вовсе. Все операции идут теперь через один
reconcileLiveSessions, а он — через disconnectAuthIDs, единственный вход к /kick:
он принимает готовые идентификаторы, дедуплицирует их, разбивает на части и не
обращается к базе. Импорт собирает старые auth_id ВНУТРИ транзакции (после
commit их в базе уже нет) и рвёт ПОСЛЕ commit (до него клиент успел бы
переподключиться к ещё не изменённому пиру). Правило асимметрично намеренно:
ограничение применяется немедленно, послабление — нет.

Цикл учёта. CronHandleAccount запускала горутину, которая запускала ещё две, —
для планировщика джоба заканчивалась почти мгновенно, поэтому StopCron не ждал
настоящей работы: releaseResource закрывал SQLite, а горутины продолжали в неё
писать. Параллельность обеих половин означала ещё и то, что enforcement читал
счётчики до записи снятой дельты. Джоба стала синхронной, под одним мьютексом на
весь цикл, порядок строгий. Закрыты три nil-разыменования — trafficSecretConfig,
item.AuthId и item.Id, — каждое из которых роняло процесс целиком вместе с
обработчиком machine-auth. Гейт Hysteria2IsRunning убран: util.Exec не отличает
«служба неактивна» от «спросить не удалось», и сломанный systemctl при живой
Hysteria молча отключал и учёт, и enforcement. Потеря дельты при отказе SQLite
больше не молчит: чтение /traffic?clear=1 деструктивно, и каждая потеря
считается. Checkpoint accounting в 1.0.0 намеренно не вводится — квота здесь
операционный предел доступа, а не учёт с финансово значимым каждым байтом.

Лимит устройств. Между чтением /online и ответом allow место ничем не
удерживалось: при online=max-1 два одновременных запроса получали разрешение
оба. Мьютекс вокруг /online этого не чинит — ответив allow, админка не создаёт
подключение, и следующий запрос продолжает видеть прежнее число. Появился
process-local учёт выданных, но ещё не проявившихся разрешений: решение по сумме
«подключено плюс зарезервировано», рост online снимает соответствующее их число,
протухшие снимаются по внутреннему TTL. Сеть опрашивается вне блокировки.

Гейты. Проверка «авторизация не возвращает успех из ветки ошибки» была записана
регуляркой err != nil \{[\s\S]*?return \*peer\.Id, а ленивый [\s\S]*? свободно
пересекает границы блоков: она даёт совпадение на коде из HEAD, то есть гейт
нельзя было удовлетворить, не сломав продукт. Тело ветки теперь выделяется по
балансу фигурных скобок, и логика проверена в обе стороны. go test -race стал
обязательным шагом сборки: состояние трекера разрешений и мьютекс цикла учёта
принадлежат процессу, и их корректность не наблюдаема ни в go test, ни в go vet;
пропуск при недоступном компиляторе не предусмотрен.

Панель. importPeerApi не объявлял skipErrorToast, а handleImport не имел ни try,
ни catch: после появления частичного результата отказ уходил бы необработанным
отклонением промиса, список не обновлялся бы при уже изменённой базе, а общий
перехватчик показал бы предупреждение красной ошибкой. Формулировка
peer_disconnect_failed во всех трёх местах сделана operation-neutral: через этот
код отчитываются восемь операций, а для удалённого пира прежняя фраза «новые
подключения пира запрещены» просто бессмысленна.
2026-09-01 20:46:21 +05:00
founder 162759c599 fix(admin): достроить вторые половины отзыва доступа, лимита и журнала
Разбор кода на c0a43ae со сверкой с официальной документацией Hysteria 2.
Общая тема: операции, у которых была только одна из двух необходимых половин.

Отзыв доступа. Запись disabled=1 видит лишь выборка в Hysteria2Auth, то есть
закрывает БУДУЩИЕ обращения к HTTP-auth; установленная QUIC-сессия живёт своей
жизнью и сама не разрывается. После «Отключить» пир пользовался доступом сколько
угодно долго, а панель показывала его отключённым. Появился DisconnectPeers —
только официальный Traffic Stats /kick, без записи в базу; прежний Hysteria2Kick
вместе с разрывом проставлял banned_until и потому для отключения не годился.
Порядок «запись, затем разрыв» обратному не подлежит и доказан снимком базы в
момент прихода /kick. Неудача разрыва не откатывает disabled и сообщается кодом
peer_disconnect_failed: обычная ошибка означала бы для оператора вывод, прямо
противоположный истине. KickPeer переведён на тот же примитив — он писал
banned_until дважды и мог ответить чистым отказом уже в применённом состоянии.

Ограничение устройств. Отказ /online обрабатывался возвратом успеха
авторизации, то есть недоступность 127.0.0.1 превращала объявленный лимит в
безлимит. Вторая половина дыры была тише: Hysteria2Online отдавал пустую карту
БЕЗ ошибки, когда systemd отвечал «служба неактивна», — а этот ответ не
отличается от «спросить systemctl не удалось». Пути разделены: терпимый для
отображения, строгий для решения о доступе. Hysteria2IsRunning убран с путей
принятия решений совсем.

Журнал. entry.Info() вызывался без аргумента, и logrus писал "msg":"" для
каждого запроса — пустой столбец на экране был точным отражением файла. Ветка
«файла ещё нет» отвечала голым массивом вместо {records,total}, поэтому на
свежей установке страница системных логов не работала вовсе. Битая строка
вызывала vo.Fail И continue: клиент получал два JSON-документа подряд.

Панель. Общий LogViewer и utils/download.ts (копий скачивания было четыре, две
ставили запрос вне try и глушили причину); меню на command с быстрым
включением/отключением; popper-style у подсказки; kick с подтверждением и
названным сроком; отмена подтверждений перестала быть ошибкой. Отдельно:
skipErrorToast гасил и транспортный отказ, при том что страницы писали
«перехватчик уже показал» и молчали, — обрыв связи не показывал ничего.

Закреплено go-тестами против настоящего HTTP, контрактными тестами панели и
двумя гейтами приёмки. Ручная часть — в
docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md.
2026-09-01 17:17:17 +05:00
founder 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 после
разнесения по каталогам совпадал бы ровно с одним файлом.
2026-09-01 07:27:15 +05:00
founder 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-блоков с текстовыми
инвариантами и все отрицательные сканы. Кроме этого одного — расхождений нет.
2026-09-01 04:56:33 +05:00
founder d32811b804 fix(build): отрицательные сканы приёмки проверяют форму кода, а не прозу
Комментарий, объясняющий, почему чего-то больше нет, обязан называть это по
имени. Скан по голой подстроке такой комментарий от кода не отличает и падает
на документации к выполненной им же работе. Найдено три таких гейта, все на
пути ближайшей сборки:

    скан иконок             -> блочный комментарий в SvgIcon/sprite.ts
    скан имён раннеров      -> слово `systemd-run` в прозе firewall.ts
    скан cancelFirewall...  -> комментарий о разделении функции

Второй сломан моим же комментарием из 2259f7c. Третий сломан с момента своего
появления (330a63b) и не падал только потому, что сборка до него не доходила:
её останавливали более ранние гейты.

Исправления по форме, а не удалением комментариев:

- скан прежних имён раннеров требует, чтобы перед именем не стоял дефис. В
  JavaScript идентификатор после дефиса не начинается, поэтому исключение
  точное и ни один настоящий вызов не пропускает;
- скан cancelFirewallRollback ищет имя со скобкой, то есть объявление или
  вызов, а не упоминание;
- литеральный скан по virtual:svg-icons-register удалён: его роль исполняет
  более сильный и более ранний гейт — production `vite build`, где активный
  импорт неразрешимого виртуального модуля роняет сборку bundle. Оставлены две
  точные проверки: отсутствие плагина в package.json и вызов registerSvgIcons
  в main.ts.

В шапке acceptance.sh зафиксировано правило для отрицательных сканов и
ограничение code_without_comments: строчные комментарии он отбрасывает,
блочные — нет, и блок-парсер сознательно не заводится (наивный стриппер
спотыкается о `/*` внутри строк и регулярных выражений, а это ложный PASS).

Все отрицательные сканы приёмки прогнаны по текущему дереву: срабатываний
больше нет; новые шаблоны проверены на синтетическом регрессе — ловят.
2026-09-01 04:44:23 +05:00
founder 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 на весь вызов;
- возврат пайплайна запрещён отдельной приёмкой.
2026-09-01 04:28:54 +05:00
founder 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-граф.
2026-08-31 16:44:20 +05:00
founder 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) — там же.
2026-08-31 03:30:14 +05:00
founder 0230f1ca99 fix(orchestrator): не полагаться на мангление имени транзиентного юнита
systemd-run пропускает голое имя через unit_name_mangle_with_suffix, который
сначала проверяет, не заканчивается ли оно уже известным типом юнита. Ключ
операции — санитизированный ISO-timestamp вида `...T12-34-56.789Z`, то есть
содержит точку, и корректность имени зависела бы от того, что `.789Z` случайно
не совпало ни с одним типом systemd.

Имя передаётся с явным суффиксом `.service`: systemd-run берёт его как есть и
создаёт рядом одноимённый `.timer`, на который и рассчитывают снятие guard и
status.
2026-08-31 01:32:35 +05:00
founder 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: шестой проход.
2026-08-31 01:31:15 +05:00
founder 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, безопасное переиспользование замка мёртвого держателя;
- формула ключа операции обязана существовать в одном файле.
2026-08-31 01:25:29 +05:00
founder 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 — снимать его на месте означало бы риск снять
живой. Непонятое содержимое не снимается автоматически: оно не доказывает
отсутствие операции, и сомнение трактуется в пользу отказа.
2026-08-30 22:59:50 +05:00
founder 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 не существовал.
2026-08-30 22:54:57 +05:00
founder 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»,
переписаны на обратный: команды обязаны сообщать о своих отказах.
2026-08-30 19:32:43 +05:00
founder df73459ea5 test(guard): закрепить создание каталога как запись под read-only guard
ensureDir — такой же примитив записи lib/fs, как writeText и writeTextAtomic,
и guard обязан покрывать его наравне с ними. /var/lib/hy2xs, созданный до
успешной PHASE 0, уже делает хост изменённым: следующая чистая установка
опознает его содержимое как чужую установку.

Покрытие guard'ом всех примитивов ФС собрано в одном файле: непокрытый
примитив — это дыра в границе PHASE 0, и заметить её можно только там, где
проверяется весь набор.
2026-08-30 18:29:54 +05:00
founder 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: до неё появление дубликата было ненаблюдаемым.
2026-08-30 18:28:39 +05:00
founder 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, а утверждение о прогоне обязано следовать за прогоном.
2026-08-30 18:18:13 +05:00
founder 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). Приёмка сборки закрепляет порядок шагов
атомарной записи, отсутствие незащищённой записи состояния в обработчиках и
отсутствие отменяемых цепочек в откате.
2026-08-30 18:11:55 +05:00
founder 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.
2026-08-30 07:47:40 +05:00
founder 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 в раздел запуска, семь новых
пунктов приёмки.
2026-08-30 07:44:51 +05:00
founder 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.
2026-08-30 07:41:31 +05:00
founder 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.
2026-08-30 07:29:53 +05:00
founder 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.
2026-08-30 07:14:43 +05:00
founder 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.
2026-08-30 07:09:38 +05:00
founder 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 и
только потом сообщал о типовой ошибке.
2026-08-30 07:04:28 +05:00
founder 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.
2026-08-30 06:48:50 +05:00
founder 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 внутри объявленных диапазонов.
2026-08-29 21:38:40 +05:00
founder 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 пакета.
2026-08-29 21:37:50 +05:00
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
founder 5574b7c89a test(router): закрепить пространства имён API тестом регистрации маршрутов
Ошибка в регистрации маршрутов проявляется паникой при старте сервиса, а не
ответом с кодом: конфликт с wildcard фронтенда или дублирующая регистрация
обнаружились бы только на живом сервере. Смена namespace относится ровно к
этому классу изменений.
2026-08-27 20:51:04 +05:00
founder 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.
2026-08-27 20:50:03 +05:00
founder 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-значение
определяется по самому значению, а не по имени ключа.
2026-08-27 20:49:38 +05:00
founder 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 и был единственным элементом вне палитры.
2026-08-27 20:49:07 +05:00
founder 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 с разбором каждого исправленного дефекта.
2026-08-27 12:16:38 +05:00
founder 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, версия админки из контракта.
2026-08-27 12:16:22 +05:00
founder 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).
2026-08-27 12:16:07 +05:00
founder 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.
2026-08-27 12:15:53 +05:00
founder 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.
2026-08-27 12:15:38 +05:00
founder 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-сборка снова
запускается одной командой.
2026-08-27 12:15:24 +05:00
founder 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-санитайзере расширены и синхронизированы с
оркестратором. Формулировка гарантии сужена до честной: известные
секреты и неизвестные поля с секретоподобным именем.
2026-08-27 12:15:07 +05:00
founder 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-бандл.
2026-08-27 12:14:47 +05:00
founder 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 файлов),
документация на русском.
2026-08-27 08:15:02 +05:00
founder 0205334cd8 tools/build/README: исправлены две битые ссылки
- ссылка на lib/deps.sh резолвилась как tools/build/tools/build/lib/deps.sh
- удалена ссылка на несуществующий fix17.txt рядом с /proc/cpuinfo
2026-08-15 20:27:37 +05:00
founder 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) намеренно не тронут: он требует отдельной
миграции БД и выносится в отдельный этап.
2026-08-15 03:34:18 +05:00
Crimson 656cc7ca5b Обновить README.md 2026-08-14 19:49:11 +00:00
325 changed files with 59439 additions and 12726 deletions
+23
View File
@@ -0,0 +1,23 @@
# Разработка ведётся в том числе на Windows, где часто включён core.autocrlf=true.
# Всё, что исполняется или читается на target-сервере Debian, обязано остаться LF:
# CRLF в shell-скрипте даёт `$'\r': command not found`, а в systemd unit и env-файле
# ломает разбор значений.
* text=auto eol=lf
*.sh text eol=lf
*.tpl text eol=lf
*.env text eol=lf
*.service text eol=lf
*.nft text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.sql text eol=lf
# Бинарные ассеты не должны трогаться нормализацией.
*.png binary
*.ico binary
*.gif binary
*.jpg binary
*.woff binary
*.woff2 binary
+2338
View File
File diff suppressed because it is too large Load Diff
+657 -17
View File
@@ -1,21 +1,661 @@
MIT License
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (c) 2026 Flamy studio
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Preamble
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+426 -34
View File
@@ -1,4 +1,4 @@
# HY2XS
# HY2XS Core
<p align="center">
<img src="apps/frontend/src/assets/logo.png" width="96" alt="HY2XS logo">
@@ -10,10 +10,13 @@
<p align="center">
<a href="#что-это">Что это</a> ·
<a href="#статус-приёмки-v100">Приёмка v1.0.0</a> ·
<a href="#возможности">Возможности</a> ·
<a href="#быстрый-старт-для-нового-сервера">Быстрый старт</a> ·
<a href="#конфигурация-hy2xsenv">Конфигурация</a> ·
<a href="#версионная-политика-hysteria2">Версии</a> ·
<a href="#сборка-release-пакета">Сборка</a> ·
<a href="CHANGELOG.md">Changelog</a> ·
<a href="#лицензия">Лицензия</a>
</p>
@@ -22,7 +25,7 @@
<img alt="Architecture" src="https://img.shields.io/badge/arch-amd64%20%2F%20x86__64-555555">
<img alt="Firewall" src="https://img.shields.io/badge/firewall-nftables-2563eb">
<img alt="Runtime" src="https://img.shields.io/badge/runtime-systemd-111827">
<img alt="License" src="https://img.shields.io/badge/license-MIT-green">
<img alt="License" src="https://img.shields.io/badge/license-AGPL--3.0--only-green">
</p>
---
@@ -35,11 +38,27 @@ HY2XS подходит для сценария, где нужен один produ
Ключевая идея: на target‑сервере выполняется только установка готового пакета. Вся тяжёлая сборочная часть остаётся на build‑машине.
## Статус приёмки v1.0.0
Stable `v1.0.0` выпущен из принятого artifact `1.0.0-rc5` без пересборки.
Проверенные source commit, tarball и SHA-256 не изменялись; release сознательно
остаётся привязан к тегу `1.0.0-rc5`.
Финальная production-приёмка на чистом Debian 13 охватила build, установку,
reboot persistence, Admin/UI, управление пирами, diagnostics, реальный E2E
официальным клиентом Hysteria 2.12.2 и дополнительный gateway/TUN anti-leak
прогон. Вердикт: **`PASS / PRODUCTION E2E VALIDATED`**.
Подробности:
- [итоговый отчёт о приёмке RC5](docs/acceptance/2026-09-15-v1.0.0-rc5-final-acceptance.md);
- [фиксация stable promotion](docs/acceptance/2026-09-15-v1.0.0-stable-promotion.md).
## Возможности
HY2XS release‑пакет разворачивает и настраивает:
- официальный upstream‑бинарник Hysteria2, закреплённый в metadata пакета и проверяемый по SHA256;
- официальный upstream‑бинарник Hysteria2: последняя стабильная версия выбирается при сборке пакета, закрепляется в его metadata и проверяется по SHA256;
- HY2XS admin — встроенную админ‑панель для управления users/peers, трафиком, конфигурацией, логами и состоянием сервера;
- systemd‑юнит `hysteria-server` для Hysteria2;
- systemd‑юнит `hy2xs-admin` для админ‑панели;
@@ -108,6 +127,8 @@ Windows и macOS можно использовать для разработки
- не включает IPv6production baseline;
- не настраивает `sshd` автоматически;
- не предоставляет полноценный uninstall/update framework;
- не обновляет Hysteria2 на уже работающем сервере: `reconfigure` намеренно не является Hysteria updater;
- не мигрирует установки `0.x` на `1.0.0` — переход выполняется чистой установкой, см. [CHANGELOG](CHANGELOG.md);
- не выполняет сложную миграцию старых неизвестных состояний сервера;
- не реализует Telegram‑бота, port hopping и универсальный accessdelivery workflow;
- не предназначен для установки поверх давно используемого сервера с неизвестными firewall/systemd‑правками.
@@ -134,7 +155,17 @@ hy2xs-install/
└── metadata/
```
При запуске `install.sh` пакет проверяет `metadata/checksums.txt`, устанавливает orchestrator в `/usr/local/lib/hy2xs/hy2xs-orchestrator`, создаёт symlink `/usr/local/bin/hy2xs-orchestrator`, копирует package assets в `/usr/local/lib/hy2xs/package` и передаёт управление installonly orchestrator.
При запуске `install.sh` пакет проверяет `metadata/checksums.txt` и выполняет
readonly cleanhost preflight **из распакованного архива**. После этого он
передаёт управление installonly orchestrator через `exec` — и больше не делает
ничего: сам `install.sh` не изменяет на сервере ни одного файла.
Всю раскладку выполняет уже оркестратор: ставит себя в
`/usr/local/lib/hy2xs/hy2xs-orchestrator`, создаёт symlink
`/usr/local/bin/hy2xs-orchestrator`, копирует package assets в
`/usr/local/lib/hy2xs/package` и продолжает установку. Это сделано ради одного
свойства: у изменений сервера ровно один владелец, поэтому при любом отказе
известно, что именно было создано и что откатывать.
## Сетевая модель по умолчанию
@@ -147,10 +178,79 @@ hy2xs-install/
| TrafficStats Hysteria2 | `127.0.0.1:36712` |
| Firewall mode | `takeover` в packaged baseline |
| Hysteria2 auth | `http` через локальный HY2XS admin |
| Hysteria2 obfs | `salamander` |
| Hysteria2 obfs | `gecko` (512/1200); `salamander` доступен как режим совместимости |
| Congestion fallback | `bbr`, профиль `standard` |
| QUIC stateless reset | включён |
Важно: `HY2XS_SSH_PORT` нужен HY2XS для nftables‑правил и проверки доступности SSH‑порта. Сам `sshd` проект не перенастраивает. SSH на `2323` и вход только по ключу нужно настроить до запуска `./install.sh`.
## Версионная политика Hysteria2
HY2XS **не привязан к конкретному номеру версии Hysteria**.
> Источник по умолчанию берёт последний стабильный релиз Hysteria, доступный на момент сборки пакета. Разрешённая версия, URL артефакта и контрольная сумма замораживаются в получившемся install‑пакете.
Как это работает:
```text
build machine target server
───────────── ─────────────
определить последнюю стабильную ─┐
взять ожидаемый SHA-256 из │
upstream hashes.txt │
скачать артефакт и сверить его ├─► release‑пакет ──► скачать ровно
проверить, что бинарник принимает │ version + url этот артефакт,
канонический конфиг HY2XS │ + sha256 сверить SHA-256
заморозить version/url/sha256 ─┘ и `hysteria version`
```
Контрольная сумма берётся из upstream‑ассета `hashes.txt`, а не считается
только локально: локальный пересчёт подтверждает, что файл не изменился после
скачивания, но не доказывает, что скачан именно ожидаемый upstream artifact.
Что это даёт:
- новая установка получает актуальную Hysteria без ручного обновления version lock;
- если между сборкой пакета и его установкой выйдет новая версия, **содержимое установки не изменится**;
- повторная установка старого пакета поставит ту же версию, что и в день сборки;
- несовместимый upstream ломает сборку, а не сервер оператора.
Переопределения при сборке:
```bash
# по умолчанию: последняя стабильная
./tools/build/build.sh
# закрепить конкретную версию
HYSTERIA_VERSION_OVERRIDE=v2.12.2 ./tools/build/build.sh
# офлайн-сборка по закоммиченному tools/build/hysteria-lock.env
HYSTERIA_CHANNEL=pinned ./tools/build/build.sh
```
Фактически установленная версия видна в `/etc/hysteria/post-install.env` (`HY2_VERSION`), а способ её выбора — в `HY2_RESOLUTION`.
Обновление Hysteria на уже работающем сервере в текущем релизе не поддерживается: `reconfigure` намеренно не является Hysteria updater. Это сохраняет immutable‑контракт развёртывания.
## Обфускация
Новые установки HY2XS используют **Gecko**.
Gecko помечен upstream как **experimental**. Он достраивается поверх Salamander: помимо scramble он дополнительно фрагментирует QUIC handshake на пакеты случайного размера. HY2XS использует upstream‑defaults размеров пакетов `512/1200` как проверенный production‑профиль.
**Salamander остаётся поддержанным режимом совместимости.** Смена типа обфускации требует соответствующих изменений на клиенте: это изменение wire‑совместимости, а не косметическая настройка.
| | Gecko | Salamander |
| --- | --- | --- |
| Статус upstream | experimental | stable |
| Роль в HY2XS | default для новых установок | режим совместимости |
| Параметр | `HY2XS_HYSTERIA_OBFS_TYPE=gecko` | `HY2XS_HYSTERIA_OBFS_TYPE=salamander` |
| В клиентской ссылке | `obfs=gecko` | `obfs=salamander` |
Экспериментальность upstream остаётся контролируемым риском, потому что одновременно выполняются три условия: Salamander доступен как fallback, каждая разрешённая версия проходит compatibility gate до выпуска пакета, и существующие серверы никогда не переводятся на Gecko молча.
Размеры пакетов Gecko не выносятся в конфигурацию: официальная схема `hysteria2://` не умеет их передавать, поэтому нестандартные значения сделали бы клиентскую ссылку неполной.
## Быстрый старт для нового сервера
Ниже приведён полный путь для оператора, который работает с Windows и ставит HY2XS на чистый Debian 13 сервер.
@@ -185,6 +285,20 @@ vpn.example.com -> SERVER_IP
Если у домена есть AAAA‑запись, при строгой политике `HY2XS_DNS_AAAA_POLICY=strict` установка будет остановлена, потому что текущий production‑профиль HY2XS является IPv4only.
A‑запись должна указывать именно на этот сервер, а не просто существовать. Preflight сверяет её с публичными IPv4, назначенными интерфейсам машины, и останавливает установку при расхождении:
```text
DNS IPv4 mismatch for HY2XS_PUBLIC_HOST vpn.example.com:
DNS A records: 185.xxx.xxx.10
server public IPv4: 185.xxx.xxx.27
Update the DNS A record before using this server.
```
Та же проверка выполняется в `reconfigure` и `doctor`, поэтому принудительная смена IPv4 провайдером не остаётся незамеченной. Адрес сервера определяется локально, без обращения к внешним сервисам определения IP.
Если сервер работает за NAT или на floating IP — это топология вне текущего baseline; осознанное решение оформляется значением `HY2XS_PUBLIC_ENDPOINT_POLICY=warn`.
### 2. Создайте SSH‑ключ на Windows
Откройте PowerShell:
@@ -379,29 +493,46 @@ HY2XS_IPV6_ENABLED=false
HY2XS_TLS_MODE=acme
HY2XS_ACME_TYPE=http
HY2XS_HYSTERIA_AUTH_MODE=http
HY2XS_HYSTERIA_OBFS_TYPE=salamander
HY2XS_HYSTERIA_OBFS_TYPE=gecko
HY2XS_UI_PUBLIC_ACCESS=false
```
Если нужен режим совместимости со старыми клиентами, укажите `HY2XS_HYSTERIA_OBFS_TYPE=salamander`. Подробнее — в разделе [Обфускация](#обфускация).
### 9. Запустите установку
```bash
./install.sh --config /root/hy2xs-target.env --non-interactive
```
Во время установки HY2XS:
Установка идёт в две фазы с жёсткой границей между ними.
1. проверит checksums release‑пакета;
2. установит orchestrator в `/usr/local/lib/hy2xs`;
3. создаст runtime‑каталоги и service users;
4. запишет `/etc/hy2xs/hy2xs.env`;
5. разложит bundled HY2XS admin;
6. скачает pinned Hysteria2 binary из upstream и проверит SHA256;
7. создаст `/etc/hysteria/config.yaml`;
8. установит systemd‑юниты;
9. применит nftables‑правила;
10. выполнит smokechecks;
11. зафиксирует успешное состояние в `/var/lib/hy2xs/install-state.json`.
**PHASE 0 — только чтение.** До её успешного завершения на сервере не
изменяется ни один файл, включая `/usr/local/lib/hy2xs`:
1. проверит, что запущено от root;
2. проверит checksums release‑пакета;
3. запустит cleanhost preflight **из распакованного архива**: платформа
Debian 13 amd64, отсутствие предыдущей установки, валидность конфигурации.
**PHASE 1 — применение изменений.** Её целиком выполняет оркестратор, которому
`install.sh` передал управление через `exec`:
4. установит сам себя в `/usr/local/lib/hy2xs` и разложит runtime‑пакет;
5. создаст runtime‑каталоги и service users;
6. запишет `/etc/hy2xs/hy2xs.env`;
7. разложит bundled HY2XS admin;
8. скачает закреплённый в пакете Hysteria2 binary из upstream, проверит SHA256 и фактическую версию;
9. создаст `/etc/hysteria/config.yaml`;
10. установит systemd‑юниты;
11. применит nftables‑правила;
12. выполнит smokechecks;
13. зафиксирует успешное состояние в `/var/lib/hy2xs/install-state.json`.
Если PHASE 0 не прошла, установщик завершается с ошибкой и **сервер остаётся в
том же состоянии, в котором был**. HY2XS v1 не устанавливается поверх
предыдущего поколения и не мигрирует его состояние: очистка старой установки —
отдельная явная операция, см. [docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md).
### 10. Получите bootstrap‑пароль админки
@@ -507,9 +638,11 @@ hy2xs-orchestrator status \
| Переменная | Назначение | Значение по умолчанию в packaged baseline |
| --- | --- | --- |
| `HY2XS_CONFIG_SCHEMA_VERSION` | Версия схемы конфигурации HY2XS. Конфигурация другой схемы отклоняется fail‑fast | `2` |
| `HY2XS_IPV6_ENABLED` | IPv6‑режим. В production baseline должен быть `false` | `false` |
| `HY2XS_DOMAIN` | Домен для ACME и deploy‑профиля | `fi.api.withen.pro` |
| `HY2XS_DNS_AAAA_POLICY` | Поведение при наличии AAAA‑записи: `strict`, `warn`, `off` | `strict` |
| `HY2XS_PUBLIC_ENDPOINT_POLICY` | Строгость проверки того, что A‑записи публичного endpoint ведут на IPv4 этого сервера: `strict`, `warn`, `off` | `strict` |
| `HY2XS_PUBLIC_HOST` | Публичный host без схемы, порта и path | `fi.api.withen.pro` |
| `HY2XS_PUBLIC_PORT` | Публичный порт Hysteria2 endpoint | `443` |
| `HY2XS_SSH_PORT` | SSH‑порт, который будет разрешён firewall‑правилами | `2323` |
@@ -518,8 +651,8 @@ hy2xs-orchestrator status \
| `HY2XS_UI_BIND_HOST` | IPv4 bind HY2XS admin | `127.0.0.1` |
| `HY2XS_UI_PUBLIC_ACCESS` | Флаг публичного UI‑доступа. В baseline оставляйте `false` | `false` |
| `HY2XS_UI_PORT` | Порт HY2XS admin | `8080` |
| `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора | `hy2xsadmin` |
| `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора; `__GENERATE__` генерируется при install | `__GENERATE__` |
| `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора: 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`. Значение вне контракта роняет установку — панель его не приняла бы на форме входа | `hy2xsadmin` |
| `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора: 6-64 символа Unicode **и** не более 72 байт в UTF‑8 (предел bcrypt); валидный UTF‑8 в документированном домене `EnvironmentFile=` (в частности, без U+FEFF), без управляющих символов; пробелы по краям — часть пароля, поэтому такое значение записывается в двойных кавычках; `__GENERATE__` генерируется при install | `__GENERATE__` |
| `HY2XS_ADMIN_CON_PASS` | Bootstrap‑секрет peer/auth слоя; `__GENERATE__` генерируется при install | `__GENERATE__` |
| `HY2XS_FORCE_PASSWORD_CHANGE` | Принудительная смена пароля. UX‑flow пока не включён в production baseline | `false` |
| `HY2XS_ALLOW_SELF_SIGNED_DEV` | Разрешает `self_signed_dev` TLS‑режим | `false` |
@@ -531,11 +664,11 @@ hy2xs-orchestrator status \
| `HY2XS_HYSTERIA_BIND_HOST` | Bind Hysteria2. В production profile фиксируется на `0.0.0.0` | `0.0.0.0` |
| `HY2XS_HYSTERIA_PORT` | UDP‑порт Hysteria2 | `443` |
| `HY2XS_HYSTERIA_AUTH_MODE` | Auth mode Hysteria2. Фиксированное значение production‑профиля | `http` |
| `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST` | Host trafficStats API | `127.0.0.1` |
| `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST` | Host trafficStats API. Фиксированное значение production‑профиля: админка обращается к нему только по loopback, поэтому любой другой адрес выключает лимит устройств, учёт трафика и принудительное отключение | `127.0.0.1` |
| `HY2XS_HYSTERIA_TRAFFIC_STATS_PORT` | Порт trafficStats API | `36712` |
| `HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET` | Secret для trafficStats и machine auth | `__GENERATE__` |
| `HY2XS_HYSTERIA_OBFS_TYPE` | Obfuscation type. Фиксированное значение production‑профиля | `salamander` |
| `HY2XS_HYSTERIA_OBFS_PASSWORD` | Salamander password | `__GENERATE__` |
| `HY2XS_HYSTERIA_OBFS_TYPE` | Тип обфускации: `gecko` или `salamander`. Смена меняет wire‑совместимость | `gecko` |
| `HY2XS_HYSTERIA_OBFS_PASSWORD` | Пароль обфускации; `__GENERATE__` генерируется при install | `__GENERATE__` |
| `HY2XS_HYSTERIA_BANDWIDTH_UP` | Hysteria2 upstream bandwidth | `50 mbps` |
| `HY2XS_HYSTERIA_BANDWIDTH_DOWN` | Hysteria2 downstream bandwidth | `50 mbps` |
| `HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH` | Игнорировать bandwidth клиента | `false` |
@@ -555,6 +688,50 @@ hy2xs-orchestrator status \
При `managed` и `takeover` генерируется nftables‑конфигурация с default drop policy, разрешением loopback, established/related, SSH‑порта, ACME challenge‑порта, Hysteria2 UDP‑порта и ICMP echorequest.
### Защита от потери доступа при смене firewall
При `HY2XS_FIREWALL_STAGED_APPLY=true` (значение по умолчанию) перед применением новых правил HY2XS взводит rollback guard — транзиентный systemd‑юнит с окном 45 секунд. Если операция не снимет его вовремя, guard вернёт прежний firewall, и SSH останется доступным.
Таймеру явно задаётся `AccuracySec=1s`, поэтому «45 секунд» — это реальный контракт, а не приблизительный: по умолчанию `systemd.timer` разрешает себе сработать в окне `[цель; цель + AccuracySec]`, где `AccuracySec` — одна минута, и обещанное окно превращалось бы в 45–105 секунд. Вторым свойством задаётся `RemainAfterElapse=no`: отработавший таймер обязан выгрузиться, иначе он навсегда блокировал бы следующую операцию (см. [«Одна операция за раз»](#одна-операция-за-раз)).
Окно намеренно короткое и **не** обязано покрывать smoke‑checks: на медленном сервере они идут дольше. Вместо этого guard оставляет за собой факт срабатывания в `/run/hy2xs/rollback/<op-id>/auto-rollback-fired`, и операция не имеет права объявить себя успешной, если этот файл появился, — сервер в такой момент работает на прежнем firewall, а не на том, который она сгенерировала. Установка завершится отказом с `phase: firewall_guard_fired`, и её нужно повторить после устранения причины медленного прохода.
Дополнительно smoke сверяет, что действующий firewall — именно тот, который сгенерирован для текущей конфигурации: разбора `/etc/nftables.conf` для этого недостаточно, потому что прежний ruleset тоже валиден.
## Одна операция за раз
`install`, `reconfigure`, `repair` и `doctor` сериализованы эксклюзивным замком `/run/lock/hy2xs-orchestrator.lock`. Вторая операция отказывает сразу и **до первой мутации**:
```text
another HY2XS operation is already in progress: reconfigure (pid 4242, started at …)
```
Это не перестраховка: конфиги, unit‑файлы, `/etc/nftables.conf` и маркер установки — общие, и две одновременные операции записывают их поверх друг друга, после чего откат одной «восстанавливает» состояние поверх изменений другой.
`status` и `diagnostics collect` замок не берут — они нужны в том числе во время долгой операции, — но сообщают о ней в своём выводе.
Замок снимается сам при любом завершении держателя, включая `Ctrl+C`, SIGTERM и обрыв SSH. Если процесс был убит `kill -9`, следующая операция обнаружит мёртвого держателя и переиспользует замок самостоятельно.
Замка при этом недостаточно: он действует, пока жив процесс‑держатель, а rollback guard firewall — отдельный объект systemd, который свой процесс переживает. Аварийно умершая операция оставляет guard вооружённым, и он способен вернуть прежний firewall уже посреди следующей операции. Поэтому условие старта — не «предыдущая операция мертва», а «у неё не осталось исполнителей, способных изменить систему»:
```text
previous HY2XS operation is no longer running, but its firewall rollback guard
is still armed: hy2xs-fw-rollback-<op-id>.timer (active/waiting)
```
Ждать в этом случае нужно не дольше 45–46 секунд с момента применения firewall.
Покоем считаются ровно два состояния юнита — `inactive` и `failed`: отработавший guard больше ничего не сделает, а отказ по `failed` заблокировал бы `repair`, которым чинят последствия. Всё остальное, включая незнакомые барьеру состояния systemd, операцию запрещает.
Отдельный случай — когда состояние guard'а вообще не удалось выяснить:
```text
unable to verify firewall rollback guard state; systemd query failed,
refusing to start a lifecycle operation
```
Здесь ждать нечего: отсутствие ответа systemd — это отсутствие доказательства, а не доказательство покоя, и разбираться нужно с systemd. Барьер обязан **доказать**, что у предыдущей операции не осталось исполнителей, способных изменить firewall; молчаливое «наверное, всё в порядке» однажды означало бы срабатывание старого таймера поверх новой операции.
## Реконфигурация
После изменения `/etc/hy2xs/hy2xs.env` сначала выполните dry‑run:
@@ -583,14 +760,21 @@ hy2xs-orchestrator reconfigure \
| Команда | Назначение |
| --- | --- |
| `hy2xs-orchestrator preflight-install` | Read‑only проверка чистоты хоста; ничего не меняет |
| `hy2xs-orchestrator status` | Показать состояние платформы, сервисов, firewall и install marker |
| `hy2xs-orchestrator doctor` | Выполнить preflight и smokechecks текущей установки |
| `hy2xs-orchestrator doctor` | Выполнить preflight и smokechecks текущей установки; сервисы **не перезапускает** |
| `hy2xs-orchestrator reconfigure --dry-run` | Проверить конфиг без применения |
| `hy2xs-orchestrator reconfigure --apply` | Применить runtime‑конфигурацию |
| `hy2xs-orchestrator repair` | Попытаться восстановить partial install state |
| `hy2xs-orchestrator diagnostics collect` | Собрать diagnostic bundle в `/var/log/hy2xs/diagnostics` |
| `hy2xs-orchestrator repair --allow-partial-state` | Довести до конца незавершённую установку **текущего поколения** |
| `hy2xs-orchestrator diagnostics collect` | Собрать diagnostic bundle в `/var/lib/hy2xs/diagnostics` |
| `hy2xs-orchestrator redact-config` | Отредактировать секреты в env/yaml перед публикацией логов |
`repair` без `--allow-partial-state` работает только поверх полностью успешной
установки. В обоих режимах он сначала проверяет, что
`/var/lib/hy2xs/install-state.json` принадлежит текущему поколению продукта
(`product`, `release_line`, `config_schema_version`), и отказывается работать
поверх чужого состояния.
Пример сбора диагностики:
```bash
@@ -599,6 +783,22 @@ hy2xs-orchestrator diagnostics collect \
--config /etc/hy2xs/hy2xs.env
```
Бандл не содержит сырых промежуточных копий конфигурации или журналов:
редакция выполняется в памяти до записи. Файл с повреждённым UTF-8 не
декодируется с заменой и не попадает в архив; вместо него записывается
безопасная причина пропуска.
`HY2XS_FORCE_PASSWORD_CHANGE` — диагностический boolean, поэтому его значение
`true`/`false` сохраняется. Исключение точное и не распространяется на другие
ключи с `PASSWORD`: начальный пароль администратора, `ADMIN_CON_PASS`, пароль
obfs и остальные секреты по-прежнему заменяются на `<redacted>`.
Архив создаётся в `/var/lib/hy2xs/diagnostics` с режимом `0600`. Этот каталог
принадлежит `root:root`, имеет режим `0700` и отделён от
`HY2XS_LOG_DIR`, которым владеет сервисный пользователь `hy2xs-admin`.
Оркестратор отказывает, если каталог подменён symlink, имеет другого владельца
или ослабленные права. Незавершённый staging-каталог после упаковки удаляется.
## Проверка безопасности после установки
Минимальный набор проверок:
@@ -637,6 +837,35 @@ permitopen 127.0.0.1:8080 localhost:8080
## Troubleshooting
### Установка отказывается: обнаружена предыдущая установка
```text
[hy2xs] ERROR: На сервере обнаружена предыдущая или посторонняя установка.
HY2XS v1 не поддерживает установку поверх и не мигрирует состояние 0.x.
Ни один файл на сервере не изменён.
```
Это ожидаемое поведение, а не сбой. Отказ происходит в PHASE 0, до любой
мутации: сервер остался в том состоянии, в котором был.
Что делать:
1. сохраните нужные данные (база пиров, конфиг) — см.
[docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md);
2. посмотрите план очистки: `sudo ./purge-v0.sh`;
3. выполните очистку: `sudo ./purge-v0.sh --apply --yes-i-know`;
4. повторите установку.
Отдельный случай — отказ вида
`HY2XS_CONFIG_SCHEMA_VERSION отсутствует в конфигурации`. Он означает, что
переданный `--config` относится к предыдущему поколению: до v1 этого поля не
существовало. Создайте конфиг заново по разделу «Создайте конфиг для своего
сервера».
Если установка HY2XS v1 упала **после** начала применения изменений, полная
очистка не нужна — используйте
`hy2xs-orchestrator repair --allow-partial-state`.
### Установка падает на DNS AAAA
Причина: домен имеет IPv6 AAAA‑запись, а HY2XS production profile является IPv4only.
@@ -646,6 +875,23 @@ permitopen 127.0.0.1:8080 localhost:8080
1. удалить AAAA‑запись у домена;
2. либо временно установить `HY2XS_DNS_AAAA_POLICY=warn`, если оператор осознанно принимает риск клиентских IPv6‑маршрутов вне текущего baseline.
### `DNS IPv4 mismatch`: DNS ведёт не на этот сервер
Причина: A‑запись публичного endpoint указывает на адрес, которого нет среди публичных IPv4 этого сервера. Типичный случай — провайдер принудительно сменил IP, а DNS остался старым: сервисы на машине живы, но клиентская ссылка отправляет людей на другой адрес.
Решения:
1. сверить фактический адрес сервера и обновить A‑запись:
```bash
ip -4 addr show scope global
```
2. дождаться истечения TTL и повторить `hy2xs-orchestrator doctor`;
3. если в строке `server public IPv4:` пусто — на интерфейсах нет публичного IPv4 (сервер за NAT). Это вне baseline; при осознанном решении установите `HY2XS_PUBLIC_ENDPOINT_POLICY=warn`.
Если A‑записей несколько и среди них есть посторонняя, проверка тоже отказывает: HY2XS — single‑host профиль, и второй backend за тем же именем означает, что часть клиентов попадёт не на этот сервер.
### Установка падает на проверке SSH‑порта
HY2XS firewall‑слой проверяет, что порт из `HY2XS_SSH_PORT` уже слушается. Если указано `2323`, но `sshd` продолжает слушать только `22`, установка остановится.
@@ -703,6 +949,43 @@ hy2xs-orchestrator redact-config \
Обычному пользователю не нужно собирать проект из исходников. Этот раздел нужен maintainer’у, который готовит release‑архив.
### Перед работой: сверьте среду с контрактом
```bash
./tools/dev/doctor.sh # Linux/macOS
.\tools\dev\doctor.ps1 # Windows (PowerShell 7+)
```
```text
HY2XS development environment
contract: versions.env (HY2XS 1.0.0, release line 1)
Go:
required: 1.26.8
found: 1.25.6
FAIL — локальный Go собирает не ту stdlib, что уедет в релиз; поставьте 1.26.8
Node:
required: 24.20.0
found: 24.20.0
OK
```
Скрипт ничего не устанавливает и не меняет — он отвечает на один вопрос:
совпадает ли эта машина с контрактом сборки.
Раньше `versions.env` был контрактом только для сборки: она скачивает Go, Node и
Bun ровно тех версий, что там записаны, сверяя контрольные суммы, а машина
разработчика не проверялась никак. Расхождение обнаруживалось на Debian, внутри
release‑сборки, и выглядело как «у меня работало».
Расхождение не гипотетическое. Директива `go` в `apps/go.mod` — это языковой
baseline модуля, а не выбор компилятора, поэтому локальный Go другой минорной
линии собирал проект успешно, пока релизный бинарь компилировался на 1.26.7 и
наследовал **её** stdlib: проверялся не тот код, который уезжает в production.
Поэтому `go.mod` теперь объявляет `toolchain` явно, а `doctor` показывает
расхождение до сборки, а не после.
Сборка поддерживается на Debian 13 amd64 из чистого git work tree.
```bash
@@ -716,26 +999,112 @@ git status --short
Подготовьте build env:
Версии и контрольные суммы toolchain **не задаются переменными окружения**: они
объявлены в корневом [`versions.env`](versions.env), и сборка берёт их оттуда.
Раньше их приходилось передавать снаружи, из-за чего воспроизводимая сборка в
чистой Debian‑среде требовала предварительного знания четырёх SHA‑256.
```bash
export PACKAGE_VERSION=0.2.2
export BUILD_ID=prod-$(date -u +%Y%m%dT%H%M%SZ)
# Для переносимости между x86_64-серверами без AVX2 предпочтителен baseline artifact.
# Ожидаемый digest выбирается автоматически: в versions.env зафиксированы обе суммы.
export BUN_FLAVOR=x64-baseline
# Builder требует SHA256 для скачиваемых toolchain-архивов.
# Значения нужно брать из официальных release/checksum источников для конкретных версий.
export GO_ARCHIVE_SHA256=<sha256-go1.21.13-linux-amd64.tar.gz>
export NODE_ARCHIVE_SHA256=<sha256-node-v20.19.0-linux-x64.tar.xz>
export BUN_ARCHIVE_SHA256=<sha256-bun-linux-x64-baseline-1.3.13.zip>
# Опционально: снимает anonymous rate limit при разрешении upstream-релиза.
export GITHUB_TOKEN=<token>
```
`PACKAGE_VERSION` тоже приходит из `versions.env` (`HY2XS_VERSION`). Шаг
`verify_versions_contract` роняет сборку, если версия продукта, схема
конфигурации, целевая платформа или `packageManager` в `package.json`
разошлись с контрактом.
Запустите сборку:
```bash
./tools/build/build.sh
```
Сборка последовательно:
1. проверяет контракт `versions.env` (`verify_versions_contract`);
2. прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`);
3. прогоняет dependency-free контракты панели (спрайт иконок, совпадение словарей, коды ошибок, атрибуция и frontend/Go-контракты);
4. определяет последнюю стабильную версию Hysteria, берёт ожидаемый SHA‑256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт;
5. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander;
6. собирает standalone-бинарник orchestrator;
7. устанавливает frontend lock-граф, runtime-компилирует каждое сообщение RU/EN реальным `vue-i18n`, затем проверяет типы и собирает frontend и backend, проставляя версию админки из контракта;
8. прогоняет `go vet` и `go test` для HY2XS admin;
9. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу);
10. формирует архив и прогоняет acceptance‑проверки.
Любой сбой на шагах 1–9 останавливает сборку до создания пакета.
Тесты и типы (шаги 2, 3, 7 и 8) — такой же обязательный гейт, как проверка
зависимостей: переменной, которая их отключает, не существует. Готовый пакет
объявляет об этом полем `tests_gate=true` в `metadata/package.env`, и это
утверждение опирается на фактический прогон, а не на намерение.
Для локальной работы обходить нечего: `bun test`, `bun x tsc --noEmit`,
`go vet ./...`, `go test ./...` и
`bun test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts`
запускаются напрямую и tarball не создают.
Runtime-проверка словарей требует установленного frontend lock-графа:
```bash
cd apps/frontend
pnpm install --frozen-lockfile
bun test test/i18n-runtime.test.ts
```
Переменные, управляющие выбором версии Hysteria:
| Переменная | По умолчанию | Назначение |
| --- | --- | --- |
| `HYSTERIA_CHANNEL` | из `versions.env` (`stable`) | `stable` — разрешить последнюю стабильную; `pinned` — офлайн‑сборка по `tools/build/hysteria-lock.env` |
| `HYSTERIA_VERSION_OVERRIDE` | пусто | Закрепить конкретную версию `vX.Y.Z` |
| `HYSTERIA_COMPAT_GATE` | `true` | Compatibility gate; для release‑сборок обязателен |
| `HYSTERIA_VERIFY_UPSTREAM_HASHES` | `true` | Сверять артефакт с upstream `hashes.txt`; отключение — только break‑glass |
| `HYSTERIA_WRITE_LOCK` | `false` | Записать разрешённые значения обратно в lock‑файл |
Проверка зависимостей переменными не управляется: у неё **нет аварийного
выхода**. Релизный артефакт HY2XS невозможно собрать с непройденным гейтом, и
поле `dependency_security_gate` в `metadata/package.env` принимает единственное
значение `true`.
Раньше здесь были описаны два способа выпустить релиз, зная об уязвимости. Ими
они не являлись: финальная приёмка архива требует буквально
`dependency_security_gate=true`, поэтому сборка с любым из них проходила весь
цикл и падала на последнем шаге. Документированная операция, которую продукт сам
же запрещает, — хуже отсутствующей.
`pnpm audit` при этом проверяет **весь** lock‑граф frontend, а не только
production‑подграф. Причина в том, что build tooling исполняется на build‑машине
и порождает production‑бандл: уязвимость в `vite`/`rollup` уезжает в артефакт,
хотя сами они на сервер не копируются. Ровно такой случай и был найден — DOM
clobbering в Rollup затрагивал генерируемый бандл, а проверка по одному
production‑подграфу его не показывала.
Если advisory вышло в неудачный момент, чинится это обновлением графа
(`apps/go.sum`, `apps/frontend/pnpm-lock.yaml`) или версии toolchain в
`versions.env`. Для локальной работы обходить нечего: `go test ./...`,
`govulncheck ./...` и `pnpm audit` запускаются напрямую и tarball не создают.
Проверка versions‑контракта и проверка зависимостей отвечают на разные вопросы.
Первая следит, что зафиксированные версии **согласованы между собой**; вторая —
что про эти версии **не стало известно плохого**. Зафиксированный граф не
стареет только на бумаге: advisory по нему выходят и после фиксации, а сборка
релиза — единственный момент, когда это расхождение ловится дёшево.
Полный E2E с реальным клиентом Hysteria запускается отдельно (нужен Go: ссылка
берётся из production‑генератора, а не из отдельной реализации внутри теста):
```bash
HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
```
Результат:
```text
@@ -748,7 +1117,7 @@ dist/hy2xs-install-<version>.tar.gz
ls -lh dist/hy2xs-install-*.tar.gz
sha256sum dist/hy2xs-install-*.tar.gz
tar -tzf dist/hy2xs-install-0.2.2.tar.gz | grep -E \
tar -tzf dist/hy2xs-install-1.0.0.tar.gz | grep -E \
'^(hy2xs-install/install.sh|hy2xs-install/orchestrator/hy2xs-orchestrator|hy2xs-install/ui/hy2xs-admin/hy2xs-admin|hy2xs-install/metadata/checksums.txt)$'
```
@@ -762,12 +1131,27 @@ tar -tzf dist/hy2xs-install-0.2.2.tar.gz | grep -E \
├── orchestrator/ # install-only orchestrator на Bun + TypeScript
├── package/ # skeleton будущего install package
├── tools/build/ # production builder и packaging pipeline
├── tools/dev/ # doctor: сверка среды разработки с versions.env
├── tools/test/ # e2e с реальным клиентом Hysteria и контракты панели
├── tools/legacy/ # purge-v0.sh: очистка сервера от предыдущего поколения
├── docs/ # документация, разложенная по слоям
│ ├── architecture/ # baseline-модель и рамки
│ ├── build/ # builder layer и состав пакета
│ ├── runtime/ # оркестратор, systemd, post-install
│ ├── admin/ # HY2XS admin и контракты панели
│ ├── operations/ # runbook, разбор отказов, очистка 0.x
│ ├── testing/ # набор проверок по слоям
│ └── acceptance/ # отчёты о фактических прогонах приёмки
├── versions.env # контракт продукта, платформы и toolchain
├── CHANGELOG.md
├── README.md
└── LICENSE
```
Каталог `dist/` создаётся builder’ом и не должен храниться в git.
Точка входа в документацию — [docs/README.md](docs/README.md).
## Для кого этот проект
HY2XS рассчитан на операторов, которым нужен воспроизводимый способ поставить Hysteria2‑сервер с локальной панелью управления, не собирая проект на production‑сервере и не открывая admin UI наружу.
@@ -783,4 +1167,12 @@ HY2XS рассчитан на операторов, которым нужен в
## Лицензия
Проект распространяется по лицензии MIT. Текст лицензии находится в [`LICENSE`](LICENSE).
HY2XS распространяется на условиях **GNU Affero General Public License v3.0 only** (`AGPL-3.0-only`).
Полный текст лицензии находится в [`LICENSE`](LICENSE).
Сторонние библиотеки и зависимости сохраняют собственные лицензии.
---
<p align="center">Разработано во <a href="https://flamy.studio">Flamy Studio</a>.</p>
+12 -5
View File
@@ -35,11 +35,18 @@ func run(cmd *cobra.Command, args []string) {
fmt.Println(err.Error())
os.Exit(1)
}
for {
if err := runServer(port); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
// Цикла `for { runServer() }` здесь больше нет.
//
// Он существовал ради единственного сценария: смена RESET_TRAFFIC_CRON
// делала StopServer(), runServer возвращался, и цикл поднимал сервис
// заново, чтобы новое расписание попало в новый планировщик. Это был
// внутрипроцессный «перезапуск», из-за которого планировщики накапливались
// (см. service/cron_scheduler.go). Расписание теперь переносится на месте,
// перезапускать сервер незачем, а перезапуском упавшего юнита занимается
// systemd (Restart=on-failure) — он для этого и предназначен.
if err := runServer(port); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
}
+93 -37
View File
@@ -1,6 +1,7 @@
package cmd
import (
"errors"
"fmt"
"github.com/spf13/cobra"
"hy2xs-admin/dao"
@@ -21,33 +22,106 @@ func init() {
rootCmd.AddCommand(resetCmd)
}
// Длины восстановительных учётных данных.
//
// Было по 6 символов на оба поля. Шесть символов алфавита из 62 — это около 36
// бит, то есть ровно нижняя граница, которую пропускает HashPassword, и
// единственной защитой оставался force_password_change. Пароль восстановления
// живёт до первого входа администратора, но до этого входа он полноценно
// пускает в панель, поэтому его стойкость должна соответствовать остальным
// секретам продукта, а не минимально допустимой.
const (
resetUsernameLength = 12
resetPasswordLength = 24
)
// adminLookup — способ узнать о существующей учётной записи администратора.
//
// Параметризовано ради теста на отказ хранилища. Отличить «ветку создания» от
// «ветки обновления» при недоступной базе иначе нельзя: при по-настоящему
// сломанной базе обе ветки заканчиваются ошибкой записи, и наблюдаемый
// результат совпадает. Опасен же ровно транзиентный отказ, когда чтение упало,
// а запись прошла.
type adminLookup func() (entity.AdminUser, error)
func runReset(cmd *cobra.Command, args []string) {
username, err := util.RandomString(6)
username, err := util.RandomString(resetUsernameLength)
if err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
password, err := util.RandomString(6)
password, err := util.RandomString(resetPasswordLength)
if err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
if err = dao.InitSql(""); err != nil {
if err = dao.InitSql(); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
admin, err := dao.GetAdminUser("1 = 1")
nowMs := time.Now().UnixMilli()
lookup := func() (entity.AdminUser, error) { return dao.GetAdminUser("1 = 1") }
if err = resetAdminCredentials(lookup, username, password, time.Now().UnixMilli()); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
if err = dao.CloseSqliteDB(); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
fmt.Println(fmt.Sprintf("HY2XS admin Login Username: %s", username))
fmt.Println(fmt.Sprintf("HY2XS admin Login Password: %s", password))
}
func resetAdminCredentials(lookup adminLookup, username, password string, nowMs int64) error {
// Хеш считается ОДИН раз и ДО записи.
//
// В ветке обновления стояло `hash, _ := util.HashPassword(password)` внутри
// литерала map. Ошибка bcrypt проглатывалась, в password_hash уезжала пустая
// строка, а на экран печатался пароль, которым войти уже невозможно:
// VerifyPassword отклоняет всё, что не является bcrypt-хешем. То есть
// команда восстановления доступа умела молча его отобрать.
hash, err := util.HashPassword(password)
if err != nil {
return fmt.Errorf("не удалось захешировать пароль восстановления: %w", err)
}
admin, err := lookup()
// Отказ хранилища — это не «администратора нет».
//
// Здесь стояло обычное `if err != nil { создать } else { обновить }`, хотя
// слой данных специально различает ErrAdminUserNotFound и ErrStorage.
// Склейка опасна не только нарушением смысла sentinel'ов: при транзиентном
// отказе SQLite («database is locked») ветка создания отрабатывала успешно,
// и в таблице оказывались ДВЕ учётные записи администратора.
// `GetAdminUser("1 = 1").First()` дальше отдаёт произвольную из них, то есть
// на сервере остаётся вторая рабочая учётка с паролем, который уже был
// напечатан на экран.
switch {
case err == nil:
tokenVersion := int64(1)
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
tokenVersion = *admin.TokenVersion + 1
}
if updateErr := dao.UpdateAdminUser([]int64{*admin.Id}, map[string]interface{}{
"username": username,
"password_hash": hash,
"force_password_change": 1,
"password_changed_at": nowMs,
"token_version": tokenVersion,
"status": 1,
}); updateErr != nil {
return fmt.Errorf("не удалось обновить учётную запись администратора: %w", updateErr)
}
return nil
case errors.Is(err, dao.ErrAdminUserNotFound):
tokenVersion := int64(1)
status := int64(1)
forcePasswordChange := int64(1)
passwordChangedAt := nowMs
hash, hashErr := util.HashPassword(password)
if hashErr != nil {
fmt.Println(hashErr.Error())
os.Exit(1)
}
adminUser := entity.AdminUser{
Username: &username,
PasswordHash: &hash,
@@ -57,33 +131,15 @@ func runReset(cmd *cobra.Command, args []string) {
TokenVersion: &tokenVersion,
}
if _, saveErr := dao.SaveAdminUser(adminUser); saveErr != nil {
fmt.Println(saveErr.Error())
os.Exit(1)
}
} else {
tokenVersion := int64(1)
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
tokenVersion = *admin.TokenVersion + 1
}
if err = dao.UpdateAdminUser([]int64{*admin.Id}, map[string]interface{}{
"username": username,
"password_hash": func() string {
hash, _ := util.HashPassword(password)
return hash
}(),
"force_password_change": 1,
"password_changed_at": nowMs,
"token_version": tokenVersion,
"status": 1,
}); err != nil {
fmt.Println(err.Error())
os.Exit(1)
return fmt.Errorf("не удалось создать учётную запись администратора: %w", saveErr)
}
return nil
default:
return fmt.Errorf(
"не удалось прочитать учётную запись администратора: база данных не ответила. "+
"Сброс не выполнен: создавать вторую учётную запись при недоступной базе нельзя: %w",
err,
)
}
if err = dao.CloseSqliteDB(); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
fmt.Println(fmt.Sprintf("HY2XS admin Login Username: %s", username))
fmt.Println(fmt.Sprintf("HY2XS admin Login Password: %s", password))
}
+226
View File
@@ -0,0 +1,226 @@
package cmd
import (
"errors"
"path/filepath"
"strings"
"testing"
"hy2xs-admin/dao"
"hy2xs-admin/model/entity"
"hy2xs-admin/util"
)
// reset-admin — команда восстановления доступа, и ошибиться ей дороже, чем
// обычному обработчику: она печатает новые учётные данные на экран и на этом
// основании оператор считает доступ восстановленным.
//
// Здесь закрепляются два дефекта:
//
// 1. отказ хранилища трактовался как «администратора нет», то есть переводил
// команду в ветку СОЗДАНИЯ учётной записи;
// 2. ошибка bcrypt в ветке обновления проглатывалась (`hash, _ := ...`), и в
// password_hash уезжала пустая строка.
func newAdminDB(t *testing.T) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
if err := dao.InitSqliteDBAt(dbPath); err != nil {
t.Fatalf("не удалось открыть тестовую базу: %v", err)
}
if err := dao.RunMigrations(); err != nil {
t.Fatalf("не удалось применить миграции: %v", err)
}
t.Cleanup(func() { _ = dao.CloseSqliteDB() })
}
func realLookup() (entity.AdminUser, error) {
return dao.GetAdminUser("1 = 1")
}
func countAdmins(t *testing.T) int64 {
t.Helper()
count, err := dao.CountAdminUsers()
if err != nil {
t.Fatalf("не удалось посчитать администраторов: %v", err)
}
return count
}
func TestResetCreatesAdminWhenNoneExists(t *testing.T) {
newAdminDB(t)
if err := resetAdminCredentials(realLookup, "operator-1", "recovery-password-1", 1700000000000); err != nil {
t.Fatalf("сброс не выполнен: %v", err)
}
if got := countAdmins(t); got != 1 {
t.Fatalf("ожидалась одна учётная запись, получено %d", got)
}
admin, err := dao.GetAdminUser("1 = 1")
if err != nil {
t.Fatalf("учётная запись не читается: %v", err)
}
if *admin.Username != "operator-1" {
t.Errorf("имя пользователя не записано: %q", *admin.Username)
}
if !util.VerifyPassword("recovery-password-1", *admin.PasswordHash) {
t.Error("напечатанный пароль не подходит к сохранённому хешу")
}
if *admin.ForcePasswordChange != 1 {
t.Error("восстановительный пароль обязан требовать смены при первом входе")
}
}
func TestResetUpdatesExistingAdminInPlace(t *testing.T) {
newAdminDB(t)
if err := resetAdminCredentials(realLookup, "operator-1", "recovery-password-1", 1700000000000); err != nil {
t.Fatalf("первичный сброс не выполнен: %v", err)
}
before, err := dao.GetAdminUser("1 = 1")
if err != nil {
t.Fatalf("учётная запись не читается: %v", err)
}
if err := resetAdminCredentials(realLookup, "operator-2", "recovery-password-2", 1700000001000); err != nil {
t.Fatalf("повторный сброс не выполнен: %v", err)
}
if got := countAdmins(t); got != 1 {
t.Fatalf("повторный сброс размножил учётные записи: %d", got)
}
after, err := dao.GetAdminUser("1 = 1")
if err != nil {
t.Fatalf("учётная запись не читается: %v", err)
}
if *after.Id != *before.Id {
t.Errorf("учётная запись пересоздана: было id=%d, стало id=%d", *before.Id, *after.Id)
}
if *after.Username != "operator-2" {
t.Errorf("имя пользователя не обновлено: %q", *after.Username)
}
if !util.VerifyPassword("recovery-password-2", *after.PasswordHash) {
t.Error("новый пароль не подходит к сохранённому хешу")
}
if util.VerifyPassword("recovery-password-1", *after.PasswordHash) {
t.Error("прежний пароль всё ещё действует")
}
// Смена пароля обязана обесценивать выданные ранее токены.
if *after.TokenVersion <= *before.TokenVersion {
t.Errorf("token_version не увеличен: было %d, стало %d", *before.TokenVersion, *after.TokenVersion)
}
}
// Ключевая регрессия. База ПОЛНОСТЬЮ работоспособна, отказало только чтение —
// ровно тот транзиентный случай («database is locked»), из-за которого прежний
// код уходил в ветку создания и оставлял на сервере вторую рабочую учётку с
// паролем, уже напечатанным на экран.
func TestResetRefusesToCreateSecondAdminOnStorageFailure(t *testing.T) {
newAdminDB(t)
if err := resetAdminCredentials(realLookup, "operator-1", "recovery-password-1", 1700000000000); err != nil {
t.Fatalf("первичный сброс не выполнен: %v", err)
}
before, err := dao.GetAdminUser("1 = 1")
if err != nil {
t.Fatalf("учётная запись не читается: %v", err)
}
failingLookup := func() (entity.AdminUser, error) {
return entity.AdminUser{}, dao.ErrStorage
}
err = resetAdminCredentials(failingLookup, "operator-2", "recovery-password-2", 1700000001000)
if err == nil {
t.Fatal("отказ хранилища обязан останавливать сброс, а не трактоваться как отсутствие администратора")
}
if !strings.Contains(err.Error(), "база данных не ответила") {
t.Errorf("сообщение не объясняет причину отказа: %v", err)
}
if got := countAdmins(t); got != 1 {
t.Fatalf("при отказе чтения создана вторая учётная запись: всего %d", got)
}
after, err := dao.GetAdminUser("1 = 1")
if err != nil {
t.Fatalf("учётная запись не читается: %v", err)
}
if *after.Id != *before.Id || *after.Username != *before.Username {
t.Error("существующая учётная запись изменена при отказе чтения")
}
if !util.VerifyPassword("recovery-password-1", *after.PasswordHash) {
t.Error("прежний пароль перестал действовать, хотя сброс не выполнялся")
}
if util.VerifyPassword("recovery-password-2", *after.PasswordHash) {
t.Error("напечатанный при отказе пароль действует")
}
}
// «Записи нет» по-прежнему означает создание: строгость к ErrStorage не имеет
// права сломать штатный путь восстановления на пустой базе.
func TestResetStillCreatesOnNotFoundSentinel(t *testing.T) {
newAdminDB(t)
notFound := func() (entity.AdminUser, error) {
return entity.AdminUser{}, dao.ErrAdminUserNotFound
}
if err := resetAdminCredentials(notFound, "operator-1", "recovery-password-1", 1700000000000); err != nil {
t.Fatalf("сброс на пустой базе не выполнен: %v", err)
}
if got := countAdmins(t); got != 1 {
t.Fatalf("ожидалась одна учётная запись, получено %d", got)
}
}
// Sentinel'ы «нет записи» намеренно НЕСУТ ОДИНАКОВЫЙ ТЕКСТ: WrongPassword
// уезжает в ответ Hysteria при неудачной machine-auth, и менять его ради
// внутренней аккуратности было бы изменением внешнего контракта. Поэтому
// различать их можно только через errors.Is, и решение о ветке обязано
// опираться на идентичность значения, а не на строку.
func TestAdminSentinelsAreDistinguishableOnlyByIdentity(t *testing.T) {
if dao.ErrAdminUserNotFound.Error() != dao.ErrPeerNotFound.Error() {
t.Log("тексты sentinel'ов разошлись; сравнение по идентичности остаётся обязательным")
}
if errors.Is(dao.ErrAdminUserNotFound, dao.ErrPeerNotFound) {
t.Error("sentinel'ы разных таблиц неразличимы по идентичности")
}
if errors.Is(dao.ErrStorage, dao.ErrAdminUserNotFound) {
t.Error("отказ хранилища опознаётся как отсутствие записи")
}
if dao.IsNotFound(dao.ErrStorage) {
t.Error("IsNotFound истинна для отказа хранилища")
}
}
// Ошибка хеширования не имеет права превратиться в пустой password_hash.
func TestResetRefusesWhenPasswordCannotBeHashed(t *testing.T) {
newAdminDB(t)
if err := resetAdminCredentials(realLookup, "operator-1", "recovery-password-1", 1700000000000); err != nil {
t.Fatalf("первичный сброс не выполнен: %v", err)
}
// HashPassword отклоняет пароль короче шести символов.
err := resetAdminCredentials(realLookup, "operator-2", "abc", 1700000001000)
if err == nil {
t.Fatal("непригодный пароль обязан останавливать сброс")
}
admin, getErr := dao.GetAdminUser("1 = 1")
if getErr != nil {
t.Fatalf("учётная запись не читается: %v", getErr)
}
if !util.IsBcryptHash(*admin.PasswordHash) {
t.Errorf("в password_hash оказалась не-bcrypt строка: %q", *admin.PasswordHash)
}
if !util.VerifyPassword("recovery-password-1", *admin.PasswordHash) {
t.Error("прежний пароль перестал действовать после неудачного сброса")
}
}
+105 -30
View File
@@ -1,6 +1,7 @@
package cmd
import (
"context"
"errors"
"fmt"
"github.com/gin-gonic/gin"
@@ -14,7 +15,11 @@ import (
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
)
func resolveUiBindHost() (string, error) {
@@ -29,31 +34,30 @@ func resolveUiBindHost() (string, error) {
return host, nil
}
// resolveUiPort проверяет порт, полученный из контракта запуска.
//
// Источник истины — HY2XS_UI_PORT, который оркестратор подставляет в ExecStart
// как `-p`. Раньше это значение по дороге записывалось в SQLite и читалось
// оттуда обратно: круг, в котором база не добавляла ни одного факта, но делала
// вид, что порт принадлежит ей.
func resolveUiPort(port string) (int, error) {
trimmed := strings.TrimSpace(port)
if trimmed == "" {
return 0, errors.New("UI port is required: pass -p <port>")
}
value, err := strconv.Atoi(trimmed)
if err != nil || value < 1 || value > 65535 {
return 0, fmt.Errorf("port: %s is invalid", port)
}
return value, nil
}
func runServer(port string) error {
defer releaseResource()
middleware.InitLog()
if err := initFile(); err != nil {
return err
}
if err := dao.InitSql(port); err != nil {
return err
}
if err := middleware.InitCron(); err != nil {
return err
}
if err := service.InitHysteria2(); err != nil {
return err
}
config, err := dao.GetConfig("key = ?", constant.HUIWebContext)
if err != nil {
return err
}
r := gin.Default()
router.Router(r, config.Value)
serverPort, crtPath, keyPath, err := service.GetServerPortAndCert()
uiPort, err := resolveUiPort(port)
if err != nil {
return err
}
@@ -62,20 +66,92 @@ func runServer(port string) error {
return err
}
service.InitServer(fmt.Sprintf("%s:%d", bindHost, serverPort), r)
if err := service.StartServer(crtPath, keyPath); err != nil && err != http.ErrServerClosed {
logrus.Errorf("start server err: %v", err)
return errors.New("start server err")
if err := initFile(); err != nil {
return err
}
if err := dao.InitSql(); err != nil {
return err
}
if err := service.InitCron(); err != nil {
return err
}
// Порядок остановки обратный порядку регистрации: планировщик глушится
// РАНЬШЕ, чем releaseResource закроет SQLite. Каждая джоба ходит в базу,
// и обратный порядок означал бы гарантированные «database is closed» на
// каждом штатном завершении сервиса.
defer service.StopCron()
if err := service.InitHysteria2(); err != nil {
return err
}
// gin.New(), а не gin.Default().
//
// gin.Default() подключает gin.Logger(), который печатает в stdout путь
// ВМЕСТЕ с query string. Для админки это второй, независимый от logrus
// канал журналирования: stdout юнита уезжает в journald, а diagnostics
// кладёт `journalctl -u hy2xs-admin` в бандл. Пока он существовал,
// machine token из /internal/hysteria/auth?access_token=… утекал в
// журнал даже после того, как из собственного логгера админки query
// string убрали.
//
// HTTP-логгер у продукта ровно один — middleware.LogHandler, и он
// подключается пофазно в router.Router. Здесь остаётся только Recovery:
// паника в обработчике не должна ронять сервис.
r := gin.New()
r.Use(gin.Recovery())
router.Router(r)
// TLS внутри админки не поддерживается намеренно: панель слушает
// loopback и публикуется через SSH-туннель или reverse proxy, на котором
// TLS и заканчивается.
service.InitServer(fmt.Sprintf("%s:%d", bindHost, uiPort), r)
// Штатное завершение по сигналу.
//
// Раньше SIGTERM от systemd просто убивал процесс: соединения обрывались
// на середине, SQLite закрывался не через defer, а вместе с процессом, и
// джобы планировщика могли быть остановлены посреди записи. Для сервиса,
// который ведёт учёт трафика в транзакциях, это не абстракция — обычный
// `systemctl restart` попадал в это окно.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
serverErr := make(chan error, 1)
go func() {
serverErr <- service.StartServer()
}()
select {
case err := <-serverErr:
return classifyServeError(err)
case <-ctx.Done():
logrus.Infof("shutdown signal received, stopping hy2xs-admin")
if err := service.StopServer(); err != nil {
return err
}
return classifyServeError(<-serverErr)
}
return nil
}
// classifyServeError отделяет штатное закрытие сервера от настоящего отказа.
func classifyServeError(err error) error {
if err == nil || errors.Is(err, http.ErrServerClosed) {
return nil
}
logrus.Errorf("start server err: %v", err)
return errors.New("start server err")
}
// releaseResource закрывает то, чем владеет сам процесс админки.
//
// Вызова service.ReleaseHysteria2() здесь больше нет. Функция была пустой
// заглушкой `return nil` — остатком модели, в которой панель считала Hysteria
// своим подпроцессом и обязана была его отпустить. Жизненным циклом Hysteria
// владеет systemd, отпускать админке нечего, и шаг завершения, который ничего
// не делает, но выглядит освобождением ресурса, хуже отсутствующего.
func releaseResource() {
if err := dao.CloseSqliteDB(); err != nil {
logrus.Errorf(err.Error())
}
if err := service.ReleaseHysteria2(); err != nil {
logrus.Errorf(err.Error())
logrus.Errorf("%v", err)
}
}
@@ -84,7 +160,6 @@ func initFile() error {
constant.LogDir,
constant.SqliteDBDir,
constant.BinDir,
constant.ExportPathDir,
filepath.Dir(constant.SqliteDBPath),
}
for _, item := range dirs {
+39
View File
@@ -0,0 +1,39 @@
package controller
import (
"errors"
"github.com/gin-gonic/gin"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
)
// Вход в панель живёт в собственном файле, а не среди обработчиков пиров.
//
// Это не косметика. Пока Login лежал в controller/peer.go, паника валидатора на
// форме входа приходила в journal стеком `controller/peer.go:50`, и первая
// гипотеза при разборе RC2 указывала на управление пирами — то есть на
// подсистему, не имеющую к отказу никакого отношения. Самая ценная дверь
// продукта обязана называться своим именем в стеке.
func Login(c *gin.Context) {
loginDto, err := validateField(c, dto.LoginDto{})
if err != nil {
return
}
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
if err != nil {
// Неверные учётные данные получают код, чтобы панель показала
// оператору внятную фразу на его языке. Отказ базы остаётся системной
// ошибкой: выдавать «неверный логин или пароль» при недоступной SQLite
// значит отправить оператора искать несуществующую опечатку.
if errors.Is(err, service.ErrInvalidCredentials) {
vo.FailDomain(constant.ErrCodeInvalidCredentials, err.Error(), c)
return
}
vo.Fail(err.Error(), c)
return
}
vo.Success(vo.JwtVo{TokenType: constant.TokenType, AccessToken: token, ForcePasswordChange: forcePasswordChange}, c)
}
+838
View File
@@ -0,0 +1,838 @@
package controller
import (
"bytes"
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"regexp"
"strconv"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"hy2xs-admin/credential"
"hy2xs-admin/dao"
"hy2xs-admin/middleware"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
"hy2xs-admin/model/vo"
"hy2xs-admin/util"
)
// Контракт входа в панель.
//
// Что здесь произошло и почему тестов было мало. В RC2 каждый
// `POST /api/auth/login` заканчивался HTTP 500: на LoginDto.Username стоял тег
// `validateStr`, правило с таким именем не регистрировалось, и
// go-playground/validator ПАНИКОВАЛ до всякой проверки логина и пароля.
// Существующие тесты этого не видели, потому что проверяли регексп напрямую —
// в обход production-валидатора, — а ни одного запроса к обработчику входа не
// делал никто.
//
// Поэтому проверок здесь три уровня, и каждый ловит свой класс:
//
// структурный — ни один тег ни в одном DTO не ссылается на незарегистрированное
// правило (закрывает КЛАСС, а не найденный экземпляр);
// правила — LoginDto проходит через тот самый validate, что и в бою;
// HTTP — обработчик отвечает конвертом, а не 500, на любом входе.
// --------------------------------------------------------------- структура ---
var validationTagPattern = regexp.MustCompile(`validate:"([^"]*)"`)
// Ни один тег валидации не имеет права ссылаться на правило, которого нет.
//
// Это главный барьер файла. Наивная версия — «прогнать нулевое DTO через
// validate.Struct» — дефекта RC2 НЕ ловит: цепочка правил поля обрывается на
// первом несработавшем, поэтому на пустом Username проверка отказывает по
// `required` и до испорченного тега не доходит. Поэтому теги извлекаются из
// исходников и каждый предъявляется валидатору отдельно: незарегистрированное
// правило паникует ровно так же, как паниковало в бою, но здесь — на сборке.
func TestEveryValidationTagIsRegistered(t *testing.T) {
root := filepath.Join("..", "model")
users := map[string]map[string]struct{}{}
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") {
return nil
}
source, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
for _, match := range validationTagPattern.FindAllSubmatch(source, -1) {
for _, rule := range splitValidationRules(string(match[1])) {
if users[rule] == nil {
users[rule] = map[string]struct{}{}
}
users[rule][filepath.ToSlash(path)] = struct{}{}
}
}
return nil
})
if err != nil {
t.Fatalf("не удалось прочитать исходники модели: %v", err)
}
// Пустой обход означал бы, что проверка молчит из-за неверного пути, а не
// из-за отсутствия дефектов. Число намеренно грубое: тест закрепляет факт
// «теги найдены», а не их количество.
if len(users) < 5 {
t.Fatalf("в %s найдено %d правил валидации — обход не дошёл до DTO", root, len(users))
}
for rule, files := range users {
assertRuleIsRegistered(t, rule, files)
}
}
// splitValidationRules разбирает содержимое тега на отдельные правила.
//
// Разделителями считаются и `,`, и `|`: go-playground/validator понимает оба —
// первый как «и», второй как «или», — и правило, записанное через `|`, обязано
// существовать ровно так же.
func splitValidationRules(tag string) []string {
var rules []string
for _, rule := range strings.FieldsFunc(tag, func(r rune) bool { return r == ',' || r == '|' }) {
rule = strings.TrimSpace(rule)
if rule == "" || rule == "-" {
continue
}
rules = append(rules, rule)
}
return rules
}
func assertRuleIsRegistered(t *testing.T, rule string, files map[string]struct{}) {
t.Helper()
where := make([]string, 0, len(files))
for file := range files {
where = append(where, file)
}
defer func() {
if recovered := recover(); recovered != nil {
t.Errorf(
"правило %q не зарегистрировано в controller/validator.go, но используется в %s: %v\n"+
"незарегистрированный тег означает панику на КАЖДОМ запросе к этой форме",
rule, strings.Join(where, ", "), recovered,
)
}
}()
// Значение подобрано так, чтобы вопрос был именно о существовании правила:
// результат проверки здесь не важен, важно её отсутствие паники.
_ = validate.Var("hy2xsadmin", rule)
}
// ------------------------------------------------------------------ правила ---
func loginDto(username string, password string) dto.LoginDto {
return dto.LoginDto{Username: &username, Pass: &password}
}
// normalizedAndValidated повторяет конвейер validateField: нормализация, затем
// правила. Проверять правила в обход нормализации бессмысленно — в бою они
// видят только приведённый вход.
func normalizedAndValidated(login dto.LoginDto) error {
login.Normalize()
return validate.Struct(&login)
}
// Регрессия RC2: корректный вход не имеет права ни паниковать, ни отказывать.
func TestLoginDtoPassesProductionValidator(t *testing.T) {
defer func() {
if recovered := recover(); recovered != nil {
t.Fatalf("проверка LoginDto паникует: %v", recovered)
}
}()
if err := normalizedAndValidated(loginDto("hy2xsadmin", "bootstrap-password")); err != nil {
t.Fatalf("корректный LoginDto отклонён: %v", err)
}
}
// Отказ обязан быть ОТКАЗОМ — значением с причиной, а не паникой.
func TestLoginDtoReportsReasonInsteadOfPanic(t *testing.T) {
cases := []struct {
name string
username string
password string
code string
field string
}{
{"кириллица в логине", "админ12", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
{"пробел в логине", "hy2xs admin", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
{"логин короче минимума", "admin", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
{"логин длиннее максимума", strings.Repeat("a", credential.AdminUsernameMaxLength+1), "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
{"пустой логин", " ", "bootstrap-password", constant.ErrCodeRequired, "username"},
{"пустой пароль", "hy2xsadmin", "", constant.ErrCodeRequired, "pass"},
{"пароль короче минимума", "hy2xsadmin", "short", constant.ErrCodeAdminPassword, "pass"},
{"пароль длиннее максимума", "hy2xsadmin", strings.Repeat("a", credential.AdminPasswordMaxLength+1), constant.ErrCodeAdminPassword, "pass"},
// Укладывается в 64 символа и НЕ укладывается в 72 байта. Прежний
// контракт принимал это значение, и отказ приходил из bcrypt уже после
// всех проверок — системной ошибкой, а не причиной на поле формы.
{"пароль длиннее предела bcrypt", "hy2xsadmin", strings.Repeat("я", credential.AdminPasswordMaxLength), constant.ErrCodeAdminPassword, "pass"},
{"управляющий символ в пароле", "hy2xsadmin", "bootstrap\npassword", constant.ErrCodeAdminPassword, "pass"},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
defer func() {
if recovered := recover(); recovered != nil {
t.Fatalf("проверка обязана вернуть отказ, а не паниковать: %v", recovered)
}
}()
err := normalizedAndValidated(loginDto(testCase.username, testCase.password))
if err == nil {
t.Fatal("некорректный вход принят")
}
described := describeValidationErrors(err)
if !hasFieldReason(described, testCase.field, testCase.code) {
t.Fatalf("ожидалась причина %q на поле %q, получено %+v", testCase.code, testCase.field, described)
}
})
}
}
func hasFieldReason(described []vo.FieldError, field string, code string) bool {
for _, reason := range described {
if reason.Field == field && reason.Code == code {
return true
}
}
return false
}
// Причина отказа по набору символов обязана НАЗЫВАТЬ набор.
//
// Оператору негде посмотреть, какие символы допустимы в логине: значение
// приходит из hy2xs.env, а панель показывает то, что ответил сервер. Прежнее
// «содержит недопустимые символы» вдобавок описывало и отказ по длине.
func TestCredentialReasonNamesCharsetAndBounds(t *testing.T) {
err := normalizedAndValidated(loginDto("админ12", "bootstrap-password"))
if err == nil {
t.Fatal("некорректный логин принят")
}
for _, reason := range describeValidationErrors(err) {
if reason.Code != constant.ErrCodeCredentialStr {
continue
}
if reason.Params["charset"] != credential.AdminUsernameCharset {
t.Errorf("причина не называет набор символов: %+v", reason.Params)
}
if reason.Params["min"] != strconv.Itoa(credential.AdminUsernameMinLength) {
t.Errorf("причина не называет нижнюю границу: %+v", reason.Params)
}
if reason.Params["max"] != strconv.Itoa(credential.AdminUsernameMaxLength) {
t.Errorf("причина не называет верхнюю границу: %+v", reason.Params)
}
return
}
t.Fatalf("отказ не содержит причины %q", constant.ErrCodeCredentialStr)
}
// ---------------------------------------------------------------- контракты ---
// passwordFields — все поля продукта, несущие пароль администратора.
var passwordFields = []struct {
name string
typ reflect.Type
field string
}{
{"LoginDto.Pass", reflect.TypeOf(dto.LoginDto{}), "Pass"},
{"AdminChangePasswordDto.OldPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "OldPassword"},
{"AdminChangePasswordDto.NewPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "NewPassword"},
}
// Правило пароля ОДНО, и границы живут внутри него.
//
// Здесь стояли `min=6,max=64` прямо в тегах, и проверка следила за тем, чтобы
// числа совпадали с контрактом. Совпадали они честно — и всё равно закрепляли
// неполное правило: тег считает символы Unicode, а bcrypt считает байты, и
// границы в байтах тегом не выразить вовсе. Поэтому теперь проверяется не
// равенство чисел, а ОТСУТСТВИЕ чисел: длина принадлежит правилу, а не соседям
// по тегу. Тот же барьер уже стоит на логине (TestUsernameLengthLivesInsideItsRule).
func TestPasswordLengthLivesInsideItsRule(t *testing.T) {
for _, subject := range passwordFields {
field, ok := subject.typ.FieldByName(subject.field)
if !ok {
t.Fatalf("%s: поле не найдено", subject.name)
}
rules := splitValidationRules(field.Tag.Get("validate"))
for _, rule := range rules {
if strings.HasPrefix(rule, "min=") || strings.HasPrefix(rule, "max=") {
t.Errorf(
"%s: отдельная граница %q рядом с правилом. Длина принадлежит adminPassword: "+
"тег умеет считать только символы, а у пароля есть ещё граница в байтах",
subject.name, rule,
)
}
}
var hasPasswordRule bool
for _, rule := range rules {
if rule == "adminPassword" {
hasPasswordRule = true
}
}
if !hasPasswordRule {
t.Errorf("%s: проверяется правилами %v — среди них нет adminPassword", subject.name, rules)
}
}
}
// Все поля пароля проверяются ТЕМ ЖЕ правилом, что и bootstrap-установка.
//
// Проверка идёт через production-валидатор на граничных значениях: совпадения
// имён тегов мало, потому что именно расхождение реализаций за одинаковыми
// именами и было исходным дефектом.
func TestEveryPasswordFieldFollowsTheContract(t *testing.T) {
samples := []string{
strings.Repeat("a", credential.AdminPasswordMinLength),
strings.Repeat("a", credential.AdminPasswordMinLength-1),
strings.Repeat("a", credential.AdminPasswordMaxLength),
strings.Repeat("я", 36),
strings.Repeat("я", 37),
strings.Repeat("😀", 18),
strings.Repeat("😀", 19),
strings.Repeat("я", credential.AdminPasswordMaxLength),
"abcde ",
"abcde\n",
}
for _, subject := range passwordFields {
field, _ := subject.typ.FieldByName(subject.field)
rules := strings.Join(splitValidationRules(field.Tag.Get("validate")), ",")
rules = strings.ReplaceAll(rules, "required,", "")
for _, sample := range samples {
expected := credential.IsValidAdminPassword(sample)
if got := validate.Var(sample, rules) == nil; got != expected {
t.Errorf(
"%s: значение из %d символов / %d байт принято=%v, контракт=%v",
subject.name, len([]rune(sample)), len(sample), got, expected,
)
}
}
}
}
// Набор символов пароля сервером НЕ ограничивается — ни на входе, ни при смене.
//
// Это решение, а не упущение: значение всё равно сравнивается с хешем, поэтому
// ограничение набора не защищает ничего и умеет только отвергнуть пароль,
// который сервер принял бы. Панель обязана держаться этого же контракта, и
// проверка существует, чтобы правило не «навели» обратно.
func TestPasswordHasNoCharsetRule(t *testing.T) {
for _, subject := range passwordFields {
field, _ := subject.typ.FieldByName(subject.field)
for _, rule := range splitValidationRules(field.Tag.Get("validate")) {
if rule == "credentialStr" || rule == "peerName" {
t.Errorf("%s: на пароль повешено правило набора символов %q", subject.name, rule)
}
}
}
}
// Длина логина живёт ВНУТРИ правила, а не рядом с ним.
//
// Два правила длины на одном поле уже приводили к отказу, который невозможно
// объяснить оператору: `min=1,max=32` вместе с правилом «6-32» принимали и
// отвергали одно и то же значение, а панель показывала «invalid».
func TestUsernameLengthLivesInsideItsRule(t *testing.T) {
field, ok := reflect.TypeOf(dto.LoginDto{}).FieldByName("Username")
if !ok {
t.Fatal("LoginDto.Username не найдено")
}
rules := splitValidationRules(field.Tag.Get("validate"))
for _, rule := range rules {
if strings.HasPrefix(rule, "min=") || strings.HasPrefix(rule, "max=") {
t.Errorf("на логине отдельная граница %q: длина принадлежит правилу credentialStr", rule)
}
}
var hasCredentialRule bool
for _, rule := range rules {
if rule == "credentialStr" {
hasCredentialRule = true
}
}
if !hasCredentialRule {
t.Fatalf("логин проверяется правилами %v — среди них нет credentialStr", rules)
}
}
// --------------------------------------------------------------------- HTTP ---
func newAuthTestDB(t *testing.T, username string, password string) int64 {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
if err := dao.InitSqliteDBAt(dbPath); err != nil {
t.Fatalf("не удалось открыть тестовую базу: %v", err)
}
if err := dao.RunMigrations(); err != nil {
t.Fatalf("не удалось применить миграции: %v", err)
}
t.Cleanup(func() { _ = dao.CloseSqliteDB() })
// Токен подписывается ключом из таблицы config. Без него успешный вход
// отказал бы системной ошибкой, и тест перестал бы отличать работающий
// вход от неработающего.
if err := dao.UpsertConfigValue(constant.JwtSecret, "test-jwt-secret"); err != nil {
t.Fatalf("не удалось записать ключ подписи: %v", err)
}
hash, err := util.HashPassword(password)
if err != nil {
t.Fatalf("не удалось захешировать пароль: %v", err)
}
name := username
status := int64(1)
tokenVersion := int64(1)
force := int64(0)
changedAt := time.Now().UnixMilli()
id, err := dao.SaveAdminUser(entity.AdminUser{
Username: &name,
PasswordHash: &hash,
Status: &status,
TokenVersion: &tokenVersion,
ForcePasswordChange: &force,
PasswordChangedAt: &changedAt,
})
if err != nil {
t.Fatalf("не удалось создать администратора: %v", err)
}
return id
}
// postLoginRaw отправляет тело КАК ЕСТЬ через маршрут боевой конфигурации.
//
// `recovery` выбирает, стоит ли перед обработчиком gin.Recovery. Обе ветки
// нужны: без него паника роняет тест и называет причину, с ним воспроизводится
// ровно то, что видел оператор RC2, — HTTP 500 вместо конверта.
func postLoginRaw(t *testing.T, body []byte, recovery bool) (int, apiResult) {
t.Helper()
gin.SetMode(gin.TestMode)
engine := gin.New()
if recovery {
engine.Use(gin.Recovery())
}
engine.POST("/api/auth/login", Login)
request := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var result apiResult
if recorder.Body.Len() > 0 {
_ = json.Unmarshal(recorder.Body.Bytes(), &result)
}
return recorder.Code, result
}
func postLogin(t *testing.T, body any) (int, apiResult) {
t.Helper()
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("не удалось собрать тело запроса: %v", err)
}
return postLoginRaw(t, payload, false)
}
// postLoginThroughFilter воспроизводит реальную внешнюю дверь login API:
// scanner filter выполняется раньше DTO и контроллера. Именно этой связки не
// было в тестах до RC3, поэтому backend и smoke были зелёными по отдельности,
// а настоящий installer получал 403 на стандартный curl User-Agent.
func postLoginThroughFilter(t *testing.T, body any, userAgent string) (int, apiResult) {
t.Helper()
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("не удалось собрать тело запроса: %v", err)
}
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(gin.Recovery(), middleware.FilterHandler())
engine.POST("/api/auth/login", Login)
request := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(payload))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", userAgent)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var result apiResult
if recorder.Body.Len() > 0 {
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("middleware вернул не JSON: %s", recorder.Body.String())
}
}
return recorder.Code, result
}
// Полный wire-path RC3: стандартный UA curl действительно блокируется, а
// выделенный UA установщика проходит тот же middleware до authentication logic.
func TestLoginWirePathRespectsScannerFilterAndInstallerUserAgent(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
status, blocked := postLoginThroughFilter(t, map[string]any{
"username": "hy2xsadmin",
"pass": "bootstrap-password",
}, "curl/8.10.1")
if status != http.StatusForbidden || blocked.Code != http.StatusForbidden {
t.Fatalf("scanner-like curl не заблокирован: HTTP %d, ответ %+v", status, blocked)
}
status, accepted := postLoginThroughFilter(t, map[string]any{
"username": "hy2xsadmin",
"pass": "bootstrap-password",
}, "HY2XS-Installer/1.0")
if status != http.StatusOK || accepted.Code != constant.CodeSuccess {
t.Fatalf("UA установщика не дошёл до успешного входа: HTTP %d, ответ %+v", status, accepted)
}
var issued struct {
AccessToken string `json:"accessToken"`
}
if err := json.Unmarshal(accepted.Data, &issued); err != nil || issued.AccessToken == "" {
t.Fatalf("успешная wire-проба не выдала токен: %s", string(accepted.Data))
}
_, rejected := postLoginThroughFilter(t, map[string]any{
"username": "hy2xsadmin",
"pass": "wrong-password",
}, "HY2XS-Installer/1.0")
var invalidCredentials bool
for _, reason := range rejected.Errors {
invalidCredentials = invalidCredentials || reason.Code == constant.ErrCodeInvalidCredentials
}
if rejected.Code != constant.CodeSysError || !invalidCredentials {
t.Fatalf("negative wire-проба не дошла до auth logic: %+v", rejected)
}
}
// `password` не является скрытым alias: иначе orchestrator и frontend могли бы
// незаметно разойтись по двум разным HTTP-контрактам.
func TestLoginWirePathRejectsPasswordAlias(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
_, result := postLoginThroughFilter(t, map[string]any{
"username": "hy2xsadmin",
"password": "bootstrap-password",
}, "HY2XS-Installer/1.0")
var passRequired bool
for _, reason := range result.Errors {
passRequired = passRequired || (reason.Field == "pass" && reason.Code == constant.ErrCodeRequired)
}
if result.Code != constant.CodeInvalidError || !passRequired {
t.Fatalf("alias password не отклонён как отсутствие wire-поля pass: %+v", result)
}
}
// Регрессия RC2 целиком: вход bootstrap-учёткой обязан выдать токен.
func TestLoginEndpointIssuesTokenForValidCredentials(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
status, result := postLogin(t, map[string]any{
"username": "hy2xsadmin",
"pass": "bootstrap-password",
})
if status != http.StatusOK {
t.Fatalf("вход ответил HTTP %d, ожидался 200", status)
}
if result.Code != constant.CodeSuccess {
t.Fatalf("вход отклонён: %+v", result)
}
var issued struct {
TokenType string `json:"tokenType"`
AccessToken string `json:"accessToken"`
}
if err := json.Unmarshal(result.Data, &issued); err != nil {
t.Fatalf("ответ не содержит токена: %s", string(result.Data))
}
if strings.TrimSpace(issued.AccessToken) == "" {
t.Fatal("выдан пустой токен")
}
if issued.TokenType != constant.TokenType {
t.Fatalf("тип токена %q, ожидался %q", issued.TokenType, constant.TokenType)
}
}
// Повреждённые байты и непарные surrogate escape не имеют права превратиться
// в U+FFFD до проверки пароля. Сам U+FFFD остаётся обычным допустимым символом.
func TestLoginEndpointRejectsLossyJSONBeforeAuthentication(t *testing.T) {
password := "abcde" + string(rune(0xFFFD))
id := newAuthTestDB(t, "hy2xsadmin", password)
for _, body := range [][]byte{
[]byte{'{', '"', 'u', 's', 'e', 'r', 'n', 'a', 'm', 'e', '"', ':', '"', 'h', 'y', '2', 'x', 's', 'a', 'd', 'm', 'i', 'n', '"', ',', '"', 'p', 'a', 's', 's', '"', ':', '"', 0xFF, '"', '}'},
[]byte(`{"username":"hy2xsadmin","pass":"abcde\uD800"}`),
[]byte(`{"username":"hy2xsadmin","pass":"abcde\uDC00"}`),
} {
status, result := postLoginRaw(t, body, false)
if status != http.StatusOK {
t.Fatalf("повреждённое тело дало HTTP %d", status)
}
if result.Code != constant.CodeInvalidError {
t.Fatalf("повреждённое тело не отклонено как invalid: %+v", result)
}
var found bool
for _, reason := range result.Errors {
found = found || reason.Code == constant.ErrCodeBodyInvalid
}
if !found {
t.Fatalf("нет причины %q: %+v", constant.ErrCodeBodyInvalid, result)
}
}
admin, err := dao.GetAdminUser("id = ?", id)
if err != nil {
t.Fatalf("не удалось проверить состояние администратора: %v", err)
}
if admin.LastLoginAt != nil && *admin.LastLoginAt != 0 {
t.Fatalf("отклонённое JSON-тело изменило last_login_at: %d", *admin.LastLoginAt)
}
_, valid := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
if valid.Code != constant.CodeSuccess {
t.Fatalf("настоящий U+FFFD ошибочно отклонён: %+v", valid)
}
}
// Тот же запрос ЗА gin.Recovery: именно так он шёл в бою.
//
// В RC2 здесь был HTTP 500 — панику валидатора Recovery превращал в системную
// ошибку транспорта, и панель не могла сказать о ней ничего осмысленного.
func TestLoginEndpointNeverAnswersServerErrorBehindRecovery(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
bodies := [][]byte{
[]byte(`{"username":"hy2xsadmin","pass":"bootstrap-password"}`),
[]byte(`{"username":"","pass":""}`),
[]byte(`{"username":"админ12","pass":"bootstrap-password"}`),
[]byte(`{"username":"hy2xsadmin"}`),
[]byte(`{}`),
[]byte(`{"username":123,"pass":false}`),
[]byte(`не json вовсе`),
[]byte(``),
}
for _, body := range bodies {
status, result := postLoginRaw(t, body, true)
if status != http.StatusOK {
t.Errorf("тело %q дало HTTP %d: обработчик обязан отвечать конвертом, а не отказом транспорта", string(body), status)
continue
}
if result.Code != constant.CodeSuccess && len(result.Errors) == 0 && strings.TrimSpace(result.Message) == "" {
t.Errorf("тело %q дало отказ без причины: %+v", string(body), result)
}
}
}
// Неверный пароль — доменный отказ с кодом, а не системная ошибка.
func TestLoginEndpointReportsInvalidCredentials(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
_, result := postLogin(t, map[string]any{
"username": "hy2xsadmin",
"pass": "wrong-password",
})
if result.Code == constant.CodeSuccess {
t.Fatal("вход с неверным паролем выполнен")
}
if _, found := errorFor(t, result, ""); !found {
t.Fatalf("отказ без причины: %+v", result)
}
var hasCode bool
for _, reason := range result.Errors {
if reason.Code == constant.ErrCodeInvalidCredentials {
hasCode = true
}
}
if !hasCode {
t.Fatalf("отказ не помечен кодом %q: %+v", constant.ErrCodeInvalidCredentials, result)
}
}
// Несуществующий администратор неотличим от неверного пароля.
//
// Иначе форма входа превращается в способ проверять существование имён.
func TestLoginEndpointDoesNotRevealWhetherAccountExists(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
_, missing := postLogin(t, map[string]any{"username": "otheradmin", "pass": "bootstrap-password"})
_, wrong := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "wrong-password"})
if missing.Code != wrong.Code || missing.Message != wrong.Message {
t.Fatalf("ответы различимы: несуществующий %+v против неверного пароля %+v", missing, wrong)
}
}
// Логин из bootstrap-admin.secret регулярно приезжает с пробелом или переводом
// строки на конце. Раньше это давало «неверный логин или пароль».
func TestLoginEndpointTrimsSurroundingWhitespaceInUsername(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
_, result := postLogin(t, map[string]any{
"username": " hy2xsadmin\n",
"pass": "bootstrap-password",
})
if result.Code != constant.CodeSuccess {
t.Fatalf("логин с окружающими пробелами отклонён: %+v", result)
}
}
// Пароль НЕ триммится: его набор символов не ограничен, и пробел по краям —
// часть значения, а не мусор ввода.
func TestLoginEndpointDoesNotTrimPassword(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password ")
_, exact := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "bootstrap-password "})
if exact.Code != constant.CodeSuccess {
t.Fatalf("пароль с пробелом на конце отклонён: %+v", exact)
}
_, trimmed := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "bootstrap-password"})
if trimmed.Code == constant.CodeSuccess {
t.Fatal("пароль обрезан по краям: вход выполнен не тем значением, которое ввёл оператор")
}
}
// Пароль длиной в 64 символа, назначенный формой смены, обязан пускать в панель.
//
// Ровно этот путь был закрыт: форма входа ограничивала пароль 32 символами при
// серверном пределе в 64, и оператор терял доступ после штатной смены пароля.
func TestLoginEndpointAcceptsFullLengthPassword(t *testing.T) {
password := strings.Repeat("a", credential.AdminPasswordMaxLength)
newAuthTestDB(t, "hy2xsadmin", password)
_, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
if result.Code != constant.CodeSuccess {
t.Fatalf("пароль предельной длины отклонён: %+v", result)
}
}
// Пароль, упирающийся в предел bcrypt ровно, обязан работать целиком: он
// назначается формой смены пароля и вводится формой входа.
//
// 36 кириллических букв — это ровно 72 байта, то есть последнее значение,
// которое GenerateFromPassword принимает. Проверяется весь путь: хеширование
// при создании учётной записи и вход этим же значением.
func TestLoginEndpointAcceptsPasswordAtBcryptByteLimit(t *testing.T) {
password := strings.Repeat("я", 36)
if len(password) != credential.AdminPasswordMaxBytes {
t.Fatalf("подготовка: %d байт вместо %d", len(password), credential.AdminPasswordMaxBytes)
}
newAuthTestDB(t, "hy2xsadmin", password)
_, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
if result.Code != constant.CodeSuccess {
t.Fatalf("пароль в 72 байта отклонён: %+v", result)
}
}
// А на символ длиннее — отказ ПРАВИЛА, с причиной на поле, а не системная
// ошибка из bcrypt.
//
// Ровно этого не было: 37 кириллических букв (74 байта) укладывались в 64
// символа, проходили все проверки и упирались в ErrPasswordTooLong уже внутри
// хеширования. На форме смены пароля оператор видел «system error».
func TestLoginEndpointRejectsPasswordOverBcryptByteLimitWithFieldReason(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
password := strings.Repeat("я", 37)
if len(password) <= credential.AdminPasswordMaxBytes {
t.Fatalf("подготовка: %d байт, ожидалось больше %d", len(password), credential.AdminPasswordMaxBytes)
}
status, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
if status != http.StatusOK {
t.Fatalf("HTTP %d: обработчик обязан отвечать конвертом", status)
}
if result.Code != constant.CodeInvalidError {
t.Fatalf("код ответа %d, ожидался отказ проверки данных %d: %+v", result.Code, constant.CodeInvalidError, result)
}
var hasReason bool
for _, reason := range result.Errors {
if reason.Field == "pass" && reason.Code == constant.ErrCodeAdminPassword {
hasReason = true
}
}
if !hasReason {
t.Fatalf("отказ не назван причиной %q на поле pass: %+v", constant.ErrCodeAdminPassword, result)
}
}
// Отметка о входе обязана писаться: колонка есть в схеме, а писать её было
// некому — UpdateAdminLastLoginAt не вызывался ниоткуда.
func TestLoginEndpointRecordsLastLoginAt(t *testing.T) {
id := newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
before, err := dao.GetAdminUser("id = ?", id)
if err != nil {
t.Fatalf("не удалось прочитать администратора: %v", err)
}
if before.LastLoginAt != nil && *before.LastLoginAt != 0 {
t.Fatalf("подготовка: отметка о входе уже проставлена (%d)", *before.LastLoginAt)
}
if _, result := postLogin(t, map[string]any{
"username": "hy2xsadmin",
"pass": "bootstrap-password",
}); result.Code != constant.CodeSuccess {
t.Fatalf("вход отклонён: %+v", result)
}
after, err := dao.GetAdminUser("id = ?", id)
if err != nil {
t.Fatalf("не удалось перечитать администратора: %v", err)
}
if after.LastLoginAt == nil || *after.LastLoginAt == 0 {
t.Fatal("время входа не записано")
}
}
// Неудачная попытка отметку о входе НЕ ставит.
func TestFailedLoginDoesNotRecordLastLoginAt(t *testing.T) {
id := newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
if _, result := postLogin(t, map[string]any{
"username": "hy2xsadmin",
"pass": "wrong-password",
}); result.Code == constant.CodeSuccess {
t.Fatal("вход с неверным паролем выполнен")
}
after, err := dao.GetAdminUser("id = ?", id)
if err != nil {
t.Fatalf("не удалось прочитать администратора: %v", err)
}
if after.LastLoginAt != nil && *after.LastLoginAt != 0 {
t.Fatalf("неудачная попытка проставила время входа: %d", *after.LastLoginAt)
}
}
+166 -252
View File
@@ -1,159 +1,176 @@
package controller
import (
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
"hy2xs-admin/util"
"io"
"path/filepath"
"strconv"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
)
func isOrchestratorManagedConfigKey(key string) bool {
switch key {
case constant.HUIWebPort,
constant.HUIWebContext,
constant.HUICrtPath,
constant.HUIKeyPath,
constant.Hysteria2Enable,
constant.Hysteria2Config,
constant.Hysteria2TrafficStatsSecret:
return true
default:
return false
}
// Доступ операторского API к таблице `config` — строго по allowlist.
//
// Что было. Проверка работала denylist'ом из трёх orchestrator-ключей, а
// GetConfig/ListConfig принимали произвольную строку. В той же таблице лежат
// JWT_SECRET, PEER_SECRET_KEY, PEER_SECRET_ENCRYPTION_KEY и
// HYSTERIA2_TRAFFIC_STATS_SECRET, поэтому авторизованный запрос
// `?key=PEER_SECRET_ENCRYPTION_KEY` отдавал master-key шифрования секретов
// пиров, а updateConfigs позволял подменить JWT_SECRET и оба peer-ключа.
//
// То есть опасность, ради которой удаляли generic export/import таблицы
// `config`, никуда не делась — она осталась в точечном API.
//
// Список ключей ведётся в model/constant/config.go: там же, где сами ключи, а
// не в слое HTTP.
func denyUnknownConfigKey(key string, allowed []string, operation string, c *gin.Context) {
sort.Strings(allowed)
vo.Fail(
fmt.Sprintf(
"config key %q is not available for %s via API (allowed: %s)",
key, operation, strings.Join(allowed, ", "),
),
c,
)
}
// UpdateConfigs применяет партию настроек по принципу «всё или ничего».
//
// Три прохода, и каждый отвечает за своё:
//
// 1. проверка партии целиком — права на ключ, дубликаты, значения;
// 2. одна транзакция базы;
// 3. применение к рантайму.
//
// Раньше проходов не было вовсе: цикл проверял очередной элемент и тут же его
// записывал. Партия «разрешённый ключ + запрещённый» применяла первый и
// возвращала ошибку на втором — оператор получал отказ на запрос, который
// систему уже изменил.
//
// Второе изменение того же места: смена расписания больше не роняет
// HTTP-сервер. Раньше здесь стоял `go service.StopServer()`, а точка входа
// крутила runServer в цикле, из-за чего на процессе накапливались планировщики
// (см. service/cron_scheduler.go).
func UpdateConfigs(c *gin.Context) {
configsUpdateDto, err := validateField(c, dto.ConfigsUpdateDto{})
if err != nil {
return
}
port, crtPath, keyPath, err := service.GetPortAndCert()
if err != nil {
vo.Fail(err.Error(), c)
return
}
needRestart := false
updates := make([]service.ConfigUpdate, 0, len(configsUpdateDto.ConfigUpdateDtos))
seen := make(map[string]struct{}, len(configsUpdateDto.ConfigUpdateDtos))
for _, item := range configsUpdateDto.ConfigUpdateDtos {
key := *item.Key
value := *item.Value
// Отдельное сообщение для ключей, которыми владеет оркестратор: их
// отказ — это не «нет такого ключа», а указание на владельца.
if isOrchestratorManagedConfigKey(key) {
vo.Fail(fmt.Sprintf("%s managed by orchestrator: use hy2xs-orchestrator reconfigure", key), c)
return
}
if key == constant.HUIWebPort && strconv.FormatInt(port, 10) != value {
port, err := strconv.Atoi(value)
if err != nil {
vo.Fail(fmt.Sprintf("port: %s is invalid", value), c)
return
}
if !util.IsPortAvailable(uint(port), "tcp") {
vo.Fail(fmt.Sprintf("port: %s is used", value), c)
return
}
needRestart = true
}
if key == constant.HUICrtPath && crtPath != value {
if value != "" && !util.Exists(value) {
vo.Fail(fmt.Sprintf("crt path: %s is not exist", value), c)
return
}
needRestart = true
}
if key == constant.HUIKeyPath && keyPath != value {
if value != "" && !util.Exists(value) {
vo.Fail(fmt.Sprintf("key path: %s is not exist", value), c)
return
}
needRestart = true
if !constant.IsPublicWritableConfigKey(key) {
denyUnknownConfigKey(key, constant.PublicWritableConfigKeys(), "write", c)
return
}
if key == constant.HUIWebContext {
huiWebContext, err := service.GetConfig(constant.HUIWebContext)
if err != nil {
vo.Fail(err.Error(), c)
return
}
if *huiWebContext.Value != value {
needRestart = true
}
// Один ключ дважды в одной партии — неоднозначный запрос: какое из
// двух значений считать намерением оператора, определить нельзя.
if _, duplicate := seen[key]; duplicate {
vo.Fail(fmt.Sprintf("config key %q appears more than once in the batch", key), c)
return
}
seen[key] = struct{}{}
if key == constant.ResetTrafficCron {
resetTrafficCron, err := service.GetConfig(constant.ResetTrafficCron)
if err != nil {
vo.Fail(err.Error(), c)
return
}
if *resetTrafficCron.Value != value {
needRestart = true
}
}
if err = service.UpdateConfig(key, value); err != nil {
if err = service.ValidateConfigValue(key, value); err != nil {
vo.Fail(err.Error(), c)
return
}
updates = append(updates, service.ConfigUpdate{Key: key, Value: value})
}
if needRestart {
go func() {
_ = service.StopServer()
}()
if err = service.UpdateConfigs(updates); err != nil {
vo.Fail(err.Error(), c)
return
}
if err = applyRuntimeConfigUpdates(updates); err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(nil, c)
}
func GetConfig(c *gin.Context) {
configDto, err := validateField(c, dto.ConfigDto{})
if err != nil {
return
}
config, err := service.GetConfig(*configDto.Key)
if err != nil {
vo.Fail(err.Error(), c)
return
}
configVo := vo.ConfigVo{
Key: *config.Key,
Value: *config.Value,
}
running := service.Hysteria2IsRunning()
if *config.Key == constant.Hysteria2Enable {
if running {
configVo.Value = "1"
} else {
configVo.Value = "0"
// applyRuntimeConfigUpdates доносит уже сохранённые настройки до рантайма.
//
// Отказ здесь недостижим по построению: значения проверены тем же парсером,
// которым планировщик их разбирает, а сам планировщик поднимается в runServer
// до старта HTTP-сервера — то есть к моменту обработки запроса он всегда
// запущен. Проверка оставлена именно поэтому: если этот инвариант когда-нибудь
// сломают, отказ должен быть громким, а не молча пропавшей джобой.
func applyRuntimeConfigUpdates(updates []service.ConfigUpdate) error {
for _, item := range updates {
if item.Key != constant.ResetTrafficCron {
continue
}
if err := service.RescheduleResetTraffic(item.Value); err != nil {
return err
}
}
vo.Success(configVo, c)
return nil
}
// isOrchestratorManagedConfigKey — ключи, которыми владеет install-оркестратор.
//
// Формально они и так не входят в allowlist, но отказ по ним обязан объяснять
// ПОЧЕМУ: «этим значением владеет оркестратор» — это другой ответ, чем «такого
// ключа в API нет», и он ведёт оператора к `hy2xs-orchestrator reconfigure`.
//
// HYSTERIA2_ENABLE и HYSTERIA2_CONFIG отсюда убраны вместе с самими ключами:
// первым никто не управлял, второй был вторым источником истины рядом с
// /etc/hysteria/config.yaml. Serverный конфиг по-прежнему принадлежит
// оркестратору — просто теперь он живёт только в файле, а не ещё и в SQLite.
func isOrchestratorManagedConfigKey(key string) bool {
switch key {
case constant.Hysteria2TrafficStatsSecret:
return true
default:
return false
}
}
// Маршрута GET /config/getConfig здесь больше нет.
//
// Он принимал произвольный ключ и был вторым, менее заметным входом в ту же
// таблицу секретов, что и удалённый generic export. При этом ни одного
// потребителя у него не было: панель читает настройки только через listConfig.
// Маршрут не оставлен с фильтром, а удалён — точка входа, которой никто не
// пользуется, не должна существовать.
func ListConfig(c *gin.Context) {
configsDto, err := validateField(c, dto.ConfigsDto{})
if err != nil {
return
}
// Проверка идёт до обращения к базе: отказ не должен зависеть от того,
// существует ли строка с таким ключом.
for _, key := range configsDto.Keys {
if !constant.IsPublicReadableConfigKey(key) {
denyUnknownConfigKey(key, constant.PublicReadableConfigKeys(), "read", c)
return
}
}
configs, err := service.ListConfig(configsDto.Keys)
if err != nil {
vo.Fail(err.Error(), c)
@@ -170,168 +187,65 @@ func ListConfig(c *gin.Context) {
vo.Success(configVos, c)
}
// GetHysteria2Config отдаёт панели конфигурацию в терминах production-профиля.
//
// Что было: `vo.Success(service.GetHysteria2Config(), c)` — внутренняя модель
// серверного конфига сериализовалась в браузер целиком. У этого было два
// следствия.
//
// Первое — секреты. `auth` и `trafficStats.secret` закрыты `json:"-"`, но
// пароль обфускации, токены ACME DNS (`acme.dns.config`), учётные данные
// outbound-прокси и masquerade уезжали в открытом виде. Скачиваемая выгрузка
// того же конфига их вырезает, и читающий экран не имеет права быть щедрее.
// Привилегий это не повышало — маршрут под admin JWT, — но и нужды в этих
// значениях у read-only экрана нет.
//
// Второе — смысл ответа. Модель отдавала «все известные HY2XS поля», а панель
// накладывала их на полный объект дефолтов, поэтому экран показывал не файл, а
// файл, дополненный выдумкой: отсутствующий `trafficStats` превращался в
// `:9999`. Ровно тот дрейф, который экран обязан показывать, он и скрывал.
//
// Теперь ответ описывает профиль явно, отличает «не задано» от значения и
// отдельно перечисляет секции вне профиля. Полный документ доступен
// санитизированной выгрузкой ниже.
func GetHysteria2Config(c *gin.Context) {
config, err := service.GetHysteria2Config()
profile, err := service.BuildHysteria2Profile()
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(config, c)
}
func UpdateHysteria2Config(c *gin.Context) {
vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c)
vo.Success(profile, c)
}
// ExportHysteria2Config отдаёт оператору фактический серверный конфиг.
//
// Экспорт работает от исходного YAML, а не от типизированной модели: поля,
// о которых HY2XS ещё не знает, обязаны пережить выгрузку. Секреты при этом
// вырезаются — файл покидает сервер.
func ExportHysteria2Config(c *gin.Context) {
hysteria2ServerConfig, err := service.GetHysteria2Config()
sanitized, err := service.ExportHysteria2ConfigYaml()
if err != nil {
vo.Fail(err.Error(), c)
return
}
// Значения по умолчанию
config, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.Hysteria2TrafficStatsSecret})
if err != nil {
vo.Fail(err.Error(), c)
return
}
var hUIWebPort string
var trafficStatsSecret string
for _, item := range config {
if *item.Key == constant.HUIWebPort {
hUIWebPort = *item.Value
} else if *item.Key == constant.Hysteria2TrafficStatsSecret {
trafficStatsSecret = *item.Value
}
}
if hUIWebPort == "" || trafficStatsSecret == "" {
logrus.Errorf("hUIWebPort or trafficStatsSecret is nil")
vo.Fail(constant.SysError, c)
return
}
authHttpUrl, err := service.GetAuthHttpUrl()
if err != nil {
vo.Fail(err.Error(), c)
return
}
authType := "http"
authHttpInsecure := true
var auth bo.ServerConfigAuth
auth.Type = &authType
var http bo.ServerConfigAuthHTTP
http.URL = &authHttpUrl
http.Insecure = &authHttpInsecure
auth.HTTP = &http
hysteria2ServerConfig.Auth = &auth
if hysteria2ServerConfig.TrafficStats == nil {
hysteria2ServerConfig.TrafficStats = &bo.ServerConfigTrafficStats{}
}
hysteria2ServerConfig.TrafficStats.Secret = &trafficStatsSecret
fileName := fmt.Sprintf("Hysteria2Config-%s.yaml", time.Now().Format("20060102150405"))
filePath := filepath.Join(constant.ExportPathDir, fileName)
if err = util.ExportFile(filePath, hysteria2ServerConfig, 1); err != nil {
vo.Fail(err.Error(), c)
return
}
if !util.Exists(filePath) {
vo.Fail("file not exist", c)
return
}
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
c.File(filePath)
c.Data(200, "application/octet-stream", sanitized)
}
func ImportHysteria2Config(c *gin.Context) {
vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c)
}
func ExportConfig(c *gin.Context) {
configs, err := service.ListConfigNotIn([]string{constant.Hysteria2Config})
if err != nil {
vo.Fail(err.Error(), c)
return
}
fileName := fmt.Sprintf("SystemConfig-%s.json", time.Now().Format("20060102150405"))
filePath := filepath.Join(constant.ExportPathDir, fileName)
if err = util.ExportFile(filePath, configs, 0); err != nil {
vo.Fail(err.Error(), c)
return
}
if !util.Exists(filePath) {
vo.Fail("file not exist", c)
return
}
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
c.File(filePath)
}
func ImportConfig(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
vo.Fail(constant.SysError, c)
return
}
if header.Size > 1024*1024*2 {
vo.Fail("the file is too big", c)
return
}
if !strings.HasSuffix(header.Filename, ".json") {
vo.Fail(constant.InvalidError, c)
return
}
content, err := io.ReadAll(file)
if err != nil {
vo.Fail("json file read err", c)
return
}
var configs []entity.Config
if err = json.Unmarshal(content, &configs); err != nil {
vo.Fail("content Unmarshal err", c)
return
}
for _, cfg := range configs {
if cfg.Key != nil && isOrchestratorManagedConfigKey(*cfg.Key) {
vo.Fail(fmt.Sprintf("%s managed by orchestrator: use hy2xs-orchestrator reconfigure", *cfg.Key), c)
return
}
}
if err = service.UpsertConfig(configs); err != nil {
vo.Fail(err.Error(), c)
return
}
go func() {
_ = service.StopServer()
}()
vo.Success(nil, c)
}
func Hysteria2AcmePath(c *gin.Context) {
hysteria2AcmePathVo, err := service.Hysteria2AcmePath()
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(hysteria2AcmePathVo, c)
}
func RestartServer(c *gin.Context) {
vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c)
}
func UploadCertFile(c *gin.Context) {
vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c)
}
// Generic-выгрузки и загрузки таблицы `config` здесь нет намеренно.
//
// Она отдавала таблицу целиком, исключая только сырой Hysteria YAML, а в той
// же таблице лежат JWT_SECRET, PEER_SECRET_KEY, PEER_SECRET_ENCRYPTION_KEY и
// HYSTERIA2_TRAFFIC_STATS_SECRET. Кнопка «Export» в панели выгружала их в
// открытом виде, а зеркальный импорт позволял их подменить — включая ключ
// шифрования, без которого перестают расшифровываться секреты уже
// существующих пиров.
//
// Осмысленного production-сценария у этой пары не было: конфигурацией сервера
// владеет install-оркестратор, перенос пиров делают ImportPeer/ExportPeer, а
// серверный конфиг Hysteria выгружается отдельным санитизирующим маршрутом.
+304
View File
@@ -0,0 +1,304 @@
package controller
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
)
// Секреты, которые лежат в таблице `config` рядом с пользовательскими
// настройками. Каждый из них раньше можно было прочитать через
// GET /api/config/getConfig?key=… и подменить через updateConfigs.
var secretConfigKeys = []string{
constant.JwtSecret,
constant.PeerSecretKey,
constant.PeerSecretEncryptionKey,
constant.Hysteria2TrafficStatsSecret,
}
type apiResult struct {
Code int `json:"code"`
Type string `json:"type"`
Message string `json:"message"`
Errors []vo.FieldError `json:"errors"`
Data json.RawMessage `json:"data"`
}
func postJSON(t *testing.T, handler gin.HandlerFunc, path string, body any) apiResult {
t.Helper()
gin.SetMode(gin.TestMode)
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("не удалось собрать тело запроса: %v", err)
}
engine := gin.New()
engine.POST(path, handler)
request := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var result apiResult
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
}
return result
}
// Отказ обязан наступать ДО обращения к базе: тест выполняется без
// инициализированного SQLite, и любой поход в dao здесь уронил бы обработчик.
// Это и есть доказательство того, что проверка стоит на входе.
func TestListConfigRefusesInternalKeys(t *testing.T) {
for _, key := range secretConfigKeys {
result := postJSON(t, ListConfig, "/config/listConfig", map[string]any{"keys": []string{key}})
if result.Type != "no" {
t.Errorf("чтение %s не отклонено: %+v", key, result)
}
if !strings.Contains(result.Message, "not available for read") {
t.Errorf("невнятный отказ для %s: %s", key, result.Message)
}
}
}
// Секретный ключ, спрятанный среди разрешённых, обязан отклонить весь запрос.
func TestListConfigRefusesMixedBatch(t *testing.T) {
result := postJSON(t, ListConfig, "/config/listConfig", map[string]any{
"keys": []string{constant.ResetTrafficCron, constant.PeerSecretEncryptionKey},
})
if result.Type != "no" {
t.Fatalf("смешанная партия не отклонена: %+v", result)
}
if len(result.Data) != 0 && string(result.Data) != "null" {
t.Fatalf("отказ вернул данные: %s", string(result.Data))
}
}
func TestListConfigRefusesUnknownKey(t *testing.T) {
result := postJSON(t, ListConfig, "/config/listConfig", map[string]any{
"keys": []string{"TOTALLY_UNKNOWN_KEY"},
})
if result.Type != "no" {
t.Fatalf("неизвестный ключ не отклонён: %+v", result)
}
}
func TestUpdateConfigsRefusesInternalKeys(t *testing.T) {
for _, key := range secretConfigKeys {
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
"configUpdateDtos": []map[string]string{{"key": key, "value": "attacker-controlled"}},
})
if result.Type != "no" {
t.Errorf("запись %s не отклонена: %+v", key, result)
}
}
}
// Ключи оркестратора отклоняются с указанием владельца, а не общим «нет
// такого ключа»: оператор должен понять, что менять их надо через reconfigure.
func TestUpdateConfigsPointsAtOrchestratorForItsOwnKeys(t *testing.T) {
for _, key := range []string{
constant.Hysteria2TrafficStatsSecret,
} {
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
"configUpdateDtos": []map[string]string{{"key": key, "value": "x"}},
})
if result.Type != "no" {
t.Errorf("запись %s не отклонена: %+v", key, result)
}
if !strings.Contains(result.Message, "hy2xs-orchestrator reconfigure") {
t.Errorf("отказ по %s не называет владельца: %s", key, result.Message)
}
}
}
// Удалённые ключи обязаны отклоняться как неизвестные, а не приниматься.
func TestUpdateConfigsRefusesRemovedKeys(t *testing.T) {
for _, key := range []string{
"HYSTERIA2_ENABLE",
"HYSTERIA2_CONFIG",
"HYSTERIA2_TRAFFIC_TIME",
"HYSTERIA2_CONFIG_REMARK",
} {
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
"configUpdateDtos": []map[string]string{{"key": key, "value": "x"}},
})
if result.Type != "no" {
t.Errorf("удалённый ключ %s принят на запись: %+v", key, result)
}
}
}
func TestUpdateConfigsRefusesUnknownKey(t *testing.T) {
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
"configUpdateDtos": []map[string]string{{"key": "TOTALLY_UNKNOWN_KEY", "value": "x"}},
})
if result.Type != "no" {
t.Fatalf("неизвестный ключ не отклонён: %+v", result)
}
if !strings.Contains(result.Message, "not available for write") {
t.Fatalf("невнятный отказ: %s", result.Message)
}
}
// newConfigTestDB поднимает настоящую SQLite: проверка «партия не применилась
// частично» без базы бессмысленна — именно состояние базы и есть предмет
// утверждения. Схема поднимается отдельно от InitSql: bootstrap-администратор
// и bootstrap-пир здесь не нужны.
func newConfigTestDB(t *testing.T) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
if err := dao.InitSqliteDBAt(dbPath); err != nil {
t.Fatalf("не удалось открыть тестовую базу: %v", err)
}
if err := dao.RunMigrations(); err != nil {
t.Fatalf("не удалось применить миграции: %v", err)
}
t.Cleanup(func() {
_ = dao.CloseSqliteDB()
})
}
func storedConfigValue(t *testing.T, key string) string {
t.Helper()
config, err := dao.GetConfig("key = ?", key)
if err != nil {
t.Fatalf("не удалось прочитать %s: %v", key, err)
}
if config.Value == nil {
return ""
}
return *config.Value
}
// Отказ на любой записи партии обязан отменить партию ЦЕЛИКОМ.
//
// Разрешённый ключ стоит ПЕРВЫМ намеренно. Предыдущая версия этого теста
// ставила запрещённый ключ первым, поэтому проходила и на реализации, которая
// проверяла и записывала настройки в одном цикле: до второго элемента дело не
// доходило. Поймать частичное применение такой порядок неспособен по
// построению, и состояние базы тест тоже не смотрел.
func TestUpdateConfigsRefusesWholeBatchWhenLaterKeyIsForbidden(t *testing.T) {
newConfigTestDB(t)
before := storedConfigValue(t, constant.ResetTrafficCron)
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
"configUpdateDtos": []map[string]string{
{"key": constant.ResetTrafficCron, "value": "@weekly"},
{"key": constant.JwtSecret, "value": "attacker-controlled"},
},
})
if result.Type != "no" {
t.Fatalf("партия с секретом не отклонена: %+v", result)
}
if after := storedConfigValue(t, constant.ResetTrafficCron); after != before {
t.Fatalf("отклонённая партия изменила %s: было %q, стало %q", constant.ResetTrafficCron, before, after)
}
if secret := storedConfigValue(t, constant.JwtSecret); secret == "attacker-controlled" {
t.Fatal("подписывающий ключ перезаписан отклонённой партией")
}
}
// Успешная партия обязана и сохраниться, и доехать до рантайма — БЕЗ
// перезапуска HTTP-сервера.
//
// Раньше новое расписание применялось единственным способом: обработчик
// вызывал StopServer(), точка входа поднимала runServer заново, и новый
// планировщик читал настройку из базы. Тест закрепляет обе половины замены:
// значение в базе изменилось, джоба перепланирована, число записей
// планировщика не выросло.
func TestUpdateConfigsAppliesScheduleWithoutRestart(t *testing.T) {
newConfigTestDB(t)
if err := service.InitCron(); err != nil {
t.Fatalf("InitCron: %v", err)
}
t.Cleanup(service.StopCron)
before := service.CronEntryCount()
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
"configUpdateDtos": []map[string]string{
{"key": constant.ResetTrafficCron, "value": "@weekly"},
},
})
if result.Type != "ok" {
t.Fatalf("корректная партия отклонена: %+v", result)
}
if stored := storedConfigValue(t, constant.ResetTrafficCron); stored != "@weekly" {
t.Fatalf("значение не сохранено: %q", stored)
}
if !service.ResetTrafficScheduled() {
t.Fatal("новое расписание не применилось к планировщику")
}
if after := service.CronEntryCount(); after != before {
t.Fatalf("применение расписания добавило джобы: было %d, стало %d", before, after)
}
}
// Невалидное значение в партии обязано отменить её так же, как запрещённый
// ключ: разница между «нельзя писать сюда» и «нельзя писать это» касается
// только текста ошибки, но не последствий для базы.
func TestUpdateConfigsRefusesWholeBatchOnInvalidValue(t *testing.T) {
newConfigTestDB(t)
before := storedConfigValue(t, constant.ResetTrafficCron)
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
"configUpdateDtos": []map[string]string{
{"key": constant.ResetTrafficCron, "value": "какая-нибудь ерунда"},
},
})
if result.Type != "no" {
t.Fatalf("невалидное cron-выражение принято: %+v", result)
}
if after := storedConfigValue(t, constant.ResetTrafficCron); after != before {
t.Fatalf("невалидное значение попало в базу: было %q, стало %q", before, after)
}
}
// Один ключ дважды в партии — неоднозначный запрос: какое из двух значений
// считать намерением оператора, определить нельзя.
func TestUpdateConfigsRefusesDuplicateKeys(t *testing.T) {
newConfigTestDB(t)
before := storedConfigValue(t, constant.ResetTrafficCron)
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
"configUpdateDtos": []map[string]string{
{"key": constant.ResetTrafficCron, "value": "@weekly"},
{"key": constant.ResetTrafficCron, "value": "@monthly"},
},
})
if result.Type != "no" {
t.Fatalf("партия с дубликатом ключа принята: %+v", result)
}
if after := storedConfigValue(t, constant.ResetTrafficCron); after != before {
t.Fatalf("партия с дубликатом изменила базу: было %q, стало %q", before, after)
}
}
+24
View File
@@ -0,0 +1,24 @@
package controller
import (
"errors"
"github.com/gin-gonic/gin"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
)
// failService переводит отказ сервисного слоя в ответ панели.
//
// Доменный отказ несёт код и, если он относится к полю формы, имя этого поля
// (см. service.PeerError). Всё остальное остаётся отказом уровня операции с
// человеческим сообщением — панель покажет его как есть, но разбирать текст ей
// при этом не придётся ни в одном известном случае.
func failService(err error, c *gin.Context) {
var peerErr *service.PeerError
if errors.As(err, &peerErr) {
vo.FailField(peerErr.Code, peerErr.Field, peerErr.Message, c)
return
}
vo.Fail(err.Error(), c)
}
+1 -9
View File
@@ -11,7 +11,7 @@ import (
func Hysteria2Auth(c *gin.Context) {
var req dto.Hysteria2AuthDto
if err := c.ShouldBindJSON(&req); err != nil {
if err := strictBindJSON(c, &req); err != nil {
vo.Hysteria2AuthBadRequest(c)
return
}
@@ -32,11 +32,3 @@ func Hysteria2Auth(c *gin.Context) {
}
vo.Hysteria2AuthSuccess(username, c)
}
func Hysteria2ChangeVersion(c *gin.Context) {
vo.Fail("Смена версии Hysteria2 отключена: runtime управляется install-оркестратором HY2XS", c)
}
func ListRelease(c *gin.Context) {
vo.Success([]string{}, c)
}
+113
View File
@@ -0,0 +1,113 @@
package controller
import (
"bytes"
"errors"
"fmt"
"io"
"unicode/utf8"
"github.com/gin-gonic/gin"
)
const jsonBodyMaxBytes = 1024 * 1024
var errJSONBodyTooLarge = errors.New("тело JSON превышает 1 МиБ")
// strictBindJSON не позволяет стандартному encoding/json молча заменить
// повреждённый UTF-8 или непарный UTF-16 surrogate escape на U+FFFD.
//
// Это критично для секретов: U+FFFD сам по себе разрешён, поэтому без этой
// границы другое байтовое значение могло аутентифицироваться как пароль,
// который фактически хранится в базе. После проверки тело восстанавливается и
// передаётся штатному Gin binder, чтобы сохранить действующий API-контракт.
func strictBindJSON(c *gin.Context, dst any) error {
body := c.Request.Body
if body == nil {
return errors.New("пустое тело JSON")
}
raw, err := io.ReadAll(io.LimitReader(body, jsonBodyMaxBytes+1))
_ = body.Close()
c.Request.Body = io.NopCloser(bytes.NewReader(raw))
if err != nil {
return fmt.Errorf("не удалось прочитать тело JSON: %w", err)
}
if len(raw) > jsonBodyMaxBytes {
return errJSONBodyTooLarge
}
if err := validateStrictJSONEncoding(raw); err != nil {
return err
}
return c.ShouldBindJSON(dst)
}
// validateStrictJSONEncoding проверяет только границу кодировки. Синтаксис,
// типы и структуру затем проверяет encoding/json; здесь отдельно закрывается
// его документированное replacement-поведение.
func validateStrictJSONEncoding(raw []byte) error {
if !utf8.Valid(raw) {
return errors.New("тело JSON содержит некорректный UTF-8")
}
inString := false
for i := 0; i < len(raw); i++ {
switch raw[i] {
case '"':
inString = !inString
case '\\':
if !inString {
continue
}
i++
if i >= len(raw) {
return errors.New("незавершённая escape-последовательность JSON")
}
if raw[i] != 'u' {
continue
}
code, ok := parseJSONHex4(raw, i+1)
if !ok {
return errors.New("некорректная Unicode escape-последовательность JSON")
}
i += 4
switch {
case code >= 0xD800 && code <= 0xDBFF:
// Старший суррогат допустим только как первая половина пары и
// только в соседней escape-последовательности \uXXXX.
if i+6 >= len(raw) || raw[i+1] != '\\' || raw[i+2] != 'u' {
return errors.New("непарный старший UTF-16 суррогат в JSON")
}
low, lowOK := parseJSONHex4(raw, i+3)
if !lowOK || low < 0xDC00 || low > 0xDFFF {
return errors.New("непарный старший UTF-16 суррогат в JSON")
}
i += 6
case code >= 0xDC00 && code <= 0xDFFF:
return errors.New("непарный младший UTF-16 суррогат в JSON")
}
}
}
return nil
}
func parseJSONHex4(raw []byte, start int) (uint16, bool) {
if start < 0 || start+4 > len(raw) {
return 0, false
}
var value uint16
for _, character := range raw[start : start+4] {
value <<= 4
switch {
case character >= '0' && character <= '9':
value += uint16(character - '0')
case character >= 'a' && character <= 'f':
value += uint16(character-'a') + 10
case character >= 'A' && character <= 'F':
value += uint16(character-'A') + 10
default:
return 0, false
}
}
return value, true
}
+31
View File
@@ -0,0 +1,31 @@
package controller
import "testing"
func TestValidateStrictJSONEncoding(t *testing.T) {
tests := []struct {
name string
body []byte
wantErr bool
}{
{"обычный UTF-8", []byte(`{"password":"пароль"}`), false},
{"настоящий U+FFFD", []byte(`{"password":"abcde"}`), false},
{"валидная surrogate pair", []byte(`{"value":"\uD83D\uDE00"}`), false},
{"экранированный обратный слеш", []byte(`{"value":"\\uD800"}`), false},
{"байт FF", []byte{'{', '"', 'x', '"', ':', '"', 0xFF, '"', '}'}, true},
{"оборванный UTF-8", []byte{'{', '"', 'x', '"', ':', '"', 0xC3, '"', '}'}, true},
{"UTF-8 суррогата", []byte{'{', '"', 'x', '"', ':', '"', 0xED, 0xA0, 0x80, '"', '}'}, true},
{"непарный старший", []byte(`{"value":"\uD800"}`), true},
{"старший перед обычным", []byte(`{"value":"\uD800\u0041"}`), true},
{"непарный младший", []byte(`{"value":"\uDC00"}`), true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
gotErr := validateStrictJSONEncoding(test.body) != nil
if gotErr != test.wantErr {
t.Fatalf("ошибка=%v, ожидалась=%v", gotErr, test.wantErr)
}
})
}
}
+64 -16
View File
@@ -17,10 +17,20 @@ func LogSystem(c *gin.Context) {
if err != nil {
return
}
exists := util.Exists(constant.SystemLogPath)
logSystemVos := make([]vo.LogSystemVo, 0)
if !exists {
vo.Success(logSystemVos, c)
// Форма ответа одна для ВСЕХ веток: {records, total}.
//
// Раньше ветка «файла ещё нет» отвечала голым массивом:
//
// vo.Success(logSystemVos, c)
//
// Панель читает `data.records`, поэтому получала undefined и передавала
// его в `:data` таблицы. То есть на свежепоставленном хосте — до первой
// записи в журнал — страница системных логов была сломана целиком, и
// именно этот сценарий воспроизводится на каждой приёмке чистой установки.
if !util.Exists(constant.SystemLogPath) {
vo.Success(vo.LogSystemPage[vo.LogSystemVo]{LogSystemVos: logSystemVos, Total: 0}, c)
return
}
numLine := 0
@@ -38,11 +48,36 @@ func LogSystem(c *gin.Context) {
continue
}
logSystemVo := vo.LogSystemVo{}
err := json.Unmarshal([]byte(line), &logSystemVo)
if err != nil {
vo.Fail("Unable to unmarshal log data", c)
// Неразбираемая строка ПРОПУСКАЕТСЯ, а не отказывает запросу.
//
// Было так:
//
// if err != nil {
// vo.Fail("Unable to unmarshal log data", c)
// continue
// }
//
// — ответ записывался в поток и цикл шёл дальше, а в конце
// безусловно выполнялся vo.Success. Клиент получал два JSON-документа
// подряд, то есть невалидный ответ, и gin ругался на повторную запись
// заголовков. Достаточно было ОДНОЙ битой строки, чтобы страница
// перестала открываться целиком.
//
// Строка при этом бьётся штатно: lumberjack ротирует файл, и обрыв
// последней записи на границе ротации — обычное событие, а не признак
// повреждения журнала. Отказывать из-за него всей выдаче нечем
// оправдать: остальные записи прочитаны и полезны.
if err := json.Unmarshal([]byte(line), &logSystemVo); err != nil {
continue
}
// Собственный журнал санитизируется так же, как чужой.
//
// Раньше через SanitizeLogText проходил только журнал Hysteria: он
// «чужой», а свой мы якобы контролируем. Контроль этот держался на
// внимательности — ровно до logrus.Warnf с паролем администратора в
// dao/sqlite.go. Санитайз здесь стоит не вместо аккуратности в местах
// записи, а на случай следующего такого места.
logSystemVo.Msg = service.SanitizeLogText(logSystemVo.Msg)
logSystemVos = append(logSystemVos, logSystemVo)
}
vo.Success(vo.LogSystemPage[vo.LogSystemVo]{
@@ -80,10 +115,26 @@ func ExportLog(c *gin.Context) {
}
var fileName string
var filePath string
if *logExportDto.Option == 0 {
fileName = fmt.Sprintf("hy2xs-admin-%s.log", time.Now().Format("20060102150405"))
filePath = constant.SystemLogPath
// Журнал админки отдаётся санитизированным, а не файлом «как есть».
//
// Раньше здесь стоял c.File(constant.SystemLogPath): оператор скачивал
// сырой /var/log/hy2xs/hy2xs-admin.log. Журнал Hysteria при этом
// проходил через SanitizeLogText — то есть чужому журналу продукт не
// доверял, а своему доверял. Асимметрия ничем не обоснована: файл в
// обоих случаях покидает сервер и пересылается в переписке.
output, exportErr := service.ExportAdminLog()
if exportErr != nil {
vo.Fail("log file not exist", c)
return
}
c.Header("Content-Type", "text/plain; charset=utf-8")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
c.Data(200, "text/plain; charset=utf-8", []byte(output))
return
} else if *logExportDto.Option == 1 {
fileName = fmt.Sprintf("hysteria2-%s.log", time.Now().Format("20060102150405"))
output, exportErr := service.ExportHysteriaJournalLogs(5000)
@@ -98,12 +149,9 @@ func ExportLog(c *gin.Context) {
return
}
if !util.Exists(filePath) {
vo.Fail("log file not exist", c)
return
}
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
c.File(filePath)
// Обе ветки выше завершаются return. Сюда попадает только неизвестное
// значение option, и это отказ, а не отдача пустого файла: раньше здесь
// оставался путь с пустым filePath, который сводился к тому же сообщению
// окольной дорогой.
vo.Fail("unsupported log export option", c)
}
+134
View File
@@ -0,0 +1,134 @@
package controller
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"hy2xs-admin/model/constant"
)
// Форма ответа страницы системных логов.
//
// Панель читает `data.records` и передаёт результат прямо в `:data` таблицы.
// Поэтому ответ обязан нести records/total на КАЖДОЙ ветке — иначе таблица
// получает undefined и страница ломается целиком.
type logPageEnvelope struct {
Code int `json:"code"`
Type string `json:"type"`
Message string `json:"message"`
Data *struct {
Records []map[string]any `json:"records"`
Total int64 `json:"total"`
} `json:"data"`
}
// requestSystemLog выполняет запрос к странице системных логов, подставив
// вместо журнала переданное содержимое. Пустой content означает «файла нет».
func requestSystemLog(t *testing.T, content *string) (logPageEnvelope, string) {
t.Helper()
gin.SetMode(gin.TestMode)
path := filepath.Join(t.TempDir(), "hy2xs-admin.log")
if content != nil {
if err := os.WriteFile(path, []byte(*content), 0o600); err != nil {
t.Fatalf("не удалось записать тестовый журнал: %v", err)
}
}
previous := constant.SystemLogPath
constant.SystemLogPath = path
t.Cleanup(func() { constant.SystemLogPath = previous })
engine := gin.New()
engine.GET("/log/logSystem", LogSystem)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/log/logSystem?numLine=100", nil))
raw := recorder.Body.String()
var envelope logPageEnvelope
if err := json.Unmarshal([]byte(raw), &envelope); err != nil {
t.Fatalf("ответ не разбирается как один JSON-документ: %v\nтело: %s", err, raw)
}
return envelope, raw
}
// Регрессия LOG-04. Ветка «файла ещё нет» отвечала ГОЛЫМ МАССИВОМ:
//
// vo.Success(logSystemVos, c)
//
// Панель получала `data.records === undefined` и передавала undefined в
// таблицу. То есть на свежепоставленном хосте — до первой записи в журнал —
// страница системных логов не работала вовсе, а это ровно тот сценарий,
// который проверяется на приёмке каждой чистой установки.
func TestLogSystemKeepsPageShapeWhenFileIsMissing(t *testing.T) {
envelope, raw := requestSystemLog(t, nil)
if envelope.Type != "ok" {
t.Fatalf("отсутствие журнала признано отказом: %s", raw)
}
if envelope.Data == nil {
t.Fatalf("ответ не несёт объекта страницы: %s", raw)
}
if envelope.Data.Records == nil {
t.Fatalf("ответ не несёт поля records: %s", raw)
}
if len(envelope.Data.Records) != 0 || envelope.Data.Total != 0 {
t.Fatalf("ожидалась пустая страница, получено %+v", *envelope.Data)
}
}
// Регрессия LOG-05. При неразбираемой строке вызывался vo.Fail(...) и следом
// `continue`: ответ уже записан в поток, цикл идёт дальше, в конце безусловно
// выполняется vo.Success. Клиент получал два JSON-документа подряд — то есть
// невалидный ответ, — и одной битой строки хватало, чтобы страница перестала
// открываться целиком.
//
// Строка бьётся штатно: lumberjack ротирует файл, и обрыв последней записи на
// границе ротации — обычное событие.
func TestLogSystemSkipsBrokenLineWithoutBreakingResponse(t *testing.T) {
content := `{"level":"info","msg":"GET /api/peers → 200 (7 ms)","time":"2026-09-01 10:00:00"}
{"level":"info","msg":"обрыв записи на границе ротации
{"level":"warning","msg":"PATCH /api/peers/12 → 400 (4 ms)","time":"2026-09-01 10:00:01"}
`
envelope, raw := requestSystemLog(t, &content)
if envelope.Type != "ok" {
t.Fatalf("битая строка отказала всей выдаче: %s", raw)
}
if envelope.Data == nil {
t.Fatalf("ответ не несёт объекта страницы: %s", raw)
}
if len(envelope.Data.Records) != 2 {
t.Fatalf("ожидались две читаемые записи, получено %d: %+v", len(envelope.Data.Records), envelope.Data.Records)
}
for _, record := range envelope.Data.Records {
if message, _ := record["msg"].(string); message == "" {
t.Errorf("запись пришла с пустым msg: %+v", record)
}
}
}
// Читаемый журнал доезжает до панели в той же форме.
func TestLogSystemReturnsRecordsForReadableFile(t *testing.T) {
content := `{"level":"info","msg":"GET /api/peers → 200 (7 ms)","reqMethod":"GET","reqPath":"/api/peers","statusCode":200,"latencyTime":7,"time":"2026-09-01 10:00:00"}
`
envelope, raw := requestSystemLog(t, &content)
if envelope.Data == nil || len(envelope.Data.Records) != 1 {
t.Fatalf("читаемая запись не доехала до панели: %s", raw)
}
record := envelope.Data.Records[0]
if message, _ := record["msg"].(string); message == "" {
t.Fatalf("msg потерян при отдаче: %+v", record)
}
if path, _ := record["reqPath"].(string); path != "/api/peers" {
t.Fatalf("структурные поля не доехали: %+v", record)
}
}
+128 -50
View File
@@ -1,48 +1,49 @@
package controller
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
"hy2xs-admin/util"
)
// resolveID читает идентификатор пира ИЗ ПУТИ и только оттуда.
//
// Запасной ветки «если в пути нет — разобрать тело» здесь больше нет. Все
// маршруты, ведущие сюда, объявлены с `:id` (см. router/peer.go), то есть
// ветка была недостижима. Хуже недостижимости было бы её срабатывание: она
// вызывала validateField, который читает тело запроса, а обработчик следом
// читает то же тело второй раз — gin его не буферизует, и второй разбор
// получил бы пустой поток. То есть запасной путь не работал бы ровно тогда,
// когда понадобился бы.
func resolveID(c *gin.Context) (int64, error) {
if raw := strings.TrimSpace(c.Param("id")); raw != "" {
parsed, err := strconv.ParseInt(raw, 10, 64)
if err == nil && parsed > 0 {
return parsed, nil
}
raw := strings.TrimSpace(c.Param("id"))
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil || parsed <= 0 {
vo.FailValidation(
"идентификатор пира в адресе некорректен",
[]vo.FieldError{{
Code: constant.ErrCodeBodyInvalid,
Field: "id",
Message: fmt.Sprintf("ожидался положительный числовой идентификатор, получено %q", raw),
}},
c,
)
return 0, errors.New(constant.ErrCodeBodyInvalid)
}
idDto, err := validateField(c, dto.IdDto{})
if err != nil {
return 0, err
}
return *idDto.Id, nil
}
func Login(c *gin.Context) {
loginDto, err := validateField(c, dto.LoginDto{})
if err != nil {
return
}
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(vo.JwtVo{TokenType: constant.TokenType, AccessToken: token, ForcePasswordChange: forcePasswordChange}, c)
return parsed, nil
}
func PagePeer(c *gin.Context) {
@@ -50,12 +51,12 @@ func PagePeer(c *gin.Context) {
if err != nil {
return
}
records, total, err := service.PagePeer(peerPageDto)
records, total, onlineState, err := service.PagePeer(peerPageDto)
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(vo.PeerPageVo{Records: records, Total: total}, c)
vo.Success(vo.PeerPageVo{Records: records, Total: total, OnlineState: onlineState}, c)
}
func SavePeer(c *gin.Context) {
@@ -65,7 +66,7 @@ func SavePeer(c *gin.Context) {
}
peerVo, err := service.CreatePeer(peerSaveDto)
if err != nil {
vo.Fail(err.Error(), c)
failService(err, c)
return
}
vo.Success(peerVo, c)
@@ -76,8 +77,14 @@ func DeletePeer(c *gin.Context) {
if err != nil {
return
}
// failService, а не vo.Fail: удаление умеет завершиться ЧАСТИЧНО — пир
// отключён в базе, но завершить его активную сессию не удалось, поэтому
// строка намеренно оставлена на месте. Через vo.Fail этот результат уехал
// бы панели неотличимо от полного отказа, и оператор сделал бы неверный
// вывод: «удаление не сработало, пир как был», — тогда как доступ уже
// закрыт, а строка ждёт повторной попытки.
if err = service.DeletePeer(id); err != nil {
vo.Fail(err.Error(), c)
failService(err, c)
return
}
vo.Success(nil, c)
@@ -93,12 +100,19 @@ func UpdatePeer(c *gin.Context) {
return
}
peerUpdateDto.Id = &id
if peerUpdateDto.Name != nil && *peerUpdateDto.Name != "" && service.ExistPeerName(*peerUpdateDto.Name, id) {
vo.Fail(fmt.Sprintf("name %s already exists", *peerUpdateDto.Name), c)
return
if peerUpdateDto.Name != nil && *peerUpdateDto.Name != "" {
taken, nameErr := service.ExistPeerName(*peerUpdateDto.Name, id)
if nameErr != nil {
vo.Fail(nameErr.Error(), c)
return
}
if taken {
failService(service.PeerNameTakenError(*peerUpdateDto.Name), c)
return
}
}
if err = service.UpdatePeer(id, peerUpdateDto); err != nil {
vo.Fail(err.Error(), c)
failService(err, c)
return
}
vo.Success(nil, c)
@@ -129,37 +143,98 @@ func GetPeer(c *gin.Context) {
vo.Success(peer, c)
}
// peerImportMaxBytes — предел размера загружаемого файла.
const peerImportMaxBytes = 2 * 1024 * 1024
// ImportPeer принимает выгрузку, сделанную ExportPeer.
//
// Импорт — полноценная дверь в таблицу пиров, поэтому его вход проверяется
// так же строго, как обычное создание пира: разбор JSON ограничен по размеру,
// неизвестные поля отклоняются, а содержимое записей валидируется в
// service.ValidatePeerImportBatch до первой записи в базу.
func ImportPeer(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
vo.Fail(constant.SysError, c)
return
}
if header.Size > 1024*1024*2 {
defer func() { _ = file.Close() }()
if header.Size > peerImportMaxBytes {
vo.Fail("the file is too big", c)
return
}
if !strings.HasSuffix(header.Filename, ".json") {
vo.Fail(constant.InvalidError, c)
if !strings.HasSuffix(strings.ToLower(header.Filename), ".json") {
vo.FailValidation(
"импорт принимает только файлы .json",
[]vo.FieldError{{
Code: constant.ErrCodeImportFileExtension,
Field: "file",
Message: "импорт принимает только файлы .json",
}},
c,
)
return
}
content, err := io.ReadAll(file)
// Заявленный Size — это то, что сказал клиент; читаем с собственным
// пределом, чтобы расхождение не превращалось в чтение произвольного
// объёма в память.
content, err := io.ReadAll(io.LimitReader(file, peerImportMaxBytes+1))
if err != nil {
vo.Fail("json file read err", c)
return
}
if len(content) > peerImportMaxBytes {
vo.Fail("the file is too big", c)
return
}
if err = validateStrictJSONEncoding(content); err != nil {
vo.Fail("content contains invalid UTF-8 or UTF-16", c)
return
}
var peerExports []bo.PeerExport
if err = json.Unmarshal(content, &peerExports); err != nil {
decoder := json.NewDecoder(bytes.NewReader(content))
// Опечатка в имени поля должна быть видимой ошибкой, а не молча
// импортированным значением по умолчанию.
decoder.DisallowUnknownFields()
if err = decoder.Decode(&peerExports); err != nil {
vo.Fail("content Unmarshal err", c)
return
}
// json.Decoder читает ПЕРВЫЙ документ и останавливается. Файл вида
//
// [{"name":"peer"}]
// {"что-то":"ещё"}
//
// принимался целиком, а хвост просто не читался: оператор видел «импорт
// выполнен» и не узнавал, что применилась половина файла. Файл обязан
// содержать ровно один JSON-документ.
if err = decoder.Decode(&struct{}{}); err != io.EOF {
vo.Fail("json file must contain exactly one JSON document", c)
return
}
// failService, а не vo.Fail: импорт умеет завершиться ЧАСТИЧНО — партия
// зафиксирована в базе целиком, но завершить старые сессии обновлённых
// пиров не удалось. Полный отказ здесь означал бы для оператора «файл не
// применился», хотя он применился весь.
if err = service.UpsertPeerExport(peerExports); err != nil {
vo.Fail(err.Error(), c)
failService(err, c)
return
}
vo.Success(nil, c)
}
// ExportPeer отдаёт выгрузку пиров прямо в ответ, не создавая файл на сервере.
//
// Раньше выгрузка шла через os.Create в /var/lib/hy2xs-admin/export и файл
// оставался там навсегда. При includeSecrets=true это означало расшифрованные
// секреты пиров — фактические учётные данные доступа — в открытом виде на
// диске, накапливающиеся с каждым нажатием кнопки. Артефакт, который покидает
// сервер, не должен существовать на сервере дольше самого запроса.
func ExportPeer(c *gin.Context) {
includeSecrets := strings.EqualFold(strings.TrimSpace(c.Query("includeSecrets")), "true")
peerExports, err := service.ListExportPeer(includeSecrets)
@@ -167,20 +242,19 @@ func ExportPeer(c *gin.Context) {
vo.Fail(err.Error(), c)
return
}
payload, err := json.MarshalIndent(peerExports, "", " ")
if err != nil {
logrus.Errorf("peer export marshal err: %v", err)
vo.Fail(constant.SysError, c)
return
}
fileName := fmt.Sprintf("PeerExport-%s.json", time.Now().Format("20060102150405"))
filePath := filepath.Join(constant.ExportPathDir, fileName)
if err = util.ExportFile(filePath, peerExports, 0); err != nil {
vo.Fail(err.Error(), c)
return
}
if !util.Exists(filePath) {
vo.Fail("file not exist", c)
return
}
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
c.File(filePath)
c.Data(200, "application/octet-stream", payload)
}
func ReleaseKickPeer(c *gin.Context) {
@@ -204,8 +278,12 @@ func KickPeer(c *gin.Context) {
if err != nil {
return
}
// failService, а не vo.Fail: отзыв доступа умеет завершиться ЧАСТИЧНО —
// блокировка записана, активную сессию завершить не удалось. Через
// vo.Fail этот результат уезжал бы панели неотличимо от полного отказа, и
// оператор сделал бы неверный вывод о состоянии пира.
if err = service.KickPeer(id, *kickDto.BannedUntil); err != nil {
vo.Fail(err.Error(), c)
failService(err, c)
return
}
vo.Success(nil, c)
+140
View File
@@ -0,0 +1,140 @@
package controller
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"hy2xs-admin/dao"
)
// Разбор загруженного файла проверяется без базы: все три отказа наступают до
// первого обращения к слою данных, и это часть контракта — испорченный файл не
// должен доходить до транзакции.
func postImportBytes(t *testing.T, fileName string, content []byte) apiResult {
t.Helper()
gin.SetMode(gin.TestMode)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", fileName)
if err != nil {
t.Fatalf("не удалось собрать форму: %v", err)
}
if _, err := part.Write(content); err != nil {
t.Fatalf("не удалось записать файл в форму: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("не удалось закрыть форму: %v", err)
}
engine := gin.New()
engine.POST("/peer-import", ImportPeer)
request := httptest.NewRequest(http.MethodPost, "/peer-import", body)
request.Header.Set("Content-Type", writer.FormDataContentType())
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var result apiResult
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
}
return result
}
func postImportFile(t *testing.T, fileName string, content string) apiResult {
t.Helper()
return postImportBytes(t, fileName, []byte(content))
}
func TestImportPeerRejectsInvalidUTF8(t *testing.T) {
for _, content := range [][]byte{
{'[', '"', 0xFF, '"', ']'},
{'[', '"', 0xC3, '"', ']'},
{'[', '"', 0xED, 0xA0, 0x80, '"', ']'},
} {
result := postImportBytes(t, "peers.json", content)
if result.Type != "no" {
t.Fatalf("повреждённый UTF-8 принят: %+v", result)
}
}
}
// Регрессия: json.Decoder читает ПЕРВЫЙ документ и останавливается. Файл с
// хвостом принимался целиком, оператор видел «импорт выполнен», а вторая
// половина файла молча не применялась.
func TestImportPeerRejectsTrailingDocument(t *testing.T) {
result := postImportFile(t, "peers.json", `[{"name":"alpha1"}]
{"name":"ignored"}`)
if result.Type != "no" {
t.Fatalf("файл с хвостовым документом не отклонён: %+v", result)
}
if !strings.Contains(result.Message, "exactly one JSON document") {
t.Fatalf("невнятный отказ: %s", result.Message)
}
}
func TestImportPeerRejectsTrailingGarbage(t *testing.T) {
for _, content := range []string{
"[{\"name\":\"alpha1\"}] trailing",
"[{\"name\":\"alpha1\"}][{\"name\":\"bravo2\"}]",
"[{\"name\":\"alpha1\"}]\n[]",
} {
result := postImportFile(t, "peers.json", content)
if result.Type != "no" {
t.Errorf("файл %q не отклонён: %+v", content, result)
}
}
}
func TestImportPeerRejectsUnknownFields(t *testing.T) {
result := postImportFile(t, "peers.json", `[{"name":"alpha1","totallyUnknown":1}]`)
if result.Type != "no" {
t.Fatalf("неизвестное поле не отклонено: %+v", result)
}
}
func TestImportPeerRejectsNonJsonFileName(t *testing.T) {
result := postImportFile(t, "peers.txt", `[{"name":"alpha1"}]`)
if result.Type != "no" {
t.Fatalf("файл не с расширением .json не отклонён: %+v", result)
}
}
// Положительный путь целиком: корректный одиночный документ обязан не только
// разобраться, но и доехать до базы. Без него проверки выше доказывали бы
// только то, что отклоняется всё подряд.
func TestImportPeerAcceptsSingleDocument(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
if err := dao.InitSqliteDBAt(dbPath); err != nil {
t.Fatalf("не удалось открыть тестовую базу: %v", err)
}
if err := dao.RunMigrations(); err != nil {
t.Fatalf("не удалось применить миграции: %v", err)
}
t.Cleanup(func() { _ = dao.CloseSqliteDB() })
result := postImportFile(t, "peers.json", `[{"name":"alpha1","quotaBytes":-1,"maxDevices":3}]`)
if result.Type != "ok" {
t.Fatalf("корректный файл отклонён: %+v", result)
}
peers, err := dao.ListPeer("name = ?", "alpha1")
if err != nil {
t.Fatalf("не удалось прочитать пиров: %v", err)
}
if len(peers) != 1 {
t.Fatalf("импорт не создал пира: найдено %d", len(peers))
}
}
+548
View File
@@ -0,0 +1,548 @@
package controller
import (
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
"hy2xs-admin/service"
)
// Контракт формы пира: необязательный секрет и внятный отказ.
//
// Проверяется весь путь запроса — разбор тела, нормализация DTO, правила
// валидатора, сервис, база, — потому что дефект жил ровно на стыке этих
// слоёв и ни один из них по отдельности его не показывал: панель обещала
// автогенерацию, сервис умел её выполнить, а правило `omitempty,min=6` на
// поле-указателе отказывало раньше, чем управление доходило до сервиса.
func newPeerControllerDB(t *testing.T) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
if err := dao.InitSqliteDBAt(dbPath); err != nil {
t.Fatalf("не удалось открыть тестовую базу: %v", err)
}
if err := dao.RunMigrations(); err != nil {
t.Fatalf("не удалось применить миграции: %v", err)
}
t.Cleanup(func() { _ = dao.CloseSqliteDB() })
}
// answerTrafficStats поднимает минимальный Traffic Stats API Hysteria.
//
// Нужен там, где тест доходит до service.Hysteria2Auth: проверка лимита
// устройств теперь fail-closed, и подключение без ответа Traffic Stats API
// отклоняется (см. AUTH-01/AUTH-02). Наблюдений этот ответчик не ведёт —
// контракт `/kick` и `/online` доказывается в пакете service; здесь он лишь
// делает вопрос «сколько устройств онлайн» отвечаемым.
func answerTrafficStats(t *testing.T) {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/online" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("{}"))
}))
t.Cleanup(server.Close)
_, port, err := net.SplitHostPort(strings.TrimPrefix(server.URL, "http://"))
if err != nil {
t.Fatalf("не удалось разобрать адрес тестового сервера %q: %v", server.URL, err)
}
path := filepath.Join(t.TempDir(), "config.yaml")
content := "listen: \":443\"\ntrafficStats:\n listen: \"127.0.0.1:" + port + "\"\n secret: \"test-secret\"\n"
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("не удалось записать тестовый конфиг Hysteria: %v", err)
}
previous := constant.Hysteria2ConfigPath
constant.Hysteria2ConfigPath = path
t.Cleanup(func() { constant.Hysteria2ConfigPath = previous })
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, "test-secret"); err != nil {
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
}
}
// peerPayload — тело создания пира со всеми обязательными полями.
// Тесты меняют в нём ровно то, что проверяют.
func peerPayload(name string) map[string]any {
return map[string]any{
"name": name,
"quotaBytes": -1,
"expiresAt": 0,
"maxDevices": 3,
"disabled": 0,
"remark": "",
}
}
func createPeer(t *testing.T, body map[string]any) apiResult {
t.Helper()
return postJSON(t, SavePeer, "/peers", body)
}
// errorFor возвращает причину отказа по имени поля.
func errorFor(t *testing.T, result apiResult, field string) (string, bool) {
t.Helper()
for _, item := range result.Errors {
if item.Field == field {
return item.Code, true
}
}
return "", false
}
func storedPeer(t *testing.T, name string) entity.Peer {
t.Helper()
peer, err := dao.GetPeer("name = ?", name)
if err != nil {
t.Fatalf("пир %q не найден в базе: %v", name, err)
}
return peer
}
// Регрессия UX-02. Панель писала под полем «оставьте пустым — сгенерируем
// автоматически» и отправляла `secret: ""`. Правило `omitempty,min=6` на
// поле-указателе НЕ пропускалось (см. hasValue в baked_in.go валидатора),
// применялось к пустой строке и отказывало. Оператор видел «Invalid», а
// генерация в CreatePeer была недостижима.
func TestCreatePeerGeneratesSecretWhenNotProvided(t *testing.T) {
cases := map[string]func(map[string]any){
"поле отсутствует": func(body map[string]any) {},
"пустая строка": func(body map[string]any) { body["secret"] = "" },
"только пробелы": func(body map[string]any) { body["secret"] = " " },
"перевод строки": func(body map[string]any) { body["secret"] = "\n" },
"табуляция и пробел": func(body map[string]any) { body["secret"] = "\t " },
}
for label, mutate := range cases {
t.Run(label, func(t *testing.T) {
newPeerControllerDB(t)
answerTrafficStats(t)
body := peerPayload("client-01")
mutate(body)
result := createPeer(t, body)
if result.Type != "ok" {
t.Fatalf("создание пира отклонено: code=%d message=%q errors=%+v",
result.Code, result.Message, result.Errors)
}
peer := storedPeer(t, "client-01")
if peer.SecretEncrypted == nil || *peer.SecretEncrypted == "" {
t.Fatal("секрет не сохранён")
}
secret, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
if err != nil {
t.Fatalf("сохранённый секрет не расшифровывается: %v", err)
}
if len(secret) < 6 {
t.Fatalf("сгенерирован слишком короткий секрет: %q", secret)
}
// Сгенерированный секрет обязан РАБОТАТЬ немедленно: то, что он
// записан, ничего не значит, пока по нему не проходит проверка
// доступа. Это же связывает digest и шифртекст между собой.
id, authID, authErr := service.Hysteria2Auth(secret)
if authErr != nil {
t.Fatalf("пир не аутентифицируется своим секретом: %v", authErr)
}
if id != *peer.Id || authID != *peer.AuthId {
t.Fatalf("аутентифицировался другой пир: id=%d authId=%q", id, authID)
}
})
}
}
// Два одинаковых запроса не должны давать одинаковый секрет: генератор
// обязан быть случайным, а не производной от имени.
func TestGeneratedPeerSecretsDiffer(t *testing.T) {
newPeerControllerDB(t)
secrets := make(map[string]struct{}, 5)
for _, name := range []string{"client-01", "client-02", "client-03", "client-04", "client-05"} {
if result := createPeer(t, peerPayload(name)); result.Type != "ok" {
t.Fatalf("создание %q отклонено: %+v", name, result)
}
peer := storedPeer(t, name)
secret, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
if err != nil {
t.Fatalf("секрет %q не расшифровывается: %v", name, err)
}
if _, seen := secrets[secret]; seen {
t.Fatalf("сгенерированный секрет повторился: %q", secret)
}
secrets[secret] = struct{}{}
}
}
// Границы ручного секрета — ровно те, что обещает подсказка под полем.
func TestCreatePeerSecretLengthBoundaries(t *testing.T) {
cases := []struct {
label string
secret string
accepted bool
expectCode string
}{
{"5 символов", strings.Repeat("a", 5), false, constant.ErrCodeMinLength},
{"6 символов", strings.Repeat("a", 6), true, ""},
{"128 символов", strings.Repeat("a", 128), true, ""},
{"129 символов", strings.Repeat("a", 129), false, constant.ErrCodeMaxLength},
}
for _, tc := range cases {
t.Run(tc.label, func(t *testing.T) {
newPeerControllerDB(t)
body := peerPayload("client-01")
body["secret"] = tc.secret
result := createPeer(t, body)
if tc.accepted {
if result.Type != "ok" {
t.Fatalf("секрет длиной %d отклонён: %+v", len(tc.secret), result)
}
peer := storedPeer(t, "client-01")
stored, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
if err != nil {
t.Fatalf("секрет не расшифровывается: %v", err)
}
if stored != tc.secret {
t.Fatalf("сохранён не тот секрет, который передали")
}
return
}
if result.Type != "no" {
t.Fatalf("секрет длиной %d принят", len(tc.secret))
}
code, ok := errorFor(t, result, "secret")
if !ok {
t.Fatalf("отказ не назвал поле secret: %+v", result.Errors)
}
if code != tc.expectCode {
t.Fatalf("код отказа %q, ожидался %q", code, tc.expectCode)
}
})
}
}
// Регрессия UX-03. Любая ошибка любого поля превращалась в одно слово
// `invalid`: панель не могла ни подсветить поле, ни объяснить причину, и
// вынуждена была бы разбирать текст, чтобы попытаться.
func TestCreatePeerNamesTheFieldAndTheRule(t *testing.T) {
cases := []struct {
label string
body func() map[string]any
field string
code string
}{
{
label: "имя не передано",
body: func() map[string]any {
body := peerPayload("client-01")
delete(body, "name")
return body
},
field: "name",
code: constant.ErrCodeRequired,
},
{
label: "имя короче допустимого",
body: func() map[string]any { return peerPayload("pc1") },
field: "name",
code: constant.ErrCodePeerName,
},
{
label: "имя длиннее допустимого",
body: func() map[string]any { return peerPayload(strings.Repeat("a", 33)) },
field: "name",
code: constant.ErrCodePeerName,
},
{
label: "лимит устройств меньше единицы",
body: func() map[string]any {
body := peerPayload("client-01")
body["maxDevices"] = 0
return body
},
field: "maxDevices",
code: constant.ErrCodeMin,
},
{
label: "disabled вне множества значений",
body: func() map[string]any {
body := peerPayload("client-01")
body["disabled"] = 7
return body
},
field: "disabled",
code: constant.ErrCodeOneOf,
},
{
label: "квота меньше минимума",
body: func() map[string]any {
body := peerPayload("client-01")
body["quotaBytes"] = -2
return body
},
field: "quotaBytes",
code: constant.ErrCodeMin,
},
{
label: "комментарий длиннее допустимого",
body: func() map[string]any {
body := peerPayload("client-01")
body["remark"] = strings.Repeat("я", 65)
return body
},
field: "remark",
code: constant.ErrCodeMaxLength,
},
}
for _, tc := range cases {
t.Run(tc.label, func(t *testing.T) {
newPeerControllerDB(t)
result := createPeer(t, tc.body())
if result.Type != "no" {
t.Fatalf("некорректный ввод принят: %+v", result)
}
if result.Code != constant.CodeInvalidError {
t.Fatalf("код ответа %d, ожидался %d", result.Code, constant.CodeInvalidError)
}
code, ok := errorFor(t, result, tc.field)
if !ok {
t.Fatalf("отказ не назвал поле %q: %+v", tc.field, result.Errors)
}
if code != tc.code {
t.Fatalf("код отказа %q, ожидался %q", code, tc.code)
}
// Сообщение остаётся человекочитаемым для клиента без панели, но
// панель им не пользуется: у неё есть код.
if strings.TrimSpace(result.Message) == "" {
t.Fatal("отказ без человекочитаемого сообщения")
}
})
}
}
// Регрессия: слой контроллеров нёс собственную копию правила имени, в которой
// неэкранированный дефис превращал `+-=` в диапазон и впускал `, - . / : ; <`.
// Имя `peer/name` создавалось через панель и отклонялось импортом того же
// пира, хотя имя уезжает во fragment клиентской ссылки и в секрет.
func TestCreatePeerRejectsNamesOutsideTheCharset(t *testing.T) {
for _, name := range []string{
"peer/name",
"peer:name",
"peer;name",
"peer,name",
"peer.name",
"peer<name",
"peer name",
"пир-01",
} {
t.Run(name, func(t *testing.T) {
newPeerControllerDB(t)
result := createPeer(t, peerPayload(name))
if result.Type != "no" {
t.Fatalf("имя %q принято", name)
}
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerName {
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerName)
}
// Обе двери в таблицу пиров обязаны требовать одного и того же.
if service.IsValidPeerName(name) {
t.Fatalf("импорт принимает имя %q, которое отклоняет панель", name)
}
})
}
}
func TestCreatePeerReportsTakenName(t *testing.T) {
newPeerControllerDB(t)
if result := createPeer(t, peerPayload("client-01")); result.Type != "ok" {
t.Fatalf("первое создание отклонено: %+v", result)
}
result := createPeer(t, peerPayload("client-01"))
if result.Type != "no" {
t.Fatal("повторное имя принято")
}
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerNameTaken {
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerNameTaken)
}
}
func TestCreatePeerReportsReservedName(t *testing.T) {
newPeerControllerDB(t)
result := createPeer(t, peerPayload(service.ReservedBootstrapPeerName))
if result.Type != "no" {
t.Fatal("зарезервированное имя принято")
}
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerNameReserved {
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerNameReserved)
}
}
// Тело, которое вообще не разобралось, — это не нарушение правила поля.
// Панели важно различать: в первом случае подсвечивать нечего.
func TestCreatePeerReportsUnparsableBody(t *testing.T) {
newPeerControllerDB(t)
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.POST("/peers", SavePeer)
request := httptest.NewRequest(http.MethodPost, "/peers", strings.NewReader("{не json"))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var result apiResult
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
}
if result.Type != "no" {
t.Fatal("неразбираемое тело принято")
}
if len(result.Errors) != 1 || result.Errors[0].Code != constant.ErrCodeBodyInvalid {
t.Fatalf("неожиданное описание отказа: %+v", result.Errors)
}
if result.Errors[0].Field != "" {
t.Fatalf("отказ разбора привязан к полю %q", result.Errors[0].Field)
}
}
// patchPeer выполняет PATCH /peers/:id так же, как это делает панель.
func patchPeer(t *testing.T, id int64, body map[string]any) apiResult {
t.Helper()
gin.SetMode(gin.TestMode)
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("не удалось собрать тело запроса: %v", err)
}
engine := gin.New()
engine.PATCH("/peers/:id", UpdatePeer)
target := "/peers/" + strconv.FormatInt(id, 10)
request := httptest.NewRequest(http.MethodPatch, target, strings.NewReader(string(payload)))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var result apiResult
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
}
return result
}
// При изменении пустой секрет означает «не менять», и это то же самое
// состояние, что и отсутствие поля. Панель отправляет `secret: ""` всякий раз,
// когда оператор открыл форму и не трогал поле секрета.
func TestUpdatePeerKeepsSecretWhenFieldIsBlank(t *testing.T) {
newPeerControllerDB(t)
if result := createPeer(t, peerPayload("client-01")); result.Type != "ok" {
t.Fatalf("создание пира отклонено: %+v", result)
}
before := storedPeer(t, "client-01")
for _, blank := range []string{"", " "} {
result := patchPeer(t, *before.Id, map[string]any{
"name": "client-01",
"secret": blank,
"remark": "рабочее устройство",
})
if result.Type != "ok" {
t.Fatalf("изменение с пустым секретом %q отклонено: %+v", blank, result)
}
after := storedPeer(t, "client-01")
if *after.SecretDigest != *before.SecretDigest {
t.Fatal("секрет пира изменился, хотя поле оставили пустым")
}
if after.Remark == nil || *after.Remark != "рабочее устройство" {
t.Fatal("остальные поля формы не применились")
}
}
}
// Пустой комментарий обязан ОЧИЩАТЬ комментарий, а не означать «не менять»:
// иначе оператор не может убрать однажды сделанную пометку. Это граница, по
// которой нормализация проходит для каждого поля отдельно.
func TestUpdatePeerClearsRemarkWhenFieldIsBlank(t *testing.T) {
newPeerControllerDB(t)
body := peerPayload("client-01")
body["remark"] = "временная пометка"
if result := createPeer(t, body); result.Type != "ok" {
t.Fatalf("создание пира отклонено: %+v", result)
}
peer := storedPeer(t, "client-01")
if result := patchPeer(t, *peer.Id, map[string]any{"remark": ""}); result.Type != "ok" {
t.Fatalf("очистка комментария отклонена: %+v", result)
}
after := storedPeer(t, "client-01")
if after.Remark != nil && *after.Remark != "" {
t.Fatalf("комментарий не очищен: %q", *after.Remark)
}
}
// Регрессия, найденная вместе с UX-02 и в отчёте не значившаяся: `el-input`
// с крестиком очистки ставит пустую строку, axios сериализует её как `?name=`,
// и та же ловушка `omitempty` на указателе отказывала поиску пиров с
// «invalid» — то есть список пиров ломался в один клик по крестику.
func TestPagePeerAcceptsClearedFilters(t *testing.T) {
newPeerControllerDB(t)
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.GET("/peers", PagePeer)
query := url.Values{}
query.Set("pageNum", "1")
query.Set("pageSize", "10")
query.Set("name", "")
query.Set("remark", "")
request := httptest.NewRequest(http.MethodGet, "/peers?"+query.Encode(), nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var result apiResult
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
}
if result.Type != "ok" {
t.Fatalf("очищенный фильтр отклонён: code=%d message=%q errors=%+v",
result.Code, result.Message, result.Errors)
}
}
+233 -19
View File
@@ -1,47 +1,261 @@
package controller
import (
"errors"
"fmt"
"net/http"
"reflect"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"hy2xs-admin/credential"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/vo"
"net/http"
"regexp"
"hy2xs-admin/service"
)
var validate *validator.Validate
func init() {
validate = validator.New()
_ = validate.RegisterValidation("validateStr", validateStr)
// Имя поля в отказе — это имя из JSON, а не из структуры Go. Панель знает
// поля формы под теми именами, под которыми их отправляет; `Secret` вместо
// `secret` заставил бы её переводить одно в другое ещё одним словарём.
validate.RegisterTagNameFunc(func(field reflect.StructField) string {
name := strings.SplitN(field.Tag.Get("json"), ",", 2)[0]
if name == "" || name == "-" {
return field.Name
}
return name
})
mustRegister("peerName", validatePeerName)
mustRegister("credentialStr", validateCredentialStr)
mustRegister("adminPassword", validateAdminPassword)
}
func validateStr(f validator.FieldLevel) bool {
field := f.Field().String()
// Строка должна быть длиной 6-32 символа и состоять из букв, цифр или разрешённых спецсимволов
reg := "^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$"
compile := regexp.MustCompile(reg)
return field == "" || compile.MatchString(field)
func mustRegister(tag string, fn validator.Func) {
if err := validate.RegisterValidation(tag, fn); err != nil {
panic(fmt.Sprintf("не удалось зарегистрировать правило %q: %v", tag, err))
}
}
// validatePeerName — единственное правило имени пира.
//
// Набор символов и длина берутся из service: имя пира проверяется на двух
// дверях в одну и ту же таблицу — обычное создание и импорт выгрузки, — и две
// независимые копии правила уже расходились. Копия в слое контроллеров
// выглядела так:
//
// ^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$
//
// и её комментарий утверждал, что набор тот же, что у импорта. Он был другим:
// дефис внутри класса не экранирован, поэтому `+-=` образует ДИАПАЗОН и
// впускает `, - . / 0-9 : ; < =`. То есть через панель проходило имя
// `peer/name`, которое импорт того же самого пира отклонял, — а имя пира
// уезжает во fragment клиентской ссылки и в автогенерируемый секрет.
func validatePeerName(f validator.FieldLevel) bool {
return service.IsValidPeerName(f.Field().String())
}
// validateCredentialStr — единственное правило имени администратора.
//
// Набор символов и длина берутся из service по той же причине, что и у имени
// пира: собственная копия правила в слое контроллеров уже расходилась с
// остальным продуктом. Здесь она вдобавок была единственным местом, знавшим
// набор, — оркестратор писал в hy2xs.env любое имя, а панель проверяла своё
// третье правило.
//
// Пустое значение НЕ впускается. Раньше здесь стояло `field == "" || …`, и это
// была ловушка: у поля-указателя `required` считает непустым сам факт
// ненулевого указателя, поэтому `{"username":""}` доходило сюда и объявлялось
// корректным. Пустой логин отсекается нормализацией DTO, которая превращает
// его в «не задано», и получает отказ `required` — то есть ту причину, которая
// у него на самом деле.
func validateCredentialStr(f validator.FieldLevel) bool {
return credential.IsValidAdminUsername(f.Field().String())
}
// validateAdminPassword — единственное правило пароля администратора.
//
// Здесь стояли `min=6,max=64` прямо в теге, и это была та же конструкция, от
// которой уже отказались на логине: границы жили РЯДОМ с правилом, а не внутри
// него. У пароля она вдобавок была неполна. Тег `max=64` считает символы
// Unicode, а bcrypt считает байты, поэтому пароль из 64 кириллических букв —
// 128 байт — проходил проверку целиком и падал только в хешировании, уже как
// системная ошибка на штатной смене пароля.
//
// Границу в байтах тегом не выразить: у валидатора нет правила длины в байтах.
// Поэтому всё правило целиком переехало в contract-функцию, и теперь у формы
// входа, формы смены пароля, bootstrap-установки и `reset-admin` она одна.
func validateAdminPassword(f validator.FieldLevel) bool {
return credential.IsValidAdminPassword(f.Field().String())
}
// validateField разбирает запрос, приводит его к каноничному виду и проверяет
// правила.
//
// Отказ описывается ПОЛЯМИ, а не одним словом. Раньше и ошибка разбора тела, и
// нарушение любого правила любого поля превращались в одну строку `invalid`:
// оператор, оставивший секрет пустым, видел «Invalid» и не имел ни одного
// способа узнать, что именно не так, — а не так было ровно то, что панель ему
// же и предлагала сделать.
func validateField[T interface{}](c *gin.Context, field T) (T, error) {
var bindErr error
if c.Request.Method == http.MethodGet {
switch c.Request.Method {
case http.MethodGet:
bindErr = c.ShouldBindQuery(&field)
} else if c.Request.Method == http.MethodPost ||
c.Request.Method == http.MethodPut ||
c.Request.Method == http.MethodPatch ||
c.Request.Method == http.MethodDelete {
bindErr = c.ShouldBindJSON(&field)
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
bindErr = strictBindJSON(c, &field)
}
if bindErr != nil {
vo.Fail(constant.InvalidError, c)
return field, fmt.Errorf(constant.InvalidError)
vo.FailValidation(
"запрос не разобран: проверьте формат и типы полей",
[]vo.FieldError{{
Code: constant.ErrCodeBodyInvalid,
Message: bindErr.Error(),
}},
c,
)
return field, errors.New(constant.ErrCodeBodyInvalid)
}
// Нормализация идёт между разбором и проверкой: правила обязаны видеть уже
// каноничный вход, иначе «не задано» и «задано пустым» остаются разными
// состояниями для валидатора и одинаковыми для человека.
if normalizable, ok := any(&field).(dto.Normalizable); ok {
normalizable.Normalize()
}
if err := validate.Struct(&field); err != nil {
vo.Fail(constant.InvalidError, c)
return field, fmt.Errorf(constant.InvalidError)
vo.FailValidation(
"проверка данных не пройдена",
describeValidationErrors(err),
c,
)
return field, errors.New(constant.ErrCodeValidationFailed)
}
return field, nil
}
// describeValidationErrors переводит отказ валидатора в список причин.
func describeValidationErrors(err error) []vo.FieldError {
var validationErrors validator.ValidationErrors
if !errors.As(err, &validationErrors) {
// InvalidValidationError означает ошибку программиста (в проверку
// передали не структуру), а не плохой вход оператора. Скрывать её за
// сообщением о поле нельзя: она никогда не чинится правкой формы.
return []vo.FieldError{{
Code: constant.ErrCodeValidationFailed,
Message: err.Error(),
}}
}
out := make([]vo.FieldError, 0, len(validationErrors))
for _, fieldErr := range validationErrors {
out = append(out, describeFieldError(fieldErr))
}
return out
}
// isTextField сообщает, что `min`/`max` на этом поле ограничивают ДЛИНУ, а не
// величину. Указатели валидатор к этому моменту уже разыменовал.
func isTextField(fieldErr validator.FieldError) bool {
return fieldErr.Kind() == reflect.String
}
func describeFieldError(fieldErr validator.FieldError) vo.FieldError {
field := fieldErr.Field()
param := fieldErr.Param()
described := vo.FieldError{Field: field}
switch fieldErr.Tag() {
case "required":
described.Code = constant.ErrCodeRequired
described.Message = fmt.Sprintf("поле %q обязательно", field)
case "min":
if isTextField(fieldErr) {
described.Code = constant.ErrCodeMinLength
described.Params = map[string]string{"min": param}
described.Message = fmt.Sprintf("поле %q короче %s символов", field, param)
break
}
described.Code = constant.ErrCodeMin
described.Params = map[string]string{"min": param}
described.Message = fmt.Sprintf("поле %q меньше допустимого минимума %s", field, param)
case "max":
if isTextField(fieldErr) {
described.Code = constant.ErrCodeMaxLength
described.Params = map[string]string{"max": param}
described.Message = fmt.Sprintf("поле %q длиннее %s символов", field, param)
break
}
described.Code = constant.ErrCodeMax
described.Params = map[string]string{"max": param}
described.Message = fmt.Sprintf("поле %q больше допустимого максимума %s", field, param)
case "len":
described.Code = constant.ErrCodeLen
described.Params = map[string]string{"len": param}
described.Message = fmt.Sprintf("поле %q должно иметь длину %s", field, param)
case "oneof":
described.Code = constant.ErrCodeOneOf
described.Params = map[string]string{"values": param}
described.Message = fmt.Sprintf("поле %q принимает одно из значений: %s", field, param)
case "gt":
described.Code = constant.ErrCodeGreaterThan
described.Params = map[string]string{"gt": param}
described.Message = fmt.Sprintf("поле %q должно быть больше %s", field, param)
case "peerName":
described.Code = constant.ErrCodePeerName
described.Params = map[string]string{
"min": fmt.Sprintf("%d", service.PeerNameMinLength),
"max": fmt.Sprintf("%d", service.PeerNameMaxLength),
"charset": service.PeerNameCharset,
}
described.Message = fmt.Sprintf(
"имя пира: от %d до %d символов из набора %s",
service.PeerNameMinLength, service.PeerNameMaxLength, service.PeerNameCharset,
)
case "credentialStr":
// Причина называет набор и границы ровно так же, как это делает
// `peerName`. Прежнее «содержит недопустимые символы» не отвечало на
// вопрос оператора: набор символов логина ему негде посмотреть, а
// отказ по длине этой фразой описывался как отказ по символам.
described.Code = constant.ErrCodeCredentialStr
described.Params = map[string]string{
"min": fmt.Sprintf("%d", credential.AdminUsernameMinLength),
"max": fmt.Sprintf("%d", credential.AdminUsernameMaxLength),
"charset": credential.AdminUsernameCharset,
}
described.Message = fmt.Sprintf(
"поле %q: от %d до %d символов из набора %s",
field, credential.AdminUsernameMinLength, credential.AdminUsernameMaxLength, credential.AdminUsernameCharset,
)
case "adminPassword":
// Причина называет ОБЕ границы, потому что их две и они в разных
// единицах. Отказ «не длиннее 64 символов» на пароле из 40 эмодзи был
// бы неправдой: символов там 40, а байтов 160, и оператор, послушав
// такую причину, сокращал бы пароль, который отвергнут не за это.
described.Code = constant.ErrCodeAdminPassword
described.Params = map[string]string{
"min": fmt.Sprintf("%d", credential.AdminPasswordMinLength),
"max": fmt.Sprintf("%d", credential.AdminPasswordMaxLength),
"maxBytes": fmt.Sprintf("%d", credential.AdminPasswordMaxBytes),
}
described.Message = fmt.Sprintf(
"поле %q: от %d до %d символов, не более %d байт в UTF-8, без управляющих символов и U+FEFF",
field,
credential.AdminPasswordMinLength,
credential.AdminPasswordMaxLength,
credential.AdminPasswordMaxBytes,
)
default:
described.Code = constant.ErrCodeRuleUnknown
described.Params = map[string]string{"rule": fieldErr.Tag()}
described.Message = fmt.Sprintf("поле %q не удовлетворяет правилу %q", field, fieldErr.Tag())
}
return described
}
+101
View File
@@ -0,0 +1,101 @@
package controller
import (
"strings"
"testing"
"hy2xs-admin/credential"
"hy2xs-admin/service"
)
// Правила валидатора обязаны спрашивать КОНТРАКТ, а не собственную копию.
//
// Прежние версии этих проверок обращались к регекспу внутри слоя контроллеров и
// потому не увидели бы расхождения между копией и правилом — а именно так этот
// класс дефектов и появлялся: набор символов логина существовал в четырёх
// местах, и все четыре разошлись. Сам контракт проверяется в пакете credential;
// здесь проверяется ПРОВОДКА: тег валидатора и функция контракта отвечают
// одинаково на одних и тех же значениях.
func TestCredentialStrTagDelegatesToContract(t *testing.T) {
for _, candidate := range []string{
"hy2xsadmin",
"admin.ops",
"admin+1",
"admi",
"админ12",
"admin пробел",
strings.Repeat("a", credential.AdminUsernameMaxLength+1),
} {
expected := credential.IsValidAdminUsername(candidate)
if got := validate.Var(candidate, "credentialStr") == nil; got != expected {
t.Errorf("тег credentialStr на %q дал %v, контракт — %v", candidate, got, expected)
}
}
}
// Тот же вопрос про пароль, и он важнее.
//
// Границы пароля стояли прямо в теге (`min=6,max=64`), и тег НЕ МОГ выразить
// предел bcrypt в байтах — правила длины в байтах у go-playground/validator
// нет. Поэтому пароль из 64 кириллических букв проходил валидацию формы и
// падал в хешировании, возвращаясь оператору системной ошибкой. Проверка
// закрепляет, что правило теперь одно и что оно то же самое, которым
// пользуются bootstrap-установка и `reset-admin`.
func TestAdminPasswordTagDelegatesToContract(t *testing.T) {
for _, candidate := range []string{
"bootstrap-password",
"abcde ",
strings.Repeat("a", credential.AdminPasswordMinLength-1),
strings.Repeat("a", credential.AdminPasswordMaxLength),
strings.Repeat("a", credential.AdminPasswordMaxLength+1),
strings.Repeat("я", 36),
strings.Repeat("я", 37),
strings.Repeat("😀", 18),
strings.Repeat("😀", 19),
strings.Repeat("я", credential.AdminPasswordMaxLength),
"abcde\n",
} {
expected := credential.IsValidAdminPassword(candidate)
if got := validate.Var(candidate, "adminPassword") == nil; got != expected {
t.Errorf(
"тег adminPassword на значении из %d символов / %d байт дал %v, контракт — %v",
len([]rune(candidate)), len(candidate), got, expected,
)
}
}
}
// Имя пира проверяется ОДНИМ правилом на весь продукт: панель и импорт ведут в
// одну таблицу и не имеют права требовать разного.
func TestPeerNameRuleIsSharedWithImport(t *testing.T) {
accepted := []string{
"client-01",
"alpha1",
"bootstrap-admin-peer",
strings.Repeat("a", 6),
strings.Repeat("a", 32),
}
for _, name := range accepted {
if !service.IsValidPeerName(name) {
t.Errorf("имя %q отклонено, ожидался приём", name)
}
}
rejected := []string{
"",
" ",
"pc1",
strings.Repeat("a", 33),
"peer name",
"peer\nname",
"peer/name",
"peer:name",
"peer.name",
"пир-01",
}
for _, name := range rejected {
if service.IsValidPeerName(name) {
t.Errorf("имя %q принято, ожидался отказ", name)
}
}
}
+236
View File
@@ -0,0 +1,236 @@
// Package credential объявляет контракт учётных данных администратора — ОДИН
// на весь продукт.
//
// Почему это отдельный пакет, а не файл в service. Контракт нужен четырём
// слоям сразу, и два из них лежат НИЖЕ service по графу импортов:
//
// controller -> service -> util (хеширование пароля)
// dao -> util (создание первой учётной записи)
// cmd -> dao, util (reset-admin)
//
// Пока контракт жил в service, `util.HashPassword` не мог его позвать — импорт
// был бы циклическим, — и завёл собственную проверку `len(TrimSpace(p)) < 6`.
// Эта третья копия правила разошлась с остальными ровно тем способом, ради
// предотвращения которого контракт и объявлен: LoginDto считал `"abcde "`
// корректным паролем, а HashPassword отказывался его хешировать. Пакет-лист
// ниже всех потребителей — единственная запись, при которой у правила
// физически не может появиться второго экземпляра.
package credential
import (
"regexp"
"strconv"
"strings"
"unicode/utf8"
)
// Контракт учётных данных администратора.
//
// Про логин и пароль администратора одновременно знали четыре места: регексп в
// слое контроллеров, границы длины в тегах LoginDto, правило формы входа в
// панели и правило формы смены пароля в ней же. Ни одно из них не ссылалось на
// остальные, и все четыре успели разойтись:
//
// - тег `validateStr` на Username пережил переименование правила и остался
// ссылкой на несуществующую функцию — валидатор паниковал на КАЖДОМ входе,
// а gin.Recovery превращал панику в HTTP 500;
// - форма входа ограничивала пароль 32 символами, форма смены пароля — 64,
// сервер — тоже 64. То есть пароль, назначенный штатной формой смены,
// форма входа отказывалась отправлять: панель запирала оператора снаружи
// после операции, которую сама же и предложила;
// - оркестратор не проверял HY2XS_ADMIN_USER вовсе и по умолчанию писал в
// hy2xs.env `admin` — пять символов при минимуме в шесть. Установка
// завершалась успешно, а войти в панель было нельзя.
//
// Общее у всех трёх — контракт существовал только как совпадение литералов,
// которое ничто не проверяло. Теперь он объявлен здесь, слой контроллеров зовёт
// IsValidAdminUsername и IsValidAdminPassword, а расхождение с панелью и
// оркестратором ловится тестами, читающими этот же файл.
const (
AdminUsernameMinLength = 6
AdminUsernameMaxLength = 32
// Границы пароля в СИМВОЛАХ Unicode (code points).
AdminPasswordMinLength = 6
AdminPasswordMaxLength = 64
// AdminPasswordMaxBytes — предел, установленный bcrypt.
//
// Он объявлен ОТДЕЛЬНО от AdminPasswordMaxLength, потому что это ДРУГАЯ
// единица измерения, и подменять одну другой нельзя.
//
// Здесь стоял ровно этот дефект. Верхняя граница в 64 символа была выбрана
// «заведомо ниже 72 байт», и обоснование выглядело убедительно, пока пароль
// оставался ASCII. Но `golang.org/x/crypto/bcrypt` считает БАЙТЫ:
//
// 64 x "a" = 64 байта -> GenerateFromPassword принимает
// 64 x "я" = 128 байт -> ErrPasswordTooLong
// 19 x "😀" = 76 байт -> ErrPasswordTooLong
//
// причём отказ приходил НЕ на форме, а из хеширования: смена пароля на 64
// кириллические буквы проходила все проверки продукта и заканчивалась
// системной ошибкой на штатной операции. Комментарий в прежней редакции
// вдобавок утверждал, что bcrypt «молча отбрасывает остаток» — так вело
// себя издание пакета до v0.28; действующее (v0.55.0, bcrypt.go:96)
// отвечает ошибкой:
//
// if len(password) > 72 { return nil, ErrPasswordTooLong }
AdminPasswordMaxBytes = 72
)
// adminUsernameCharacterClass — набор символов логина в записи регекспа.
//
// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая
// последствия неэкранированного дефиса в исходной записи `_+-=`: там `+-=`
// образовывал ДИАПАЗОН и впускал `, - . / 0-9 : ; < =`. Сужать набор здесь
// нельзя в одиночку — имя администратора приходит из HY2XS_ADMIN_USER, и
// установка с логином вроде `admin.ops` перестала бы пускать оператора в
// панель. Набор и проверка на стороне оркестратора меняются только вместе.
const adminUsernameCharacterClass = `a-zA-Z0-9!@#$%^&*()_+,\-./:;<=`
// AdminUsernameCharset — тот же набор в том виде, в каком его показывают
// оператору. Объявлен рядом с регекспом, чтобы сообщение об отказе не заводило
// собственную копию того же множества.
const AdminUsernameCharset = `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`
// adminUsernamePattern собирается из констант, а не пишется литералом: границы
// длины обязаны быть одними и теми же для правила и для сообщения о нём.
var adminUsernamePattern = regexp.MustCompile(
"^[" + adminUsernameCharacterClass + "]{" +
strconv.Itoa(AdminUsernameMinLength) + "," +
strconv.Itoa(AdminUsernameMaxLength) + "}$",
)
// IsValidAdminUsername сообщает, пригодно ли имя администратора.
//
// Пробелы по краям снимаются здесь повторно: через форму входа они уже сняты
// нормализацией DTO, но эту же функцию зовёт проверка HY2XS_ADMIN_USER, куда
// значение приходит из файла окружения, а не из DTO.
func IsValidAdminUsername(username string) bool {
return adminUsernamePattern.MatchString(strings.TrimSpace(username))
}
// Noncharacters и суррогаты — то, что НЕ ПРИМЕТ systemd.
//
// `EnvironmentFile=` разбирается systemd, и перед тем как принять пару, он
// прогоняет ключ и значение через `utf8_is_valid` (src/basic/env-file.c,
// `check_utf8ness_and_warn`). Отказ там — это `-EINVAL`, то есть НЕ загруженный
// environment file, то есть юнит, который не стартует; предупреждением это не
// является.
//
// `utf8_is_valid` отвергает встроенный NUL и всё, что не является Unicode
// scalar value, а `unichar_is_valid` (src/basic/utf8.c) сверх того отвергает:
//
// U+D800..U+DFFF суррогаты
// U+FDD0..U+FDEF noncharacters
// (cp & 0xFFFE) == 0xFFFE — U+FFFE, U+FFFF, U+1FFFE, … U+10FFFF
//
// Пока контракт этого не знал, пароль вроде `abcde﷐` — шесть символов,
// восемь байт, ни одного управляющего — проходил ВСЕ проверки продукта,
// записывался в /etc/hy2xs/hy2xs.env, и админка после этого не стартовала. Тот
// же класс дефекта, ради уничтожения которого контракт и существует, только на
// один слой ниже.
//
// Публичная документация systemd также запрещает U+FEFF. Реализация v257.13
// случайно пропускает его из-за маски в `unichar_is_valid`; продукт следует
// документированному контракту, а не этому расхождению реализации.
func isUnicodeNoncharacter(r rune) bool {
if r >= 0xFDD0 && r <= 0xFDEF {
return true
}
return r&0xFFFE == 0xFFFE
}
// IsEnvTransportableText сообщает, переживёт ли значение файл окружения.
//
// Это документированный ДОМЕН systemd, а не политика HY2XS. Продуктовые ограничения
// (границы длины, запрет управляющих символов) живут отдельно — иначе
// невозможно объяснить, какое из двух правил нарушено и чем это грозит.
func IsEnvTransportableText(value string) bool {
// Невалидный UTF-8 сюда попадает не только из файла: Go-строка — это
// произвольные байты, и `[]byte` из внешнего источника может оказаться
// чем угодно. Проверка идёт ПЕРВОЙ, потому что `range` по невалидной строке
// молча отдаёт U+FFFD и скрыл бы причину.
if !utf8.ValidString(value) {
return false
}
for _, r := range value {
// NUL — валидный UTF-8, но границей строки в C; systemd отвергает его
// отдельной веткой (`embedded NUL`).
if r == 0 || r == 0xFEFF {
return false
}
if isUnicodeNoncharacter(r) {
return false
}
}
return true
}
// IsValidAdminPassword — единственное правило пароля администратора.
//
// Правило состоит из четырёх частей и НИ ОДНА из них не является набором
// символов:
//
// домен systemd — значение обязано быть загружаемым из EnvironmentFile
// (IsEnvTransportableText);
// 6-64 code points — граница, которую видит оператор;
// <= 72 UTF-8 bytes — граница, которую ставит bcrypt;
// Cc — продуктовая политика, см. hasForbiddenRune.
//
// Длина в СИМВОЛАХ, а не в байтах, потому что именно так её считает
// go-playground/validator (`utf8.RuneCountInString` в baked_in.go) и так её
// видит оператор: «пароль из 64 символов» обязано означать одно и то же для
// латиницы и для кириллицы.
//
// Предел в БАЙТАХ существует одновременно с ним, потому что это ограничение
// другого слоя — хранилища пароля, — и выразить его через символы нельзя: у
// 64 символов длина от 64 до 256 байт в зависимости от алфавита.
//
// Набор символов НЕ ограничивается ничем сверх перечисленного. Пароль назначает
// оператор — установкой через HY2XS_ADMIN_INITIAL_PASSWORD или формой смены, —
// и сервер нигде его набор не проверяет. Ограничение на форме входа не
// защищает ничего: значение всё равно сравнивается с хешем, — зато отвергает
// пароль, который сервер принял бы.
//
// Пробелы по краям — ЧАСТЬ пароля и не снимаются нигде: ни здесь, ни в
// нормализации DTO, ни при чтении bootstrap-окружения.
func IsValidAdminPassword(password string) bool {
// Домен транспорта проверяется ПЕРВЫМ: значение вне него не доедет до
// админки вовсе — systemd откажется загружать файл окружения, и юнит не
// стартует. Это отказ более грубого рода, чем нарушение границ длины.
if !IsEnvTransportableText(password) {
return false
}
runes := utf8.RuneCountInString(password)
if runes < AdminPasswordMinLength || runes > AdminPasswordMaxLength {
return false
}
if len(password) > AdminPasswordMaxBytes {
return false
}
return !hasForbiddenRune(password)
}
// hasForbiddenRune — продуктовая политика HY2XS поверх домена systemd.
//
// Запрещены УПРАВЛЯЮЩИЕ символы Unicode целиком, то есть категория Cc:
//
// U+0000..U+001F C0
// U+007F DEL
// U+0080..U+009F C1
//
// Раньше здесь стояли только C0 и DEL, а комментарий обещал «без управляющих
// символов» — то есть документация была шире кода ровно на C1. Проверяется
// теперь то, что обещано.
//
// U+FEFF здесь уже не проверяется: он принадлежит документированному домену
// EnvironmentFile и отвергается IsEnvTransportableText.
func hasForbiddenRune(value string) bool {
for _, r := range value {
if r < 0x20 || (r >= 0x7F && r <= 0x9F) {
return true
}
}
return false
}
+269
View File
@@ -0,0 +1,269 @@
package credential
import (
"strings"
"testing"
"unicode/utf8"
)
// Набор символов логина администратора закреплён ФАКТИЧЕСКИМ множеством.
//
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
// Действующая запись перечисляет эти символы явно и НЕ сужает множество: имя
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, и сужение правила
// означало бы, что установка с логином вроде `admin.ops` перестаёт пускать
// оператора в панель.
//
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
// классе символов уронит его, а не вход администратора на живом сервере.
func TestAdminUsernameCharsetIsUnchanged(t *testing.T) {
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
for _, symbol := range strings.Split(historical, "") {
candidate := "admin" + symbol
if !IsValidAdminUsername(candidate) {
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
}
}
for _, rejected := range []string{
"", // не задано
"admi", // короче шести символов
strings.Repeat("a", 33), // длиннее тридцати двух
"admin пробел", // пробел
"админ1", // кириллица
"admin\n1", // перевод строки
"admin'1", // апостроф вне набора
} {
if IsValidAdminUsername(rejected) {
t.Errorf("значение %q принято логином, ожидался отказ", rejected)
}
}
}
// Границы длины логина принадлежат ОДНОМУ правилу, и человекочитаемый набор
// обязан описывать тот же класс, что и регексп.
//
// Расхождение здесь тихо превращает сообщение об отказе в дезинформацию:
// оператор читает набор, которого правило не принимает.
func TestAdminUsernameBoundsAgreeWithRule(t *testing.T) {
if IsValidAdminUsername(strings.Repeat("a", AdminUsernameMinLength-1)) {
t.Errorf("логин длиной %d принят при минимуме %d", AdminUsernameMinLength-1, AdminUsernameMinLength)
}
if !IsValidAdminUsername(strings.Repeat("a", AdminUsernameMinLength)) {
t.Errorf("логин длиной ровно %d отклонён", AdminUsernameMinLength)
}
if !IsValidAdminUsername(strings.Repeat("a", AdminUsernameMaxLength)) {
t.Errorf("логин длиной ровно %d отклонён", AdminUsernameMaxLength)
}
if IsValidAdminUsername(strings.Repeat("a", AdminUsernameMaxLength+1)) {
t.Errorf("логин длиной %d принят при максимуме %d", AdminUsernameMaxLength+1, AdminUsernameMaxLength)
}
// Человекочитаемый набор перечисляет ровно те же знаки препинания, что и
// класс регекспа. Пробелы в нём — разделители групп, а не допустимый символ.
punctuation := strings.Fields(AdminUsernameCharset)
for _, symbol := range strings.Split(punctuation[len(punctuation)-1], "") {
if !IsValidAdminUsername("admin" + symbol) {
t.Errorf("набор из сообщения обещает символ %q, но правило его не принимает", symbol)
}
}
}
// Длина пароля считается В СИМВОЛАХ, а предел bcrypt — В БАЙТАХ, и это разные
// границы.
//
// Здесь был дефект. Верхняя граница в 64 символа объявлялась «заведомо ниже 72
// байт», и это верно ТОЛЬКО для ASCII. Тест, закреплявший поведение, вдобавок
// требовал приёма 64 кириллических букв — то есть 128 байт — и тем самым
// фиксировал как ожидаемое ровно то значение, на котором продукт ломался:
// оркестратор его пропускал, DTO пропускало, а bcrypt отвечал
// ErrPasswordTooLong уже при создании учётной записи.
func TestAdminPasswordBoundaries(t *testing.T) {
// Проверка самой таблицы: если «я» перестанет быть двухбайтовой, а «😀» —
// четырёхбайтовым, случаи ниже проверяли бы не то, что написано.
if got := len(strings.Repeat("я", 36)); got != AdminPasswordMaxBytes {
t.Fatalf("подготовка: 36 x «я» = %d байт, ожидалось %d", got, AdminPasswordMaxBytes)
}
if got := len(strings.Repeat("😀", 18)); got != AdminPasswordMaxBytes {
t.Fatalf("подготовка: 18 x «😀» = %d байт, ожидалось %d", got, AdminPasswordMaxBytes)
}
cases := []struct {
name string
password string
accepted bool
}{
{"ровно минимум", strings.Repeat("a", AdminPasswordMinLength), true},
{"на символ короче минимума", strings.Repeat("a", AdminPasswordMinLength-1), false},
{"ровно максимум в ASCII", strings.Repeat("a", AdminPasswordMaxLength), true},
{"на символ длиннее максимума", strings.Repeat("a", AdminPasswordMaxLength+1), false},
// Граница bcrypt проходит ВНУТРИ разрешённого диапазона символов.
{"36 x «я» = 72 байта", strings.Repeat("я", 36), true},
{"37 x «я» = 74 байта", strings.Repeat("я", 37), false},
{"18 x «😀» = 72 байта", strings.Repeat("😀", 18), true},
{"19 x «😀» = 76 байт", strings.Repeat("😀", 19), false},
// Исходный дефект: 64 символа, 128 байт. Прежний тест требовал ПРИЁМА.
{"64 x «я» = 128 байт", strings.Repeat("я", AdminPasswordMaxLength), false},
// Пробелы по краям — часть пароля, а не мусор ввода.
{"пробел на конце", "abcde ", true},
{"пробел в начале", " abcde", true},
{"только пробелы", strings.Repeat(" ", AdminPasswordMinLength), true},
// Набор символов не ограничен ничем, кроме управляющих.
{"кириллица", "пароль-администратора", true},
{"знаки препинания", `p@$$w0rd"\'#;`, true},
{"перевод строки", "abcde\n", false},
{"возврат каретки", "abcde\r", false},
{"табуляция", "abcde\t", false},
{"нулевой байт", "abcde\x00", false},
{"DEL", "abcde\x7f", false},
// C1: раньше документация обещала «без управляющих символов», а код
// проверял только C0 и DEL — то есть обещание было шире проверки.
{"C1 NEL", "abcde" + string(rune(0x85)), false},
{"C1 верхняя граница", "abcde" + string(rune(0x9F)), false},
// Домен systemd. Каждое из этих значений — шесть символов, ни одного
// управляющего, укладывается в 72 байта, — и при этом делает
// /etc/hy2xs/hy2xs.env НЕзагружаемым: `check_utf8ness_and_warn`
// отвечает -EINVAL, и юнит hy2xs-admin не стартует.
{"noncharacter U+FDD0", "abcde" + string(rune(0xFDD0)), false},
{"noncharacter U+FDEF", "abcde" + string(rune(0xFDEF)), false},
{"noncharacter U+FFFE", "abcde" + string(rune(0xFFFE)), false},
{"noncharacter U+FFFF", "abcde" + string(rune(0xFFFF)), false},
{"noncharacter U+1FFFF", "abcde" + string(rune(0x1FFFF)), false},
{"noncharacter U+10FFFF", "abcde" + string(rune(0x10FFFF)), false},
{"невалидный UTF-8", "abcde\xff", false},
// Соседи noncharacters приниматься обязаны: правило описывает ровно
// множество systemd, а не «всё подозрительное рядом».
{"U+FDCF рядом с диапазоном", "abcde" + string(rune(0xFDCF)), true},
{"U+FDF0 рядом с диапазоном", "abcde" + string(rune(0xFDF0)), true},
{"U+FFFD (replacement)", "abcde" + string(rune(0xFFFD)), true},
// U+FEFF отвергается документированным транспортным доменом systemd.
{"U+FEFF", "abcde" + string(rune(0xFEFF)), false},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
if got := IsValidAdminPassword(testCase.password); got != testCase.accepted {
t.Errorf(
"IsValidAdminPassword = %v, ожидалось %v (%d символов, %d байт)",
got, testCase.accepted,
utf8.RuneCountInString(testCase.password), len(testCase.password),
)
}
})
}
}
// Предел в байтах не выводится из предела в символах, и обратное неверно тоже.
//
// Проверка защищает от «упрощения», при котором одну из двух констант объявят
// производной от другой: у 64 символов длина от 64 до 256 байт.
func TestAdminPasswordLimitsAreIndependent(t *testing.T) {
if AdminPasswordMaxBytes != 72 {
t.Fatalf(
"предел bcrypt изменён на %d: GenerateFromPassword отвечает ErrPasswordTooLong строго при len > 72",
AdminPasswordMaxBytes,
)
}
// Пароль, укладывающийся в символы и не укладывающийся в байты, обязан
// существовать — иначе байтовая граница недостижима и ничего не проверяет.
long := strings.Repeat("я", AdminPasswordMaxLength)
if utf8.RuneCountInString(long) > AdminPasswordMaxLength {
t.Fatal("подготовка: значение длиннее предела в символах, проверяется не та граница")
}
if IsValidAdminPassword(long) {
t.Fatal("пароль в 128 байт принят: байтовая граница не проверяется")
}
}
// Домен транспорта повторяет публично документированное множество systemd.
//
// Шире означало бы собственный запрет сверх публичного контракта; уже — что
// значение проходит все двери продукта вопреки документации EnvironmentFile.
// Второе и было дефектом: контракт знал про управляющие символы и не знал про
// noncharacters.
//
// Границы взяты из `unichar_is_valid` (systemd, src/basic/utf8.c) и проверяются
// вместе с соседями: правило обязано отвергать ровно диапазон, а не окрестность.
func TestEnvTransportDomainMatchesDocumentedSystemdContract(t *testing.T) {
rejected := []rune{
0x0000, 0xFEFF, // NUL и BOM запрещены публичным контрактом
0xFDD0, 0xFDEF, // U+FDD0..U+FDEF
0xFFFE, 0xFFFF,
0x1FFFE, 0x1FFFF,
0x10FFFE, 0x10FFFF,
}
for _, r := range rejected {
if IsEnvTransportableText(string(r)) {
t.Errorf("U+%04X принят вопреки документированному контракту EnvironmentFile", r)
}
}
accepted := []rune{
0x0020, 0x007F, 0x0085, // управляющие формат НЕСЁТ; их запрещает политика продукта
0xFDCF, 0xFDF0, // соседи зарезервированного диапазона
0xFFFD, // replacement character — обычный символ
0x10FFFD, // последний scalar value, не noncharacter
0x1F600, // 😀
}
for _, r := range accepted {
if !IsEnvTransportableText(string(r)) {
t.Errorf("U+%04X отвергнут: правило шире документированного множества systemd", r)
}
}
// Невалидный UTF-8 — не «строка со странными символами», а байты, которые
// systemd отвергает первой же проверкой.
for _, invalid := range []string{"\xff", "\xc3", "abc\xed\xa0\x80"} {
if IsEnvTransportableText(invalid) {
t.Errorf("невалидный UTF-8 %q принят", invalid)
}
}
}
// Продуктовая политика и домен systemd — РАЗНЫЕ множества, и это видно.
//
// Пока они были склеены, невозможно было ни объяснить отказ, ни заметить, что
// одно из двух правил отсутствует.
func TestProductPolicyIsWiderThanTransportDomain(t *testing.T) {
// Управляющие символы формат несёт — их запрещает HY2XS.
for _, r := range []rune{0x0A, 0x0D, 0x09, 0x7F, 0x85, 0x9F} {
password := "abcde" + string(r)
if !IsEnvTransportableText(password) && r != 0x00 {
t.Errorf("U+%04X: ожидалось, что формат его несёт", r)
}
if IsValidAdminPassword(password) {
t.Errorf("U+%04X принят паролем вопреки политике продукта", r)
}
}
if IsEnvTransportableText("abcde" + string(rune(0xFEFF))) {
t.Error("U+FEFF принят вопреки документированному контракту EnvironmentFile")
}
}
// Пароль НЕ триммится нигде, включая сам контракт.
//
// Пока `util.HashPassword` вёл собственную проверку через strings.TrimSpace,
// значение `"abcde "` считалось корректным всеми дверями продукта и не могло
// быть захешировано.
func TestAdminPasswordKeepsSurroundingWhitespace(t *testing.T) {
// Шесть символов, из которых последний — пробел. По длине без пробела это
// пять символов, то есть прежняя проверка отказала бы.
const password = "abcde "
if utf8.RuneCountInString(strings.TrimSpace(password)) >= AdminPasswordMinLength {
t.Fatal("подготовка: значение проходит и после тримминга, проверяется не то")
}
if !IsValidAdminPassword(password) {
t.Error("пароль с краевым пробелом отклонён: пробел объявлен частью значения")
}
}
+21 -6
View File
@@ -4,7 +4,6 @@ import (
"errors"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
"time"
)
@@ -12,19 +11,35 @@ import (
func GetAdminUser(query interface{}, args ...interface{}) (entity.AdminUser, error) {
var admin entity.AdminUser
if tx := sqliteDB.Model(&entity.AdminUser{}).Where(query, args...).First(&admin); tx.Error != nil {
if tx.Error == gorm.ErrRecordNotFound {
return admin, errors.New(constant.WrongPassword)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return admin, ErrAdminUserNotFound
}
logrus.Errorf("%v", tx.Error)
return admin, errors.New(constant.SysError)
return admin, ErrStorage
}
return admin, nil
}
// CountAdminUsers — сколько учётных записей администратора существует.
//
// Продукт допускает ровно одну, и это ЕДИНСТВЕННОЕ место, где такой вопрос
// можно задать: `GetAdminUser` берёт First() и о наличии второй строки не
// сообщает. Именно поэтому появление дубликата (ветка создания, выбранная при
// отказе чтения) было ненаблюдаемым — вторая рабочая учётка с уже напечатанным
// на экран паролем просто существовала, и никакой запрос об этом не говорил.
func CountAdminUsers() (int64, error) {
var count int64
if tx := sqliteDB.Model(&entity.AdminUser{}).Count(&count); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return 0, ErrStorage
}
return count, nil
}
func SaveAdminUser(admin entity.AdminUser) (int64, error) {
if tx := sqliteDB.Save(&admin); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return 0, errors.New(constant.SysError)
return 0, ErrStorage
}
return *admin.Id, nil
}
@@ -36,7 +51,7 @@ func UpdateAdminUser(ids []int64, updates map[string]interface{}) error {
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
if tx := sqliteDB.Model(&entity.AdminUser{}).Where("id in ?", ids).Updates(updates); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
return ErrStorage
}
return nil
}
+104
View File
@@ -0,0 +1,104 @@
package dao
import (
"path/filepath"
"strings"
"testing"
"hy2xs-admin/credential"
"hy2xs-admin/util"
)
// Bootstrap-путь администратора обязан подчиняться ОБЩЕМУ контракту пароля.
//
// Здесь было два расхождения, и оба ломали установку молча.
//
// Первое: `strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))`. Продукт
// объявляет пробел по краям частью пароля и не снимает его ни на форме входа,
// ни при смене, ни в оркестраторе — а здесь снимал. Учётная запись создавалась
// с паролем, отличным от того, который оператор записал в hy2xs.env и которым
// он потом пытался войти.
//
// Второе: контракт не проверялся вовсе, поэтому непригодный для bcrypt пароль
// (64 кириллические буквы = 128 байт) доходил до GenerateFromPassword, и старт
// службы падал с «bcrypt: password length exceeds 72 bytes» — сообщением, по
// которому нельзя понять ни что чинить, ни где.
func startAdminBootstrap(t *testing.T, password string) error {
t.Helper()
t.Setenv("HY2XS_ADMIN_USER", "hy2xsadmin")
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", password)
t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret)
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken)
err := InitSqlAt(filepath.Join(t.TempDir(), "hy2xs-admin-test.db"))
t.Cleanup(func() { _ = CloseSqliteDB() })
return err
}
// Пароль с краевым пробелом создаёт учётную запись С ЭТИМ пробелом.
func TestBootstrapAdminKeepsSurroundingWhitespaceInPassword(t *testing.T) {
const password = "bootstrap-admin-password "
if err := startAdminBootstrap(t, password); err != nil {
t.Fatalf("запуск сервиса не удался: %v", err)
}
admin, err := GetAdminUser("username = ?", "hy2xsadmin")
if err != nil {
t.Fatalf("учётная запись администратора не создана: %v", err)
}
if admin.PasswordHash == nil {
t.Fatal("у созданной учётной записи нет хеша пароля")
}
if !util.VerifyPassword(password, *admin.PasswordHash) {
t.Error("вход исходным значением невозможен: пароль обрезан при создании учётной записи")
}
if util.VerifyPassword(strings.TrimSpace(password), *admin.PasswordHash) {
t.Error("вход обрезанным значением возможен: пробел по краям снят вопреки контракту")
}
}
// Пароль, не проходящий контракт, роняет старт с ПРИЧИНОЙ, а не с ошибкой
// bcrypt, и учётной записи после этого не остаётся.
func TestBootstrapAdminRefusesPasswordOutsideTheContract(t *testing.T) {
// 64 символа — в границе; 128 байт — за пределом bcrypt.
password := strings.Repeat("я", credential.AdminPasswordMaxLength)
if credential.IsValidAdminPassword(password) {
t.Fatal("подготовка: контракт принимает значение, проверяется не то")
}
err := startAdminBootstrap(t, password)
if err == nil {
t.Fatal("старт с непригодным паролем завершился успешно")
}
if !strings.Contains(err.Error(), "HY2XS_ADMIN_INITIAL_PASSWORD") {
t.Errorf("отказ не называет переменную, которую надо чинить: %v", err)
}
if !strings.Contains(err.Error(), "hy2xs.env") {
t.Errorf("отказ не называет файл, в котором чинится причина: %v", err)
}
}
// Пароль ровно в предел bcrypt (72 байта) — законное значение, и установка с
// ним обязана проходить целиком.
func TestBootstrapAdminAcceptsPasswordAtBcryptByteLimit(t *testing.T) {
password := strings.Repeat("я", 36)
if len(password) != credential.AdminPasswordMaxBytes {
t.Fatalf("подготовка: %d байт вместо %d", len(password), credential.AdminPasswordMaxBytes)
}
if err := startAdminBootstrap(t, password); err != nil {
t.Fatalf("установка с паролем в 72 байта не удалась: %v", err)
}
admin, err := GetAdminUser("username = ?", "hy2xsadmin")
if err != nil {
t.Fatalf("учётная запись администратора не создана: %v", err)
}
if !util.VerifyPassword(password, *admin.PasswordHash) {
t.Error("пароль в 72 байта не проходит проверку собственным хешем")
}
}
+330
View File
@@ -0,0 +1,330 @@
package dao
import (
"path/filepath"
"strings"
"testing"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
)
const (
testAdminPassword = "bootstrap-admin-password"
testBootstrapSecret = "bootstrap-peer-secret-value"
testTrafficStatsToken = "traffic-stats-machine-token"
)
// startService воспроизводит запуск сервиса на указанной базе.
//
// Именно последовательность целиком, а не отдельная функция: дефект, ради
// которого написаны эти тесты, проявлялся только на ВТОРОМ запуске.
func startService(t *testing.T, dbPath string) error {
t.Helper()
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword)
t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret)
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken)
err := InitSqlAt(dbPath)
t.Cleanup(func() { _ = CloseSqliteDB() })
return err
}
func newInstalledService(t *testing.T) string {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
if err := startService(t, dbPath); err != nil {
t.Fatalf("первый запуск сервиса не удался: %v", err)
}
return dbPath
}
func testPeer(name string, authID string, digest string) entity.Peer {
remark := ""
cipher := ""
quota := int64(-1)
zero := int64(0)
maxDevices := int64(3)
return entity.Peer{
Name: &name,
Remark: &remark,
AuthId: &authID,
SecretDigest: &digest,
SecretEncrypted: &cipher,
QuotaBytes: &quota,
DownloadBytes: &zero,
UploadBytes: &zero,
ExpiresAt: &zero,
MaxDevices: &maxDevices,
Disabled: &zero,
BannedUntil: &zero,
LastConnectionAt: &zero,
}
}
func bootstrapPeerExists(t *testing.T) bool {
t.Helper()
_, err := GetPeer("name = ?", BootstrapPeerName)
if err == nil {
return true
}
if !IsNotFound(err) {
t.Fatalf("неожиданная ошибка при чтении пира установщика: %v", err)
}
return false
}
// Установка создаёт пира установщика и отмечает это в базе.
func TestBootstrapPeerIsSeededOnFirstStart(t *testing.T) {
newInstalledService(t)
if !bootstrapPeerExists(t) {
t.Fatal("пир установщика не создан при первом запуске")
}
seeded, err := bootstrapPeerSeeded()
if err != nil {
t.Fatalf("чтение отметки: %v", err)
}
if !seeded {
t.Fatal("отметка о создании пира установщика не выставлена")
}
}
// Обычный перезапуск сервиса ничего не меняет: пир на месте, дублей нет.
func TestBootstrapPeerSurvivesRestartUntouched(t *testing.T) {
dbPath := newInstalledService(t)
before, err := GetPeer("name = ?", BootstrapPeerName)
if err != nil {
t.Fatalf("пир установщика не найден после установки: %v", err)
}
_ = CloseSqliteDB()
if err := startService(t, dbPath); err != nil {
t.Fatalf("перезапуск сервиса не удался: %v", err)
}
after, err := GetPeer("name = ?", BootstrapPeerName)
if err != nil {
t.Fatalf("пир установщика пропал после перезапуска: %v", err)
}
if *after.Id != *before.Id {
t.Fatalf("пир установщика пересоздан: id %d -> %d", *before.Id, *after.Id)
}
peers, err := ListPeer("name = ?", BootstrapPeerName)
if err != nil {
t.Fatalf("список пиров: %v", err)
}
if len(peers) != 1 {
t.Fatalf("после перезапуска пиров установщика %d, ожидался ровно один", len(peers))
}
}
// Главный инвариант: удаление пира установщика — необратимый отзыв доступа.
//
// Раньше HY2XS_ADMIN_CON_PASS оставался в /etc/hy2xs/hy2xs.env, и признаком
// «создавать или нет» служило наличие строки в таблице. Поэтому
// `systemctl restart hy2xs-admin` после удаления возвращал того же пира с тем
// же секретом, молча, и отозванные учётные данные снова становились
// действующими.
func TestDeletedBootstrapPeerIsNotRecreatedOnRestart(t *testing.T) {
dbPath := newInstalledService(t)
peer, err := GetPeer("name = ?", BootstrapPeerName)
if err != nil {
t.Fatalf("пир установщика не найден после установки: %v", err)
}
if err := DeletePeer([]int64{*peer.Id}); err != nil {
t.Fatalf("удаление пира установщика отклонено: %v", err)
}
_ = CloseSqliteDB()
// Переменная окружения на месте — ровно то состояние, в котором сервис и
// живёт после установки.
if err := startService(t, dbPath); err != nil {
t.Fatalf("перезапуск сервиса после отзыва не удался: %v", err)
}
if bootstrapPeerExists(t) {
t.Fatal("пир установщика воскрес после перезапуска: отзыв доступа не переживает restart")
}
}
// Отзыв обязан пережить не один перезапуск, а любое их число.
func TestDeletedBootstrapPeerStaysGoneAcrossRepeatedRestarts(t *testing.T) {
dbPath := newInstalledService(t)
peer, err := GetPeer("name = ?", BootstrapPeerName)
if err != nil {
t.Fatalf("пир установщика не найден: %v", err)
}
if err := DeletePeer([]int64{*peer.Id}); err != nil {
t.Fatalf("удаление: %v", err)
}
_ = CloseSqliteDB()
for i := 0; i < 3; i++ {
if err := startService(t, dbPath); err != nil {
t.Fatalf("перезапуск #%d не удался: %v", i+1, err)
}
if bootstrapPeerExists(t) {
t.Fatalf("пир установщика воскрес на перезапуске #%d", i+1)
}
_ = CloseSqliteDB()
}
}
// Отключённый пир остаётся в базе и не переподписывается сам собой.
func TestDisabledBootstrapPeerKeepsItsStateAcrossRestart(t *testing.T) {
dbPath := newInstalledService(t)
peer, err := GetPeer("name = ?", BootstrapPeerName)
if err != nil {
t.Fatalf("пир установщика не найден: %v", err)
}
if err := UpdatePeer([]int64{*peer.Id}, map[string]interface{}{"disabled": 1}); err != nil {
t.Fatalf("отключение пира установщика: %v", err)
}
_ = CloseSqliteDB()
if err := startService(t, dbPath); err != nil {
t.Fatalf("перезапуск: %v", err)
}
after, err := GetPeer("name = ?", BootstrapPeerName)
if err != nil {
t.Fatalf("пир установщика пропал: %v", err)
}
if after.Disabled == nil || *after.Disabled != 1 {
t.Fatal("перезапуск снял отключение с пира установщика")
}
if *after.SecretDigest != *peer.SecretDigest {
t.Fatal("перезапуск переподписал пира установщика")
}
}
// Отсутствие HY2XS_ADMIN_CON_PASS на чистой базе — отказ старта, а не повод
// придумать секрет: он продублирован в /etc/hy2xs/bootstrap-admin.secret.
func TestFirstStartWithoutBootstrapSecretFails(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword)
t.Setenv("HY2XS_ADMIN_CON_PASS", "")
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken)
err := InitSqlAt(dbPath)
t.Cleanup(func() { _ = CloseSqliteDB() })
if err == nil {
t.Fatal("запуск без HY2XS_ADMIN_CON_PASS не отклонён")
}
if !strings.Contains(err.Error(), "HY2XS_ADMIN_CON_PASS") {
t.Errorf("отказ не называет переменную: %v", err)
}
}
// Перезапуск уже установленного сервиса без переменной обязан работать: пир
// создан однажды, и требовать секрет второй раз незачем.
func TestRestartWithoutBootstrapSecretSucceeds(t *testing.T) {
dbPath := newInstalledService(t)
_ = CloseSqliteDB()
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword)
t.Setenv("HY2XS_ADMIN_CON_PASS", "")
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken)
if err := InitSqlAt(dbPath); err != nil {
t.Fatalf("перезапуск установленного сервиса без HY2XS_ADMIN_CON_PASS отклонён: %v", err)
}
t.Cleanup(func() { _ = CloseSqliteDB() })
if !bootstrapPeerExists(t) {
t.Fatal("пир установщика пропал при перезапуске без переменной")
}
}
// Отметка и сам пир пишутся неделимо: иначе падение между двумя операциями
// вернуло бы прежнее поведение на следующем старте.
func TestBootstrapSeedIsAtomic(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
if err := InitSqliteDBAt(dbPath); err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = CloseSqliteDB() })
if err := RunMigrations(); err != nil {
t.Fatalf("миграции: %v", err)
}
// Занимаем имя обычной записью: следующая вставка упрётся в UNIQUE(name)
// уже ВНУТРИ транзакции. Отметка не имеет права остаться выставленной.
if _, err := SavePeer(testPeer(BootstrapPeerName, "some-auth-id", "some-digest")); err != nil {
t.Fatalf("подготовка конфликтующей записи: %v", err)
}
if err := seedBootstrapPeerOnce(testPeer(BootstrapPeerName, BootstrapPeerName, "bootstrap-digest")); err == nil {
t.Fatal("вставка с конфликтом UNIQUE(name) не отклонена")
}
seeded, err := bootstrapPeerSeeded()
if err != nil {
t.Fatalf("чтение отметки: %v", err)
}
if seeded {
t.Fatal("отметка выставлена, хотя пир не записан: транзакции нет")
}
}
// Токен trafficStats — межкомпонентный контракт: при пустом env и пустой базе
// админка обязана отказать, а не сгенерировать значение, которого не знает
// Hysteria.
func TestTrafficStatsSecretIsNotInvented(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword)
t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret)
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", "")
err := InitSqlAt(dbPath)
t.Cleanup(func() { _ = CloseSqliteDB() })
if err == nil {
t.Fatal("запуск без machine token не отклонён: админка придумала его сама")
}
if !strings.Contains(err.Error(), "HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET") {
t.Errorf("отказ не называет переменную: %v", err)
}
cfg, cfgErr := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
if cfgErr == nil && cfg.Value != nil && strings.TrimSpace(*cfg.Value) != "" {
t.Fatal("в базе появился сгенерированный machine token")
}
}
// Уже согласованный токен в базе позволяет запуститься без переменной: это не
// изобретение контракта, а использование существующего.
func TestTrafficStatsSecretFromDatabaseIsAccepted(t *testing.T) {
dbPath := newInstalledService(t)
_ = CloseSqliteDB()
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword)
t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret)
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", "")
if err := InitSqlAt(dbPath); err != nil {
t.Fatalf("перезапуск с токеном в базе отклонён: %v", err)
}
t.Cleanup(func() { _ = CloseSqliteDB() })
cfg, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
if err != nil {
t.Fatalf("токен пропал из базы: %v", err)
}
if strings.TrimSpace(*cfg.Value) != testTrafficStatsToken {
t.Fatalf("токен подменён: %q", *cfg.Value)
}
}
+142 -28
View File
@@ -2,31 +2,156 @@ package dao
import (
"errors"
"fmt"
"strings"
"time"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
"time"
)
func SaveConfig(config entity.Config) (int64, error) {
if tx := sqliteDB.Save(&config); tx.Error != nil {
return saveConfigOn(sqliteDB, config)
}
func saveConfigOn(db *gorm.DB, config entity.Config) (int64, error) {
if tx := db.Save(&config); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return 0, errors.New(constant.SysError)
return 0, ErrStorage
}
return *config.Id, nil
}
func UpdateConfig(keys []string, updates map[string]interface{}) error {
if len(updates) > 0 {
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
if tx := sqliteDB.Model(&entity.Config{}).
Where("key in ?", keys).
Updates(updates); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
return updateConfigOn(sqliteDB, keys, updates)
}
// UpsertConfigValue записывает значение ключа, создавая строку, если её нет.
//
// Заменяет две зеркальные самодельные схемы, которые жили в sqlite.go и обе
// были неверны:
//
// 1. «сначала INSERT, при ошибке UPDATE» — работало, но на КАЖДОЙ первой
// загрузке печатало в журнал `duplicated key not allowed` уровня error, по
// одной строке на секрет. Оператор видел три ошибки на совершенно здоровом
// старте и не имел способа отличить их от настоящих;
//
// 2. «сначала UPDATE, при ошибке INSERT» — тихо не делало НИЧЕГО, если строки
// не было. UPDATE без совпавших строк не является ошибкой: gorm возвращает
// nil и RowsAffected = 0, поэтому ветка INSERT не выполнялась, а вызывающий
// получал сгенерированный секрет как успешно сохранённый. Для JWT_SECRET
// это означало бы подпись токенов ключом, которого нет в базе. Спасала
// только предварительная вставка пустых строк в seedBaseConfig — то есть
// корректность держалась на порядке выполнения миграций.
//
// Здесь решение принимается по RowsAffected, а не по наличию ошибки.
func UpsertConfigValue(key string, value string) error {
if sqliteDB == nil {
return errors.New("sqlite is not initialized")
}
return upsertConfigValueOn(sqliteDB, key, value)
}
// upsertConfigValueOn — та же запись, но по переданному соединению.
//
// Нужна для операций, которые обязаны быть неделимыми вместе с записью в другую
// таблицу: пир установщика и отметка о его создании (см. SeedBootstrapPeerOnce).
func upsertConfigValueOn(db *gorm.DB, key string, value string) error {
tx := db.Model(&entity.Config{}).
Where("key = ?", key).
Updates(map[string]interface{}{
"value": value,
"update_time": time.Now().Format("2006-01-02 15:04:05"),
})
if tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return ErrStorage
}
if tx.RowsAffected > 0 {
return nil
}
remark := key
if _, err := saveConfigOn(db, entity.Config{Key: &key, Value: &value, Remark: &remark}); err != nil {
return err
}
return nil
}
// ConfigTx — записи в таблицу `config` в границах одной единицы работы.
//
// Интерфейс повторяет PeerTx и по той же причине: транзакция должна быть
// доступна слою service, а *gorm.DB туда протекать не должен. Реализация одна
// и та же для глобального соединения и для транзакции, поэтому «обычный» и
// «транзакционный» пути не могут разойтись в поведении.
type ConfigTx interface {
UpdateConfig(keys []string, updates map[string]interface{}) error
}
type configTx struct {
db *gorm.DB
}
// WithConfigTx выполняет fn в одной транзакции: либо применяются все записи
// партии, либо ни одной.
//
// Что было. updateConfigs валидировал и записывал настройки в одном цикле:
// проверил элемент — записал, проверил следующий. Партия из разрешённого
// ключа и запрещённого применяла первый и возвращала ошибку на втором, то есть
// оператор получал отказ на запрос, который систему уже изменил. Тест на этот
// случай существовал, но ставил запрещённый ключ ПЕРВЫМ и не смотрел в базу,
// поэтому поймать частичное применение был неспособен по построению.
func WithConfigTx(fn func(tx ConfigTx) error) error {
if sqliteDB == nil {
return errors.New("sqlite is not initialized")
}
return sqliteDB.Transaction(func(tx *gorm.DB) error {
return fn(configTx{db: tx})
})
}
func (c configTx) UpdateConfig(keys []string, updates map[string]interface{}) error {
return updateConfigOn(c.db, keys, updates)
}
// updateConfigOn обновляет существующие строки и отказывает, если обновлять
// было нечего.
//
// UPDATE без совпавших строк — не ошибка SQL: gorm возвращает nil и
// RowsAffected = 0. Пока результат определялся только по tx.Error, отсутствие
// строки выглядело успешной записью, и это не теоретическая аккуратность.
// Путь оператора выглядит так:
//
// controller.UpdateConfigs → service.UpdateConfigs (транзакция)
// → applyRuntimeConfigUpdates → RescheduleResetTraffic
//
// Применение к рантайму идёт ПОСЛЕ записи и полагается на её успех. При
// отсутствующей строке RESET_TRAFFIC_CRON планировщик получал новое расписание,
// API отвечал успехом, а база сохраняла старое значение — и ближайший
// перезапуск сервиса молча возвращал прежний cron. Оператор при этом дважды
// видел подтверждение изменения, которого не произошло.
//
// Здесь решение принимается по RowsAffected — ровно так же, как в
// UpsertConfigValue ниже, где эта же ошибка уже была разобрана и исправлена.
// Разница между двумя функциями одного файла и была настоящим дефектом.
func updateConfigOn(db *gorm.DB, keys []string, updates map[string]interface{}) error {
if len(updates) == 0 {
return nil
}
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
tx := db.Model(&entity.Config{}).
Where("key in ?", keys).
Updates(updates)
if tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return ErrStorage
}
if tx.RowsAffected == 0 {
return fmt.Errorf(
"config: не найдено ни одной строки для ключей %s; настройка не сохранена",
strings.Join(keys, ", "),
)
}
return nil
}
@@ -35,11 +160,11 @@ func GetConfig(query interface{}, args ...interface{}) (entity.Config, error) {
var config entity.Config
if tx := sqliteDB.Model(&entity.Config{}).
Where(query, args...).First(&config); tx.Error != nil {
if tx.Error == gorm.ErrRecordNotFound {
return config, errors.New(constant.ConfigNotExist)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return config, ErrConfigNotFound
}
logrus.Errorf("%v", tx.Error)
return config, errors.New(constant.SysError)
return config, ErrStorage
}
return config, nil
}
@@ -49,18 +174,7 @@ func ListConfig(query interface{}, args ...interface{}) ([]entity.Config, error)
if tx := sqliteDB.Model(&entity.Config{}).
Where(query, args...).Order("create_time desc").Find(&configs); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return configs, errors.New(constant.SysError)
return configs, ErrStorage
}
return configs, nil
}
func UpsertConfig(configs []entity.Config) error {
if tx := sqliteDB.Model(&entity.Config{}).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value", "remark", "create_time", "update_time"}),
}).Create(configs); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
return nil
}
+145
View File
@@ -0,0 +1,145 @@
package dao
import (
"errors"
"path/filepath"
"strings"
"testing"
"hy2xs-admin/model/constant"
)
func newConfigDB(t *testing.T) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
if err := InitSqliteDBAt(dbPath); err != nil {
t.Fatalf("не удалось открыть тестовую базу: %v", err)
}
if err := RunMigrations(); err != nil {
t.Fatalf("не удалось применить миграции: %v", err)
}
t.Cleanup(func() { _ = CloseSqliteDB() })
}
// Запись в отсутствующую строку — отказ, а не тихий успех.
//
// UPDATE без совпавших строк не является ошибкой SQL: gorm возвращает nil и
// RowsAffected = 0. Пока результат определялся только по tx.Error, оператор
// получал подтверждение изменения, которого не произошло, а
// applyRuntimeConfigUpdates следом применял новое значение к планировщику —
// то есть рантайм и база расходились до ближайшего перезапуска сервиса.
func TestUpdateConfigFailsWhenRowIsMissing(t *testing.T) {
newConfigDB(t)
if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", constant.ResetTrafficCron); tx.Error != nil {
t.Fatalf("подготовка повреждённой базы: %v", tx.Error)
}
err := UpdateConfig([]string{constant.ResetTrafficCron}, map[string]interface{}{"value": "@weekly"})
if err == nil {
t.Fatal("запись в отсутствующую строку выдана за успех")
}
if !strings.Contains(err.Error(), constant.ResetTrafficCron) {
t.Errorf("отказ не называет ключ: %v", err)
}
// UpdateConfig обновляет существующее и не имеет права заводить ключи:
// иначе он молча создавал бы строки, которых нет в модели продукта.
if _, err := GetConfig("key = ?", constant.ResetTrafficCron); !errors.Is(err, ErrConfigNotFound) {
t.Fatalf("строка появилась после отказа: %v", err)
}
}
// Обычная запись в существующую строку продолжает работать.
func TestUpdateConfigWritesExistingRow(t *testing.T) {
newConfigDB(t)
if err := UpdateConfig([]string{constant.ResetTrafficCron}, map[string]interface{}{"value": "@weekly"}); err != nil {
t.Fatalf("запись существующего ключа отклонена: %v", err)
}
cfg, err := GetConfig("key = ?", constant.ResetTrafficCron)
if err != nil {
t.Fatalf("чтение: %v", err)
}
if *cfg.Value != "@weekly" {
t.Fatalf("значение не сохранено: %q", *cfg.Value)
}
}
// Транзакционная запись обязана вести себя так же: отказ на отсутствующей
// строке откатывает всю партию, а не оставляет её наполовину применённой.
func TestConfigTxRollsBackWhenRowIsMissing(t *testing.T) {
newConfigDB(t)
if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", constant.JwtSecret); tx.Error != nil {
t.Fatalf("подготовка: %v", tx.Error)
}
err := WithConfigTx(func(tx ConfigTx) error {
if err := tx.UpdateConfig([]string{constant.ResetTrafficCron}, map[string]interface{}{"value": "@weekly"}); err != nil {
return err
}
return tx.UpdateConfig([]string{constant.JwtSecret}, map[string]interface{}{"value": "x"})
})
if err == nil {
t.Fatal("партия с отсутствующей строкой не отклонена")
}
cfg, getErr := GetConfig("key = ?", constant.ResetTrafficCron)
if getErr != nil {
t.Fatalf("чтение: %v", getErr)
}
if *cfg.Value == "@weekly" {
t.Fatal("первый ключ партии остался применённым после отказа на втором")
}
}
// Отсутствие записи и отказ хранилища — разные ответы.
func TestGetConfigReportsNotFoundDistinctly(t *testing.T) {
newConfigDB(t)
_, err := GetConfig("key = ?", "NO_SUCH_KEY")
if !errors.Is(err, ErrConfigNotFound) {
t.Fatalf("отсутствие ключа не распознано как ErrConfigNotFound: %v", err)
}
if errors.Is(err, ErrStorage) {
t.Fatal("отсутствие ключа выдано за отказ хранилища")
}
}
func TestGetPeerReportsNotFoundDistinctly(t *testing.T) {
newConfigDB(t)
_, err := GetPeer("name = ?", "no-such-peer")
if !errors.Is(err, ErrPeerNotFound) {
t.Fatalf("отсутствие пира не распознано как ErrPeerNotFound: %v", err)
}
if !IsNotFound(err) {
t.Fatal("IsNotFound не распознаёт ErrPeerNotFound")
}
if IsNotFound(ErrStorage) {
t.Fatal("IsNotFound принимает отказ хранилища за отсутствие записи")
}
}
// UpsertConfigValue, в отличие от UpdateConfig, строку заводит: это её работа.
func TestUpsertConfigValueCreatesMissingRow(t *testing.T) {
newConfigDB(t)
if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", constant.ResetTrafficCron); tx.Error != nil {
t.Fatalf("подготовка: %v", tx.Error)
}
if err := UpsertConfigValue(constant.ResetTrafficCron, "@daily"); err != nil {
t.Fatalf("upsert отклонён: %v", err)
}
cfg, err := GetConfig("key = ?", constant.ResetTrafficCron)
if err != nil {
t.Fatalf("строка не создана: %v", err)
}
if *cfg.Value != "@daily" {
t.Fatalf("значение не сохранено: %q", *cfg.Value)
}
}
+8 -10
View File
@@ -1,9 +1,7 @@
package dao
import (
"errors"
"github.com/sirupsen/logrus"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
"hy2xs-admin/model/vo"
"strings"
@@ -13,7 +11,7 @@ import (
func SaveMetricSample(sample entity.MetricSample) error {
if tx := sqliteDB.Save(&sample); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
return ErrStorage
}
return nil
}
@@ -22,7 +20,7 @@ func LastMetricSample() (entity.MetricSample, error) {
var sample entity.MetricSample
if tx := sqliteDB.Model(&entity.MetricSample{}).Order("sampled_at desc").Limit(1).Find(&sample); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return sample, errors.New(constant.SysError)
return sample, ErrStorage
}
return sample, nil
}
@@ -33,7 +31,7 @@ func CleanupMetricSample(olderThanMs int64) error {
}
if tx := sqliteDB.Exec("DELETE FROM metric_sample WHERE sampled_at < ?", olderThanMs); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
return ErrStorage
}
return nil
}
@@ -54,7 +52,7 @@ func DashboardPeerSummary(nowMs int64) (vo.DashboardPeerVo, error) {
COALESCE(SUM(CASE WHEN expires_at > 0 AND expires_at < ? THEN 1 ELSE 0 END),0) AS expired
FROM peer`, nowMs).Scan(&r); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return result, errors.New(constant.SysError)
return result, ErrStorage
}
result.Total = r.Total
result.Enabled = r.Enabled
@@ -75,7 +73,7 @@ func DashboardTrafficSummary() (vo.DashboardTrafficVo, error) {
COALESCE(SUM(upload_bytes),0) AS upload
FROM peer`).Scan(&r); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return result, errors.New(constant.SysError)
return result, ErrStorage
}
result.DownloadBytes = r.Download
result.UploadBytes = r.Upload
@@ -125,7 +123,7 @@ func DashboardTopPeers(fromMs int64, toMs int64, limit int, source string) ([]vo
ORDER BY total DESC
LIMIT ?`, fromMs, toMs, limit).Scan(&rows); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return rows, errors.New(constant.SysError)
return rows, ErrStorage
}
return rows, nil
}
@@ -164,7 +162,7 @@ func DashboardTrafficTimeseries(fromMs int64, toMs int64, bucketMs int64, source
GROUP BY ts
ORDER BY ts ASC`, alignedFrom, alignedFrom, bucketMs, bucketMs, queryFrom, toMs).Scan(&rows); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return rows, errors.New(constant.SysError)
return rows, ErrStorage
}
rowMap := make(map[int64]vo.DashboardSeriesPointVo, len(rows))
@@ -203,7 +201,7 @@ func DashboardSystemTimeseries(fromMs int64, toMs int64, bucketMs int64) ([]vo.D
GROUP BY ts
ORDER BY ts ASC`, alignedFrom, alignedFrom, bucketMs, bucketMs, fromMs, toMs).Scan(&rows); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return rows, errors.New(constant.SysError)
return rows, ErrStorage
}
return rows, nil
}
+56
View File
@@ -0,0 +1,56 @@
package dao
import (
"errors"
"hy2xs-admin/model/constant"
)
// Отличать «строки нет» от «база не ответила» обязан слой данных.
//
// Что было. Каждый Get* возвращал `errors.New(constant.WrongPassword)` для
// gorm.ErrRecordNotFound и `errors.New(constant.SysError)` для всего
// остального. Обе — свежие значения ошибки со строкой внутри, поэтому
// вызывающий мог отличить одно от другого только сравнением текста, чего никто
// не делал: везде проверялось `err != nil` или `err == nil`.
//
// Из-за этого сбой SQLite неотличим от отсутствия записи, а решения на этом
// принимаются серьёзные:
//
// ensureSecureBootstrapPeer — «пира нет» означает «создать пир заново»;
// applyPeerImportEntry — «по auth_id не нашли» означает «искать по имени»
// и, не найдя, СОЗДАТЬ запись;
// ExistPeerName — «ошибка» означает «имя свободно».
//
// То есть при недоступной базе продукт не отказывал, а трактовал отказ как
// разрешение действовать. Транзакция спасает целостность, но не смысл: решение
// уже принято неверно к моменту записи.
//
// Значения объявлены один раз и возвращаются как есть, поэтому работает
// errors.Is. Тексты сохранены прежними: WrongPassword уезжает в ответ Hysteria
// при неудачной machine-auth, и менять его здесь было бы изменением внешнего
// контракта ради внутренней аккуратности.
var (
// ErrPeerNotFound — пира с такими условиями в базе нет.
ErrPeerNotFound = errors.New(constant.WrongPassword)
// ErrAdminUserNotFound — учётной записи администратора нет.
ErrAdminUserNotFound = errors.New(constant.WrongPassword)
// ErrConfigNotFound — строки с таким ключом в таблице `config` нет.
ErrConfigNotFound = errors.New(constant.ConfigNotExist)
// ErrStorage — база не смогла ответить. Не «нет данных», а «неизвестно».
ErrStorage = errors.New(constant.SysError)
)
// IsNotFound — истина только для «записи нет», и никогда для отказа хранилища.
//
// Помощник существует, чтобы у вызывающих был ровно один способ задать этот
// вопрос: три разных sentinel'а для трёх таблиц иначе неизбежно проверялись бы
// по-разному в разных местах.
func IsNotFound(err error) bool {
return errors.Is(err, ErrPeerNotFound) ||
errors.Is(err, ErrAdminUserNotFound) ||
errors.Is(err, ErrConfigNotFound)
}
+88 -31
View File
@@ -5,57 +5,114 @@ import (
"fmt"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
"time"
)
func SavePeer(peer entity.Peer) (int64, error) {
if tx := sqliteDB.Save(&peer); tx.Error != nil {
// PeerTx — операции над пирами в границах одной единицы работы.
//
// Интерфейс существует, чтобы транзакция была доступна слою service, но
// *gorm.DB туда не протекал: выбор ORM остаётся деталью dao. Реализация одна и
// та же и для глобального соединения, и для транзакции — за счёт этого
// невозможна ситуация, когда «обычный» путь и «транзакционный» расходятся в
// поведении.
type PeerTx interface {
GetPeer(query interface{}, args ...interface{}) (entity.Peer, error)
SavePeer(peer entity.Peer) (int64, error)
UpdatePeer(ids []int64, updates map[string]interface{}) error
}
type peerTx struct {
db *gorm.DB
}
// WithPeerTx выполняет fn в одной транзакции: либо применяются все записи,
// либо ни одной.
//
// Что было. Импорт пиров валидировал партию целиком до первой записи, но сами
// записи шли по одной, каждая своим оператором. Комментарий обещал «либо
// целиком, либо никак», а на практике UNIQUE-конфликт на 37-й записи оставлял
// 36 применённых. Конфликт при этом совершенно реальный: если в базе есть
// A(auth_id=a, name=alice) и B(auth_id=b, name=bob), а импорт несёт
// (auth_id=a, name=bob), то поиск найдёт A по auth_id и попытается
// переименовать его в bob — прямо в UNIQUE(name).
func WithPeerTx(fn func(tx PeerTx) error) error {
if sqliteDB == nil {
return errors.New("sqlite is not initialized")
}
return sqliteDB.Transaction(func(tx *gorm.DB) error {
return fn(peerTx{db: tx})
})
}
func (p peerTx) SavePeer(peer entity.Peer) (int64, error) {
if tx := p.db.Save(&peer); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return 0, errors.New(constant.SysError)
return 0, ErrStorage
}
return *peer.Id, nil
}
func (p peerTx) UpdatePeer(ids []int64, updates map[string]interface{}) error {
if len(updates) == 0 {
return nil
}
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
if tx := p.db.Model(&entity.Peer{}).Where("id in ?", ids).Updates(updates); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return ErrStorage
}
return nil
}
// GetPeer различает «пира нет» и «база не ответила» разными значениями ошибки.
//
// Раньше оба случая возвращались как свежие errors.New, и вызывающий не имел
// способа их разделить иначе как сравнением текста. Разница принципиальна:
// «нет» разрешает создать запись, «неизвестно» — не разрешает ничего.
func (p peerTx) GetPeer(query interface{}, args ...interface{}) (entity.Peer, error) {
var peer entity.Peer
if tx := p.db.Model(&entity.Peer{}).Where(query, args...).First(&peer); tx.Error != nil {
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return peer, ErrPeerNotFound
}
logrus.Errorf("%v", tx.Error)
return peer, ErrStorage
}
return peer, nil
}
// global — операции вне транзакции. Одна и та же реализация, другое соединение.
func global() peerTx {
return peerTx{db: sqliteDB}
}
func SavePeer(peer entity.Peer) (int64, error) {
return global().SavePeer(peer)
}
func UpdatePeer(ids []int64, updates map[string]interface{}) error {
return global().UpdatePeer(ids, updates)
}
func GetPeer(query interface{}, args ...interface{}) (entity.Peer, error) {
return global().GetPeer(query, args...)
}
func DeletePeer(ids []int64) error {
if tx := sqliteDB.Where("id in ?", ids).Delete(&entity.Peer{}); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
return ErrStorage
}
return nil
}
func UpdatePeer(ids []int64, updates map[string]interface{}) error {
if len(updates) == 0 {
return nil
}
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
if tx := sqliteDB.Model(&entity.Peer{}).Where("id in ?", ids).Updates(updates); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
return nil
}
func GetPeer(query interface{}, args ...interface{}) (entity.Peer, error) {
var peer entity.Peer
if tx := sqliteDB.Model(&entity.Peer{}).Where(query, args...).First(&peer); tx.Error != nil {
if tx.Error == gorm.ErrRecordNotFound {
return peer, errors.New(constant.WrongPassword)
}
logrus.Errorf("%v", tx.Error)
return peer, errors.New(constant.SysError)
}
return peer, nil
}
func ListPeer(query interface{}, args ...interface{}) ([]entity.Peer, error) {
var peers []entity.Peer
if tx := sqliteDB.Model(&entity.Peer{}).Where(query, args...).Order("create_time desc").Find(&peers); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return peers, errors.New(constant.SysError)
return peers, ErrStorage
}
return peers, nil
}
@@ -76,7 +133,7 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]entity.Peer, int64, error) {
tx.Count(&total)
if tx.Scopes(Paginate(peerPageDto.PageNum, peerPageDto.PageSize)).Order("create_time desc").Find(&peers); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return peers, 0, errors.New(constant.SysError)
return peers, 0, ErrStorage
}
return peers, total, nil
}
+359 -279
View File
@@ -9,14 +9,12 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
"hy2xs-admin/credential"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
"hy2xs-admin/util"
"log"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
@@ -24,8 +22,17 @@ import (
var sqliteDB *gorm.DB
func InitSqliteDB() error {
return InitSqliteDBAt(constant.SqliteDBPath)
}
// InitSqliteDBAt открывает базу по указанному пути.
//
// Путь параметризован, а не зашит: без этого единственным способом проверить
// поведение слоя данных (транзакционность импорта пиров, в первую очередь)
// было бы обращение к реальной /var/lib/hy2xs-admin/hy2xs-admin.db.
func InitSqliteDBAt(path string) error {
var err error
sqliteDB, err = gorm.Open(sqlite.Open(constant.SqliteDBPath), &gorm.Config{
sqliteDB, err = gorm.Open(sqlite.Open(path), &gorm.Config{
TranslateError: true,
Logger: logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
@@ -48,19 +55,30 @@ func InitSqliteDB() error {
return nil
}
func InitSql(port string) error {
if err := InitSqliteDB(); err != nil {
// InitSql поднимает базу админки.
//
// Порт сюда больше не передаётся: раньше InitSql записывал в config тот самый
// порт, который получил аргументом из ExecStart, а startup тут же читал его
// обратно. База не была источником этого факта ни на одном шаге.
func InitSql() error {
return InitSqlAt(constant.SqliteDBPath)
}
// InitSqlAt поднимает базу по указанному пути.
//
// Путь параметризован по той же причине, что и у InitSqliteDBAt: жизненный
// цикл пира установщика — единственное, что доказывает необратимость отзыва
// доступа, и проверить его можно только прогнав ПОЛНУЮ последовательность
// запуска (миграции → seeder → повторный запуск) дважды на одной базе.
// Отдельная проверка ensureSecureBootstrapPeer в отрыве от InitSql доказывала
// бы поведение функции, а не поведение сервиса при перезапуске.
func InitSqlAt(path string) error {
if err := InitSqliteDBAt(path); err != nil {
return err
}
if err := runMigrations(); err != nil {
return err
}
if port != "" {
if tx := sqliteDB.Exec("UPDATE config set value = ? where key = 'H_UI_WEB_PORT'", port); tx.Error != nil {
logrus.Errorf("sqlite exec err: %v", tx.Error)
return errors.New("sqlite exec err")
}
}
if err := ensureSecureBootstrapAdmin(); err != nil {
return err
}
@@ -77,15 +95,6 @@ func InitSql(port string) error {
return nil
}
func envInt(name string, fallback int) int {
if raw := strings.TrimSpace(os.Getenv(name)); raw != "" {
if value, err := strconv.Atoi(raw); err == nil {
return value
}
}
return fallback
}
func envBoolAsInt(name string, fallback int) int {
raw := strings.TrimSpace(strings.ToLower(os.Getenv(name)))
if raw == "" {
@@ -100,6 +109,22 @@ func envBoolAsInt(name string, fallback int) int {
return fallback
}
// ensureSecureBootstrapAdmin создаёт первую учётную запись администратора.
//
// Пароль приходит ТОЛЬКО из HY2XS_ADMIN_INITIAL_PASSWORD, и его отсутствие —
// отказ старта, а не повод придумать пароль самостоятельно.
//
// Что было. При пустом env админка генерировала пароль сама и печатала его
// двумя logrus.Warnf — то есть открытым текстом в /var/log/hy2xs/hy2xs-admin.log.
// Этот же файл отдаётся оператору кнопкой выгрузки журнала и попадает в
// diagnostics-бандл, который люди пересылают в переписке. Получался постоянный
// канал утечки учётных данных администратора.
//
// Второе, менее очевидное: сгенерированный здесь пароль не знает НИКТО, кроме
// журнала. Оркестратор пишет свой в /etc/hy2xs/bootstrap-admin.secret и в
// hy2xs.env, и именно он считается контрактом установки. Значит, попадание
// сюда означает не «нужно что-то придумать», а повреждённый контракт запуска —
// и правильная реакция на него громкая, а не молчаливая.
func ensureSecureBootstrapAdmin() error {
adminUser := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_USER"))
if adminUser == "" {
@@ -109,16 +134,47 @@ func ensureSecureBootstrapAdmin() error {
return nil
}
adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))
passwordGenerated := false
// Пароль читается КАК ЕСТЬ.
//
// Здесь стоял strings.TrimSpace, и он противоречил контракту продукта:
// пробел по краям объявлен частью пароля и не снимается ни на форме входа,
// ни при смене пароля, ни в оркестраторе. То есть bootstrap-путь
// гарантированно создавал учётную запись НЕ с тем паролем, который оператор
// записал в hy2xs.env, а войти в неё он пытался бы исходным значением.
adminPassword := os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")
if adminPassword == "" {
password, pwdErr := util.RandomString(18)
if pwdErr != nil {
return pwdErr
}
adminPassword = password
passwordGenerated = true
return errors.New(
"HY2XS_ADMIN_INITIAL_PASSWORD не задан, а учётной записи администратора в базе нет.\n" +
"Это признак повреждённого контракта запуска: пароль первого администратора\n" +
"задаёт install-оркестратор через /etc/hy2xs/hy2xs.env, а копию кладёт в\n" +
"/etc/hy2xs/bootstrap-admin.secret. Придумывать его самостоятельно админка не\n" +
"будет: такой пароль не знал бы никто, кроме журнала.\n" +
"Проверьте EnvironmentFile в юните hy2xs-admin и запустите\n" +
"`hy2xs-orchestrator repair --allow-partial-state`.",
)
}
// Контракт проверяется ЗДЕСЬ, а не только при хешировании.
//
// Отказ bcrypt на непригодном пароле выглядел бы как «bcrypt: password
// length exceeds 72 bytes» в journal — сообщение, по которому оператор не
// поймёт ни что чинить, ни где. Причина же чинится одной строкой в
// hy2xs.env, и назвать её обязан тот, кто знает про этот файл.
if !credential.IsValidAdminPassword(adminPassword) {
return fmt.Errorf(
"HY2XS_ADMIN_INITIAL_PASSWORD не удовлетворяет контракту панели: "+
"от %d до %d символов, не более %d байт в UTF-8, без управляющих символов и U+FEFF.\n"+
"Набор символов не ограничен, пробелы по краям являются частью пароля.\n"+
"Учётная запись администратора с таким значением создана НЕ будет: "+
"установка завершилась бы успешно, а войти в панель было бы нельзя.\n"+
"Исправьте значение в /etc/hy2xs/hy2xs.env и запустите\n"+
"`hy2xs-orchestrator repair --allow-partial-state`.",
credential.AdminPasswordMinLength,
credential.AdminPasswordMaxLength,
credential.AdminPasswordMaxBytes,
)
}
forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1)
status := int64(1)
tokenVersion := int64(1)
@@ -140,28 +196,88 @@ func ensureSecureBootstrapAdmin() error {
if _, saveErr := SaveAdminUser(account); saveErr != nil {
return saveErr
}
if passwordGenerated {
logrus.Warnf("Initial admin username: %s", adminUser)
logrus.Warnf("Initial admin password: %s", adminPassword)
}
return nil
}
// BootstrapPeerName — имя и authId пира, который создаёт установка.
//
// Объявлено здесь, а не литералом в четырёх местах: то же значение проверяет
// service.ReservedBootstrapPeerName, и расхождение между ними означало бы, что
// guard защищает не тот пир, который создал seeder.
const BootstrapPeerName = "bootstrap-admin-peer"
// bootstrapPeerSeededValue — значение маркера constant.BootstrapPeerSeeded.
const bootstrapPeerSeededValue = "true"
// ensureSecureBootstrapPeer создаёт пир установщика РОВНО ОДИН РАЗ за жизнь
// базы.
//
// Секрет приходит ТОЛЬКО из HY2XS_ADMIN_CON_PASS: он лежит ещё и в
// /etc/hy2xs/bootstrap-admin.secret, откуда его читает smoke оркестратора для
// проверки machine-auth. Сгенерированный здесь секрет разошёлся бы с файлом, и
// первая же проверка подключения после установки провалилась бы — на
// корректном во всём остальном сервере.
//
// Что было и почему это была дыра в отзыве доступа.
//
// Признаком «создавать или нет» служило наличие строки в таблице пиров. Панель
// при этом разрешает удалить bootstrap-admin-peer, и это осознанное решение:
// после установки он остаётся обычным действующим доступом, а запрет удаления
// означал бы вечный неотзываемый вход. Но HY2XS_ADMIN_CON_PASS продолжает жить
// в /etc/hy2xs/hy2xs.env, который читает systemd-юнит, поэтому:
//
// оператор удаляет bootstrap-admin-peer → доступ действительно исчезает
// systemctl restart hy2xs-admin / reboot → InitSql → ensureSecureBootstrapPeer
// → строки нет → создать заново
// → ТОТ ЖЕ секрет снова действует
//
// То есть отзыв учётных данных не переживал перезапуск, и восстановление
// происходило молча: в журнале ничего, в списке пиров запись просто снова есть.
// Отзыв, который не переживает restart, отзывом не является.
//
// Теперь решение принимается по отдельной отметке в таблице `config`. Она
// отвечает на вопрос «пир КОГДА-ЛИБО создавался», а не «существует сейчас»,
// поэтому удаление становится окончательным. Отметка и сам пир пишутся одной
// транзакцией: иначе падение между двумя операциями вернуло бы прежнее
// поведение на следующем старте.
//
// Порядок проверок сохранён: сначала выясняем, нужно ли вообще создавать пир, и
// только потом требуем env. Перезапуск установленного сервиса без переменной
// обязан работать штатно.
func ensureSecureBootstrapPeer() error {
bootstrapSecret := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_CON_PASS"))
if bootstrapSecret == "" {
generated, err := util.RandomString(24)
if err != nil {
return err
}
bootstrapSecret = generated
seeded, err := bootstrapPeerSeeded()
if err != nil {
return err
}
if seeded {
return nil
}
if _, err := GetPeer("auth_id = ?", "bootstrap-admin-peer"); err == nil {
return nil
// Пир есть, а отметки нет. На чистой установке это недостижимо: имя
// зарезервировано во всех путях записи панели, поэтому единственный
// создатель такой строки — этот же seeder. Ветка закрывает базу, созданную
// сборкой до появления отметки, и делает функцию идемпотентной, не
// возвращая при этом прежнюю семантику: она срабатывает только при
// отсутствующем маркере.
switch _, err := GetPeer("name = ?", BootstrapPeerName); {
case err == nil:
return UpsertConfigValue(constant.BootstrapPeerSeeded, bootstrapPeerSeededValue)
case !errors.Is(err, ErrPeerNotFound):
// База не ответила. «Неизвестно» не даёт права создавать учётные данные.
return err
}
if _, err := GetPeer("name = ?", "bootstrap-admin-peer"); err == nil {
return nil
bootstrapSecret := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_CON_PASS"))
if bootstrapSecret == "" {
return errors.New(
"HY2XS_ADMIN_CON_PASS не задан, а пир установщика bootstrap-admin-peer ещё ни разу не создавался.\n" +
"Секрет этого пира задаёт install-оркестратор и кладёт копию в\n" +
"/etc/hy2xs/bootstrap-admin.secret, откуда его читает проверка machine-auth.\n" +
"Секрет, придуманный админкой, разошёлся бы с этим файлом, и проверка\n" +
"подключения после установки провалилась бы.\n" +
"Проверьте EnvironmentFile в юните hy2xs-admin и запустите\n" +
"`hy2xs-orchestrator repair --allow-partial-state`.",
)
}
secretDigest, err := buildPeerSecretDigest(bootstrapSecret)
@@ -169,8 +285,18 @@ func ensureSecureBootstrapPeer() error {
return err
}
if _, err := GetPeer("secret_digest = ?", secretDigest); err == nil {
return nil
// Чужой пир с тем же секретом. Создавать нельзя — UNIQUE(secret_digest), —
// но и молчать нельзя: секрет установщика уже выдан кому-то ещё.
switch _, err := GetPeer("secret_digest = ?", secretDigest); {
case err == nil:
return errors.New(
"секрет из HY2XS_ADMIN_CON_PASS уже принадлежит другому пиру.\n" +
"Пир установщика не создан: два пира не могут делить один секрет.\n" +
"Переименуйте или удалите этот пир, либо смените HY2XS_ADMIN_CON_PASS\n" +
"через `hy2xs-orchestrator reconfigure`.",
)
case !errors.Is(err, ErrPeerNotFound):
return err
}
secretEncrypted, err := encryptBootstrapPeerSecret(bootstrapSecret)
@@ -178,8 +304,8 @@ func ensureSecureBootstrapPeer() error {
return err
}
name := "bootstrap-admin-peer"
authID := "bootstrap-admin-peer"
name := BootstrapPeerName
authID := BootstrapPeerName
remark := "bootstrap peer seeded from HY2XS_ADMIN_CON_PASS"
quota := int64(-1)
download := int64(0)
@@ -205,20 +331,64 @@ func ensureSecureBootstrapPeer() error {
BannedUntil: &bannedUntil,
LastConnectionAt: &lastConnection,
}
_, saveErr := SavePeer(peer)
return saveErr
return seedBootstrapPeerOnce(peer)
}
// bootstrapPeerSeeded читает отметку о том, что пир установщика уже создавался.
//
// Отсутствие строки — это «ещё не создавался», а не отказ: миграция 000
// заводит ключ пустым, но база могла быть создана и до его появления. Любая
// ДРУГАЯ ошибка — отказ хранилища, и трактовать её как «не создавался» нельзя:
// именно так и появлялся бы пир заново при недоступной базе.
func bootstrapPeerSeeded() (bool, error) {
cfg, err := GetConfig("key = ?", constant.BootstrapPeerSeeded)
if err != nil {
if errors.Is(err, ErrConfigNotFound) {
return false, nil
}
return false, err
}
if cfg.Value == nil {
return false, nil
}
return strings.TrimSpace(*cfg.Value) == bootstrapPeerSeededValue, nil
}
// seedBootstrapPeerOnce пишет пир и отметку о его создании неделимо.
//
// Раздельная запись вернула бы исходный дефект в новой форме: упади процесс
// между SavePeer и записью отметки, следующий старт снова увидел бы
// «не создавался» и — после удаления пира оператором — создал бы его заново.
func seedBootstrapPeerOnce(peer entity.Peer) error {
if sqliteDB == nil {
return errors.New("sqlite is not initialized")
}
return sqliteDB.Transaction(func(tx *gorm.DB) error {
if _, err := (peerTx{db: tx}).SavePeer(peer); err != nil {
return err
}
return upsertConfigValueOn(tx, constant.BootstrapPeerSeeded, bootstrapPeerSeededValue)
})
}
func buildPeerSecretDigest(rawSecret string) (string, error) {
secretKey, err := getOrCreatePeerSecretDigestKey()
secretKey, err := GetOrCreatePeerSecretDigestKey()
if err != nil {
return "", err
}
return util.HmacSHA256Hex(rawSecret, secretKey), nil
}
func getOrCreatePeerSecretEncryptionKey() ([]byte, error) {
raw, err := getOrCreateConfigKey(constant.PeerSecretEncryptionKey, 32)
// GetOrCreatePeerSecretEncryptionKey возвращает master-key шифрования секретов
// пиров, при необходимости создавая его.
//
// Экспортирована, чтобы у ключа была ровно одна реализация. Раньше идентичная
// копия жила в service/peer_secret.go: две функции с одинаковым именем в разных
// пакетах, обе создающие один и тот же материал шифрования. Расхождение между
// ними означало бы, что секреты пиров шифруются одним ключом, а
// расшифровываются другим, и обнаружилось бы это на живых пирах.
func GetOrCreatePeerSecretEncryptionKey() ([]byte, error) {
raw, err := GetOrCreateConfigSecret(constant.PeerSecretEncryptionKey, 32)
if err != nil {
return nil, err
}
@@ -226,45 +396,55 @@ func getOrCreatePeerSecretEncryptionKey() ([]byte, error) {
if decErr == nil {
return decoded, nil
}
// Ключ, записанный не в base64: приводим к стабильному представлению один
// раз, сохранив исходные байты, — иначе уже зашифрованные секреты пиров
// перестали бы расшифровываться.
plain := []byte(strings.TrimSpace(raw))
if len(plain) < 32 {
return nil, errors.New("invalid peer secret encryption key")
}
plain = plain[:32]
encoded := base64.StdEncoding.EncodeToString(plain)
if err := UpdateConfig([]string{constant.PeerSecretEncryptionKey}, map[string]interface{}{"value": encoded}); err != nil {
if err := UpsertConfigValue(constant.PeerSecretEncryptionKey, base64.StdEncoding.EncodeToString(plain)); err != nil {
return nil, err
}
return plain, nil
}
func encryptBootstrapPeerSecret(rawSecret string) (string, error) {
key, err := getOrCreatePeerSecretEncryptionKey()
key, err := GetOrCreatePeerSecretEncryptionKey()
if err != nil {
return "", err
}
return util.EncryptAESGCM(rawSecret, key)
}
func getOrCreateConfigKey(key string, size int) (string, error) {
// GetOrCreateConfigSecret читает секрет из таблицы `config`, создавая его при
// первом обращении. Единственная реализация этой операции в продукте.
func GetOrCreateConfigSecret(key string, size int) (string, error) {
cfg, err := GetConfig("key = ?", key)
if err == nil && cfg.Value != nil && strings.TrimSpace(*cfg.Value) != "" {
return strings.TrimSpace(*cfg.Value), nil
}
raw, genErr := util.RandomString(size)
value, genErr := util.RandomString(size)
if genErr != nil {
return "", genErr
}
value := raw
remark := key
if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &value, Remark: &remark}); saveErr != nil {
if updErr := UpdateConfig([]string{key}, map[string]interface{}{"value": value}); updErr != nil {
return "", updErr
}
if err := UpsertConfigValue(key, value); err != nil {
return "", err
}
return value, nil
}
// RunMigrations приводит схему базы к текущему поколению.
//
// Экспортирована, чтобы схему можно было поднять отдельно от InitSql: тестам
// слоя данных нужна таблица, но не нужны bootstrap-администратор, bootstrap-пир
// и генерация JWT-секрета.
func RunMigrations() error {
return runMigrations()
}
func runMigrations() error {
if tx := sqliteDB.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
@@ -279,13 +459,24 @@ func runMigrations() error {
apply func() error
}
// Миграций 002_migrate_legacy_accounts и 003_archive_legacy_account здесь
// больше нет.
//
// Они переносили таблицу `account` предыдущего поколения в admin_user/peer
// и переименовывали её в бэкап. Это compatibility-слой продукта, который
// HY2XS v1 принципиально не поддерживает: установка возможна только на
// чистый хост, база 0.x не мигрируется ни при каком сценарии, а clean-host
// контракт отказывает ещё до создания базы. Живого пути, по которому такая
// таблица могла бы оказаться в hy2xs-admin.db, не существует.
//
// Номера оставшихся миграций сохранены: перенумерация заставила бы их
// примениться повторно на уже установленных машинах.
migrations := []migration{
{version: "000_base_config", apply: migrateBaseConfig},
{version: "001_admin_peer_split", apply: migrateAdminPeerSplit},
{version: "002_migrate_legacy_accounts", apply: migrateLegacyAccounts},
{version: "003_archive_legacy_account", apply: archiveLegacyAccount},
{version: "004_traffic_samples_and_aggregates", apply: migrateTrafficTables},
{version: "005_metric_sample", apply: migrateMetricSampleTable},
{version: "006_drop_dead_config_keys", apply: migrateDropDeadConfigKeys},
}
for _, m := range migrations {
@@ -307,20 +498,27 @@ func runMigrations() error {
}
func seedBaseConfig() error {
// Сетевых настроек панели здесь нет: ими владеет оркестратор.
//
// HYSTERIA2_ENABLE, HYSTERIA2_CONFIG, HYSTERIA2_TRAFFIC_TIME и
// HYSTERIA2_CONFIG_REMARK удалены: ни один из них не имел потребителя в
// рантайме, см. model/constant/config.go. Строки этих ключей на уже
// установленных машинах убирает миграция 006_drop_dead_config_keys.
//
// Оставшееся — четыре внутренних секрета и единственная настройка, которой
// действительно владеет панель.
//
// BOOTSTRAP_PEER_SEEDED заводится пустым: пустое значение означает «пир
// установщика ещё ни разу не создавался», и это верное состояние для
// чистой базы. Отметку выставляет ensureSecureBootstrapPeer той же
// транзакцией, которой создаёт пир.
defaults := map[string]string{
constant.HUIWebPort: "8080",
constant.HUIWebContext: "/",
constant.HUICrtPath: "",
constant.HUIKeyPath: "",
constant.JwtSecret: "",
constant.Hysteria2Enable: "0",
constant.Hysteria2Config: "",
constant.Hysteria2TrafficTime: "10",
constant.Hysteria2ConfigRemark: "",
constant.ResetTrafficCron: "0 0 * * *",
constant.Hysteria2TrafficStatsSecret: "",
constant.PeerSecretKey: "",
constant.PeerSecretEncryptionKey: "",
constant.BootstrapPeerSeeded: "",
}
for k, v := range defaults {
if tx := sqliteDB.Exec("INSERT OR IGNORE INTO config(key, value, remark) VALUES(?, ?, ?)", k, v, k); tx.Error != nil {
@@ -352,6 +550,34 @@ func migrateBaseConfig() error {
return seedBaseConfig()
}
// migrateDropDeadConfigKeys убирает из таблицы `config` ключи, у которых не
// осталось ни одного потребителя.
//
// Миграция нужна не ради места в базе, а ради того же инварианта, что и
// allowlist: строка в таблице секретов, которую никто не читает и не пишет,
// рано или поздно кем-нибудь прочитается. Ключи перечислены здесь строковыми
// литералами намеренно — соответствующих констант в коде уже нет, и появиться
// они не должны.
//
// Миграция безопасна на чистой установке (удалять нечего) и идемпотентна:
// применяется один раз и отмечается в schema_migrations. Номера предыдущих
// миграций не тронуты — они уже записаны на установленных машинах.
func migrateDropDeadConfigKeys() error {
deadKeys := []string{
"HYSTERIA2_ENABLE",
"HYSTERIA2_CONFIG",
"HYSTERIA2_TRAFFIC_TIME",
"HYSTERIA2_CONFIG_REMARK",
}
for _, key := range deadKeys {
if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", key); tx.Error != nil {
logrus.Errorf("sqlite migration exec err: %v", tx.Error)
return errors.New("sqlite migration exec err")
}
}
return nil
}
func migrationApplied(version string) (bool, error) {
var count int64
if tx := sqliteDB.Raw("SELECT COUNT(1) FROM schema_migrations WHERE version = ?", version).Scan(&count); tx.Error != nil {
@@ -407,125 +633,6 @@ func migrateAdminPeerSplit() error {
return nil
}
func migrateLegacyAccounts() error {
if !tableExists("account") {
return nil
}
var accounts []entity.LegacyAccount
if tx := sqliteDB.Model(&entity.LegacyAccount{}).Order("id asc").Find(&accounts); tx.Error != nil {
logrus.Errorf("sqlite legacy account query err: %v", tx.Error)
return errors.New("sqlite legacy account query err")
}
nowMs := time.Now().UnixMilli()
for _, acc := range accounts {
if acc.Role != nil && *acc.Role == "admin" {
if acc.Username == nil || acc.Pass == nil {
continue
}
_, getErr := GetAdminUser("username = ?", *acc.Username)
if getErr == nil {
continue
}
status := int64(1)
if acc.Deleted != nil && *acc.Deleted == 1 {
status = 0
}
lastLogin := int64(0)
if acc.LoginAt != nil {
lastLogin = *acc.LoginAt
}
passwordChangedAt := nowMs
admin := entity.AdminUser{
Username: acc.Username,
PasswordHash: acc.Pass,
Status: &status,
ForcePasswordChange: acc.ForcePasswordChange,
LastLoginAt: &lastLogin,
PasswordChangedAt: &passwordChangedAt,
TokenVersion: func() *int64 { v := int64(1); return &v }(),
}
if _, saveErr := SaveAdminUser(admin); saveErr != nil {
return saveErr
}
continue
}
if acc.Username == nil || acc.ConPass == nil {
continue
}
_, getPeerErr := GetPeer("name = ?", *acc.Username)
if getPeerErr == nil {
continue
}
authId, authErr := util.RandomString(18)
if authErr != nil {
return authErr
}
peerSecretKey, keyErr := getOrCreatePeerSecretDigestKey()
if keyErr != nil {
return keyErr
}
secretDigest := util.HmacSHA256Hex(*acc.ConPass, peerSecretKey)
secretEncrypted := *acc.ConPass
quota := int64(0)
if acc.Quota != nil {
quota = *acc.Quota
}
download := int64(0)
if acc.Download != nil {
download = *acc.Download
}
upload := int64(0)
if acc.Upload != nil {
upload = *acc.Upload
}
expires := int64(0)
if acc.ExpireTime != nil {
expires = *acc.ExpireTime
}
maxDevices := int64(3)
if acc.DeviceNo != nil {
maxDevices = *acc.DeviceNo
}
disabled := int64(0)
if acc.Deleted != nil {
disabled = *acc.Deleted
}
bannedUntil := int64(0)
if acc.KickUtilTime != nil {
bannedUntil = *acc.KickUtilTime
}
lastConnection := int64(0)
if acc.ConAt != nil {
lastConnection = *acc.ConAt
}
remark := ""
if acc.Remark != nil {
remark = *acc.Remark
}
peer := entity.Peer{
Name: acc.Username,
Remark: &remark,
AuthId: &authId,
SecretDigest: &secretDigest,
SecretEncrypted: &secretEncrypted,
QuotaBytes: &quota,
DownloadBytes: &download,
UploadBytes: &upload,
ExpiresAt: &expires,
MaxDevices: &maxDevices,
Disabled: &disabled,
BannedUntil: &bannedUntil,
LastConnectionAt: &lastConnection,
}
if _, saveErr := SavePeer(peer); saveErr != nil {
return saveErr
}
}
return nil
}
func getOrCreateJwtSecret() (string, error) {
if existing, err := GetConfig("key = ?", constant.JwtSecret); err == nil {
if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
@@ -536,46 +643,17 @@ func getOrCreateJwtSecret() (string, error) {
if secErr != nil {
return "", secErr
}
if err := UpdateConfig([]string{constant.JwtSecret}, map[string]interface{}{"value": secret}); err != nil {
key := constant.JwtSecret
remark := "JWT signing secret"
if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &secret, Remark: &remark}); saveErr != nil {
return "", saveErr
}
if err := UpsertConfigValue(constant.JwtSecret, secret); err != nil {
return "", err
}
return secret, nil
}
func getOrCreatePeerSecretDigestKey() (string, error) {
if existing, err := GetConfig("key = ?", constant.PeerSecretKey); err == nil {
if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
return strings.TrimSpace(*existing.Value), nil
}
}
keyValue, keyErr := util.RandomString(48)
if keyErr != nil {
return "", keyErr
}
if err := UpdateConfig([]string{constant.PeerSecretKey}, map[string]interface{}{"value": keyValue}); err != nil {
key := constant.PeerSecretKey
remark := "Peer secret digest key"
if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &keyValue, Remark: &remark}); saveErr != nil {
return "", saveErr
}
}
return keyValue, nil
}
func archiveLegacyAccount() error {
if !tableExists("account") {
return nil
}
backupName := fmt.Sprintf("legacy_account_backup_%d", time.Now().Unix())
if tx := sqliteDB.Exec("ALTER TABLE account RENAME TO " + backupName); tx.Error != nil {
logrus.Errorf("sqlite legacy archive err: %v", tx.Error)
return errors.New("sqlite legacy archive err")
}
return nil
// GetOrCreatePeerSecretDigestKey — HMAC-ключ, которым считается secret_digest
// пира. Экспортирована по той же причине, что и ключ шифрования: копия этой же
// логики жила в service/peer_secret.go под именем GetPeerSecretKey.
func GetOrCreatePeerSecretDigestKey() (string, error) {
return GetOrCreateConfigSecret(constant.PeerSecretKey, 48)
}
func migrateTrafficTables() error {
@@ -663,62 +741,64 @@ func tableExists(tableName string) bool {
return count > 0
}
func listSQLMigrationFiles(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
files := make([]string, 0)
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasSuffix(strings.ToLower(name), ".sql") {
files = append(files, filepath.Join(dir, name))
}
}
sort.Strings(files)
return files, nil
}
// ensureTrafficStatsSecret синхронизирует machine token с окружением и НЕ
// придумывает его сам.
//
// Владелец значения — оркестратор. Он же кладёт этот секрет в
// /etc/hysteria/config.yaml, откуда Hysteria берёт его для обращения к
// /internal/hysteria/auth, и в /etc/hy2xs/hy2xs.env, откуда его читает
// systemd-юнит админки. Токен — межкомпонентный контракт, а не внутреннее
// состояние панели.
//
// Что было. При пустом env и пустой базе админка генерировала случайный токен
// сама. Записать его в /etc/hysteria/config.yaml она не может — файл
// принадлежит оркестратору и для пользователя hy2xs-admin доступен только на
// чтение (это проверяет smoke). Результат:
//
// admin стартовал успешно
// Hysteria продолжала слать старый токен
// machine auth переставал совпадать
//
// То есть повреждённый контракт запуска не диагностировался, а маскировался
// «починкой» неправильным способом: сервис объявлял себя здоровым, а
// авторизация клиентов молча ломалась.
//
// Тот же принцип, что уже применён к HY2XS_ADMIN_INITIAL_PASSWORD и
// HY2XS_ADMIN_CON_PASS: значение, которого не знает никто, кроме сгенерировавшего
// его процесса, бесполезно, и правильная реакция на его отсутствие громкая.
//
// Три допустимых состояния:
//
// env задан → синхронизировать базу с ним (env главнее);
// env пуст, в базе токен есть → рабочее состояние, ничего не делаем;
// env пуст, в базе пусто → отказ старта.
//
// Вторая ветка нужна для ручного `systemctl start` без EnvironmentFile: она не
// изобретает контракт, а использует уже согласованный.
func ensureTrafficStatsSecret() error {
envSecret := strings.TrimSpace(os.Getenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET"))
if envSecret != "" {
if existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret); err == nil {
if existing.Value != nil && strings.TrimSpace(*existing.Value) != envSecret {
return UpdateConfig([]string{constant.Hysteria2TrafficStatsSecret}, map[string]interface{}{"value": envSecret})
}
return nil
}
key := constant.Hysteria2TrafficStatsSecret
remark := "Hysteria2 trafficStats API secret"
if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &envSecret, Remark: &remark}); saveErr != nil {
return saveErr
}
return UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, envSecret)
}
existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
if err != nil && !errors.Is(err, ErrConfigNotFound) {
return err
}
if err == nil && existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
return nil
}
if existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret); err == nil {
if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
return nil
}
secret, secErr := util.RandomString(32)
if secErr != nil {
return secErr
}
return UpdateConfig([]string{constant.Hysteria2TrafficStatsSecret}, map[string]interface{}{"value": secret})
}
secret, secErr := util.RandomString(32)
if secErr != nil {
return secErr
}
key := constant.Hysteria2TrafficStatsSecret
remark := "Hysteria2 trafficStats API secret"
if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &secret, Remark: &remark}); saveErr != nil {
return saveErr
}
return nil
return errors.New(
"HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET не задан, и в базе этого токена тоже нет.\n" +
"Это межкомпонентный контракт, а не настройка панели: тем же значением\n" +
"Hysteria обращается к /internal/hysteria/auth, и владеет им\n" +
"install-оркестратор через /etc/hysteria/config.yaml и /etc/hy2xs/hy2xs.env.\n" +
"Сгенерированный админкой токен не попал бы в конфиг Hysteria: этот файл\n" +
"доступен ей только на чтение, — и machine auth перестал бы совпадать.\n" +
"Проверьте EnvironmentFile в юните hy2xs-admin и запустите\n" +
"`hy2xs-orchestrator repair --allow-partial-state`.",
)
}
func CloseSqliteDB() error {
+6 -8
View File
@@ -1,19 +1,17 @@
package dao
import (
"errors"
"fmt"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
)
func SaveTrafficSample(sample entity.TrafficSample) error {
if tx := sqliteDB.Save(&sample); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
return ErrStorage
}
return nil
}
@@ -36,7 +34,7 @@ func UpsertTrafficAggregateHourly(peerId int64, hourStart int64, rxBytes int64,
}),
}).Create(&agg); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
return ErrStorage
}
return nil
}
@@ -59,7 +57,7 @@ func UpsertTrafficAggregateDaily(peerId int64, dayStart int64, rxBytes int64, tx
}),
}).Create(&agg); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
return ErrStorage
}
return nil
}
@@ -74,7 +72,7 @@ func CleanupTrafficSample(olderThanMs int64) error {
}
if tx := sqliteDB.Exec("DELETE FROM traffic_sample WHERE sampled_at < ?", olderThanMs); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
return ErrStorage
}
return nil
}
@@ -85,7 +83,7 @@ func CleanupTrafficAggregateHourly(olderThanMs int64) error {
}
if tx := sqliteDB.Exec("DELETE FROM traffic_aggregate_hourly WHERE hour_start < ?", olderThanMs); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
return ErrStorage
}
return nil
}
@@ -96,7 +94,7 @@ func CleanupTrafficAggregateDaily(olderThanMs int64) error {
}
if tx := sqliteDB.Exec("DELETE FROM traffic_aggregate_daily WHERE day_start < ?", olderThanMs); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
return ErrStorage
}
return nil
}
-13
View File
@@ -1,13 +0,0 @@
dist
node_modules
public
.vscode
.idea
*.sh
*.md
src/assets
.eslintrc.cjs
.prettierrc.cjs
.stylelintrc.cjs
+25 -210
View File
@@ -1,269 +1,84 @@
{
"globals": {
"Component": true,
"ComponentPublicInstance": true,
"ComputedRef": true,
"DirectiveBinding": true,
"EffectScope": true,
"ElForm": true,
"ElMessage": true,
"ElMessageBox": true,
"ElTree": true,
"asyncComputed": true,
"autoResetRef": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,
"InjectionKey": true,
"MaybeRef": true,
"MaybeRefOrGetter": true,
"PropType": true,
"Ref": true,
"ShallowRef": true,
"Slot": true,
"Slots": true,
"VNode": true,
"WritableComputedRef": true,
"computed": true,
"computedAsync": true,
"computedEager": true,
"computedInject": true,
"computedWithControl": true,
"controlledComputed": true,
"controlledRef": true,
"createApp": true,
"createEventHook": true,
"createGlobalState": true,
"createInjectionState": true,
"createReactiveFn": true,
"createSharedComposable": true,
"createUnrefFn": true,
"customRef": true,
"debouncedRef": true,
"debouncedWatch": true,
"defineAsyncComponent": true,
"defineComponent": true,
"eagerComputed": true,
"effectScope": true,
"extendRef": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"getCurrentWatcher": true,
"h": true,
"ignorableWatch": true,
"inject": true,
"isDefined": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"makeDestructurable": true,
"isShallow": true,
"markRaw": true,
"nextTick": true,
"onActivated": true,
"onBeforeMount": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onClickOutside": true,
"onDeactivated": true,
"onErrorCaptured": true,
"onKeyStroke": true,
"onLongPress": true,
"onMounted": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onStartTyping": true,
"onUnmounted": true,
"onUpdated": true,
"pausableWatch": true,
"onWatcherCleanup": true,
"provide": true,
"reactify": true,
"reactifyObject": true,
"reactive": true,
"reactiveComputed": true,
"reactiveOmit": true,
"reactivePick": true,
"readonly": true,
"ref": true,
"refAutoReset": true,
"refDebounced": true,
"refDefault": true,
"refThrottled": true,
"refWithControl": true,
"resolveComponent": true,
"resolveDirective": true,
"resolveRef": true,
"resolveUnref": true,
"shallowReactive": true,
"shallowReadonly": true,
"shallowRef": true,
"syncRef": true,
"syncRefs": true,
"templateRef": true,
"throttledRef": true,
"throttledWatch": true,
"toRaw": true,
"toReactive": true,
"toRef": true,
"toRefs": true,
"toValue": true,
"triggerRef": true,
"tryOnBeforeMount": true,
"tryOnBeforeUnmount": true,
"tryOnMounted": true,
"tryOnScopeDispose": true,
"tryOnUnmounted": true,
"unref": true,
"unrefElement": true,
"until": true,
"useActiveElement": true,
"useArrayEvery": true,
"useArrayFilter": true,
"useArrayFind": true,
"useArrayFindIndex": true,
"useArrayFindLast": true,
"useArrayJoin": true,
"useArrayMap": true,
"useArrayReduce": true,
"useArraySome": true,
"useArrayUnique": true,
"useAsyncQueue": true,
"useAsyncState": true,
"useAttrs": true,
"useBase64": true,
"useBattery": true,
"useBluetooth": true,
"useBreakpoints": true,
"useBroadcastChannel": true,
"useBrowserLocation": true,
"useCached": true,
"useClipboard": true,
"useCloned": true,
"useColorMode": true,
"useConfirmDialog": true,
"useCounter": true,
"useCssModule": true,
"useCssVar": true,
"useCssVars": true,
"useCurrentElement": true,
"useCycleList": true,
"useDark": true,
"useDateFormat": true,
"useDebounce": true,
"useDebounceFn": true,
"useDebouncedRefHistory": true,
"useDeviceMotion": true,
"useDeviceOrientation": true,
"useDevicePixelRatio": true,
"useDevicesList": true,
"useDisplayMedia": true,
"useDocumentVisibility": true,
"useDraggable": true,
"useDropZone": true,
"useElementBounding": true,
"useElementByPoint": true,
"useElementHover": true,
"useElementSize": true,
"useElementVisibility": true,
"useEventBus": true,
"useEventListener": true,
"useEventSource": true,
"useEyeDropper": true,
"useFavicon": true,
"useFetch": true,
"useFileDialog": true,
"useFileSystemAccess": true,
"useFocus": true,
"useFocusWithin": true,
"useFps": true,
"useFullscreen": true,
"useGamepad": true,
"useGeolocation": true,
"useIdle": true,
"useImage": true,
"useInfiniteScroll": true,
"useIntersectionObserver": true,
"useInterval": true,
"useIntervalFn": true,
"useKeyModifier": true,
"useLastChanged": true,
"useLocalStorage": true,
"useMagicKeys": true,
"useManualRefHistory": true,
"useMediaControls": true,
"useMediaQuery": true,
"useMemoize": true,
"useMemory": true,
"useMounted": true,
"useMouse": true,
"useMouseInElement": true,
"useMousePressed": true,
"useMutationObserver": true,
"useNavigatorLanguage": true,
"useNetwork": true,
"useNow": true,
"useObjectUrl": true,
"useOffsetPagination": true,
"useOnline": true,
"usePageLeave": true,
"useParallax": true,
"usePermission": true,
"usePointer": true,
"usePointerLock": true,
"usePointerSwipe": true,
"usePreferredColorScheme": true,
"usePreferredContrast": true,
"usePreferredDark": true,
"usePreferredLanguages": true,
"usePreferredReducedMotion": true,
"usePrevious": true,
"useRafFn": true,
"useRefHistory": true,
"useResizeObserver": true,
"useScreenOrientation": true,
"useScreenSafeArea": true,
"useScriptTag": true,
"useScroll": true,
"useScrollLock": true,
"useSessionStorage": true,
"useShare": true,
"useId": true,
"useModel": true,
"useSlots": true,
"useSorted": true,
"useSpeechRecognition": true,
"useSpeechSynthesis": true,
"useStepper": true,
"useStorage": true,
"useStorageAsync": true,
"useStyleTag": true,
"useSupported": true,
"useSwipe": true,
"useTemplateRefsList": true,
"useTextDirection": true,
"useTextSelection": true,
"useTextareaAutosize": true,
"useThrottle": true,
"useThrottleFn": true,
"useThrottledRefHistory": true,
"useTimeAgo": true,
"useTimeout": true,
"useTimeoutFn": true,
"useTimeoutPoll": true,
"useTimestamp": true,
"useTitle": true,
"useToNumber": true,
"useToString": true,
"useToggle": true,
"useTransition": true,
"useUrlSearchParams": true,
"useUserMedia": true,
"useTemplateRef": true,
"useVModel": true,
"useVModels": true,
"useVibrate": true,
"useVirtualList": true,
"useWakeLock": true,
"useWebNotification": true,
"useWebSocket": true,
"useWebWorker": true,
"useWebWorkerFn": true,
"useWindowFocus": true,
"useWindowScroll": true,
"useWindowSize": true,
"watch": true,
"watchArray": true,
"watchAtMost": true,
"watchDebounced": true,
"watchEffect": true,
"watchIgnorable": true,
"watchOnce": true,
"watchPausable": true,
"watchPostEffect": true,
"watchSyncEffect": true,
"watchThrottled": true,
"watchTriggerable": true,
"watchWithFilter": true,
"whenever": true
"watchSyncEffect": true
}
}
-31
View File
@@ -1,31 +0,0 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
parser: "vue-eslint-parser", // https://eslint.vuejs.org/user-guide/#bundle-configurations
extends: [
"eslint:recommended",
"plugin:vue/vue3-essential",
"plugin:@typescript-eslint/recommended",
"./.eslintrc-auto-import.json",
],
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
parser: "@typescript-eslint/parser",
},
plugins: ["vue", "@typescript-eslint"],
rules: {
"vue/multi-word-component-names": "off", // Disable multi-word component name requirement: https://eslint.vuejs.org/rules/multi-word-component-names.html
"@typescript-eslint/no-empty-function": "off", // Disable empty function checks
"@typescript-eslint/no-explicit-any": "off", // Disable warnings for any
"vue/no-v-model-argument": "off",
"@typescript-eslint/no-non-null-assertion": "off",
}, // https://eslint.org/docs/latest/use/configure/language-options#specifying-globals
globals: {
DialogType: "readonly",
OptionType: "readonly",
},
};
+6
View File
@@ -25,6 +25,12 @@ module.exports = {
"custom-property-pattern": null, // Custom property naming rule
"keyframes-name-pattern": null, // Keyframe naming rule
"no-descending-specificity": null, // Allow non-descending specificity
// Пустая строка `//` внутри блока комментария — абзацный разделитель, а не
// забытый комментарий. Правило появилось в stylelint-config-recommended-scss
// 17 и требует либо склеивать абзацы, либо разрывать блок пустой строкой;
// и то и другое ухудшает читаемость объяснений, ради которых эти
// комментарии написаны.
"scss/comment-no-empty": null,
// Allow global, export and deep pseudo classes
"selector-pseudo-class-no-unknown": [
true,
+7 -1
View File
@@ -19,5 +19,11 @@ Frontend собирается production builder'ом из [`tools/build`](../..
```sh
pnpm install --frozen-lockfile
pnpm run build:prod
bun test test/i18n-runtime.test.ts
pnpm run verify
```
Runtime-тест обходит все строковые сообщения русского и английского словарей
через установленный `vue-i18n`. Он проверяет не только исключения, но и
compiler diagnostics в `console.error`: некоторые версии компилятора сообщают
ошибку формата без исключения из `t()`.
+104
View File
@@ -0,0 +1,104 @@
import { createRequire } from "node:module";
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import pluginVue from "eslint-plugin-vue";
import prettier from "eslint-config-prettier";
import vueParser from "vue-eslint-parser";
/**
* Конфигурация ESLint во flat-формате.
*
* Заменяет `.eslintrc.cjs`. Причина не в моде на формат: eslint 8 объявлен
* EOL, перестал получать исправления и оставался последним источником десятка
* предупреждений `pnpm audit` в проекте (minimatch, brace-expansion, js-yaml,
* flatted — всё в его собственном графе). Eslintrc-формат в eslint 9 работает
* только через переменную окружения, а в 10 удалён вовсе, поэтому обновление
* версии без перехода на flat-config было бы отсрочкой на один релиз.
*
* Набор правил сохранён ровно тем же, что был в `.eslintrc.cjs`: перевод
* формата не место для изменения политики линтинга.
*/
// Глобальные имена авто-импорта генерирует unplugin-auto-import в
// eslintrc-формате (`{ globals: { ref: true, ... } }`). Flat-config такой файл
// через `extends` не подключает, поэтому список читается напрямую — источник
// истины остаётся один, и он по-прежнему пересоздаётся сборкой.
const require = createRequire(import.meta.url);
const autoImportGlobals = require("./.eslintrc-auto-import.json").globals;
export default tseslint.config(
{
// Перенесено из .eslintignore: eslint 9 этот файл больше не читает, и
// список исключений обязан жить в самой конфигурации.
//
// src/assets — SVG и изображения; src/types/*.d.ts — объявления, которые
// генерируют unplugin-auto-import и unplugin-vue-components, править их
// вручную нельзя, а замечания линтера по ним только шумят.
ignores: [
"dist/**",
"node_modules/**",
"public/**",
"src/assets/**",
"src/types/*.d.ts",
],
},
js.configs.recommended,
...tseslint.configs.recommended,
...pluginVue.configs["flat/essential"],
{
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
...globals.node,
...autoImportGlobals,
// Объявлены в src/types/global.d.ts и используются как глобальные
// типы. Списку положено совпадать с содержимым того файла: `no-undef`
// не знает о declare global, и отсутствующее здесь имя падает ошибкой
// линтера при совершенно корректном коде.
//
// DialogType и OptionType отсюда убраны: в src/types/global.d.ts их
// нет и в панели они не встречаются — это остаток чужого шаблона,
// разрешавший необъявленные имена.
IdDto: "readonly",
BaseDto: "readonly",
PageVo: "readonly",
},
},
rules: {
// Имя компонента из одного слова — устоявшееся соглашение этого проекта.
"vue/multi-word-component-names": "off",
"vue/no-v-model-argument": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
},
},
{
// .vue разбирает vue-eslint-parser, а <script lang="ts"> внутри него —
// парсер TypeScript. Без второй половины содержимое блока не разбирается.
files: ["**/*.vue"],
languageOptions: {
parser: vueParser,
parserOptions: {
parser: tseslint.parser,
ecmaVersion: "latest",
sourceType: "module",
},
},
},
// Последним: отключает правила форматирования, которыми владеет prettier.
//
// Ни один из включённых наборов таких правил сейчас не содержит, так что это
// страховка, а не исправление: форматированием занимается отдельный скрипт
// lint:prettier, и правило-дубликат в линтере привело бы к двум источникам
// истины о стиле.
prettier
);
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
+45 -42
View File
@@ -2,67 +2,70 @@
"name": "hy2xs-admin",
"version": "0.0.0",
"private": true,
"license": "AGPL-3.0-only",
"description": "HY2XS admin",
"author": "HY2XS",
"packageManager": "pnpm@9.15.9",
"scripts": {
"dev": "vite serve --mode development",
"build:prod": "vite build --mode production && vue-tsc --noEmit",
"lint:eslint": "eslint --fix --ext .ts,.js,.vue ./src ",
"typecheck": "vue-tsc --noEmit",
"build:prod": "vite build --mode production",
"verify": "pnpm run typecheck && pnpm run build:prod",
"lint:eslint": "eslint --fix ./src",
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"",
"lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix"
},
"dependencies": {
"@element-plus/icons-vue": "^1.0.0",
"@vueuse/core": "^9.1.1",
"axios": "^1.3.4",
"@element-plus/icons-vue": "^2.3.2",
"@vueuse/core": "^14.4.0",
"axios": "^1.20.0",
"copy-to-clipboard": "^3.3.3",
"echarts": "^5.5.1",
"element-plus": "^2.3.1",
"echarts": "^6.1.0",
"element-plus": "^2.14.5",
"nprogress": "^0.2.0",
"path-browserify": "^1.0.1",
"pinia": "^2.0.33",
"pinia": "^2.3.1",
"qrcode.vue": "3.4.1",
"vue": "^3.2.45",
"vue-echarts": "^7.0.3",
"vue-i18n": "9",
"vue-router": "^4.1.6",
"vuedraggable": "^4.1.0"
"vue": "^3.5.42",
"vue-echarts": "^8.1.0",
"vue-i18n": "^11.4.10",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@iconify-json/ep": "^1.1.8",
"@eslint/js": "10.0.1",
"@iconify-json/ep": "^1.2.4",
"@types/nprogress": "^0.2.0",
"@types/path-browserify": "^1.0.0",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0",
"@vitejs/plugin-vue": "^4.0.0",
"@vitejs/plugin-vue": "^6.0.8",
"autoprefixer": "^10.4.13",
"eslint": "^8.34.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "^9.9.0",
"postcss": "^8.4.21",
"postcss-html": "^1.5.0",
"postcss-scss": "^4.0.6",
"prettier": "^2.8.7",
"sass": "^1.58.3",
"stylelint": "^15.5.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-recess-order": "^4.0.0",
"stylelint-config-recommended-scss": "^9.0.1",
"stylelint-config-recommended-vue": "^1.4.0",
"stylelint-config-standard": "^32.0.0",
"stylelint-config-standard-scss": "^8.0.0",
"typescript": "^4.9.3",
"unocss": "^0.50.1",
"unplugin-auto-import": "^0.13.0",
"unplugin-icons": "^0.15.1",
"unplugin-vue-components": "^0.23.0",
"vite": "^4.3.1",
"vite-plugin-svg-icons": "^2.0.1",
"vue-tsc": "^0.35.0"
"eslint": "^10.9.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^10.10.0",
"globals": "17.11.0",
"postcss": "^8.5.26",
"postcss-html": "^2.0.0",
"postcss-scss": "^4.0.9",
"prettier": "^3.9.6",
"sass": "^1.103.1",
"stylelint": "^17.14.1",
"stylelint-config-html": "^2.0.0",
"stylelint-config-recess-order": "^7.8.0",
"stylelint-config-recommended-scss": "^17.0.1",
"stylelint-config-recommended-vue": "^2.0.0",
"stylelint-config-standard": "^40.0.0",
"stylelint-config-standard-scss": "^17.0.0",
"stylelint-order": "8.0.0",
"typescript": "^5.9.3",
"typescript-eslint": "8.68.0",
"unocss": "^66.8.1",
"unplugin-auto-import": "^21.1.0",
"unplugin-icons": "^23.0.1",
"unplugin-vue-components": "^32.1.0",
"vite": "^7.3.6",
"vue-eslint-parser": "10.4.1",
"vue-tsc": "^3.3.11"
},
"engines": {
"node": ">=18.12.0"
"node": "^20.19.0 || >=22.12.0"
}
}
+2880 -4136
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
# Настройки resolution принадлежат pnpm, а не npm package manifest.
# Единственный workspace-проект — сам frontend.
packages:
- "."
overrides:
lodash: ">=4.18.1"
lodash-es: ">=4.18.1"
brace-expansion: ">=1.1.18"
picomatch: ">=2.3.2"
js-yaml: ">=4.3.1"
"table>ajv": ">=8.18.0"
browserslist: "4.28.7"
+17 -75
View File
@@ -1,39 +1,34 @@
import { AxiosPromise } from "axios";
import request from "@/utils/request";
import {
ConfigDto,
ConfigsDto,
ConfigUpdateDto,
ConfigVo,
Hysteria2AcmePathVo,
Hysteria2ServerConfig,
Hysteria2ProfileVo,
} from "@/api/config/types";
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ServerConfig> {
// Серверный конфиг Hysteria доступен панели только на чтение и на выгрузку:
// им владеет install-оркестратор. Клиентов записи, импорта, перезапуска и
// загрузки сертификатов здесь нет — соответствующих маршрутов не существует.
//
// Generic-выгрузки таблицы `config` здесь тоже нет: она отдавала JWT- и
// peer-ключи в открытом виде. Перенос пиров делают ImportPeer/ExportPeer.
//
// getConfigApi удалён вместе с маршрутом: он принимал произвольный ключ той же
// таблицы и не вызывался ни из одного экрана. Чтение настроек идёт через
// listConfigApi, на стороне сервера — по allowlist.
// Ответ описывает production-профиль, а не всю модель серверного конфига: он
// отличает «не задано» от значения и отдельно перечисляет секции вне профиля.
// Раньше сюда приезжала внутренняя модель целиком — вместе с паролем
// обфускации, токенами ACME DNS и учётными данными outbound-прокси.
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ProfileVo> {
return request({
url: "/config/getHysteria2Config",
method: "get",
});
}
export function updateHysteria2ConfigApi(
data: Hysteria2ServerConfig
): AxiosPromise {
return request({
url: "/config/updateHysteria2Config",
method: "post",
data: data,
});
}
export function getConfigApi(data: ConfigDto): AxiosPromise<ConfigVo> {
return request({
url: "/config/getConfig",
method: "get",
params: data,
});
}
export function listConfigApi(data: ConfigsDto): AxiosPromise<Array<ConfigVo>> {
return request({
url: "/config/listConfig",
@@ -50,25 +45,6 @@ export function updateConfigsApi(data: ConfigUpdateDto): AxiosPromise {
});
}
export function exportConfigApi(): AxiosPromise {
return request({
url: "/config/exportConfig",
method: "post",
responseType: "blob",
});
}
export function importConfigApi(data: FormData): AxiosPromise {
return request({
url: "/config/importConfig",
method: "post",
headers: {
"Content-Type": "multipart/form-data",
},
data: data,
});
}
export function exportHysteria2ConfigApi(): AxiosPromise {
return request({
url: "/config/exportHysteria2Config",
@@ -76,37 +52,3 @@ export function exportHysteria2ConfigApi(): AxiosPromise {
responseType: "blob",
});
}
export function importHysteria2ConfigApi(data: FormData): AxiosPromise {
return request({
url: "/config/importHysteria2Config",
method: "post",
headers: {
"Content-Type": "multipart/form-data",
},
data: data,
});
}
export function hysteria2AcmePathApi(): AxiosPromise<Hysteria2AcmePathVo> {
return request({
url: "/config/hysteria2AcmePath",
method: "get",
});
}
export function restartServerApi(): AxiosPromise {
return request({
url: "/config/restartServer",
method: "post",
});
}
export function uploadCertFileApi(data: FormData): AxiosPromise<string> {
return request({
url: "/config/uploadCertFile",
method: "post",
data,
headers: { "Content-Type": "multipart/form-data" },
});
}
+96 -276
View File
@@ -1,6 +1,5 @@
export interface ConfigDto {
key: string;
}
// ConfigDto удалён вместе с маршрутом getConfig: чтение произвольного ключа
// таблицы `config` было входом к JWT- и peer-ключам.
export interface ConfigsDto {
keys: Array<string>;
@@ -20,283 +19,104 @@ export interface ConfigUpdateDto {
configUpdateDtos: Array<ConfigsUpdateDto>;
}
export interface Hysteria2ServerConfig {
listen: string;
tls?: {
cert: string;
key: string;
sniGuard?: string;
};
acme?: {
domains: string[];
email: string;
ca: string;
listenHost: string;
dir: string;
type?: string;
http?: {
altPort: number;
};
tls?: {
altPort: number;
};
dns?: {
name: string;
config: { [key: string]: string };
};
disableHTTP: boolean;
disableTLSALPN: boolean;
altHTTPPort: number;
altTLSALPNPort: number;
};
obfs?: {
type: string;
salamander: {
password: string;
};
};
quic?: {
initStreamReceiveWindow?: number;
maxStreamReceiveWindow?: number;
initConnReceiveWindow?: number;
maxConnReceiveWindow?: number;
maxIdleTimeout?: string;
maxIncomingStreams?: number;
disablePathMTUDiscovery?: boolean;
};
bandwidth?: {
up: string;
down: string;
};
ignoreClientBandwidth?: boolean;
speedTest?: boolean;
disableUDP?: boolean;
udpIdleTimeout?: string;
resolver?: {
type: string;
tcp?: {
addr: string;
timeout: string;
};
udp?: {
addr: string;
timeout: string;
};
tls?: {
addr: string;
timeout: string;
sni: string;
insecure: boolean;
};
https?: {
addr: string;
timeout: string;
sni: string;
insecure: boolean;
};
};
sniff?: {
enable: boolean;
timeout: string;
rewriteDomain: boolean;
tcpPorts?: string;
udpPorts?: string;
};
acl?: {
file?: string;
inline?: string[];
geoip?: string;
geosite?: string;
geoUpdateInterval?: string;
};
outbounds?: Hysteria2ServerConfigOutbound[];
trafficStats: {
listen: string;
};
masquerade?: {
type: string;
file?: {
dir: string;
};
proxy?: {
url: string;
rewriteHost: boolean;
insecure: boolean;
};
string?: {
content: string;
headers?: { [key: string]: string };
statusCode?: number;
};
listenHTTP?: string;
listenHTTPS?: string;
forceHTTPS?: boolean;
};
/**
* Конфигурация Hysteria в терминах production-профиля HY2XS.
*
* Что было. Здесь жил `Hysteria2ServerConfig` — полная модель серверного
* конфига со всеми секциями upstream (resolver, sniff, acl, outbounds,
* masquerade, mimic, realm, ech), а рядом, в `hysteriaViewModel.ts`, — её
* копия с обязательными полями и полный объект значений по умолчанию, поверх
* которого накладывался ответ сервера.
*
* Из-за этой конструкции экран отвечал не на тот вопрос. Отсутствующая секция
* `trafficStats` показывалась как `:9999`, отсутствующий блок ACME — как набор
* дефолтов UI, `speedTest: false` и `disableUDP: false` считались
* ненастроенными и прятали свои вкладки. Диагностический экран скрывал ровно
* тот дрейф, ради которого его открывают.
*
* Продуктом является ОДИН профиль: конфиг генерирует оркестратор и сам же
* проверяет соответствие файла профилю. Поэтому панель показывает значения
* профиля так, как они записаны, и отдельно перечисляет секции вне профиля.
* Полный документ по-прежнему доступен санитизированной выгрузкой.
*
* `null` означает «в файле этого нет». Это единственный способ отличить
* отсутствие от значения: `false`, `0` и пустая строка — законные значения.
*/
export interface Hysteria2ProfileVo {
listen: string | null;
auth: Hysteria2ProfileAuth | null;
tls: Hysteria2ProfileTls | null;
acme: Hysteria2ProfileAcme | null;
obfs: Hysteria2ProfileObfs | null;
bandwidth: Hysteria2ProfileBandwidth | null;
ignoreClientBandwidth: boolean | null;
congestion: Hysteria2ProfileCongestion | null;
quic: Hysteria2ProfileQuic | null;
trafficStats: Hysteria2ProfileTrafficStats | null;
/** Секции файла, которых production-профиль не описывает. */
drift: string[];
}
export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
listen: ":443",
tls: {
cert: "",
key: "",
sniGuard: "",
},
acme: {
domains: [],
email: "",
ca: "zerossl",
listenHost: "0.0.0.0",
dir: "my_acme_dir",
type: "",
http: {
altPort: 8888,
},
tls: {
altPort: 44333,
},
dns: {
name: "gomommy",
config: {},
},
disableHTTP: false,
disableTLSALPN: false,
altHTTPPort: 80,
altTLSALPNPort: 443,
},
obfs: {
type: "salamander",
salamander: {
password: "cry_me_a_r1ver",
},
},
quic: {
initStreamReceiveWindow: 8388608,
maxStreamReceiveWindow: 8388608,
initConnReceiveWindow: 20971520,
maxConnReceiveWindow: 20971520,
maxIdleTimeout: "30s",
maxIncomingStreams: 1024,
disablePathMTUDiscovery: false,
},
bandwidth: {
up: "1 gbps",
down: "1 gbps",
},
ignoreClientBandwidth: false,
speedTest: false,
disableUDP: false,
udpIdleTimeout: "60s",
resolver: {
type: "",
tcp: {
addr: "8.8.8.8:53",
timeout: "4s",
},
udp: {
addr: "8.8.4.4:53",
timeout: "4s",
},
tls: {
addr: "1.1.1.1:853",
timeout: "10s",
sni: "cloudflare-dns.com",
insecure: false,
},
https: {
addr: "1.1.1.1:443",
timeout: "10s",
sni: "cloudflare-dns.com",
insecure: false,
},
},
sniff: {
enable: true,
timeout: "2s",
rewriteDomain: false,
tcpPorts: "80,443,8000-9000",
udpPorts: "all",
},
acl: {
file: "",
inline: [],
geoip: "",
geosite: "",
geoUpdateInterval: "168h",
},
outbounds: [],
trafficStats: {
listen: ":9999",
},
masquerade: {
type: "",
file: {
dir: "",
},
proxy: {
url: "",
rewriteHost: true,
insecure: false,
},
string: {
content: "hello stupid world",
headers: {},
statusCode: 200,
},
listenHTTP: ":80",
listenHTTPS: ":443",
forceHTTPS: true,
},
};
export interface Hysteria2ServerConfigOutbound {
name: string;
type: string;
socks5?: {
addr: string;
username?: string;
password?: string;
};
http?: {
url: string;
insecure: boolean;
};
direct?: {
mode: string;
bindIPv4?: string;
bindIPv6?: string;
bindDevice?: string;
fastOpen?: boolean;
};
export interface Hysteria2ProfileAuth {
type: string | null;
/** Адрес backend-auth без machine token: он вырезан на сервере. */
url: string | null;
insecure: boolean | null;
}
export const defaultHysteria2ServerConfigOutbound: Hysteria2ServerConfigOutbound =
{
name: "",
type: "socks5",
socks5: {
addr: "",
username: undefined,
password: undefined,
},
http: {
url: "",
insecure: false,
},
direct: {
mode: "auto",
bindIPv4: undefined,
bindIPv6: undefined,
bindDevice: undefined,
fastOpen: false,
},
};
export interface Tab {
name: string;
desc: string;
export interface Hysteria2ProfileTls {
cert: string | null;
key: string | null;
sniGuard: string | null;
clientCA: string | null;
}
export interface Hysteria2AcmePathVo {
crtPath: string;
keyPath: string;
export interface Hysteria2ProfileAcme {
domains: string[];
email: string | null;
ca: string | null;
dir: string | null;
listenHost: string | null;
type: string | null;
dnsProvider: string | null;
/** Имена параметров DNS-провайдера; значения на сервер не отдаются. */
dnsConfigKeys: string[];
}
export interface Hysteria2ProfileObfs {
type: string | null;
/**
* Пароль обфускации в панель не приходит: он входит в клиентскую ссылку и
* выдаётся там, где нужен. Диагностичен только факт, что он задан.
*/
passwordSet: boolean;
minPacketSize: number | null;
maxPacketSize: number | null;
}
export interface Hysteria2ProfileBandwidth {
up: string | null;
down: string | null;
disableLossCompensation: boolean | null;
}
export interface Hysteria2ProfileCongestion {
type: string | null;
bbrProfile: string | null;
}
export interface Hysteria2ProfileQuic {
initStreamReceiveWindow: number | null;
maxStreamReceiveWindow: number | null;
initConnReceiveWindow: number | null;
maxConnReceiveWindow: number | null;
maxIdleTimeout: string | null;
maxIncomingStreams: number | null;
disablePathMTUDiscovery: boolean | null;
disableStatelessReset: boolean | null;
}
export interface Hysteria2ProfileTrafficStats {
listen: string | null;
secretSet: boolean;
}
+16
View File
@@ -18,7 +18,23 @@ export interface DashboardSummaryVo {
};
hysteria: {
version: string;
/**
* Состояние службы по systemd.
*
* `unknown` — это НЕ «остановлена»: `systemctl is-active` может не
* ответить вовсе, и тогда о службе не известно ничего. Пока состояние было
* булевым, второе выдавалось за первое, и оператор шёл перезапускать
* работающий туннель.
*/
serviceState: "active" | "inactive" | "unknown";
/** Ровно `serviceState === "active"`. */
running: boolean;
/**
* Ответил ли Traffic Stats API на фактическое обращение.
*
* Независим от `serviceState`: раньше выводился из него и мог утверждать
* «API доступен» при «служба остановлена», ни разу не сходив в API.
*/
apiReachable: boolean;
lastApiError?: string;
};
+11 -3
View File
@@ -8,12 +8,20 @@ export interface LogExportDto {
export interface LogSystemVo {
clientIp: string;
latencyTime: string;
// Числа объявлены числами: сервер шлёт их из vo.LogSystemVo как int64
// (latencyTime, statusCode), а не строками. Пока обе колонки не
// отображались, расхождение было безвредным; после того как строка журнала
// стала читаемой, оно стало бы обычной ошибкой сравнения или форматирования.
latencyTime: number;
level: string;
msg: string;
reqMethod: string;
reqUri: string;
statusCode: string;
// Путь без query string: в query по машинному endpoint'у приезжает
// действующий machine token, и в журнале его быть не должно.
reqPath: string;
// Только имена переданных query-параметров, без значений.
reqQueryKeys: string;
statusCode: number;
time: string;
}
+48 -2
View File
@@ -4,6 +4,7 @@ import {
KickPeerDto,
PeerClientConfigVo,
PeerPageDto,
PeerPageVo,
PeerSaveDto,
PeerUpdateDto,
PeerVo,
@@ -16,15 +17,20 @@ export function getPeerApi(data: IdDto): AxiosPromise<PeerVo> {
});
}
// Форма пира показывает причины отказа под своими полями, поэтому общий тост
// ей не нужен: он повторял бы то же самое вторым сигналом.
export function savePeerApi(data: PeerSaveDto): AxiosPromise {
return request({
url: "/peers",
method: "post",
data,
skipErrorToast: true,
});
}
export function pagePeerApi(data: PeerPageDto): AxiosPromise<PageVo<PeerVo>> {
// Ответ страницы описан собственным типом, а не общим PageVo<PeerVo>: кроме
// записей он несёт признак того, известна ли вообще картина подключений.
export function pagePeerApi(data: PeerPageDto): AxiosPromise<PeerPageVo> {
return request({
url: "/peers",
method: "get",
@@ -32,10 +38,20 @@ export function pagePeerApi(data: PeerPageDto): AxiosPromise<PageVo<PeerVo>> {
});
}
// Действия над строкой пира сообщают свой исход сами (см. reportPeerActionError
// в списке пиров), поэтому общий тост им не нужен: он был бы вторым сигналом о
// том же событии.
//
// Для отзыва доступа это не косметика. Операция умеет завершиться ЧАСТИЧНО —
// запрет новых подключений записан, активную сессию завершить не удалось, — и
// страница показывает такой исход предупреждением. Общий перехватчик показал бы
// рядом ошибку, то есть два противоречащих друг другу сообщения об одном
// результате.
export function deletePeerApi(data: IdDto): AxiosPromise {
return request({
url: `/peers/${data.id}`,
method: "delete",
skipErrorToast: true,
});
}
@@ -44,6 +60,7 @@ export function updatePeerApi(data: PeerUpdateDto): AxiosPromise {
url: `/peers/${data.id}`,
method: "patch",
data,
skipErrorToast: true,
});
}
@@ -51,6 +68,7 @@ export function resetPeerTrafficApi(data: IdDto): AxiosPromise {
return request({
url: `/peers/${data.id}/reset-traffic`,
method: "patch",
skipErrorToast: true,
});
}
@@ -58,6 +76,7 @@ export function releaseKickPeerApi(data: IdDto): AxiosPromise {
return request({
url: `/peers/${data.id}/release-kick`,
method: "patch",
skipErrorToast: true,
});
}
@@ -66,6 +85,7 @@ export function kickPeerApi(id: number, data: KickPeerDto): AxiosPromise {
url: `/peers/${id}/kick`,
method: "patch",
data,
skipErrorToast: true,
});
}
@@ -78,6 +98,15 @@ export function getPeerClientConfigApi(
});
}
// Импорт сообщает свой исход сам — по той же причине, что и действия строки
// пира.
//
// Партия применяется одной транзакцией, а после её фиксации завершаются старые
// сессии обновлённых пиров. Второй шаг умеет не удаться отдельно от первого, и
// тогда ответ несёт peer_disconnect_failed: файл применён целиком, но часть
// клиентов остаётся на связи до переподключения. Общий перехватчик показал бы
// такой исход красной ошибкой, то есть сообщил бы оператору ровно обратное
// тому, что произошло.
export function importPeerApi(data: FormData): AxiosPromise {
return request({
url: "/peer-import",
@@ -86,13 +115,30 @@ export function importPeerApi(data: FormData): AxiosPromise {
"Content-Type": "multipart/form-data",
},
data,
skipErrorToast: true,
});
}
export function exportPeerApi(): AxiosPromise {
/**
* Экспорт пиров в двух режимах, и это продуктовое различие, а не флаг.
*
* Без секретов — список настроек пиров. Файл безопасно хранить, но при импорте
* на другой сервер записям с пустым секретом выдаются НОВЫЕ секреты, то есть
* прежние клиентские ссылки перестают работать.
*
* С секретами — резервная копия, сохраняющая работоспособность существующих
* ссылок. Файл содержит действующие учётные данные доступа, поэтому запускается
* только через явное подтверждение оператора.
*
* Раньше кнопка была одна и всегда звала маршрут без includeSecrets, а
* документация называла эту пару механизмом переноса пиров. Оператор
* переносил пиров и обнаруживал, что все клиенты отвалились.
*/
export function exportPeerApi(includeSecrets = false): AxiosPromise {
return request({
url: "/peer-export",
method: "post",
params: includeSecrets ? { includeSecrets: "true" } : undefined,
responseType: "blob",
});
}
+45 -2
View File
@@ -24,7 +24,28 @@ export interface PeerSaveDto {
remark: string;
}
export interface PeerVo extends IdDto {
/**
* Строка списка пиров.
*
* Объявлена через `type`, а не `interface`, и это не стилистика.
*
* `el-table-column` типизирует scope своего default-слота как
* `{ row: DefaultRow, ... }`, где `DefaultRow = Record<PropertyKey, any>`:
* колонка — отдельный компонент, и тип строки из `:data` родительской таблицы
* в неё не попадает. Значит, где-то на пути от слота к обработчику нужен ровно
* один переход `DefaultRow -> PeerVo` (см. `peerRow` в списке пиров).
*
* TypeScript даёт неявную индексную сигнатуру псевдонимам типов, но НЕ
* интерфейсам и не пересечениям с ними. Пока `PeerVo` был `interface`, он не
* был совместим с `DefaultRow` ни в одну сторону, и этот переход требовал
* двойного приведения через `unknown` — то есть утверждения, которое компилятор
* вообще не проверяет. С псевдонимом типа переход остаётся одним обычным
* приведением между связанными типами.
*/
export type PeerVo = {
// `id` объявлен здесь, а не унаследован от `IdDto`: пересечение с интерфейсом
// неявную индексную сигнатуру не получает.
id: number;
name: string;
remark: string;
authId: string;
@@ -39,11 +60,33 @@ export interface PeerVo extends IdDto {
createTime: string;
online: boolean;
onlineDevices: number;
};
/**
* Известна ли панели картина подключений прямо сейчас.
*
* Признак относится к ответу целиком, а не к строке: при `unavailable` поле
* `online` в строках не означает ничего. Раньше отказ Traffic Stats API молча
* превращался в «все офлайн» — ответ, уводящий оператора искать проблему у
* пользователей.
*/
export type PeerOnlineState = "ok" | "unavailable";
export interface PeerPageVo {
records: PeerVo[];
total: number;
onlineState: PeerOnlineState;
}
/**
* Клиентская ссылка пира.
*
* Поля `qrCode` здесь больше нет: QR рисуется в панели из самой ссылки
* (qrcode.vue), и второй его экземпляр в ответе был лишним трафиком и вторым
* способом получить то же самое.
*/
export interface PeerClientConfigVo {
url: string;
qrCode?: string | Uint8Array;
}
export interface KickPeerDto {
+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720229787" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8983" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M512 720m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z" p-id="8984" fill="#000000"></path><path d="M480 416v184c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V416c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8z" p-id="8985" fill="#000000"></path><path d="M955.7 856l-416-720c-6.2-10.7-16.9-16-27.7-16s-21.6 5.3-27.7 16l-416 720C56 877.4 71.4 904 96 904h832c24.6 0 40-26.6 27.7-48z m-783.5-27.9L512 239.9l339.8 588.2H172.2z" p-id="8986" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720229787" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8983" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M512 720m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z" p-id="8984" fill="currentColor"></path><path d="M480 416v184c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V416c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8z" p-id="8985" fill="currentColor"></path><path d="M955.7 856l-416-720c-6.2-10.7-16.9-16-27.7-16s-21.6 5.3-27.7 16l-416 720C56 877.4 71.4 904 96 904h832c24.6 0 40-26.6 27.7-48z m-783.5-27.9L512 239.9l339.8 588.2H172.2z" p-id="8986" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 768 B

After

Width:  |  Height:  |  Size: 783 B

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720422565" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15443" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M235.5 871.691v-740h98v304h385v-304h98v740h-98v-349h-385v349h-98z" p-id="15444" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720422565" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15443" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M235.5 871.691v-740h98v304h385v-304h98v740h-98v-349h-385v349h-98z" p-id="15444" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 440 B

After

Width:  |  Height:  |  Size: 445 B

@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720786193" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10390" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M688 312v-48c0-4.4-3.6-8-8-8H296c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h384c4.4 0 8-3.6 8-8zM296 400c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h184c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H296z" p-id="10391" fill="#000000"></path><path d="M440 852H208V148h560v344c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V108c0-17.7-14.3-32-32-32H168c-17.7 0-32 14.3-32 32v784c0 17.7 14.3 32 32 32h272c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" p-id="10392" fill="#000000"></path><path d="M885.7 903.5l-93.3-93.3C814.7 780.7 828 743.9 828 704c0-97.2-78.8-176-176-176s-176 78.8-176 176 78.8 176 176 176c35.8 0 69-10.7 96.8-29l94.7 94.7c1.6 1.6 3.6 2.3 5.6 2.3s4.1-0.8 5.6-2.3l31-31c3.1-3.1 3.1-8.1 0-11.2zM652 816c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z" p-id="10393" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720786193" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10390" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M688 312v-48c0-4.4-3.6-8-8-8H296c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h384c4.4 0 8-3.6 8-8zM296 400c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h184c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H296z" p-id="10391" fill="currentColor"></path><path d="M440 852H208V148h560v344c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V108c0-17.7-14.3-32-32-32H168c-17.7 0-32 14.3-32 32v784c0 17.7 14.3 32 32 32h272c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" p-id="10392" fill="currentColor"></path><path d="M885.7 903.5l-93.3-93.3C814.7 780.7 828 743.9 828 704c0-97.2-78.8-176-176-176s-176 78.8-176 176 78.8 176 176 176c35.8 0 69-10.7 96.8-29l94.7 94.7c1.6 1.6 3.6 2.3 5.6 2.3s4.1-0.8 5.6-2.3l31-31c3.1-3.1 3.1-8.1 0-11.2zM652 816c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z" p-id="10393" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714755103595" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8918" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M193 796c0 17.7 14.3 32 32 32h574c17.7 0 32-14.3 32-32V563c0-176.2-142.8-319-319-319S193 386.8 193 563v233z m72-233c0-136.4 110.6-247 247-247s247 110.6 247 247v193H404V585c0-5.5-4.5-10-10-10h-44c-5.5 0-10 4.5-10 10v171h-75V563zM216.9 310.5l39.6-39.6c3.1-3.1 3.1-8.2 0-11.3l-67.9-67.9c-3.1-3.1-8.2-3.1-11.3 0l-39.6 39.6c-3.1 3.1-3.1 8.2 0 11.3l67.9 67.9c3.1 3.1 8.1 3.1 11.3 0zM886.5 231.3l-39.6-39.6c-3.1-3.1-8.2-3.1-11.3 0l-67.9 67.9c-3.1 3.1-3.1 8.2 0 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l67.9-67.9c3.1-3.2 3.1-8.2 0-11.3zM832 892H192c-17.7 0-32 14.3-32 32v24c0 4.4 3.6 8 8 8h688c4.4 0 8-3.6 8-8v-24c0-17.7-14.3-32-32-32zM484 180h56c4.4 0 8-3.6 8-8V76c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v96c0 4.4 3.6 8 8 8z" p-id="8919" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714755103595" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8918" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M193 796c0 17.7 14.3 32 32 32h574c17.7 0 32-14.3 32-32V563c0-176.2-142.8-319-319-319S193 386.8 193 563v233z m72-233c0-136.4 110.6-247 247-247s247 110.6 247 247v193H404V585c0-5.5-4.5-10-10-10h-44c-5.5 0-10 4.5-10 10v171h-75V563zM216.9 310.5l39.6-39.6c3.1-3.1 3.1-8.2 0-11.3l-67.9-67.9c-3.1-3.1-8.2-3.1-11.3 0l-39.6 39.6c-3.1 3.1-3.1 8.2 0 11.3l67.9 67.9c3.1 3.1 8.1 3.1 11.3 0zM886.5 231.3l-39.6-39.6c-3.1-3.1-8.2-3.1-11.3 0l-67.9 67.9c-3.1 3.1-3.1 8.2 0 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l67.9-67.9c3.1-3.2 3.1-8.2 0-11.3zM832 892H192c-17.7 0-32 14.3-32 32v24c0 4.4 3.6 8 8 8h688c4.4 0 8-3.6 8-8v-24c0-17.7-14.3-32-32-32zM484 180h56c4.4 0 8-3.6 8-8V76c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v96c0 4.4 3.6 8 8 8z" p-id="8919" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720044650" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8586" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M312.1 591.5c3.1 3.1 8.2 3.1 11.3 0l101.8-101.8 86.1 86.2c3.1 3.1 8.2 3.1 11.3 0l226.3-226.5c3.1-3.1 3.1-8.2 0-11.3l-36.8-36.8c-3.1-3.1-8.2-3.1-11.3 0L517 485.3l-86.1-86.2c-3.1-3.1-8.2-3.1-11.3 0L275.3 543.4c-3.1 3.1-3.1 8.2 0 11.3l36.8 36.8z" p-id="8587" fill="#000000"></path><path d="M904 160H548V96c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H120c-17.7 0-32 14.3-32 32v520c0 17.7 14.3 32 32 32h356.4v32L311.6 884.1c-3.7 2.4-4.7 7.3-2.3 11l30.3 47.2v0.1c2.4 3.7 7.4 4.7 11.1 2.3L512 838.9l161.3 105.8c3.7 2.4 8.7 1.4 11.1-2.3v-0.1l30.3-47.2c2.4-3.7 1.3-8.6-2.3-11L548 776.3V744h356c17.7 0 32-14.3 32-32V192c0-17.7-14.3-32-32-32z m-40 512H160V232h704v440z" p-id="8588" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720044650" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8586" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M312.1 591.5c3.1 3.1 8.2 3.1 11.3 0l101.8-101.8 86.1 86.2c3.1 3.1 8.2 3.1 11.3 0l226.3-226.5c3.1-3.1 3.1-8.2 0-11.3l-36.8-36.8c-3.1-3.1-8.2-3.1-11.3 0L517 485.3l-86.1-86.2c-3.1-3.1-8.2-3.1-11.3 0L275.3 543.4c-3.1 3.1-3.1 8.2 0 11.3l36.8 36.8z" p-id="8587" fill="currentColor"></path><path d="M904 160H548V96c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H120c-17.7 0-32 14.3-32 32v520c0 17.7 14.3 32 32 32h356.4v32L311.6 884.1c-3.7 2.4-4.7 7.3-2.3 11l30.3 47.2v0.1c2.4 3.7 7.4 4.7 11.1 2.3L512 838.9l161.3 105.8c3.7 2.4 8.7 1.4 11.1-2.3v-0.1l30.3-47.2c2.4-3.7 1.3-8.6-2.3-11L548 776.3V744h356c17.7 0 32-14.3 32-32V192c0-17.7-14.3-32-32-32z m-40 512H160V232h704v440z" p-id="8588" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714719706106" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9222" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M924.8 625.7l-65.5-56c3.1-19 4.7-38.4 4.7-57.8s-1.6-38.8-4.7-57.8l65.5-56c10.1-8.6 13.8-22.6 9.3-35.2l-0.9-2.6c-18.1-50.5-44.9-96.9-79.7-137.9l-1.8-2.1c-8.6-10.1-22.5-13.9-35.1-9.5l-81.3 28.9c-30-24.6-63.5-44-99.7-57.6l-15.7-85c-2.4-13.1-12.7-23.3-25.8-25.7l-2.7-0.5c-52.1-9.4-106.9-9.4-159 0l-2.7 0.5c-13.1 2.4-23.4 12.6-25.8 25.7l-15.8 85.4c-35.9 13.6-69.2 32.9-99 57.4l-81.9-29.1c-12.5-4.4-26.5-0.7-35.1 9.5l-1.8 2.1c-34.8 41.1-61.6 87.5-79.7 137.9l-0.9 2.6c-4.5 12.5-0.8 26.5 9.3 35.2l66.3 56.6c-3.1 18.8-4.6 38-4.6 57.1 0 19.2 1.5 38.4 4.6 57.1L99 625.5c-10.1 8.6-13.8 22.6-9.3 35.2l0.9 2.6c18.1 50.4 44.9 96.9 79.7 137.9l1.8 2.1c8.6 10.1 22.5 13.9 35.1 9.5l81.9-29.1c29.8 24.5 63.1 43.9 99 57.4l15.8 85.4c2.4 13.1 12.7 23.3 25.8 25.7l2.7 0.5c26.1 4.7 52.8 7.1 79.5 7.1 26.7 0 53.5-2.4 79.5-7.1l2.7-0.5c13.1-2.4 23.4-12.6 25.8-25.7l15.7-85c36.2-13.6 69.7-32.9 99.7-57.6l81.3 28.9c12.5 4.4 26.5 0.7 35.1-9.5l1.8-2.1c34.8-41.1 61.6-87.5 79.7-137.9l0.9-2.6c4.5-12.3 0.8-26.3-9.3-35zM788.3 465.9c2.5 15.1 3.8 30.6 3.8 46.1s-1.3 31-3.8 46.1l-6.6 40.1 74.7 63.9c-11.3 26.1-25.6 50.7-42.6 73.6L721 702.8l-31.4 25.8c-23.9 19.6-50.5 35-79.3 45.8l-38.1 14.3-17.9 97c-28.1 3.2-56.8 3.2-85 0l-17.9-97.2-37.8-14.5c-28.5-10.8-55-26.2-78.7-45.7l-31.4-25.9-93.4 33.2c-17-22.9-31.2-47.6-42.6-73.6l75.5-64.5-6.5-40c-2.4-14.9-3.7-30.3-3.7-45.5 0-15.3 1.2-30.6 3.7-45.5l6.5-40-75.5-64.5c11.3-26.1 25.6-50.7 42.6-73.6l93.4 33.2 31.4-25.9c23.7-19.5 50.2-34.9 78.7-45.7l37.9-14.3 17.9-97.2c28.1-3.2 56.8-3.2 85 0l17.9 97 38.1 14.3c28.7 10.8 55.4 26.2 79.3 45.8l31.4 25.8 92.8-32.9c17 22.9 31.2 47.6 42.6 73.6L781.8 426l6.5 39.9z" p-id="9223" fill="#000000"></path><path d="M512 326c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176z m79.2 255.2C570 602.3 541.9 614 512 614c-29.9 0-58-11.7-79.2-32.8C411.7 560 400 531.9 400 502c0-29.9 11.7-58 32.8-79.2C454 401.6 482.1 390 512 390c29.9 0 58 11.6 79.2 32.8C612.3 444 624 472.1 624 502c0 29.9-11.7 58-32.8 79.2z" p-id="9224" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714719706106" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9222" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M924.8 625.7l-65.5-56c3.1-19 4.7-38.4 4.7-57.8s-1.6-38.8-4.7-57.8l65.5-56c10.1-8.6 13.8-22.6 9.3-35.2l-0.9-2.6c-18.1-50.5-44.9-96.9-79.7-137.9l-1.8-2.1c-8.6-10.1-22.5-13.9-35.1-9.5l-81.3 28.9c-30-24.6-63.5-44-99.7-57.6l-15.7-85c-2.4-13.1-12.7-23.3-25.8-25.7l-2.7-0.5c-52.1-9.4-106.9-9.4-159 0l-2.7 0.5c-13.1 2.4-23.4 12.6-25.8 25.7l-15.8 85.4c-35.9 13.6-69.2 32.9-99 57.4l-81.9-29.1c-12.5-4.4-26.5-0.7-35.1 9.5l-1.8 2.1c-34.8 41.1-61.6 87.5-79.7 137.9l-0.9 2.6c-4.5 12.5-0.8 26.5 9.3 35.2l66.3 56.6c-3.1 18.8-4.6 38-4.6 57.1 0 19.2 1.5 38.4 4.6 57.1L99 625.5c-10.1 8.6-13.8 22.6-9.3 35.2l0.9 2.6c18.1 50.4 44.9 96.9 79.7 137.9l1.8 2.1c8.6 10.1 22.5 13.9 35.1 9.5l81.9-29.1c29.8 24.5 63.1 43.9 99 57.4l15.8 85.4c2.4 13.1 12.7 23.3 25.8 25.7l2.7 0.5c26.1 4.7 52.8 7.1 79.5 7.1 26.7 0 53.5-2.4 79.5-7.1l2.7-0.5c13.1-2.4 23.4-12.6 25.8-25.7l15.7-85c36.2-13.6 69.7-32.9 99.7-57.6l81.3 28.9c12.5 4.4 26.5 0.7 35.1-9.5l1.8-2.1c34.8-41.1 61.6-87.5 79.7-137.9l0.9-2.6c4.5-12.3 0.8-26.3-9.3-35zM788.3 465.9c2.5 15.1 3.8 30.6 3.8 46.1s-1.3 31-3.8 46.1l-6.6 40.1 74.7 63.9c-11.3 26.1-25.6 50.7-42.6 73.6L721 702.8l-31.4 25.8c-23.9 19.6-50.5 35-79.3 45.8l-38.1 14.3-17.9 97c-28.1 3.2-56.8 3.2-85 0l-17.9-97.2-37.8-14.5c-28.5-10.8-55-26.2-78.7-45.7l-31.4-25.9-93.4 33.2c-17-22.9-31.2-47.6-42.6-73.6l75.5-64.5-6.5-40c-2.4-14.9-3.7-30.3-3.7-45.5 0-15.3 1.2-30.6 3.7-45.5l6.5-40-75.5-64.5c11.3-26.1 25.6-50.7 42.6-73.6l93.4 33.2 31.4-25.9c23.7-19.5 50.2-34.9 78.7-45.7l37.9-14.3 17.9-97.2c28.1-3.2 56.8-3.2 85 0l17.9 97 38.1 14.3c28.7 10.8 55.4 26.2 79.3 45.8l31.4 25.8 92.8-32.9c17 22.9 31.2 47.6 42.6 73.6L781.8 426l6.5 39.9z" p-id="9223" fill="currentColor"></path><path d="M512 326c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176z m79.2 255.2C570 602.3 541.9 614 512 614c-29.9 0-58-11.7-79.2-32.8C411.7 560 400 531.9 400 502c0-29.9 11.7-58 32.8-79.2C454 401.6 482.1 390 512 390c29.9 0 58 11.6 79.2 32.8C612.3 444 624 472.1 624 502c0 29.9-11.7 58-32.8 79.2z" p-id="9224" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745361102" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9516" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M858.5 763.6c-18.9-44.8-46.1-85-80.6-119.5-34.5-34.5-74.7-61.6-119.5-80.6-0.4-0.2-0.8-0.3-1.2-0.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-0.4 0.2-0.8 0.3-1.2 0.5-44.8 18.9-85 46-119.5 80.6-34.5 34.5-61.6 74.7-80.6 119.5C146.9 807.5 137 854 136 901.8c-0.1 4.5 3.5 8.2 8 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c0.1 4.4 3.6 7.8 8 7.8h60c4.5 0 8.1-3.7 8-8.2-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z" p-id="9517" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745361102" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9516" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M858.5 763.6c-18.9-44.8-46.1-85-80.6-119.5-34.5-34.5-74.7-61.6-119.5-80.6-0.4-0.2-0.8-0.3-1.2-0.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-0.4 0.2-0.8 0.3-1.2 0.5-44.8 18.9-85 46-119.5 80.6-34.5 34.5-61.6 74.7-80.6 119.5C146.9 807.5 137 854 136 901.8c-0.1 4.5 3.5 8.2 8 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c0.1 4.4 3.6 7.8 8 7.8h60c4.5 0 8.1-3.7 8-8.2-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z" p-id="9517" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745286527" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9317" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M824.2 699.9c-25.4-25.4-54.7-45.7-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5-31.7 14.7-60.9 34.9-86.4 60.4C345 754.6 314 826.8 312 903.8c-0.1 4.5 3.5 8.2 8 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5C493.8 707.7 551.1 684 612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c0.1 4.3 3.7 7.7 8 7.7h56c4.5 0 8.1-3.7 8-8.2-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5-24.5-24.5-37.9-57.1-37.5-91.8 0.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-0.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5-24.2 24.2-56.4 37.5-90.6 37.5z" p-id="9318" fill="#000000"></path><path d="M361.5 510.4c-0.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5 0.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1-25.8-25.2-39.7-59.3-38.7-95.4 0.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9 0.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-0.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204-0.1 4.5 3.5 8.2 8 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z" p-id="9319" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745286527" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9317" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M824.2 699.9c-25.4-25.4-54.7-45.7-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5-31.7 14.7-60.9 34.9-86.4 60.4C345 754.6 314 826.8 312 903.8c-0.1 4.5 3.5 8.2 8 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5C493.8 707.7 551.1 684 612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c0.1 4.3 3.7 7.7 8 7.7h56c4.5 0 8.1-3.7 8-8.2-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5-24.5-24.5-37.9-57.1-37.5-91.8 0.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-0.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5-24.2 24.2-56.4 37.5-90.6 37.5z" p-id="9318" fill="currentColor"></path><path d="M361.5 510.4c-0.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5 0.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1-25.8-25.2-39.7-59.3-38.7-95.4 0.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9 0.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-0.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204-0.1 4.5 3.5 8.2 8 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z" p-id="9319" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,77 +0,0 @@
<template>
<draggable class="flex gap-2" :list="tags" item-key="id" animation="200">
<template #item="{ element }">
<el-tag closable @close="handleClose(element)" size="large">
{{ element }}
</el-tag>
</template>
<template #footer>
<el-input
v-if="inputVisible"
ref="inputRef"
v-model="tag"
class="w-50"
@keyup.enter="handleConfirm"
@blur="handleConfirm"
/>
<el-button v-else @click="showInput">+</el-button>
</template>
</draggable>
</template>
<script setup lang="ts">
import draggable from "vuedraggable";
import { ElInput } from "element-plus";
import { PropType } from "vue";
const props = defineProps({
tags: {
required: false,
type: Array as PropType<string[]>,
default: () => [],
},
});
const emit = defineEmits<{
(event: "update:tags", value: string[]): void;
}>();
const tags = useVModel(props, "tags", emit);
const inputRef = ref(ElInput);
const state = reactive({
tag: "",
inputVisible: false,
});
const { tag, inputVisible } = toRefs(state);
const showInput = () => {
state.inputVisible = true;
nextTick(() => {
inputRef.value!.input!.focus();
});
};
const handleConfirm = (): void => {
const newTag = state.tag.trim();
if (newTag && !tags.value?.includes(newTag)) {
tags.value?.push(newTag);
state.tag = "";
}
state.inputVisible = false;
};
const handleClose = (tag: string): void => {
const index = tags.value?.indexOf(tag.trim());
if (index !== -1) {
tags.value?.splice(index, 1);
}
};
</script>
<style lang="scss" scoped>
.flex.gap-2 {
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,169 @@
<template>
<div class="app-container">
<div class="page-toolbar">
<el-form :model="queryParams" :inline="true">
<el-form-item :label="$t('log.numLine')" prop="numLine">
<el-select
v-model="queryParams.numLine"
style="width: 200px"
@change="load"
>
<el-option
v-for="option in LINE_OPTIONS"
:key="option"
:label="String(option)"
:value="option"
/>
</el-select>
</el-form-item>
<el-form-item prop="export">
<el-button :loading="exporting" @click="handleExport">
<template #icon>
<i-ep-download />
</template>
{{ $t("common.export") }}
</el-button>
</el-form-item>
<el-form-item prop="refresh">
<el-button @click="load">
<template #icon>
<i-ep-refresh />
</template>
{{ $t("common.refresh") }}
</el-button>
</el-form-item>
</el-form>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="records" class="log-table">
<!--
Ширины заданы явно и по назначению колонки.
Без width/min-width Element Plus делит доступную ширину между
колонками практически поровну: уровень и время получали по трети
строки, а сообщение тоже треть, хотя именно оно и является
содержимым журнала. У Hysteria в msg приезжает диагностический JSON,
поэтому колонка сообщения растягивается за счёт остатка, а служебные
колонки зафиксированы.
-->
<el-table-column
prop="level"
label="level"
width="110"
align="center"
/>
<el-table-column prop="msg" label="msg" min-width="640" align="left">
<template #default="scope">
<!--
Сообщение ПЕРЕНОСИТСЯ, а не обрезается многоточием: у Hysteria
это диагностический JSON, и обрезанная строка не отвечает ни на
один вопрос, ради которого страницу открыли.
-->
<span class="log-message">{{ scope.row.msg }}</span>
</template>
</el-table-column>
<el-table-column prop="time" label="time" width="200" align="center" />
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
/**
* Общая страница журнала: системного и Hysteria.
*
* Обе страницы были побайтово одинаковы, кроме вызываемого API, и обе несли
* одни и те же три дефекта: колонки без ширин, экспорт с сетевым запросом ВНЕ
* try и пустой catch, глушивший всё остальное. Починка в двух местах разошлась
* бы снова — как разошлись форма ответа и обработка ошибок до этого.
*
* Различие между страницами вынесено в параметры: чем читать и что выгружать.
*/
import { AxiosPromise, AxiosResponse } from "axios";
import { ElMessage } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import { useI18n } from "vue-i18n";
import { exportLogApi } from "@/api/log";
import { LogDto } from "@/api/log/types";
import { saveResponseAsFile } from "@/utils/download";
/** Строка журнала в объёме, который показывает таблица. */
interface LogRow {
level: string;
msg: string;
time: string;
}
const props = defineProps<{
/** Чтение страницы журнала. */
fetchLogs: (query: LogDto) => AxiosPromise<PageVo<LogRow>>;
/** Значение option для выгрузки: 0 — журнал админки, 1 — журнал Hysteria. */
exportOption: number;
/** Имя файла, если сервер его не назвал. */
exportFileName: string;
}>();
const LINE_OPTIONS = [100, 200, 300];
const { t } = useI18n();
const loading = ref(false);
const exporting = ref(false);
const records = ref<LogRow[]>([]);
const queryParams = reactive<LogDto>({ numLine: LINE_OPTIONS[0] });
async function load() {
loading.value = true;
try {
const { data } = await props.fetchLogs(queryParams);
// Ответ обязан нести records на любой ветке — это контракт LogSystemPage.
// Запасное значение здесь стоит не вместо контракта, а чтобы отказ формы
// ответа не превращался в исключение внутри таблицы.
records.value = data?.records ?? [];
} finally {
loading.value = false;
}
}
/**
* Выгрузка журнала.
*
* Сетевой запрос находится ВНУТРИ try. Раньше он стоял перед ним, поэтому его
* отказ не ловился этим catch вовсе, а сам catch был пуст: оператор не получал
* ни файла, ни причины.
*
* Отказ показывается ОДИН раз. Выгрузка приходит бинарным потоком и не
* проходит через общий разбор конверта — у Blob нет полей code и errors, —
* поэтому фразу здесь даёт сама страница.
*/
async function handleExport() {
exporting.value = true;
try {
const response: AxiosResponse = await exportLogApi({
option: props.exportOption,
});
saveResponseAsFile(response, props.exportFileName);
ElMessage.success(t("common.downloadSuccess"));
} catch {
ElMessage.error(t("log.exportFailed"));
} finally {
exporting.value = false;
}
}
onMounted(load);
</script>
<style scoped>
.log-message {
display: inline-block;
width: 100%;
font-family: var(--el-font-family-mono, monospace);
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
</style>
@@ -1,182 +0,0 @@
<template>
<div class="flex gap-2">
<el-tag
:key="key"
v-for="(value, key) in mapObject"
@close="handleClose(key)"
@click="handleInfo(key)"
size="large"
closable
>
{{ key }}
</el-tag>
<el-button @click="handleAdd">+</el-button>
<el-dialog
:title="dialog.title"
v-model="dialog.visible"
width="600px"
append-to-body
@close="closeDialog"
>
<el-form
ref="dataFormRef"
:rules="dataFormRules"
label-position="top"
:model="dataForm"
>
<el-form-item label="key" prop="key">
<el-input v-model="dataForm.key" clearable />
</el-form-item>
<el-form-item label="value" prop="value">
<el-input v-model="dataForm.value" clearable />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitForm"
>{{ $t("common.confirm") }}
</el-button>
<el-button @click="closeDialog">{{ $t("common.cancel") }}</el-button>
</div>
</template>
</el-dialog>
<el-dialog
:title="infoDialog.title"
v-model="infoDialog.visible"
width="600px"
append-to-body
@close="infoDialog.visible = false"
>
<el-form label-position="top">
<el-form-item label="key" prop="key">
<el-tag>{{ dataInfo.key }}</el-tag>
</el-form-item>
<el-form-item label="value" prop="value">
<el-tag>{{ dataInfo.value }}</el-tag>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="infoDialog.visible = false"
>{{ $t("common.cancel") }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
export default {
name: "mapObject",
};
</script>
<script setup lang="ts">
import { PropType } from "vue";
import { useI18n } from "vue-i18n";
interface Form {
key: string;
value: string;
}
const props = defineProps({
mapObject: {
required: false,
type: Object as PropType<{ [key: string]: string }>,
default: () => ({}),
},
});
const emit = defineEmits<{
(event: "update:mapObject", value: { [key: string]: string }): void;
}>();
const mapObject = useVModel(props, "mapObject", emit);
const { t } = useI18n();
const dataFormRef = ref(ElForm);
const dataFormRules = {
key: [
{
required: true,
message: t("common.required"),
trigger: ["change", "blur"],
},
],
value: [
{
required: true,
message: t("common.required"),
trigger: ["change", "blur"],
},
],
};
const state = reactive({
dataForm: {
key: "",
value: "",
} as Form,
dialog: {
title: "Add",
visible: false,
} as DialogType,
infoDialog: {
title: "Info",
visible: false,
},
dataInfo: {
key: "",
value: "",
} as Form,
});
const { dataForm, dialog, infoDialog, dataInfo } = toRefs(state);
const handleAdd = () => {
state.dialog.visible = true;
};
const handleClose = (key: string): void => {
delete mapObject.value[key];
};
const handleInfo = (key: string) => {
state.dataInfo = {
key: key,
value: mapObject.value[key] || "",
};
state.infoDialog.visible = true;
};
const submitForm = () => {
dataFormRef.value.validate((valid: any) => {
if (valid) {
if (mapObject.value[state.dataForm.key]) {
ElMessage.error(t("common.invalid"));
return;
}
mapObject.value[state.dataForm.key] = state.dataForm.value;
closeDialog();
}
});
};
const closeDialog = (): void => {
state.dialog.visible = false;
dataFormRef.value.resetFields();
dataFormRef.value.clearValidate();
};
</script>
<style lang="scss" scoped>
.flex.gap-2 {
flex-wrap: wrap;
}
</style>
+18 -9
View File
@@ -1,33 +1,42 @@
<template>
<svg
aria-hidden="true"
focusable="false"
class="svg-icon"
:style="'width:' + size + ';height:' + size"
>
<use :xlink:href="symbolId" :fill="color" />
<use :xlink:href="symbolId" />
</svg>
</template>
<script setup lang="ts">
import { SYMBOL_PREFIX } from "./symbol";
/**
* Цвет иконке не передаётся — и это контракт, а не упущение.
*
* Раньше здесь были проп `color` и `:fill="color"` на `<use>`. Ими никто не
* пользовался ни разу, а существование такого пропа приглашает чинить
* сломанный цвет точечно: «вот этой иконке передадим белый». Монохромная
* иконка обязана получать цвет ровно одним способом — наследованием
* `currentColor` от компонента и темы; ассет, который так не умеет, чинится в
* самом ассете и не доезжает до релиза (см. `symbol.ts`).
*
* Префикс id тоже больше не проп: он принадлежит спрайту, а не месту вызова, и
* объявлен рядом с кодом, который этот id создаёт.
*/
const props = defineProps({
prefix: {
type: String,
default: "icon",
},
iconClass: {
type: String,
required: false,
},
color: {
type: String,
},
size: {
type: String,
default: "1em",
},
});
const symbolId = computed(() => `#${props.prefix}-${props.iconClass}`);
const symbolId = computed(() => `#${SYMBOL_PREFIX}-${props.iconClass}`);
</script>
<style scoped>
@@ -0,0 +1,77 @@
/**
* Спрайт локальных SVG-иконок.
*
* Заменяет `vite-plugin-svg-icons` и его `virtual:svg-icons-register`.
*
* Почему плагин убран. Он не обновлялся с 2022 года и тянул за собой цепочку,
* которую починить нельзя: `svgo@2.8.0`, `postcss@5.2.18`, `braces@2.3.2` и
* `image-size@0.5.5` — у последней в advisory прямо указано `Patched versions:
* <0.0.0`, то есть исправленной версии не существует. Один build-плагин
* оставался единственным источником половины оставшихся предупреждений
* `pnpm audit` во всём проекте.
*
* Задача, которую он решал, здесь занимает сорок строк: собрать семнадцать
* файлов в один скрытый `<svg>` со `<symbol>` внутри, чтобы `<use href="#...">`
* находил их по имени. Имя часто вычисляется в рантайме
* (`onlyOneChild.meta.icon`, `isFullscreen ? 'exit-fullscreen' : 'fullscreen'`),
* поэтому подход «компонент на иконку», как у unplugin-icons, здесь не
* подходит — спрайт с поиском по id нужен именно для этого.
*
* Оптимизация через SVGO при этом потеряна. Для семнадцати вручную отобранных
* иконок это несколько килобайт, и они не стоят неисправимой зависимости в
* сборке.
*
* Преобразование файла в `<symbol>` и контракт ассета живут в `./symbol.ts`:
* там нет ни Vite, ни DOM, поэтому те же правила проверяются тестом и
* релизным гейтом, а не только глазами на живой странице.
*/
import { iconName, toSymbol } from "./symbol";
const SPRITE_ELEMENT_ID = "__hy2xs_svg_sprite__";
// eager: файлы читаются на этапе сборки и попадают в бандл строками, сетевых
// запросов за иконками не возникает.
const sources = import.meta.glob<string>("@/assets/icons/*.svg", {
query: "?raw",
import: "default",
eager: true,
});
/**
* Вставляет спрайт в документ. Идемпотентна: повторный вызов заменяет
* содержимое, а не добавляет второй элемент с теми же id.
*/
export function registerSvgIcons(): void {
if (typeof document === "undefined") {
return;
}
const symbols = Object.entries(sources)
.map(([filePath, raw]) => toSymbol(raw, iconName(filePath)))
.filter(Boolean)
.join("");
// Тип уточняется явно: getElementById возвращает HTMLElement | null, а
// спрайт — SVGSVGElement, и без этого он не примет ни собственный
// createElementNS, ни последующие обращения.
let sprite = document.getElementById(
SPRITE_ELEMENT_ID
) as SVGSVGElement | null;
if (!sprite) {
sprite = document.createElementNS("http://www.w3.org/2000/svg", "svg");
sprite.id = SPRITE_ELEMENT_ID;
sprite.setAttribute("aria-hidden", "true");
// Скрываем так же, как это делал плагин: элемент обязан остаться в
// документе, иначе `<use>` не найдёт symbol, поэтому display:none не
// подходит — нужен нулевой размер.
sprite.setAttribute(
"style",
"position:absolute;width:0;height:0;overflow:hidden"
);
document.body.insertBefore(sprite, document.body.firstChild);
}
sprite.innerHTML = symbols;
}
@@ -0,0 +1,206 @@
/**
* Превращение исходного SVG-файла в `<symbol>` и контракт, которому исходник
* обязан соответствовать.
*
* Модуль намеренно ЧИСТЫЙ: ни `import.meta.glob`, ни `document`, ни любого
* другого Vite/DOM API здесь нет. Сборка спрайта из файлов живёт в `sprite.ts`,
* а сюда вынесено ровно то, что можно выполнить вне браузера и вне Vite —
* то есть проверить тестом (`tools/test/frontend-sprite.test.ts`) и релизным
* гейтом.
*
* Разделение появилось не ради красоты. Цвет иконок был сломан молча: контракт
* `fill: currentcolor` существовал в двух местах (`SvgIcon/index.vue` и
* `styles/sidebar.scss`), но восемь из семнадцати ассетов несли литеральный
* атрибут `fill="#000000"` прямо на `<path>`, а атрибут представления
* перебивает унаследованное CSS-свойство. Все семь иконок бокового меню
* рисовались чёрным по `--menuBg: #181818`. Ни одна существующая проверка
* этого не видела, потому что проверять было нечего: сам файл иконки под
* гейтом не был.
*/
/** Префикс id у `<symbol>`; `SvgIcon` строит по нему `<use href="#icon-…">`. */
export const SYMBOL_PREFIX = "icon";
/**
* Иконки, которые многоцветны НАМЕРЕННО.
*
* Для них собственная палитра — часть ассета, а не дефект, поэтому проверка
* цвета к ним не применяется. Список закрытый и явный: «многоцветность»
* обязана быть решением, а не следствием того, что иконку скачали с готовыми
* значениями fill.
*
* Всё остальное — монохромный UI: цвет наследуется от компонента и темы через
* `currentColor`, и это единственный способ, которым иконка может получить
* цвет. Ни CSS-фильтров, ни правил на конкретное имя иконки.
*/
export const MULTICOLOR_ICONS: ReadonlySet<string> = new Set([
"download",
"upload",
]);
/**
* Значения `fill`/`stroke`, которые цветом не являются и потому разрешены
* монохромной иконке.
*
* `none` — это «не закрашивать», а не цвет: у `refresh` контур рисуется
* штрихом, и `fill="none"` там обязателен.
*/
const NON_COLOR_PAINT = new Set(["currentcolor", "none", "inherit", "transparent"]);
/** Атрибуты, любое литеральное значение которых задаёт цвет. */
const PAINT_ATTRIBUTES = [
"fill",
"stroke",
"stop-color",
"flood-color",
"lighting-color",
];
/** `icons/log-system.svg` → `log-system`. */
export function iconName(filePath: string): string {
return filePath.replace(/^.*[\\/]/, "").replace(/\.svg$/i, "");
}
/**
* Убирает то, что внутри уже существующего документа не только бесполезно, но
* и делает разметку невалидной: XML-пролог, DOCTYPE и комментарии.
*/
function stripProlog(raw: string): string {
return raw
.replace(/<\?xml[\s\S]*?\?>/gi, "")
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
.replace(/<!--[\s\S]*?-->/g, "")
.trim();
}
/**
* Превращает содержимое файла в `<symbol>`.
*
* `width` и `height` отбрасываются вместе с корневым тегом — размер задаёт
* компонент. Цвета НЕ переписываются: источник истины — сам файл, а
* молчаливая нормализация в рантайме скрывала бы ровно тот дефект, который
* этот модуль обязан делать видимым. За соответствие отвечает
* `findIconContractViolations`, вызываемая тестом и релизным гейтом.
*
* `viewBox` обязателен: без него `<use>` не знает систему координат иконки и
* рисует её в натуральную величину, обрезая по размеру родительского `<svg>`.
* Три иконки из семнадцати (eye, fullscreen, exit-fullscreen) его не имеют и
* задают только width/height, поэтому viewBox для них синтезируется — ровно
* так же, как это делал заменённый `vite-plugin-svg-icons`.
*/
export function toSymbol(raw: string, name: string): string {
const withoutProlog = stripProlog(raw);
const openTag = withoutProlog.match(/<svg\b[^>]*>/i);
if (!openTag) {
return "";
}
const body = withoutProlog
.replace(/^<svg\b[^>]*>/i, "")
.replace(/<\/svg>\s*$/i, "");
const viewBoxAttr = resolveViewBox(openTag[0]);
return `<symbol id="${SYMBOL_PREFIX}-${name}"${viewBoxAttr}>${body}</symbol>`;
}
export function resolveViewBox(openTag: string): string {
const declared = openTag.match(/viewBox="([^"]+)"/i);
if (declared) {
return ` viewBox="${declared[1]}"`;
}
const width = numericAttribute(openTag, "width");
const height = numericAttribute(openTag, "height");
if (width !== null && height !== null) {
return ` viewBox="0 0 ${width} ${height}"`;
}
return "";
}
/** Читает размер, игнорируя единицы измерения: `128`, `128px`, `128pt`. */
function numericAttribute(openTag: string, name: string): number | null {
const match = openTag.match(new RegExp(`${name}="([\\d.]+)[a-z%]*"`, "i"));
if (!match) {
return null;
}
const value = Number.parseFloat(match[1]);
return Number.isFinite(value) && value > 0 ? value : null;
}
/**
* Проверяет ассет на соответствие контракту спрайта.
*
* Возвращает список нарушений; пустой список означает, что иконка пригодна.
* Проверка одна на всех потребителей — тест и релизный гейт зовут её, а не
* повторяют правила у себя. Второй экземпляр этих правил неизбежно разошёлся
* бы с первым, и разошёлся бы молча.
*/
export function findIconContractViolations(raw: string, name: string): string[] {
const violations: string[] = [];
const source = stripProlog(raw);
const openTag = source.match(/<svg\b[^>]*>/i);
if (!openTag) {
return [`${name}: нет корневого <svg>`];
}
// Система координат: либо объявленный viewBox, либо пара width/height, из
// которой он синтезируется. Иконка без обоих способов сломала бы отрисовку
// молча.
if (!resolveViewBox(openTag[0])) {
violations.push(`${name}: нет ни viewBox, ни пары width/height`);
}
if (MULTICOLOR_ICONS.has(name)) {
return violations;
}
for (const attribute of PAINT_ATTRIBUTES) {
const pattern = new RegExp(`\\b${attribute}\\s*=\\s*"([^"]*)"`, "gi");
for (const match of source.matchAll(pattern)) {
const value = match[1].trim();
if (value === "") {
continue;
}
if (!NON_COLOR_PAINT.has(value.toLowerCase())) {
violations.push(
`${name}: атрибут ${attribute}="${value}" задаёт цвет мимо currentColor`
);
}
}
}
// Инлайновый style бьёт и атрибут, и наследование, поэтому цвет в нём —
// такое же нарушение контракта, как литеральный атрибут.
for (const match of source.matchAll(/\bstyle\s*=\s*"([^"]*)"/gi)) {
const declarations = match[1].toLowerCase();
for (const attribute of PAINT_ATTRIBUTES) {
const property = declarations.match(
new RegExp(`(?:^|;)\\s*${attribute}\\s*:\\s*([^;]+)`)
);
if (property && !NON_COLOR_PAINT.has(property[1].trim())) {
violations.push(
`${name}: инлайновый style задаёт ${attribute}: ${property[1].trim()}`
);
}
}
}
// Непустой <style> внутри ассета уезжает в документ вместе со спрайтом и
// способен покрасить что угодно, включая чужие иконки: селекторы там
// глобальные. Пустой блок остаётся от редакторов и безвреден.
for (const match of source.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
if (match[1].trim() !== "") {
violations.push(`${name}: непустой <style> внутри ассета`);
}
}
// Растр внутри иконки не наследует цвет ничем и никогда.
if (/<image\b/i.test(source)) {
violations.push(`${name}: растровое <image> не подчиняется currentColor`);
}
return violations;
}
@@ -1,72 +0,0 @@
<template>
<div style="display: flex; align-items: center">
<el-input-number
v-model="capacity"
placeholder="Please enter a value"
:min="-1"
:controls="false"
:precision="0"
clearable
style="width: 220px"
/>
<el-select
v-model="unit"
:placeholder="$t('peer.unit')"
style="width: 100px"
>
<el-option
v-for="item in units"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</div>
</template>
<script setup lang="ts">
import { PropType } from "vue";
import {
calculateBytes,
formatStorageCapacity,
formatStorageUnit,
} from "@/utils/byte";
const units = ["Bytes", "KB", "MB", "GB", "TB", "PB"];
const props = defineProps({
valueTmp: {
type: Number as PropType<number>,
required: true,
},
setValue: {
type: Function as PropType<(newValue: number) => void>,
required: true,
},
});
const state = reactive({
capacity: 0,
unit: "GB",
});
const { capacity, unit } = toRefs(state);
watch(
[capacity, unit],
([newC, newU]) => {
const newValue = calculateBytes(newC, newU);
props.setValue(newValue);
},
{ immediate: true }
);
watch(
() => props.valueTmp,
(newValue) => {
state.capacity = formatStorageCapacity(newValue);
state.unit = formatStorageUnit(newValue);
},
{ immediate: true }
);
</script>
+17
View File
@@ -0,0 +1,17 @@
/**
* Внутренние константы бренда.
*
* Единственное место, где живёт адрес атрибуции. Это не настройка: оператор
* HY2XS не должен иметь возможности переназначить, куда ведёт подпись
* разработчика, — ни через панель, ни через hy2xs.env, ни через таблицу
* `config`. Поэтому значение принадлежит приложению и попадает в бандл при
* сборке.
*
* По той же причине оно объявлено один раз, а не написано в шаблоне
* компонента: литерал, размазанный по нескольким Vue-файлам, невозможно ни
* проверить одним гейтом, ни изменить одной правкой.
*
* Отсутствие адреса в операторской конфигурации проверяется приёмкой сборки.
*/
export const FLAMY_NAME = "Flamy" as const;
export const FLAMY_URL = "https://flamy.studio" as const;
+171
View File
@@ -0,0 +1,171 @@
/**
* Контракт учётных данных администратора на стороне панели.
*
* Зачем этот модуль существует. Правила логина и пароля жили прямо в двух
* формах и разошлись и с сервером, и друг с другом:
*
* форма входа логин 6-32 + набор символов, пароль 6-32 + набор
* форма смены пароля пароль 6-64 + набор
* сервер логин 6-32 + набор символов, пароль 6-64 без набора
*
* Следствий было два, и оба закрывали панель. Пароль, назначенный штатной
* формой смены, мог оказаться длиннее 32 символов — и форма входа отказывалась
* его отправлять: оператор терял доступ после операции, которую панель ему же и
* предложила. А набор символов на пароле отвергал значение, которое сервер
* принял бы, — панель была строже сервера там, где она не имеет на это права.
*
* Правило теперь одно на обе формы, и оно сверяется с Go-контрактом
* (apps/credential/admin.go) тестом tools/test/frontend-contract.test.ts.
*/
export const ADMIN_USERNAME_MIN_LENGTH = 6;
export const ADMIN_USERNAME_MAX_LENGTH = 32;
/**
* Набор символов логина в записи регекспа.
*
* Дефис ЭКРАНИРОВАН намеренно. В прежней записи `[a-zA-Z0-9!@#$%^&*()_+-=]` он
* экранирован не был, поэтому `+-=` образовывал ДИАПАЗОН и молча впускал
* `, - . / 0-9 : ; < =`. Действующий набор совпадает с этим фактическим
* множеством — сужать его нельзя, оно уже работает на установленных
* серверах, — но записан явно: пока он выглядел опечаткой, любая попытка
* «навести порядок» развела бы панель и сервер обратно.
*/
const ADMIN_USERNAME_CHARACTER_CLASS = "a-zA-Z0-9!@#$%^&*()_+,\\-./:;<=";
export const ADMIN_USERNAME_PATTERN = new RegExp(
`^[${ADMIN_USERNAME_CHARACTER_CLASS}]{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}}$`
);
/** Тот же набор в том виде, в каком его показывают оператору. */
export const ADMIN_USERNAME_CHARSET = "a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=";
/**
* Границы пароля в СИМВОЛАХ Unicode. Набора символов у пароля НЕТ: сервер его
* не ограничивает ни при установке, ни при смене, и панель не имеет права
* отвергать значение, которое сервер принял бы.
*/
export const ADMIN_PASSWORD_MIN_LENGTH = 6;
export const ADMIN_PASSWORD_MAX_LENGTH = 64;
/**
* Предел bcrypt — в БАЙТАХ, и он существует одновременно с границей в символах.
*
* `golang.org/x/crypto/bcrypt` отвечает `ErrPasswordTooLong` на пароль длиннее
* 72 байт. Через границу в символах это не выражается: у 64 символов длина от
* 64 до 256 байт в зависимости от алфавита.
*/
export const ADMIN_PASSWORD_MAX_BYTES = 72;
/**
* Единственное правило пароля на стороне панели.
*
* Почему не встроенные `min`/`max` Element Plus. Правила формы Element Plus
* делегирует библиотеке `async-validator`, а она сравнивает `min`/`max` строки
* с `String.prototype.length`, то есть с числом единиц UTF-16. Go и оркестратор
* считают code points. Расхождение не теоретическое:
*
* "😀😀😀" -> Go: 3 руны -> сервер отказывает (минимум 6)
* JS: value.length = 6 -> форма считает минимум достигнутым
*
* то есть панель отправляла бы заведомо отвергаемый пароль и не могла бы
* объяснить отказ. Обратная сторона того же — пароль из 40 эмодзи: 40 code
* points укладываются в границу символов и не укладываются в 72 байта.
*
* Поэтому правило одно и считает ровно то же, что сервер: code points через
* итератор строки (он идёт по code points, а не по единицам UTF-16) и байты
* через `TextEncoder`, который кодирует именно в UTF-8.
*
* Ограничений НАБОРА в контракте два, и они разного происхождения.
*
* Первое — домен systemd. Первый пароль администратора уезжает в
* `/etc/hy2xs/hy2xs.env`, который systemd читает как `EnvironmentFile=`, и
* перед принятием пары прогоняет значение через `utf8_is_valid`. Отказ там —
* это не предупреждение, а незагруженный файл окружения и, следовательно,
* юнит, который не стартует. Публичный контракт отвергает NUL, U+FEFF,
* суррогаты, `U+FDD0..U+FDEF` и все code points вида `*FFFE`/`*FFFF`. Реализация
* v257.13 случайно пропускает U+FEFF; панель следует документации. Иначе
* она предложила бы оператору назначить пароль, после которого сервер
* перестанет подниматься.
*
* Одиночные суррогаты проверяются отдельно и по своей причине: строка
* JavaScript вправе их содержать, а `TextEncoder` молча заменит непарный
* суррогат на U+FFFD — то есть без проверки на сервер уехал бы ДРУГОЙ пароль, а
* не отказ.
*
* Второе — продуктовая политика: управляющие символы Unicode целиком (Cc — C0,
* DEL, C1). U+FEFF запрещён документированным транспортным доменом. Ни один из них
* невозможно ни увидеть в поле ввода, ни повторить при следующем входе, то есть
* они умеют ровно одно — запереть оператора снаружи.
*
* Проверяются они перебором кодов, а не классом регекспа: литеральные
* управляющие символы в исходнике невидимы, и такое правило нельзя проверить
* глазами при ревью.
*/
export function isValidAdminPassword(value: string): boolean {
let characters = 0;
for (const character of value) {
const code = character.codePointAt(0) ?? 0;
// Домен systemd: значение вне него не доедет до сервера вовсе.
if (
code === 0 ||
code === 0xfeff ||
(code >= 0xd800 && code <= 0xdfff) ||
(code >= 0xfdd0 && code <= 0xfdef) ||
(code & 0xfffe) === 0xfffe
) {
return false;
}
// Политика HY2XS: Cc. U+FEFF уже отвергнут транспортной веткой.
if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) {
return false;
}
characters += 1;
}
if (
characters < ADMIN_PASSWORD_MIN_LENGTH ||
characters > ADMIN_PASSWORD_MAX_LENGTH
) {
return false;
}
return new TextEncoder().encode(value).length <= ADMIN_PASSWORD_MAX_BYTES;
}
/**
* Правило пароля в том виде, в каком его принимает `el-form`.
*
* Собрано здесь, а не в каждой форме, по той же причине, по которой здесь живёт
* сам контракт: у формы входа и формы смены пароля правило обязано быть одним и
* тем же объектом кода, а не двумя совпадающими записями. Пока они были
* двумя, они разошлись — 32 против 64 — и панель заперла оператора снаружи
* после штатной смены пароля.
*
* Пустое значение пропускается: о нём отчитывается правило `required`, и без
* этого оператор, отправивший пустую форму, читал бы про границы длины вместо
* «поле обязательно». Сервер ведёт себя ровно так же — DTO приводит пустой
* пароль к «не задано» до проверки правил.
*
* `message` передаётся аргументом: фраза принадлежит словарю локализации, а
* `t()` доступен только внутри компонента.
*/
export function adminPasswordFormRule(message: string) {
return {
validator: (
_rule: unknown,
value: string,
callback: (error?: Error) => void
) => {
if ((value ?? "") === "" || isValidAdminPassword(value)) {
callback();
return;
}
callback(new Error(message));
},
trigger: ["change", "blur"] as string[],
};
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Контракт имени пира на стороне панели.
*
* Сервер остаётся конечным авторитетом: эти значения зеркалят константы из
* apps/service/peer_import.go и сверяются с ними release-тестом. Общий модуль
* не даёт форме, сообщению об ошибке и подсказке завести разные копии правила.
*/
export const PEER_NAME_MIN_LENGTH = 6;
export const PEER_NAME_MAX_LENGTH = 32;
/** Набор знаков без букв и цифр — передаётся в i18n как значение. */
export const PEER_NAME_PUNCTUATION = "!@#$%^&*()_+-=";
/** Полный набор в том виде, в каком его показывают оператору. */
export const PEER_NAME_CHARSET = `a-z A-Z 0-9 ${PEER_NAME_PUNCTUATION}`;
/**
* Набор символов в записи регулярного выражения.
*
* Дефис экранирован намеренно: неэкранированное `+-=` становится диапазоном и
* принимает лишние знаки. Числовые границы берутся из тех же констант, что и
* тексты формы.
*/
const PEER_NAME_CHARACTER_CLASS = "a-zA-Z0-9!@#$%^&*()_+\\-=";
export const PEER_NAME_PATTERN = new RegExp(
`^[${PEER_NAME_CHARACTER_CLASS}]{${PEER_NAME_MIN_LENGTH},${PEER_NAME_MAX_LENGTH}}$`
);
@@ -16,7 +16,7 @@ export const hasRole: Directive = {
});
if (!hasRole) {
el.parentNode && el.parentNode.removeChild(el);
el.parentNode?.removeChild(el);
}
} else {
throw new Error("need roles! Like v-has-role=\"['admin', 'user']\"");
+162 -220
View File
@@ -21,8 +21,13 @@ export default {
password: "Password",
login: "Login",
capsLockOn: "Caps lock is On",
usernameFormatIncorrect: "Username format is incorrect",
passwordFormatIncorrect: "Password format is incorrect",
},
// Требования к учётным данным администратора: общие для формы входа и формы
// смены пароля. См. комментарий в ru.ts.
credentials: {
usernameFormat: "Username: {min} to {max} characters from {charset}",
passwordFormat:
"Password: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters or U+FEFF",
},
dashboard: {
stale: "Dashboard data is stale. Retrying automatically...",
@@ -44,8 +49,19 @@ export default {
download: "Download",
upload: "Upload",
total: "Total",
serviceStateLabel: "Hysteria service",
serviceState: {
active: "Running",
inactive: "Stopped",
unknown: "State unknown",
},
trafficApiLabel: "Traffic Stats API",
trafficApiReachable: "Reachable",
trafficApiUnreachable: "Unreachable",
security: {
hysteriaStopped: "Hysteria2 is stopped",
hysteriaStateUnknown:
"Hysteria2 service state is unknown: systemd did not answer",
trafficApiUnavailable: "Traffic API is unavailable",
},
error: {
@@ -80,6 +96,7 @@ export default {
add: "Add",
confirm: "Confirm",
cancel: "Cancel",
close: "Close",
copySuccess: "Copy successful",
nodeUrl: "Node URL",
nodeQrCode: "Node QR Code",
@@ -89,16 +106,14 @@ export default {
save: "Save",
update: "Update",
downloadSuccess: "Download successful",
wait: "The version is being changed, please wait a moment",
enableSuccess: "Hysteria2 start successful",
disableSuccess: "Hysteria2 stop successful",
// `wait`, `enableSuccess` and `disableSuccess` are gone together with the
// operations the panel does not have: starting, stopping and changing the
// version of Hysteria. That lifecycle belongs to systemd and the orchestrator.
success: "Success",
refresh: "Refresh",
yes: "Yes",
no: "No",
securityRisk: "Security Risks",
defaultPassTip: `Please change the default login password as soon as possible, It is recommended to set a strong password to protect your account security. <a href="/#/admin/change-password" style="color: #00BFFF">Click here</a> to change`,
noHttpsTip: `Your website is not using HTTPS, making data transmission insecure, Please enable HTTPS as soon as possible to protect user information. <a href="/#/config/list?focus=huiHttps" style="color: #00BFFF">Click here</a> to enable`,
required: "Required",
warning: "Warning",
fileFormatUnsupported: "File format not supported",
@@ -113,7 +128,77 @@ export default {
invalid: "Invalid value",
switchLanguageSuccess: "Language switched successfully",
logoutConfirm: "Are you sure you want to log out?",
sessionExpired: "Current session has expired, please log in again",
sessionExpired: "Your session has expired. Sign in again to continue.",
signInRequired: "Signing in is required.",
signIn: "Sign in",
systemError: "System error",
networkError: "The server is not responding. Check the connection.",
},
error: {
field: {
name: "Peer name",
secret: "Secret",
remark: "Remark",
quotaBytes: "Quota",
expiresAt: "Expiry",
maxDevices: "Max devices",
disabled: "State",
bannedUntil: "Banned until",
file: "File",
id: "Identifier",
username: "Username",
pass: "Password",
oldPassword: "Old password",
newPassword: "New password",
key: "Setting key",
value: "Setting value",
numLine: "Line count",
pageNum: "Page number",
pageSize: "Page size",
},
code: {
required: "“{field}”: required",
min: "“{field}”: must not be less than {min}",
max: "“{field}”: must not be greater than {max}",
min_length: "“{field}”: at least {min} characters",
max_length: "“{field}”: at most {max} characters",
len: "“{field}”: length must be exactly {len}",
oneof: "“{field}”: allowed values are {values}",
gt: "“{field}”: must be greater than {gt}",
peer_name:
"“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed",
credential_format: "“{field}”: {min} to {max} characters from {charset}",
admin_password_format:
"“{field}”: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters or U+FEFF",
rule_violated: "“{field}”: value is not acceptable",
validation_failed: "Validation failed",
body_invalid:
"Request could not be parsed: check field formats and types",
peer_name_taken: "A peer with this name already exists",
peer_name_reserved: "This name is reserved for the installer peer",
peer_bootstrap_identity_locked:
"The installer peer's name and secret are mirrored in a file on the server and cannot be changed from the panel. Delete the bootstrap peer entirely if it is no longer needed.",
invalid_credentials: "Wrong username or password",
// The phrase must open with what has ALREADY been applied, otherwise it
// reads as "the operation failed" and the operator repeats an action
// that in fact went through.
//
// It also names no specific operation. This code is reported by
// disabling a peer, a temporary ban, secret rotation, quota and expiry
// reductions, a lower device limit, a batch import and peer deletion;
// the previous "new connections for this peer are now refused" held only
// for the first case and is meaningless for a deleted peer.
peer_disconnect_failed:
"The changes were saved, but the related active sessions could not be terminated: the Hysteria Traffic Stats API is unreachable. Established connections may keep working until the client reconnects. Check the hysteria-server service and retry.",
import_file_extension: "Import accepts .json files only",
unauthorized: "Signing in is required",
session_expired: "Session expired",
token_invalid: "Session is not valid",
account_disabled: "Account is disabled",
},
},
sidebar: {
developedBy: "Made at {brand}",
},
info: {
expireTime: "y-M-d H:m:s",
@@ -128,6 +213,19 @@ export default {
name: "Peer",
remark: "Remark",
secret: "Secret",
form: {
namePlaceholder: "client-01",
// The character set is stated as service.IsValidPeerName accepts it. The
// previous wording described the rule that was in force before EX-03.
nameHint:
"Peer identifier: {min} to {max} characters — latin letters, digits and {punctuation}. The name becomes part of the auto-generated secret and is shown to the client as the profile name.",
remarkPlaceholder: "laptop",
remarkHint: "Optional operator note. It is never shown to the client.",
secretPlaceholder: "leave empty to generate one",
secretHint:
"Client connection password. Leave empty to generate one automatically. If set manually: 6 to 128 characters.",
quotaHint: "Traffic limit in bytes. Use -1 for unlimited.",
},
maxDevices: "Max devices",
disabled: "Disabled",
status: "Status",
@@ -141,6 +239,13 @@ export default {
unlimited: "Unlimited",
secretRotateConfirm:
"Rotate peer secret? Existing client configurations will stop working until updated.",
exportSettings: "Export settings",
exportBackup: "Backup",
exportSettingsHint:
"Peer list without secrets. Safe to store and share, but after importing on another server the peers get new secrets, so existing client links stop working.",
exportBackupConfirm:
"A backup contains the LIVE connection secrets of every peer in plain text: anyone who gets this file gets VPN access. Treat it like a password and delete it once the migration is done. Only this kind of copy keeps existing client links working after moving to another server.",
exportBackupConfirmButton: "I understand, download with secrets",
quota: "Quota",
download: "Download",
upload: "Upload",
@@ -151,16 +256,31 @@ export default {
onlineStatus: "Online Status",
online: "Online",
offline: "Offline",
onlineUnknown: "Online unknown",
onlineUnavailable: "Live connection state is currently unavailable",
onlineUnavailableHint:
"The Hysteria Traffic Stats API did not answer, so online status and device counts are unknown. Stored peer state in the table is accurate.",
device: "Online Devices",
unit: "Unit",
loginAt: "Last login time",
conAt: "Last connection time",
createTime: "Create Time",
releaseSuccess: "Release successful",
kick: "Kick",
kickTip: "Force user to log off",
releaseKick: "Release",
releaseKickTip: "Remove offline status",
kick: "Ban temporarily",
kickTip:
"Drop the session and refuse new connections until the ban expires",
kickConfirm:
"Ban peer “{name}” until {until}? The current session will be dropped and new connections refused until then.",
kickSuccess: "Peer banned temporarily",
releaseKick: "Lift ban",
releaseKickTip: "Lift the temporary ban early",
// Disabling and the temporary ban are separate mechanisms: the ban expires
// on its own, disabling is only undone by hand.
enablePeer: "Enable peer",
disablePeer: "Disable peer",
disableConfirm:
"Disable peer “{name}”? The current session will be dropped and new connections refused until the peer is enabled again.",
enableSuccess: "Peer enabled",
disableSuccess: "Peer disabled",
createdAt: "Created at",
bannedUntil: "Banned until",
totalTraffic: "Total traffic",
@@ -169,44 +289,21 @@ export default {
},
config: {
orchestratorManaged: "Managed by hy2xs-orchestrator reconfigure",
huiWebPort: "HY2XS admin Web Port",
huiWebContext: "HY2XS admin Web Context",
hysteria2TrafficTime: "Hysteria2 Traffic Time",
huiCrtPath: "HY2XS admin CRT File Path",
huiKeyPath: "HY2XS admin KEY File Path",
uploadCrtFile: "Upload CRT File",
uploadKeyFile: "Upload KEY File",
restartServer: "Restart Panel",
restartTip: "Restarting, please refresh",
useHysteria2Cert: "Use Hysteria2 cert",
huiHttps: "Open https on the panel",
resetTrafficCron: "Reset traffic schedule task",
resetTrafficCronTip:
"Scheduled task expression, reference: https://pkg.go.dev/github.com/robfig/cron/v3",
"Scheduled task expression, reference: https://pkg.go.dev/github.com/robfig/cron/v3. An empty value disables the automatic reset",
resetTrafficMonth: "Run once a month, midnight, first of month",
resetTrafficWeek: "Run once a week, midnight between Sat/Sun",
mustBeInteger: "Field must be an integer",
invalidWebContext:
"Field must start with / and contain only lowercase letters (a-z) and numbers (0-9)",
invalidTrafficTime: "Field must be a number with up to one decimal place",
},
monitor: {
huiVersion: "HY2XS admin Version",
cpuPercent: "CPU Usage",
memPercent: "Memory Usage",
diskPercent: "Disk Usage",
hysteria2UserTotal: "Number of online users",
hysteria2DeviceTotal: "Number of online devices",
hysteria2Version: "Hysteria2 Version",
hysteria2Running: "Hysteria2 Status",
hysteria2RunningTrue: "Running",
hysteria2RunningFalse: "Stop",
},
// The `monitor` section is gone together with its only consumer — the
// "Hysteria2 Status" tag on the config page. The service state has three
// values, not two, and its phrases live in `dashboard.serviceState`.
log: {
numLine: "Number of lines",
level: "Level",
message: "Message",
time: "Time",
exportFailed: "Could not export the log",
},
errorPage: {
back: "Back",
@@ -221,189 +318,34 @@ export default {
notFoundBackHome: "Back to home",
},
hysteria: {
enable: "Enable",
disable: "Disable",
addConfigItem: "Add Config Item",
hysteria2Version: "Hysteria2 Version",
hysteria2Running: "Hysteria2 Status",
hysteria2ChangeVersion: "Change",
addOutbound: "Add Outbound",
extension: "Extension",
listen: "Listen",
ownedByOrchestrator: "The Hysteria configuration is owned by the installer",
ownedByOrchestratorHint:
"The panel shows /etc/hysteria/config.yaml read-only. Changes are applied by `hy2xs-orchestrator reconfigure`.",
listen: "Listen address",
auth: "Peer authentication",
tls: "TLS",
obfs: "Obfuscation",
quic: "QUIC parameters",
bandwidth: "Bandwidth",
speedTest: "Speed Test",
udp: "UDP",
resolver: "Resolver",
sniff: "Protocol Sniffing",
acl: "ACL",
outbounds: "Outbounds",
http: "Traffic Stats API (HTTP)",
masquerade: "Masquerade",
config: {
enable: "Enable/Disable",
remark: "Remark",
listen:
"When the IP address is omitted, the server will listen on all interfaces, both IPv4 and IPv6. To listen on IPv4 only, you can use 0.0.0.0:443. To listen on IPv6 only, you can use [::]:443.",
tlsType: "TLS type",
tls: {
cert: "The path to the Cert file.",
key: "The path to the Key file.",
sniGuard:
'Verify the SNI provided by the client. Accept the connection only when it matches what\'s in the certificate. Terminate the TLS handshake otherwise. Set to strict to enforce this behavior. Set to disable to disable this entirely. The default is dns-san, which enables this feature only when the certificate contains the "Subject Alternative Name" extension with a domain name in it.',
},
acme: {
domains: "Domains",
email: "Email",
ca: "The CA to use. Can be letsencrypt or zerossl.",
listenHost:
"The host address (not including the port) to listen on for the ACME challenge. If omitted, the server will listen on all interfaces.",
dir: "The directory to store the ACME account key and certificates.",
type: "ACME challenge type. Can be http, tls, or dns.",
http: {
altPort:
"Listening port for HTTP challenges. (Note: Changing to a port other than 80 requires port forwarding or HTTP reverse proxy, or the challenge will fail!)",
},
tls: {
altPort:
"Listening port for TLS-ALPN challenges. (Note: Changing to a port other than 443 requires port forwarding or TLS reverse proxy, or the challenge will fail!)",
},
dns: {
name: "DNS provider. For details, refer to ACME DNS Configuration.",
config: "ACME DNS Configuration",
},
disableHTTP: "Disable HTTP challenge.",
disableTLSALPN: "Disable TLS-ALPN challenge.",
altHTTPPort:
"Alternate HTTP challenge port. (Note: If you want to use anything other than 80, you must set up port forward/HTTP reverse proxy from 80 to that port, otherwise ACME will not be able to issue the certificate.)",
altTLSALPNPort:
"Alternate TLS-ALPN challenge port. (Note: If you want to use anything other than 443, you must set up port forward/SNI proxy from 443 to that port, otherwise ACME will not be able to issue the certificate.)",
},
obfs: {
type: "Type",
salamander: {
password: "Replace with a strong password of your choice.",
},
},
quic: {
initStreamReceiveWindow: "The initial QUIC stream receive window size.",
maxStreamReceiveWindow: "The maximum QUIC stream receive window size.",
initConnReceiveWindow:
"The initial QUIC connection receive window size.",
maxConnReceiveWindow:
"The maximum QUIC connection receive window size.",
maxIdleTimeout:
"The maximum idle timeout. How long the server will consider the client still connected without any activity.",
maxIncomingStreams:
"The maximum number of concurrent incoming streams.",
disablePathMTUDiscovery: "Disable QUIC path MTU discovery.",
},
bandwidth: {
up: "Up",
down: "Down",
},
ignoreClientBandwidth:
"When enabled, makes the server to disregard any bandwidth hints set by clients",
speedTest:
"speedTest enables the built-in speed test server. When enabled, clients can test their download and upload speeds with the server. For more information, see the Speed Test documentation.",
disableUDP:
"disableUDP disables UDP forwarding, only allowing TCP connections.",
udpIdleTimeout:
"udpIdleTimeout specifies the amount of time the server will keep a local UDP port open for each UDP session that has no activity. This is conceptually similar to the NAT UDP session timeout.",
resolver: {
type: "Type",
tcp: {
addr: "The address of the TCP resolver.",
timeout: "The timeout for DNS queries.",
},
udp: {
addr: "The address of the UDP resolver.",
timeout: "The timeout for DNS queries.",
},
tls: {
addr: "The address of the TLS resolver.",
timeout: "The timeout for DNS queries.",
sni: "The SNI to use for the TLS resolver.",
insecure: "Disable TLS verification for the TLS resolver.",
},
https: {
addr: "The address of the HTTPS resolver.",
timeout: "The timeout for DNS queries.",
sni: "The SNI to use for the TLS resolver.",
insecure: "Disable TLS verification for the TLS resolver.",
},
},
sniff: {
enable: "Whether to enable protocol sniffing.",
timeout:
"Sniffing timeout. If the protocol/domain cannot be determined within this time, the original address will be used to initiate the connection.",
rewriteDomain:
"Whether to rewrite requests that are already in domain name form. If enabled, requests with the target address already in domain name form will still be sniffed.",
tcpPorts:
"List of TCP ports. Only TCP requests on these ports will be sniffed.",
udpPorts:
"List of UDP ports. Only UDP requests on these ports will be sniffed.",
},
aclType: "ACL type",
acl: {
file: "The path to the ACL file.",
inline: "The list of inline ACL rules.",
geoip:
"Optional. Uncomment to enable. The path to the GeoIP database file. If this field is omitted, Hysteria will automatically download the latest database to your working directory.",
geosite:
"Optional. Uncomment to enable. The path to the GeoSite database file. If this field is omitted, Hysteria will automatically download the latest database to your working directory.",
geoUpdateInterval:
"Optional. The interval at which to refresh the GeoIP/GeoSite databases. 168 hours (1 week) by default. Only applies if the GeoIP/GeoSite databases are automatically downloaded. (Check the note below for more information.)",
},
outbounds: {
name: "The name of the outbound. This is used in ACL rules.",
type: "Type",
socks5: {
addr: "The address of the SOCKS5 proxy.",
username:
"Optional. The username for the SOCKS5 proxy, if authentication is required.",
password:
"Optional. The password for the SOCKS5 proxy, if authentication is required.",
},
http: {
url: "The URL of the HTTP/HTTPS proxy. (Can be http:// or https://)",
insecure:
"Optional. Whether to disable TLS verification. Applies to HTTPS proxies only.",
},
direct: {
mode: "Type",
bindIPv4: "The local IPv4 address to bind to.",
bindIPv6: "The local IPv6 address to bind to.",
bindDevice: "The local network interface to bind to.",
fastOpen: "Enable TCP fast open.",
},
},
trafficStats: {
listen: "The address to listen on.",
},
masquerade: {
type: "Type",
file: {
dir: "The directory to serve files from.",
},
proxy: {
url: "The URL of the website to proxy.",
rewriteHost:
"Whether to rewrite the Host header to match the proxied website. This is required if the target web server uses Host to determine which site to serve.",
insecure: "Disable TLS verification for the proxied website.",
},
string: {
content: "The string to return.",
headers: "Optional. The headers to return.",
statusCode: "Optional. The status code to return. 200 by default.",
},
listenHTTP: "HTTP (TCP) listen address.",
listenHTTPS: "HTTPS (TCP) listen address.",
forceHTTPS:
"Whether to force HTTPS. If enabled, all HTTP requests will be redirected to HTTPS.",
},
},
congestion: "Congestion control",
trafficStats: "Traffic Stats API",
notSet: "not set",
sectionMissing: "The section is absent from the configuration",
tlsMissing: "Neither tls nor acme is configured",
valuesHidden: "values are not shown",
secretSet: "set",
secretMissing: "not set",
obfsPasswordHint: "handed out in the peer share link",
driftTitle: "Configuration drift",
driftHint:
"The file contains sections outside the HY2XS production profile. The orchestrator neither creates nor supports them: the configuration was most likely edited by hand.",
trafficStatsMissing: "Traffic Stats API is not configured",
trafficStatsMissingHint:
"Without the trafficStats section the device limit, traffic accounting and forced disconnect do not work: the panel reaches Hysteria only through this API.",
trafficStatsUnreachable:
"the panel cannot reach this address: the Traffic Stats API is queried through 127.0.0.1 only. While the addresses differ, the device limit, traffic accounting and forced disconnect do not work",
trafficStatsWildcard:
"the API is reachable but listens on every interface. The HY2XS production profile uses 127.0.0.1: run hy2xs-orchestrator reconfigure",
},
};
+196 -171
View File
@@ -19,8 +19,24 @@ export default {
password: "Пароль",
login: "Войти",
capsLockOn: "Caps Lock включён",
usernameFormatIncorrect: "Неверный формат логина",
passwordFormatIncorrect: "Неверный формат пароля",
},
// Требования к учётным данным администратора. Фразы общие для формы входа и
// формы смены пароля: требование одно, и второй его формулировки быть не
// должно — расхождение здесь означало бы, что оператору обещают разное про
// одно и то же поле.
//
// Обе фразы НАЗЫВАЮТ требование, а не сообщают о его нарушении. Прежние
// «Неверный формат логина» и «Некорректное значение» не давали оператору ни
// одного способа узнать, что именно от него хотят: набор символов логина
// приходит из hy2xs.env, и посмотреть его в панели негде.
credentials: {
usernameFormat: "Логин: от {min} до {max} символов из набора {charset}",
// Фраза называет ОБЕ границы, потому что их две и они в разных единицах.
// Пароль из 40 эмодзи укладывается в 64 символа и не укладывается в 72
// байта; сообщение «не длиннее {max} символов» отправило бы оператора
// сокращать пароль, отвергнутый не за это.
passwordFormat:
"Пароль: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов и U+FEFF",
},
dashboard: {
stale:
@@ -43,8 +59,21 @@ export default {
download: "Скачано",
upload: "Отдано",
total: "Всего",
serviceStateLabel: "Служба Hysteria",
// Три состояния, а не два: «спросить systemd не удалось» — это не
// «служба остановлена», и действия оператора у них разные.
serviceState: {
active: "Работает",
inactive: "Остановлена",
unknown: "Состояние неизвестно",
},
trafficApiLabel: "Traffic Stats API",
trafficApiReachable: "Доступен",
trafficApiUnreachable: "Недоступен",
security: {
hysteriaStopped: "Hysteria2 остановлена",
hysteriaStateUnknown:
"Состояние службы Hysteria2 неизвестно: systemd не ответил",
trafficApiUnavailable: "Traffic API недоступен",
},
error: {
@@ -77,6 +106,7 @@ export default {
add: "Добавить",
confirm: "Подтвердить",
cancel: "Отмена",
close: "Закрыть",
copySuccess: "Скопировано",
nodeUrl: "URL узла",
nodeQrCode: "QR узла",
@@ -86,16 +116,14 @@ export default {
save: "Сохранить",
update: "Обновить",
downloadSuccess: "Загрузка завершена",
wait: "Версия меняется, подождите",
enableSuccess: "Hysteria2 запущена",
disableSuccess: "Hysteria2 остановлена",
// Фразы `wait`, `enableSuccess` и `disableSuccess` удалены вместе с
// операциями, которых у панели нет: запуском, остановкой и сменой версии
// Hysteria. Жизненный цикл принадлежит systemd и оркестратору.
success: "Готово",
refresh: "Обновить",
yes: "Да",
no: "Нет",
securityRisk: "Риски безопасности",
defaultPassTip: `Смените пароль по умолчанию как можно скорее. <a href="/#/admin/change-password" style="color: #00BFFF">Перейти к смене</a>`,
noHttpsTip: `Панель работает без HTTPS. Включите HTTPS для защиты данных. <a href="/#/config/list?focus=huiHttps" style="color: #00BFFF">Открыть настройки</a>`,
required: "Обязательное поле",
warning: "Внимание",
fileFormatUnsupported: "Формат файла не поддерживается",
@@ -110,7 +138,88 @@ export default {
invalid: "Некорректное значение",
switchLanguageSuccess: "Язык переключён",
logoutConfirm: "Выйти из системы?",
sessionExpired: "Текущая сессия истекла, войдите снова",
sessionExpired: "Сессия истекла. Войдите снова, чтобы продолжить.",
signInRequired: "Требуется вход в панель.",
signIn: "Войти",
systemError: "Системная ошибка",
networkError: "Сервер не отвечает. Проверьте соединение с панелью.",
},
// Причины отказа API.
//
// Ключи строятся из КОДА ответа, а не из его текста: панель не разбирает
// человеческие сообщения сервера. Числа правил приходят в параметрах, поэтому
// второй копии границ длины здесь нет — она неизбежно разошлась бы с
// серверной.
error: {
field: {
name: "Имя пира",
secret: "Секрет",
remark: "Комментарий",
quotaBytes: "Квота",
expiresAt: "Срок действия",
maxDevices: "Лимит устройств",
disabled: "Состояние",
bannedUntil: "Блокировка до",
file: "Файл",
id: "Идентификатор",
username: "Логин",
pass: "Пароль",
oldPassword: "Старый пароль",
newPassword: "Новый пароль",
key: "Ключ настройки",
value: "Значение настройки",
numLine: "Число строк",
pageNum: "Номер страницы",
pageSize: "Размер страницы",
},
code: {
required: "«{field}»: поле обязательно",
min: "«{field}»: значение не может быть меньше {min}",
max: "«{field}»: значение не может быть больше {max}",
min_length: "«{field}»: не короче {min} символов",
max_length: "«{field}»: не длиннее {max} символов",
len: "«{field}»: длина должна быть ровно {len}",
oneof: "«{field}»: допустимые значения — {values}",
gt: "«{field}»: значение должно быть больше {gt}",
peer_name:
"«{field}»: от {min} до {max} символов из набора {charset}. Пробелы, кириллица и знаки / : ; . недопустимы",
// Сервер присылает границы и набор в params — фраза называет требование
// целиком. Прежнее «недопустимые символы» вдобавок описывало этими же
// словами отказ по ДЛИНЕ: правило одно, и оно проверяет и то, и другое.
credential_format:
"«{field}»: от {min} до {max} символов из набора {charset}",
admin_password_format:
"«{field}»: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов и U+FEFF",
rule_violated: "«{field}»: значение не подходит",
validation_failed: "Проверка данных не пройдена",
body_invalid: "Запрос не разобран: проверьте формат и типы полей",
peer_name_taken: "Пир с таким именем уже существует",
peer_name_reserved: "Это имя зарезервировано за пиром установщика",
peer_bootstrap_identity_locked:
"Имя и секрет пира установщика продублированы в файле на сервере и не меняются через панель. Ненужный bootstrap-пир следует удалить целиком.",
invalid_credentials: "Неверный логин или пароль",
// Фраза обязана начинаться с того, что УЖЕ СДЕЛАНО: иначе оператор
// прочитает её как «операция не выполнена» и повторит действие, которое
// на самом деле применилось.
//
// И она НЕ называет конкретную операцию. Этим кодом отчитываются
// отключение пира, временная блокировка, смена секрета, урезание квоты и
// срока, снижение лимита устройств, импорт партии и удаление пира;
// прежнее «новые подключения пира запрещены» было верно ровно для
// первого случая, а для удалённого пира — просто бессмысленно.
peer_disconnect_failed:
"Изменения сохранены, но завершить связанные активные сессии не удалось: Traffic Stats API Hysteria недоступен. Установленные соединения могут работать до переподключения клиента. Проверьте состояние службы hysteria-server и повторите действие.",
import_file_extension: "Импорт принимает только файлы .json",
unauthorized: "Требуется вход в панель",
session_expired: "Сессия истекла",
token_invalid: "Сессия недействительна",
account_disabled: "Учётная запись отключена",
},
},
sidebar: {
// {brand} подставляется ссылкой, поэтому фраза обязана остаться одной
// строкой с одним подстановочным местом.
developedBy: "Разработано во {brand}",
},
info: {
expireTime: "г-М-д Ч:м:с",
@@ -124,6 +233,21 @@ export default {
name: "Пир",
remark: "Комментарий",
secret: "Секрет",
form: {
namePlaceholder: "client-01",
// Набор символов назван так, как его понимает service.IsValidPeerName.
// Прежняя формулировка «латиница, цифры и дефис» осталась от правила,
// действовавшего до EX-03: подсказка обещала более узкий набор, чем
// сервер принимает, и оператор не имел причин пробовать остальные.
nameHint:
"Идентификатор пира: от {min} до {max} символов — латиница, цифры и знаки {punctuation}. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
remarkPlaceholder: "ноутбук",
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
secretPlaceholder: "оставьте пустым — сгенерируем",
secretHint:
"Пароль подключения клиента. Если оставить поле пустым, секрет будет сгенерирован автоматически. При ручном вводе: от 6 до 128 символов.",
quotaHint: "Лимит трафика в байтах. Укажите -1 для безлимита.",
},
maxDevices: "Лимит устройств",
disabled: "Отключён",
status: "Статус",
@@ -137,6 +261,13 @@ export default {
unlimited: "Безлимит",
secretRotateConfirm:
"Сменить секрет пира? Текущие клиентские конфиги перестанут работать до обновления.",
exportSettings: "Экспорт настроек",
exportBackup: "Резервная копия",
exportSettingsHint:
"Список пиров без секретов. Безопасно хранить и передавать, но после импорта на другой сервер пирам будут выданы новые секреты, и прежние клиентские ссылки работать не будут.",
exportBackupConfirm:
"Резервная копия содержит ДЕЙСТВУЮЩИЕ секреты подключения всех пиров в открытом виде: любой, кто получит этот файл, получит доступ к VPN. Храните его как пароль и удалите, когда перенос завершён. Только такая копия сохраняет работоспособность существующих клиентских ссылок после переноса на другой сервер.",
exportBackupConfirmButton: "Понимаю, скачать копию с секретами",
quota: "Квота",
download: "Скачано",
upload: "Отдано",
@@ -147,62 +278,54 @@ export default {
onlineStatus: "Онлайн",
online: "Онлайн",
offline: "Офлайн",
onlineUnknown: "Онлайн неизвестен",
onlineUnavailable: "Картина подключений сейчас недоступна",
onlineUnavailableHint:
"Traffic Stats API Hysteria не ответил, поэтому онлайн и число устройств неизвестны. Сохранённое состояние пиров в таблице верно.",
device: "Устройства",
createdAt: "Создан",
bannedUntil: "Блокировка до",
totalTraffic: "Суммарный трафик",
copyUri: "Копировать URI",
more: "Ещё",
unit: "Ед. изм.",
loginAt: "Последний вход",
conAt: "Последнее подключение",
createTime: "Создано",
releaseSuccess: "Ограничение снято",
kick: "Отключить",
kickTip: "Принудительно отключить пользователя",
releaseKick: "Снять",
releaseKickTip: "Снять офлайн-статус",
kick: "Временно заблокировать",
kickTip: "Прервать соединение и не пускать до истечения срока",
kickConfirm:
"Заблокировать пир «{name}» до {until}? Текущее соединение будет разорвано, новые подключения до этого времени приниматься не будут.",
kickSuccess: "Пир временно заблокирован",
releaseKick: "Снять блокировку",
releaseKickTip: "Снять временную блокировку досрочно",
// Отключение и временная блокировка — разные механизмы: у блокировки есть
// срок, который истекает сам, отключение снимается только руками.
enablePeer: "Включить пир",
disablePeer: "Отключить пир",
disableConfirm:
"Отключить пир «{name}»? Текущее соединение будет разорвано, а новые подключения запрещены, пока пир не будет включён обратно.",
enableSuccess: "Пир включён",
disableSuccess: "Пир отключён",
},
config: {
orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure",
huiWebPort: "Порт HY2XS admin",
huiWebContext: "Web-контекст HY2XS admin",
hysteria2TrafficTime: "Период учёта трафика Hysteria2",
huiCrtPath: "Путь к CRT HY2XS admin",
huiKeyPath: "Путь к KEY HY2XS admin",
uploadCrtFile: "Загрузить CRT",
uploadKeyFile: "Загрузить KEY",
restartServer: "Перезапустить панель",
restartTip: "Перезапуск, обновите страницу",
useHysteria2Cert: "Использовать сертификат Hysteria2",
huiHttps: "Включить HTTPS панели",
resetTrafficCron: "Расписание сброса трафика",
resetTrafficCronTip: "Cron-выражение для планового сброса трафика",
resetTrafficCronTip:
"Cron-выражение для планового сброса трафика. Пустое значение отключает автоматический сброс",
resetTrafficMonth: "Раз в месяц, в полночь первого дня",
resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем",
mustBeInteger: "Поле должно быть целым числом",
invalidWebContext:
"Поле должно начинаться с / и содержать только строчные буквы (a-z) и цифры (0-9)",
invalidTrafficTime:
"Поле должно быть числом максимум с одним знаком после запятой",
},
monitor: {
huiVersion: "Версия HY2XS admin",
cpuPercent: "CPU",
memPercent: "Память",
diskPercent: "Диск",
hysteria2UserTotal: "Пользователей онлайн",
hysteria2DeviceTotal: "Устройств онлайн",
hysteria2Version: "Версия Hysteria2",
hysteria2Running: "Статус Hysteria2",
hysteria2RunningTrue: "Работает",
hysteria2RunningFalse: "Остановлена",
},
// Раздел `monitor` удалён вместе со своим единственным потребителем —
// плашкой «Статус Hysteria2» на странице конфигурации. Состояние службы
// имеет три значения, а не два, и его фразы живут в `dashboard.serviceState`:
// «остановлена» и «неизвестно» — разные ответы оператору.
log: {
numLine: "Количество строк",
level: "Уровень",
message: "Сообщение",
time: "Время",
exportFailed: "Не удалось выгрузить журнал",
},
errorPage: {
back: "Назад",
@@ -217,140 +340,42 @@ export default {
notFoundBackHome: "На главную",
},
hysteria: {
enable: "Включить",
disable: "Отключить",
addConfigItem: "Добавить параметр",
hysteria2Version: "Версия Hysteria2",
hysteria2Running: "Статус Hysteria2",
hysteria2ChangeVersion: "Сменить",
addOutbound: "Добавить outbound",
extension: "Расширение",
ownedByOrchestrator: "Конфигурацией Hysteria владеет установщик",
ownedByOrchestratorHint:
"Панель показывает файл /etc/hysteria/config.yaml только на чтение. Изменения вносит `hy2xs-orchestrator reconfigure`.",
// Разделы production-профиля. Экран показывает то, что записано в файле,
// и отдельно называет секции сверх профиля: универсального редактора всех
// возможностей Hysteria здесь нет намеренно — конфиг создаёт оркестратор.
listen: "Адрес прослушивания",
auth: "Авторизация пиров",
tls: "TLS",
obfs: "Маскировка",
quic: "Параметры QUIC",
bandwidth: "Полоса",
speedTest: "Тест скорости",
udp: "UDP",
resolver: "DNS",
sniff: "Sniffing протоколов",
acl: "ACL",
outbounds: "Outbounds",
http: "Traffic Stats API (HTTP)",
masquerade: "Masquerade",
config: {
enable: "Включить/отключить",
remark: "Комментарий",
listen: "Адрес и порт прослушивания. Для IPv4 используйте 0.0.0.0:443.",
tlsType: "Тип TLS",
tls: {
cert: "Путь к cert-файлу",
key: "Путь к key-файлу",
sniGuard: "Проверка SNI клиента перед принятием TLS-соединения.",
},
acme: {
domains: "Домены",
email: "Email",
ca: "CA: letsencrypt или zerossl",
listenHost: "Адрес для ACME challenge",
dir: "Каталог ACME аккаунта и сертификатов",
type: "Тип ACME challenge: http, tls или dns",
http: { altPort: "Альтернативный порт HTTP challenge" },
tls: { altPort: "Альтернативный порт TLS-ALPN challenge" },
dns: { name: "DNS-провайдер", config: "Конфигурация ACME DNS" },
disableHTTP: "Отключить HTTP challenge",
disableTLSALPN: "Отключить TLS-ALPN challenge",
altHTTPPort: "Альтернативный HTTP-порт",
altTLSALPNPort: "Альтернативный TLS-ALPN-порт",
},
obfs: {
type: "Тип",
salamander: { password: "Сильный пароль Salamander" },
},
quic: {
initStreamReceiveWindow: "Начальное окно приёма QUIC stream",
maxStreamReceiveWindow: "Максимальное окно приёма QUIC stream",
initConnReceiveWindow: "Начальное окно приёма QUIC connection",
maxConnReceiveWindow: "Максимальное окно приёма QUIC connection",
maxIdleTimeout: "Максимальный idle timeout",
maxIncomingStreams: "Максимум входящих stream",
disablePathMTUDiscovery: "Отключить QUIC path MTU discovery",
},
bandwidth: { up: "Вверх", down: "Вниз" },
ignoreClientBandwidth: "Игнорировать bandwidth, заявленный клиентом",
speedTest: "Встроенный сервер теста скорости",
disableUDP: "Отключить UDP forwarding",
udpIdleTimeout: "Idle timeout для UDP-сессий",
resolver: {
type: "Тип",
tcp: { addr: "TCP DNS resolver", timeout: "Timeout DNS-запросов" },
udp: { addr: "UDP DNS resolver", timeout: "Timeout DNS-запросов" },
tls: {
addr: "DNS over TLS resolver",
timeout: "Timeout DNS-запросов",
sni: "SNI для TLS resolver",
insecure: "Отключить TLS-проверку",
},
https: {
addr: "DNS over HTTPS resolver",
timeout: "Timeout DNS-запросов",
sni: "SNI для HTTPS resolver",
insecure: "Отключить TLS-проверку",
},
},
sniff: {
enable: "Включить sniffing",
timeout: "Timeout sniffing",
rewriteDomain: "Повторно анализировать доменные запросы",
tcpPorts: "TCP-порты для sniffing",
udpPorts: "UDP-порты для sniffing",
},
aclType: "Тип ACL",
acl: {
file: "Путь к ACL-файлу",
inline: "Inline ACL-правила",
geoip: "Путь к GeoIP базе",
geosite: "Путь к GeoSite базе",
geoUpdateInterval: "Интервал обновления GeoIP/GeoSite",
},
outbounds: {
name: "Имя outbound",
type: "Тип",
socks5: {
addr: "Адрес SOCKS5",
username: "Логин SOCKS5",
password: "Пароль SOCKS5",
},
http: {
url: "URL HTTP/HTTPS proxy",
insecure: "Отключить TLS-проверку proxy",
},
direct: {
mode: "Тип",
bindIPv4: "Локальный IPv4",
bindIPv6: "Локальный IPv6",
bindDevice: "Сетевой интерфейс",
fastOpen: "TCP fast open",
},
},
trafficStats: { listen: "Адрес прослушивания" },
masquerade: {
type: "Тип",
file: { dir: "Каталог файлов" },
proxy: {
url: "URL проксируемого сайта",
rewriteHost: "Переписывать Host header",
insecure: "Отключить TLS-проверку",
},
string: {
content: "Ответ строкой",
headers: "HTTP headers",
statusCode: "HTTP status code",
},
listenHTTP: "HTTP listen address",
listenHTTPS: "HTTPS listen address",
forceHTTPS: "Принудительно использовать HTTPS",
},
},
congestion: "Congestion control",
trafficStats: "Traffic Stats API",
notSet: "не задано",
sectionMissing: "Секция в конфигурации отсутствует",
tlsMissing: "Ни tls, ни acme в конфигурации не заданы",
valuesHidden: "значения не показываются",
secretSet: "задан",
secretMissing: "не задан",
obfsPasswordHint: "выдаётся в клиентской ссылке пира",
driftTitle: "Расхождение конфигурации",
driftHint:
"В файле есть секции вне production-профиля HY2XS. Оркестратор их не создаёт и не поддерживает: скорее всего, конфигурацию правили вручную.",
trafficStatsMissing: "Traffic Stats API не настроен",
trafficStatsMissingHint:
"Без секции trafficStats не работают лимит устройств, учёт трафика и принудительное отключение: панель обращается к Hysteria только через этот API.",
// Две разные новости об одном поле, и объединять их нельзя: первая
// означает, что доступ пиров уже не работает, вторая — что API работает, но
// опубликован шире необходимого. Прочие адреса loopback (127.0.0.5 и
// подобные) относятся к ПЕРВОЙ: слушатель на конкретном адресе соединения
// на 127.0.0.1 не принимает.
trafficStatsUnreachable:
"панель не может обратиться по этому адресу: Traffic Stats API запрашивается строго через 127.0.0.1. Пока адреса расходятся, лимит устройств, учёт трафика и принудительное отключение не работают",
trafficStatsWildcard:
"API доступен, но слушает все интерфейсы. Production-профиль HY2XS использует 127.0.0.1: выполните hy2xs-orchestrator reconfigure",
},
};
+16 -9
View File
@@ -28,17 +28,24 @@ const { isFullscreen, toggle } = useFullscreen();
/**
* Выход из системы.
*
* Отмена диалога — это ОТВЕТ оператора, а не ошибка. ElMessageBox отклоняет
* промис при нажатии «Отмена», и цепочка без разбора отказа оставляла в
* консоли необработанное отклонение промиса на каждый передумавший выход.
*/
function logout() {
ElMessageBox.confirm(t("common.logoutConfirm"), t("common.warning"), {
confirmButtonText: t("navbar.logout"),
cancelButtonText: t("common.cancel"),
type: "warning",
}).then(() => {
adminStore.logout().then(() => {
router.push(`/login?redirect=${route.fullPath}`);
async function logout() {
try {
await ElMessageBox.confirm(t("common.logoutConfirm"), t("common.warning"), {
confirmButtonText: t("navbar.logout"),
cancelButtonText: t("common.cancel"),
type: "warning",
});
});
} catch {
return;
}
await adminStore.logout();
router.push(`/login?redirect=${route.fullPath}`);
}
</script>
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { FLAMY_NAME, FLAMY_URL } from "@/constants/branding";
defineProps({
collapse: {
type: Boolean,
required: true,
},
});
</script>
<template>
<div class="sidebar-footer" :class="{ 'is-collapsed': collapse }">
<!--
Свёрнутое меню шириной 54px не вмещает фразу целиком, поэтому в нём
остаётся только имя-ссылка. Прятать подпись совсем нельзя: атрибуция
обязана быть видна в обоих состояниях.
-->
<a
v-if="collapse"
class="sidebar-footer-brand"
:href="FLAMY_URL"
target="_blank"
rel="noopener noreferrer"
>{{ FLAMY_NAME }}</a
>
<i18n-t v-else keypath="sidebar.developedBy" tag="span" scope="global">
<template #brand>
<a
class="sidebar-footer-brand"
:href="FLAMY_URL"
target="_blank"
rel="noopener noreferrer"
>{{ FLAMY_NAME }}</a
>
</template>
</i18n-t>
</div>
</template>
<style lang="scss" scoped>
.sidebar-footer {
display: flex;
align-items: center;
justify-content: center;
height: $sidebarFooterHeight;
padding: 0 12px;
overflow: hidden;
font-size: 12px;
line-height: 1.2;
color: rgb(255 255 255 / 45%);
text-align: center;
white-space: nowrap;
background-color: var(--menuBg);
border-top: 1px solid rgb(255 255 255 / 6%);
}
.sidebar-footer.is-collapsed {
padding: 0 4px;
}
.sidebar-footer-brand {
color: var(--el-color-primary);
text-decoration: none;
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
</style>
@@ -3,6 +3,7 @@ import { useRoute } from "vue-router";
import SidebarItem from "./SidebarItem.vue";
import Logo from "./Logo.vue";
import Footer from "./Footer.vue";
import { usePermissionStore } from "@/store/modules/permission";
import { useAppStore } from "@/store/modules/app";
@@ -36,5 +37,6 @@ const route = useRoute();
/>
</el-menu>
</el-scrollbar>
<Footer :collapse="!appStore.sidebar.opened" />
</div>
</template>
+1 -1
View File
@@ -72,8 +72,8 @@ function handleOutsideClick() {
<style lang="scss" scoped>
.app-wrapper {
&::after {
display: table;
clear: both;
display: table;
content: "";
}
+3 -1
View File
@@ -7,7 +7,7 @@ import { setupDirective } from "@/directive";
import "@/permission";
// Локальные SVG-иконки
import "virtual:svg-icons-register";
import { registerSvgIcons } from "@/components/SvgIcon/sprite";
// Локализация
import i18n from "@/lang/index";
@@ -16,6 +16,8 @@ import i18n from "@/lang/index";
import "@/styles/index.scss";
import "uno.css";
registerSvgIcons();
const app = createApp(App);
// Глобальная регистрация пользовательских директив
setupDirective(app);
+8 -2
View File
@@ -3,7 +3,9 @@ import { useAdminStoreHook } from "@/store/modules/admin";
import { usePermissionStoreHook } from "@/store/modules/permission";
import NProgress from "nprogress";
// Порядок важен: сначала vendor-база, затем тема HY2XS поверх неё.
import "nprogress/nprogress.css";
import "@/styles/nprogress.scss";
NProgress.configure({ showSpinner: false }); // Индикатор загрузки
@@ -34,7 +36,11 @@ router.beforeEach(async (to, from, next) => {
}
// Если маршрут не найден, перейти на 404
if (to.matched.length === 0) {
from.name ? next({ name: from.name }) : next("/404");
if (from.name) {
next({ name: from.name });
} else {
next("/404");
}
} else {
next();
}
@@ -56,7 +62,7 @@ router.beforeEach(async (to, from, next) => {
return;
}
next({ ...to, replace: true });
} catch (error) {
} catch {
// Удалить token и перейти на страницу входа
await adminStore.resetToken();
next(`/login?redirect=${to.path}`);
+7 -2
View File
@@ -26,9 +26,14 @@
box-sizing: content-box;
}
// Цвет фона выбранной строки
// Цвет фона выбранной строки.
//
// Раньше здесь стоял литерал `#e1f3d8b5` — бледно-зелёный из исходного
// admin-шаблона, не имеющий отношения к палитре HY2XS. Подсветка выделения —
// это семантика «активный элемент», поэтому берётся самый светлый оттенок
// брендового primary.
.el-table__body tr.current-row td {
background-color: #e1f3d8b5 !important;
background-color: var(--el-color-primary-light-9) !important;
}
// Единая высота header у card
+27
View File
@@ -0,0 +1,27 @@
// Тема NProgress.
//
// Vendor-стили nprogress приходят со своим фирменным синим `#29d`. Он никогда
// не переопределялся, поэтому индикатор загрузки был единственным элементом
// интерфейса вне брендовой палитры HY2XS.
//
// Здесь сознательно не пишется `#ff4e2f`: второй литерал того же цвета сразу
// стал бы вторым источником истины. Индикатор привязан к той же переменной,
// что и весь остальной интерфейс, и поменяется вместе с ней.
#nprogress {
.bar {
background: var(--el-color-primary);
}
.peg {
box-shadow:
0 0 10px var(--el-color-primary),
0 0 5px var(--el-color-primary);
}
// Spinner сейчас выключен через NProgress.configure({ showSpinner: false }),
// но правило оставлено: если его когда-нибудь включат, синий не вернётся.
.spinner-icon {
border-top-color: var(--el-color-primary);
border-left-color: var(--el-color-primary);
}
}
+2 -1
View File
@@ -25,7 +25,8 @@ body {
width: 100%;
height: 100%;
margin: 0;
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB",
font-family:
"Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", Arial, sans-serif;
line-height: inherit;
-moz-osx-font-smoothing: grayscale;
+16 -4
View File
@@ -20,7 +20,9 @@
// reset element-ui css
.horizontal-collapse-transition {
transition: 0s width ease-in-out, 0s padding-left ease-in-out,
transition:
0s width ease-in-out,
0s padding-left ease-in-out,
0s padding-right ease-in-out;
}
@@ -36,12 +38,22 @@
height: 100%;
}
// Область прокрутки меню ограничена сверху логотипом, снизу — подписью
// разработчика. Пункты меню поэтому не могут наехать на подпись даже при
// длинном списке: им физически некуда.
&.has-logo {
.el-scrollbar {
height: calc(100% - 50px);
height: calc(100% - 50px - #{$sidebarFooterHeight});
}
}
.sidebar-footer {
position: absolute;
right: 0;
bottom: 0;
left: 0;
}
.is-horizontal {
display: none;
}
@@ -178,10 +190,10 @@
& > .el-sub-menu__title {
& > span {
display: inline-block;
visibility: hidden;
width: 0;
height: 0;
overflow: hidden;
visibility: hidden;
}
}
}
@@ -206,8 +218,8 @@
&.hideSidebar {
.sidebar-container {
pointer-events: none;
transition-duration: 0.3s;
transform: translate3d(-$sideBarWidth, 0, 0);
transition-duration: 0.3s;
}
}
}
+7
View File
@@ -32,3 +32,10 @@ $menuActiveBorder: var(--menuActiveBorder);
$sideBarWidth: 210px;
$sideBarCollapsedWidth: 54px;
// Высота подписи разработчика внизу бокового меню.
//
// Значение объявлено здесь, потому что его знают ДВОЕ: сам футер и высота
// области прокрутки меню, из которой оно вычитается. Разойдясь, эти двое дают
// либо наезд пунктов меню на подпись, либо полосу пустоты над ней.
$sidebarFooterHeight: 34px;
File diff suppressed because it is too large Load Diff
+59 -58
View File
@@ -1,65 +1,66 @@
// generated by unplugin-vue-components
// We suggest you to commit this file into source control
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
import "@vue/runtime-core";
export {};
export {}
declare module "@vue/runtime-core" {
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import("element-plus/es")["ElAlert"];
ElAvatar: typeof import("element-plus/es")["ElAvatar"];
ElButton: typeof import("element-plus/es")["ElButton"];
ElCard: typeof import("element-plus/es")["ElCard"];
ElCol: typeof import("element-plus/es")["ElCol"];
ElDatePicker: typeof import("element-plus/es")["ElDatePicker"];
ElDialog: typeof import("element-plus/es")["ElDialog"];
ElDrawer: typeof import("element-plus/es")["ElDrawer"];
ElDropdown: typeof import("element-plus/es")["ElDropdown"];
ElDropdownItem: typeof import("element-plus/es")["ElDropdownItem"];
ElDropdownMenu: typeof import("element-plus/es")["ElDropdownMenu"];
ElEmpty: typeof import("element-plus/es")["ElEmpty"];
ElForm: typeof import("element-plus/es")["ElForm"];
ElFormItem: typeof import("element-plus/es")["ElFormItem"];
ElInput: typeof import("element-plus/es")["ElInput"];
ElInputNumber: typeof import("element-plus/es")["ElInputNumber"];
ElMenu: typeof import("element-plus/es")["ElMenu"];
ElMenuItem: typeof import("element-plus/es")["ElMenuItem"];
ElOption: typeof import("element-plus/es")["ElOption"];
ElPagination: typeof import("element-plus/es")["ElPagination"];
ElProgress: typeof import("element-plus/es")["ElProgress"];
ElRadioButton: typeof import("element-plus/es")["ElRadioButton"];
ElRadioGroup: typeof import("element-plus/es")["ElRadioGroup"];
ElRow: typeof import("element-plus/es")["ElRow"];
ElScrollbar: typeof import("element-plus/es")["ElScrollbar"];
ElSelect: typeof import("element-plus/es")["ElSelect"];
ElSubMenu: typeof import("element-plus/es")["ElSubMenu"];
ElSwitch: typeof import("element-plus/es")["ElSwitch"];
ElTable: typeof import("element-plus/es")["ElTable"];
ElTableColumn: typeof import("element-plus/es")["ElTableColumn"];
ElTabPane: typeof import("element-plus/es")["ElTabPane"];
ElTabs: typeof import("element-plus/es")["ElTabs"];
ElTag: typeof import("element-plus/es")["ElTag"];
ElTooltip: typeof import("element-plus/es")["ElTooltip"];
ElUpload: typeof import("element-plus/es")["ElUpload"];
Hamburger: typeof import("./../components/Hamburger/index.vue")["default"];
IEpCaretBottom: typeof import("~icons/ep/caret-bottom")["default"];
IEpDownload: typeof import("~icons/ep/download")["default"];
IEpMoreFilled: typeof import("~icons/ep/more-filled")["default"];
IEpRefresh: typeof import("~icons/ep/refresh")["default"];
IEpRefreshRight: typeof import("~icons/ep/refresh-right")["default"];
IEpUpload: typeof import("~icons/ep/upload")["default"];
IEpUserFilled: typeof import("~icons/ep/user-filled")["default"];
ImputMultiple: typeof import("./../components/ImputMultiple/index.vue")["default"];
LangSelect: typeof import("./../components/LangSelect/index.vue")["default"];
MapAdd: typeof import("./../components/MapAdd/index.vue")["default"];
Pagination: typeof import("./../components/Pagination/index.vue")["default"];
RouterLink: typeof import("vue-router")["RouterLink"];
RouterView: typeof import("vue-router")["RouterView"];
SvgIcon: typeof import("./../components/SvgIcon/index.vue")["default"];
UnitSelect: typeof import("./../components/UnitSelect/index.vue")["default"];
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload']
Hamburger: typeof import('./../components/Hamburger/index.vue')['default']
IEpCaretBottom: typeof import('~icons/ep/caret-bottom')['default']
IEpDownload: typeof import('~icons/ep/download')['default']
IEpMoreFilled: typeof import('~icons/ep/more-filled')['default']
IEpRefresh: typeof import('~icons/ep/refresh')['default']
IEpUserFilled: typeof import('~icons/ep/user-filled')['default']
LangSelect: typeof import('./../components/LangSelect/index.vue')['default']
LogViewer: typeof import('./../components/LogViewer/index.vue')['default']
Pagination: typeof import('./../components/Pagination/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SvgIcon: typeof import('./../components/SvgIcon/index.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: typeof import("element-plus/es")["ElLoadingDirective"];
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
+6 -2
View File
@@ -2,8 +2,12 @@
declare module "*.vue" {
import { DefineComponent } from "vue";
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
const component: DefineComponent<{}, {}, any>;
// `object` вместо `{}`: последний допускает любое ненулевое значение,
// включая 0 и "", то есть не ограничивает ничего. Правило ban-types, под
// которое здесь стояло подавление, в @typescript-eslint 8 удалено и
// разделено на точечные — само подавление ссылалось на несуществующее имя.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const component: DefineComponent<object, object, any>;
export default component;
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Разбор структурированного отказа API.
*
* Панель НЕ разбирает текст сообщения. Раньше у неё не было выбора: сервер
* отвечал на любую ошибку любого поля формы одним словом `invalid`, и всё, что
* панель могла сделать, — показать это слово тостом. Оператор, оставивший поле
* секрета пустым ровно так, как предлагала подпись под полем, видел «Invalid» и
* не имел ни одного способа узнать причину.
*
* Теперь у отказа есть код, а у отказа по полю — ещё и имя поля. Панель
* выбирает по коду СВОЮ локализованную фразу; текст сервера остаётся запасным
* вариантом для кода, которого она ещё не знает, и ответом для клиента без UI.
*/
/** Числовые коды ответа; синхронизировано с model/constant/code.go. */
export const API_CODE = {
success: 20000,
systemError: 50000,
validationFailed: 50001,
unauthorized: 50401,
forbidden: 50403,
} as const;
/**
* Коды причин; синхронизировано с constant.ErrCode* в model/constant/error.go.
*
* Перечислены только те, на которые панель реагирует по-разному. Остальные
* доезжают до оператора сообщением сервера.
*/
export const ERR_CODE = {
bodyInvalid: "body_invalid",
validationFailed: "validation_failed",
required: "required",
min: "min",
max: "max",
// Границы числа и границы длины строки различаются кодом, хотя тег
// валидатора у них один: «не меньше 1 устройства» и «не короче 6 символов» —
// разные фразы для оператора.
minLength: "min_length",
maxLength: "max_length",
len: "len",
oneOf: "oneof",
greaterThan: "gt",
ruleViolated: "rule_violated",
peerName: "peer_name",
credentialFormat: "credential_format",
// Правило пароля администратора целиком: длина в символах, длина в байтах
// (предел bcrypt) и запрет управляющих символов. Отдельный код нужен потому,
// что min_length/max_length не умеют назвать отказ по БАЙТАМ, а он бывает у
// пароля, укладывающегося в границу символов.
adminPasswordFormat: "admin_password_format",
peerNameTaken: "peer_name_taken",
peerNameReserved: "peer_name_reserved",
peerBootstrapLocked: "peer_bootstrap_identity_locked",
// ЧАСТИЧНЫЙ результат отзыва доступа, а не отказ операции: запрет новых
// подключений записан, активную сессию завершить не удалось. Панель
// показывает его предупреждением и обновляет строку — состояние в базе уже
// изменилось.
peerDisconnectFailed: "peer_disconnect_failed",
invalidCredentials: "invalid_credentials",
importFileExtension: "import_file_extension",
unauthorized: "unauthorized",
sessionExpired: "session_expired",
tokenInvalid: "token_invalid",
accountDisabled: "account_disabled",
} as const;
export interface ApiFieldError {
code: string;
field?: string;
message: string;
params?: Record<string, string>;
}
export interface ApiErrorPayload {
code: number;
message?: string;
errors?: ApiFieldError[];
}
/** Отказ API как исключение, сохраняющее машиночитаемую причину. */
export class ApiError extends Error {
readonly code: number;
readonly errors: ApiFieldError[];
constructor(payload: ApiErrorPayload) {
super(payload.message || "Error");
this.name = "ApiError";
this.code = payload.code;
this.errors = payload.errors ?? [];
}
/** Причины, привязанные к полям формы. */
fieldErrors(): ApiFieldError[] {
return this.errors.filter((item) => !!item.field);
}
/** Первая причина без привязки к полю — отказ уровня операции. */
operationError(): ApiFieldError | undefined {
return this.errors.find((item) => !item.field);
}
hasCode(code: string): boolean {
return this.errors.some((item) => item.code === code);
}
get requiresSignIn(): boolean {
return this.code === API_CODE.unauthorized;
}
get sessionExpired(): boolean {
return (
this.hasCode(ERR_CODE.sessionExpired) ||
this.hasCode(ERR_CODE.accountDisabled)
);
}
}
export function isApiError(value: unknown): value is ApiError {
return value instanceof ApiError;
}
+73
View File
@@ -0,0 +1,73 @@
import i18n from "@/lang/index";
import { ApiError, ApiFieldError } from "@/utils/api-error";
/**
* Локализация причины отказа.
*
* Ключ строится ИЗ КОДА, а не из текста ответа. Сервер присылает и своё
* человекочитаемое сообщение — оно остаётся ответом для клиента без панели и
* запасным вариантом здесь: код, которого панель ещё не знает, обязан доехать
* до оператора хоть в каком-то виде, а не превратиться в пустую строку.
*
* Числа правил (границы длины, допустимые значения) приходят в `params`.
* Второй копии этих чисел в панели нет намеренно: копия неизбежно разошлась бы
* с серверной, и оператор читал бы «от 6 до 128», получая отказ по другим
* границам.
*/
const t = i18n.global.t;
const te = i18n.global.te;
/** Локализованное название поля формы; при отсутствии — имя из ответа. */
function fieldLabel(field: string): string {
const key = `error.field.${field}`;
return te(key) ? t(key) : field;
}
/** Сообщение по одной причине отказа. */
export function describeFieldError(error: ApiFieldError): string {
const key = `error.code.${error.code}`;
if (te(key)) {
return t(key, {
field: error.field ? fieldLabel(error.field) : "",
...(error.params ?? {}),
});
}
return error.message;
}
/** Причины по именам полей формы — для подстановки в el-form. */
export function fieldErrorMap(error: ApiError): Record<string, string> {
const result: Record<string, string> = {};
for (const item of error.fieldErrors()) {
// Первая причина по полю выигрывает: показывать в одном поле две строки
// некуда, а порядок ответа отражает порядок правил.
if (item.field && !(item.field in result)) {
result[item.field] = describeFieldError(item);
}
}
return result;
}
/**
* Одна строка, пригодная для тоста.
*
* Отказ уровня операции показывается как есть. Отказ по полям сворачивается в
* перечисление «поле: причина» — тост при этом остаётся вторым сигналом, а
* первым служит подсветка самих полей.
*/
export function describeApiError(error: ApiError): string {
const operation = error.operationError();
if (operation) {
return describeFieldError(operation);
}
const fields = error.fieldErrors();
if (fields.length > 0) {
return fields
.map((item) => `${fieldLabel(item.field!)}: ${describeFieldError(item)}`)
.join("; ");
}
return error.message || t("common.systemError");
}
+6 -70
View File
@@ -23,73 +23,9 @@ export const formatBytes = (bytes: number, decimals = 2): string => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
};
export const calculateBytes = (value = 0, unit = "Bytes"): number => {
// Приведение единицы к верхнему регистру и удаление пробелов
const formattedUnit = unit.toUpperCase().trim();
// Маппинг единиц хранения на количество байт
const unitToBytes: Record<string, number> = {
BYTES: 1,
KB: 1024 ** 1,
MB: 1024 ** 2,
GB: 1024 ** 3,
TB: 1024 ** 4,
PB: 1024 ** 5,
EB: 1024 ** 6,
ZB: 1024 ** 7,
YB: 1024 ** 8,
};
// Проверка наличия единицы в маппинге
if (!Object.prototype.hasOwnProperty.call(unitToBytes, formattedUnit)) {
throw new Error("Invalid unit");
}
if (value == -1) {
return -1;
}
// Расчёт и возврат количества байт
return value * unitToBytes[formattedUnit];
};
/**
* Форматирование единицы хранения
* @param bytes Объём хранения в байтах
* @param decimals Количество знаков после запятой, по умолчанию 2
* @returns Отформатированное значение объёма хранения
*/
export const formatStorageCapacity = (bytes: number, decimals = 2): number => {
// Проверка корректности входных данных
if (!bytes || bytes <= 0) {
return bytes;
}
// Расчёт единицы хранения
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const i = Math.floor(Math.log(bytes) / Math.log(k));
// Форматирование и возврат объёма хранения
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
};
/**
* Форматирование единицы хранения
* @param bytes Объём хранения в байтах
* @returns Отформатированная единица хранения
*/
export const formatStorageUnit = (bytes: number): string => {
// Проверка корректности входных данных
if (!bytes || bytes <= 0) {
return "Bytes";
}
// Расчёт единицы хранения
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
// Возврат отформатированной единицы хранения
return sizes[i];
};
// calculateBytes, formatStorageCapacity и formatStorageUnit удалены вместе с
// компонентом UnitSelect, который был их единственным потребителем.
//
// Сам UnitSelect не использовался ни одной страницей панели: квота
// вводится обычным полем в байтах, а показывается через formatBytes. Компонент
// остался от поколения, в котором форма пира предлагала выбор единиц.
+73
View File
@@ -0,0 +1,73 @@
import { AxiosResponse } from "axios";
/**
* Скачивание бинарного ответа API одним способом на всю панель.
*
* Раньше эта последовательность — Blob, createObjectURL, временная ссылка,
* клик, revokeObjectURL — была скопирована в трёх местах, и все три копии
* успели разойтись: страница пиров разбирала Content-Disposition с запасным
* именем файла, а обе страницы логов падали на `undefined.split(...)`, если
* заголовка не оказалось.
*
* Хуже расхождения был порядок на страницах логов:
*
* const response = await exportLogApi(...); // <- запрос ВНЕ try
* try { ... } catch (e) { } // <- и пустой catch
*
* Сетевой запрос стоял ПЕРЕД try, поэтому его отказ этим catch не ловился
* вовсе, а всё, что происходило внутри, глушилось молча. Оператор нажимал
* «Экспорт» и не получал ни файла, ни объяснения.
*/
/** Имя файла по умолчанию, если сервер его не назвал. */
const CONTENT_DISPOSITION_PREFIX = "attachment; filename=";
/**
* Имя файла из заголовка ответа.
*
* Отсутствующий или незнакомый заголовок — не отказ: у скачивания есть
* разумное запасное имя, и терять из-за формата заголовка уже полученный файл
* незачем.
*/
export function fileNameFromResponse(
response: AxiosResponse,
fallback: string
): string {
const header = response.headers?.["content-disposition"];
if (typeof header !== "string") {
return fallback;
}
const index = header.indexOf(CONTENT_DISPOSITION_PREFIX);
if (index < 0) {
return fallback;
}
const name = header.slice(index + CONTENT_DISPOSITION_PREFIX.length).trim();
return name === "" ? fallback : name;
}
/**
* Отдаёт бинарный ответ пользователю как файл.
*
* Временная ссылка УДАЛЯЕТСЯ из документа, а objectURL освобождается в
* `finally`. Прежние копии оставляли `<a>` в body навсегда — по одному
* элементу на каждое нажатие «Экспорт» за всю жизнь вкладки.
*/
export function saveResponseAsFile(
response: AxiosResponse,
fallbackFileName: string
): void {
const blob = new Blob([response.data], { type: "application/octet-stream" });
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement("a");
try {
anchor.href = url;
anchor.download = fileNameFromResponse(response, fallbackFileName);
anchor.style.display = "none";
document.body.appendChild(anchor);
anchor.click();
} finally {
anchor.remove();
window.URL.revokeObjectURL(url);
}
}
+129 -27
View File
@@ -1,10 +1,40 @@
import axios, { InternalAxiosRequestConfig, AxiosResponse } from "axios";
import axios, {
AxiosError,
AxiosResponse,
InternalAxiosRequestConfig,
} from "axios";
import { useAdminStoreHook } from "@/store/modules/admin";
import i18n from "@/lang/index";
import { API_CODE, ApiError, ApiErrorPayload } from "@/utils/api-error";
import { describeApiError } from "@/utils/api-message";
const dynamicBase = (window as any).__dynamic_base__ || "";
const API_BASE = "/hui";
// Операторский API живёт под /api. Прежний префикс «hui» был наследием H UI:
// под ним лежали и machine-to-machine auth Hysteria, и JWT-защищённый
// админский API, хотя middleware у них разные.
// Значение синхронизировано с constant.AdminAPIBase в админке и
// ADMIN_API_BASE в оркестраторе.
const API_BASE = "/api";
const t = i18n.global.t;
/**
* Запрос может отказаться от общего тоста, если показывает причину сам.
*
* Так делает форма пира: причины по полям она подставляет прямо под поля, и
* второй сигнал тостом там только шумит. Так же поступают действия над строкой
* пира: отзыв доступа умеет завершиться частично, и такой исход показывается
* предупреждением, а не ошибкой.
*
* Флаг относится ТОЛЬКО к отказу API — тому, у которого есть конверт с `code`
* и `errors`. Транспортный отказ показывается всегда: разбирать в нём нечего,
* и молчание о нём означало бы операцию, не происходящую без объяснений.
*/
declare module "axios" {
export interface AxiosRequestConfig {
skipErrorToast?: boolean;
}
}
// Создание axios instance
const service = axios.create({
baseURL: `${dynamicBase}${API_BASE}`,
@@ -26,38 +56,110 @@ service.interceptors.request.use(
}
);
/**
* Сессия кончилась под руками у оператора.
*
* Раньше эта ветка была недостижима, и не в одном месте, а в двух. Сервер
* отвечал HTTP 200 на любой отказ, поэтому обработчик ошибок axios (второй
* аргумент interceptors.response.use) для отказов API не вызывался вовсе — а
* жила ветка сессии именно там. Условие в ней проверяло `code === "A0230"` и
* поле `msg`, которых в этом API никогда не было: остатки чужого шаблона.
* Ключ common.sessionExpired существовал и был мёртвым.
*
* Диалог показывается ОДИН раз: истёкший токен обычно роняет сразу несколько
* параллельных запросов страницы, и без этого оператор получил бы стопку
* одинаковых окон.
*/
let sessionPromptOpen = false;
function promptSignIn(expired: boolean): void {
if (sessionPromptOpen) {
return;
}
sessionPromptOpen = true;
const finish = () => {
sessionPromptOpen = false;
// Сбрасывается ТОЛЬКО сессия. Прежний код звал localStorage.clear(), то
// есть заодно стирал выбранный оператором язык панели: при следующем входе
// интерфейс возвращался к значению по умолчанию без всякой причины.
useAdminStoreHook().resetToken();
const redirect = encodeURIComponent(
window.location.pathname + window.location.search
);
window.location.href = `/login?redirect=${redirect}`;
};
ElMessageBox.confirm(
expired ? t("common.sessionExpired") : t("common.signInRequired"),
t("common.warning"),
{
confirmButtonText: t("common.signIn"),
showCancelButton: false,
closeOnClickModal: false,
closeOnPressEscape: false,
showClose: false,
type: "warning",
}
)
.then(finish)
.catch(finish);
}
// Response interceptor
service.interceptors.response.use(
(response: AxiosResponse) => {
const { code, message } = response.data;
if (code === 20000) {
return response.data;
}
// Обработка бинарного ответа при экспорте файлов
if (response.data instanceof ArrayBuffer || response.data instanceof Blob) {
// Бинарный ответ (выгрузка файла) не несёт конверта с кодом и обязан
// проверяться ДО обращения к его полям: у Blob их нет.
if (
response.data instanceof ArrayBuffer ||
response.data instanceof Blob
) {
return response;
}
ElMessage.error(message || "Системная ошибка");
return Promise.reject(new Error(message || "Error"));
},
(error: any) => {
if (error.response.data) {
const { code, msg } = error.response.data;
// Token истёк, нужен повторный вход
if (code === "A0230") {
ElMessageBox.confirm(t("common.sessionExpired"), t("common.warning"), {
confirmButtonText: t("common.confirm"),
type: "warning",
}).then(() => {
localStorage.clear();
window.location.href = "/";
});
} else {
ElMessage.error(msg || "Системная ошибка");
}
const payload = response.data as ApiErrorPayload;
if (payload?.code === API_CODE.success) {
return response.data;
}
return Promise.reject(error.message);
const apiError = new ApiError(payload ?? { code: API_CODE.systemError });
if (apiError.requiresSignIn) {
promptSignIn(apiError.sessionExpired);
return Promise.reject(apiError);
}
if (!response.config?.skipErrorToast) {
ElMessage.error(describeApiError(apiError));
}
return Promise.reject(apiError);
},
(error: AxiosError) => {
// Сюда приходит транспорт: сеть недоступна, таймаут, отменённый запрос,
// HTTP-статус вне 2xx. Прежний код читал error.response.data без проверки
// самого error.response — то есть при обрыве соединения падал с
// TypeError и подменял настоящую причину отказом внутри обработчика.
const message = error.response
? t("common.systemError")
: t("common.networkError");
// skipErrorToast здесь НЕ действует, и это существенно.
//
// Флаг существует ради одного случая: страница разбирает причину отказа
// сама — раскладывает её по полям формы или отличает частичный результат
// от полного отказа по коду. Всё это возможно только для отказа API, у
// которого есть конверт с `code` и `errors`.
//
// У транспортного отказа конверта нет вовсе. Страница о нём не может
// сказать ничего, кроме того же «сервер не отвечает», и все они пишут в
// своих обработчиках `// транспортный отказ уже показан общим
// перехватчиком» и молчат. Пока флаг гасил и эту ветку, утверждение было
// ложным: обрыв соединения при сохранении пира, его удалении или отзыве
// доступа не показывал оператору НИЧЕГО — операция просто не происходила
// молча.
ElMessage.error(message);
return Promise.reject(error);
}
);
@@ -33,6 +33,12 @@ import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { adminChangePasswordApi } from "@/api/admin";
import { useAdminStore } from "@/store/modules/admin";
import {
ADMIN_PASSWORD_MAX_BYTES,
ADMIN_PASSWORD_MAX_LENGTH,
ADMIN_PASSWORD_MIN_LENGTH,
adminPasswordFormRule,
} from "@/constants/credentials";
const { t } = useI18n();
const route = useRoute();
@@ -46,7 +52,33 @@ const form = reactive({
newPassword: "",
});
const passwordPattern = /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,64}$/;
// Проверяется ТОЛЬКО длина, и она берётся из общего контракта.
//
// Здесь стояло правило набора символов `[a-zA-Z0-9!@#$%^&*()_+-=]`, которого
// сервер не предъявляет ни при смене пароля, ни при установке. То есть панель
// отказывала оператору в пароле, который сервер принял бы, и сообщала об этом
// фразой «Некорректное значение», не называя ни одного требования.
//
// Границы совпадают с формой входа не случайно: пока они расходились, длинный
// пароль, назначенный здесь, невозможно было ввести там.
//
// Правило — собственное, а не встроенные `min`/`max` Element Plus: те
// делегируются `async-validator`, который считает длину строки в единицах
// UTF-16, тогда как сервер считает code points и вдобавок ограничивает длину в
// БАЙТАХ пределом bcrypt. Пароль из 64 кириллических букв проходил здесь и
// возвращался с сервера системной ошибкой хеширования.
//
// Комментарий записан строчными `//`, а не блоком: скан релизных гейтов
// отбрасывает только их, и объяснение, называющее убранную конструкцию по
// имени, иначе роняет проверку «этой конструкции здесь больше нет».
const passwordRule = adminPasswordFormRule(
t("credentials.passwordFormat", {
min: ADMIN_PASSWORD_MIN_LENGTH,
max: ADMIN_PASSWORD_MAX_LENGTH,
maxBytes: ADMIN_PASSWORD_MAX_BYTES,
})
);
const rules: FormRules = {
oldPassword: [
{
@@ -54,11 +86,7 @@ const rules: FormRules = {
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: passwordPattern,
message: t("common.invalid"),
trigger: ["change", "blur"],
},
{ ...passwordRule },
],
newPassword: [
{
@@ -66,11 +94,7 @@ const rules: FormRules = {
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: passwordPattern,
message: t("common.invalid"),
trigger: ["change", "blur"],
},
{ ...passwordRule },
],
};
+29 -264
View File
@@ -7,39 +7,6 @@
{{ $t("common.save") }}
</el-button>
</el-form-item>
<el-form-item>
<el-button @click="handleRestartServer">
<template #icon>
<i-ep-refreshRight />
</template>
{{ $t("config.restartServer") }}
</el-button>
</el-form-item>
<el-form-item>
<el-upload
v-model:file-list="fileList"
:http-request="handleImport"
:show-file-list="false"
accept=".json"
:limit="1"
:before-upload="beforeImport"
>
<el-button>
<template #icon>
<i-ep-upload />
</template>
{{ $t("common.import") }}
</el-button>
</el-upload>
</el-form-item>
<el-form-item>
<el-button @click="handleExport">
<template #icon>
<i-ep-download />
</template>
{{ $t("common.export") }}
</el-button>
</el-form-item>
</el-form>
</div>
@@ -57,73 +24,6 @@
:model="dataForm"
label-position="top"
>
<el-form-item :label="$t('config.huiWebPort')" prop="huiWebPort">
<el-input
v-model="dataForm.huiWebPort"
:placeholder="$t('config.huiWebPort')"
disabled
clearable
/>
</el-form-item>
<el-form-item :label="$t('config.huiWebContext')" prop="huiWebContext">
<el-input
v-model="dataForm.huiWebContext"
:placeholder="$t('config.huiWebContext')"
disabled
clearable
/>
</el-form-item>
<el-form-item
:label="$t('config.hysteria2TrafficTime')"
prop="hysteria2TrafficTime"
>
<el-input
v-model="dataForm.hysteria2TrafficTime"
:placeholder="$t('config.hysteria2TrafficTime')"
clearable
/>
</el-form-item>
<el-form-item :label="$t('config.huiHttps')" prop="huiHttps">
<el-select
v-model="huiHttps"
style="width: 50%"
ref="huiHttpsRef"
disabled
>
<el-option
v-for="item in huiHttpsList"
:key="item.key"
:label="item.key"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="huiHttps"
:label="$t('config.huiCrtPath')"
prop="huiCrtPath"
>
<el-input
v-model="dataForm.huiCrtPath"
:placeholder="$t('config.huiCrtPath')"
style="width: 50%"
disabled
clearable
/>
</el-form-item>
<el-form-item
v-if="huiHttps"
:label="$t('config.huiKeyPath')"
prop="huiKeyPath"
>
<el-input
v-model="dataForm.huiKeyPath"
:placeholder="$t('config.huiKeyPath')"
style="width: 50%"
disabled
clearable
/>
</el-form-item>
<el-tooltip
:content="$t('config.resetTrafficCronTip')"
placement="bottom"
@@ -161,104 +61,60 @@ export default {
</script>
<script setup lang="ts">
// Сетевые настройки самой панели (порт, web-контекст, HTTPS, пути к
// сертификатам) здесь отсутствуют намеренно: ими владеет install-оркестратор
// через /etc/hy2xs/hy2xs.env и systemd-юнит. Раньше они лежали в SQLite и
// показывались тут в disabled-виде — второй источник истины, из которого
// ничего нельзя было изменить.
//
// Generic import/export конфигурации удалён: он выгружал таблицу `config`
// целиком, вместе с JWT- и peer-ключами.
//
// Поля «Период учёта трафика Hysteria2» здесь тоже больше нет. Ключ
// HYSTERIA2_TRAFFIC_TIME не имел в рантайме ни одного потребителя: интервал
// сбора метрик задан в коде админки, а traffic accounting его не читал.
// Настройка меняла строку в базе и ничего больше — то есть панель обещала
// оператору управление, которого не существовало.
//
// Осталась ровно одна настройка, которой панель действительно владеет.
import { Select } from "@element-plus/icons-vue";
import {
exportConfigApi,
importConfigApi,
listConfigApi,
restartServerApi,
updateConfigsApi,
} from "@/api/config";
import { listConfigApi, updateConfigsApi } from "@/api/config";
import { ConfigsUpdateDto } from "@/api/config/types";
import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus";
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
const { t } = useI18n();
const route = useRoute();
const dataFormRef = ref(ElForm);
const huiHttpsRef = ref<any>(null);
const huiWebPortKey = "H_UI_WEB_PORT";
const huiWebContext = "H_UI_WEB_CONTEXT";
const hysteria2TrafficTimeKey = "HYSTERIA2_TRAFFIC_TIME";
const huiCrtPathKey = "H_UI_CRT_PATH";
const huiKeyPathKey = "H_UI_KEY_PATH";
const resetTrafficCronKey = "RESET_TRAFFIC_CRON";
const huiHttpsList = [
{ key: t("common.yes"), value: 1 },
{ key: t("common.no"), value: 0 },
];
const cronResetTraffic = [
{ key: t("config.resetTrafficMonth"), value: "@monthly" },
{ key: t("config.resetTrafficWeek"), value: "@weekly" },
];
const dataFormRules = {
huiWebPort: [
{
required: true,
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: /^\d+$/,
message: t("config.mustBeInteger"),
trigger: ["change", "blur"],
},
],
huiWebContext: [
{
required: true,
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: /^\/([a-z0-9]+(\/[a-z0-9]+)*)?$/,
message: t("config.invalidWebContext"),
trigger: ["change", "blur"],
},
],
hysteria2TrafficTime: [
{
required: true,
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: /^\d+(\.\d)?$/,
message: t("config.invalidTrafficTime"),
trigger: ["change", "blur"],
},
],
};
// Правил формы здесь нет намеренно.
//
// Поле — el-select с allow-create, то есть значение произвольное, и
// единственная осмысленная проверка cron-выражения — разбор его тем же
// парсером, которым пользуется планировщик. Такой парсер живёт на сервере;
// вторая, приблизительная реализация на фронтенде расходилась бы с ним и
// давала бы либо ложные отказы, либо ложные разрешения. Пустое значение
// легально и означает «автоматический сброс выключен».
const dataFormRules = {};
const state = reactive({
dataForm: {
huiWebPort: "8081",
huiWebContext: "/",
hysteria2TrafficTime: "1",
huiCrtPath: "",
huiKeyPath: "",
resetTrafficCron: "",
},
huiHttps: 0,
fileList: [] as UploadFile[],
});
const { dataForm, huiHttps, fileList } = toRefs(state);
const { dataForm } = toRefs(state);
const submitForm = () => {
dataFormRef.value.validate((valid: boolean) => {
if (valid) {
let configs: ConfigsUpdateDto[] = [
{
key: hysteria2TrafficTimeKey,
value: state.dataForm.hysteria2TrafficTime,
},
{
key: resetTrafficCronKey,
value: state.dataForm.resetTrafficCron,
@@ -274,109 +130,18 @@ const submitForm = () => {
const setConfig = async () => {
const { data } = await listConfigApi({
keys: [
huiCrtPathKey,
huiWebContext,
huiKeyPathKey,
huiWebPortKey,
hysteria2TrafficTimeKey,
resetTrafficCronKey,
],
keys: [resetTrafficCronKey],
});
data.forEach((configVo) => {
if (configVo.key === huiWebPortKey) {
state.dataForm.huiWebPort = configVo.value;
} else if (configVo.key === huiWebContext) {
state.dataForm.huiWebContext = configVo.value;
} else if (configVo.key === hysteria2TrafficTimeKey) {
state.dataForm.hysteria2TrafficTime = configVo.value;
} else if (configVo.key === huiCrtPathKey) {
state.dataForm.huiCrtPath = configVo.value;
} else if (configVo.key === huiKeyPathKey) {
state.dataForm.huiKeyPath = configVo.value;
} else if (configVo.key === resetTrafficCronKey) {
if (configVo.key === resetTrafficCronKey) {
state.dataForm.resetTrafficCron = configVo.value;
}
});
if (state.dataForm.huiCrtPath != "" && state.dataForm.huiKeyPath != "") {
state.huiHttps = 1;
}
};
const handleImport = async (params: UploadRequestOptions) => {
if (state.fileList.length > 0) {
try {
let formData = new FormData();
formData.append("file", params.file);
await importConfigApi(formData);
ElMessage.success(t("common.success"));
state.fileList = [];
} catch (e) {
/* empty */
} finally {
await setConfig();
}
}
};
const beforeImport = (file: UploadRawFile) => {
if (!file.name.endsWith(".json")) {
ElMessage.error(t("common.fileFormatUnsupported"));
return false;
}
if (file.size / 1024 / 1024 > 2) {
ElMessage.error(t("common.fileTooLarge"));
return false;
}
};
const handleExport = async () => {
try {
let response = await exportConfigApi();
const blob = new Blob([response.data], {
type: "application/octet-stream",
});
let url = window.URL.createObjectURL(blob);
let a = document.createElement("a");
document.body.appendChild(a);
a.href = url;
let dis = response.headers["content-disposition"];
a.download = dis.split("attachment; filename=")[1];
// Имитация клика для скачивания
a.click();
window.URL.revokeObjectURL(url);
ElMessage.success(t("common.success"));
} catch (e) {
/* empty */
}
};
const handleRestartServer = async () => {
try {
ElMessageBox.confirm(t("config.restartTip"), t("common.warning"), {
confirmButtonText: t("common.confirm"),
cancelButtonText: t("common.cancel"),
type: "warning",
}).then(() => {
restartServerApi();
ElMessage.success(t("config.restartTip"));
});
} catch (e) {
/* empty */
}
};
onMounted(() => {
setConfig();
if (route.query.focus === "huiHttps") {
nextTick(() => {
const input = huiHttpsRef.value.$el.querySelector(".el-input__inner");
if (input) {
input.focus();
}
});
}
});
</script>
+74 -9
View File
@@ -29,13 +29,44 @@
risk.severity === 'critical'
? 'error'
: risk.severity === 'warning'
? 'warning'
: 'info'
? 'warning'
: 'info'
"
:closable="risk.dismissible"
class="mb-2"
/>
<!--
Два независимых факта показываются отдельно и называются своими именами.
Раньше их было полтора: доступность Traffic Stats API выводилась из
ответа systemctl, поэтому дашборд умел утверждать «служба остановлена» и
«API доступен» одновременно.
-->
<el-card shadow="never" class="mt-2">
<el-descriptions :column="3" size="small" border>
<el-descriptions-item :label="$t('hysteria.hysteria2Version')">
{{ summary.hysteria.version || "-" }}
</el-descriptions-item>
<el-descriptions-item :label="$t('dashboard.serviceStateLabel')">
<el-tag size="small" :type="serviceStateTagType">
{{ $t(`dashboard.serviceState.${summary.hysteria.serviceState}`) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="$t('dashboard.trafficApiLabel')">
<el-tag
size="small"
:type="summary.hysteria.apiReachable ? 'success' : 'danger'"
>
{{
summary.hysteria.apiReachable
? $t("dashboard.trafficApiReachable")
: $t("dashboard.trafficApiUnreachable")
}}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-row :gutter="10" class="mt-2">
<el-col :xs="24" :sm="12" :lg="6"
><el-card shadow="never"
@@ -58,16 +89,26 @@
>{{ $t("dashboard.peers") }}: {{ summary.peers.total }}</el-card
></el-col
>
<!--
Пока Traffic Stats API недоступен, «0 онлайн» не данные, а
утверждение, которого никто не проверял. Раньше именно так и было:
сборщик метрик получал пустую картину подключений из ответа systemctl и
показывал ноль рядом с плашкой «служба остановлена».
-->
<el-col :xs="24" :sm="12" :lg="6"
><el-card shadow="never"
>{{ $t("dashboard.onlinePeers") }}:
{{ summary.peers.onlinePeers }}</el-card
{{
summary.hysteria.apiReachable ? summary.peers.onlinePeers : "?"
}}</el-card
></el-col
>
<el-col :xs="24" :sm="12" :lg="6"
><el-card shadow="never"
>{{ $t("dashboard.onlineDevices") }}:
{{ summary.peers.onlineDevices }}</el-card
{{
summary.hysteria.apiReachable ? summary.peers.onlineDevices : "?"
}}</el-card
></el-col
>
<el-col :xs="24" :sm="12" :lg="6"
@@ -89,9 +130,9 @@
<div class="chart-header">
<span>{{ $t("dashboard.trafficChart") }}</span>
<el-radio-group v-model="range" size="small" @change="loadDashboard">
<el-radio-button label="1h">1h</el-radio-button>
<el-radio-button label="24h">24h</el-radio-button>
<el-radio-button label="7d">7d</el-radio-button>
<el-radio-button value="1h">1h</el-radio-button>
<el-radio-button value="24h">24h</el-radio-button>
<el-radio-button value="7d">7d</el-radio-button>
</el-radio-group>
</div>
</template>
@@ -183,7 +224,14 @@ const summary = ref<DashboardSummaryVo>({
diskTotalBytes: 0,
diskPercent: 0,
},
hysteria: { version: "-", running: false, apiReachable: false },
// До первого ответа сервера о службе не известно ничего: `inactive` здесь
// было бы утверждением, которого никто не проверял.
hysteria: {
version: "-",
serviceState: "unknown",
running: false,
apiReachable: false,
},
peers: {
total: 0,
enabled: 0,
@@ -231,6 +279,23 @@ const isStale = computed(() => {
return Date.now() - lastSuccessAt.value > staleThresholdMs;
});
/**
* Цвет плашки состояния службы.
*
* «Неизвестно» — предупреждение, а не ошибка: это отказ ДИАГНОСТИКИ, и он не
* означает, что туннель не работает.
*/
const serviceStateTagType = computed(() => {
switch (summary.value.hysteria.serviceState) {
case "active":
return "success";
case "inactive":
return "danger";
default:
return "warning";
}
});
const loadDashboard = async () => {
if (loading.value) {
return;
@@ -247,7 +312,7 @@ const loadDashboard = async () => {
securityRisks.value = secRes.data;
lastSuccessAt.value = Date.now();
loadError.value = "";
} catch (error) {
} catch {
loadError.value = t("dashboard.refreshFailed");
} finally {
loading.value = false;
+2 -2
View File
@@ -61,7 +61,7 @@ function back() {
.pan-back-btn {
color: #fff;
background: #008489;
background: var(--el-color-primary);
border: none !important;
}
@@ -90,7 +90,7 @@ function back() {
}
a {
color: #008489;
color: var(--el-color-primary);
text-decoration: none;
&:hover {
+3 -3
View File
@@ -209,7 +209,7 @@ function message() {
font-size: 32px;
font-weight: bold;
line-height: 40px;
color: #1482f0;
color: var(--el-color-primary);
opacity: 0;
animation-name: slideUp;
animation-duration: 0.5s;
@@ -242,8 +242,8 @@ function message() {
}
&__return-home {
display: block;
float: left;
display: block;
width: 110px;
height: 36px;
font-size: 14px;
@@ -251,7 +251,7 @@ function message() {
color: #fff;
text-align: center;
cursor: pointer;
background: #1482f0;
background: var(--el-color-primary);
border-radius: 100px;
opacity: 0;
animation-name: slideUp;
@@ -1,382 +0,0 @@
<template>
<div class="flex gap-2">
<el-tag
:key="item"
v-for="item in outbounds"
@close="handleClose(item)"
@click="handleInfo(item)"
size="large"
closable
>
{{ item.name }}
</el-tag>
<el-button @click="handleAdd">+</el-button>
<el-dialog
:title="dialog.title"
v-model="dialog.visible"
width="600px"
append-to-body
@close="closeDialog"
>
<el-form ref="dataFormRef" label-position="top" :model="dataForm">
<el-tooltip
:content="$t('hysteria.config.outbounds.name')"
placement="bottom"
>
<el-form-item label="name" prop="name">
<el-input v-model="dataForm.name" clearable />
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.type')"
placement="bottom"
>
<el-form-item label="type" prop="type">
<el-select v-model="dataForm.type" style="width: 100%">
<el-option
v-for="item in outboundTypes"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-tooltip>
<template v-if="dataForm.type === 'socks5'">
<el-tooltip
:content="$t('hysteria.config.outbounds.socks5.addr')"
placement="bottom"
>
<el-form-item label="socks5.addr" prop="socks5.addr">
<el-input v-model="dataForm.socks5.addr" clearable />
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.socks5.username')"
placement="bottom"
>
<el-form-item label="socks5.username" prop="socks5.username">
<el-input v-model="dataForm.socks5.username" clearable />
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.socks5.password')"
placement="bottom"
>
<el-form-item label="socks5.password" prop="socks5.password">
<el-input v-model="dataForm.socks5.password" clearable />
</el-form-item>
</el-tooltip>
</template>
<template v-if="dataForm.type === 'http'">
<el-tooltip
:content="$t('hysteria.config.outbounds.http.url')"
placement="bottom"
>
<el-form-item label="http.url" prop="http.url">
<el-input v-model="dataForm.http.url" clearable />
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.http.insecure')"
placement="bottom"
>
<el-form-item label="http.insecure" prop="http.insecure">
<el-switch v-model="dataForm.http.insecure" />
</el-form-item>
</el-tooltip>
</template>
<template v-if="dataForm.type === 'direct'">
<el-tooltip
:content="$t('hysteria.config.outbounds.direct.mode')"
placement="bottom"
>
<el-form-item label="direct.mode" prop="direct.mode">
<el-select v-model="dataForm.direct.mode" style="width: 100%">
<el-option
v-for="item in outboundDirectModes"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.direct.bindIPv4')"
placement="bottom"
>
<el-form-item label="direct.bindIPv4" prop="direct.bindIPv4">
<el-input v-model="dataForm.direct.bindIPv4" clearable />
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.direct.bindIPv6')"
placement="bottom"
>
<el-form-item label="direct.bindIPv6" prop="direct.bindIPv6">
<el-input v-model="dataForm.direct.bindIPv6" clearable />
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.direct.bindDevice')"
placement="bottom"
>
<el-form-item label="direct.bindDevice" prop="direct.bindDevice">
<el-input v-model="dataForm.direct.bindDevice" clearable />
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.direct.fastOpen')"
placement="bottom"
>
<el-form-item label="direct.fastOpen" prop="direct.fastOpen">
<el-switch v-model="dataForm.direct.fastOpen" />
</el-form-item>
</el-tooltip>
</template>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitForm"
>{{ $t("common.confirm") }}
</el-button>
<el-button @click="closeDialog">{{ $t("common.cancel") }}</el-button>
</div>
</template>
</el-dialog>
<el-dialog
:title="outboundInfoDialog.title"
v-model="outboundInfoDialog.visible"
width="600px"
append-to-body
@close="outboundInfoDialog.visible = false"
>
<el-form label-position="top">
<el-tooltip
:content="$t('hysteria.config.outbounds.name')"
placement="bottom"
>
<el-form-item label="name" prop="name">
<el-tag>{{ outboundInfo.name }}</el-tag>
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.type')"
placement="bottom"
>
<el-form-item label="type" prop="type">
<el-tag>{{ outboundInfo.type }}</el-tag>
</el-form-item>
</el-tooltip>
<template v-if="outboundInfo.type === 'socks5'">
<el-tooltip
:content="$t('hysteria.config.outbounds.socks5.addr')"
placement="bottom"
>
<el-form-item label="socks5.addr" prop="socks5.addr">
<el-tag>{{ outboundInfo.socks5.addr }}</el-tag>
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.socks5.username')"
placement="bottom"
>
<el-form-item label="socks5.username" prop="outboundInfo.username">
<el-tag>{{ outboundInfo.socks5.username }}</el-tag>
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.socks5.password')"
placement="bottom"
>
<el-form-item label="socks5.password" prop="socks5.password">
<el-tag>{{ outboundInfo.socks5.password }}</el-tag>
</el-form-item>
</el-tooltip>
</template>
<template v-if="outboundInfo.type === 'http'">
<el-tooltip
:content="$t('hysteria.config.outbounds.http.url')"
placement="bottom"
>
<el-form-item label="http.url" prop="http.url">
<el-tag>{{ outboundInfo.http.url }}</el-tag>
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.http.insecure')"
placement="bottom"
>
<el-form-item label="http.insecure" prop="http.insecure">
<el-tag>{{ outboundInfo.http.insecure }}</el-tag>
</el-form-item>
</el-tooltip>
</template>
<template v-if="outboundInfo.type === 'direct'">
<el-tooltip
:content="$t('hysteria.config.outbounds.direct.mode')"
placement="bottom"
>
<el-form-item label="direct.mode" prop="direct.mode">
<el-tag>{{ outboundInfo.direct.mode }}</el-tag>
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.direct.bindIPv4')"
placement="bottom"
>
<el-form-item label="direct.bindIPv4" prop="direct.bindIPv4">
<el-tag>{{ outboundInfo.direct.bindIPv4 }}</el-tag>
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.direct.bindIPv6')"
placement="bottom"
>
<el-form-item label="direct.bindIPv6" prop="direct.bindIPv6">
<el-tag>{{ outboundInfo.direct.bindIPv6 }}</el-tag>
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.direct.bindDevice')"
placement="bottom"
>
<el-form-item label="direct.bindDevice" prop="direct.bindDevice">
<el-tag>{{ outboundInfo.direct.bindDevice }}</el-tag>
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.direct.fastOpen')"
placement="bottom"
>
<el-form-item label="direct.fastOpen" prop="direct.fastOpen">
<el-tag>{{ outboundInfo.direct.fastOpen }}</el-tag>
</el-form-item>
</el-tooltip>
</template>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="outboundInfoDialog.visible = false"
>{{ $t("common.cancel") }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
export default {
name: "outbounds",
};
</script>
<script setup lang="ts">
import {
defaultHysteria2ServerConfigOutbound,
Hysteria2ServerConfigOutbound,
} from "@/api/config/types";
import { PropType } from "vue";
import { deepCopy } from "@/utils/copy";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = defineProps({
outbounds: {
required: true,
type: Array as PropType<Array<Hysteria2ServerConfigOutbound>>,
default: (): Array<Hysteria2ServerConfigOutbound> => [],
},
});
const emit = defineEmits<{
(
event: "update:outbounds",
value: Array<Hysteria2ServerConfigOutbound>
): void;
}>();
const outbounds = useVModel(props, "outbounds", emit);
const dataFormRef = ref(ElForm);
const state = reactive({
dataForm: {
...defaultHysteria2ServerConfigOutbound,
} as Hysteria2ServerConfigOutbound,
dialog: {
title: t("hysteria.addOutbound"),
visible: false,
} as DialogType,
outboundInfoDialog: {
title: t("hysteria.outbounds"),
visible: false,
},
outboundInfo: {} as Hysteria2ServerConfigOutbound,
});
const { dataForm, dialog, outboundInfoDialog, outboundInfo } = toRefs(state);
const outboundTypes = ["socks5", "http", "direct"];
const outboundDirectModes = ["auto", "64", "46", "6", "4"];
const handleAdd = () => {
state.dialog.visible = true;
};
const handleClose = (outbound: Hysteria2ServerConfigOutbound): void => {
const index = outbounds.value.indexOf(outbound);
if (index !== -1) {
outbounds.value.splice(index, 1);
}
};
const handleInfo = (outbound: Hysteria2ServerConfigOutbound) => {
state.outboundInfo = outbound;
state.outboundInfoDialog.visible = true;
};
const submitForm = () => {
dataFormRef.value.validate((valid: any) => {
if (valid) {
if (outbounds.value.some((item) => item.name === state.dataForm.name)) {
ElMessage.error(t("common.invalid"));
return;
}
if (state.dataForm.type === "socks5") {
state.dataForm.http = undefined;
state.dataForm.direct = undefined;
} else if (state.dataForm.type === "http") {
state.dataForm.socks5 = undefined;
state.dataForm.direct = undefined;
} else if (state.dataForm.type === "direct") {
state.dataForm.socks5 = undefined;
state.dataForm.http = undefined;
}
let outbound = deepCopy(state.dataForm);
outbounds.value.push(outbound);
closeDialog();
}
});
};
const closeDialog = (): void => {
state.dialog.visible = false;
dataFormRef.value.resetFields();
dataFormRef.value.clearValidate();
};
</script>
<style lang="scss" scoped>
.flex.gap-2 {
flex-wrap: wrap;
}
</style>
File diff suppressed because it is too large Load Diff
+14 -99
View File
@@ -1,109 +1,24 @@
<template>
<div class="app-container">
<div class="page-toolbar">
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
<el-form-item :label="$t('log.numLine')" prop="numLine">
<el-select
v-model="queryParams.numLine"
style="width: 200px"
@change="setRecords"
>
<el-option label="100" value="100" />
<el-option label="200" value="200" />
<el-option label="300" value="300" />
</el-select>
</el-form-item>
<el-form-item prop="export">
<el-button @click="handleExport">
<template #icon>
<i-ep-download />
</template>
{{ $t("common.export") }}
</el-button>
</el-form-item>
<el-form-item prop="refresh">
<el-button @click="setRecords">
<template #icon>
<i-ep-refresh />
</template>
{{ $t("common.refresh") }}
</el-button>
</el-form-item>
</el-form>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="records">
<el-table-column
key="level"
label="level"
align="center"
prop="level"
/>
<el-table-column key="msg" label="msg" align="center" prop="msg" />
<el-table-column key="time" label="time" align="center" prop="time" />
</el-table>
</el-card>
</div>
<log-viewer
:fetch-logs="logHysteria2Api"
:export-option="1"
export-file-name="hysteria2.log"
/>
</template>
<script lang="ts">
export default {
name: "index",
name: "LogHysteria",
};
</script>
<script setup lang="ts">
import { LogDto, LogHysteria2Vo } from "@/api/log/types";
import { exportLogApi, logHysteria2Api } from "@/api/log";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const state = reactive({
loading: true,
total: 0,
records: [] as LogHysteria2Vo[],
queryParams: {
numLine: 100,
} as LogDto,
});
const { loading, records, queryParams } = toRefs(state);
const setRecords = async () => {
try {
state.loading = true;
const { data } = await logHysteria2Api(state.queryParams);
state.records = data.records;
state.total = data.total;
} finally {
state.loading = false;
}
};
const handleExport = async () => {
let response = await exportLogApi({ option: 1 });
try {
const blob = new Blob([response.data], {
type: "application/octet-stream",
});
let url = window.URL.createObjectURL(blob);
let a = document.createElement("a");
document.body.appendChild(a);
a.href = url;
let dis = response.headers["content-disposition"];
a.download = dis.split("attachment; filename=")[1];
// Имитация клика для скачивания
a.click();
window.URL.revokeObjectURL(url);
ElMessage.success(t("common.success"));
} catch (e) {
/* empty */
}
};
onMounted(() => {
setRecords();
});
/**
* Журнал Hysteria.
*
* Разметка, ширины колонок и обработка выгрузки живут в LogViewer см.
* страницу системного журнала.
*/
import LogViewer from "@/components/LogViewer/index.vue";
import { logHysteria2Api } from "@/api/log";
</script>

Some files were not shown because too many files have changed in this diff Show More