fix(admin): дать отзыву доступа вторую попытку, а лимиту устройств — порядок снимков

Предыдущий проход сделал правильным порядок «сначала долговременная запись,
потом разрыв сессии» и правильно запретил откат при неудаче разрыва. Способа
прийти к согласованному состоянию ПОТОМ он не дал: у двух операций повтор не
работал вовсе.

Импорт, заменивший auth_id: после неудавшегося /kick старое значение не
хранится нигде, повтор того же файла читает из базы уже новое и рвёт его, а
cron пропускал незнакомый authID молча — dao.ListPeer просто не возвращала
строку. Живая сессия оставалась навсегда.

Снижение maxDevices: повтор формы даёт 1 < 1 -> false, разрыва больше нет.
Лимит устройств в политику доступа не входит и входить не должен — это
свойство сессий, — поэтому механизма схождения у него не было.

enforcePeerAccess стал сверкой живых сессий: обход идёт по каждому authID из
/online. Нет строки в базе -> kick; peerAccessDenied -> kick; непригодный
maxDevices -> kick; устройств больше разрешённого -> kick. Отказ базы при этом
не рвёт ничего. Ни таблицы отложенных операций, ни очереди retry: список живых
сессий уже есть, и это /online.

Отдельно закрыт второй TOCTOU лимита устройств. Учёт выданных разрешений
закрыл сравнение двух одинаковых снимков, но сетевой запрос выполнялся вне
блокировки, поэтому снимки приходили в резервацию в произвольном порядке и
устаревший откатывал lastOnline назад, возвращая уже занятое место. Это не
data race — память защищена мьютексом, и -race здесь молчит принципиально.
Последовательность «прочитать /online -> занять место» выполняется под замком
по authId; глобальный замок не годится, внутри идёт сетевой запрос.

Учёт разрешений больше не растёт бесконечно: запись снималась только на ветке
отказа, поэтому в карте копились удалённые пиры и переписанные импортом
идентификаторы. Уборка идёт по фактической картине подключений.

