Files
HY2XS_flamy/apps/service/hysteria2_api.go
T
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

430 lines
21 KiB
Go
Raw 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 (
"errors"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"hy2xs-admin/proxy"
"hy2xs-admin/util"
"net"
"net/url"
"os"
"strconv"
"strings"
"time"
)
// resolveShareSni выбирает SNI для клиентской ссылки.
//
// ACME-домен — не единственный источник истины: в режиме tls (файловые
// сертификаты) блока acme в конфиге нет, но домен продукта известен из
// runtime-конфига. Публичный IPv4 в качестве SNI не используется.
func resolveShareSni(acmeDomain string, publicHost string) string {
if domain := strings.TrimSpace(acmeDomain); domain != "" {
return domain
}
if domain := strings.TrimSpace(os.Getenv("HY2XS_DOMAIN")); domain != "" && !isIPAddress(domain) {
return domain
}
if host := strings.TrimSpace(publicHost); host != "" && !isIPAddress(host) {
return host
}
return ""
}
func isIPAddress(value string) bool {
return net.ParseIP(strings.TrimSpace(value)) != nil
}
func resolvePublicEndpoint() (string, int, error) {
host := strings.TrimSpace(os.Getenv("HY2XS_PUBLIC_HOST"))
if host == "" || host == "0.0.0.0" {
return "", 0, errors.New("HY2XS_PUBLIC_HOST must be set to public domain or IPv4")
}
portRaw := strings.TrimSpace(os.Getenv("HY2XS_PUBLIC_PORT"))
if portRaw == "" {
return "", 0, errors.New("HY2XS_PUBLIC_PORT is required")
}
port, err := strconv.Atoi(portRaw)
if err != nil || port < 1 || port > 65535 {
// Транспорт в формулировке не называется: публичный endpoint Hysteria —
// UDP/QUIC, и «TCP port» здесь закладывал в сообщение об ошибке
// заведомо ложную семантику.
return "", 0, errors.New("HY2XS_PUBLIC_PORT must be a valid port")
}
return host, port, nil
}
func Hysteria2Auth(conPass string) (int64, string, error) {
now := time.Now().UnixMilli()
secretDigest, digestErr := PeerSecretDigest(conPass)
if digestErr != nil {
return 0, "", digestErr
}
// Поиск идёт ТОЛЬКО по учётным данным. Политика доступа больше не живёт
// внутри выборки: её объявляет peerAccessDenied, и ровно её же применяет
// принудительное отключение в cron. Пока правило было записано двумя
// разными SQL-условиями, авторизация и enforcement расходились на границах
// quota, expiry и ban — см. комментарий в peer_access.go.
peer, err := dao.GetPeer("secret_digest = ?", secretDigest)
if err != nil {
return 0, "", err
}
if peerAccessDenied(peer, now) {
return 0, "", errors.New("peer access denied")
}
// Строка без идентичности — повреждённые данные, а не пир.
//
// Проверка стоит здесь по той же причине, что и проверка maxDevices ниже:
// это путь КАЖДОГО подключения пира, и разыменование nil на нём означает
// панику в обработчике machine-auth, а не отказ одному клиенту.
if peer.Id == nil || peer.AuthId == nil || *peer.AuthId == "" {
logrus.Error("peer row has no usable identity; rejecting auth")
return 0, "", errors.New("peer identity unavailable")
}
// Ограничение количества устройств — fail-closed.
//
// Раньше отказ Traffic Stats API обрабатывался так:
//
// onlineUsers, err := Hysteria2Online()
// if err != nil {
// logrus.WithError(err).Warn(...)
// return *peer.Id, *peer.AuthId, nil
// }
//
// То есть недоступность внутреннего 127.0.0.1 превращала объявленный в
// панели «Лимит устройств: 3» в безлимит, и узнать об этом оператор мог
// только по строке WARN в журнале, которую никто не читает. Ограничение,
// которое отключается само при первой же внутренней неполадке, не является
// ограничением.
//
// Вторая половина той же дыры была тише: общий Hysteria2Online отдавал
// пустую карту БЕЗ ошибки, когда systemd отвечал «служба неактивна», —
// а этот ответ не отличается от «спросить systemctl не удалось». Поэтому
// здесь берётся строгий путь: только фактический ответ Traffic Stats API.
//
// Направление отказа выбрано осознанно. Запрос авторизации приходит ОТ
// Hysteria, то есть в момент этой проверки Hysteria заведомо жива, а её
// Traffic Stats API слушает loopback внутри того же процесса. Его
// недоступность здесь — не штатное состояние, а аномалия, и пускать
// подключения без единственной проверки, которая ещё не выполнена, значит
// молча снять лимит со всех пиров сразу.
// Чтение `/online` и резервация места — ОДНА последовательность, и она
// выполняется под замком этого пира.
//
// Без замка снимки приходили в резервацию в произвольном порядке, и
// устаревший откатывал учёт назад: разрешение, уже признанное проявившимся,
// возвращалось в «свободное место». Подробный разбор — в начале
// peer_admission.go.
//
// Замок берётся именно здесь, а не раньше: до этой точки известен только
// секрет, а сериализовать нужно подключения ОДНОГО пира, то есть замок
// невозможно взять, пока не прочитан его authId. Всё, что выше, — работа с
// базой и политикой доступа, и разным пирам она не мешает.
unlockAdmission := lockPeerAdmission(*peer.AuthId)
defer unlockAdmission()
onlineUsers, err := hysteria2Online()
if err != nil {
logrus.WithError(err).
WithField("peerId", *peer.Id).
Error("hysteria2 traffic stats api unavailable; device limit cannot be enforced, rejecting auth")
return 0, "", errors.New("device limit unavailable")
}
// maxDevices без значения — это не «безлимит», а неизвестная граница.
// Схема даёт колонке DEFAULT, форма требует min=1, импорт приводит <=0 к 3,
// поэтому nil здесь означать может только повреждённую строку — и на пути
// принятия решения о доступе она обязана вести к отказу, а не к пропуску.
if peer.MaxDevices == nil || *peer.MaxDevices < 1 {
logrus.WithField("peerId", *peer.Id).
Error("peer has no usable maxDevices; rejecting auth")
return 0, "", errors.New("device limit unavailable")
}
// Место занимается ПОСЛЕ всех остальных проверок и с учётом уже выданных,
// но ещё не проявившихся разрешений — см. peer_admission.go. Сравнение
// одного лишь ответа `/online` пропускало параллельные подключения: между
// чтением и ответом «allow» ничего не удерживало место, и два одновременных
// запроса при `online=2, max=3` получали разрешение оба.
//
// Порядок существенен: если бы резервация делалась раньше проверки
// квоты или срока, отказ по ним съедал бы слот на всё время TTL.
if !reserveDeviceSlot(*peer.AuthId, onlineUsers[*peer.AuthId], *peer.MaxDevices, time.Now()) {
return 0, "", errors.New("device limited")
}
return *peer.Id, *peer.AuthId, nil
}
// Hysteria2Online — картина подключений ДЛЯ ОТОБРАЖЕНИЯ.
//
// Отличается от hysteria2Online ровно ничем, и это результат исправления, а не
// упущение. Раньше здесь стоял ярлык
//
// if !hysteria2IsRunning() {
// return map[string]int64{}, nil
// }
//
// то есть «пусто, ошибки нет» по мнению systemd. У него было два следствия, и
// оба вредные.
//
// Первое — на пути доступа: ответ systemctl не отличает «служба неактивна» от
// «спросить не удалось», а пустая картина при проверке лимита устройств
// означает «пускать всех». Эта половина закрыта раньше — авторизация ходит
// строгим путём.
//
// Второе осталось и живёт в диагностике. Пустая карта БЕЗ ошибки неотличима от
// «никто не подключён», поэтому сборщик метрик выставлял `ApiReachable = true`
// и `OnlineDevices = 0`, ни разу не обратившись к Traffic Stats API, а список
// пиров показывал всех офлайн. Дашборд утверждал одновременно «служба
// остановлена» и «API доступен, онлайн 0» — два несовместимых факта об одной
// системе, полученные из одного и того же ответа systemctl.
//
// Поэтому ярлыка нет: «кто сейчас на связи» спрашивается у того, кто это
// знает. Недоступность остаётся ОШИБКОЙ, а решать, как её показать оператору,
// обязан вызывающий — см. CollectMetricsSnapshot и PagePeer, где она
// превращается в явное «состояние неизвестно», а не в «все офлайн».
//
// Функция сохранена отдельно от hysteria2Online как имя для внешнего слоя:
// внутри пакета строгий путь остаётся строчным.
func Hysteria2Online() (map[string]int64, error) {
return hysteria2Online()
}
// hysteria2Online — фактический ответ Traffic Stats API, без ярлыков.
//
// Недоступность здесь остаётся ошибкой: вызывающий обязан решить, что она для
// него значит, и не может получить пустую карту вместо отказа.
func hysteria2Online() (map[string]int64, error) {
apiPort, err := GetHysteria2ApiPort()
if err != nil {
return nil, errors.New("get hysteria2 apiPort err")
}
secret, err := hysteria2TrafficSecret()
if err != nil {
return nil, err
}
return proxy.NewHysteria2Api(apiPort).OnlineUsers(secret)
}
// hysteria2TrafficSecret отдаёт секрет Traffic Stats API.
//
// Отсутствующее значение ключа — отказ, а не пустая строка. Раньше по этому
// пути стояло `*config.Value` без проверки: строка в таблице `config` без
// значения роняла бы админку паникой на разыменовании nil прямо в обработчике
// machine-auth, то есть на пути каждого подключения пира.
func hysteria2TrafficSecret() (string, error) {
trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
if err != nil {
return "", err
}
if trafficSecretConfig.Value == nil || *trafficSecretConfig.Value == "" {
return "", errors.New("hysteria2 traffic stats secret is not configured")
}
return *trafficSecretConfig.Value, nil
}
// kickChunkSize ограничивает размер одного обращения к `/kick`.
//
// Импорт применяет до MaxPeerImportItems записей за операцию, и без разбиения
// в Hysteria уехал бы один POST с многотысячным массивом в теле. Значение
// выбрано с запасом относительно любого реального размера панели: смысл здесь
// не в оптимизации, а в отсутствии запроса, размер которого задаёт содержимое
// пользовательского файла.
const kickChunkSize = 100
// disconnectAuthIDs — ЕДИНСТВЕННЫЙ путь к Traffic Stats `/kick` в продукте.
//
// Контракт предельно узкий и намеренно ничего не знает про пиров:
//
// auth IDs -> дедупликация -> порт API -> секрет -> POST /kick
//
// Никакой базы, никакого `disabled`, никакого `banned_until`. Разрыв сессии и
// запись состояния разделены сознательно: прежний Hysteria2Kick делал и то и
// другое — вместе с обращением к `/kick` он проставлял `banned_until`, — и
// из-за этого им нельзя было воспользоваться для отключения пира: операция
// записала бы заодно временную блокировку, а это другой механизм с другим
// сроком жизни и другим способом снятия.
//
// Вход — именно auth IDs, а не идентификаторы пиров, и это не деталь. Операции
// удаления и импорта меняют или убирают auth ID: после commit действующего
// значения в базе уже нет, и рвать надо по тому, которое Hysteria знала ДО
// операции. Функция, которая сама читала бы auth ID из базы, для этих двух
// путей опоздала бы всегда.
//
// Состояние службы по systemd НЕ проверяется. Ответ systemd не отличает
// «служба неактивна» от «спросить не удалось» (см. Hysteria2IsRunning),
// поэтому сбой самого systemctl отказывал бы операции при живой Hysteria.
// Обращение к `/kick` отвечает на нужный вопрос напрямую и без посредника.
func disconnectAuthIDs(authIDs []string) error {
keys := make([]string, 0, len(authIDs))
seen := make(map[string]struct{}, len(authIDs))
for _, authID := range authIDs {
if authID == "" {
continue
}
if _, duplicate := seen[authID]; duplicate {
continue
}
seen[authID] = struct{}{}
keys = append(keys, authID)
}
// Ни одной цели — значит рвать нечего, и это не отказ. Раньше по
// аналогичному пути в cron уезжал POST с пустым массивом каждые 30 секунд.
if len(keys) == 0 {
return nil
}
apiPort, err := GetHysteria2ApiPort()
if err != nil {
return errors.New("get hysteria2 apiPort err")
}
secret, err := hysteria2TrafficSecret()
if err != nil {
return err
}
api := proxy.NewHysteria2Api(apiPort)
for _, chunk := range util.SplitArr(keys, kickChunkSize) {
if err := api.KickUsers(chunk, secret); err != nil {
return err
}
}
return nil
}
func Hysteria2Url(accountId int64) (string, error) {
hysteria2Config, err := GetHysteria2Config()
if err != nil {
return "", err
}
if hysteria2Config.Listen == nil || *hysteria2Config.Listen == "" {
return "", errors.New("hysteria2 config is empty")
}
hostname, port, err := resolvePublicEndpoint()
if err != nil {
return "", err
}
peer, err := dao.GetPeer("id = ?", accountId)
if err != nil {
return "", err
}
remark := shareRemark(peer.Name, hostname)
obfs := hysteria2Config.ObfsShare()
sni := resolveShareSni(hysteria2Config.AcmeDomain(), hostname)
secret := ""
if peer.SecretEncrypted != nil {
decrypted, decErr := DecryptPeerSecret(*peer.SecretEncrypted)
if decErr != nil {
return "", decErr
}
secret = decrypted
}
return buildHysteria2Url(secret, hostname, port, obfs, sni, remark), nil
}
// shareRemark даёт имя профиля, которое клиент показывает в списке серверов.
//
// Раньше оно бралось из HYSTERIA2_CONFIG_REMARK — пустой строки в таблице
// `config`, которую clean install создавал один раз и которую никто никогда не
// записывал. То есть fragment у ссылки отсутствовал всегда, и все выданные
// ссылки выглядели в клиенте одинаково.
//
// Теперь значение выводится детерминированно из имени пира: у панели с
// несколькими пирами это ровно то различие, которое пользователю и нужно
// видеть, и оно не требует ни одной дополнительной настройки. Fallback на
// публичный хост нужен для пира без имени — база это допускает (name имеет
// DEFAULT ”), а ссылка без имени профиля хуже, чем ссылка с именем сервера.
func shareRemark(peerName *string, hostname string) string {
if peerName != nil {
if name := strings.TrimSpace(*peerName); name != "" {
return name
}
}
return hostname
}
// isShareableObfsType перечисляет типы обфускации, которые официальная
// URI-схема Hysteria умеет передавать клиенту.
func isShareableObfsType(obfsType string) bool {
return obfsType == "salamander" || obfsType == "gecko"
}
// ShareURIOptions — вход генератора клиентской ссылки.
//
// Тип экспортируется, чтобы end-to-end проверка подключалась РОВНО тем же
// кодом, который выдаёт ссылки пользователю. Раньше e2e собирал URI
// собственной реализацией на bash, и дрейф любой из двух реализаций
// оставлял обе группы тестов зелёными.
type ShareURIOptions struct {
Secret string
Host string
Port int
Obfs bo.ObfsShareConfig
SNI string
Remark string
// Insecure отключает проверку сертификата на стороне клиента.
//
// В production всегда false: Hysteria2Url другого значения не передаёт,
// и это закреплено тестом. Поле существует только ради e2e, который
// работает на самоподписанном сертификате и иначе не смог бы
// использовать production-генератор.
Insecure bool
}
// BuildHysteria2ShareURI собирает ссылку по официальной URI-схеме Hysteria 2.
func BuildHysteria2ShareURI(opts ShareURIOptions) string {
query := url.Values{}
if isShareableObfsType(opts.Obfs.Type) && opts.Obfs.Password != "" {
query.Set("obfs", opts.Obfs.Type)
query.Set("obfs-password", opts.Obfs.Password)
}
if opts.SNI != "" {
query.Set("sni", opts.SNI)
}
if opts.Insecure {
query.Set("insecure", "1")
} else {
query.Set("insecure", "0")
}
u := url.URL{
Scheme: "hysteria2",
User: url.User(opts.Secret),
Host: net.JoinHostPort(opts.Host, strconv.Itoa(opts.Port)),
Path: "/",
RawQuery: query.Encode(),
}
if strings.TrimSpace(opts.Remark) != "" {
u.Fragment = opts.Remark
}
return u.String()
}
// buildHysteria2Url — production-путь: проверка сертификата никогда не
// отключается.
func buildHysteria2Url(conPass string, hostname string, port int, obfs bo.ObfsShareConfig, sni string, remark string) string {
return BuildHysteria2ShareURI(ShareURIOptions{
Secret: conPass,
Host: hostname,
Port: port,
Obfs: obfs,
SNI: sni,
Remark: remark,
Insecure: false,
})
}