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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user