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 — картина подключений ДЛЯ ОТОБРАЖЕНИЯ. // // Терпима к недоступности Hysteria намеренно: дашборду и списку пиров нечего // показать, если Traffic Stats API молчит, и пустая картина — честный ответ на // вопрос «кто сейчас на связи», когда служба остановлена. // // Этой терпимости НЕТ в решениях о доступе. Ярлык `!hysteria2IsRunning() -> // пусто, ошибки нет` раньше стоял на общем пути, и через него ограничение // устройств обходилось молча: systemctl отвечает через util.Exec, который не // отличает «служба неактивна» от «спросить не удалось», а «никого нет онлайн» // для проверки лимита означает «пускать всех». Отказа при этом не возникало // вовсе — не было даже записи в журнале. func Hysteria2Online() (map[string]int64, error) { if !hysteria2IsRunning() { return map[string]int64{}, nil } 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, }) }