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
+44 -157
View File
@@ -43,8 +43,21 @@ export default {
download: "Скачано",
upload: "Отдано",
total: "Всего",
serviceStateLabel: "Служба Hysteria",
// Три состояния, а не два: «спросить systemd не удалось» — это не
// «служба остановлена», и действия оператора у них разные.
serviceState: {
active: "Работает",
inactive: "Остановлена",
unknown: "Состояние неизвестно",
},
trafficApiLabel: "Traffic Stats API",
trafficApiReachable: "Доступен",
trafficApiUnreachable: "Недоступен",
security: {
hysteriaStopped: "Hysteria2 остановлена",
hysteriaStateUnknown:
"Состояние службы Hysteria2 неизвестно: systemd не ответил",
trafficApiUnavailable: "Traffic API недоступен",
},
error: {
@@ -243,13 +256,16 @@ export default {
onlineStatus: "Онлайн",
online: "Онлайн",
offline: "Офлайн",
onlineUnknown: "Онлайн неизвестен",
onlineUnavailable: "Картина подключений сейчас недоступна",
onlineUnavailableHint:
"Traffic Stats API Hysteria не ответил, поэтому онлайн и число устройств неизвестны. Сохранённое состояние пиров в таблице верно.",
device: "Устройства",
createdAt: "Создан",
bannedUntil: "Блокировка до",
totalTraffic: "Суммарный трафик",
copyUri: "Копировать URI",
more: "Ещё",
unit: "Ед. изм.",
loginAt: "Последний вход",
conAt: "Последнее подключение",
createTime: "Создано",
@@ -278,17 +294,10 @@ export default {
resetTrafficMonth: "Раз в месяц, в полночь первого дня",
resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем",
},
monitor: {
cpuPercent: "CPU",
memPercent: "Память",
diskPercent: "Диск",
hysteria2UserTotal: "Пользователей онлайн",
hysteria2DeviceTotal: "Устройств онлайн",
hysteria2Version: "Версия Hysteria2",
hysteria2Running: "Статус Hysteria2",
hysteria2RunningTrue: "Работает",
hysteria2RunningFalse: "Остановлена",
},
// Раздел `monitor` удалён вместе со своим единственным потребителем —
// плашкой «Статус Hysteria2» на странице конфигурации. Состояние службы
// имеет три значения, а не два, и его фразы живут в `dashboard.serviceState`:
// «остановлена» и «неизвестно» — разные ответы оператору.
log: {
numLine: "Количество строк",
level: "Уровень",
@@ -309,157 +318,35 @@ export default {
notFoundBackHome: "На главную",
},
hysteria: {
enable: "Включить",
disable: "Отключить",
addConfigItem: "Добавить параметр",
hysteria2Version: "Версия Hysteria2",
hysteria2Running: "Статус Hysteria2",
// addOutbound удалён вместе с редактором outbounds: страница read-only,
// маршрутов записи серверного конфига в API нет.
outboundsEmpty: "Outbounds в конфигурации сервера не заданы",
ownedByOrchestrator: "Конфигурацией Hysteria владеет установщик",
ownedByOrchestratorHint:
"Панель показывает файл /etc/hysteria/config.yaml только на чтение. Изменения вносит `hy2xs-orchestrator reconfigure`.",
// Разделы production-профиля. Экран показывает то, что записано в файле,
// и отдельно называет секции сверх профиля: универсального редактора всех
// возможностей Hysteria здесь нет намеренно — конфиг создаёт оркестратор.
listen: "Адрес прослушивания",
auth: "Авторизация пиров",
tls: "TLS",
obfs: "Маскировка",
quic: "Параметры QUIC",
bandwidth: "Полоса",
congestion: "Congestion control",
speedTest: "Тест скорости",
udp: "UDP",
resolver: "DNS",
sniff: "Sniffing протоколов",
acl: "ACL",
outbounds: "Outbounds",
http: "Traffic Stats API (HTTP)",
masquerade: "Masquerade",
config: {
listen: "Адрес и порт прослушивания. Для IPv4 используйте 0.0.0.0:443.",
tlsType: "Тип TLS",
tls: {
cert: "Путь к cert-файлу",
key: "Путь к key-файлу",
sniGuard: "Проверка SNI клиента перед принятием TLS-соединения.",
},
acme: {
domains: "Домены",
email: "Email",
ca: "CA: letsencrypt или zerossl",
listenHost: "Адрес для ACME challenge",
dir: "Каталог ACME аккаунта и сертификатов",
type: "Тип ACME challenge: http, tls или dns",
http: { altPort: "Альтернативный порт HTTP challenge" },
tls: { altPort: "Альтернативный порт TLS-ALPN challenge" },
dns: { name: "DNS-провайдер", config: "Конфигурация ACME DNS" },
disableHTTP: "Отключить HTTP challenge",
disableTLSALPN: "Отключить TLS-ALPN challenge",
altHTTPPort: "Альтернативный HTTP-порт",
altTLSALPNPort: "Альтернативный TLS-ALPN-порт",
},
obfs: {
type: "Тип обфускации: gecko (production default HY2XS) или salamander (совместимость)",
salamander: { password: "Сильный пароль Salamander" },
gecko: {
password: "Сильный пароль Gecko",
minPacketSize:
"Минимальный размер фрагмента QUIC handshake. Baseline HY2XS: 512",
maxPacketSize:
"Максимальный размер фрагмента QUIC handshake. Baseline HY2XS: 1200, верхний предел upstream: 2048",
},
},
quic: {
initStreamReceiveWindow: "Начальное окно приёма QUIC stream",
maxStreamReceiveWindow: "Максимальное окно приёма QUIC stream",
initConnReceiveWindow: "Начальное окно приёма QUIC connection",
maxConnReceiveWindow: "Максимальное окно приёма QUIC connection",
maxIdleTimeout: "Максимальный idle timeout",
maxIncomingStreams: "Максимум входящих stream",
disablePathMTUDiscovery: "Отключить QUIC path MTU discovery",
disableStatelessReset:
"Отключить QUIC stateless reset. В baseline HY2XS выключено: stateless reset ускоряет переподключение клиента после перезапуска сервера или сна устройства",
},
bandwidth: {
up: "Вверх",
down: "Вниз",
disableLossCompensation:
"Отключить компенсацию потерь. В baseline HY2XS выключено, то есть компенсация работает",
},
congestion: {
type: "Fallback congestion controller: bbr или reno. Применяется, когда Brutal bandwidth не согласован сторонами",
bbrProfile: "Профиль BBR: standard, conservative или aggressive",
},
ignoreClientBandwidth: "Игнорировать bandwidth, заявленный клиентом",
speedTest: "Встроенный сервер теста скорости",
disableUDP: "Отключить UDP forwarding",
udpIdleTimeout: "Idle timeout для UDP-сессий",
resolver: {
type: "Тип",
tcp: { addr: "TCP DNS resolver", timeout: "Timeout DNS-запросов" },
udp: { addr: "UDP DNS resolver", timeout: "Timeout DNS-запросов" },
tls: {
addr: "DNS over TLS resolver",
timeout: "Timeout DNS-запросов",
sni: "SNI для TLS resolver",
insecure: "Отключить TLS-проверку",
},
https: {
addr: "DNS over HTTPS resolver",
timeout: "Timeout DNS-запросов",
sni: "SNI для HTTPS resolver",
insecure: "Отключить TLS-проверку",
},
},
sniff: {
enable: "Включить sniffing",
timeout: "Timeout sniffing",
rewriteDomain: "Повторно анализировать доменные запросы",
tcpPorts: "TCP-порты для sniffing",
udpPorts: "UDP-порты для sniffing",
},
aclType: "Тип ACL",
acl: {
file: "Путь к ACL-файлу",
inline: "Inline ACL-правила",
geoip: "Путь к GeoIP базе",
geosite: "Путь к GeoSite базе",
geoUpdateInterval: "Интервал обновления GeoIP/GeoSite",
},
outbounds: {
name: "Имя outbound",
type: "Тип",
socks5: {
addr: "Адрес SOCKS5",
username: "Логин SOCKS5",
password: "Пароль SOCKS5",
},
http: {
url: "URL HTTP/HTTPS proxy",
insecure: "Отключить TLS-проверку proxy",
},
direct: {
mode: "Тип",
bindIPv4: "Локальный IPv4",
bindIPv6: "Локальный IPv6",
bindDevice: "Сетевой интерфейс",
fastOpen: "TCP fast open",
},
},
trafficStats: { listen: "Адрес прослушивания" },
masquerade: {
type: "Тип",
file: { dir: "Каталог файлов" },
proxy: {
url: "URL проксируемого сайта",
rewriteHost: "Переписывать Host header",
insecure: "Отключить TLS-проверку",
},
string: {
content: "Ответ строкой",
headers: "HTTP headers",
statusCode: "HTTP status code",
},
listenHTTP: "HTTP listen address",
listenHTTPS: "HTTPS listen address",
forceHTTPS: "Принудительно использовать HTTPS",
},
},
trafficStats: "Traffic Stats API",
notSet: "не задано",
sectionMissing: "Секция в конфигурации отсутствует",
tlsMissing: "Ни tls, ни acme в конфигурации не заданы",
valuesHidden: "значения не показываются",
secretSet: "задан",
secretMissing: "не задан",
obfsPasswordHint: "выдаётся в клиентской ссылке пира",
driftTitle: "Расхождение конфигурации",
driftHint:
"В файле есть секции вне production-профиля HY2XS. Оркестратор их не создаёт и не поддерживает: скорее всего, конфигурацию правили вручную.",
trafficStatsMissing: "Traffic Stats API не настроен",
trafficStatsMissingHint:
"Без секции trafficStats не работают лимит устройств, учёт трафика и принудительное отключение: панель обращается к Hysteria только через этот API.",
trafficStatsNotLoopback:
"адрес не loopback: панель обращается к Traffic Stats API только по 127.0.0.1",
},
};