Files
HY2XS_flamy/apps/service/hysteria2_api.go
T
founder 920a78fdae test(e2e): подключаться по ссылке из production-генератора share URI
Внутри e2e-hysteria.sh жила вторая реализация hysteria2:// URI на bash.
Go-юнит-тесты проверяли production-генератор, e2e проверял свою
функцию - и дрейф любой из двух реализаций оставлял обе группы тестов
зелёными. Фраза "реальный клиент подключается именно по ссылке, которую
выдаёт HY2XS" была неточной.

Билдер ссылки вынесен в экспортируемую service.BuildHysteria2ShareURI,
production-путь Hysteria2Url стал её тонкой обёрткой. Новая тестовая
утилита apps/tools/share-uri печатает ссылку тем же кодом; в
production-бинарь админки она не входит.

Единственное расхождение с пользовательской ссылкой - insecure=1: e2e
работает на самоподписанном сертификате. Расхождение ограничено с трёх
сторон:

- e2e отдельно печатает и проверяет production-вариант ссылки
  (insecure=0, корректные obfs и sni);
- TestBuildHysteria2ShareURI_InsecureDiffersOnlyInThatParam доказывает,
  что кроме этого параметра ссылки совпадают;
- TestBuildHysteria2Url_ProductionPathNeverDisablesVerification
  фиксирует, что production-путь никогда не передаёт insecure=1.

Для запуска e2e теперь нужен Go (GO_BIN).
2026-08-27 12:16:07 +05:00

246 lines
7.4 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"
"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
}
hysteria2ConfigRemark, err := dao.GetConfig("key = ?", constant.Hysteria2ConfigRemark)
if err != nil {
return "", err
}
remark := ""
if hysteria2ConfigRemark.Value != nil {
remark = *hysteria2ConfigRemark.Value
}
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
}
// 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,
})
}