42 Commits

Author SHA1 Message Date
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
208 changed files with 32659 additions and 9049 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
+1340
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/>.
+368 -30
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">
@@ -13,7 +13,9 @@
<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 +24,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>
---
@@ -39,7 +41,7 @@ HY2XS подходит для сценария, где нужен один produ
HY2XS release‑пакет разворачивает и настраивает:
- официальный upstream‑бинарник Hysteria2, закреплённый в metadata пакета и проверяемый по SHA256;
- официальный upstream‑бинарник Hysteria2: последняя стабильная версия выбирается при сборке пакета, закрепляется в его metadata и проверяется по SHA256;
- HY2XS admin — встроенную админ‑панель для управления users/peers, трафиком, конфигурацией, логами и состоянием сервера;
- systemd‑юнит `hysteria-server` для Hysteria2;
- systemd‑юнит `hy2xs-admin` для админ‑панели;
@@ -108,6 +110,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 +138,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 +161,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 +268,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 +476,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/14-legacy-cleanup.md](docs/14-legacy-cleanup.md).
### 10. Получите bootstrap‑пароль админки
@@ -507,9 +621,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` |
@@ -534,8 +650,8 @@ hy2xs-orchestrator status \
| `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST` | Host trafficStats API | `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 +671,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 +743,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 repair --allow-partial-state` | Довести до конца незавершённую установку **текущего поколения** |
| `hy2xs-orchestrator diagnostics collect` | Собрать diagnostic bundle в `/var/log/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
@@ -637,6 +804,35 @@ permitopen 127.0.0.1:8080 localhost:8080
## Troubleshooting
### Установка отказывается: обнаружена предыдущая установка
```text
[hy2xs] ERROR: На сервере обнаружена предыдущая или посторонняя установка.
HY2XS v1 не поддерживает установку поверх и не мигрирует состояние 0.x.
Ни один файл на сервере не изменён.
```
Это ожидаемое поведение, а не сбой. Отказ происходит в PHASE 0, до любой
мутации: сервер остался в том состоянии, в котором был.
Что делать:
1. сохраните нужные данные (база пиров, конфиг) — см.
[docs/14-legacy-cleanup.md](docs/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 +842,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 +916,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.7
found: 1.25.6
FAIL — локальный Go собирает не ту stdlib, что уедет в релиз; поставьте 1.26.7
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 +966,100 @@ 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. определяет последнюю стабильную версию Hysteria, берёт ожидаемый SHA‑256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт;
4. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander;
5. собирает orchestrator, frontend и backend, проставляя версию админки из контракта;
6. прогоняет `go vet` и `go test` для HY2XS admin;
7. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу);
8. формирует архив и прогоняет acceptance‑проверки.
Любой сбой на шагах 1–7 останавливает сборку до создания пакета.
Тесты и типы (шаги 2 и 6) — такой же обязательный гейт, как проверка
зависимостей: переменной, которая их отключает, не существует. Готовый пакет
объявляет об этом полем `tests_gate=true` в `metadata/package.env`, и это
утверждение опирается на фактический прогон, а не на намерение.
Для локальной работы обходить нечего: `bun test`, `bun x tsc --noEmit`,
`go vet ./...` и `go test ./...` запускаются напрямую и tarball не создают.
Переменные, управляющие выбором версии 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 +1072,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,6 +1086,12 @@ 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/ # end-to-end проверки с реальным клиентом Hysteria
├── tools/legacy/ # purge-v0.sh: очистка сервера от предыдущего поколения
├── docs/ # спецификации baseline, тестов и эксплуатации
├── versions.env # контракт продукта, платформы и toolchain
├── CHANGELOG.md
├── README.md
└── LICENSE
```
@@ -783,4 +1113,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("прежний пароль перестал действовать после неудачного сброса")
}
}
+99 -28
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,88 @@ 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
}
return nil
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)
}
}
// 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")
}
func releaseResource() {
if err := dao.CloseSqliteDB(); err != nil {
logrus.Errorf(err.Error())
logrus.Errorf("%v", err)
}
if err := service.ReleaseHysteria2(); err != nil {
logrus.Errorf(err.Error())
logrus.Errorf("%v", err)
}
}
@@ -84,7 +156,6 @@ func initFile() error {
constant.LogDir,
constant.SqliteDBDir,
constant.BinDir,
constant.ExportPathDir,
filepath.Dir(constant.SqliteDBPath),
}
for _, item := range dirs {
+143 -250
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)
@@ -179,159 +196,35 @@ func GetHysteria2Config(c *gin.Context) {
vo.Success(config, c)
}
func UpdateHysteria2Config(c *gin.Context) {
vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", 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 выгружается отдельным санитизирующим маршрутом.
+302
View File
@@ -0,0 +1,302 @@
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/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"`
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)
}
}
-8
View File
@@ -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)
}
+31 -10
View File
@@ -43,6 +43,14 @@ func LogSystem(c *gin.Context) {
vo.Fail("Unable to unmarshal log data", c)
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 +88,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 +122,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)
}
+70 -19
View File
@@ -1,21 +1,21 @@
package controller
import (
"bytes"
"encoding/json"
"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"
)
func resolveID(c *gin.Context) (int64, error) {
@@ -93,9 +93,16 @@ 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 {
vo.Fail(fmt.Sprintf("name %s already exists", *peerUpdateDto.Name), c)
return
}
}
if err = service.UpdatePeer(id, peerUpdateDto); err != nil {
vo.Fail(err.Error(), c)
@@ -129,30 +136,68 @@ 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") {
if !strings.HasSuffix(strings.ToLower(header.Filename), ".json") {
vo.Fail(constant.InvalidError, 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
}
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
}
if err = service.UpsertPeerExport(peerExports); err != nil {
vo.Fail(err.Error(), c)
return
@@ -160,6 +205,13 @@ func ImportPeer(c *gin.Context) {
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 +219,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) {
+122
View File
@@ -0,0 +1,122 @@
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 postImportFile(t *testing.T, fileName string, content string) 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([]byte(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
}
// Регрессия: 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))
}
}
+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
}
+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
}
+328 -280
View File
@@ -3,7 +3,6 @@ package dao
import (
"encoding/base64"
"errors"
"fmt"
"github.com/glebarez/sqlite"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
@@ -14,9 +13,6 @@ import (
"hy2xs-admin/util"
"log"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
@@ -24,8 +20,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 +53,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 +93,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 +107,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 == "" {
@@ -110,14 +133,16 @@ func ensureSecureBootstrapAdmin() error {
}
adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))
passwordGenerated := false
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`.",
)
}
forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1)
status := int64(1)
@@ -140,28 +165,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 +254,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 +273,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 +300,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 +365,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 +428,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 +467,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 +519,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 +602,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 +612,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 {
@@ -662,63 +709,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,
+96
View File
@@ -0,0 +1,96 @@
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 и используются как глобальные типы.
DialogType: "readonly",
OptionType: "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" />
+55 -42
View File
@@ -2,67 +2,80 @@
"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": "^9.14.5",
"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"
},
"pnpm": {
"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"
}
},
"engines": {
"node": ">=18.12.0"
"node": "^20.19.0 || >=22.12.0"
}
}
+2845 -4123
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,312 @@
import type {
Hysteria2ServerConfig,
Hysteria2ServerConfigOutbound,
} from "./types";
/**
* Нормализация конфига Hysteria на границе API.
*
* Зачем этот файл существует.
*
* `Hysteria2ServerConfig` описывает то, что РЕАЛЬНО приходит по сети, и почти
* все его секции необязательны — потому что необязательны они и в upstream
* YAML. Панель при этом показывает их как обычную форму: `dataForm.tls.cert`,
* `dataForm.acme.dns.config`, `dataForm.resolver.https.sni`.
*
* Пока проверка типов SFC-шаблонов не работала, это выглядело безобидно.
* Современный `vue-tsc` даёт на этом 141 ошибку `TS18048` в двух файлах — и он
* прав: обращение через возможно отсутствующий объект в рантайме падает.
* Спасало только то, что форма строится merge'ем поверх полного объекта
* значений по умолчанию, то есть инвариант «секция есть всегда» существовал,
* но держался на порядке присваиваний внутри компонента и нигде не был
* выражен типом.
*
* Два способа это закрыть неверны:
*
* `?.` в 141 месте шаблона — прячет вопрос «а что показывать, если секции
* нет», не отвечая на него, и делает шаблон нечитаемым;
*
* `as any` — выключает ровно ту проверку, ради которой обновлялся
* typechecker.
*
* Здесь выбран третий: одно преобразование на входе. Ответ приходит в
* `Hysteria2ServerConfig` (как есть, с необязательными секциями), а форма
* работает с `Hysteria2ServerConfigView`, где присутствие каждой секции —
* свойство типа. Шаблону больше не нужно знать ни одного нюанса
* необязательности upstream-схемы.
*
* Важно, чего этот слой НЕ делает: он не участвует в экспорте. Выгрузка
* серверного конфига идёт на backend от исходного YAML и сохраняет поля, о
* которых HY2XS ещё не знает (см. docs/04). View-модель — только для
* отображения, поэтому потеря неизвестных полей здесь безвредна.
*/
/**
* DeepRequired делает обязательными все поля на всех уровнях.
*
* Массивы обрабатываются отдельно: без этой ветки `T[]` попал бы в `object` и
* маппинг прошёлся бы по свойствам самого массива.
*/
type DeepRequired<T> = T extends (infer U)[]
? DeepRequired<U>[]
: T extends object
? { [K in keyof T]-?: DeepRequired<NonNullable<T[K]>> }
: T;
/** Конфиг Hysteria в том виде, в котором его показывает панель. */
export type Hysteria2ServerConfigView = DeepRequired<Hysteria2ServerConfig>;
/** Один outbound в том же виде. */
export type Hysteria2ServerConfigOutboundView =
DeepRequired<Hysteria2ServerConfigOutbound>;
/**
* Полное значение по умолчанию: каждая секция заполнена.
*
* Тип здесь не декоративный. `Hysteria2ServerConfigView` требует все поля, и
* добавление секции в `Hysteria2ServerConfig` сломает компиляцию ровно здесь —
* то есть новое поле upstream нельзя молча не отобразить.
*/
export const defaultHysteria2ServerConfigView: Hysteria2ServerConfigView = {
listen: ":443",
tls: {
cert: "",
key: "",
sniGuard: "",
clientCA: "",
},
ech: {
keyPath: "",
},
acme: {
domains: [],
email: "",
ca: "letsencrypt",
listenHost: "0.0.0.0",
dir: "/var/lib/hysteria/acme",
type: "",
http: {
altPort: 8888,
},
tls: {
altPort: 44333,
},
dns: {
name: "cloudflare",
config: {},
},
disableHTTP: false,
disableTLSALPN: false,
altHTTPPort: 80,
altTLSALPNPort: 443,
},
obfs: {
type: "gecko",
salamander: {
password: "",
},
gecko: {
password: "",
minPacketSize: 512,
maxPacketSize: 1200,
},
},
quic: {
initStreamReceiveWindow: 8388608,
maxStreamReceiveWindow: 8388608,
initConnReceiveWindow: 20971520,
maxConnReceiveWindow: 20971520,
maxIdleTimeout: "30s",
maxIncomingStreams: 1024,
disablePathMTUDiscovery: false,
disableStatelessReset: false,
},
bandwidth: {
up: "50 mbps",
down: "50 mbps",
disableLossCompensation: false,
},
congestion: {
type: "bbr",
bbrProfile: "standard",
},
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,
xForwarded: false,
},
string: {
content: "hello stupid world",
headers: {},
statusCode: 200,
},
listenHTTP: ":80",
listenHTTPS: ":443",
forceHTTPS: true,
},
mimic: {
enabled: false,
interface: "",
xdpMode: "",
path: "",
extraArgs: [],
},
realm: {
stunServers: [],
stunTimeout: "",
punchTimeout: "",
heartbeatInterval: "",
insecure: false,
ipMode: "",
portMapping: {
enabled: false,
timeout: "",
lifetime: "",
},
},
};
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Рекурсивное наложение ответа сервера на значение по умолчанию.
*
* `null` и `undefined` игнорируются намеренно: в YAML отсутствующая секция и
* секция со значением `null` означают одно и то же — «не задано», — и обе
* обязаны оставить значение по умолчанию, а не обнулить поле формы.
*
* Массивы заменяются целиком, а не сливаются поэлементно: список ACL-правил
* или outbounds с сервера — это весь список, а не патч к дефолтному.
*/
function mergeInto(target: Record<string, unknown>, source: unknown): void {
if (!isPlainObject(source)) {
return;
}
for (const [key, value] of Object.entries(source)) {
if (value === null || value === undefined) {
continue;
}
if (Array.isArray(value)) {
target[key] = value;
continue;
}
if (isPlainObject(value)) {
const existing = target[key];
if (!isPlainObject(existing)) {
target[key] = {};
}
mergeInto(target[key] as Record<string, unknown>, value);
continue;
}
target[key] = value;
}
}
function cloneDefaults(): Hysteria2ServerConfigView {
// structuredClone есть во всех целевых браузерах и, в отличие от
// JSON.parse(JSON.stringify(...)), не тратит проход на сериализацию.
return structuredClone(defaultHysteria2ServerConfigView);
}
/**
* Приводит ответ сервера к модели, с которой работает форма.
*
* Пустой или отсутствующий ответ даёт полное значение по умолчанию: это то же
* состояние, в котором форма находится до первого запроса.
*/
export function normalizeHysteriaViewModel(
raw: Hysteria2ServerConfig | null | undefined
): Hysteria2ServerConfigView {
const view = cloneDefaults();
mergeInto(view as unknown as Record<string, unknown>, raw);
return view;
}
/** Значение по умолчанию для одного outbound. */
export const defaultHysteria2ServerConfigOutboundView: Hysteria2ServerConfigOutboundView =
{
name: "",
type: "socks5",
socks5: {
addr: "",
username: "",
password: "",
},
http: {
url: "",
insecure: false,
},
direct: {
mode: "auto",
bindIPv4: "",
bindIPv6: "",
bindDevice: "",
fastOpen: false,
},
};
/**
* Тот же приём для одного outbound: список приходит с необязательными
* подблоками, а карточка показывает их как обычные поля.
*/
export function normalizeOutboundViewModel(
raw: Hysteria2ServerConfigOutbound | null | undefined
): Hysteria2ServerConfigOutboundView {
const view = structuredClone(defaultHysteria2ServerConfigOutboundView);
mergeInto(view as unknown as Record<string, unknown>, raw);
return view;
}
+11 -73
View File
@@ -1,14 +1,23 @@
import { AxiosPromise } from "axios";
import request from "@/utils/request";
import {
ConfigDto,
ConfigsDto,
ConfigUpdateDto,
ConfigVo,
Hysteria2AcmePathVo,
Hysteria2ServerConfig,
} from "@/api/config/types";
// Серверный конфиг Hysteria доступен панели только на чтение и на выгрузку:
// им владеет install-оркестратор. Клиентов записи, импорта, перезапуска и
// загрузки сертификатов здесь нет — соответствующих маршрутов не существует.
//
// Generic-выгрузки таблицы `config` здесь тоже нет: она отдавала JWT- и
// peer-ключи в открытом виде. Перенос пиров делают ImportPeer/ExportPeer.
//
// getConfigApi удалён вместе с маршрутом: он принимал произвольный ключ той же
// таблицы и не вызывался ни из одного экрана. Чтение настроек идёт через
// listConfigApi, на стороне сервера — по allowlist.
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ServerConfig> {
return request({
url: "/config/getHysteria2Config",
@@ -16,24 +25,6 @@ export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ServerConfig> {
});
}
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 +41,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 +48,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" },
});
}
+50 -145
View File
@@ -1,6 +1,5 @@
export interface ConfigDto {
key: string;
}
// ConfigDto удалён вместе с маршрутом getConfig: чтение произвольного ключа
// таблицы `config` было входом к JWT- и peer-ключам.
export interface ConfigsDto {
keys: Array<string>;
@@ -20,12 +19,27 @@ export interface ConfigUpdateDto {
configUpdateDtos: Array<ConfigsUpdateDto>;
}
/**
* Конфиг Hysteria в том виде, в котором он ПРИХОДИТ ПО СЕТИ.
*
* Необязательность секций здесь не оплошность: ровно так устроен upstream YAML,
* и модель обязана его повторять, иначе она врала бы об ответе сервера.
*
* Форма панели работает не с этим типом, а с `Hysteria2ServerConfigView` из
* `hysteriaViewModel.ts`, где все секции обязательны. Значения по умолчанию
* живут там же: пока они лежали здесь, рядом с описанием ответа сервера, они
* выглядели частью протокола, хотя относятся исключительно к отображению.
*/
export interface Hysteria2ServerConfig {
listen: string;
tls?: {
cert: string;
key: string;
sniGuard?: string;
clientCA?: string;
};
ech?: {
keyPath?: string;
};
acme?: {
domains: string[];
@@ -51,9 +65,14 @@ export interface Hysteria2ServerConfig {
};
obfs?: {
type: string;
salamander: {
salamander?: {
password: string;
};
gecko?: {
password: string;
minPacketSize?: number;
maxPacketSize?: number;
};
};
quic?: {
initStreamReceiveWindow?: number;
@@ -63,10 +82,16 @@ export interface Hysteria2ServerConfig {
maxIdleTimeout?: string;
maxIncomingStreams?: number;
disablePathMTUDiscovery?: boolean;
disableStatelessReset?: boolean;
};
bandwidth?: {
up: string;
down: string;
disableLossCompensation?: boolean;
};
congestion?: {
type?: string;
bbrProfile?: string;
};
ignoreClientBandwidth?: boolean;
speedTest?: boolean;
@@ -122,6 +147,7 @@ export interface Hysteria2ServerConfig {
url: string;
rewriteHost: boolean;
insecure: boolean;
xForwarded?: boolean;
};
string?: {
content: string;
@@ -132,122 +158,28 @@ export interface Hysteria2ServerConfig {
listenHTTPS?: string;
forceHTTPS?: boolean;
};
mimic?: {
enabled?: boolean;
interface?: string;
xdpMode?: string;
path?: string;
extraArgs?: string[];
};
realm?: {
stunServers?: string[];
stunTimeout?: string;
punchTimeout?: string;
heartbeatInterval?: string;
insecure?: boolean;
ipMode?: string;
portMapping?: {
enabled?: boolean;
timeout?: string;
lifetime?: 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;
@@ -269,34 +201,7 @@ export interface Hysteria2ServerConfigOutbound {
};
}
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 Hysteria2AcmePathVo {
crtPath: string;
keyPath: string;
}
+5 -1
View File
@@ -12,7 +12,11 @@ export interface LogSystemVo {
level: string;
msg: string;
reqMethod: string;
reqUri: string;
// Путь без query string: в query по машинному endpoint'у приезжает
// действующий machine token, и в журнале его быть не должно.
reqPath: string;
// Только имена переданных query-параметров, без значений.
reqQueryKeys: string;
statusCode: string;
time: string;
}
+17 -1
View File
@@ -89,10 +89,26 @@ export function importPeerApi(data: FormData): AxiosPromise {
});
}
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",
});
}
+23 -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,7 +60,7 @@ export interface PeerVo extends IdDto {
createTime: string;
online: boolean;
onlineDevices: number;
}
};
export interface PeerClientConfigVo {
url: string;
@@ -1,29 +1,40 @@
<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>
<div class="flex gap-2">
<el-tag v-for="item in tags" :key="item" size="large">
{{ item }}
</el-tag>
<el-text v-if="!tags.length" type="info"></el-text>
</div>
</template>
<script setup lang="ts">
import draggable from "vuedraggable";
import { ElInput } from "element-plus";
import { PropType } from "vue";
/**
* Список строковых значений конфигурации: ACME-домены, inline-правила ACL.
*
* Компонент только показывает список, и это приведение UI в соответствие с тем,
* что продукт умеет, а не сокращение возможностей.
*
* Что было. Здесь жил редактор на `vuedraggable`: перетаскивание тегов,
* closable-теги с удалением, поле ввода нового значения и
* `emit("update:tags")`. Сохранить он ничего не мог — единственное место
* использования — форма конфига Hysteria, отрисованная с `:disabled="true"`,
* причём значения передаются как `:tags="dataForm.acme.domains"`, без v-model,
* то есть слушателя у события не существует. Маршрутов записи серверного
* конфига в API нет.
*
* Цена этого редактора была измеримой. `vuedraggable` поставляется UMD-сборкой,
* поэтому её `require("vue")` разрешался в `vue/dist/vue.cjs.prod.js` — полную
* сборку Vue вместе с рантайм-компилятором шаблонов. В бандл уезжало:
*
* vuedraggable ~155 КБ
* sortablejs ~115 КБ
* @vue/compiler-core + compiler-dom ~230 КБ
*
* то есть около полумегабайта исходников ради перетаскивания тегов в форме,
* которая недоступна для редактирования.
*/
const props = defineProps({
tags: {
required: false,
@@ -32,42 +43,7 @@ const props = defineProps({
},
});
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);
}
};
const tags = computed(() => props.tags ?? []);
</script>
<style lang="scss" scoped>
+21 -152
View File
@@ -1,71 +1,9 @@
<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 v-for="entry in entries" :key="entry.key" size="large">
{{ entry.key }}: {{ entry.value }}
</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>
<el-text v-if="!entries.length" type="info"></el-text>
</div>
</template>
@@ -77,13 +15,22 @@ export default {
<script setup lang="ts">
import { PropType } from "vue";
import { useI18n } from "vue-i18n";
interface Form {
key: string;
value: string;
}
/**
* Словарь «ключ — значение» из конфигурации: параметры ACME DNS-провайдера,
* заголовки masquerade.
*
* Как и ImputMultiple, компонент только показывает содержимое. Здесь был
* редактор с диалогом добавления, closable-тегами и `emit("update:mapObject")`,
* и он ничего не сохранял по тем же трём причинам: форма конфига Hysteria
* отрисована с `:disabled="true"`, значения передаются как
* `:map-object="dataForm.acme.dns.config"` без v-model, маршрутов записи
* серверного конфига в API нет.
*
* Значение показывается рядом с ключом: прежний вариант прятал его во второй
* диалог по клику на тег, что для read-only страницы лишний шаг — увидеть
* настройку сервера нужно сразу.
*/
const props = defineProps({
mapObject: {
required: false,
@@ -92,87 +39,9 @@ const props = defineProps({
},
});
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();
};
const entries = computed(() =>
Object.entries(props.mapObject ?? {}).map(([key, value]) => ({ key, value }))
);
</script>
<style lang="scss" scoped>
@@ -0,0 +1,134 @@
/**
* Спрайт локальных 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 при этом потеряна. Для семнадцати вручную отобранных
* иконок это несколько килобайт, и они не стоят неисправимой зависимости в
* сборке.
*/
const SYMBOL_PREFIX = "icon";
const SPRITE_ELEMENT_ID = "__hy2xs_svg_sprite__";
// eager: файлы читаются на этапе сборки и попадают в бандл строками, сетевых
// запросов за иконками не возникает.
const sources = import.meta.glob<string>("@/assets/icons/*.svg", {
query: "?raw",
import: "default",
eager: true,
});
function iconName(filePath: string): string {
return filePath.replace(/^.*\//, "").replace(/\.svg$/, "");
}
/**
* Превращает содержимое файла в `<symbol>`.
*
* Отбрасываются XML-пролог и DOCTYPE: внутри уже существующего документа они
* не только бесполезны, но и делают разметку невалидной. `width` и `height`
* тоже отбрасываются — размер задаёт компонент.
*
* `viewBox` обязателен: без него `<use>` не знает систему координат иконки и
* рисует её в натуральную величину, обрезая по размеру родительского `<svg>`.
* Три иконки из семнадцати (eye, fullscreen, exit-fullscreen) его не имеют и
* задают только width/height, поэтому viewBox для них синтезируется — ровно
* так же, как это делал заменённый плагин.
*/
function toSymbol(raw: string, name: string): string {
const withoutProlog = raw
.replace(/<\?xml[\s\S]*?\?>/gi, "")
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
.replace(/<!--[\s\S]*?-->/g, "")
.trim();
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>`;
}
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;
}
/**
* Вставляет спрайт в документ. Идемпотентна: повторный вызов заменяет
* содержимое, а не добавляет второй элемент с теми же 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;
}
@@ -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']\"");
+38 -25
View File
@@ -80,6 +80,7 @@ export default {
add: "Add",
confirm: "Confirm",
cancel: "Cancel",
close: "Close",
copySuccess: "Copy successful",
nodeUrl: "Node URL",
nodeQrCode: "Node QR Code",
@@ -97,8 +98,6 @@ export default {
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",
@@ -128,6 +127,17 @@ export default {
name: "Peer",
remark: "Remark",
secret: "Secret",
form: {
namePlaceholder: "e.g. ivan-laptop",
nameHint:
"Short peer identifier. Use latin letters, digits and hyphens — the name becomes part of the auto-generated secret and is shown to the client as the profile name.",
remarkPlaceholder: "e.g. Ivan's laptop, sales team",
remarkHint: "Optional operator note. It is never shown to the client.",
secretPlaceholder: "leave empty to generate automatically",
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 +151,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",
@@ -169,29 +186,13 @@ 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",
@@ -226,14 +227,13 @@ export default {
addConfigItem: "Add Config Item",
hysteria2Version: "Hysteria2 Version",
hysteria2Running: "Hysteria2 Status",
hysteria2ChangeVersion: "Change",
addOutbound: "Add Outbound",
extension: "Extension",
outboundsEmpty: "No outbounds are configured on the server",
listen: "Listen",
tls: "TLS",
obfs: "Obfuscation",
quic: "QUIC parameters",
bandwidth: "Bandwidth",
congestion: "Congestion control",
speedTest: "Speed Test",
udp: "UDP",
resolver: "Resolver",
@@ -243,8 +243,6 @@ export default {
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",
@@ -282,10 +280,17 @@ export default {
"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",
type: "Obfuscation type: gecko (HY2XS production default) or salamander (compatibility fallback).",
salamander: {
password: "Replace with a strong password of your choice.",
},
gecko: {
password: "Replace with a strong password of your choice.",
minPacketSize:
"Minimum QUIC handshake fragment size. HY2XS baseline: 512.",
maxPacketSize:
"Maximum QUIC handshake fragment size. HY2XS baseline: 1200, upstream limit: 2048.",
},
},
quic: {
initStreamReceiveWindow: "The initial QUIC stream receive window size.",
@@ -299,10 +304,18 @@ export default {
maxIncomingStreams:
"The maximum number of concurrent incoming streams.",
disablePathMTUDiscovery: "Disable QUIC path MTU discovery.",
disableStatelessReset:
"Disable QUIC stateless reset. Kept off in the HY2XS baseline: stateless reset lets a client with a stale connection reconnect immediately after a server restart or device sleep.",
},
bandwidth: {
up: "Up",
down: "Down",
disableLossCompensation:
"Disable loss compensation. Kept off in the HY2XS baseline, so compensation stays active.",
},
congestion: {
type: "Fallback congestion controller: bbr or reno. Used when Brutal bandwidth is not negotiated by both sides.",
bbrProfile: "BBR profile: standard, conservative or aggressive.",
},
ignoreClientBandwidth:
"When enabled, makes the server to disregard any bandwidth hints set by clients",
+45 -27
View File
@@ -77,6 +77,7 @@ export default {
add: "Добавить",
confirm: "Подтвердить",
cancel: "Отмена",
close: "Закрыть",
copySuccess: "Скопировано",
nodeUrl: "URL узла",
nodeQrCode: "QR узла",
@@ -94,8 +95,6 @@ export default {
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: "Формат файла не поддерживается",
@@ -124,6 +123,17 @@ export default {
name: "Пир",
remark: "Комментарий",
secret: "Секрет",
form: {
namePlaceholder: "например, ivan-laptop",
nameHint:
"Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
remarkPlaceholder: "например, Ноутбук Ивана, отдел продаж",
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
secretPlaceholder: "оставьте пустым — сгенерируем автоматически",
secretHint:
"Пароль подключения клиента. Если оставить поле пустым, секрет будет сгенерирован автоматически. При ручном вводе: от 6 до 128 символов.",
quotaHint: "Лимит трафика в байтах. Укажите -1 для безлимита.",
},
maxDevices: "Лимит устройств",
disabled: "Отключён",
status: "Статус",
@@ -137,6 +147,13 @@ export default {
unlimited: "Безлимит",
secretRotateConfirm:
"Сменить секрет пира? Текущие клиентские конфиги перестанут работать до обновления.",
exportSettings: "Экспорт настроек",
exportBackup: "Резервная копия",
exportSettingsHint:
"Список пиров без секретов. Безопасно хранить и передавать, но после импорта на другой сервер пирам будут выданы новые секреты, и прежние клиентские ссылки работать не будут.",
exportBackupConfirm:
"Резервная копия содержит ДЕЙСТВУЮЩИЕ секреты подключения всех пиров в открытом виде: любой, кто получит этот файл, получит доступ к VPN. Храните его как пароль и удалите, когда перенос завершён. Только такая копия сохраняет работоспособность существующих клиентских ссылок после переноса на другой сервер.",
exportBackupConfirmButton: "Понимаю, скачать копию с секретами",
quota: "Квота",
download: "Скачано",
upload: "Отдано",
@@ -165,29 +182,13 @@ export default {
},
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: "Диск",
@@ -222,14 +223,15 @@ export default {
addConfigItem: "Добавить параметр",
hysteria2Version: "Версия Hysteria2",
hysteria2Running: "Статус Hysteria2",
hysteria2ChangeVersion: "Сменить",
addOutbound: "Добавить outbound",
extension: "Расширение",
// addOutbound удалён вместе с редактором outbounds: страница read-only,
// маршрутов записи серверного конфига в API нет.
outboundsEmpty: "Outbounds в конфигурации сервера не заданы",
listen: "Адрес прослушивания",
tls: "TLS",
obfs: "Маскировка",
quic: "Параметры QUIC",
bandwidth: "Полоса",
congestion: "Congestion control",
speedTest: "Тест скорости",
udp: "UDP",
resolver: "DNS",
@@ -239,8 +241,6 @@ export default {
http: "Traffic Stats API (HTTP)",
masquerade: "Masquerade",
config: {
enable: "Включить/отключить",
remark: "Комментарий",
listen: "Адрес и порт прослушивания. Для IPv4 используйте 0.0.0.0:443.",
tlsType: "Тип TLS",
tls: {
@@ -264,8 +264,15 @@ export default {
altTLSALPNPort: "Альтернативный TLS-ALPN-порт",
},
obfs: {
type: "Тип",
type: "Тип обфускации: gecko (production default HY2XS) или salamander (совместимость)",
salamander: { password: "Сильный пароль Salamander" },
gecko: {
password: "Сильный пароль Gecko",
minPacketSize:
"Минимальный размер фрагмента QUIC handshake. Baseline HY2XS: 512",
maxPacketSize:
"Максимальный размер фрагмента QUIC handshake. Baseline HY2XS: 1200, верхний предел upstream: 2048",
},
},
quic: {
initStreamReceiveWindow: "Начальное окно приёма QUIC stream",
@@ -275,8 +282,19 @@ export default {
maxIdleTimeout: "Максимальный idle timeout",
maxIncomingStreams: "Максимум входящих stream",
disablePathMTUDiscovery: "Отключить QUIC path MTU discovery",
disableStatelessReset:
"Отключить QUIC stateless reset. В baseline HY2XS выключено: stateless reset ускоряет переподключение клиента после перезапуска сервера или сна устройства",
},
bandwidth: {
up: "Вверх",
down: "Вниз",
disableLossCompensation:
"Отключить компенсацию потерь. В baseline HY2XS выключено, то есть компенсация работает",
},
congestion: {
type: "Fallback congestion controller: bbr или reno. Применяется, когда Brutal bandwidth не согласован сторонами",
bbrProfile: "Профиль BBR: standard, conservative или aggressive",
},
bandwidth: { up: "Вверх", down: "Вниз" },
ignoreClientBandwidth: "Игнорировать bandwidth, заявленный клиентом",
speedTest: "Встроенный сервер теста скорости",
disableUDP: "Отключить UDP forwarding",
+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;
+5 -3
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;
}
@@ -178,10 +180,10 @@
& > .el-sub-menu__title {
& > span {
display: inline-block;
visibility: hidden;
width: 0;
height: 0;
overflow: hidden;
visibility: hidden;
}
}
}
@@ -206,8 +208,8 @@
&.hideSidebar {
.sidebar-container {
pointer-events: none;
transition-duration: 0.3s;
transform: translate3d(-$sideBarWidth, 0, 0);
transition-duration: 0.3s;
}
}
}
File diff suppressed because it is too large Load Diff
+61 -58
View File
@@ -1,65 +1,68 @@
// 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']
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']
ElText: typeof import('element-plus/es')['ElText']
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']
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']
}
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;
}
+6 -1
View File
@@ -3,7 +3,12 @@ import { useAdminStoreHook } from "@/store/modules/admin";
import i18n from "@/lang/index";
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;
// Создание axios instance
const service = axios.create({
+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>
+3 -3
View File
@@ -29,8 +29,8 @@
risk.severity === 'critical'
? 'error'
: risk.severity === 'warning'
? 'warning'
: 'info'
? 'warning'
: 'info'
"
:closable="risk.dismissible"
class="mb-2"
@@ -247,7 +247,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,152 +1,17 @@
<template>
<div class="flex gap-2">
<el-tag
:key="item"
v-for="item in outbounds"
@close="handleClose(item)"
:key="item.name"
@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-text v-if="!outbounds.length" type="info">
{{ $t("hysteria.outboundsEmpty") }}
</el-text>
<el-dialog
:title="outboundInfoDialog.title"
@@ -185,7 +50,7 @@
:content="$t('hysteria.config.outbounds.socks5.username')"
placement="bottom"
>
<el-form-item label="socks5.username" prop="outboundInfo.username">
<el-form-item label="socks5.username" prop="socks5.username">
<el-tag>{{ outboundInfo.socks5.username }}</el-tag>
</el-form-item>
</el-tooltip>
@@ -262,7 +127,7 @@
<template #footer>
<div class="dialog-footer">
<el-button @click="outboundInfoDialog.visible = false"
>{{ $t("common.cancel") }}
>{{ $t("common.close") }}
</el-button>
</div>
</template>
@@ -277,16 +142,36 @@ export default {
</script>
<script setup lang="ts">
import type { Hysteria2ServerConfigOutbound } from "@/api/config/types";
import {
defaultHysteria2ServerConfigOutbound,
Hysteria2ServerConfigOutbound,
} from "@/api/config/types";
defaultHysteria2ServerConfigOutboundView,
normalizeOutboundViewModel,
type Hysteria2ServerConfigOutboundView,
} from "@/api/config/hysteriaViewModel";
import { PropType } from "vue";
import { deepCopy } from "@/utils/copy";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
// Компонент только показывает outbounds, и это не сужение возможностей, а
// приведение UI в соответствие с тем, что продукт умеет.
//
// Что было. Здесь жил полноценный редактор: кнопка «+», диалог создания,
// submitForm с валидацией, closable-теги с удалением и `emit("update:outbounds")`.
// Ни одно из этих действий не могло ничего сохранить:
//
// родительская форма отрисована с `:disabled="true"` — страница read-only и
// прямо сообщает, что конфигом Hysteria владеет `hy2xs-orchestrator reconfigure`;
//
// родитель передаёт `:outbounds="dataForm.outbounds"` БЕЗ v-model, то есть
// слушателя у `update:outbounds` не существует;
//
// маршрутов записи серверного конфига в API нет — они удалены вместе с
// мёртвым updater/config-write слоем.
//
// То есть оператор мог добавить outbound, увидеть его в списке и уйти в
// уверенности, что изменил конфигурацию сервера. Изменения не переживали даже
// перехода на другую вкладку.
const props = defineProps({
outbounds: {
required: true,
@@ -295,84 +180,27 @@ const props = defineProps({
},
});
const emit = defineEmits<{
(
event: "update:outbounds",
value: Array<Hysteria2ServerConfigOutbound>
): void;
}>();
const outbounds = useVModel(props, "outbounds", emit);
const dataFormRef = ref(ElForm);
const outbounds = computed(() => props.outbounds ?? []);
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,
// Карточка работает с нормализованной моделью по той же причине, что и
// основная форма: подблоки socks5/http/direct в схеме необязательны, а
// шаблон обращается к их полям напрямую.
outboundInfo: {
...defaultHysteria2ServerConfigOutboundView,
} as Hysteria2ServerConfigOutboundView,
});
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 { outboundInfoDialog, outboundInfo } = toRefs(state);
const handleInfo = (outbound: Hysteria2ServerConfigOutbound) => {
state.outboundInfo = outbound;
state.outboundInfo = normalizeOutboundViewModel(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>
+191 -144
View File
@@ -51,16 +51,6 @@
:disabled="true"
>
<el-tabs v-model="activeName" class="tabs">
<el-tab-pane :label="$t('hysteria.extension')" name="extension">
<el-tooltip
:content="$t('hysteria.config.remark')"
placement="bottom"
>
<el-form-item label="remark" prop="remark">
<el-input v-model="configForm.remark" clearable />
</el-form-item>
</el-tooltip>
</el-tab-pane>
<el-tab-pane :label="$t('hysteria.listen')" name="listen">
<el-tooltip
:content="$t('hysteria.config.listen')"
@@ -95,28 +85,9 @@
<el-form-item label="cert" prop="tls.cert">
<el-input
v-model="dataForm.tls.cert"
style="width: 50%"
style="width: 100%"
clearable
/>
<el-upload
style="height: 32px"
ref="uploadCrtFile"
action=""
:file-list="crtFileList"
:http-request="uploadCertFile"
accept=".crt"
:before-upload="
() => {
crtFileList = [];
}
"
:show-file-list="false"
:limit="1"
>
<template #trigger>
<el-button>{{ t("config.uploadCrtFile") }}</el-button>
</template>
</el-upload>
</el-form-item>
</el-tooltip>
<el-tooltip
@@ -127,28 +98,9 @@
<el-form-item label="key" prop="tls.key">
<el-input
v-model="dataForm.tls.key"
style="width: 50%"
style="width: 100%"
clearable
/>
<el-upload
style="height: 32px"
ref="uploadKeyFile"
action=""
:file-list="keyFileList"
:http-request="uploadCertFile"
accept=".key"
:before-upload="
() => {
keyFileList = [];
}
"
:show-file-list="false"
:limit="1"
>
<template #trigger>
<el-button>{{ t("config.uploadKeyFile") }}</el-button>
</template>
</el-upload>
</el-form-item>
</el-tooltip>
<el-tooltip
@@ -361,21 +313,63 @@
</el-select>
</el-form-item>
</el-tooltip>
<el-tooltip
v-if="dataForm.obfs.type === 'salamander'"
:content="$t('hysteria.config.obfs.salamander.password')"
placement="bottom"
>
<el-form-item
label="obfs.salamander.password"
prop="obfs.salamander.password"
<template v-if="dataForm.obfs.type === 'salamander'">
<el-tooltip
:content="$t('hysteria.config.obfs.salamander.password')"
placement="bottom"
>
<el-input
v-model="dataForm.obfs.salamander.password"
clearable
/>
</el-form-item>
</el-tooltip>
<el-form-item
label="obfs.salamander.password"
prop="obfs.salamander.password"
>
<el-input
v-model="dataForm.obfs.salamander.password"
clearable
/>
</el-form-item>
</el-tooltip>
</template>
<template v-if="dataForm.obfs.type === 'gecko'">
<el-tooltip
:content="$t('hysteria.config.obfs.gecko.password')"
placement="bottom"
>
<el-form-item
label="obfs.gecko.password"
prop="obfs.gecko.password"
>
<el-input v-model="dataForm.obfs.gecko.password" clearable />
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.obfs.gecko.minPacketSize')"
placement="bottom"
>
<el-form-item
label="obfs.gecko.minPacketSize"
prop="obfs.gecko.minPacketSize"
>
<el-input
v-model.number="dataForm.obfs.gecko.minPacketSize"
clearable
/>
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.obfs.gecko.maxPacketSize')"
placement="bottom"
>
<el-form-item
label="obfs.gecko.maxPacketSize"
prop="obfs.gecko.maxPacketSize"
>
<el-input
v-model.number="dataForm.obfs.gecko.maxPacketSize"
clearable
/>
</el-form-item>
</el-tooltip>
</template>
</el-tab-pane>
<el-tab-pane :label="$t('hysteria.quic')" name="quic" v-if="quic">
<el-tooltip
@@ -470,6 +464,17 @@
<el-switch v-model="dataForm.quic.disablePathMTUDiscovery" />
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.quic.disableStatelessReset')"
placement="bottom"
>
<el-form-item
label="quic.disableStatelessReset"
prop="quic.disableStatelessReset"
>
<el-switch v-model="dataForm.quic.disableStatelessReset" />
</el-form-item>
</el-tooltip>
</el-tab-pane>
<el-tab-pane
:label="$t('hysteria.bandwidth')"
@@ -494,6 +499,19 @@
<el-input v-model="dataForm.bandwidth.down" clearable />
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.bandwidth.disableLossCompensation')"
placement="bottom"
>
<el-form-item
label="bandwidth.disableLossCompensation"
prop="bandwidth.disableLossCompensation"
>
<el-switch
v-model="dataForm.bandwidth.disableLossCompensation"
/>
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.ignoreClientBandwidth')"
placement="bottom"
@@ -506,6 +524,53 @@
</el-form-item>
</el-tooltip>
</el-tab-pane>
<el-tab-pane
:label="$t('hysteria.congestion')"
name="congestion"
v-if="congestion && dataForm.congestion"
>
<el-tooltip
:content="$t('hysteria.config.congestion.type')"
placement="bottom"
>
<el-form-item label="congestion.type" prop="congestion.type">
<el-select
v-model="dataForm.congestion.type"
style="width: 100%"
clearable
>
<el-option
v-for="item in congestionTypes"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.congestion.bbrProfile')"
placement="bottom"
>
<el-form-item
label="congestion.bbrProfile"
prop="congestion.bbrProfile"
>
<el-select
v-model="dataForm.congestion.bbrProfile"
style="width: 100%"
clearable
>
<el-option
v-for="item in bbrProfiles"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-tooltip>
</el-tab-pane>
<el-tab-pane
:label="$t('hysteria.speedTest')"
name="speedTest"
@@ -977,25 +1042,24 @@ export default {
<script setup lang="ts">
import {
defaultHysteria2ServerConfig,
Hysteria2ServerConfig,
} from "@/api/config/types";
defaultHysteria2ServerConfigView,
normalizeHysteriaViewModel,
type Hysteria2ServerConfigView,
} from "@/api/config/hysteriaViewModel";
import Outbounds from "./components/Outbounds/index.vue";
import {
exportHysteria2ConfigApi,
getHysteria2ConfigApi,
listConfigApi,
uploadCertFileApi,
} from "@/api/config";
import { exportHysteria2ConfigApi, getHysteria2ConfigApi } from "@/api/config";
import { useI18n } from "vue-i18n";
import { assignWith, deepCopy } from "@/utils/copy";
import { UploadRequestOptions } from "element-plus";
import { deepCopy } from "@/utils/copy";
import { dashboardSummaryApi } from "@/api/dashboard";
import { UploadUserFile } from "element-plus";
const { t } = useI18n();
const hysteria2Remark = "HYSTERIA2_CONFIG_REMARK";
// Вкладки «extension» с полем remark здесь больше нет.
//
// Она показывала HYSTERIA2_CONFIG_REMARK пустую строку в таблице `config`,
// которую clean install создавал один раз и которую не записывал никто. Имя
// профиля в клиентской ссылке теперь выводится из имени пира на сервере
// (см. service/hysteria2_api.go), поэтому настраивать здесь нечего.
const dataFormRef = ref(ElForm);
const dataFormRules = {
@@ -1020,29 +1084,36 @@ const tlsSniGuards = ref<string[]>(["dns-san", "disable", "strict"]);
const aclTypes = ref<string[]>(["file", "inline"]);
const acmeCas = ref<string[]>(["zerossl", "letsencrypt"]);
const acmeTypes = ref<string[]>(["http", "tls", "dns"]);
// Актуальный upstream-реестр ACME DNS-провайдеров.
// `namedotcom` удалён в Hysteria 2.11.0 вместе с переписанным ACME-стеком:
// конфигурация с ним больше не запускается.
const dnsNames = ref<string[]>([
"cloudflare",
"duckdns",
"gandi",
"godaddy",
"namedotcom",
"namecheap",
"njalla",
"porkbun",
"vultr",
]);
const obfsTypes = ref<string[]>(["salamander"]);
const obfsTypes = ref<string[]>(["gecko", "salamander"]);
const congestionTypes = ref<string[]>(["bbr", "reno"]);
const bbrProfiles = ref<string[]>(["standard", "conservative", "aggressive"]);
const resolverTypes = ref<string[]>(["tcp", "udp", "tls", "https"]);
const masqueradeTypes = ref<string[]>(["file", "proxy", "string"]);
const state = reactive({
configForm: {
remark: "",
},
dataForm: { ...defaultHysteria2ServerConfig } as Hysteria2ServerConfig,
activeName: "extension",
dataForm: deepCopy(
defaultHysteria2ServerConfigView
) as Hysteria2ServerConfigView,
activeName: "listen",
tlsType: "acme",
aclType: "inline",
obfs: false,
quic: false,
bandwidth: false,
congestion: false,
speedTest: false,
udp: false,
resolver: false,
@@ -1054,12 +1125,9 @@ const state = reactive({
version: "",
running: false,
},
crtFileList: [] as UploadUserFile[],
keyFileList: [] as UploadUserFile[],
});
const {
configForm,
activeName,
dataForm,
tlsType,
@@ -1067,6 +1135,7 @@ const {
obfs,
quic,
bandwidth,
congestion,
speedTest,
udp,
resolver,
@@ -1075,8 +1144,6 @@ const {
outbounds,
masquerade,
hysteria2Monitor,
crtFileList,
keyFileList,
} = toRefs(state);
const handleExport = async () => {
@@ -1100,73 +1167,53 @@ const handleExport = async () => {
};
const setConfig = () => {
listConfigApi({
keys: [hysteria2Remark],
}).then((response) => {
const data = response.data;
data.forEach((configVo) => {
if (configVo.key === hysteria2Remark) {
state.configForm.remark = configVo.value;
}
});
});
getHysteria2ConfigApi().then((response) => {
const data = response.data;
if (data) {
state.tlsType = data?.tls?.cert && data?.tls?.key ? "tls" : "acme";
state.aclType = data?.acl?.inline ? "inline" : "file";
state.obfs = !!data?.obfs;
state.quic = !!data?.quic;
state.bandwidth = !!data?.bandwidth;
state.speedTest = !!data?.speedTest;
state.udp = !!data?.disableUDP || !!data?.udpIdleTimeout;
state.resolver = !!data?.resolver;
state.sniff = !!data?.sniff;
state.acl = !!data?.acl;
state.outbounds = !!data?.outbounds;
state.masquerade = !!data?.masquerade;
state.dataForm = deepCopy(defaultHysteria2ServerConfig);
assignWith(state.dataForm, data);
if (!data) {
return;
}
// Какие вкладки показывать, решает ОТВЕТ СЕРВЕРА, а не форма: в форме после
// нормализации есть все секции, потому что это её инвариант. Эти флаги
// единственное место, где присутствие секции в исходном конфиге ещё имеет
// значение, и поэтому они читаются из `data`, а не из `state.dataForm`.
state.tlsType = data.tls?.cert && data.tls?.key ? "tls" : "acme";
state.aclType = data.acl?.inline ? "inline" : "file";
state.obfs = !!data.obfs;
state.quic = !!data.quic;
state.bandwidth = !!data.bandwidth;
state.congestion = !!data.congestion;
state.speedTest = !!data.speedTest;
state.udp = !!data.disableUDP || !!data.udpIdleTimeout;
state.resolver = !!data.resolver;
state.sniff = !!data.sniff;
state.acl = !!data.acl;
state.outbounds = !!data.outbounds;
state.masquerade = !!data.masquerade;
state.dataForm = normalizeHysteriaViewModel(data);
});
};
// dropInactiveObfsSubtype здесь больше нет.
//
// Функция удаляла из формы неактивную ветку obfs, потому что форма строилась
// merge'ем поверх дефолта и показала бы блок, которого нет в конфиге сервера.
// Но какой блок показывать, всегда однозначно определял `obfs.type` а он
// приходит с сервера. Проверка присутствия дублировала проверку типа и была
// единственной причиной, по которой шаблону приходилось писать
// `v-if="dataForm.obfs.type === 'gecko' && dataForm.obfs.gecko"`.
//
// Теперь ветку выбирает только тип, а присутствие обеих обеспечивает
// нормализация.
const setHysteria2Monitor = async () => {
const { data } = await dashboardSummaryApi();
state.hysteria2Monitor.version = data.hysteria.version;
state.hysteria2Monitor.running = data.hysteria.running;
};
const uploadCertFile = async (params: UploadRequestOptions) => {
try {
if (!state.dataForm.tls) {
return;
}
if (
!params.file.name.endsWith(".crt") &&
!params.file.name.endsWith(".key")
) {
ElMessage.error(t("common.fileFormatUnsupported"));
}
if (params.file.size > 1024 * 1024) {
ElMessage.error(t("common.fileTooLarge"));
}
let formData = new FormData();
formData.append("file", params.file);
const { data } = await uploadCertFileApi(formData);
if (params.file.name.endsWith(".crt")) {
state.dataForm.tls.cert = data;
} else if (params.file.name.endsWith(".key")) {
state.dataForm.tls.key = data;
}
} catch (e) {
/* empty */
}
};
onMounted(() => {
setConfig();
setHysteria2Monitor();
+4 -2
View File
@@ -222,10 +222,10 @@ const handleLogin = () => {
.el-input__inner {
color: #fff;
caret-color: #fff;
background: transparent;
border: 0;
border-radius: 0;
caret-color: #fff;
&:-webkit-autofill {
box-shadow: 0 0 0 1000px transparent inset !important;
@@ -237,7 +237,9 @@ const handleLogin = () => {
&:-webkit-autofill:hover,
&:-webkit-autofill:focus,
&:-webkit-autofill:active {
transition: color 99999s ease-out, background-color 99999s ease-out;
transition:
color 99999s ease-out,
background-color 99999s ease-out;
transition-delay: 99999s;
}
}
+129 -42
View File
@@ -30,9 +30,19 @@
>
<el-button>{{ $t("common.import") }}</el-button>
</el-upload>
<el-button class="ml-2" @click="handleExport">{{
$t("common.export")
}}</el-button>
<el-tooltip :content="$t('peer.exportSettingsHint')" placement="bottom">
<el-button class="ml-2" @click="handleExport">{{
$t("peer.exportSettings")
}}</el-button>
</el-tooltip>
<el-button
class="ml-2"
type="warning"
plain
@click="handleExportBackup"
>
{{ $t("peer.exportBackup") }}
</el-button>
</div>
<el-table v-loading="loading" :data="records">
<el-table-column :label="$t('peer.name')" min-width="220">
@@ -65,19 +75,17 @@
<el-table-column :label="$t('peer.traffic')" min-width="260">
<template #default="scope">
<div>
{{
formatBytes(scope.row.downloadBytes + scope.row.uploadBytes)
}}
{{ formatBytes(scope.row.downloadBytes + scope.row.uploadBytes) }}
/ {{ quotaText(scope.row.quotaBytes) }}
</div>
<el-progress
:percentage="trafficPercent(scope.row)"
:percentage="trafficPercent(peerRow(scope.row))"
:status="
scope.row.quotaBytes < 0
? undefined
: trafficPercent(scope.row) >= 90
? 'exception'
: undefined
: trafficPercent(peerRow(scope.row)) >= 90
? 'exception'
: undefined
"
:show-text="false"
/>
@@ -112,36 +120,45 @@
>
<template #default="scope">
<div class="peer-actions">
<el-button link type="primary" @click="openOverview(scope.row)">{{
$t("peer.overview")
}}</el-button>
<el-button link type="primary" @click="copyUri(scope.row)">{{
$t("peer.copyUri")
}}</el-button>
<el-button
link
type="primary"
@click="openOverview(peerRow(scope.row))"
>{{ $t("peer.overview") }}</el-button
>
<el-button
link
type="primary"
@click="copyUri(peerRow(scope.row))"
>{{ $t("peer.copyUri") }}</el-button
>
<el-dropdown trigger="click" placement="bottom-end">
<el-button text circle class="peer-more-btn" @click.stop>
<i-ep-more-filled />
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="showQr(scope.row)">{{
<el-dropdown-item @click="showQr(peerRow(scope.row))">{{
$t("peer.clientQr")
}}</el-dropdown-item>
<el-dropdown-item @click="handleUpdate(scope.row)">{{
$t("common.edit")
}}</el-dropdown-item>
<el-dropdown-item @click="handleResetTraffic(scope.row)">{{
$t("common.resetTraffic")
}}</el-dropdown-item>
<el-dropdown-item @click="handleKick(scope.row)">{{
<el-dropdown-item
@click="handleUpdate(peerRow(scope.row))"
>{{ $t("common.edit") }}</el-dropdown-item
>
<el-dropdown-item
@click="handleResetTraffic(peerRow(scope.row))"
>{{ $t("common.resetTraffic") }}</el-dropdown-item
>
<el-dropdown-item @click="handleKick(peerRow(scope.row))">{{
$t("peer.kick")
}}</el-dropdown-item>
<el-dropdown-item @click="handleReleaseKick(scope.row)">{{
$t("peer.releaseKick")
}}</el-dropdown-item>
<el-dropdown-item
@click="handleReleaseKick(peerRow(scope.row))"
>{{ $t("peer.releaseKick") }}</el-dropdown-item
>
<el-dropdown-item
divided
@click="handleDelete(scope.row)"
@click="handleDelete(peerRow(scope.row))"
>{{ $t("common.delete") }}</el-dropdown-item
>
</el-dropdown-menu>
@@ -167,18 +184,32 @@
:rules="rules"
label-width="140px"
>
<el-form-item :label="$t('peer.name')" prop="name"
><el-input v-model="dataForm.name"
/></el-form-item>
<el-form-item :label="$t('peer.remark')"
><el-input v-model="dataForm.remark"
/></el-form-item>
<el-form-item :label="$t('peer.secret')" prop="secret"
><el-input v-model="dataForm.secret" show-password
/></el-form-item>
<el-form-item :label="$t('peer.quota')"
><el-input-number v-model="dataForm.quotaBytes" :min="-1"
/></el-form-item>
<el-form-item :label="$t('peer.name')" prop="name">
<el-input
v-model="dataForm.name"
:placeholder="$t('peer.form.namePlaceholder')"
/>
<div class="form-hint">{{ $t("peer.form.nameHint") }}</div>
</el-form-item>
<el-form-item :label="$t('peer.remark')">
<el-input
v-model="dataForm.remark"
:placeholder="$t('peer.form.remarkPlaceholder')"
/>
<div class="form-hint">{{ $t("peer.form.remarkHint") }}</div>
</el-form-item>
<el-form-item :label="$t('peer.secret')" prop="secret">
<el-input
v-model="dataForm.secret"
show-password
:placeholder="$t('peer.form.secretPlaceholder')"
/>
<div class="form-hint">{{ $t("peer.form.secretHint") }}</div>
</el-form-item>
<el-form-item :label="$t('peer.quota')">
<el-input-number v-model="dataForm.quotaBytes" :min="-1" />
<div class="form-hint">{{ $t("peer.form.quotaHint") }}</div>
</el-form-item>
<el-form-item :label="$t('peer.expireTime')"
><el-date-picker
v-model="dataForm.expiresAt"
@@ -339,6 +370,28 @@ import {
} from "@/api/peer/types";
import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus";
/**
* Единственный переход от строки слота таблицы к модели пира.
*
* `el-table-column` отдельный компонент, и тип строки из `:data` родительской
* таблицы в его слот не попадает: scope всегда типизирован как
* `{ row: DefaultRow, ... }`, где `DefaultRow = Record<PropertyKey, any>`.
* Обобщённость `el-table` здесь не помогает, а аннотировать слот нельзя
* `DefaultRow` не сужается до `PeerVo` контравариантно.
*
* Поэтому переход нужен, и вопрос только в том, где он стоит. Здесь он ровно
* один, назван и объяснён; альтернативой были семь `as any` прямо в шаблоне,
* каждый из которых отключал бы проверку и всего остального выражения.
*
* Утверждение опирается на соседнюю строку шаблона: `:data="records"`, где
* `records` объявлен как `Ref<PeerVo[]>`. Таблица не получает никаких других
* данных, поэтому в `row` не может оказаться ничего другого. Смена типа
* `records` сломает компиляцию здесь же: `PeerVo` объявлен псевдонимом типа и
* потому совместим с `DefaultRow`, так что это обычное приведение между
* связанными типами, а не `as unknown as`.
*/
const peerRow = (row: Record<PropertyKey, any>): PeerVo => row as PeerVo;
const { t } = useI18n();
const loading = ref(false);
const total = ref(0);
@@ -546,9 +599,9 @@ function beforeImport(file: UploadRawFile) {
return true;
}
async function handleExport() {
async function downloadExport(includeSecrets: boolean) {
try {
const response = await exportPeerApi();
const response = await exportPeerApi(includeSecrets);
const blob = new Blob([response.data], {
type: "application/octet-stream",
});
@@ -566,6 +619,28 @@ async function handleExport() {
}
}
// Обычный экспорт: настройки пиров без секретов.
async function handleExport() {
await downloadExport(false);
}
// Резервная копия: файл содержит действующие секреты подключения всех пиров.
// Подтверждение здесь обязательно это единственное место в панели, где
// оператор осознанно выгружает наружу работающие учётные данные доступа.
async function handleExportBackup() {
await ElMessageBox.confirm(
t("peer.exportBackupConfirm"),
t("common.securityRisk"),
{
type: "warning",
confirmButtonText: t("peer.exportBackupConfirmButton"),
cancelButtonText: t("common.cancel"),
confirmButtonClass: "el-button--danger",
}
);
await downloadExport(true);
}
onMounted(handleQuery);
</script>
@@ -573,23 +648,35 @@ onMounted(handleQuery);
.peer-title {
font-weight: 600;
}
.peer-sub {
font-size: 12px;
color: #909399;
}
.peer-actions {
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
}
.peer-more-btn {
font-size: 16px;
}
.qr-dialog-body {
display: flex;
align-items: center;
justify-content: center;
padding: 12px 0 20px;
}
.form-hint {
width: 100%;
margin-top: 2px;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
}
</style>
+35 -15
View File
@@ -9,8 +9,6 @@ import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
import Icons from "unplugin-icons/vite";
import IconsResolver from "unplugin-icons/resolver";
import { createSvgIconsPlugin } from "vite-plugin-svg-icons";
import UnoCSS from "unocss/vite";
import path from "path";
@@ -21,7 +19,8 @@ const pathSrc = path.resolve(__dirname, "src");
const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$&*+,:;<=>?[\]^`{|}\u007F]/g;
const DRIVE_LETTER_REGEX = /^[a-z]:/i;
const DEV_SERVER_PORT = 8080;
const API_BASE = "/hui";
// Синхронизировано с src/utils/request.ts и constant.AdminAPIBase.
const API_BASE = "/api";
export default defineConfig((): UserConfig => {
return {
@@ -57,14 +56,42 @@ export default defineConfig((): UserConfig => {
},
plugins: [
vue(),
UnoCSS({
/* options */
}),
UnoCSS({/* options */}),
AutoImport({
// Auto import Vue helpers: ref, reactive, toRef, etc.
imports: ["vue", "@vueuse/core"],
//
// Из @vueuse/core берутся ПОИМЁННО только те функции, которые
// действительно используются без явного импорта, а не весь пакет.
//
// Причина не стилистическая. С `"@vueuse/core"` целиком в список
// авто-импорта попадает вся его публичная поверхность — более пятисот
// имён, — и на VueUse 13 сборка падает:
//
// [auto-import] identifier toRef already defined with vue
//
// VueUse реэкспортирует `toRef` и `toValue`, и они конфликтуют с
// одноимёнными функциями Vue. Разрешать конфликт порядком записей в
// массиве значило бы решать вопрос «какой из двух toRef здесь имеется в
// виду» невидимо для читателя файла.
//
// Побочный эффект — src/types/auto-imports.d.ts сокращается с 526 строк
// до размера, который можно прочитать глазами.
imports: [
"vue",
{
"@vueuse/core": ["useVModel", "useFullscreen"],
},
],
eslintrc: {
enabled: false, // Default `false`
// Включено намеренно. Файл перечисляет глобальные имена для eslint и
// подключается через `extends` в .eslintrc.cjs, но генерация была
// выключена — то есть список жил своей жизнью и расходился со
// списком авто-импортов при каждом его изменении.
//
// Устаревшие записи здесь не безобидны: eslint перестаёт сообщать об
// обращении к имени, которого больше нет в авто-импорте, и ошибка
// доезжает до typecheck или до рантайма вместо линтера.
enabled: true,
filepath: "./.eslintrc-auto-import.json", // Default `./.eslintrc-auto-import.json`
globalsPropValue: true, // Default `true`, (true | false | 'readonly' | 'readable' | 'writable' | 'writeable')
},
@@ -94,13 +121,6 @@ export default defineConfig((): UserConfig => {
// Auto install icon collections
autoInstall: true,
}),
createSvgIconsPlugin({
// Icon folders to cache
iconDirs: [path.resolve(pathSrc, "assets/icons")],
// symbolId format
symbolId: "icon-[dir]-[name]",
}),
],
build: {
rollupOptions: {
+58 -39
View File
@@ -1,67 +1,86 @@
module hy2xs-admin
go 1.21
// Языковая версия держится ниже GO_VERSION из versions.env: сборка проверяет
// это шагом verify_go_toolchain_contract. 1.21 здесь стояла с поколения, в
// котором она ещё поддерживалась; к v1 её линия давно вне поддержки, а вместе
// с ней в бинарь попадала соответствующая stdlib.
go 1.25.0
// toolchain объявляет, каким компилятором проект собирается на самом деле.
//
// Директива `go` выше это языковой baseline модуля, и она НЕ выбирает
// компилятор: с ней одной локальный `go build` на 1.25 проходил успешно, хотя
// релизный бинарь собирается на 1.26.7 и наследует её stdlib. То есть
// разработчик и сборка проверяли разный код, а расхождение не было видно ни в
// одном из выводов.
//
// Значение обязано совпадать с GO_VERSION из versions.env; это проверяет
// verify_go_toolchain_contract, а `tools/dev/doctor` показывает то же
// расхождение локально, до сборки.
toolchain go1.26.7
require (
github.com/didip/tollbooth v4.0.2+incompatible
github.com/gin-gonic/gin v1.9.1
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/go-playground/validator/v10 v10.14.0
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/go-playground/validator/v10 v10.30.3
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil v3.21.11+incompatible
github.com/sirupsen/logrus v1.9.3
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/spf13/cobra v1.8.1
golang.org/x/crypto v0.18.0
github.com/sirupsen/logrus v1.10.2
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.55.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
gorm.io/gorm v1.25.9
gorm.io/gorm v1.31.2
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.3 // indirect
github.com/bytedance/sonic/loader v0.5.2 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
github.com/glebarez/go-sqlite v1.23.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.61.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/tklauser/go-sysconf v0.3.13 // indirect
github.com/tklauser/numcpus v0.7.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/ugorji/go/codec v1.3.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.8.2 // indirect
golang.org/x/arch v0.30.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
modernc.org/libc v1.75.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
modernc.org/sqlite v1.57.0 // indirect
)
+134 -97
View File
@@ -1,11 +1,12 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.3 h1:P3akjLPBtV/i6bHC6LbcLjY3KuoOvfiqF8wFHeP5IhY=
github.com/bytedance/sonic v1.15.3/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc=
github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -14,39 +15,42 @@ github.com/didip/tollbooth v4.0.2+incompatible h1:fVSa33JzSz0hoh2NxpwZtksAzAgd7z
github.com/didip/tollbooth v4.0.2+incompatible/go.mod h1:A9b0665CE6l1KmzpDws2++elm/CsuWBMa5Jv4WY0PEY=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.23.0 h1:FyhIq4jqmgphQAUlY79zPldYGwISEZikaDfhiGWkkaI=
github.com/glebarez/go-sqlite v1.23.0/go.mod h1:IIYrOH3L0rHY3jb4IXOHoWdklNajSGUN2eJcvK8WrnI=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -55,106 +59,139 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA=
github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tklauser/go-sysconf v0.3.13 h1:GBUpcahXSpR2xN01jhkNAbTLRk2Yzgggk8IM08lq3r4=
github.com/tklauser/go-sysconf v0.3.13/go.mod h1:zwleP4Q4OehZHGn4CYZDipCgg9usW5IJePewFCGVEa0=
github.com/tklauser/numcpus v0.7.0 h1:yjuerZP127QG9m5Zh/mSO4wqurYil27tHrqwRoRjpr4=
github.com/tklauser/numcpus v0.7.0/go.mod h1:bb6dMVcj8A42tSE7i32fsIUCbQNllK5iDguyOZRUzAY=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
go.mongodb.org/mongo-driver/v2 v2.8.2 h1:b6o2m7zL8g2URuO8urBedAylxojybKXNZTxgkOcl+2w=
go.mongodb.org/mongo-driver/v2 v2.8.2/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU=
golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8=
gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
-44
View File
@@ -1,44 +0,0 @@
package middleware
import (
"errors"
"github.com/robfig/cron/v3"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
"hy2xs-admin/service"
"time"
)
func InitCron() error {
loc := time.Now().Location()
c := cron.New(cron.WithLocation(loc))
_, err := c.AddFunc("@every 30s", service.CronHandleAccount)
if err != nil {
logrus.Errorf("cron add func CronHandleAccount err: %v", err)
return errors.New("cron add func CronHandleAccount err")
}
_, err = c.AddFunc("@every 10s", service.CollectMetricsSnapshot)
if err != nil {
logrus.Errorf("cron add func CollectMetricsSnapshot err: %v", err)
return errors.New("cron add func CollectMetricsSnapshot err")
}
_, err = c.AddFunc("@every 1h", service.CleanupStatsRetention)
if err != nil {
logrus.Errorf("cron add func CleanupStatsRetention err: %v", err)
return errors.New("cron add func CleanupStatsRetention err")
}
resetTrafficCron, err := dao.GetConfig("key = ?", constant.ResetTrafficCron)
if err != nil {
return err
}
if *resetTrafficCron.Value != "" {
_, err := c.AddFunc(*resetTrafficCron.Value, service.CronResetTraffic)
if err != nil {
logrus.Errorf("cron add func CronResetTraffic err: %v", err)
}
}
go service.CleanupStatsRetention()
c.Start()
return nil
}
+46 -4
View File
@@ -1,11 +1,14 @@
package middleware
import (
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"gopkg.in/natefinch/lumberjack.v2"
"hy2xs-admin/model/constant"
"time"
)
func InitLog() {
@@ -21,6 +24,40 @@ func InitLog() {
logrus.SetLevel(logrus.InfoLevel)
}
// requestQueryKeys возвращает ИМЕНА query-параметров запроса, без значений.
//
// Для диагностики важно, был ли параметр передан, а не что в нём лежало.
// Список сортируется, чтобы одинаковые запросы давали одинаковую строку в
// логе и её можно было группировать.
func requestQueryKeys(c *gin.Context) string {
query := c.Request.URL.Query()
if len(query) == 0 {
return ""
}
keys := make([]string, 0, len(query))
for key := range query {
keys = append(keys, key)
}
sort.Strings(keys)
return strings.Join(keys, ",")
}
// LogHandler пишет журнал HTTP-запросов админки.
//
// В лог попадает ПУТЬ, а не RequestURI.
//
// Разница здесь не косметическая. RequestURI включает query string, а Hysteria
// обращается к машинному endpoint'у как
// /internal/hysteria/auth?access_token=<machine token> — то есть при каждом
// подключении пира. Пока логировался RequestURI, действующий machine token
// оседал открытым текстом в /var/log/hy2xs/hy2xs-admin.log, а этот файл
// отдаётся оператору через ExportLog и попадает в diagnostics-бандл.
// Получался постоянный канал утечки секрета в обычной штатной работе, мимо
// всей структурной редакции, сделанной для конфигов и env.
//
// Значения query-параметров не логируются вовсе: список того, «что можно», при
// таком подходе пришлось бы поддерживать вручную и он неизбежно разошёлся бы с
// набором маршрутов. Имена параметров сохранены — их для диагностики хватает.
func LogHandler() gin.HandlerFunc {
return func(c *gin.Context) {
startTime := time.Now()
@@ -30,13 +67,18 @@ func LogHandler() gin.HandlerFunc {
statusCode := c.Writer.Status()
latencyTime := time.Since(startTime)
entry := logrus.WithFields(logrus.Fields{
fields := logrus.Fields{
"statusCode": statusCode,
"latencyTime": latencyTime.Milliseconds(),
"clientIP": c.ClientIP(),
"reqMethod": c.Request.Method,
"reqUri": c.Request.RequestURI,
})
"reqPath": c.Request.URL.Path,
}
if keys := requestQueryKeys(c); keys != "" {
fields["reqQueryKeys"] = keys
}
entry := logrus.WithFields(fields)
if statusCode >= 500 {
entry.Error()
+105
View File
@@ -0,0 +1,105 @@
package middleware
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
// sentinel имитирует действующий machine token: если он окажется в журнале —
// значит секрет утёк в /var/log/hy2xs/hy2xs-admin.log, который отдаётся
// оператору через ExportLog и попадает в diagnostics-бандл.
const sentinel = "SUPER_SECRET_SENTINEL"
func captureLog(t *testing.T, request *http.Request) string {
t.Helper()
previousOut := logrus.StandardLogger().Out
previousFormatter := logrus.StandardLogger().Formatter
t.Cleanup(func() {
logrus.SetOutput(previousOut)
logrus.SetFormatter(previousFormatter)
})
buffer := &bytes.Buffer{}
logrus.SetOutput(buffer)
logrus.SetFormatter(&logrus.JSONFormatter{})
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(LogHandler())
engine.POST("/internal/hysteria/auth", func(c *gin.Context) { c.Status(http.StatusOK) })
engine.GET("/api/peers", func(c *gin.Context) { c.Status(http.StatusOK) })
engine.ServeHTTP(httptest.NewRecorder(), request)
return buffer.String()
}
// Регрессия: логировался c.Request.RequestURI, то есть путь ВМЕСТЕ с query.
// Hysteria обращается к машинному endpoint'у при каждом подключении пира, так
// что токен оседал в журнале в штатном режиме работы.
func TestLogHandlerNeverLogsQueryValues(t *testing.T) {
request := httptest.NewRequest(
http.MethodPost,
"/internal/hysteria/auth?access_token="+sentinel,
strings.NewReader("{}"),
)
logged := captureLog(t, request)
if strings.Contains(logged, sentinel) {
t.Fatalf("machine token попал в журнал: %s", logged)
}
if !strings.Contains(logged, `"reqPath":"/internal/hysteria/auth"`) {
t.Fatalf("в журнале нет пути запроса: %s", logged)
}
if strings.Contains(logged, "reqUri") {
t.Fatalf("поле reqUri вернулось в журнал: %s", logged)
}
}
// Имена параметров для диагностики сохраняются: важно видеть, что параметр был
// передан, а не что в нём лежало.
func TestLogHandlerKeepsQueryKeyNames(t *testing.T) {
request := httptest.NewRequest(
http.MethodPost,
"/internal/hysteria/auth?access_token="+sentinel,
strings.NewReader("{}"),
)
logged := captureLog(t, request)
if !strings.Contains(logged, `"reqQueryKeys":"access_token"`) {
t.Fatalf("имя query-параметра не попало в журнал: %s", logged)
}
}
func TestLogHandlerOmitsQueryKeysWhenThereAreNone(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/api/peers", nil)
logged := captureLog(t, request)
if strings.Contains(logged, "reqQueryKeys") {
t.Fatalf("пустой список параметров не должен появляться в журнале: %s", logged)
}
}
// Значение секретоподобного параметра не должно попасть в журнал ни на каком
// маршруте, а не только на машинном.
func TestLogHandlerRedactsQueryValuesOnOperatorRoutes(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/api/peers?includeSecrets=true&name="+sentinel, nil)
logged := captureLog(t, request)
if strings.Contains(logged, sentinel) {
t.Fatalf("значение query-параметра попало в журнал: %s", logged)
}
if !strings.Contains(logged, `"reqPath":"/api/peers"`) {
t.Fatalf("в журнале нет пути запроса: %s", logged)
}
}
+6 -1
View File
@@ -1,6 +1,7 @@
package middleware
import (
"crypto/subtle"
"net/http"
"strings"
@@ -38,7 +39,11 @@ func MachineAuthHandler() gin.HandlerFunc {
provided = strings.TrimSpace(c.GetHeader("X-HY2XS-Machine-Token"))
}
if provided == "" || provided != expected {
// Сравнение постоянного времени: обычное `!=` завершается на первом
// несовпавшем байте. Endpoint слушает только loopback, но локальный
// непривилегированный процесс — ровно та позиция, из которой такой
// замер и делается, а ценой ошибки здесь является machine token.
if provided == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"code": http.StatusForbidden,
"type": "no",
+114 -1
View File
@@ -1,12 +1,55 @@
package bo
// ObfsShareConfig — та часть обфускации, которую способна описать официальная
// схема hysteria2:// URI: только type и password.
//
// Gecko minPacketSize/maxPacketSize в ссылку не помещаются. Именно поэтому
// HY2XS фиксирует их на upstream defaults 512/1200: иначе сгенерированная
// ссылка не описывала бы подключение полностью.
type ObfsShareConfig struct {
Type string
Password string
}
// ObfsShare приводит любой поддерживаемый тип обфускации к единому виду для
// генерации клиентской ссылки. Знание о подтипах живёт рядом с моделью, чтобы
// добавление нового типа не требовало правок в слое share URI.
func (c *Hysteria2ServerConfig) ObfsShare() ObfsShareConfig {
if c == nil || c.Obfs == nil || c.Obfs.Type == nil {
return ObfsShareConfig{}
}
switch *c.Obfs.Type {
case "salamander":
if c.Obfs.Salamander != nil && c.Obfs.Salamander.Password != nil {
return ObfsShareConfig{Type: "salamander", Password: *c.Obfs.Salamander.Password}
}
case "gecko":
if c.Obfs.Gecko != nil && c.Obfs.Gecko.Password != nil {
return ObfsShareConfig{Type: "gecko", Password: *c.Obfs.Gecko.Password}
}
}
return ObfsShareConfig{}
}
// AcmeDomain возвращает первый ACME-домен, если он есть.
func (c *Hysteria2ServerConfig) AcmeDomain() string {
if c == nil || c.ACME == nil || len(c.ACME.Domains) == 0 {
return ""
}
return c.ACME.Domains[0]
}
type Hysteria2ServerConfig struct {
Listen *string `yaml:"listen,omitempty" json:"listen" validate:"required"`
Obfs *serverConfigObfs `yaml:"obfs,omitempty" json:"obfs" validate:"omitempty"`
TLS *serverConfigTLS `yaml:"tls,omitempty" json:"tls" validate:"omitempty"`
ACME *serverConfigACME `yaml:"acme,omitempty" json:"acme" validate:"omitempty"`
ECH *serverConfigECH `yaml:"ech,omitempty" json:"ech" validate:"omitempty"`
QUIC *serverConfigQUIC `yaml:"quic,omitempty" json:"quic" validate:"omitempty"`
Bandwidth *serverConfigBandwidth `yaml:"bandwidth,omitempty" json:"bandwidth" validate:"omitempty"`
Congestion *ServerConfigCongestion `yaml:"congestion,omitempty" json:"congestion" validate:"omitempty"`
IgnoreClientBandwidth *bool `yaml:"ignoreClientBandwidth,omitempty" json:"ignoreClientBandwidth" validate:"omitempty"`
SpeedTest *bool `yaml:"speedTest,omitempty" json:"speedTest" validate:"omitempty"`
DisableUDP *bool `yaml:"disableUDP,omitempty" json:"disableUDP" validate:"omitempty"`
@@ -18,21 +61,43 @@ type Hysteria2ServerConfig struct {
Outbounds []serverConfigOutboundEntry `yaml:"outbounds,omitempty" json:"outbounds" validate:"omitempty"`
TrafficStats *ServerConfigTrafficStats `yaml:"trafficStats,omitempty" json:"trafficStats" validate:"required"`
Masquerade *serverConfigMasquerade `yaml:"masquerade,omitempty" json:"masquerade" validate:"omitempty"`
Mimic *serverConfigMimic `yaml:"mimic,omitempty" json:"mimic" validate:"omitempty"`
Realm *serverConfigRealm `yaml:"realm,omitempty" json:"realm" validate:"omitempty"`
}
type serverConfigObfsSalamander struct {
Password *string `yaml:"password,omitempty" json:"password" validate:"required"`
}
// serverConfigObfsGecko описывает Gecko-обфускацию (Hysteria 2.9.2+).
// Gecko достраивается поверх Salamander и дополнительно фрагментирует
// QUIC handshake на пакеты случайного размера в диапазоне min..max.
type serverConfigObfsGecko struct {
Password *string `yaml:"password,omitempty" json:"password" validate:"required"`
MinPacketSize *int `yaml:"minPacketSize,omitempty" json:"minPacketSize" validate:"omitempty"`
MaxPacketSize *int `yaml:"maxPacketSize,omitempty" json:"maxPacketSize" validate:"omitempty"`
}
// Обе ветки опциональны: в конфиге присутствует ровно одна из них,
// соответствующая Type.
type serverConfigObfs struct {
Type *string `yaml:"type,omitempty" json:"type" validate:"required"`
Salamander *serverConfigObfsSalamander `yaml:"salamander,omitempty" json:"salamander" validate:"required"`
Salamander *serverConfigObfsSalamander `yaml:"salamander,omitempty" json:"salamander" validate:"omitempty"`
Gecko *serverConfigObfsGecko `yaml:"gecko,omitempty" json:"gecko" validate:"omitempty"`
}
type serverConfigTLS struct {
Cert *string `yaml:"cert,omitempty" json:"cert" validate:"required"`
Key *string `yaml:"key,omitempty" json:"key" validate:"required"`
SNIGuard *string `yaml:"sniGuard,omitempty" json:"sniGuard" validate:"omitempty"`
ClientCA *string `yaml:"clientCA,omitempty" json:"clientCA" validate:"omitempty"`
}
// serverConfigECH — Encrypted Client Hello (Hysteria 2.10.0+).
// HY2XS не генерирует ECH keypair и не включает ECH в default-профиле,
// но обязан корректно читать и сохранять этот блок.
type serverConfigECH struct {
KeyPath *string `yaml:"keyPath,omitempty" json:"keyPath" validate:"omitempty"`
}
type serverConfigACME struct {
@@ -78,11 +143,25 @@ type serverConfigQUIC struct {
MaxIdleTimeout *string `yaml:"maxIdleTimeout,omitempty" json:"maxIdleTimeout" validate:"omitempty"`
MaxIncomingStreams *int64 `yaml:"maxIncomingStreams,omitempty" json:"maxIncomingStreams" validate:"omitempty"`
DisablePathMTUDiscovery *bool `yaml:"disablePathMTUDiscovery,omitempty" json:"disablePathMTUDiscovery" validate:"omitempty"`
// DisableStatelessReset появился в Hysteria 2.12.2. HY2XS оставляет
// stateless reset включённым: клиент после перезапуска сервера или сна
// устройства переподключается сразу, а не по таймауту.
DisableStatelessReset *bool `yaml:"disableStatelessReset,omitempty" json:"disableStatelessReset" validate:"omitempty"`
}
type serverConfigBandwidth struct {
Up *string `yaml:"up,omitempty" json:"up" validate:"required"`
Down *string `yaml:"down,omitempty" json:"down" validate:"required"`
// DisableLossCompensation появился в Hysteria 2.10.0.
DisableLossCompensation *bool `yaml:"disableLossCompensation,omitempty" json:"disableLossCompensation" validate:"omitempty"`
}
// ServerConfigCongestion — fallback congestion controller. Используется, когда
// Brutal bandwidth не согласован сторонами. Это не то же самое, что host-level
// BBR ядра Linux: у Hysteria собственный congestion-control контур.
type ServerConfigCongestion struct {
Type *string `yaml:"type,omitempty" json:"type" validate:"omitempty"`
BBRProfile *string `yaml:"bbrProfile,omitempty" json:"bbrProfile" validate:"omitempty"`
}
type ServerConfigAuthHTTP struct {
@@ -183,9 +262,11 @@ type serverConfigMasqueradeFile struct {
}
type serverConfigMasqueradeProxy struct {
// URL с Hysteria 2.12.2 может указывать и на unix socket.
URL *string `yaml:"url,omitempty" json:"url" validate:"required"`
RewriteHost *bool `yaml:"rewriteHost,omitempty" json:"rewriteHost" validate:"required"`
Insecure *bool `yaml:"insecure,omitempty" json:"insecure" validate:"required"`
XForwarded *bool `yaml:"xForwarded,omitempty" json:"xForwarded" validate:"omitempty"`
}
type serverConfigMasqueradeString struct {
@@ -203,3 +284,35 @@ type serverConfigMasquerade struct {
ListenHTTPS *string `yaml:"listenHTTPS,omitempty" json:"listenHTTPS" validate:"omitempty"`
ForceHTTPS *bool `yaml:"forceHTTPS,omitempty" json:"forceHTTPS" validate:"omitempty"`
}
// serverConfigMimic — Mimic / fake TCP (Hysteria 2.12.0+).
// Требует отдельный сторонний бинарник, root и eBPF/XDP, поэтому в
// production-профиле HY2XS выключен: текущий systemd-контракт запускает
// Hysteria под непривилегированным пользователем.
type serverConfigMimic struct {
Enabled *bool `yaml:"enabled,omitempty" json:"enabled" validate:"omitempty"`
Interface *string `yaml:"interface,omitempty" json:"interface" validate:"omitempty"`
XDPMode *string `yaml:"xdpMode,omitempty" json:"xdpMode" validate:"omitempty"`
Path *string `yaml:"path,omitempty" json:"path" validate:"omitempty"`
ExtraArgs []string `yaml:"extraArgs,omitempty" json:"extraArgs" validate:"omitempty"`
}
type serverConfigRealmPortMapping struct {
Enabled *bool `yaml:"enabled,omitempty" json:"enabled" validate:"omitempty"`
Timeout *string `yaml:"timeout,omitempty" json:"timeout" validate:"omitempty"`
Lifetime *string `yaml:"lifetime,omitempty" json:"lifetime" validate:"omitempty"`
}
// serverConfigRealm — Hysteria Realms (2.9.0+), запуск сервера за NAT через
// STUN/hole punching. Меняет фундаментальный сетевой контракт HY2XS
// (выделенный сервер, публичный IPv4, UDP/443, own nftables), поэтому в
// default-профиле не используется, но должен корректно читаться.
type serverConfigRealm struct {
StunServers []string `yaml:"stunServers,omitempty" json:"stunServers" validate:"omitempty"`
StunTimeout *string `yaml:"stunTimeout,omitempty" json:"stunTimeout" validate:"omitempty"`
PunchTimeout *string `yaml:"punchTimeout,omitempty" json:"punchTimeout" validate:"omitempty"`
HeartbeatInterval *string `yaml:"heartbeatInterval,omitempty" json:"heartbeatInterval" validate:"omitempty"`
Insecure *bool `yaml:"insecure,omitempty" json:"insecure" validate:"omitempty"`
IPMode *string `yaml:"ipMode,omitempty" json:"ipMode" validate:"omitempty"`
PortMapping *serverConfigRealmPortMapping `yaml:"portMapping,omitempty" json:"portMapping" validate:"omitempty"`
}
+27
View File
@@ -0,0 +1,27 @@
package constant
// Пространства имён HTTP API админки.
//
// Раньше всё жило под одним префиксом «hui» — наследие H UI, попавшее не
// только во внутренние имена, но и в runtime-контракт: путь machine-auth
// записывается в /etc/hysteria/config.yaml и в post-install.env, то есть
// является частью протокола между Hysteria и админкой.
//
// Разделение на два пространства отражает разницу в природе маршрутов:
//
// AdminAPIBase — операторский API, за JWT и rate limiter;
// HysteriaMachineAuth — не API интерфейса вовсе, а внутренний
// IPC-подобный endpoint между Hysteria и админкой,
// доступный только с loopback и по machine token.
//
// Значения обязаны совпадать с константами оркестратора
// (orchestrator/src/config/profile.ts). Сверка выполняется на сборке шагом
// verify_versions_contract.
const (
// AdminAPIBase — префикс операторского и auth API панели.
AdminAPIBase = "/api"
// HysteriaMachineAuthPath — полный путь machine-auth endpoint'а,
// который Hysteria вызывает при подключении пира.
HysteriaMachineAuthPath = "/internal/hysteria/auth"
)
+117 -8
View File
@@ -1,17 +1,126 @@
package constant
// Ключи таблицы `config` — то, чем действительно владеет админка.
//
// Сетевых настроек самой панели (порт, web-контекст, пути к сертификатам)
// здесь больше нет. Они достались от H UI, где панель конфигурировала себя
// сама, и в архитектуре HY2XS были вторым источником истины: порт приходит из
// HY2XS_UI_PORT через systemd, bind — из HY2XS_UI_BIND_HOST, TLS
// терминируется на внешнем слое. Панель записывала в SQLite тот же порт,
// который ей передали аргументом, и тут же читала его обратно.
// Ключей HYSTERIA2_ENABLE, HYSTERIA2_CONFIG, HYSTERIA2_TRAFFIC_TIME и
// HYSTERIA2_CONFIG_REMARK здесь больше нет. Все четыре достались от предыдущего
// поколения панели и к v1 перестали чем-либо управлять:
//
// HYSTERIA2_ENABLE — жизненным циклом Hysteria владеет systemd, а
// единственным потребителем ключа была строка лога;
// HYSTERIA2_CONFIG — второй источник истины рядом с
// /etc/hysteria/config.yaml, причём читался ПЕРВЫМ;
// HYSTERIA2_TRAFFIC_TIME — настройка в панели без единого потребителя в
// рантайме: интервал сбора метрик задан в коде;
// HYSTERIA2_CONFIG_REMARK — пустая read-only строка, которую никто не писал.
//
// Пустой ключ, который нельзя изменить и который ни на что не влияет, — это не
// «задел на будущее», а состояние, которое приходится объяснять в документации
// и защищать в allowlist. Настоящую замену получил только remark: имя профиля
// в share URI теперь выводится из имени пира (см. service/hysteria2_api.go).
const (
HUIWebPort = "H_UI_WEB_PORT"
HUIWebContext = "H_UI_WEB_CONTEXT"
HUICrtPath = "H_UI_CRT_PATH"
HUIKeyPath = "H_UI_KEY_PATH"
JwtSecret = "JWT_SECRET"
PeerSecretKey = "PEER_SECRET_KEY"
PeerSecretEncryptionKey = "PEER_SECRET_ENCRYPTION_KEY"
Hysteria2TrafficStatsSecret = "HYSTERIA2_TRAFFIC_STATS_SECRET"
Hysteria2Enable = "HYSTERIA2_ENABLE"
Hysteria2Config = "HYSTERIA2_CONFIG"
Hysteria2TrafficTime = "HYSTERIA2_TRAFFIC_TIME"
Hysteria2ConfigRemark = "HYSTERIA2_CONFIG_REMARK"
ResetTrafficCron = "RESET_TRAFFIC_CRON"
// BootstrapPeerSeeded — отметка о том, что пир установщика УЖЕ был создан
// однажды. Не «существует сейчас», а именно «был создан».
//
// Различие и есть суть ключа. Раньше единственным признаком служило наличие
// строки в таблице пиров, поэтому удаление bootstrap-admin-peer выглядело
// отзывом доступа, но им не было: HY2XS_ADMIN_CON_PASS продолжает жить в
// /etc/hy2xs/hy2xs.env, и ближайший `systemctl restart hy2xs-admin` или
// перезагрузка сервера возвращали тот же самый секрет в базу. Молча.
//
// Отзыв учётных данных, который не переживает перезапуск, — не отзыв.
// Маркер делает удаление окончательным: seeder читает его, а не таблицу
// пиров, поэтому после первого успешного bootstrap он не создаёт запись
// больше никогда.
BootstrapPeerSeeded = "BOOTSTRAP_PEER_SEEDED"
)
// Что операторский 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 позволял его подменить. После
// подмены перестают расшифровываться секреты уже существующих пиров.
//
// Именно ради этого удаляли generic export/import таблицы `config`, но точечный
// API остался прежним, и дыра просто сменила размер отверстия.
//
// Denylist здесь неверен структурно: он требует, чтобы автор КАЖДОГО нового
// ключа вспомнил про этот файл. Забытый ключ при denylist сразу публичен, при
// allowlist — сразу закрыт. Отказ по умолчанию — единственное поведение,
// которое не зависит от внимательности.
var (
// publicReadableConfigKeys — настройки, которые панель показывает оператору.
publicReadableConfigKeys = map[string]struct{}{
ResetTrafficCron: {},
}
// publicWritableConfigKeys — подмножество читаемых, которые оператор
// действительно редактирует в интерфейсе.
//
// После очистки мёртвых ключей оба списка совпали: единственная настройка,
// которой владеет панель, — расписание сброса счётчиков трафика. Списки
// намеренно не схлопнуты в один: читаемое и записываемое — разные права, и
// первый же ключ «показываем, но менять нельзя» вернёт разницу.
publicWritableConfigKeys = map[string]struct{}{
ResetTrafficCron: {},
}
)
// IsPublicReadableConfigKey сообщает, можно ли отдать значение ключа наружу.
func IsPublicReadableConfigKey(key string) bool {
_, ok := publicReadableConfigKeys[key]
return ok
}
// IsPublicWritableConfigKey сообщает, можно ли принять значение ключа снаружи.
func IsPublicWritableConfigKey(key string) bool {
_, ok := publicWritableConfigKeys[key]
return ok
}
// PublicReadableConfigKeys возвращает копию списка читаемых ключей.
// Используется тестами и текстами ошибок.
func PublicReadableConfigKeys() []string {
return keysOf(publicReadableConfigKeys)
}
// PublicWritableConfigKeys возвращает копию списка записываемых ключей.
func PublicWritableConfigKeys() []string {
return keysOf(publicWritableConfigKeys)
}
// InternalConfigKeys — ключи, которые не покидают процесс ни при каком
// сценарии. Объявлены явно, чтобы тест мог проверить их поимённо, а не
// полагаться на то, что их «случайно нет» в allowlist.
func InternalConfigKeys() []string {
return []string{
JwtSecret,
PeerSecretKey,
PeerSecretEncryptionKey,
Hysteria2TrafficStatsSecret,
}
}
func keysOf(set map[string]struct{}) []string {
out := make([]string, 0, len(set))
for key := range set {
out = append(out, key)
}
return out
}
+79
View File
@@ -0,0 +1,79 @@
package constant
import "testing"
// Allowlist существует ровно для того, чтобы забытый ключ был закрыт, а не
// открыт. Тест закрепляет обе стороны этого утверждения поимённо.
func TestInternalKeysAreNeitherReadableNorWritable(t *testing.T) {
for _, key := range InternalConfigKeys() {
if IsPublicReadableConfigKey(key) {
t.Errorf("внутренний ключ %s доступен на чтение через API", key)
}
if IsPublicWritableConfigKey(key) {
t.Errorf("внутренний ключ %s доступен на запись через API", key)
}
}
}
func TestSecretKeysAreListedAsInternal(t *testing.T) {
required := []string{
JwtSecret,
PeerSecretKey,
PeerSecretEncryptionKey,
Hysteria2TrafficStatsSecret,
}
internal := make(map[string]struct{}, len(InternalConfigKeys()))
for _, key := range InternalConfigKeys() {
internal[key] = struct{}{}
}
for _, key := range required {
if _, ok := internal[key]; !ok {
t.Errorf("секрет %s не объявлен внутренним ключом", key)
}
}
}
func TestOperatorSettingsRemainAvailable(t *testing.T) {
if !IsPublicReadableConfigKey(ResetTrafficCron) {
t.Errorf("пользовательская настройка %s недоступна на чтение", ResetTrafficCron)
}
if !IsPublicWritableConfigKey(ResetTrafficCron) {
t.Errorf("пользовательская настройка %s недоступна на запись", ResetTrafficCron)
}
}
// Ключи, удалённые как мёртвое состояние, не должны вернуться в API «по
// инерции» вместе с восстановлением константы. Проверка идёт по строковым
// литералам: соответствующих констант в коде уже нет, и появиться они не
// должны.
func TestRemovedKeysStayClosed(t *testing.T) {
for _, key := range []string{
"HYSTERIA2_ENABLE",
"HYSTERIA2_CONFIG",
"HYSTERIA2_TRAFFIC_TIME",
"HYSTERIA2_CONFIG_REMARK",
} {
if IsPublicReadableConfigKey(key) {
t.Errorf("удалённый ключ %s снова доступен на чтение", key)
}
if IsPublicWritableConfigKey(key) {
t.Errorf("удалённый ключ %s снова доступен на запись", key)
}
}
}
// Право записи без права чтения означает настройку, которую оператор может
// изменить, но не может увидеть.
func TestWritableIsSubsetOfReadable(t *testing.T) {
for _, key := range PublicWritableConfigKeys() {
if !IsPublicReadableConfigKey(key) {
t.Errorf("ключ %s можно записать, но нельзя прочитать", key)
}
}
}
func TestUnknownKeyIsClosedByDefault(t *testing.T) {
if IsPublicReadableConfigKey("SOME_FUTURE_KEY") || IsPublicWritableConfigKey("SOME_FUTURE_KEY") {
t.Fatal("неизвестный ключ обязан быть закрыт по умолчанию")
}
}
+26 -7
View File
@@ -13,14 +13,23 @@ func getenv(name string, fallback string) string {
return value
}
// Пути читаются напрямую из canonical env HY2XS.
//
// Раньше здесь стояли HUI_DATA/HUI_LOG, а systemd-юнит перекладывал в них
// значения из HY2XS_DATA_DIR/HY2XS_LOG_DIR. Этот мост от старого H UI не давал
// ничего, кроме второго имени у той же величины, и делал юнит обязательным
// участником контракта конфигурации.
var (
DataRootDir = getenv("HUI_DATA", "/var/lib/hy2xs-admin")
LogDir = getenv("HUI_LOG", "/var/log/hy2xs")
SqliteDBDir = DataRootDir
BinDir = filepath.Join(DataRootDir, "bin")
ExportPathDir = filepath.Join(DataRootDir, "export")
DataRootDir = getenv("HY2XS_DATA_DIR", "/var/lib/hy2xs-admin")
LogDir = getenv("HY2XS_LOG_DIR", "/var/log/hy2xs")
SqliteDBDir = DataRootDir
BinDir = filepath.Join(DataRootDir, "bin")
SqliteDBPath = filepath.Join(DataRootDir, "h_ui.db")
// Каталога для выгрузок здесь нет намеренно: артефакты экспорта
// формируются в памяти и отдаются прямо в ответ. Персистентный
// export-каталог накапливал на диске JSON с секретами.
SqliteDBPath = filepath.Join(DataRootDir, "hy2xs-admin.db")
Hysteria2ConfigPath = "/etc/hysteria/config.yaml"
Hysteria2BinPath = "/usr/local/bin/hysteria"
@@ -30,5 +39,15 @@ var (
const (
TokenType = "Bearer"
Version = "v0.0.22"
)
// Version — версия пакета HY2XS, из которого собран этот бинарник.
//
// Значение проставляется на сборке через
// `-ldflags -X hy2xs-admin/model/constant.Version=v${HY2XS_VERSION}`,
// где HY2XS_VERSION приходит из корневого versions.env. Собственной
// константы здесь нет намеренно: захардкоженная версия админки уже успела
// разъехаться с версией пакета.
//
// "dev" остаётся только для сборок вне tools/build/build.sh.
var Version = "dev"
+3 -3
View File
@@ -1,8 +1,8 @@
package dto
type ConfigDto struct {
Key *string `json:"key" form:"key" validate:"required,min=1,max=128"`
}
// ConfigDto удалён вместе с маршрутом GET /config/getConfig: чтение
// произвольного ключа таблицы `config` было точечным входом к JWT- и
// peer-ключам. Чтение идёт через ConfigsDto и allowlist.
type ConfigsDto struct {
Keys []string `json:"keys" form:"keys" validate:"required"`
-4
View File
@@ -5,7 +5,3 @@ type Hysteria2AuthDto struct {
Auth *string `json:"auth" form:"auth" validate:"required"`
Tx *int64 `json:"tx" form:"tx" validate:"required"`
}
type Hysteria2VersionDto struct {
Version *string `json:"version" form:"version" validate:"required,min=1,max=10"`
}
-25
View File
@@ -1,25 +0,0 @@
package entity
type LegacyAccount struct {
Username *string `gorm:"column:username;default:''" json:"username"`
Pass *string `gorm:"column:pass;default:''" json:"pass"`
ConPass *string `gorm:"column:con_pass;default:''" json:"conPass"`
Quota *int64 `gorm:"column:quota;default:0" json:"quota"`
Download *int64 `gorm:"column:download;default:0" json:"download"`
Upload *int64 `gorm:"column:upload;default:0" json:"upload"`
ExpireTime *int64 `gorm:"column:expire_time;default:0" json:"expireTime"`
KickUtilTime *int64 `gorm:"column:kick_util_time;default:0" json:"kickUtilTime"`
DeviceNo *int64 `gorm:"column:device_no;default:3" json:"deviceNo"`
Role *string `gorm:"column:role;default:'user'" json:"role"`
Deleted *int64 `gorm:"column:deleted;default:0" json:"deleted"`
BaseEntity `gorm:"embedded"`
LoginAt *int64 `gorm:"column:login_at;default:0" json:"loginAt"`
ConAt *int64 `gorm:"column:con_at;default:0" json:"conAt"`
Remark *string `gorm:"column:remark;default:''" json:"remark"`
ForcePasswordChange *int64 `gorm:"column:force_password_change;default:0" json:"forcePasswordChange"`
}
func (LegacyAccount) TableName() string {
return "account"
}
-10
View File
@@ -30,13 +30,3 @@ func Hysteria2AuthBadRequest(c *gin.Context) {
Id: "",
})
}
type Hysteria2UrlVo struct {
Url string `json:"url"`
QrCode []byte `json:"qrCode"`
}
type Hysteria2AcmePathVo struct {
CrtPath string `json:"crtPath"`
KeyPath string `json:"keyPath"`
}
+15 -8
View File
@@ -5,15 +5,22 @@ type LogSystemPage[T LogSystemVo | LogHysteria2Vo] struct {
Total int64 `json:"total"`
}
// LogSystemVo — строка журнала админки в том виде, в каком её пишет
// middleware.LogHandler.
//
// Поля reqUri здесь нет намеренно: RequestURI несёт query string, а по
// машинному endpoint'у в ней приезжает действующий machine token. Логируется
// путь и, отдельно, имена переданных query-параметров без значений.
type LogSystemVo struct {
ClientIP string `json:"clientIp"`
LatencyTime int64 `json:"latencyTime"`
Level string `json:"level"`
Msg string `json:"msg"`
ReqMethod string `json:"reqMethod"`
ReqUri string `json:"reqUri"`
StatusCode int64 `json:"statusCode"`
Time string `json:"time"`
ClientIP string `json:"clientIp"`
LatencyTime int64 `json:"latencyTime"`
Level string `json:"level"`
Msg string `json:"msg"`
ReqMethod string `json:"reqMethod"`
ReqPath string `json:"reqPath"`
ReqQueryKeys string `json:"reqQueryKeys"`
StatusCode int64 `json:"statusCode"`
Time string `json:"time"`
}
type LogHysteria2Vo struct {
+15 -8
View File
@@ -5,20 +5,27 @@ import (
"hy2xs-admin/controller"
)
// Серверный конфиг Hysteria в HY2XS доступен только на чтение и на выгрузку:
// его владелец — install-оркестратор. Маршруты записи (updateHysteria2Config,
// importHysteria2Config), перезапуска панели и загрузки сертификатов удалены,
// а не оставлены заглушками: маршрут, который всегда отвечает «feature
// disabled», вводит в заблуждение и остаётся точкой входа.
//
// По той же причине здесь нет generic exportConfig/importConfig: они отдавали
// и принимали таблицу `config` целиком, вместе с JWT- и peer-ключами.
// См. комментарий в controller/config.go.
//
// Маршрута getConfig тоже нет. Он принимал произвольный ключ и был вторым
// входом в ту же таблицу секретов, только точечным: `?key=JWT_SECRET`
// возвращал подписывающий ключ, `?key=PEER_SECRET_ENCRYPTION_KEY` — master-key
// шифрования секретов пиров. Потребителей у маршрута не было ни одного:
// панель читает настройки через listConfig, у которого теперь allowlist.
func initConfigRouter(configApi *gin.RouterGroup) {
config := configApi.Group("/config")
{
config.POST("/updateConfigs", controller.UpdateConfigs)
config.GET("/getConfig", controller.GetConfig)
config.POST("/listConfig", controller.ListConfig)
config.GET("/getHysteria2Config", controller.GetHysteria2Config)
config.POST("/updateHysteria2Config", controller.UpdateHysteria2Config)
config.POST("/exportHysteria2Config", controller.ExportHysteria2Config)
config.POST("/importHysteria2Config", controller.ImportHysteria2Config)
config.POST("/exportConfig", controller.ExportConfig)
config.POST("/importConfig", controller.ImportConfig)
config.GET("/hysteria2AcmePath", controller.Hysteria2AcmePath)
config.POST("/restartServer", controller.RestartServer)
config.POST("/uploadCertFile", controller.UploadCertFile)
}
}
+10 -13
View File
@@ -5,17 +5,14 @@ import (
"hy2xs-admin/controller"
)
func initHysteria2MachineAuthRouter(hysteria2Api *gin.RouterGroup) {
hysteria2 := hysteria2Api.Group("/hysteria2")
{
hysteria2.POST("/auth", controller.Hysteria2Auth)
}
}
func initHysteria2Router(hysteria2Api *gin.RouterGroup) {
hysteria2 := hysteria2Api.Group("/hysteria2")
{
hysteria2.POST("/hysteria2ChangeVersion", controller.Hysteria2ChangeVersion)
hysteria2.GET("/listRelease", controller.ListRelease)
}
// Панель HY2XS не управляет жизненным циклом Hysteria: установка, обновление
// и перезапуск выполняются install-оркестратором. Маршрутов updater'а здесь
// нет намеренно — API-контракт не должен обещать операцию, которой у продукта
// принципиально не существует, и не должен нести лишнюю attack surface.
//
// Единственный маршрут этой группы — machine-auth. Группа уже смонтирована на
// constant.HysteriaMachineAuthPath, поэтому относительный путь здесь пустой:
// полный путь объявлен ровно в одном месте.
func initHysteria2MachineAuthRouter(machineAuthAPI *gin.RouterGroup) {
machineAuthAPI.POST("", controller.Hysteria2Auth)
}
+10 -3
View File
@@ -19,9 +19,16 @@ func initPeerRouter(peerApi *gin.RouterGroup) {
peers.PATCH("/:id/kick", controller.KickPeer)
peers.GET("/:id/client-config", controller.PeerClientConfig)
// aliases на один миграционный релиз
peers.GET("/:id/client-url", controller.PeerClientConfig)
peers.GET("/:id/qr", controller.PeerClientConfig)
// Алиасов /:id/client-url и /:id/qr здесь больше нет.
//
// Они были оставлены «на один миграционный релиз», но миграционного
// релиза у продукта не существует: HY2XS v1 ставится только на чистый
// хост, база предыдущего поколения не переносится, а клиентов у этих
// путей нет — панель ходит в /:id/client-config. Дожив до 1.0.0, они
// стали бы частью публичного API v1, и убирать их пришлось бы уже со
// сломом совместимости.
//
// Отсутствие закреплено TestRouterHasNoRemovedRoutes.
}
peerApi.POST("/peer-import", controller.ImportPeer)
+26 -23
View File
@@ -7,10 +7,16 @@ import (
"hy2xs-admin/middleware"
"hy2xs-admin/model/constant"
"os"
"strings"
)
func Router(router *gin.Engine, huiWebContext *string) {
// Router собирает HTTP-контур админки.
//
// Панель всегда живёт в корне: настраиваемый web-контекст был возможностью H UI
// разворачивать панель по произвольному пути, а HY2XS слушает только loopback
// и отдаёт панель через SSH-туннель или reverse proxy. Настройка, которую UI
// уже не позволял менять, а backend продолжал читать из SQLite, — это не
// точка расширения, а лишний источник истины.
func Router(router *gin.Engine) {
router.GET("/healthz", func(c *gin.Context) {
sqliteReady := dao.IsSqliteReady()
configReadable := false
@@ -36,35 +42,32 @@ func Router(router *gin.Engine, huiWebContext *string) {
})
})
relativePath := "/"
if huiWebContext != nil && strings.HasPrefix(*huiWebContext, "/") {
relativePath = *huiWebContext
}
frontend.InitFrontend(router, "/")
frontend.InitFrontend(router, relativePath)
// Machine-контур. Не часть операторского API: это внутренний канал между
// Hysteria и админкой, поэтому у него отдельное пространство имён,
// собственный middleware и никакого JWT.
machineAPI := router.Group(constant.HysteriaMachineAuthPath)
machineAPI.Use(middleware.LocalOnlyHandler(), middleware.MachineAuthHandler(), middleware.LogHandler())
initHysteria2MachineAuthRouter(machineAPI)
globalGroup := router.Group(relativePath)
api := router.Group(constant.AdminAPIBase)
machineApi := globalGroup.Group("/hui")
machineApi.Use(middleware.LocalOnlyHandler(), middleware.MachineAuthHandler(), middleware.LogHandler())
initHysteria2MachineAuthRouter(machineApi)
authAPI := api.Group("")
authAPI.Use(middleware.FilterHandler(), middleware.LogHandler(), middleware.RateLimiterHandler())
initAuthRouter(authAPI)
authApi := globalGroup.Group("/hui")
authApi.Use(middleware.FilterHandler(), middleware.LogHandler(), middleware.RateLimiterHandler())
initAuthRouter(authApi)
huiAdminApi := globalGroup.Group("/hui")
huiAdminApi.Use(
adminAPI := api.Group("")
adminAPI.Use(
middleware.FilterHandler(),
middleware.LogHandler(),
middleware.RateLimiterHandler(),
middleware.JWTHandler(),
middleware.AdminHandler(),
)
initAdminRouter(huiAdminApi)
initDashboardRouter(huiAdminApi)
initPeerRouter(huiAdminApi)
initConfigRouter(huiAdminApi)
initHysteria2Router(huiAdminApi)
initLogRouter(huiAdminApi)
initAdminRouter(adminAPI)
initDashboardRouter(adminAPI)
initPeerRouter(adminAPI)
initConfigRouter(adminAPI)
initLogRouter(adminAPI)
}
+147
View File
@@ -0,0 +1,147 @@
package router
import (
"strings"
"testing"
"github.com/gin-gonic/gin"
"hy2xs-admin/model/constant"
)
// Регистрация маршрутов — единственное место, где ошибка проявляется паникой
// при старте сервиса, а не ответом с кодом. Смена пространства имён API как
// раз относится к таким изменениям: конфликт с wildcard-маршрутом фронтенда
// или дублирующая регистрация обнаружились бы только на живом сервере.
func buildRoutes(t *testing.T) gin.RoutesInfo {
t.Helper()
gin.SetMode(gin.TestMode)
engine := gin.New()
defer func() {
if r := recover(); r != nil {
t.Fatalf("сборка маршрутов паникует: %v", r)
}
}()
Router(engine)
return engine.Routes()
}
func TestRouterRegistersMachineAuthUnderInternalNamespace(t *testing.T) {
routes := buildRoutes(t)
found := false
for _, route := range routes {
if route.Method == "POST" && route.Path == constant.HysteriaMachineAuthPath {
found = true
}
}
if !found {
t.Fatalf("machine-auth endpoint не зарегистрирован на %s", constant.HysteriaMachineAuthPath)
}
}
func TestRouterKeepsAdminAndAuthUnderApiNamespace(t *testing.T) {
routes := buildRoutes(t)
expected := []string{
constant.AdminAPIBase + "/auth/login",
constant.AdminAPIBase + "/peers",
constant.AdminAPIBase + "/config/listConfig",
constant.AdminAPIBase + "/peer-import",
constant.AdminAPIBase + "/peer-export",
}
present := make(map[string]bool, len(routes))
for _, route := range routes {
present[route.Path] = true
}
for _, path := range expected {
if !present[path] {
t.Errorf("маршрут %s не зарегистрирован", path)
}
}
}
// Старое пространство имён — часть runtime-контракта, а не внутреннее имя:
// его возвращение сломало бы конфиг Hysteria на уже установленных серверах.
func TestRouterHasNoLegacyNamespace(t *testing.T) {
for _, route := range buildRoutes(t) {
if strings.HasPrefix(route.Path, "/hui") {
t.Errorf("вернулось legacy-пространство имён: %s %s", route.Method, route.Path)
}
}
}
// Маршруты, операциями которых продукт не владеет, удалены, а не оставлены
// отвечающими «feature disabled».
func TestRouterHasNoRemovedRoutes(t *testing.T) {
removed := []string{
"exportConfig",
"importConfig",
"updateHysteria2Config",
"importHysteria2Config",
"hysteria2ChangeVersion",
"listRelease",
"restartServer",
"uploadCertFile",
"hysteria2AcmePath",
// getConfig принимал произвольный ключ таблицы `config` и был точечным
// входом к JWT_SECRET, PEER_SECRET_KEY и PEER_SECRET_ENCRYPTION_KEY.
// Потребителей у него не было ни одного.
"getConfig",
// Алиасы клиентской конфигурации, оставленные «на один миграционный
// релиз». Миграционного релиза у clean-install-only продукта нет, а в
// 1.0.0 они стали бы частью публичного API.
"client-url",
"/qr",
}
for _, route := range buildRoutes(t) {
for _, dead := range removed {
if strings.Contains(route.Path, dead) {
t.Errorf("удалённый маршрут вернулся: %s %s", route.Method, route.Path)
}
}
}
}
// Оставшиеся маршруты /config — это чтение по allowlist, запись по allowlist и
// санитизирующая выгрузка серверного конфига. Никакого generic-доступа к
// таблице `config` у API быть не должно.
func TestRouterConfigNamespaceIsClosed(t *testing.T) {
allowed := map[string]bool{
constant.AdminAPIBase + "/config/updateConfigs": true,
constant.AdminAPIBase + "/config/listConfig": true,
constant.AdminAPIBase + "/config/getHysteria2Config": true,
constant.AdminAPIBase + "/config/exportHysteria2Config": true,
}
for _, route := range buildRoutes(t) {
if !strings.HasPrefix(route.Path, constant.AdminAPIBase+"/config") {
continue
}
if !allowed[route.Path] {
t.Errorf("в пространстве /config появился неожиданный маршрут: %s %s", route.Method, route.Path)
}
}
present := make(map[string]bool)
for _, route := range buildRoutes(t) {
present[route.Path] = true
}
for path := range allowed {
if !present[path] {
t.Errorf("маршрут %s пропал", path)
}
}
}
func TestRouterExposesHealthz(t *testing.T) {
found := false
for _, route := range buildRoutes(t) {
if route.Method == "GET" && route.Path == "/healthz" {
found = true
}
}
if !found {
t.Fatal("/healthz не зарегистрирован")
}
}
+20 -16
View File
@@ -30,16 +30,9 @@ func Login(username string, plainPassword string) (string, bool, error) {
if err != nil {
return "", false, err
}
verified, legacy := util.VerifyPassword(plainPassword, *admin.PasswordHash)
if !verified {
if !util.VerifyPassword(plainPassword, *admin.PasswordHash) {
return "", false, errors.New(constant.WrongPassword)
}
if legacy {
hash, hashErr := util.HashPassword(plainPassword)
if hashErr == nil {
_ = dao.UpdateAdminUser([]int64{*admin.Id}, map[string]interface{}{"password_hash": hash})
}
}
tokenVersion := int64(1)
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
tokenVersion = *admin.TokenVersion
@@ -49,21 +42,33 @@ func Login(username string, plainPassword string) (string, bool, error) {
if tokenErr != nil {
return "", false, tokenErr
}
requirePasswordChange := legacy
// Признак «пароль в старом формате, заставить сменить» отсюда убран вместе
// с поддержкой самого формата: единственный источник этого требования —
// флаг force_password_change, который ставит установка.
requirePasswordChange := false
if admin.ForcePasswordChange != nil {
requirePasswordChange = *admin.ForcePasswordChange != 0
}
return token, requirePasswordChange, nil
}
// GetAdminInfo читает учётные данные ТОЛЬКО из контекста запроса.
//
// Резервной ветки «если в контексте пусто — разобрать токен самостоятельно»
// здесь больше нет. Она была недостижима: все маршруты, ведущие сюда, стоят за
// middleware.JWTHandler, который разбирает токен, сверяет token_version и
// кладёт claims в контекст. При этом ветка несла собственный разбор заголовка
// Authorization через service.GetToken, где strings.SplitN(...)[1] паниковал
// на заголовке без пробела.
//
// Хуже недостижимости было бы её оживление: этот путь не проверял ни статус
// учётной записи, ни версию токена, то есть отозванный токен прошёл бы здесь,
// но не прошёл бы через middleware. Второй способ аутентификации, слабее
// первого, — не запасной вариант, а обход.
func GetAdminInfo(c *gin.Context) (vo.AdminInfoVo, error) {
claims, ok := adminClaimsFromContext(c)
if !ok {
myClaims, err := ParseToken(GetToken(c))
if err != nil {
return vo.AdminInfoVo{}, err
}
claims = myClaims.Admin
return vo.AdminInfoVo{}, errors.New(constant.UnauthorizedError)
}
admin, err := dao.GetAdminUser("id = ?", claims.Id)
if err != nil {
@@ -92,8 +97,7 @@ func ChangeAdminPassword(c *gin.Context, oldPassword string, newPassword string)
if admin.PasswordHash == nil {
return errors.New(constant.SysError)
}
verified, _ := util.VerifyPassword(oldPassword, *admin.PasswordHash)
if !verified {
if !util.VerifyPassword(oldPassword, *admin.PasswordHash) {
return errors.New(constant.WrongPassword)
}
hash, hashErr := util.HashPassword(newPassword)
+63 -135
View File
@@ -10,16 +10,58 @@ import (
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
"net"
"net/url"
"os"
"strconv"
"strings"
)
// ConfigUpdate — одна пара «ключ/значение» из партии операторского API.
type ConfigUpdate struct {
Key string
Value string
}
func UpdateConfig(key string, value string) error {
return dao.UpdateConfig([]string{key}, map[string]interface{}{"value": value})
}
// UpdateConfigs применяет партию настроек целиком или не применяет вовсе.
//
// Проверка значений в неё не входит намеренно: партия обязана быть проверена
// ПОЛНОСТЬЮ до открытия транзакции. Транзакция, внутри которой ещё может
// возникнуть отказ по содержимому, — это откат вместо отказа, то есть лишняя
// работа с базой на каждом неверно заполненном поле формы.
func UpdateConfigs(updates []ConfigUpdate) error {
if len(updates) == 0 {
return nil
}
return dao.WithConfigTx(func(tx dao.ConfigTx) error {
for _, item := range updates {
if err := tx.UpdateConfig([]string{item.Key}, map[string]interface{}{"value": item.Value}); err != nil {
return err
}
}
return nil
})
}
// ValidateConfigValue проверяет ЗНАЧЕНИЕ настройки до записи в базу.
//
// Раньше такой проверки не было вовсе: DTO ограничивал длину 128 символами, а
// поле расписания в панели — el-select с allow-create, то есть строка
// произвольная. Невалидное cron-выражение сохранялось, API отвечал успехом, а
// планировщик при следующем старте просто логировал ошибку и продолжал без
// джобы. Для оператора это выглядело как «настройка принята, сброс трафика
// молча перестал работать».
func ValidateConfigValue(key string, value string) error {
switch key {
case constant.ResetTrafficCron:
return ValidateResetTrafficCron(value)
default:
return nil
}
}
func GetConfig(key string) (entity.Config, error) {
return dao.GetConfig("key = ?", key)
}
@@ -28,106 +70,48 @@ func ListConfig(keys []string) ([]entity.Config, error) {
return dao.ListConfig("key in ?", keys)
}
func ListConfigNotIn(keys []string) ([]entity.Config, error) {
return dao.ListConfig("key not in ?", keys)
}
// GetHysteria2Config читает ТОЛЬКО /etc/hysteria/config.yaml.
//
// Ветки «сначала посмотреть в SQLite, а если пусто — в файл» здесь больше нет.
// Ключ HYSTERIA2_CONFIG остался от предыдущего поколения, где панель сама
// владела серверным конфигом. В HY2XS владелец ровно один — install-оркестратор,
// маршруты записи конфига удалены, и писателя у этого ключа не осталось: clean
// install создавал его пустым, и пустым он оставался навсегда.
//
// Опасна была не бесполезность, а приоритет: БД читалась ПЕРВОЙ. Любое
// значение, попавшее в неё в обход продукта, молча становилось тем, что панель
// показывает оператору и из чего генерирует клиентские ссылки, пока Hysteria
// продолжала работать по файлу. То есть ключ был готовым вторым источником
// истины — ровно тем, что проект последовательно убирает.
func GetHysteria2Config() (bo.Hysteria2ServerConfig, error) {
var serverConfig bo.Hysteria2ServerConfig
config, err := dao.GetConfig("key = ?", constant.Hysteria2Config)
content, err := os.ReadFile(constant.Hysteria2ConfigPath)
if err != nil {
return serverConfig, err
}
if config.Value == nil || strings.TrimSpace(*config.Value) == "" {
content, readErr := os.ReadFile(constant.Hysteria2ConfigPath)
if readErr != nil {
return serverConfig, readErr
}
if err = yaml.Unmarshal(content, &serverConfig); err != nil {
return serverConfig, err
}
return serverConfig, nil
}
if err = yaml.Unmarshal([]byte(*config.Value), &serverConfig); err != nil {
if err = yaml.Unmarshal(content, &serverConfig); err != nil {
return serverConfig, err
}
return serverConfig, nil
}
func UpdateHysteria2Config(hysteria2ServerConfig bo.Hysteria2ServerConfig) error {
// Значения по умолчанию
config, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.Hysteria2TrafficStatsSecret})
if err != nil {
return err
}
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")
return errors.New(constant.SysError)
}
authHttpUrl, err := GetAuthHttpUrl()
if err != nil {
return err
}
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
yamlConfig, err := yaml.Marshal(&hysteria2ServerConfig)
if err != nil {
return err
}
return dao.UpdateConfig([]string{constant.Hysteria2Config}, map[string]interface{}{"value": string(yamlConfig)})
}
func SetHysteria2Config(hysteria2ServerConfig bo.Hysteria2ServerConfig) error {
config, err := yaml.Marshal(&hysteria2ServerConfig)
if err != nil {
return err
}
return dao.UpdateConfig([]string{constant.Hysteria2Config}, map[string]interface{}{"value": string(config)})
}
func UpsertConfig(configs []entity.Config) error {
return dao.UpsertConfig(configs)
}
func GetHysteria2ApiPort() (int64, error) {
hysteria2Config, err := GetHysteria2Config()
if err != nil {
return 0, err
}
// Сообщение передаётся аргументом, а не форматной строкой: значение
// приходит из /etc/hysteria/config.yaml, и знак процента в нём превращал
// запись журнала в мусор вида `%!s(MISSING)`.
if hysteria2Config.TrafficStats == nil || hysteria2Config.TrafficStats.Listen == nil {
errMsg := "hysteria2 Traffic Stats API (HTTP) Listen is nil"
logrus.Errorf(errMsg)
logrus.Errorf("%s", errMsg)
return 0, errors.New(errMsg)
}
apiPort, err := parseTrafficStatsPort(*hysteria2Config.TrafficStats.Listen)
if err != nil {
errMsg := fmt.Sprintf("apiPort: %s is invalid: %v", *hysteria2Config.TrafficStats.Listen, err)
logrus.Errorf(errMsg)
logrus.Errorf("%s", errMsg)
return 0, errors.New(errMsg)
}
return apiPort, nil
@@ -157,59 +141,3 @@ func parseTrafficStatsPort(listen string) (int64, error) {
}
return port, nil
}
func GetPortAndCert() (int64, string, string, error) {
configs, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.HUICrtPath, constant.HUIKeyPath})
if err != nil {
return 0, "", "", err
}
port := ""
crtPath := ""
keyPath := ""
for _, config := range configs {
value := *config.Value
if *config.Key == constant.HUIWebPort {
port = value
} else if *config.Key == constant.HUICrtPath {
crtPath = value
} else if *config.Key == constant.HUIKeyPath {
keyPath = value
}
}
portInt, err := strconv.ParseInt(port, 10, 64)
if err != nil {
logrus.Errorf("port: %s is invalid", port)
return 0, "", "", errors.New(fmt.Sprintf("port: %s is invalid", port))
}
return portInt, crtPath, keyPath, nil
}
func GetAuthHttpUrl() (string, error) {
port, crtPath, keyPath, err := GetPortAndCert()
if err != nil {
return "", err
}
protocol := "http"
if crtPath != "" && keyPath != "" {
protocol = "https"
}
config, err := dao.GetConfig("key = ?", constant.HUIWebContext)
if err != nil {
return "", err
}
webContext := ""
if config.Value != nil && *config.Value != "/" && strings.HasPrefix(*config.Value, "/") {
webContext = *config.Value
}
trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
if err != nil {
return "", err
}
authURL := fmt.Sprintf("%s://127.0.0.1:%d%s/hui/hysteria2/auth", protocol, port, webContext)
if trafficSecretConfig.Value != nil && strings.TrimSpace(*trafficSecretConfig.Value) != "" {
authURL = fmt.Sprintf("%s?access_token=%s", authURL, url.QueryEscape(strings.TrimSpace(*trafficSecretConfig.Value)))
}
return authURL, nil
}
+244
View File
@@ -0,0 +1,244 @@
package service
import (
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/robfig/cron/v3"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
)
// Планировщик принадлежит ПРОЦЕССУ, а не HTTP-серверу.
//
// Что было. InitCron жил в middleware, вызывался из runServer и на каждом
// вызове создавал новый cron.New(). Ссылка на планировщик никуда не
// сохранялась, а releaseResource() закрывал только SQLite и Hysteria —
// cron.Stop() не вызывался нигде. При этом смена RESET_TRAFFIC_CRON делала
// StopServer(), из-за чего runServer возвращался, а точка входа в cmd.go
// крутила его в `for {}` и запускала всё заново.
//
// Итог: каждая смена расписания добавляла ЦЕЛЫЙ дублирующий набор джоб, а
// старое расписание сброса трафика продолжало работать. После двух правок
// «@monthly → @weekly → @monthly» на процессе висели три планировщика,
// утроенный CollectMetricsSnapshot и три разных расписания сброса
// одновременно. Плюс окно, в котором джобы старого планировщика били в уже
// закрытое соединение SQLite: releaseResource() отрабатывал раньше, чем
// следующий runServer успевал открыть базу.
//
// Правильная граница: фиксированные джобы регистрируются один раз за жизнь
// процесса, а расписание сброса трафика перепланируется на месте по своему
// EntryID. HTTP-сервер к смене настройки отношения не имеет вообще.
// cronShutdownTimeout ограничивает ожидание уже запущенных джоб при остановке.
// systemd по умолчанию даёт юниту 90 секунд, так что запас есть.
const cronShutdownTimeout = 10 * time.Second
var (
cronMu sync.Mutex
cronScheduler *cron.Cron
resetTrafficEntryID cron.EntryID
// cronOwnedJobs учитывает джобы, запущенные ВНЕ расписания.
//
// Стартовая уборка статистики намеренно выполняется в фоне: на большой базе
// она заметно долгая, и держать на ней запуск сервиса незачем. Но
// cron.Stop() ждёт только то, что запустил сам планировщик, поэтому
// необслуженная горутина переживала закрытие SQLite — ровно та же болезнь,
// от которой лечится весь этот файл, только меньшего масштаба.
cronOwnedJobs sync.WaitGroup
)
// ValidateResetTrafficCron проверяет выражение ТЕМ ЖЕ парсером, которым его
// потом будет разбирать runtime.
//
// cron.New() без опций собирает parser из Minute|Hour|Dom|Month|Dow|Descriptor,
// и ровно его же использует cron.ParseStandard. Поэтому «валидно на входе API»
// и «планируется в рантайме» здесь не могут разойтись — а разойтись они могли
// бы, если бы валидация была написана собственной регуляркой.
//
// Пустая строка — легальное значение и означает «автоматический сброс
// выключен»: в панели поле clearable, и оператор имеет право его очистить.
func ValidateResetTrafficCron(expression string) error {
trimmed := strings.TrimSpace(expression)
if trimmed == "" {
return nil
}
if _, err := cron.ParseStandard(trimmed); err != nil {
return fmt.Errorf("%s: невалидное cron-выражение %q: %v", constant.ResetTrafficCron, expression, err)
}
return nil
}
// InitCron поднимает единственный планировщик процесса.
func InitCron() error {
cronMu.Lock()
defer cronMu.Unlock()
if cronScheduler != nil {
return errors.New("cron scheduler is already running")
}
c := cron.New(cron.WithLocation(time.Now().Location()))
fixedJobs := []struct {
name string
spec string
job func()
}{
{"CronHandleAccount", "@every 30s", CronHandleAccount},
{"CollectMetricsSnapshot", "@every 10s", CollectMetricsSnapshot},
{"CleanupStatsRetention", "@every 1h", CleanupStatsRetention},
}
for _, fixed := range fixedJobs {
if _, err := c.AddFunc(fixed.spec, fixed.job); err != nil {
logrus.Errorf("cron add func %s err: %v", fixed.name, err)
return fmt.Errorf("cron add func %s err", fixed.name)
}
}
expression, err := storedResetTrafficCron()
if err != nil {
return err
}
if expression != "" {
id, addErr := c.AddFunc(expression, CronResetTraffic)
if addErr != nil {
// Старт НЕ прерывается, и это осознанное решение.
//
// Панель отдаёт не только операторский UI: на ней же висит
// /internal/hysteria/auth, куда Hysteria ходит при каждом
// подключении пира. Отказ старта из-за испорченной строки
// расписания положил бы подключения пользователей — цена
// несопоставима с отключённым плановым сбросом счётчиков.
//
// Молчаливой деградации при этом нет: запись через API теперь
// валидируется тем же парсером, поэтому попасть сюда можно только
// правкой базы в обход продукта, и об этом пишется ERROR.
logrus.Errorf(
"cron: сохранённое %s=%q невалидно (%v); плановый сброс трафика выключен до исправления настройки",
constant.ResetTrafficCron, expression, addErr,
)
} else {
resetTrafficEntryID = id
}
}
cronOwnedJobs.Add(1)
go func() {
defer cronOwnedJobs.Done()
CleanupStatsRetention()
}()
c.Start()
cronScheduler = c
return nil
}
// RescheduleResetTraffic переносит джобу сброса трафика на новое расписание.
//
// HTTP-сервер здесь не участвует. Раньше единственным способом применить новое
// расписание был перезапуск процесса через StopServer(), и именно он и плодил
// планировщики.
func RescheduleResetTraffic(expression string) error {
if err := ValidateResetTrafficCron(expression); err != nil {
return err
}
cronMu.Lock()
defer cronMu.Unlock()
if cronScheduler == nil {
return errors.New("cron scheduler is not running")
}
// Старая запись снимается всегда, даже если новая не будет добавлена:
// пустое выражение означает «сброс выключен», а не «оставить как было».
if resetTrafficEntryID != 0 {
cronScheduler.Remove(resetTrafficEntryID)
resetTrafficEntryID = 0
}
trimmed := strings.TrimSpace(expression)
if trimmed == "" {
return nil
}
// Ошибка здесь уже невозможна: выражение прошло тот же парсер выше.
// Проверка остаётся, чтобы расхождение двух парсеров не превратилось в
// молча пропавшую джобу.
id, err := cronScheduler.AddFunc(trimmed, CronResetTraffic)
if err != nil {
return fmt.Errorf("%s: не удалось запланировать %q: %v", constant.ResetTrafficCron, expression, err)
}
resetTrafficEntryID = id
return nil
}
// StopCron останавливает планировщик и дожидается уже запущенных джоб.
//
// Без этого джобы продолжали работать после закрытия SQLite: каждая из них
// ходит в базу, и остановка в обратном порядке (сначала планировщик, потом
// соединение) — часть контракта завершения процесса.
func StopCron() {
cronMu.Lock()
scheduler := cronScheduler
cronScheduler = nil
resetTrafficEntryID = 0
cronMu.Unlock()
if scheduler == nil {
return
}
// Дожидаемся обеих групп: и джоб, запущенных планировщиком, и фоновых,
// которые он не видит. Незавершённая джоба означает работу с базой, которую
// releaseResource закроет сразу после возврата отсюда.
drained := make(chan struct{})
go func() {
<-scheduler.Stop().Done()
cronOwnedJobs.Wait()
close(drained)
}()
select {
case <-drained:
case <-time.After(cronShutdownTimeout):
logrus.Warnf("cron: запущенные джобы не завершились за %s, продолжаем остановку", cronShutdownTimeout)
}
}
// ResetTrafficScheduled сообщает, запланирован ли сейчас сброс трафика.
// Существует ради тестов: иначе проверить, что старая запись действительно
// снята, а не просто добавлена рядом, можно было бы только по времени.
func ResetTrafficScheduled() bool {
cronMu.Lock()
defer cronMu.Unlock()
return resetTrafficEntryID != 0
}
// CronEntryCount возвращает число активных записей планировщика.
// Тоже ради тестов: накопление джоб — это именно рост этого числа.
func CronEntryCount() int {
cronMu.Lock()
defer cronMu.Unlock()
if cronScheduler == nil {
return 0
}
return len(cronScheduler.Entries())
}
func storedResetTrafficCron() (string, error) {
config, err := dao.GetConfig("key = ?", constant.ResetTrafficCron)
if err != nil {
return "", err
}
if config.Value == nil {
return "", nil
}
return strings.TrimSpace(*config.Value), nil
}
+197
View File
@@ -0,0 +1,197 @@
package service
import (
"testing"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
)
// Число фиксированных джоб планировщика: CronHandleAccount,
// CollectMetricsSnapshot, CleanupStatsRetention. Записано числом намеренно —
// тест обязан заметить и появление новой джобы, и потерю существующей.
const fixedCronJobCount = 3
func startTestCron(t *testing.T, resetTrafficExpression string) {
t.Helper()
newTestDB(t)
if err := dao.UpdateConfig(
[]string{constant.ResetTrafficCron},
map[string]interface{}{"value": resetTrafficExpression},
); err != nil {
t.Fatalf("не удалось записать %s: %v", constant.ResetTrafficCron, err)
}
if err := InitCron(); err != nil {
t.Fatalf("InitCron: %v", err)
}
t.Cleanup(StopCron)
}
// Главная регрессия P0: смена расписания не имеет права добавлять джобы.
//
// Раньше применение новой настройки шло через StopServer() и повторный
// runServer, а InitCron на каждом вызове создавал новый cron.New() и нигде его
// не сохранял. Старый планировщик никто не останавливал, поэтому после каждой
// правки на процессе оставался ЦЕЛЫЙ дублирующий набор джоб, а старое
// расписание сброса трафика продолжало срабатывать.
func TestRescheduleResetTrafficDoesNotAccumulateJobs(t *testing.T) {
startTestCron(t, "@monthly")
initial := CronEntryCount()
if initial != fixedCronJobCount+1 {
t.Fatalf("после старта ожидалось %d записей, получено %d", fixedCronJobCount+1, initial)
}
for _, expression := range []string{"@weekly", "@monthly", "0 3 * * *", "@daily"} {
if err := RescheduleResetTraffic(expression); err != nil {
t.Fatalf("RescheduleResetTraffic(%q): %v", expression, err)
}
if count := CronEntryCount(); count != initial {
t.Fatalf("после смены расписания на %q записей стало %d, ожидалось %d", expression, count, initial)
}
}
}
// Пустое выражение означает «автоматический сброс выключен», а не «оставить
// как было»: поле в панели clearable, и оператор имеет право его очистить.
func TestRescheduleResetTrafficWithEmptyExpressionDisablesJob(t *testing.T) {
startTestCron(t, "@monthly")
if !ResetTrafficScheduled() {
t.Fatal("после старта с непустым расписанием джоба сброса не запланирована")
}
if err := RescheduleResetTraffic(" "); err != nil {
t.Fatalf("RescheduleResetTraffic(пусто): %v", err)
}
if ResetTrafficScheduled() {
t.Fatal("пустое выражение не сняло джобу сброса трафика")
}
if count := CronEntryCount(); count != fixedCronJobCount {
t.Fatalf("после отключения сброса ожидалось %d записей, получено %d", fixedCronJobCount, count)
}
// Включение обратно должно работать без перезапуска процесса.
if err := RescheduleResetTraffic("@weekly"); err != nil {
t.Fatalf("повторное включение: %v", err)
}
if !ResetTrafficScheduled() {
t.Fatal("джоба сброса не вернулась после повторного включения")
}
}
// Невалидное выражение не имеет права ни изменить планировщик, ни пройти
// молча: раньше ошибка AddFunc только логировалась, и сброс трафика тихо
// исчезал.
func TestRescheduleResetTrafficRejectsInvalidExpression(t *testing.T) {
startTestCron(t, "@monthly")
before := CronEntryCount()
if err := RescheduleResetTraffic("какая-нибудь ерунда"); err == nil {
t.Fatal("невалидное выражение принято")
}
if after := CronEntryCount(); after != before {
t.Fatalf("отклонённое выражение изменило планировщик: было %d записей, стало %d", before, after)
}
if !ResetTrafficScheduled() {
t.Fatal("отклонённое выражение сняло действующую джобу")
}
}
// Валидация обязана пользоваться ТЕМ ЖЕ парсером, что и планировщик. Набор
// проверяет обе стороны: то, что cron умеет, должно приниматься, а то, что не
// умеет, — отклоняться до записи в базу.
func TestValidateResetTrafficCronMatchesSchedulerParser(t *testing.T) {
valid := []string{
"",
" ",
"@monthly",
"@weekly",
"@daily",
"@hourly",
"@every 1h30m",
"0 0 * * *",
"*/15 * * * *",
"0 3 1 * 1",
}
for _, expression := range valid {
if err := ValidateResetTrafficCron(expression); err != nil {
t.Errorf("валидное выражение %q отклонено: %v", expression, err)
}
}
invalid := []string{
"какая-нибудь ерунда",
"@yearly extra",
"* * * *",
"* * * * * *",
"99 * * * *",
"@every",
"@every банан",
"- - - - -",
}
for _, expression := range invalid {
if err := ValidateResetTrafficCron(expression); err == nil {
t.Errorf("невалидное выражение %q принято", expression)
}
}
}
// Невалидное значение в базе не имеет права уронить старт: на панели висит
// /internal/hysteria/auth, и отказ старта из-за строки расписания положил бы
// подключения пользователей.
func TestInitCronSurvivesInvalidStoredExpression(t *testing.T) {
startTestCron(t, "совершенно невалидная строка")
if ResetTrafficScheduled() {
t.Fatal("невалидное выражение из базы было запланировано")
}
if count := CronEntryCount(); count != fixedCronJobCount {
t.Fatalf("фиксированные джобы не поднялись: ожидалось %d записей, получено %d", fixedCronJobCount, count)
}
// Оператор обязан иметь возможность починить настройку через API, без
// перезапуска сервиса.
if err := RescheduleResetTraffic("@weekly"); err != nil {
t.Fatalf("исправление расписания: %v", err)
}
if !ResetTrafficScheduled() {
t.Fatal("исправленное расписание не применилось")
}
}
// Планировщик принадлежит процессу: второй InitCron без остановки первого —
// это ровно тот сценарий, который и создавал дубликаты.
func TestInitCronRefusesSecondScheduler(t *testing.T) {
startTestCron(t, "@monthly")
if err := InitCron(); err == nil {
t.Fatal("второй планировщик поднялся поверх работающего")
}
if count := CronEntryCount(); count != fixedCronJobCount+1 {
t.Fatalf("отклонённый повторный старт изменил планировщик: %d записей", count)
}
}
func TestStopCronIsIdempotent(t *testing.T) {
startTestCron(t, "@monthly")
StopCron()
if count := CronEntryCount(); count != 0 {
t.Fatalf("после остановки осталось %d записей", count)
}
if ResetTrafficScheduled() {
t.Fatal("после остановки джоба сброса числится запланированной")
}
// Второй вызов не должен паниковать: StopCron стоит в defer runServer и
// может быть вызван повторно на пути обработки ошибки.
StopCron()
if err := RescheduleResetTraffic("@weekly"); err == nil {
t.Fatal("перепланирование на остановленном планировщике не отклонено")
}
}
+14 -66
View File
@@ -2,27 +2,27 @@ package service
import (
"errors"
"fmt"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/vo"
"hy2xs-admin/util"
)
// Жизненный цикл Hysteria принадлежит systemd и install-оркестратору.
// Панель умеет только наблюдать состояние: функций Start/Stop/Restart и
// смены версии здесь нет намеренно, а не «временно отключены».
// InitHysteria2 проверяет единственное, что панели действительно нужно от
// Hysteria на старте: наличие бинарника, версию которого показывает дашборд и
// у которого спрашивает состояние Hysteria2IsRunning.
//
// Чтения HYSTERIA2_ENABLE здесь больше нет. Ключ был последним следом модели,
// в которой панель сама запускала и останавливала Hysteria: clean install
// создавал его со значением "0", записать "1" было некому, а вся ветка
// сводилась к строке в журнале. Настройка, которая ничем не управляет, но
// выглядит выключателем, хуже отсутствующей.
func InitHysteria2() error {
if !util.Exists(util.GetHysteria2BinPath()) {
return errors.New("systemd-managed hysteria binary not found")
}
config, err := dao.GetConfig("key = ?", constant.Hysteria2Enable)
if err != nil {
return err
}
if *config.Value == "1" {
logrus.Infof("hysteria2 lifecycle is managed by systemd in HY2XS production package")
}
return nil
}
@@ -31,58 +31,6 @@ func Hysteria2IsRunning() bool {
return err == nil
}
func StartHysteria2() error {
return errors.New("managed by orchestrator: use hy2xs-orchestrator reconfigure")
}
func StopHysteria2() error {
return errors.New("managed by orchestrator: use hy2xs-orchestrator reconfigure")
}
func RestartHysteria2() error {
return errors.New("managed by orchestrator: use hy2xs-orchestrator reconfigure")
}
func ReleaseHysteria2() error {
return nil
}
func Hysteria2AcmePath() (vo.Hysteria2AcmePathVo, error) {
hysteria2AcmePathVo := vo.Hysteria2AcmePathVo{}
hysteria2Config, err := GetHysteria2Config()
if err != nil {
return hysteria2AcmePathVo, err
}
if hysteria2Config.TLS != nil &&
hysteria2Config.TLS.Cert != nil && *hysteria2Config.TLS.Cert != "" &&
hysteria2Config.TLS.Key != nil && *hysteria2Config.TLS.Key != "" {
if util.Exists(*hysteria2Config.TLS.Cert) && util.Exists(*hysteria2Config.TLS.Key) {
hysteria2AcmePathVo.CrtPath = *hysteria2Config.TLS.Cert
hysteria2AcmePathVo.KeyPath = *hysteria2Config.TLS.Key
return hysteria2AcmePathVo, nil
}
return hysteria2AcmePathVo, errors.New("cert not found")
} else if hysteria2Config.ACME != nil &&
hysteria2Config.ACME.Domains != nil &&
len(hysteria2Config.ACME.Domains) > 0 &&
hysteria2Config.ACME.CA != nil &&
*hysteria2Config.ACME.CA != "" &&
hysteria2Config.ACME.Dir != nil &&
*hysteria2Config.ACME.Dir != "" {
acmeDir := *hysteria2Config.ACME.Dir
for _, domain := range hysteria2Config.ACME.Domains {
crtPath, err := util.FindFile(acmeDir, fmt.Sprintf("%s.crt", domain))
if err != nil {
continue
}
keyPath, err := util.FindFile(acmeDir, fmt.Sprintf("%s.key", domain))
if err != nil {
continue
}
hysteria2AcmePathVo.CrtPath = crtPath
hysteria2AcmePathVo.KeyPath = keyPath
return hysteria2AcmePathVo, nil
}
}
return vo.Hysteria2AcmePathVo{}, errors.New("cert not found")
}
+107 -34
View File
@@ -4,6 +4,7 @@ import (
"errors"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"hy2xs-admin/proxy"
"net"
@@ -14,6 +15,28 @@ import (
"time"
)
// resolveShareSni выбирает SNI для клиентской ссылки.
//
// ACME-домен — не единственный источник истины: в режиме tls (файловые
// сертификаты) блока acme в конфиге нет, но домен продукта известен из
// runtime-конфига. Публичный IPv4 в качестве SNI не используется.
func resolveShareSni(acmeDomain string, publicHost string) string {
if domain := strings.TrimSpace(acmeDomain); domain != "" {
return domain
}
if domain := strings.TrimSpace(os.Getenv("HY2XS_DOMAIN")); domain != "" && !isIPAddress(domain) {
return domain
}
if host := strings.TrimSpace(publicHost); host != "" && !isIPAddress(host) {
return host
}
return ""
}
func isIPAddress(value string) bool {
return net.ParseIP(strings.TrimSpace(value)) != nil
}
func resolvePublicEndpoint() (string, int, error) {
host := strings.TrimSpace(os.Getenv("HY2XS_PUBLIC_HOST"))
if host == "" || host == "0.0.0.0" {
@@ -125,30 +148,11 @@ func Hysteria2Url(accountId int64) (string, error) {
if err != nil {
return "", err
}
hysteria2ConfigRemark, err := dao.GetConfig("key = ?", constant.Hysteria2ConfigRemark)
if err != nil {
return "", err
}
remark := ""
if hysteria2ConfigRemark.Value != nil {
remark = *hysteria2ConfigRemark.Value
}
remark := shareRemark(peer.Name, hostname)
obfsType := ""
obfsPassword := ""
if hysteria2Config.Obfs != nil &&
hysteria2Config.Obfs.Type != nil &&
hysteria2Config.Obfs.Salamander != nil &&
hysteria2Config.Obfs.Salamander.Password != nil {
obfsType = *hysteria2Config.Obfs.Type
obfsPassword = *hysteria2Config.Obfs.Salamander.Password
}
sni := ""
if hysteria2Config.ACME != nil && len(hysteria2Config.ACME.Domains) > 0 {
sni = hysteria2Config.ACME.Domains[0]
}
obfs := hysteria2Config.ObfsShare()
sni := resolveShareSni(hysteria2Config.AcmeDomain(), hostname)
secret := ""
if peer.SecretEncrypted != nil {
@@ -158,29 +162,98 @@ func Hysteria2Url(accountId int64) (string, error) {
}
secret = decrypted
}
return buildHysteria2Url(secret, hostname, port, obfsType, obfsPassword, sni, remark), nil
return buildHysteria2Url(secret, hostname, port, obfs, sni, remark), nil
}
func buildHysteria2Url(conPass string, hostname string, port int, obfsType string, obfsPassword string, sni string, remark string) string {
// shareRemark даёт имя профиля, которое клиент показывает в списке серверов.
//
// Раньше оно бралось из HYSTERIA2_CONFIG_REMARK — пустой строки в таблице
// `config`, которую clean install создавал один раз и которую никто никогда не
// записывал. То есть fragment у ссылки отсутствовал всегда, и все выданные
// ссылки выглядели в клиенте одинаково.
//
// Теперь значение выводится детерминированно из имени пира: у панели с
// несколькими пирами это ровно то различие, которое пользователю и нужно
// видеть, и оно не требует ни одной дополнительной настройки. Fallback на
// публичный хост нужен для пира без имени — база это допускает (name имеет
// DEFAULT ''), а ссылка без имени профиля хуже, чем ссылка с именем сервера.
func shareRemark(peerName *string, hostname string) string {
if peerName != nil {
if name := strings.TrimSpace(*peerName); name != "" {
return name
}
}
return hostname
}
// isShareableObfsType перечисляет типы обфускации, которые официальная
// URI-схема Hysteria умеет передавать клиенту.
func isShareableObfsType(obfsType string) bool {
return obfsType == "salamander" || obfsType == "gecko"
}
// ShareURIOptions — вход генератора клиентской ссылки.
//
// Тип экспортируется, чтобы end-to-end проверка подключалась РОВНО тем же
// кодом, который выдаёт ссылки пользователю. Раньше e2e собирал URI
// собственной реализацией на bash, и дрейф любой из двух реализаций
// оставлял обе группы тестов зелёными.
type ShareURIOptions struct {
Secret string
Host string
Port int
Obfs bo.ObfsShareConfig
SNI string
Remark string
// Insecure отключает проверку сертификата на стороне клиента.
//
// В production всегда false: Hysteria2Url другого значения не передаёт,
// и это закреплено тестом. Поле существует только ради e2e, который
// работает на самоподписанном сертификате и иначе не смог бы
// использовать production-генератор.
Insecure bool
}
// BuildHysteria2ShareURI собирает ссылку по официальной URI-схеме Hysteria 2.
func BuildHysteria2ShareURI(opts ShareURIOptions) string {
query := url.Values{}
if obfsType == "salamander" && obfsPassword != "" {
query.Set("obfs", "salamander")
query.Set("obfs-password", obfsPassword)
if isShareableObfsType(opts.Obfs.Type) && opts.Obfs.Password != "" {
query.Set("obfs", opts.Obfs.Type)
query.Set("obfs-password", opts.Obfs.Password)
}
if sni != "" {
query.Set("sni", sni)
if opts.SNI != "" {
query.Set("sni", opts.SNI)
}
if opts.Insecure {
query.Set("insecure", "1")
} else {
query.Set("insecure", "0")
}
query.Set("insecure", "0")
u := url.URL{
Scheme: "hysteria2",
User: url.User(conPass),
Host: net.JoinHostPort(hostname, strconv.Itoa(port)),
User: url.User(opts.Secret),
Host: net.JoinHostPort(opts.Host, strconv.Itoa(opts.Port)),
Path: "/",
RawQuery: query.Encode(),
}
if strings.TrimSpace(remark) != "" {
u.Fragment = remark
if strings.TrimSpace(opts.Remark) != "" {
u.Fragment = opts.Remark
}
return u.String()
}
// buildHysteria2Url — production-путь: проверка сертификата никогда не
// отключается.
func buildHysteria2Url(conPass string, hostname string, port int, obfs bo.ObfsShareConfig, sni string, remark string) string {
return BuildHysteria2ShareURI(ShareURIOptions{
Secret: conPass,
Host: hostname,
Port: port,
Obfs: obfs,
SNI: sni,
Remark: remark,
Insecure: false,
})
}
+342 -29
View File
@@ -4,26 +4,115 @@ import (
"net/url"
"strings"
"testing"
"hy2xs-admin/model/bo"
"gopkg.in/yaml.v3"
)
func mustParse(t *testing.T, raw string) *url.URL {
t.Helper()
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("expected valid url, got error: %v (raw=%s)", err, raw)
}
if parsed.Scheme != "hysteria2" {
t.Fatalf("expected hysteria2 scheme, got %s", parsed.Scheme)
}
return parsed
}
func TestBuildHysteria2Url_GeckoObfs(t *testing.T) {
raw := buildHysteria2Url(
"con-pass",
"vpn.example.com",
443,
bo.ObfsShareConfig{Type: "gecko", Password: "gecko-secret"},
"vpn.example.com",
"",
)
q := mustParse(t, raw).Query()
if q.Get("obfs") != "gecko" {
t.Fatalf("expected obfs=gecko, got %q", q.Get("obfs"))
}
if q.Get("obfs-password") != "gecko-secret" {
t.Fatalf("expected gecko obfs password, got %q", q.Get("obfs-password"))
}
if q.Get("sni") != "vpn.example.com" {
t.Fatalf("expected sni, got %q", q.Get("sni"))
}
if q.Get("insecure") != "0" {
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
}
}
func TestBuildHysteria2Url_SalamanderObfs(t *testing.T) {
raw := buildHysteria2Url(
"con-pass",
"vpn.example.com",
443,
bo.ObfsShareConfig{Type: "salamander", Password: "salamander-secret"},
"vpn.example.com",
"",
)
q := mustParse(t, raw).Query()
if q.Get("obfs") != "salamander" {
t.Fatalf("expected obfs=salamander, got %q", q.Get("obfs"))
}
if q.Get("obfs-password") != "salamander-secret" {
t.Fatalf("expected salamander obfs password, got %q", q.Get("obfs-password"))
}
}
func TestBuildHysteria2Url_NoObfs(t *testing.T) {
raw := buildHysteria2Url("pass", "example.com", 8443, bo.ObfsShareConfig{}, "", "")
parsed := mustParse(t, raw)
if parsed.Host != "example.com:8443" {
t.Fatalf("unexpected host: %s", parsed.Host)
}
q := parsed.Query()
if q.Get("obfs") != "" || q.Get("obfs-password") != "" || q.Get("sni") != "" {
t.Fatalf("unexpected optional query params in minimal config: %s", parsed.RawQuery)
}
if q.Get("insecure") != "0" {
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
}
}
func TestBuildHysteria2Url_UnknownObfsTypeIsNotShared(t *testing.T) {
// Неизвестный тип не должен попадать в ссылку: клиент получил бы
// параметр, который не понимает.
raw := buildHysteria2Url("pass", "example.com", 443, bo.ObfsShareConfig{Type: "future-obfs", Password: "x"}, "", "")
q := mustParse(t, raw).Query()
if q.Get("obfs") != "" {
t.Fatalf("unknown obfs type must not be shared, got %q", q.Get("obfs"))
}
}
func TestBuildHysteria2Url_ObfsWithoutPasswordIsNotShared(t *testing.T) {
raw := buildHysteria2Url("pass", "example.com", 443, bo.ObfsShareConfig{Type: "gecko"}, "", "")
q := mustParse(t, raw).Query()
if q.Get("obfs") != "" || q.Get("obfs-password") != "" {
t.Fatalf("obfs without password must not be shared: %s", q.Encode())
}
}
func TestBuildHysteria2Url_EncodesUserInfoQueryAndFragment(t *testing.T) {
raw := buildHysteria2Url(
"u@ser:#&=+ pass",
"example.com",
443,
"salamander",
"obf+s&pass=@x",
bo.ObfsShareConfig{Type: "gecko", Password: "obf+s&pass=@x"},
"exa mple.com",
"my remark #1",
)
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("expected valid url, got error: %v", err)
}
if parsed.Scheme != "hysteria2" {
t.Fatalf("expected hysteria2 scheme, got %s", parsed.Scheme)
}
parsed := mustParse(t, raw)
if parsed.User == nil {
t.Fatal("expected userinfo to be present")
}
@@ -32,42 +121,266 @@ func TestBuildHysteria2Url_EncodesUserInfoQueryAndFragment(t *testing.T) {
}
q := parsed.Query()
if q.Get("obfs") != "salamander" {
t.Fatalf("expected obfs=salamander, got %q", q.Get("obfs"))
}
if q.Get("obfs-password") != "obf+s&pass=@x" {
t.Fatalf("expected decoded obfs-password, got %q", q.Get("obfs-password"))
}
if q.Get("sni") != "exa mple.com" {
t.Fatalf("expected decoded sni, got %q", q.Get("sni"))
}
if q.Get("insecure") != "0" {
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
}
if parsed.Fragment != "my remark #1" {
t.Fatalf("expected decoded fragment, got %q", parsed.Fragment)
}
if strings.Contains(raw, "u@ser:#&=+ pass") {
t.Fatalf("raw uri must not contain unescaped userinfo: %s", raw)
}
}
func TestBuildHysteria2Url_MinimalConfig(t *testing.T) {
raw := buildHysteria2Url("pass", "example.com", 8443, "", "", "", "")
// Регрессия на upstream-баг 2.9.3: `+` в credentials при разборе share link
// превращался в пробел. Проверяем, что кодирование однозначно.
func TestBuildHysteria2Url_PlusInCredentialsSurvivesRoundTrip(t *testing.T) {
cases := []string{"a+b", "a b", "a#b", "a@b", "a/b", "a?b", "a&b", "a=b", "a%b", "тест"}
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("expected valid url, got error: %v", err)
for _, value := range cases {
raw := buildHysteria2Url(value, "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: value}, "", "")
parsed := mustParse(t, raw)
if got := parsed.User.Username(); got != value {
t.Fatalf("userinfo round-trip failed for %q: got %q (raw=%s)", value, got, raw)
}
if got := parsed.Query().Get("obfs-password"); got != value {
t.Fatalf("obfs-password round-trip failed for %q: got %q (raw=%s)", value, got, raw)
}
}
if parsed.Host != "example.com:8443" {
t.Fatalf("unexpected host: %s", parsed.Host)
// Ключевой инвариант: литеральный `+` кодируется как %2B и не может быть
// прочитан клиентом как пробел, а пробел кодируется отдельно от него.
plus := buildHysteria2Url("a+b", "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: "a+b"}, "", "")
if !strings.Contains(mustParse(t, plus).RawQuery, "%2B") {
t.Fatalf("literal '+' must be percent-encoded as %%2B: %s", plus)
}
q := parsed.Query()
if q.Get("insecure") != "0" {
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
}
if q.Get("obfs") != "" || q.Get("obfs-password") != "" || q.Get("sni") != "" {
t.Fatalf("unexpected optional query params in minimal config: %s", parsed.RawQuery)
space := buildHysteria2Url("a b", "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: "a b"}, "", "")
if mustParse(t, space).Query().Get("obfs-password") == "a+b" {
t.Fatalf("space and '+' must not collapse to the same value: %s", space)
}
}
// Инвариант production-пути: проверка сертификата не отключается никогда.
// Флаг Insecure существует только для e2e с самоподписанным сертификатом.
func TestBuildHysteria2Url_ProductionPathNeverDisablesVerification(t *testing.T) {
cases := []bo.ObfsShareConfig{
{},
{Type: "gecko", Password: "p"},
{Type: "salamander", Password: "p"},
}
for _, obfs := range cases {
raw := buildHysteria2Url("pass", "vpn.example.com", 443, obfs, "vpn.example.com", "remark")
if got := mustParse(t, raw).Query().Get("insecure"); got != "0" {
t.Fatalf("production share URI must carry insecure=0, got %q (raw=%s)", got, raw)
}
}
}
// e2e использует production-генератор, поэтому единственное расхождение с
// пользовательской ссылкой обязано быть ровно одним параметром.
func TestBuildHysteria2ShareURI_InsecureDiffersOnlyInThatParam(t *testing.T) {
opts := shareOptionsFixture()
production := BuildHysteria2ShareURI(opts)
opts.Insecure = true
relaxed := BuildHysteria2ShareURI(opts)
productionURL := mustParse(t, production)
testURL := mustParse(t, relaxed)
if productionURL.Host != testURL.Host || productionURL.Path != testURL.Path {
t.Fatalf("host/path must not depend on the insecure flag: %s vs %s", production, relaxed)
}
if productionURL.User.String() != testURL.User.String() {
t.Fatalf("userinfo must not depend on the insecure flag")
}
if productionURL.Fragment != testURL.Fragment {
t.Fatalf("remark must not depend on the insecure flag")
}
productionQuery := productionURL.Query()
testQuery := testURL.Query()
if productionQuery.Get("insecure") != "0" || testQuery.Get("insecure") != "1" {
t.Fatalf("unexpected insecure values: %q / %q",
productionQuery.Get("insecure"), testQuery.Get("insecure"))
}
productionQuery.Del("insecure")
testQuery.Del("insecure")
if productionQuery.Encode() != testQuery.Encode() {
t.Fatalf("only the insecure param may differ:\n %s\n %s",
productionQuery.Encode(), testQuery.Encode())
}
}
func shareOptionsFixture() ShareURIOptions {
return ShareURIOptions{
Secret: "peer-secret",
Host: "vpn.example.com",
Port: 443,
Obfs: bo.ObfsShareConfig{Type: "gecko", Password: "gecko-secret"},
SNI: "vpn.example.com",
Remark: "HY2XS",
}
}
func TestResolveShareSni(t *testing.T) {
tests := []struct {
name string
acmeDomain string
publicHost string
envDomain string
want string
}{
{name: "acme domain wins", acmeDomain: "acme.example.com", publicHost: "vpn.example.com", envDomain: "env.example.com", want: "acme.example.com"},
{name: "file tls falls back to product domain", acmeDomain: "", publicHost: "vpn.example.com", envDomain: "env.example.com", want: "env.example.com"},
{name: "public host used when domain is empty", acmeDomain: "", publicHost: "vpn.example.com", envDomain: "", want: "vpn.example.com"},
{name: "ipv4 public host is not a valid sni", acmeDomain: "", publicHost: "203.0.113.10", envDomain: "", want: ""},
{name: "ipv4 env domain is not a valid sni", acmeDomain: "", publicHost: "203.0.113.10", envDomain: "198.51.100.7", want: ""},
{name: "whitespace is trimmed", acmeDomain: " acme.example.com ", publicHost: "", envDomain: "", want: "acme.example.com"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("HY2XS_DOMAIN", tc.envDomain)
if got := resolveShareSni(tc.acmeDomain, tc.publicHost); got != tc.want {
t.Fatalf("resolveShareSni(%q, %q) with HY2XS_DOMAIN=%q = %q, want %q",
tc.acmeDomain, tc.publicHost, tc.envDomain, got, tc.want)
}
})
}
}
func TestObfsShare_FromServerConfig(t *testing.T) {
tests := []struct {
name string
yamlConfig string
wantType string
wantPassword string
}{
{
name: "gecko",
yamlConfig: "obfs:\n type: gecko\n gecko:\n password: gecko-pass\n minPacketSize: 512\n maxPacketSize: 1200\n",
wantType: "gecko",
wantPassword: "gecko-pass",
},
{
name: "salamander",
yamlConfig: "obfs:\n type: salamander\n salamander:\n password: salamander-pass\n",
wantType: "salamander",
wantPassword: "salamander-pass",
},
{
name: "no obfs section",
yamlConfig: "listen: 0.0.0.0:443\n",
},
{
name: "type without matching subsection",
yamlConfig: "obfs:\n type: gecko\n salamander:\n password: mismatched\n",
},
{
name: "unknown type",
yamlConfig: "obfs:\n type: future\n gecko:\n password: p\n",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var config bo.Hysteria2ServerConfig
if err := yaml.Unmarshal([]byte(tc.yamlConfig), &config); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
got := config.ObfsShare()
if got.Type != tc.wantType || got.Password != tc.wantPassword {
t.Fatalf("ObfsShare() = %+v, want type=%q password=%q", got, tc.wantType, tc.wantPassword)
}
})
}
}
func TestHysteria2ServerConfig_ParsesModernUpstreamSchema(t *testing.T) {
raw := `listen: 0.0.0.0:443
tls:
cert: /etc/hysteria/server.crt
key: /etc/hysteria/server.key
clientCA: /etc/hysteria/client-ca.crt
ech:
keyPath: /etc/hysteria/ech.pem
obfs:
type: gecko
gecko:
password: p
minPacketSize: 512
maxPacketSize: 1200
bandwidth:
up: 50 mbps
down: 50 mbps
disableLossCompensation: false
congestion:
type: bbr
bbrProfile: standard
quic:
disableStatelessReset: false
mimic:
enabled: false
interface: eth0
xdpMode: skb
realm:
stunServers:
- stun.example.com:3478
ipMode: dual
portMapping:
enabled: false
timeout: 10s
masquerade:
type: proxy
proxy:
url: https://example.com
rewriteHost: true
insecure: false
xForwarded: true
trafficStats:
listen: 127.0.0.1:36712
`
var config bo.Hysteria2ServerConfig
if err := yaml.Unmarshal([]byte(raw), &config); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if config.ECH == nil || config.ECH.KeyPath == nil || *config.ECH.KeyPath != "/etc/hysteria/ech.pem" {
t.Fatal("ech.keyPath was not parsed")
}
if config.TLS == nil || config.TLS.ClientCA == nil || *config.TLS.ClientCA != "/etc/hysteria/client-ca.crt" {
t.Fatal("tls.clientCA was not parsed")
}
if config.Congestion == nil || config.Congestion.Type == nil || *config.Congestion.Type != "bbr" {
t.Fatal("congestion.type was not parsed")
}
if config.Congestion.BBRProfile == nil || *config.Congestion.BBRProfile != "standard" {
t.Fatal("congestion.bbrProfile was not parsed")
}
if config.Bandwidth == nil || config.Bandwidth.DisableLossCompensation == nil || *config.Bandwidth.DisableLossCompensation {
t.Fatal("bandwidth.disableLossCompensation was not parsed")
}
if config.QUIC == nil || config.QUIC.DisableStatelessReset == nil || *config.QUIC.DisableStatelessReset {
t.Fatal("quic.disableStatelessReset was not parsed")
}
if config.Mimic == nil || config.Mimic.Enabled == nil || *config.Mimic.Enabled {
t.Fatal("mimic section was not parsed")
}
if config.Realm == nil || len(config.Realm.StunServers) != 1 || config.Realm.PortMapping == nil {
t.Fatal("realm section was not parsed")
}
if config.Masquerade == nil || config.Masquerade.Proxy == nil ||
config.Masquerade.Proxy.XForwarded == nil || !*config.Masquerade.Proxy.XForwarded {
t.Fatal("masquerade.proxy.xForwarded was not parsed")
}
}
+248
View File
@@ -0,0 +1,248 @@
package service
import (
"errors"
"net/url"
"os"
"strings"
"gopkg.in/yaml.v3"
"hy2xs-admin/model/constant"
)
// RedactedPlaceholder — маркер вырезанного секрета в экспортируемом конфиге.
const RedactedPlaceholder = "<redacted>"
// GetRawHysteria2Config возвращает исходный YAML серверного конфига без
// прохода через типизированную модель.
//
// Это отдельный слой от GetHysteria2Config намеренно: типизированная модель
// отражает известные HY2XS поля и используется для отображения, а сырой YAML
// нужен там, где нельзя потерять поля, о которых HY2XS пока не знает.
//
// Источник ровно один — файл. Ветка «сначала SQLite, потом файл» удалена
// вместе с ключом HYSTERIA2_CONFIG: см. GetHysteria2Config в service/config.go.
// Здесь у неё было дополнительное следствие — выгрузка «фактического
// серверного конфига» могла отдать оператору документ, по которому Hysteria
// никогда не работала.
func GetRawHysteria2Config() (string, error) {
content, err := os.ReadFile(constant.Hysteria2ConfigPath)
if err != nil {
return "", err
}
return string(content), nil
}
// ExportHysteria2ConfigYaml готовит серверный конфиг к выгрузке оператору.
//
// Гарантии:
// 1. неизвестные upstream-поля сохраняются — будущие версии Hysteria не
// обрезаются только потому, что HY2XS ещё не научился их показывать;
// 2. известные секреты и неизвестные поля с секретоподобным именем не
// покидают сервер в открытом виде.
func ExportHysteria2ConfigYaml() ([]byte, error) {
raw, err := GetRawHysteria2Config()
if err != nil {
return nil, err
}
return SanitizeHysteria2ConfigYaml(raw)
}
// SanitizeHysteria2ConfigYaml вырезает секреты из YAML, сохраняя структуру и
// все прочие поля документа.
func SanitizeHysteria2ConfigYaml(raw string) ([]byte, error) {
if strings.TrimSpace(raw) == "" {
return nil, errors.New("hysteria2 config is empty")
}
var document yaml.Node
if err := yaml.Unmarshal([]byte(raw), &document); err != nil {
return nil, err
}
redactNode(&document, nil)
out, err := yaml.Marshal(&document)
if err != nil {
return nil, err
}
return out, nil
}
// secretKeyMarkers покрывает известные секреты HY2XS/Hysteria и типовые имена,
// под которыми секрет может появиться в будущем upstream-поле.
//
// Гарантия формулируется именно так: вырезаются известные секреты и
// неизвестные поля с секретоподобным именем. Обещать, что под правило попадёт
// ЛЮБОЙ будущий секрет, нельзя — обобщённый sanitizer этого не умеет.
//
// Список синхронизирован с SECRET_KEY_MARKERS в
// orchestrator/src/lib/redaction.ts.
var secretKeyMarkers = []string{
"password",
"passwd",
"passphrase",
"secret",
"token",
"credential",
"apikey",
"api_key",
"privatekey",
"private_key",
"accesskey",
"access_key",
"secretkey",
"secret_key",
"authorization",
"cookie",
"bearer",
"signature",
}
func isSecretKey(key string) bool {
lowered := strings.ToLower(key)
for _, marker := range secretKeyMarkers {
if strings.Contains(lowered, marker) {
return true
}
}
return false
}
// isSecretMapPath — узлы, где секретом являются все значения карты, а не ключ.
func isSecretMapPath(path []string) bool {
joined := strings.Join(path, ".")
switch joined {
case "auth.userpass", "acme.dns.config":
return true
}
return false
}
func redactNode(node *yaml.Node, path []string) {
if node == nil {
return
}
switch node.Kind {
case yaml.DocumentNode:
for _, child := range node.Content {
redactNode(child, path)
}
case yaml.SequenceNode:
for _, child := range node.Content {
// Индекс не попадает в путь: правила формулируются по именам полей.
redactNode(child, path)
}
case yaml.MappingNode:
if isSecretMapPath(path) {
redactAllScalarValues(node)
return
}
for i := 0; i+1 < len(node.Content); i += 2 {
key := node.Content[i].Value
value := node.Content[i+1]
childPath := append(append([]string{}, path...), key)
if isSecretKey(key) {
redactSubtree(value)
continue
}
redactNode(value, childPath)
}
case yaml.ScalarNode:
// Секрет может лежать в URL и без «говорящего» имени ключа.
//
// Раньше здесь стоял фильтр по имени ключа (url / addr / *url), и
// upstream-поле вроде `endpoint: https://user:pass@host/?token=…`
// уезжало в выгрузку целиком. Правильная граница — не имя ключа, а
// сам факт, что значение разбирается как URL: sanitizeURLValue
// возвращает вход без изменений, если это не URL. Ровно так же
// устроен TS-санитайзер в orchestrator/src/lib/redaction.ts.
sanitizeURLScalar(node)
}
}
// sanitizeURLScalar применяет URL-санитайзер к скаляру, не трогая ни его тип,
// ни значения, которые URL не являются.
func sanitizeURLScalar(node *yaml.Node) {
if node.Tag != "" && node.Tag != "!!str" {
return
}
sanitized := sanitizeURLValue(node.Value)
if sanitized == node.Value {
return
}
node.Value = sanitized
node.Tag = "!!str"
node.Style = 0
}
func redactSubtree(node *yaml.Node) {
if node == nil {
return
}
switch node.Kind {
case yaml.ScalarNode:
setRedacted(node)
case yaml.MappingNode, yaml.SequenceNode, yaml.DocumentNode:
redactAllScalarValues(node)
}
}
func redactAllScalarValues(node *yaml.Node) {
switch node.Kind {
case yaml.MappingNode:
for i := 0; i+1 < len(node.Content); i += 2 {
redactSubtree(node.Content[i+1])
}
case yaml.SequenceNode, yaml.DocumentNode:
for _, child := range node.Content {
redactSubtree(child)
}
case yaml.ScalarNode:
setRedacted(node)
}
}
func setRedacted(node *yaml.Node) {
node.Value = RedactedPlaceholder
node.Tag = "!!str"
node.Style = 0
}
// sanitizeURLValue убирает из URL встроенные учётные данные и секретные
// query-параметры, сохраняя остальную часть адреса читаемой.
func sanitizeURLValue(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return raw
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" {
return raw
}
if parsed.User != nil {
parsed.User = url.User(RedactedPlaceholder)
}
query := parsed.Query()
changed := false
for key := range query {
if isSecretKey(key) {
query.Set(key, RedactedPlaceholder)
changed = true
}
}
if changed {
parsed.RawQuery = query.Encode()
}
return parsed.String()
}
+318
View File
@@ -0,0 +1,318 @@
package service
import (
"strings"
"testing"
"gopkg.in/yaml.v3"
)
const exportSampleConfig = `listen: 0.0.0.0:443
acme:
domains:
- vpn.example.com
email: admin@example.com
ca: letsencrypt
dir: /var/lib/hysteria/acme
listenHost: 0.0.0.0
type: http
dns:
name: cloudflare
config:
cloudflare_api_token: super-secret-token
zone: example.com
auth:
type: http
http:
url: http://127.0.0.1:8080/internal/hysteria/auth?access_token=machine-secret
insecure: false
userpass:
alice: alice-password
bob: bob-password
obfs:
type: gecko
gecko:
password: gecko-obfs-secret
minPacketSize: 512
maxPacketSize: 1200
bandwidth:
up: 50 mbps
down: 50 mbps
disableLossCompensation: false
congestion:
type: bbr
bbrProfile: standard
trafficStats:
listen: 127.0.0.1:36712
secret: traffic-stats-secret
outbounds:
- name: upstream
type: socks5
socks5:
addr: 10.0.0.1:1080
username: proxyuser
password: proxy-password
quic:
initStreamReceiveWindow: 8388608
disableStatelessReset: false
someFutureUpstreamFeature:
enabled: true
nested:
tuning: 42
list:
- a
- b
# Ни одно из имён ниже не является ни secret-shaped, ни url/addr-подобным:
# именно так выглядит поле, которое upstream добавит завтра.
endpoint: https://svc-user:svc-p4ss@relay.example.com/?access_token=endpoint-token
mirrors:
- https://mirror-user:mirror-p4ss@mirror.example.com/pull
`
func sanitizeForTest(t *testing.T, raw string) string {
t.Helper()
out, err := SanitizeHysteria2ConfigYaml(raw)
if err != nil {
t.Fatalf("sanitize failed: %v", err)
}
return string(out)
}
func TestSanitizeHysteria2ConfigYaml_RemovesSecrets(t *testing.T) {
sanitized := sanitizeForTest(t, exportSampleConfig)
leaked := []string{
"gecko-obfs-secret",
"traffic-stats-secret",
"machine-secret",
"alice-password",
"bob-password",
"proxy-password",
"super-secret-token",
"svc-p4ss",
"endpoint-token",
"mirror-p4ss",
}
for _, secret := range leaked {
if strings.Contains(sanitized, secret) {
t.Fatalf("exported config leaks secret %q:\n%s", secret, sanitized)
}
}
}
func TestSanitizeHysteria2ConfigYaml_PreservesUnknownUpstreamFields(t *testing.T) {
sanitized := sanitizeForTest(t, exportSampleConfig)
var parsed map[string]any
if err := yaml.Unmarshal([]byte(sanitized), &parsed); err != nil {
t.Fatalf("sanitized output is not valid yaml: %v", err)
}
future, ok := parsed["someFutureUpstreamFeature"].(map[string]any)
if !ok {
t.Fatalf("unknown upstream section was dropped:\n%s", sanitized)
}
if future["enabled"] != true {
t.Fatalf("unknown upstream scalar was dropped: %+v", future)
}
nested, ok := future["nested"].(map[string]any)
if !ok {
t.Fatalf("nested unknown section was dropped: %+v", future)
}
if nested["tuning"] != 42 {
t.Fatalf("nested unknown value was dropped: %+v", nested)
}
if list, ok := nested["list"].([]any); !ok || len(list) != 2 {
t.Fatalf("nested unknown list was dropped: %+v", nested)
}
}
func TestSanitizeHysteria2ConfigYaml_KeepsNonSecretOperationalFields(t *testing.T) {
sanitized := sanitizeForTest(t, exportSampleConfig)
kept := []string{
"listen: 0.0.0.0:443",
"vpn.example.com",
"type: gecko",
"minPacketSize: 512",
"maxPacketSize: 1200",
"bbrProfile: standard",
"disableLossCompensation: false",
"disableStatelessReset: false",
"127.0.0.1:36712",
}
for _, fragment := range kept {
if !strings.Contains(sanitized, fragment) {
t.Fatalf("exported config lost operational field %q:\n%s", fragment, sanitized)
}
}
}
func TestSanitizeHysteria2ConfigYaml_StripsAccessTokenButKeepsUrlShape(t *testing.T) {
sanitized := sanitizeForTest(t, exportSampleConfig)
if !strings.Contains(sanitized, "127.0.0.1:8080/internal/hysteria/auth") {
t.Fatalf("auth url shape was lost:\n%s", sanitized)
}
if !strings.Contains(sanitized, "access_token="+RedactedPlaceholder) &&
!strings.Contains(sanitized, "access_token=%3Credacted%3E") {
t.Fatalf("access_token was not redacted:\n%s", sanitized)
}
}
// Регрессия: санитайзер применял sanitizeURLValue только к ключам url/addr/*url.
// Любое будущее upstream-поле с другим именем уносило встроенные учётные данные
// и секретные query-параметры наружу целиком. TS-санитайзер оркестратора такой
// границы никогда не имел, и расхождение между двумя реализациями одного
// контракта — само по себе дефект.
func TestSanitizeHysteria2ConfigYaml_RedactsURLsUnderArbitraryKeys(t *testing.T) {
sanitized := sanitizeForTest(t, exportSampleConfig)
var parsed map[string]any
if err := yaml.Unmarshal([]byte(sanitized), &parsed); err != nil {
t.Fatalf("sanitized output is not valid yaml: %v", err)
}
future, ok := parsed["someFutureUpstreamFeature"].(map[string]any)
if !ok {
t.Fatalf("unknown upstream section was dropped:\n%s", sanitized)
}
endpoint, _ := future["endpoint"].(string)
if !strings.Contains(endpoint, "relay.example.com") {
t.Fatalf("endpoint потерял адрес, диагностика станет бесполезной: %q", endpoint)
}
if strings.Contains(endpoint, "svc-p4ss") || strings.Contains(endpoint, "endpoint-token") {
t.Fatalf("endpoint унёс секреты наружу: %q", endpoint)
}
// Скаляры внутри последовательностей раньше вообще не доходили до
// санитайзера: у redactNode не было ветки ScalarNode.
mirrors, ok := future["mirrors"].([]any)
if !ok || len(mirrors) != 1 {
t.Fatalf("список mirrors потерян: %+v", future["mirrors"])
}
mirror, _ := mirrors[0].(string)
if !strings.Contains(mirror, "mirror.example.com") {
t.Fatalf("mirror потерял адрес: %q", mirror)
}
if strings.Contains(mirror, "mirror-p4ss") {
t.Fatalf("mirror унёс встроенные учётные данные: %q", mirror)
}
}
// Санитайзер обязан оставаться безвредным для значений, которые URL не
// являются: он проходит по каждому скаляру документа.
func TestSanitizeHysteria2ConfigYaml_LeavesNonURLScalarsIntact(t *testing.T) {
sanitized := sanitizeForTest(t, exportSampleConfig)
intact := []string{
"up: 50 mbps",
"down: 50 mbps",
"listen: 0.0.0.0:443",
"listen: 127.0.0.1:36712",
"addr: 10.0.0.1:1080",
"email: admin@example.com",
"dir: /var/lib/hysteria/acme",
"initStreamReceiveWindow: 8388608",
"tuning: 42",
}
for _, fragment := range intact {
if !strings.Contains(sanitized, fragment) {
t.Fatalf("санитайзер изменил не-URL значение %q:\n%s", fragment, sanitized)
}
}
}
func TestSanitizeURLValue(t *testing.T) {
tests := []struct {
name string
in string
mustKeep []string
mustRemove []string
}{
{
name: "strips access token",
in: "http://127.0.0.1:8080/internal/hysteria/auth?access_token=abc123",
mustKeep: []string{"127.0.0.1:8080", "/internal/hysteria/auth"},
mustRemove: []string{"abc123"},
},
{
name: "strips embedded credentials",
in: "https://user:p4ssw0rd@proxy.example.com:8443/path",
mustKeep: []string{"proxy.example.com:8443", "/path"},
mustRemove: []string{"p4ssw0rd"},
},
{
name: "leaves clean url untouched",
in: "https://example.com/masq",
mustKeep: []string{"https://example.com/masq"},
},
{
name: "leaves plain host:port untouched",
in: "10.0.0.1:1080",
mustKeep: []string{"10.0.0.1:1080"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := sanitizeURLValue(tc.in)
for _, fragment := range tc.mustKeep {
if !strings.Contains(got, fragment) {
t.Fatalf("sanitizeURLValue(%q) = %q, expected to keep %q", tc.in, got, fragment)
}
}
for _, fragment := range tc.mustRemove {
if strings.Contains(got, fragment) {
t.Fatalf("sanitizeURLValue(%q) = %q, expected to remove %q", tc.in, got, fragment)
}
}
})
}
}
func TestIsSecretKey(t *testing.T) {
secret := []string{"password", "Password", "obfs_password", "secret", "trafficSecret", "access_token", "apiToken", "credentials"}
for _, key := range secret {
if !isSecretKey(key) {
t.Fatalf("expected %q to be treated as secret", key)
}
}
// Пути к файлам не являются секретами и должны остаться читаемыми.
notSecret := []string{"key", "keyPath", "cert", "clientCA", "listen", "url", "type", "dir"}
for _, key := range notSecret {
if isSecretKey(key) {
t.Fatalf("expected %q to stay visible in export", key)
}
}
}
func TestSanitizeHysteria2ConfigYaml_RejectsEmptyInput(t *testing.T) {
if _, err := SanitizeHysteria2ConfigYaml(" \n"); err == nil {
t.Fatal("expected an error for empty config")
}
}
func TestSanitizeHysteria2ConfigYaml_RedactsUnknownFutureSecretField(t *testing.T) {
// Обратная сторона сохранения неизвестных полей: новое upstream-поле с
// секретом должно вырезаться до того, как HY2XS про него узнает.
raw := "listen: 0.0.0.0:443\nfutureFeature:\n apiSecret: leak-me\n nested:\n userPassword: leak-me-too\n"
sanitized := sanitizeForTest(t, raw)
if strings.Contains(sanitized, "leak-me") {
t.Fatalf("unknown future secret field leaked:\n%s", sanitized)
}
if !strings.Contains(sanitized, "futureFeature") {
t.Fatalf("unknown future section was dropped:\n%s", sanitized)
}
}
+30 -2
View File
@@ -4,8 +4,10 @@ import (
"bufio"
"encoding/json"
"fmt"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/vo"
"hy2xs-admin/util"
"os"
"strconv"
"strings"
"time"
@@ -58,10 +60,35 @@ func ReadHysteriaJournalLogs(numLine int) ([]vo.LogHysteria2Vo, int, error) {
return result, len(result), nil
}
// ExportAdminLog отдаёт журнал самой админки, вырезав из него секреты.
//
// Раньше этот файл выгружался как есть, через c.File: чужой журнал продукт
// санитизировал, а собственный — нет. Обоснования у асимметрии не было, а цена
// известна поимённо: пока bootstrap-пароль администратора печатался в лог
// warning'ом, обычная кнопка «выгрузить журнал» отдавала его открытым текстом.
// Сам warning убран (см. dao/sqlite.go), но защита от следующего такого места
// должна стоять и на выходе.
func ExportAdminLog() (string, error) {
content, err := os.ReadFile(constant.SystemLogPath)
if err != nil {
return "", err
}
return SanitizeLogText(string(content)), nil
}
// ExportHysteriaJournalLogs отдаёт журнал Hysteria оператору.
//
// Файл покидает сервер, поэтому проходит тот же санитайз, что и остальные
// выгружаемые артефакты: HY2_AUTH_URL несёт access_token, и upstream волен
// упомянуть его в сообщении об ошибке обращения к auth-backend.
func ExportHysteriaJournalLogs(numLine int) (string, error) {
lines := normalizeJournalLineLimit(numLine, 5000, 10000)
return util.Exec(fmt.Sprintf("journalctl --no-pager -u hysteria-server.service -n %d -o short-iso", lines))
output, err := util.Exec(fmt.Sprintf("journalctl --no-pager -u hysteria-server.service -n %d -o short-iso", lines))
if err != nil {
return "", err
}
return SanitizeLogText(output), nil
}
func parseHysteriaJournalRecord(item journalctlLogLine) vo.LogHysteria2Vo {
@@ -87,12 +114,13 @@ func parseHysteriaJournalRecord(item journalctlLogLine) vo.LogHysteria2Vo {
if parsed.Msg == "" {
parsed.Msg = item.Message
}
parsed.Msg = SanitizeLogText(parsed.Msg)
return parsed
}
return vo.LogHysteria2Vo{
Level: fallbackLevel,
Msg: item.Message,
Msg: SanitizeLogText(item.Message),
Time: fallbackTime,
}
}
+87 -36
View File
@@ -2,59 +2,110 @@ package service
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"strings"
"time"
)
// Токены подписываются и проверяются библиотекой golang-jwt/jwt/v5.
//
// Переход с v3 не косметический. У `github.com/golang-jwt/jwt` v3.2.2 есть
// GO-2025-3553 (чрезмерное выделение памяти при разборе заголовка), и у неё
// НЕТ исправленной версии в ветке v3: `Fixed in: N/A`. Уязвимый код
// достигается из ParseToken, то есть с неаутентифицированного запроса — любой,
// кто может обратиться к панели, попадает в него до всякой проверки прав.
// Обновить здесь было нечего, поэтому лечится только сменой мажорной ветки.
//
// Заодно закрыт второй, тихий недостаток прежней реализации: keyfunc возвращал
// ключ, НЕ проверяя алгоритм подписи. Разбор без ограничения набора алгоритмов
// — это тот класс ошибок, из которого растёт подмена алгоритма; ниже стоит
// jwt.WithValidMethods, и допустимый алгоритм ровно один.
const TokenExpireDuration = time.Hour * 24
// tokenIssuer попадает и в подпись, и в проверку: токен, выписанный не нами,
// не должен разбираться как наш даже при совпадении ключа.
const tokenIssuer = "hy2xs-admin"
type MyClaims struct {
Admin bo.AccountBo `json:"admin"`
jwt.StandardClaims
jwt.RegisteredClaims
}
func GenToken(accountBo bo.AccountBo) (string, error) {
c := MyClaims{
Admin: accountBo,
StandardClaims: jwt.StandardClaims{
ExpiresAt: time.Now().Add(TokenExpireDuration).Unix(),
Issuer: "hy2xs-admin",
},
}
config, err := dao.GetConfig("key = ?", constant.JwtSecret)
if err != nil {
return "", errors.New(constant.SysError)
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
return token.SignedString([]byte(*config.Value))
}
func ParseToken(tokenString string) (*MyClaims, error) {
func jwtSecret() ([]byte, error) {
config, err := dao.GetConfig("key = ?", constant.JwtSecret)
if err != nil {
return nil, errors.New(constant.SysError)
}
token, err := jwt.ParseWithClaims(tokenString, &MyClaims{}, func(token *jwt.Token) (i interface{}, err error) {
return []byte(*config.Value), nil
})
if err != nil {
return nil, errors.New(constant.IllegalTokenError)
if config.Value == nil || strings.TrimSpace(*config.Value) == "" {
// Подпись пустым ключом дала бы токены, которые подделывает кто угодно.
// Раньше значение разыменовывалось без проверки: пустой JWT_SECRET
// молча превращался в ключ нулевой длины.
return nil, errors.New(constant.SysError)
}
if claims, ok := token.Claims.(*MyClaims); ok && token.Valid {
return claims, nil
}
return nil, errors.New(constant.TokenExpiredError)
return []byte(*config.Value), nil
}
func GetToken(c *gin.Context) string {
tokenStr := c.Request.Header.Get("Authorization")
if tokenStr == "" {
return ""
func GenToken(accountBo bo.AccountBo) (string, error) {
secret, err := jwtSecret()
if err != nil {
return "", err
}
return strings.SplitN(tokenStr, " ", 2)[1]
now := time.Now()
claims := MyClaims{
Admin: accountBo,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(TokenExpireDuration)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
Issuer: tokenIssuer,
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
}
func ParseToken(tokenString string) (*MyClaims, error) {
secret, err := jwtSecret()
if err != nil {
return nil, err
}
token, err := jwt.ParseWithClaims(
tokenString,
&MyClaims{},
func(*jwt.Token) (interface{}, error) { return secret, nil },
// Допустимый алгоритм ровно один. Без этого ограничения набор
// алгоритмов задаёт сам токен, то есть недоверенный вход.
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
jwt.WithIssuer(tokenIssuer),
// Токен без срока жизни не должен считаться валидным бессрочным.
jwt.WithExpirationRequired(),
)
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, errors.New(constant.TokenExpiredError)
}
return nil, errors.New(constant.IllegalTokenError)
}
claims, ok := token.Claims.(*MyClaims)
if !ok || !token.Valid {
return nil, errors.New(constant.IllegalTokenError)
}
return claims, nil
}
// Функции GetToken здесь больше нет.
//
// Она доставала токен из заголовка Authorization вторым, независимым от
// middleware.JWTHandler способом — и делала это через
// strings.SplitN(tokenStr, " ", 2)[1], то есть паниковала на заголовке без
// пробела: `Authorization: abc` давал index out of range. Единственный
// потребитель — резервная ветка GetAdminInfo, которая была недостижима и
// проверяла меньше, чем middleware. Разбор токена у продукта ровно один.
+204
View File
@@ -0,0 +1,204 @@
package service
import (
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
)
func newJwtTestDB(t *testing.T) []byte {
t.Helper()
newTestDB(t)
secret := "test-jwt-secret-not-used-anywhere-else-0123456789"
if err := dao.UpsertConfigValue(constant.JwtSecret, secret); err != nil {
t.Fatalf("не удалось записать %s: %v", constant.JwtSecret, err)
}
return []byte(secret)
}
func testAccount() bo.AccountBo {
return bo.AccountBo{Id: 1, Username: "hy2xsadmin", Roles: []string{"admin"}, TokenVersion: 1}
}
func TestGenAndParseTokenRoundTrip(t *testing.T) {
newJwtTestDB(t)
token, err := GenToken(testAccount())
if err != nil {
t.Fatalf("GenToken: %v", err)
}
claims, err := ParseToken(token)
if err != nil {
t.Fatalf("ParseToken: %v", err)
}
if claims.Admin.Id != 1 || claims.Admin.Username != "hy2xsadmin" {
t.Fatalf("claims не совпадают: %+v", claims.Admin)
}
if claims.Admin.TokenVersion != 1 {
t.Fatalf("token_version потерян: %d", claims.Admin.TokenVersion)
}
if claims.ExpiresAt == nil {
t.Fatal("токен выписан без срока жизни")
}
}
// Алгоритм подписи задаётся сервером, а не токеном.
//
// Прежний keyfunc возвращал ключ, не глядя на token.Method: набор допустимых
// алгоритмов фактически определял недоверенный вход. Здесь проверяется, что
// токен, подписанный другим HMAC-алгоритмом тем же ключом, отклоняется.
func TestParseTokenRejectsOtherSigningMethod(t *testing.T) {
secret := newJwtTestDB(t)
now := time.Now()
claims := MyClaims{
Admin: testAccount(),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
Issuer: tokenIssuer,
},
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString(secret)
if err != nil {
t.Fatalf("не удалось подписать тестовый токен: %v", err)
}
if _, err := ParseToken(signed); err == nil {
t.Fatal("токен, подписанный HS512, принят при разрешённом только HS256")
}
}
// Токен без exp не должен считаться бессрочно валидным.
func TestParseTokenRejectsTokenWithoutExpiration(t *testing.T) {
secret := newJwtTestDB(t)
claims := MyClaims{
Admin: testAccount(),
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: tokenIssuer,
},
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
if err != nil {
t.Fatalf("не удалось подписать тестовый токен: %v", err)
}
if _, err := ParseToken(signed); err == nil {
t.Fatal("токен без exp принят")
}
}
func TestParseTokenRejectsExpiredToken(t *testing.T) {
secret := newJwtTestDB(t)
past := time.Now().Add(-2 * time.Hour)
claims := MyClaims{
Admin: testAccount(),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(past.Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(past),
Issuer: tokenIssuer,
},
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
if err != nil {
t.Fatalf("не удалось подписать тестовый токен: %v", err)
}
_, err = ParseToken(signed)
if err == nil {
t.Fatal("истёкший токен принят")
}
if err.Error() != constant.TokenExpiredError {
t.Errorf("истечение срока должно сообщаться отдельно, получено: %v", err)
}
}
// Токен, выписанный не нами, не должен разбираться как наш даже при совпадении
// ключа: issuer входит в проверку.
func TestParseTokenRejectsForeignIssuer(t *testing.T) {
secret := newJwtTestDB(t)
now := time.Now()
claims := MyClaims{
Admin: testAccount(),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
Issuer: "some-other-service",
},
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
if err != nil {
t.Fatalf("не удалось подписать тестовый токен: %v", err)
}
if _, err := ParseToken(signed); err == nil {
t.Fatal("токен с чужим issuer принят")
}
}
func TestParseTokenRejectsWrongSecret(t *testing.T) {
newJwtTestDB(t)
now := time.Now()
claims := MyClaims{
Admin: testAccount(),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
Issuer: tokenIssuer,
},
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("совершенно другой ключ подписи"))
if err != nil {
t.Fatalf("не удалось подписать тестовый токен: %v", err)
}
if _, err := ParseToken(signed); err == nil {
t.Fatal("токен, подписанный чужим ключом, принят")
}
}
func TestParseTokenRejectsGarbage(t *testing.T) {
newJwtTestDB(t)
for _, value := range []string{
"",
"not-a-token",
"a.b.c",
strings.Repeat("A", 500),
"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJhZG1pbiI6eyJpZCI6MX19.",
} {
if _, err := ParseToken(value); err == nil {
t.Errorf("мусорный токен %q принят", value)
}
}
}
// Пустой JWT_SECRET — повреждённое состояние, а не «ключ нулевой длины».
// Раньше значение разыменовывалось без проверки, и подпись выполнялась пустым
// ключом: такой токен подделывает кто угодно.
func TestGenTokenRefusesEmptySecret(t *testing.T) {
newTestDB(t)
if err := dao.UpsertConfigValue(constant.JwtSecret, ""); err != nil {
t.Fatalf("не удалось очистить %s: %v", constant.JwtSecret, err)
}
if _, err := GenToken(testAccount()); err == nil {
t.Fatal("токен подписан пустым ключом")
}
if _, err := ParseToken("whatever"); err == nil {
t.Fatal("разбор токена с пустым ключом не отклонён")
}
}
+78
View File
@@ -0,0 +1,78 @@
package service
import (
"regexp"
"strings"
)
// Санитайз свободного текста логов.
//
// Отличается от YAML-санитайзера тем, что у него нет структуры: журнал — это
// строки, а не документ. Поэтому правил ровно два и оба консервативные:
//
// 1. всё, что разбирается как URL, прогоняется через тот же sanitizeURLValue,
// что и экспорт конфига: host/port/path остаются читаемыми, встроенные
// учётные данные и секретные query-параметры вырезаются;
// 2. отдельно ловятся пары `имя=значение` с секретоподобным именем, которые
// встречаются в тексте вне URL.
//
// Зачем это журналу Hysteria: HY2_AUTH_URL содержит access_token, и upstream
// волен упомянуть его в сообщении об ошибке обращения к auth-backend. Свой
// журнал админки мы контролируем, чужой — нет, а ExportLog отдаёт его
// оператору тем же файлом.
var logURLPattern = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9+.\-]*://[^\s"'<>` + "`" + `]+`)
// Имена подобраны по тем же маркерам, что и secretKeyMarkers, но записаны
// явным перечислением: в свободном тексте подстрочный поиск по «token» дал бы
// слишком много ложных срабатываний на словах вроде «tokenizer».
var logSecretAssignmentPattern = regexp.MustCompile(
`(?i)([a-z0-9_\-]*(?:access_token|token|secret|password|passwd|passphrase|credential|api_?key|authorization|signature)[a-z0-9_\-]*)\s*[=:]\s*([^\s&,;"'<>` + "`" + `]+)`,
)
// SanitizeLogText вырезает секреты из произвольного текста журнала.
func SanitizeLogText(raw string) string {
if raw == "" {
return raw
}
withSafeURLs := logURLPattern.ReplaceAllStringFunc(raw, func(match string) string {
body, trailing := splitTrailingPunctuation(match)
return sanitizeURLValue(body) + trailing
})
return logSecretAssignmentPattern.ReplaceAllStringFunc(withSafeURLs, func(match string) string {
groups := logSecretAssignmentPattern.FindStringSubmatch(match)
if len(groups) != 3 {
return match
}
value, trailing := splitTrailingPunctuation(groups[2])
// Значение уже вырезано URL-санитайзером: percent-кодированный маркер
// внутри готового URL трогать нельзя, иначе адрес перестанет быть
// валидным URL.
if value == RedactedPlaceholder || strings.EqualFold(value, encodedRedactedPlaceholder) {
return match
}
separator := "="
if strings.Contains(match, ":") && !strings.Contains(match, "=") {
separator = ": "
}
return groups[1] + separator + RedactedPlaceholder + trailing
})
}
// encodedRedactedPlaceholder — то, во что превращается маркер внутри query
// после url.Values.Encode().
const encodedRedactedPlaceholder = "%3Credacted%3E"
// splitTrailingPunctuation отделяет хвостовую пунктуацию предложения от
// значения: в «...access_token=abc.» точка принадлежит тексту, а не токену.
func splitTrailingPunctuation(value string) (string, string) {
end := len(value)
for end > 0 && strings.ContainsRune(".,;:)]}!?", rune(value[end-1])) {
end--
}
return value[:end], value[end:]
}
+80
View File
@@ -0,0 +1,80 @@
package service
import (
"strings"
"testing"
)
const journalSentinel = "SUPER_SECRET_SENTINEL"
func TestSanitizeLogTextRemovesMachineTokenFromUrl(t *testing.T) {
raw := `Aug 27 12:00:01 host hysteria[123]: {"level":"error","msg":"auth backend error: ` +
`Post \"http://127.0.0.1:8080/internal/hysteria/auth?access_token=` + journalSentinel + `\": EOF"}`
sanitized := SanitizeLogText(raw)
if strings.Contains(sanitized, journalSentinel) {
t.Fatalf("machine token остался в журнале: %s", sanitized)
}
// Адрес обязан остаться читаемым: диагностика теряет смысл, если из строки
// пропадает host и path.
if !strings.Contains(sanitized, "127.0.0.1:8080/internal/hysteria/auth") {
t.Fatalf("санитайз съел адрес целиком: %s", sanitized)
}
}
func TestSanitizeLogTextRemovesBareAssignments(t *testing.T) {
cases := []string{
"connecting with access_token=" + journalSentinel,
"trafficStats secret=" + journalSentinel + " rejected",
"obfs password: " + journalSentinel,
"Authorization=" + journalSentinel,
}
for _, raw := range cases {
sanitized := SanitizeLogText(raw)
if strings.Contains(sanitized, journalSentinel) {
t.Errorf("секрет остался в строке %q: %s", raw, sanitized)
}
}
}
func TestSanitizeLogTextKeepsOrdinaryLines(t *testing.T) {
raw := "server up and running, listening on 0.0.0.0:443"
if sanitized := SanitizeLogText(raw); sanitized != raw {
t.Fatalf("обычная строка изменена: %q -> %q", raw, sanitized)
}
}
// Хвостовая пунктуация не является частью адреса.
func TestSanitizeLogTextKeepsTrailingPunctuation(t *testing.T) {
raw := "failed to call http://127.0.0.1:8080/internal/hysteria/auth?access_token=" + journalSentinel + "."
sanitized := SanitizeLogText(raw)
if strings.Contains(sanitized, journalSentinel) {
t.Fatalf("machine token остался: %s", sanitized)
}
if !strings.HasSuffix(sanitized, ".") {
t.Fatalf("потеряна хвостовая пунктуация: %s", sanitized)
}
}
// Двойной проход не должен ломать уже вырезанное значение.
func TestSanitizeLogTextIsIdempotent(t *testing.T) {
raw := "http://127.0.0.1:8080/internal/hysteria/auth?access_token=" + journalSentinel
once := SanitizeLogText(raw)
twice := SanitizeLogText(once)
if once != twice {
t.Fatalf("санитайз не идемпотентен: %q -> %q", once, twice)
}
}
func TestSanitizeLogTextEmptyInput(t *testing.T) {
if SanitizeLogText("") != "" {
t.Fatal("пустой вход должен оставаться пустым")
}
}
+404 -96
View File
@@ -46,12 +46,46 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) {
return result, total, nil
}
// Пир установщика защищён во ВСЕХ путях записи, а не только в импорте.
//
// Что было. ReservedBootstrapPeerName проверялся исключительно в
// applyPeerImportEntry. Обычный CRUD панели такой проверки не имел, то есть
// ровно то, ради чего guard существует, делалось через интерфейс: секрет
// bootstrap-admin-peer продублирован в /etc/hy2xs/bootstrap-admin.secret,
// откуда его читает проверка machine-auth оркестратора, и смена секрета через
// форму молча рассинхронизировала бы файл с базой. Дверей в таблицу было две,
// а замок висел на одной.
//
// Границы намеренно разные для разных операций:
//
// идентичность и секрет — запрещены: они и есть то, что дублируется в файле;
// удаление и отключение — разрешены: это осознанные действия оператора,
// результат которых виден в списке пиров. Bootstrap-пир после установки
// остаётся действующим доступом, и запрет его убрать означал бы вечный
// неотзываемый доступ.
var errBootstrapPeerIdentity = fmt.Errorf(
"пир %q принадлежит установщику: его имя и секрет продублированы в "+
"/etc/hy2xs/bootstrap-admin.secret и не могут быть изменены через панель. "+
"Ненужный bootstrap-пир следует удалить целиком, а не переподписывать",
ReservedBootstrapPeerName,
)
func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
if peerDto.Name == nil || *peerDto.Name == "" {
return vo.PeerVo{}, errors.New(constant.InvalidError)
}
if ExistPeerName(*peerDto.Name, 0) {
return vo.PeerVo{}, errors.New(fmt.Sprintf("name %s already exists", *peerDto.Name))
// Имя зарезервировано за установщиком даже когда сам пир уже удалён:
// иначе после удаления обычный пир мог бы занять это имя и оказаться под
// защитой, предназначенной не ему.
if strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName {
return vo.PeerVo{}, fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
}
taken, err := ExistPeerName(*peerDto.Name, 0)
if err != nil {
return vo.PeerVo{}, err
}
if taken {
return vo.PeerVo{}, fmt.Errorf("name %s already exists", *peerDto.Name)
}
secret := ""
if peerDto.Secret != nil && *peerDto.Secret != "" {
@@ -94,6 +128,10 @@ func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
}
func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
if err := assertBootstrapPeerIdentityUnchanged(id, peerDto); err != nil {
return err
}
updates := map[string]interface{}{}
if peerDto.Name != nil && *peerDto.Name != "" {
updates["name"] = *peerDto.Name
@@ -128,6 +166,59 @@ func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
return dao.UpdatePeer([]int64{id}, updates)
}
// assertBootstrapPeerIdentityUnchanged запрещает менять то, что продублировано
// в /etc/hy2xs/bootstrap-admin.secret, и занимать зарезервированное имя.
func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) error {
// Переименование ЛЮБОГО пира в зарезервированное имя запрещено отдельно от
// проверки цели: UNIQUE(name) закрывает этот путь только пока bootstrap-пир
// существует.
if peerDto.Name != nil && strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName {
existing, err := dao.GetPeer("id = ?", id)
if err != nil {
return err
}
if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName {
return fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
}
}
existing, err := dao.GetPeer("id = ?", id)
if err != nil {
return err
}
if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName {
return nil
}
if peerDto.Name != nil && strings.TrimSpace(*peerDto.Name) != "" &&
strings.TrimSpace(*peerDto.Name) != ReservedBootstrapPeerName {
return errBootstrapPeerIdentity
}
if peerDto.Secret != nil && strings.TrimSpace(*peerDto.Secret) != "" {
return errBootstrapPeerIdentity
}
return nil
}
// DeletePeer удаляет пира, включая bootstrap-пира установщика.
//
// Удаление намеренно НЕ запрещено. Bootstrap-пир после установки — обычный
// действующий доступ, секрет которого лежит ещё и в файле на диске; оператор
// обязан иметь возможность его отозвать. В отличие от смены секрета, удаление
// не создаёт расхождения между базой и файлом: пира просто нет, и это видно в
// списке.
//
// Отзыв при этом ОКОНЧАТЕЛЬНЫЙ, и это свойство обеспечивается не здесь, а
// отметкой constant.BootstrapPeerSeeded в dao.ensureSecureBootstrapPeer. Пока
// признаком «создавать или нет» служило наличие строки в таблице, удаление
// откатывалось первым же перезапуском сервиса: HY2XS_ADMIN_CON_PASS никуда не
// девается из /etc/hy2xs/hy2xs.env, и seeder создавал пира с тем же секретом
// заново. Разрешать удаление имеет смысл только вместе с этим свойством, иначе
// панель предлагает операции, которых не выполняет.
//
// Секрет остаётся в /etc/hy2xs/bootstrap-admin.secret и после удаления. Файлом
// владеет оркестратор, админка его не трогает; после отзыва он содержит уже
// недействующее значение (см. docs/04-admin-panel.md).
func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) }
func GetPeerVo(id int64) (vo.PeerVo, error) {
@@ -173,10 +264,42 @@ func BuildPeerClientConfig(id int64) (vo.PeerClientConfigVo, error) {
return vo.PeerClientConfigVo{Url: url}, nil
}
// ListExportPeer собирает выгрузку пиров в одном из двух режимов.
//
// includeSecrets=false — безопасная выгрузка: шифртекст не трогается вовсе,
// поле secret остаётся пустым, и это ожидаемое содержимое файла.
//
// includeSecrets=true — резервная копия с ДЕЙСТВУЮЩИМИ учётными данными,
// которую оператор запрашивает отдельной кнопкой с подтверждением риска.
// Здесь правило строгое: либо в файл попадают все секреты, либо файла нет.
//
// Что было:
//
// if includeSecrets && item.SecretEncrypted != nil {
// if dec, derr := DecryptPeerSecret(*item.SecretEncrypted); derr == nil {
// ex.Secret = dec
// }
// }
//
// Два молчаливых канала потери. Ошибка расшифровки проглатывалась, и пир
// уезжал в файл с пустым secret; пир без шифртекста — тоже. Запрос при этом
// завершался успехом, и оператор получал файл, выглядящий полным:
//
// [{"name":"A","secret":"..."},
// {"name":"B","secret":""}, <- потерян молча
// {"name":"C","secret":"..."}]
//
// Обнаруживалось это уже после импорта на новом сервере: B получал новый
// сгенерированный секрет, а его клиент — отказ авторизации. Смысл режима в
// том, что пользователь СПЕЦИАЛЬНО выбрал «копия с действующими credentials»;
// частичный результат под этим именем — худший из возможных ответов.
//
// Сообщение называет пира по имени: имена не секретны и уже видны в списке, а
// без имени оператору некуда идти с этой ошибкой.
func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) {
peers, err := dao.ListPeer("1=1")
if err != nil {
return nil, errors.New(constant.SysError)
return nil, err
}
out := make([]bo.PeerExport, 0, len(peers))
for _, item := range peers {
@@ -194,129 +317,314 @@ func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) {
BannedUntil: int64Val(item.BannedUntil),
LastConnectionAt: int64Val(item.LastConnectionAt),
}
if includeSecrets && item.SecretEncrypted != nil {
if dec, derr := DecryptPeerSecret(*item.SecretEncrypted); derr == nil {
ex.Secret = dec
if includeSecrets {
secret, secretErr := exportPeerSecret(item)
if secretErr != nil {
return nil, secretErr
}
ex.Secret = secret
}
out = append(out, ex)
}
return out, nil
}
func UpsertPeerExport(items []bo.PeerExport) error {
// exportPeerSecret возвращает действующий секрет пира или объясняет, почему
// резервная копия невозможна.
func exportPeerSecret(peer entity.Peer) (string, error) {
name := strVal(peer.Name)
if name == "" {
name = fmt.Sprintf("id=%d", int64Val(peer.Id))
}
if peer.SecretEncrypted == nil || strings.TrimSpace(*peer.SecretEncrypted) == "" {
return "", fmt.Errorf(
"резервная копия с учётными данными не создана: у пира %q нет сохранённого секрета. "+
"Файл не сформирован целиком, чтобы копия не выглядела полной. "+
"Пересоздайте секрет этого пира или выгрузите список без учётных данных",
name,
)
}
decrypted, err := DecryptPeerSecret(*peer.SecretEncrypted)
if err != nil {
return "", fmt.Errorf(
"резервная копия с учётными данными не создана: не удалось расшифровать секрет пира %q. "+
"Обычная причина — подменённый или потерянный PEER_SECRET_ENCRYPTION_KEY. "+
"Файл не сформирован целиком, чтобы копия не выглядела полной",
name,
)
}
return decrypted, nil
}
// preparedPeerImport — запись импорта со всем криптоматериалом, посчитанным
// заранее.
//
// Крипто выносится ИЗ транзакции сознательно. PeerSecretDigest и
// EncryptPeerSecret читают ключи из таблицы `config`, то есть ходят в ту же
// базу; делать это, удерживая открытую запись, значит без нужды держать
// блокировку на время AES по каждой из тысяч записей. Внутри транзакции должна
// остаться только работа с таблицей пиров.
type preparedPeerImport struct {
source bo.PeerExport
name string
authID string
remark string
quota int64
expires int64
maxDevices int64
disabled int64
// Задан, только если секрет пришёл в файле: у существующего пира секрет
// перезаписывается лишь в этом случае.
hasExplicitSecret bool
explicitDigest string
explicitCipher string
// Готовятся всегда: понадобятся, если запись окажется новой.
createDigest string
createCipher string
createAuthID string
}
func preparePeerImport(items []bo.PeerExport) ([]preparedPeerImport, error) {
prepared := make([]preparedPeerImport, 0, len(items))
for _, item := range items {
name := strings.TrimSpace(item.Name)
if name == "" {
return errors.New(constant.InvalidError)
}
var existing entity.Peer
var err error
authID := strings.TrimSpace(item.AuthId)
if authID != "" {
existing, err = dao.GetPeer("auth_id = ?", authID)
}
if (err != nil || existing.Id == nil) && name != "" {
existing, err = dao.GetPeer("name = ?", name)
}
quota := item.QuotaBytes
expires := item.ExpiresAt
maxDevices := item.MaxDevices
if maxDevices <= 0 {
maxDevices = 3
}
disabled := item.Disabled
remark := item.Remark
if err == nil && existing.Id != nil {
updates := map[string]interface{}{
"name": name,
"remark": remark,
"quota_bytes": quota,
"download_bytes": item.DownloadBytes,
"upload_bytes": item.UploadBytes,
"expires_at": expires,
"max_devices": maxDevices,
"disabled": disabled,
"banned_until": item.BannedUntil,
"last_connection_at": item.LastConnectionAt,
}
if authID != "" {
updates["auth_id"] = authID
}
if strings.TrimSpace(item.Secret) != "" {
digest, derr := PeerSecretDigest(item.Secret)
if derr != nil {
return derr
}
enc, eerr := EncryptPeerSecret(item.Secret)
if eerr != nil {
return eerr
}
updates["secret_digest"] = digest
updates["secret_ciphertext"] = enc
}
if uerr := dao.UpdatePeer([]int64{*existing.Id}, updates); uerr != nil {
return uerr
}
continue
entry := preparedPeerImport{
source: item,
name: name,
authID: authID,
remark: item.Remark,
quota: item.QuotaBytes,
expires: item.ExpiresAt,
maxDevices: maxDevices,
disabled: item.Disabled,
}
createSecret := strings.TrimSpace(item.Secret)
if createSecret == "" {
generated, gerr := util.RandomString(24)
if gerr != nil {
return gerr
}
createSecret = fmt.Sprintf("%s.%s", name, generated)
}
secretDigest, derr := PeerSecretDigest(createSecret)
if derr != nil {
return derr
}
secretEncrypted, eerr := EncryptPeerSecret(createSecret)
if eerr != nil {
return eerr
}
if authID == "" {
authID, err = util.RandomString(18)
explicitSecret := strings.TrimSpace(item.Secret)
if explicitSecret != "" {
digest, err := PeerSecretDigest(explicitSecret)
if err != nil {
return nil, err
}
cipher, err := EncryptPeerSecret(explicitSecret)
if err != nil {
return nil, err
}
entry.hasExplicitSecret = true
entry.explicitDigest = digest
entry.explicitCipher = cipher
entry.createDigest = digest
entry.createCipher = cipher
} else {
generated, err := util.RandomString(24)
if err != nil {
return nil, err
}
createSecret := fmt.Sprintf("%s.%s", name, generated)
digest, err := PeerSecretDigest(createSecret)
if err != nil {
return nil, err
}
cipher, err := EncryptPeerSecret(createSecret)
if err != nil {
return nil, err
}
entry.createDigest = digest
entry.createCipher = cipher
}
entry.createAuthID = authID
if entry.createAuthID == "" {
generated, err := util.RandomString(18)
if err != nil {
return nil, err
}
entry.createAuthID = generated
}
prepared = append(prepared, entry)
}
return prepared, nil
}
// UpsertPeerExport применяет выгрузку пиров целиком или не применяет вовсе.
//
// Три прохода, и каждый отвечает за своё:
//
// 1. ValidatePeerImportBatch — содержимое файла, без обращения к базе;
// 2. preparePeerImport — весь криптоматериал, без обращения к таблице пиров;
// 3. одна транзакция — только записи.
//
// Раньше третьего прохода не существовало: записи шли по одной, каждая своим
// оператором. Комментарий обещал «либо целиком, либо никак», но 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 UpsertPeerExport(items []bo.PeerExport) error {
if err := ValidatePeerImportBatch(items); err != nil {
return err
}
prepared, err := preparePeerImport(items)
if err != nil {
return err
}
return dao.WithPeerTx(func(tx dao.PeerTx) error {
for _, entry := range prepared {
if err := applyPeerImportEntry(tx, entry); err != nil {
return err
}
}
peer := entity.Peer{
Name: &name,
Remark: &remark,
AuthId: &authID,
SecretDigest: &secretDigest,
SecretEncrypted: &secretEncrypted,
QuotaBytes: &quota,
DownloadBytes: &item.DownloadBytes,
UploadBytes: &item.UploadBytes,
ExpiresAt: &expires,
MaxDevices: &maxDevices,
Disabled: &disabled,
BannedUntil: &item.BannedUntil,
LastConnectionAt: &item.LastConnectionAt,
}
if _, serr := dao.SavePeer(peer); serr != nil {
return serr
}
}
return nil
return nil
})
}
func ExistPeerName(name string, id int64) bool {
func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) error {
existing, found, err := findPeerForImport(tx, entry)
if err != nil {
return err
}
// Пир установщика не переопределяется импортом ни при каком совпадении:
// его секрет живёт ещё и в /etc/hy2xs/bootstrap-admin.secret.
if found && existing.Name != nil && *existing.Name == ReservedBootstrapPeerName {
return fmt.Errorf(
"peer import: пир %q принадлежит установщику и не может быть изменён импортом",
ReservedBootstrapPeerName,
)
}
if found {
updates := map[string]interface{}{
"name": entry.name,
"remark": entry.remark,
"quota_bytes": entry.quota,
"download_bytes": entry.source.DownloadBytes,
"upload_bytes": entry.source.UploadBytes,
"expires_at": entry.expires,
"max_devices": entry.maxDevices,
"disabled": entry.disabled,
"banned_until": entry.source.BannedUntil,
"last_connection_at": entry.source.LastConnectionAt,
}
if entry.authID != "" {
updates["auth_id"] = entry.authID
}
if entry.hasExplicitSecret {
updates["secret_digest"] = entry.explicitDigest
updates["secret_ciphertext"] = entry.explicitCipher
}
return tx.UpdatePeer([]int64{*existing.Id}, updates)
}
name := entry.name
remark := entry.remark
authID := entry.createAuthID
digest := entry.createDigest
cipher := entry.createCipher
quota := entry.quota
expires := entry.expires
maxDevices := entry.maxDevices
disabled := entry.disabled
download := entry.source.DownloadBytes
upload := entry.source.UploadBytes
bannedUntil := entry.source.BannedUntil
lastConnection := entry.source.LastConnectionAt
peer := entity.Peer{
Name: &name,
Remark: &remark,
AuthId: &authID,
SecretDigest: &digest,
SecretEncrypted: &cipher,
QuotaBytes: &quota,
DownloadBytes: &download,
UploadBytes: &upload,
ExpiresAt: &expires,
MaxDevices: &maxDevices,
Disabled: &disabled,
BannedUntil: &bannedUntil,
LastConnectionAt: &lastConnection,
}
_, saveErr := tx.SavePeer(peer)
return saveErr
}
// findPeerForImport ищет запись, которую импорт должен обновить.
//
// Возвращает три состояния, а не два: найдено, не найдено, неизвестно. Раньше
// последних двух не существовало — код выглядел так:
//
// existing, err = tx.GetPeer("auth_id = ?", entry.authID)
// if err != nil || existing.Id == nil {
// existing, err = tx.GetPeer("name = ?", entry.name)
// }
//
// то есть отказ SQLite на первом запросе трактовался как «по auth_id ничего
// нет» и запускал поиск по имени; отказ на втором — как «такого пира нет
// вовсе», и запись СОЗДАВАЛАСЬ. При недоступной базе импорт не отказывал, а
// пытался наплодить дубликаты; целостность спасала транзакция, но решение
// принималось на несуществующих данных.
//
// Порядок поиска сохранён: authId — устойчивый идентификатор, имя — запасной
// ключ для файлов, выгруженных без него.
func findPeerForImport(tx dao.PeerTx, entry preparedPeerImport) (entity.Peer, bool, error) {
if entry.authID != "" {
peer, err := tx.GetPeer("auth_id = ?", entry.authID)
switch {
case err == nil:
return peer, true, nil
case !errors.Is(err, dao.ErrPeerNotFound):
return entity.Peer{}, false, err
}
}
peer, err := tx.GetPeer("name = ?", entry.name)
switch {
case err == nil:
return peer, true, nil
case errors.Is(err, dao.ErrPeerNotFound):
return entity.Peer{}, false, nil
default:
return entity.Peer{}, false, err
}
}
// ExistPeerName отвечает «имя занято» и отказывает, когда ответить не может.
//
// Прежняя реализация возвращала `err == nil`, то есть любой отказ хранилища
// означал «имя свободно». Проверка уникальности, которая при недоступной базе
// отвечает «можно», ничего не проверяет.
func ExistPeerName(name string, id int64) (bool, error) {
var err error
if id != 0 {
_, err = dao.GetPeer("name = ? and id != ?", name, id)
} else {
_, err = dao.GetPeer("name = ?", name)
}
return err == nil
switch {
case err == nil:
return true, nil
case errors.Is(err, dao.ErrPeerNotFound):
return false, nil
default:
return false, err
}
}
func UpdatePeerLastConnectionAt(id int64, conAt int64) error {

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