Гейты приёмки доращены под все три инварианта и проверены в обе стороны.
Go 1.26.7 -> 1.26.8. Документация приведена в соответствие в двух местах,
где описывала снятую архитектуру.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md
This commit is contained in:
2026-09-02 07:15:43 +05:00
parent 6d1686b2be
commit 8dcb50a07c
16 changed files with 1511 additions and 104 deletions
@@ -0,0 +1,274 @@
# Разбор кода перед сборкой `1.0.0-rc3`
Источник — не прогон на хосте, а разбор дерева на коммите `6d1686b8` и повторная
сверка Hysteria-интеграции с официальной документацией Hysteria 2. Разбор
проводился по состоянию **после** предыдущего прохода
([2026-09-01-v1.0.0-rc2-preflight-findings.md](2026-09-01-v1.0.0-rc2-preflight-findings.md)),
то есть проверялись выводы уже принятых исправлений, а не исходное состояние.
Тема прохода — **вторая попытка**. Предыдущий проход сделал правильным порядок
«сначала долговременная запись, потом разрыв сессии» и правильно запретил откат
при неудаче разрыва. Но способа прийти к согласованному состоянию **потом** он
не дал: у двух операций повтор не работал вовсе, и их частичный результат
оставался навсегда.
Проверить на живом сервере ещё предстоит: список ручных проверок — в конце
документа.
## Сводка
| ID | Дефект | Приоритет | Статус |
| --- | --- | --- | --- |
| ADMIT-04 | Устаревший снимок `/online` возвращал уже занятое место | P0 | закрыт |
| RECON-01 | Живая сессия без строки в базе не завершалась никогда | P0 | закрыт |
| RECON-02 | Снижение `maxDevices` после неудачного `/kick` не имело второй попытки | P1 | закрыт |
| ADMIT-05 | Учёт выданных разрешений рос неограниченно | P2 | закрыт |
| GATE-02 | Гейты приёмки не покрывали ни одного из новых инвариантов | P1 | закрыт |
| DOC-02 | Два места документации описывали снятую архитектуру | P3 | закрыт |
| VER-01 | `GO_VERSION` отставала на patch-релиз | P3 | закрыт |
ADMIT-04 и RECON-01 найдены внешним разбором; RECON-02, ADMIT-05, GATE-02 и
вторая половина DOC-02 — при проверке его выводов по коду.
---
## ADMIT-04 — устаревший снимок `/online` возвращал уже занятое место
**Наблюдалось только рассуждением:** тестами это состояние не воспроизводилось,
а `go test -race` был и остаётся зелёным.
Предыдущий проход закрыл сравнение двух ОДИНАКОВЫХ снимков: появился
process-local учёт выданных, но ещё не проявившихся разрешений
(`apps/service/peer_admission.go`). Сетевой запрос при этом по-прежнему
выполнялся **вне** блокировки — сознательно, чтобы не сериализовать подключения
всех пиров через один HTTP-обмен, — и это оставило вторую половину гонки
открытой: снимки приходили в резервацию в произвольном порядке.
```text
1. A получает разрешение при /online = 0; pending = [A], lastOnline = 0
2. B читает /online = 0 и задерживается на обратном пути
3. A подключается — Hysteria показывает /online = 1
4. C читает /online = 1 и первым входит в резервацию:
online > lastOnline -> разрешение A признано проявившимся
lastOnline = 1, C получает отказ (верно)
5. B входит со своим устаревшим online = 0
6. `online > lastOnline` ложно; следом безусловное lastOnline = online
7. lastOnline = 0, pending пуст -> 0 < 1 -> B ДОПУЩЕН
```
При `maxDevices = 1` подключений становится два — ровно тем способом, от
которого лимит и должен защищать.
**Это не data race.** Все обращения к памяти корректно защищены мьютексом,
поэтому детектор гонок здесь молчит принципиально, и «`-race` зелёный» не
является свидетельством. Доказать свойство может только семантический тест.
**Исправление.** Последовательность «прочитать `/online` → занять место»
выполняется под замком **по `authId`** (`lockPeerAdmission`). Не глобальный
мьютекс вокруг сети: внутри замка идёт HTTP-обмен, и общий замок выстроил бы
подключения всех пиров в одну очередь. Конкурируют только авторизации одного
пира, а их упорядоченность и есть требуемое свойство — снимок, прочитанный под
замком, не может оказаться старше уже обработанного. Время удержания ограничено
сверху таймаутом обращения к Traffic Stats API (3 с).
Учёт разрешений при этом остаётся нужен: сериализация не устраняет задержку
между ответом `allow` и появлением клиента в `/online`. Механизмы закрывают
разные половины и работают вместе.
Карта замков не растёт: запись живёт ровно столько, сколько есть желающие её
взять (счётчик ссылок).
**Закреплено:** `TestHysteria2AuthRejectsStaleOnlineSnapshot` — воспроизводит
последовательность выше через удержание конкретного ответа `/online`; проверено,
что тест падает на коде без замка. Плюс `TestPeerAdmissionGate*` — исключение
одного `authId`, отсутствие сериализации разных, отсутствие утечки записей.
---
## RECON-01 — живая сессия без строки в базе не завершалась никогда
**Корневая причина — форма обхода в cron:**
```go
peers, err := dao.ListPeer("auth_id in ?", chunk)
for _, peer := range peers { ... }
```
Обход шёл по НАЙДЕННЫМ строкам, поэтому `authId`, которому в базе ничего не
соответствует, молча выпадал. А именно он и остаётся единственным следом сессии
после неудавшегося второго шага:
```text
DB: old-auth
импорт old-auth -> new-auth, COMMIT прошёл
/kick old-auth -> 500
```
Повторить операцию в этом состоянии **невозможно**: `applyPeerImportEntry`
читает `replaced := authIDOf(existing)`, то есть повтор того же файла найдёт в
базе уже `new-auth` и разорвёт ЕГО. Старое значение после первой неудачи не
хранится нигде. Восстановить состояние переподключением тоже нельзя —
авторизация нового значения не знает, — а старая QUIC-сессия живёт своей жизнью
сколь угодно долго. То же самое даёт удаление пира, у которого не удался `/kick`
и следом всё-таки прошло удаление строки.
Это ровно тот класс stale session, от которого весь предыдущий проход и должен
был защитить.
**Исправление.** `enforcePeerAccess` обходит каждый `authId` из `/online`, а не
строки выборки. Идентификатор без строки в базе — orphan-сессия, и правильный
исход у неё тот же, что и у первой попытки: `/kick`.
**Отдельно проверено, что отказ базы не рвёт сессии.** «Пира нет» и «прочитать
не удалось» — разные ответы; трактовка второго как первого отключила бы всех
подключённых пиров сразу при недоступной SQLite. Ошибка выборки прекращает цикл
до единого обращения к `/kick`.
**Закреплено:** `TestCronKicksSessionWithoutPeerRow`,
`TestCronReconcilesSessionAfterFailedImportKick` (end-to-end: импорт `old→new`,
`/kick` 500, затем обычный цикл учёта рвёт `old`),
`TestCronSendsNoKickWhenPeerLookupFails`.
---
## RECON-02 — снижение `maxDevices` после неудачного `/kick` не имело второй попытки
```text
maxDevices: 5 -> 1
DB update: OK
/kick: FAIL
```
Оператор повторяет сохранение формы. Панель при правке отправляет все поля,
включая `maxDevices`, но условие разрыва сравнивает
```go
*peerDto.MaxDevices < *before.MaxDevices
```
то есть `1 < 1``false`. Второй `PATCH` возвращает успех, `/kick` больше не
вызывается, и пять подключённых клиентов продолжают работать при лимите 1.
Для `disabled` повторяемость сделана специально (условие смотрит на
ЗАПРОШЕННОЕ состояние, а не на переход), для квоты и срока её обеспечивает cron
через `peerAccessDenied`. Лимит устройств в политику доступа не входит и входить
не должен — это свойство сессий, а не хранимого состояния пира, — поэтому
механизма схождения у него не было вовсе.
**Исправление — не в условии `updateRequiresReconcile`.** Делать разрыв при
каждом сохранении формы нельзя: тогда любая правка пометки рвала бы сессии.
Схождение обеспечивает та же сверка живых сессий: `онлайн-устройств >
maxDevices``/kick`. Побочно это лечит и любое другое случайное превышение
лимита.
**Число устройств взято из upstream-контракта, а не из предположения.**
Официальная документация Traffic Stats API: `/online` возвращает «*the number of
Hysteria client instances ("devices"), NOT the number of active proxy
connections*». Сравнение с `maxDevices` корректно.
Предикат `peerSessionNeedsReconcile` **не является вторым экземпляром политики
доступа**: `disabled`, квота, срок и блокировка остаются целиком за
`peerAccessDenied`, и предикат его вызывает, а не повторяет.
**Закреплено:** `TestCronKicksWhenOnlineExceedsMaxDevices`,
`TestCronDoesNotKickAtExactDeviceLimit` (граница),
`TestCronKicksPeerWithUnusableMaxDevices`,
`TestCronReconcilesSessionAfterFailedMaxDevicesReduction` (end-to-end).
---
## ADMIT-05 — учёт выданных разрешений рос неограниченно
`forgetIfIdle` вызывался **только на ветке отказа**. После успешной выдачи
запись оставалась с непустым списком разрешений, а когда разрешение протухало,
снять её было уже некому: следующего обращения к этому `authId` могло не быть
никогда. В карте копились удалённые пиры и старые идентификаторы, переписанные
импортом, — за время жизни процесса она только росла.
**Исправление.** Уборка идёт по ФАКТИЧЕСКОЙ картине подключений в цикле учёта —
единственном месте продукта, где она известна целиком. Решение принимается не по
хранимому `lastOnline`: тот обновляется только на пути авторизации и у
отключившегося пира остаётся прежним сколь угодно долго.
Удаление записи без действующих разрешений и без подключений не меняет ни одного
будущего решения: следующая резервация начнёт с чистой записи и придёт к тому же
ответу.
**Закреплено:** `TestSweepDeviceAdmissionsForgetsIdlePeers`,
`TestSweepDeviceAdmissionsKeepsOnlinePeers`,
`TestCronSweepsAdmissionsOfOfflinePeers`.
---
## GATE-02 — гейты приёмки не покрывали новых инвариантов
`run_access_revocation_acceptance` проверял наличие `reserveDeviceSlot` и
`reconcileLiveSessions`, но ни порядок «замок → чтение `/online`», ни форму
обхода в cron. То есть исправления ADMIT-04 и RECON-01 можно было бы снять
следующим проходом, не уронив сборку.
Добавлены три гейта:
1. авторизация берёт замок **до** чтения `/online` и ключует его `*peer.AuthId`
(литеральный ключ означал бы один замок на процесс);
2. `enforcePeerAccess` обходит `authIDs` из `/online`, ветка `!found`
**завершает** сессию, а не пропускает её, отказ выборки прекращает цикл до
`/kick`, и уборка учёта вызывается;
3. `peerSessionNeedsReconcile` вызывает `peerAccessDenied` и не упоминает ни
одного поля политики доступа самостоятельно.
Каждый гейт проверен в обе стороны: он проходит на исправленном коде и падает на
восстановленном состоянии «до».
---
## DOC-02 — документация описывала снятую архитектуру
Два места, а не одно:
1. `docs/testing/11-3-target-and-runtime.md:40` — «traffic accounting/kick
ориентируются на systemd status». Прямо противоположно реализации после
предыдущего прохода и остальной документации;
2. `docs/admin/04-admin-panel.md` — «GET /online (вне блокировки: сеть не должна
сериализовать все подключения)». Верно описывало прежнее устройство и стало
ложным вместе с исправлением ADMIT-04.
Второе найдено при закрытии первого: документация здесь входит в
acceptance-контракт, поэтому расхождение — не косметика.
---
## VER-01 — `GO_VERSION` отставала на patch-релиз
`GO_VERSION=1.26.7`; 1.26.8 вышел 2026-09-01 (fixes в cgo, компиляторе, runtime,
`debug/elf` и `os`). Stdlib целиком попадает в production-бинарь, поэтому «на
один патч позади» — свойство выпускаемого артефакта, а не среды сборки.
Не архитектурный blocker и не security emergency; закрыто вместе с остальным,
раз проход всё равно затрагивает контракт версий. Major не менялся: линия 1.26
поддерживается, переход на 1.27 ради номера не нужен.
Обновлены `GO_VERSION`, `GO_LINUX_AMD64_SHA256` и `toolchain` в `apps/go.mod`
расхождение между ними роняет сборку на `verify_go_toolchain_contract`.
Контрольная сумма взята из `https://go.dev/dl/?mode=json&include=all` и сверена
повторным независимым запросом.
---
## Что осталось проверить на живом хосте
Автоматика доказывает логику; следующие свойства наблюдаемы только на реальном
сервере с Hysteria и настоящими клиентами:
1. подключить `maxDevices + 1` устройств одновременно и убедиться, что принято
ровно `maxDevices`;
2. снизить `maxDevices` при нескольких подключённых устройствах, оборвав
Traffic Stats API на время операции, и убедиться, что следующий цикл учёта
(не позднее 30 с) разрывает сессии;
3. импортировать пира с новым `auth_id`, оборвав `/kick`, и убедиться, что
старая сессия завершается следующим циклом;
4. удалить пира с оборванным `/kick`, затем восстановить API и убедиться, что
повтор удаления проходит целиком;
5. убедиться, что при недоступной SQLite (симуляция) ни одна сессия не рвётся;
6. `go test -race ./service/...` на релизном билдере: локально проверка
невыполнима, C-компилятора на машине разработчика нет.
+1
View File
@@ -41,3 +41,4 @@
| Дата | Основание | Находки |
| --- | --- | --- |
| 2026-09-01 | коммит `c0a43ae9`, сверка с Hysteria 2 и Element Plus | [UX-06…UX-10, LOG-01…LOG-05, AUTH-01/02, CORE-01/02, TYPE-01](2026-09-01-v1.0.0-rc2-preflight-findings.md) |
| 2026-09-02 | коммит `6d1686b8`, повторная сверка с Hysteria 2 | [ADMIT-04/05, RECON-01/02, GATE-02, DOC-02, VER-01](2026-09-02-v1.0.0-rc3-preflight-findings.md) |
+122 -9
View File
@@ -694,6 +694,25 @@ access-changing», то есть второго места, где полити
отвечает кодом `peer_disconnect_failed`, панель показывает его предупреждением
и обновляет список.
**И не оставляет систему в этом состоянии навсегда.** Сообщить оператору о
частичном результате недостаточно: повторить второй шаг он может не всегда.
```text
операция повтор той же операции после неудачного /kick
───────────────────────────────────────────────────────────────
disabled = 1 работает: условие смотрит на ЗАПРОШЕННОЕ состояние
удаление работает: строка осталась с disabled = 1
блокировка работает: cron видит banned_until через peerAccessDenied
квота / срок работает: cron видит их через peerAccessDenied
maxDevices ↓ НЕ работает: 1 < 1 -> false, разрыва больше не будет
импорт old→new НЕ работает: в базе уже new, повтор разорвёт ЕГО
```
Две нижние строки не имели механизма схождения вовсе, и обе закрывает cron —
см. «Сверка живых сессий» ниже. Отдельной таблицы retry, очереди отложенных
операций и хранимого «списка того, что не удалось разорвать» для этого не
нужно: `/online` и есть список живых сессий.
Формулировка сообщения **не называет конкретную операцию**: через этот код
отчитываются все восемь строк таблицы выше, а для удалённого пира фраза «новые
подключения пира запрещены» была бы просто бессмысленной.
@@ -728,7 +747,7 @@ TryLock (пропустить тик, если предыдущий ещё ид
GET /traffic?clear=1 → записать дельты в счётчики пиров
GET /online → применить peerAccessDenied → POST /kick
GET /online → сверить живые сессии → POST /kick
```
Порядок обязателен: enforcement принимает решение по счётчикам, значит счётчики
@@ -750,6 +769,47 @@ Hysteria обнуляются сразу после отправки ответ
не вводится. Квота здесь — операционный предел доступа, а не учёт с финансово
значимым каждым байтом.
#### Сверка живых сессий
Cron обходит **каждый `authId`, который Hysteria считает живым**, а не тех
пиров, которых удалось найти в базе. Разница между этими двумя формулировками и
есть то, что делает частичный результат обратимым.
```text
для каждого authId из GET /online:
выборка пиров не удалась → не рвать НИЧЕГО (цикл прекращается)
строки в базе нет → /kick (пир удалён либо переподписан)
peerAccessDenied → /kick (disabled / квота / срок / блокировка)
maxDevices непригоден → /kick (повреждённая граница — не «безлимит»)
устройств > maxDevices → /kick (лимит снижен, сессии остались)
```
Прежний обход выглядел как `ListPeer("auth_id in ?") → range peers`, поэтому
идентификатор, которому в базе ничего не соответствует, **молча выпадал**. А
именно он и остаётся единственным следом сессии после неудачного второго шага
удаления или импорта: `auth_id` в строке уже заменён либо строки нет вовсе, и
восстановить состояние переподключением невозможно — авторизация нового
значения не знает, а старая сессия живёт своей жизнью.
**Отказ базы не является основанием рвать сессии.** «Пира нет» и «прочитать не
удалось» — разные ответы, и трактовать второй как первый значит отключить всех
подключённых пиров сразу при недоступной SQLite. Ошибка выборки прекращает
цикл до единого обращения к `/kick`.
**Число устройств берётся из upstream-контракта, а не из предположения.**
`GET /online` по официальной документации Traffic Stats API возвращает
количество экземпляров клиента Hysteria («устройства»), а не число proxy-потоков.
Предикат живых сессий (`peerSessionNeedsReconcile`) **не является вторым
экземпляром политики доступа**: `disabled`, квота, срок и блокировка остаются
целиком за `peerAccessDenied`, и предикат его вызывает, а не повторяет. Своего
у него ровно одно — инвариант, которого в хранимом состоянии пира нет: сколько
устройств сейчас на связи.
Побочное следствие того же обхода — уборка учёта выданных разрешений: цикл
учёта единственный в продукте знает фактическую картину подключений целиком.
### Ограничение устройств проверяется fail-closed
`maxDevices` проверяется по `/online` Traffic Stats API, который возвращает
@@ -793,11 +853,13 @@ A: 2 < 3 -> allow B: 2 < 3 -> allow
```text
1. обычная проверка политики доступа
2. GET /online (вне блокировки: сеть не должна сериализовать все подключения)
3. снять протухшие разрешения
4. рост online означает, что столько же разрешений превратились в подключения
5. решение по сумме: online + выданные разрешения
6. свободно -> занять место и allow; иначе deny
2. взять замок ЭТОГО пира
3. GET /online
4. снять протухшие разрешения
5. рост online означает, что столько же разрешений превратились в подключения
6. решение по сумме: online + выданные разрешения
7. свободно -> занять место и allow; иначе deny
8. отпустить замок
```
Учёт **process-local**: HY2XS — один процесс на одном сервере с Hysteria, и ни
@@ -807,10 +869,46 @@ Redis, ни таблицы в базе, ни распределённых бло
клиента в статистике, а не политика доступа. Если клиент авторизовался и не
подключился, резервация исчезает сама.
#### Снимки `/online` не переупорядочиваются
Учёта разрешений самого по себе оказалось недостаточно, и это отдельный дефект,
а не оттенок предыдущего. Пока сетевой запрос выполнялся **вне** блокировки,
снимки приходили в резервацию в произвольном порядке, и более старый откатывал
учёт назад:
```text
A получил разрешение при online = 0; pending = [A], lastOnline = 0
B прочитал online = 0 и задержался на обратном пути
A подключился — Hysteria показывает online = 1
C прочитал online = 1 и вошёл ПЕРВЫМ:
разрешение A признано проявившимся, lastOnline = 1, C отклонён
B входит со своим устаревшим 0 -> lastOnline снова 0 -> B ДОПУЩЕН
```
При `maxDevices = 1` подключений становилось два. Детектор гонок здесь
бесполезен **принципиально**: вся работа с памятью защищена мьютексом, и гонка
логическая, а не по памяти. Доказать такое свойство может только семантический
тест.
Поэтому последовательность «прочитать `/online` → занять место» выполняется под
замком, и замок этот — **по `authId`, а не один на процесс**. Внутри него идёт
сетевой запрос: общий замок выстроил бы подключения всех пиров в очередь за
одним HTTP-обменом. Конкурируют только авторизации одного и того же пира, а их
упорядоченность и есть требуемое свойство. Время удержания ограничено сверху
таймаутом обращения к Traffic Stats API.
Оба механизма нужны одновременно и закрывают разные половины:
```text
замок по authId — снимки не переупорядочиваются
учёт разрешений — снимок не успевает измениться к следующему запросу
```
Чего механизм не обещает: без обратного вызова от Hysteria «соединение
установлено / не установлено» математически точной системы резервирования не
построить. Он закрывает конкретный и реальный случай — параллельные HTTP-auth
одного процесса — и делает это fail-closed.
построить. Он закрывает конкретные и реальные случаи — параллельные HTTP-auth
одного процесса — и делает это fail-closed. Случайное превышение лимита по
любой другой причине устраняет сверка живых сессий в цикле учёта.
### Что нельзя делать
@@ -829,6 +927,15 @@ Redis, ни таблицы в базе, ни распределённых бло
- обращаться к `/kick` мимо `disconnectAuthIDs`;
- удалять пира, не запомнив его `authId` и не завершив сессию до удаления;
- разрывать сессии импорта до `COMMIT` либо по новым `authId`;
- читать `/online` вне замка пира на пути авторизации: устаревший снимок
возвращает уже занятое место, и детектор гонок этого не показывает;
- заводить один замок авторизации на процесс: внутри него идёт сетевой запрос;
- пропускать в цикле учёта `authId`, которому в базе ничего не соответствует, —
это единственный след сессии после неудавшегося разрыва при удалении и
импорте;
- трактовать отказ базы как «пира нет» и рвать по нему сессии;
- заводить таблицу отложенных операций или очередь retry ради схождения:
список живых сессий уже есть, и это `/online`;
- запускать работу джобы учёта в отсоединённых горутинах: планировщик обязан
её видеть, иначе `StopCron()` вернётся раньше, чем она закончит;
- считать квоту биллинговым учётом: чтение `/traffic?clear=1` деструктивно;
@@ -1012,4 +1119,10 @@ Compatibility-ветка пережила слой совместимости,
21. удаление пира завершает его сессию до того, как исчезнет `authId`
22. импорт разрывает старые сессии после `COMMIT` и по старым `authId`
23. джоба учёта выполняется синхронно, и `StopCron()` её дожидается
24. лимит устройств не превышается параллельными запросами авторизации
24. лимит устройств не превышается параллельными запросами авторизации — в том
числе когда снимки `/online` приходят в обратном порядке
25. цикл учёта сверяет КАЖДУЮ живую сессию из `/online`, а не только тех пиров,
которых удалось найти в базе; отказ базы при этом не рвёт ничего
26. неудавшийся разрыв не оставляет систему в несогласованном состоянии
навсегда: сессия удалённого либо переподписанного пира и превышение
`maxDevices` устраняются очередным циклом учёта
+9 -3
View File
@@ -78,7 +78,7 @@ HY2XS_TARGET_OS=debian
HY2XS_TARGET_OS_VERSION=13
HY2XS_TARGET_ARCH=amd64
GO_VERSION=1.26.7
GO_VERSION=1.26.8
GO_LINUX_AMD64_SHA256=<sha256>
BUN_VERSION=1.3.13
BUN_LINUX_X64_SHA256=<sha256>
@@ -117,8 +117,14 @@ Go здесь не просто сборщик: им компилируется
Цифры, ради которых это записано. На `GO_VERSION=1.21.13` — линия, давно вне
поддержки — `govulncheck ./...` находил **21 вызываемую уязвимость**, из них 17 в
одной только stdlib. После перехода на 1.26.7 и обновления графа зависимостей —
**ноль**.
одной только stdlib. После перехода на линию 1.26 и обновления графа
зависимостей — **ноль**.
По той же причине здесь держится актуальный **patch**-релиз, а не просто
поддерживаемая линия: «на один патч позади» — свойство выпускаемого артефакта,
а не среды сборки. Смена patch-версии затрагивает два места сразу —
`GO_VERSION` с контрольной суммой здесь и `toolchain` в `apps/go.mod`, — и
расхождение между ними роняет сборку на `verify_go_toolchain_contract`.
Node живёт только на build-хосте и в артефакт не попадает, но 20.x достигла EOL,
то есть перестала получать security-обновления, а собирает она код, который
+3 -1
View File
@@ -37,13 +37,15 @@
17. `nft -c -f /etc/nftables.conf` проходит после apply
18. пароль admin и `con_pass` не перезаписываются при рестарте `hy2xs-admin`
19. остановка/рестарт UI не останавливает `hysteria-server`
20. traffic accounting/kick ориентируются на systemd status; ключа `HYSTERIA2_ENABLE` в базе больше нет
20. traffic accounting/kick обращаются к Traffic Stats API напрямую и **не** используют systemd status как гейт принятия решений; ключа `HYSTERIA2_ENABLE` в базе больше нет
21. `/etc/hysteria/config.yaml` имеет `0640 hysteria:hy2xs-admin`
22. `hy2xs-admin` может читать `/etc/hysteria/config.yaml`, но не может писать
23. смена расписания сброса трафика применяется **без** перезапуска `hy2xs-admin`, и число джоб планировщика не растёт
24. невалидное cron-выражение отклоняется API, а значение в базе не меняется
25. `systemctl restart hy2xs-admin` завершает сервис штатно: планировщик остановлен до закрытия SQLite, в журнале нет `database is closed`
26. `govulncheck ./...` на графе релиза не находит вызываемых уязвимостей
27. живая сессия, которой в базе больше ничего не соответствует (пир удалён либо его `auth_id` заменён импортом, а разрыв в тот момент не удался), завершается очередным циклом учёта — не позднее 30 секунд
28. превышение `maxDevices` живыми сессиями устраняется тем же циклом: после неудавшегося разрыва при снижении лимита повтор формы даёт успех без `/kick`, и единственный механизм схождения здесь — cron
## C1. Семантический smoke конфига