fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному

Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним,
поэтому сессия, установленная по отозванным учётным данным, была неотличима от
законной, и цикл учёта не имел признака, по которому её следовало завершить. У
состояния есть путь без единой неудачи — Hysteria регистрирует соединение в
Traffic Stats API только после возврата backend-auth, поэтому успешный /kick
может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт
по старому, пережившая сессия становится orphan.

Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4,
админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала
лимит устройств, учёт трафика и принудительное отключение разом. Адрес
зафиксирован, а расхождение файла с ним админка называет.

Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при
ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним
значением, а доступность Traffic Stats API выводилась из него же. Журнал
Hysteria разбирается в фактическом формате upstream (time — дробное число),
страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в
браузер, санитайзер выгрузки следует по YAML-якорям.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
This commit is contained in:
2026-09-02 23:24:01 +05:00
parent 8dcb50a07c
commit cb20d8d28f
66 changed files with 4981 additions and 2684 deletions
+98 -22
View File
@@ -14,15 +14,33 @@ import (
"hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
"hy2xs-admin/model/vo"
"hy2xs-admin/util"
)
func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) {
// PagePeer отдаёт страницу списка пиров и ЧЕСТНО сообщает, известна ли картина
// подключений.
//
// Что было: `onlineUsers, _ := Hysteria2Online()`. Отказ Traffic Stats API
// молча превращался в пустую карту, и оператор видел «все офлайн» вместо
// «состояние неизвестно». Признак возвращается один на страницу — см.
// vo.PeerPageVo.
func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, string, error) {
peers, total, err := dao.PagePeer(peerPageDto)
if err != nil {
return nil, 0, err
return nil, 0, "", err
}
onlineUsers, _ := Hysteria2Online()
onlineState := vo.PeerOnlineStateOk
onlineUsers, onlineErr := Hysteria2Online()
if onlineErr != nil {
// Отказ здесь не является отказом страницы: список пиров и их
// сохранённое состояние известны и полезны сами по себе. Неизвестной
// становится ровно одна колонка, и об этом сказано явно.
logrus.WithError(onlineErr).
Warn("список пиров: картина подключений недоступна, признак online не определён")
onlineState = vo.PeerOnlineStateUnavailable
onlineUsers = map[string]int64{}
}
result := make([]vo.PeerVo, 0, len(peers))
for _, p := range peers {
item := vo.PeerVo{
@@ -46,7 +64,7 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) {
}
result = append(result, item)
}
return result, total, nil
return result, total, onlineState, nil
}
// Пир установщика защищён во ВСЕХ путях записи, а не только в импорте.
@@ -97,7 +115,7 @@ func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
if err != nil {
return vo.PeerVo{}, err
}
authId, err := util.RandomString(18)
authId, err := newPeerAuthID()
if err != nil {
return vo.PeerVo{}, err
}
@@ -145,17 +163,33 @@ func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
if peerDto.Name != nil && *peerDto.Name != "" {
updates["name"] = *peerDto.Name
}
if peerDto.Secret != nil && *peerDto.Secret != "" {
digest, err := PeerSecretDigest(*peerDto.Secret)
if secret, requested := requestedSecret(peerDto.Secret); requested {
digest, err := PeerSecretDigest(secret)
if err != nil {
return err
}
enc, err := EncryptPeerSecret(*peerDto.Secret)
enc, err := EncryptPeerSecret(secret)
if err != nil {
return err
}
updates["secret_digest"] = digest
updates["secret_ciphertext"] = enc
// Новое поколение учётных данных получает новую идентичность живых
// сессий. Без этого сессия, установленная по уже отозванному секрету,
// оставалась неотличимой от законной: `auth_id` у неё прежний, пир в
// базе есть, доступ открыт — и cron не имел ни одного признака, по
// которому её следовало бы завершить. Разбор — в peer_secret.go.
//
// Разрыв при этом идёт по СТАРОМУ значению (см. ниже): именно им
// Hysteria знает сессию, которую отзывает оператор.
if credentialGenerationChanged(before.SecretDigest, digest) {
rotatedAuthID, err := newPeerAuthID()
if err != nil {
return err
}
updates["auth_id"] = rotatedAuthID
}
}
if peerDto.QuotaBytes != nil {
updates["quota_bytes"] = *peerDto.QuotaBytes
@@ -220,7 +254,12 @@ func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
func updateRequiresReconcile(before entity.Peer, peerDto dto.PeerUpdateDto, now int64) bool {
// Смена секрета. Учётные данные, по которым сессия была установлена, с
// этого момента недействительны — держать её открытой нечем.
if peerDto.Secret != nil && strings.TrimSpace(*peerDto.Secret) != "" {
//
// Условие смотрит на ЗАПРОС, а не на смену поколения credentials: повтор
// того же секрета после неудавшегося разрыва обязан дать вторую попытку,
// ровно как повторное «Отключить». Ротация `auth_id` при этом не
// выполняется — см. credentialGenerationChanged.
if _, requested := requestedSecret(peerDto.Secret); requested {
return true
}
@@ -364,10 +403,10 @@ func assertBootstrapPeerIdentityUnchanged(existing entity.Peer, peerDto dto.Peer
//
// Теперь:
//
// 1. прочитать пира и запомнить его auth ID;
// 2. записать disabled=1 — закрыть будущие обращения к HTTP-auth;
// 3. завершить живые сессии по запомненному auth ID;
// 4. удалить строку.
// 1. прочитать пира и запомнить его auth ID;
// 2. записать disabled=1 — закрыть будущие обращения к HTTP-auth;
// 3. завершить живые сессии по запомненному auth ID;
// 4. удалить строку.
//
// Ключевые исходы:
//
@@ -647,7 +686,7 @@ func preparePeerImport(items []bo.PeerExport) ([]preparedPeerImport, error) {
entry.createAuthID = authID
if entry.createAuthID == "" {
generated, err := util.RandomString(18)
generated, err := newPeerAuthID()
if err != nil {
return nil, err
}
@@ -695,13 +734,13 @@ func preparePeerImport(items []bo.PeerExport) ([]preparedPeerImport, error) {
// изменилось конкретное поле. Это сознательно более простой контракт, чем diff
// по семи access-полям:
//
// 1. импорт и так переписывает состояние целиком;
// 2. старый auth ID гарантированно нужен при его замене;
// 3. повтор того же импорта после неудавшегося `/kick` обязан снова
// попытаться завершить старые сессии;
// 4. не появляется ещё одной таблицы правил «какие поля импорта считаются
// access-changing» — то есть второго места, где политика может разойтись
// с peerAccessDenied.
// 1. импорт и так переписывает состояние целиком;
// 2. старый auth ID гарантированно нужен при его замене;
// 3. повтор того же импорта после неудавшегося `/kick` обязан снова
// попытаться завершить старые сессии;
// 4. не появляется ещё одной таблицы правил «какие поля импорта считаются
// access-changing» — то есть второго места, где политика может разойтись
// с peerAccessDenied.
//
// Цена — существующие пиры из импортируемой партии один раз переподключаются.
// Для административной операции переноса это нормальная цена.
@@ -781,6 +820,31 @@ func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) (string, erro
// Значение читается ДО записи: после неё в строке уже стоит auth ID из
// файла, а Hysteria знает пира по прежнему.
replaced := authIDOf(existing)
// Импорт — вторая дверь к смене учётных данных, и правило поколения у
// неё то же самое: новый секрет получает новую идентичность сессий.
//
// Дверь эта закрывалась отдельно, потому что здесь состояние приходит
// из файла. Случай, который проходил мимо: запись найдена по ИМЕНИ либо
// несёт прежний `auth_id`, а секрет в файле новый. Тогда `auth_id`
// оставался прежним, и сессия, установленная по отозванному секрету,
// снова выглядела законной — тот же дефект, что и в UpdatePeer.
//
// Значение `auth_id` из файла при этом перебивается, и это осознанно:
// в импорте оно является КЛЮЧОМ ПОИСКА и переносимым состоянием, а не
// обещанием сохранить идентичность отозванного поколения. Если файл
// несёт ДРУГОЙ `auth_id`, ротация не нужна — идентичность уже сменилась
// им самим, и старая сессия становится orphan без нашего участия.
if entry.hasExplicitSecret &&
credentialGenerationChanged(existing.SecretDigest, entry.explicitDigest) &&
resultingAuthID(entry, replaced) == replaced {
rotated, err := newPeerAuthID()
if err != nil {
return "", err
}
updates["auth_id"] = rotated
}
if err := tx.UpdatePeer([]int64{*existing.Id}, updates); err != nil {
return "", err
}
@@ -821,6 +885,18 @@ func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) (string, erro
return "", saveErr
}
// resultingAuthID — значение, которое окажется в строке после применения
// записи импорта, если ротации не будет.
//
// Файл волен не нести `auth_id` вовсе: тогда поиск шёл по имени, и прежнее
// значение остаётся.
func resultingAuthID(entry preparedPeerImport, existing string) string {
if entry.authID != "" {
return entry.authID
}
return existing
}
// findPeerForImport ищет запись, которую импорт должен обновить.
//
// Возвращает три состояния, а не два: найдено, не найдено, неизвестно. Раньше