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:
+98
-22
@@ -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 ищет запись, которую импорт должен обновить.
|
||||
//
|
||||
// Возвращает три состояния, а не два: найдено, не найдено, неизвестно. Раньше
|
||||
|
||||
Reference in New Issue
Block a user