package service import ( "errors" "github.com/sirupsen/logrus" "hy2xs-admin/dao" "hy2xs-admin/model/bo" "hy2xs-admin/model/constant" "hy2xs-admin/proxy" "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 } peer, err := dao.GetPeer(`secret_digest = ? and disabled = 0 and (quota_bytes < 0 or quota_bytes > download_bytes + upload_bytes) and (expires_at = 0 or ? < expires_at) and ? > banned_until`, secretDigest, now, now) if err != nil { return 0, "", err } // Строка без идентичности — повреждённые данные, а не пир. // // Проверка стоит здесь по той же причине, что и проверка 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 внутри того же процесса. Его // недоступность здесь — не штатное состояние, а аномалия, и пускать // подключения без единственной проверки, которая ещё не выполнена, значит // молча снять лимит со всех пиров сразу. 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") } if device, exist := onlineUsers[*peer.AuthId]; exist && *peer.MaxDevices <= device { 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 } // DisconnectPeers завершает активные Hysteria-сессии пиров и НИЧЕГО не пишет в // базу. // // Разрыв сессии и запись состояния разделены сознательно. Прежний // Hysteria2Kick делал и то и другое: вместе с обращением к `/kick` он // проставлял `banned_until`. Из-за этого им нельзя было воспользоваться для // отключения пира — операция `disabled=1` записала бы заодно временную // блокировку, а это другой механизм с другим сроком жизни и другим способом // снятия. Единственный вызывающий (KickPeer) при этом писал `banned_until` ещё // и сам, то есть одно и то же значение уезжало в базу дважды. // // Здесь остаётся ровно официальный Traffic Stats `/kick` и ничего больше. // // Состояние службы по systemd НЕ проверяется. Раньше путь начинался с // `!Hysteria2IsRunning() -> отказ`, и это давало худшее из двух: ответ systemd // не отличает «служба неактивна» от «спросить не удалось» (см. // Hysteria2IsRunning), поэтому сбой самого systemctl превращался в отказ // операции при живой Hysteria, а обратная ошибка молча пропускала бы разрыв. // Обращение к `/kick` отвечает на нужный вопрос напрямую и без посредника. func DisconnectPeers(ids []int64) error { if len(ids) == 0 { return nil } peers, err := dao.ListPeer("id in ?", ids) if err != nil { return err } keys := make([]string, 0, len(peers)) for _, item := range peers { if item.AuthId == nil || *item.AuthId == "" { continue } keys = append(keys, *item.AuthId) } // Пир без authId Hysteria не знает: рвать нечего, и это не отказ. 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 } return proxy.NewHysteria2Api(apiPort).KickUsers(keys, secret) } 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, }) }