ddf0ddf71e
Сквозная миграция HY2XS на современную Hysteria (2.12.2) и переход на v1. Build: - версия Hysteria резолвится на этапе сборки из HyNetworks/hysteria и замораживается в metadata пакета (version + immutable url + sha256); - compatibility gate: реальный бинарник должен принять канонический конфиг HY2XS для gecko и salamander до создания пакета; - сборка прогоняет тесты оркестратора и админки. Конфигурационный контракт: - HY2XS_CONFIG_SCHEMA_VERSION=2, чужая схема отклоняется fail-fast; - obfs стал настоящим union gecko|salamander, gecko — default; - obfs-блок рендерится оркестратором целиком, два подтипа одновременно структурно невозможны; - современный baseline: congestion bbr/standard, disableLossCompensation=false, disableStatelessReset=false, полный quic-блок. Исправления: - share URI для gecko: генератор был завязан на Obfs.Salamander.Password и выдавал нерабочую ссылку при любой другой обфускации; - SNI брался только из ACME-блока и уходил пустым при HY2XS_TLS_MODE=file; - экспорт конфига выносил trafficStats.secret, access_token и obfs-пароль; - экспорт терял неизвестные upstream-поля при round-trip через типизированную модель; - renderRuntimeEnv печатал тип обфускации литералом, расходясь с конфигом; - namedotcom удалён из ACME-реестра (нет в Hysteria с 2.11.0). Тесты: - 95 тестов оркестратора: env, рендер, семантика профиля, резолвер, rollover; - тесты URI и экспорта в Go; - tools/test/e2e-hysteria.sh с реальным клиентом Hysteria. UX: - подсказки и примеры в форме создания пира. Прочее: CHANGELOG.md, .gitattributes (LF для target-side файлов), документация на русском.
204 lines
5.7 KiB
Go
204 lines
5.7 KiB
Go
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"
|
|
}
|
|
|
|
func buildHysteria2Url(conPass string, hostname string, port int, obfs bo.ObfsShareConfig, sni string, remark string) string {
|
|
query := url.Values{}
|
|
if isShareableObfsType(obfs.Type) && obfs.Password != "" {
|
|
query.Set("obfs", obfs.Type)
|
|
query.Set("obfs-password", obfs.Password)
|
|
}
|
|
if sni != "" {
|
|
query.Set("sni", sni)
|
|
}
|
|
query.Set("insecure", "0")
|
|
|
|
u := url.URL{
|
|
Scheme: "hysteria2",
|
|
User: url.User(conPass),
|
|
Host: net.JoinHostPort(hostname, strconv.Itoa(port)),
|
|
Path: "/",
|
|
RawQuery: query.Encode(),
|
|
}
|
|
if strings.TrimSpace(remark) != "" {
|
|
u.Fragment = remark
|
|
}
|
|
return u.String()
|
|
}
|