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 { return "", 0, errors.New("HY2XS_PUBLIC_PORT must be a valid TCP 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 } // Ограничение количества устройств onlineUsers, err := Hysteria2Online() if err != nil { logrus.WithError(err).Warn("hysteria2 online users unavailable; skip device-limit check") return *peer.Id, *peer.AuthId, nil } device, exist := onlineUsers[*peer.AuthId] if exist && *peer.MaxDevices <= device { return 0, "", errors.New("device limited") } return *peer.Id, *peer.AuthId, nil } func Hysteria2Online() (map[string]int64, error) { if !Hysteria2IsRunning() { return map[string]int64{}, nil } apiPort, err := GetHysteria2ApiPort() if err != nil { return nil, errors.New("get hysteria2 apiPort err") } trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret) if err != nil { return nil, err } onlineUsers, err := proxy.NewHysteria2Api(apiPort).OnlineUsers(*trafficSecretConfig.Value) if err != nil { return nil, err } return onlineUsers, nil } func Hysteria2Kick(ids []int64, kickUtilTime int64) error { if !Hysteria2IsRunning() { return errors.New("hysteria2 is not running") } if err := dao.UpdatePeer(ids, map[string]interface{}{"banned_until": kickUtilTime}); err != nil { return err } peers, err := dao.ListPeer("id in ?", ids) if err != nil { return err } var keys []string for _, item := range peers { keys = append(keys, *item.AuthId) } apiPort, err := GetHysteria2ApiPort() if err != nil { return errors.New("get hysteria2 apiPort err") } trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret) if err != nil { return err } if err = proxy.NewHysteria2Api(apiPort).KickUsers(keys, *trafficSecretConfig.Value); 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, }) }