Files
founder cb20d8d28f 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
2026-09-02 23:24:01 +05:00

206 lines
8.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}