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
+205
View File
@@ -0,0 +1,205 @@
package service
import (
"sort"
"strings"
"gopkg.in/yaml.v3"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/vo"
)
// Проекция серверного конфига на production-профиль HY2XS.
//
// Зачем она нужна вместо «показать модель целиком». Продуктом является один
// профиль: оркестратор генерирует конфиг из шаблона и сам же проверяет, что
// установленный файл этому профилю соответствует
// (orchestrator/src/steps/configAssertions.ts). Страница конфигурации при этом
// обслуживала одновременно и его, и абстрактный «универсальный сервер
// Hysteria» — с вкладками resolver, sniff, acl, outbounds, masquerade, — то
// есть показывала как настройки то, что в этой установке не используется и не
// поддерживается.
//
// Достраивать её до полноценного редактора всех возможностей upstream
// неправильно: конфигом владеет оркестратор, панель его не пишет, а вопрос
// «почему поведение не такое, как ожидалось» решается не списком всех полей, а
// ответом «что в файле отличается от профиля». Поэтому здесь ровно две вещи:
// значения профиля как они записаны и перечень секций вне профиля.
//
// Полный документ никуда не делся — он выгружается санитизированным экспортом.
// hysteria2ProfileSections — секции верхнего уровня, которые описывает
// production-профиль HY2XS.
//
// Список обязан совпадать с whitelist'ом оркестратора
// (assertNoUnexpectedSections) с точностью до того, что там режимы TLS
// взаимоисключающие, а здесь показываются оба: файл может содержать любой из
// них, и оба принадлежат профилю. Совпадение проверяется гейтом приёмки —
// иначе два списка разъедутся молча, и панель начнёт называть расхождением то,
// что оркестратор считает нормой.
var hysteria2ProfileSections = []string{
"acme",
"auth",
"bandwidth",
"congestion",
"ignoreClientBandwidth",
"listen",
"obfs",
"quic",
"tls",
"trafficStats",
}
// BuildHysteria2Profile читает файл ОДИН раз и отвечает на оба вопроса сразу:
// что записано в секциях профиля и какие секции в профиль не входят.
func BuildHysteria2Profile() (vo.Hysteria2ProfileVo, error) {
raw, err := GetRawHysteria2Config()
if err != nil {
return vo.Hysteria2ProfileVo{}, err
}
var config bo.Hysteria2ServerConfig
if err := yaml.Unmarshal([]byte(raw), &config); err != nil {
return vo.Hysteria2ProfileVo{}, err
}
profile := hysteria2ProfileFromConfig(config)
profile.Drift = hysteria2ConfigDrift(raw)
return profile, nil
}
// hysteria2ConfigDrift перечисляет секции файла вне production-профиля.
//
// Разбор идёт в свободную карту, а не в типизированную модель: секция, о
// которой HY2XS не знает вовсе, обязана попасть в расхождения, а модель её
// молча теряет. Именно такие секции и интересны — они означают, что файл правил
// не оркестратор.
func hysteria2ConfigDrift(raw string) []string {
var document map[string]any
if err := yaml.Unmarshal([]byte(raw), &document); err != nil {
// Файл не разобрался как карта верхнего уровня. Значения профиля уже
// прочитаны типизированной моделью; сказать что-либо о расхождениях
// нельзя, и выдумывать их нечестно.
return []string{}
}
known := make(map[string]struct{}, len(hysteria2ProfileSections))
for _, section := range hysteria2ProfileSections {
known[section] = struct{}{}
}
drift := make([]string, 0)
for key := range document {
if _, ok := known[key]; ok {
continue
}
drift = append(drift, key)
}
// Порядок ключей карты в Go случаен: без сортировки список расхождений
// менялся бы при каждом обновлении страницы.
sort.Strings(drift)
return drift
}
func hysteria2ProfileFromConfig(config bo.Hysteria2ServerConfig) vo.Hysteria2ProfileVo {
profile := vo.Hysteria2ProfileVo{
Listen: config.Listen,
IgnoreClientBandwidth: config.IgnoreClientBandwidth,
Drift: []string{},
}
if auth := config.Auth; auth != nil {
profile.Auth = &vo.Hysteria2ProfileAuthVo{Type: auth.Type}
if auth.HTTP != nil {
// URL несёт machine token в query. Санитайзер тот же, что и у
// выгрузки: адрес остаётся читаемым, секрет — нет.
if auth.HTTP.URL != nil {
sanitized := sanitizeURLValue(*auth.HTTP.URL)
profile.Auth.Url = &sanitized
}
profile.Auth.Insecure = auth.HTTP.Insecure
}
}
if tls := config.TLS; tls != nil {
profile.Tls = &vo.Hysteria2ProfileTlsVo{
Cert: tls.Cert,
Key: tls.Key,
SniGuard: tls.SNIGuard,
ClientCA: tls.ClientCA,
}
}
if acme := config.ACME; acme != nil {
profile.Acme = &vo.Hysteria2ProfileAcmeVo{
Domains: acme.Domains,
Email: acme.Email,
Ca: acme.CA,
Dir: acme.Dir,
ListenHost: acme.ListenHost,
Type: acme.Type,
DnsConfigKeys: []string{},
}
if acme.DNS != nil {
profile.Acme.DnsProvider = acme.DNS.Name
for key := range acme.DNS.Config {
profile.Acme.DnsConfigKeys = append(profile.Acme.DnsConfigKeys, key)
}
sort.Strings(profile.Acme.DnsConfigKeys)
}
if profile.Acme.Domains == nil {
profile.Acme.Domains = []string{}
}
}
if obfs := config.Obfs; obfs != nil {
profile.Obfs = &vo.Hysteria2ProfileObfsVo{Type: obfs.Type}
// Показывается ветка, соответствующая объявленному типу. Наличие
// «второй» ветки рядом — это уже расхождение конфига, и его видно по
// самому типу: оркестратор пишет ровно одну.
share := config.ObfsShare()
profile.Obfs.PasswordSet = strings.TrimSpace(share.Password) != ""
if obfs.Gecko != nil && obfs.Type != nil && *obfs.Type == "gecko" {
profile.Obfs.MinPacketSize = obfs.Gecko.MinPacketSize
profile.Obfs.MaxPacketSize = obfs.Gecko.MaxPacketSize
}
}
if bandwidth := config.Bandwidth; bandwidth != nil {
profile.Bandwidth = &vo.Hysteria2ProfileBandwidthVo{
Up: bandwidth.Up,
Down: bandwidth.Down,
DisableLossCompensation: bandwidth.DisableLossCompensation,
}
}
if congestion := config.Congestion; congestion != nil {
profile.Congestion = &vo.Hysteria2ProfileCongestionVo{
Type: congestion.Type,
BbrProfile: congestion.BBRProfile,
}
}
if quic := config.QUIC; quic != nil {
profile.Quic = &vo.Hysteria2ProfileQuicVo{
InitStreamReceiveWindow: quic.InitStreamReceiveWindow,
MaxStreamReceiveWindow: quic.MaxStreamReceiveWindow,
InitConnReceiveWindow: quic.InitConnectionReceiveWindow,
MaxConnReceiveWindow: quic.MaxConnectionReceiveWindow,
MaxIdleTimeout: quic.MaxIdleTimeout,
MaxIncomingStreams: quic.MaxIncomingStreams,
DisablePathMTUDiscovery: quic.DisablePathMTUDiscovery,
DisableStatelessReset: quic.DisableStatelessReset,
}
}
if stats := config.TrafficStats; stats != nil {
profile.TrafficStats = &vo.Hysteria2ProfileTrafficStatsVo{
Listen: stats.Listen,
SecretSet: stats.Secret != nil && strings.TrimSpace(*stats.Secret) != "",
}
}
return profile
}