feat(v1): Gecko-обфускация, latest-stable Hysteria на сборке и forward-compatible admin

Сквозная миграция 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 файлов),
документация на русском.
This commit is contained in:
2026-08-27 08:15:02 +05:00
parent 0205334cd8
commit ddf0ddf71e
53 changed files with 4827 additions and 291 deletions
+36 -19
View File
@@ -4,6 +4,7 @@ import (
"errors"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"hy2xs-admin/proxy"
"net"
@@ -14,6 +15,28 @@ import (
"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" {
@@ -135,20 +158,8 @@ func Hysteria2Url(accountId int64) (string, error) {
remark = *hysteria2ConfigRemark.Value
}
obfsType := ""
obfsPassword := ""
if hysteria2Config.Obfs != nil &&
hysteria2Config.Obfs.Type != nil &&
hysteria2Config.Obfs.Salamander != nil &&
hysteria2Config.Obfs.Salamander.Password != nil {
obfsType = *hysteria2Config.Obfs.Type
obfsPassword = *hysteria2Config.Obfs.Salamander.Password
}
sni := ""
if hysteria2Config.ACME != nil && len(hysteria2Config.ACME.Domains) > 0 {
sni = hysteria2Config.ACME.Domains[0]
}
obfs := hysteria2Config.ObfsShare()
sni := resolveShareSni(hysteria2Config.AcmeDomain(), hostname)
secret := ""
if peer.SecretEncrypted != nil {
@@ -158,14 +169,20 @@ func Hysteria2Url(accountId int64) (string, error) {
}
secret = decrypted
}
return buildHysteria2Url(secret, hostname, port, obfsType, obfsPassword, sni, remark), nil
return buildHysteria2Url(secret, hostname, port, obfs, sni, remark), nil
}
func buildHysteria2Url(conPass string, hostname string, port int, obfsType string, obfsPassword string, sni string, remark string) string {
// 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 obfsType == "salamander" && obfsPassword != "" {
query.Set("obfs", "salamander")
query.Set("obfs-password", obfsPassword)
if isShareableObfsType(obfs.Type) && obfs.Password != "" {
query.Set("obfs", obfs.Type)
query.Set("obfs-password", obfs.Password)
}
if sni != "" {
query.Set("sni", sni)
+276 -29
View File
@@ -4,26 +4,115 @@ import (
"net/url"
"strings"
"testing"
"hy2xs-admin/model/bo"
"gopkg.in/yaml.v3"
)
func mustParse(t *testing.T, raw string) *url.URL {
t.Helper()
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("expected valid url, got error: %v (raw=%s)", err, raw)
}
if parsed.Scheme != "hysteria2" {
t.Fatalf("expected hysteria2 scheme, got %s", parsed.Scheme)
}
return parsed
}
func TestBuildHysteria2Url_GeckoObfs(t *testing.T) {
raw := buildHysteria2Url(
"con-pass",
"vpn.example.com",
443,
bo.ObfsShareConfig{Type: "gecko", Password: "gecko-secret"},
"vpn.example.com",
"",
)
q := mustParse(t, raw).Query()
if q.Get("obfs") != "gecko" {
t.Fatalf("expected obfs=gecko, got %q", q.Get("obfs"))
}
if q.Get("obfs-password") != "gecko-secret" {
t.Fatalf("expected gecko obfs password, got %q", q.Get("obfs-password"))
}
if q.Get("sni") != "vpn.example.com" {
t.Fatalf("expected sni, got %q", q.Get("sni"))
}
if q.Get("insecure") != "0" {
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
}
}
func TestBuildHysteria2Url_SalamanderObfs(t *testing.T) {
raw := buildHysteria2Url(
"con-pass",
"vpn.example.com",
443,
bo.ObfsShareConfig{Type: "salamander", Password: "salamander-secret"},
"vpn.example.com",
"",
)
q := mustParse(t, raw).Query()
if q.Get("obfs") != "salamander" {
t.Fatalf("expected obfs=salamander, got %q", q.Get("obfs"))
}
if q.Get("obfs-password") != "salamander-secret" {
t.Fatalf("expected salamander obfs password, got %q", q.Get("obfs-password"))
}
}
func TestBuildHysteria2Url_NoObfs(t *testing.T) {
raw := buildHysteria2Url("pass", "example.com", 8443, bo.ObfsShareConfig{}, "", "")
parsed := mustParse(t, raw)
if parsed.Host != "example.com:8443" {
t.Fatalf("unexpected host: %s", parsed.Host)
}
q := parsed.Query()
if q.Get("obfs") != "" || q.Get("obfs-password") != "" || q.Get("sni") != "" {
t.Fatalf("unexpected optional query params in minimal config: %s", parsed.RawQuery)
}
if q.Get("insecure") != "0" {
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
}
}
func TestBuildHysteria2Url_UnknownObfsTypeIsNotShared(t *testing.T) {
// Неизвестный тип не должен попадать в ссылку: клиент получил бы
// параметр, который не понимает.
raw := buildHysteria2Url("pass", "example.com", 443, bo.ObfsShareConfig{Type: "future-obfs", Password: "x"}, "", "")
q := mustParse(t, raw).Query()
if q.Get("obfs") != "" {
t.Fatalf("unknown obfs type must not be shared, got %q", q.Get("obfs"))
}
}
func TestBuildHysteria2Url_ObfsWithoutPasswordIsNotShared(t *testing.T) {
raw := buildHysteria2Url("pass", "example.com", 443, bo.ObfsShareConfig{Type: "gecko"}, "", "")
q := mustParse(t, raw).Query()
if q.Get("obfs") != "" || q.Get("obfs-password") != "" {
t.Fatalf("obfs without password must not be shared: %s", q.Encode())
}
}
func TestBuildHysteria2Url_EncodesUserInfoQueryAndFragment(t *testing.T) {
raw := buildHysteria2Url(
"u@ser:#&=+ pass",
"example.com",
443,
"salamander",
"obf+s&pass=@x",
bo.ObfsShareConfig{Type: "gecko", Password: "obf+s&pass=@x"},
"exa mple.com",
"my remark #1",
)
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("expected valid url, got error: %v", err)
}
if parsed.Scheme != "hysteria2" {
t.Fatalf("expected hysteria2 scheme, got %s", parsed.Scheme)
}
parsed := mustParse(t, raw)
if parsed.User == nil {
t.Fatal("expected userinfo to be present")
}
@@ -32,42 +121,200 @@ func TestBuildHysteria2Url_EncodesUserInfoQueryAndFragment(t *testing.T) {
}
q := parsed.Query()
if q.Get("obfs") != "salamander" {
t.Fatalf("expected obfs=salamander, got %q", q.Get("obfs"))
}
if q.Get("obfs-password") != "obf+s&pass=@x" {
t.Fatalf("expected decoded obfs-password, got %q", q.Get("obfs-password"))
}
if q.Get("sni") != "exa mple.com" {
t.Fatalf("expected decoded sni, got %q", q.Get("sni"))
}
if q.Get("insecure") != "0" {
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
}
if parsed.Fragment != "my remark #1" {
t.Fatalf("expected decoded fragment, got %q", parsed.Fragment)
}
if strings.Contains(raw, "u@ser:#&=+ pass") {
t.Fatalf("raw uri must not contain unescaped userinfo: %s", raw)
}
}
func TestBuildHysteria2Url_MinimalConfig(t *testing.T) {
raw := buildHysteria2Url("pass", "example.com", 8443, "", "", "", "")
// Регрессия на upstream-баг 2.9.3: `+` в credentials при разборе share link
// превращался в пробел. Проверяем, что кодирование однозначно.
func TestBuildHysteria2Url_PlusInCredentialsSurvivesRoundTrip(t *testing.T) {
cases := []string{"a+b", "a b", "a#b", "a@b", "a/b", "a?b", "a&b", "a=b", "a%b", "тест"}
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("expected valid url, got error: %v", err)
for _, value := range cases {
raw := buildHysteria2Url(value, "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: value}, "", "")
parsed := mustParse(t, raw)
if got := parsed.User.Username(); got != value {
t.Fatalf("userinfo round-trip failed for %q: got %q (raw=%s)", value, got, raw)
}
if got := parsed.Query().Get("obfs-password"); got != value {
t.Fatalf("obfs-password round-trip failed for %q: got %q (raw=%s)", value, got, raw)
}
}
if parsed.Host != "example.com:8443" {
t.Fatalf("unexpected host: %s", parsed.Host)
// Ключевой инвариант: литеральный `+` кодируется как %2B и не может быть
// прочитан клиентом как пробел, а пробел кодируется отдельно от него.
plus := buildHysteria2Url("a+b", "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: "a+b"}, "", "")
if !strings.Contains(mustParse(t, plus).RawQuery, "%2B") {
t.Fatalf("literal '+' must be percent-encoded as %%2B: %s", plus)
}
q := parsed.Query()
if q.Get("insecure") != "0" {
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
}
if q.Get("obfs") != "" || q.Get("obfs-password") != "" || q.Get("sni") != "" {
t.Fatalf("unexpected optional query params in minimal config: %s", parsed.RawQuery)
space := buildHysteria2Url("a b", "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: "a b"}, "", "")
if mustParse(t, space).Query().Get("obfs-password") == "a+b" {
t.Fatalf("space and '+' must not collapse to the same value: %s", space)
}
}
func TestResolveShareSni(t *testing.T) {
tests := []struct {
name string
acmeDomain string
publicHost string
envDomain string
want string
}{
{name: "acme domain wins", acmeDomain: "acme.example.com", publicHost: "vpn.example.com", envDomain: "env.example.com", want: "acme.example.com"},
{name: "file tls falls back to product domain", acmeDomain: "", publicHost: "vpn.example.com", envDomain: "env.example.com", want: "env.example.com"},
{name: "public host used when domain is empty", acmeDomain: "", publicHost: "vpn.example.com", envDomain: "", want: "vpn.example.com"},
{name: "ipv4 public host is not a valid sni", acmeDomain: "", publicHost: "203.0.113.10", envDomain: "", want: ""},
{name: "ipv4 env domain is not a valid sni", acmeDomain: "", publicHost: "203.0.113.10", envDomain: "198.51.100.7", want: ""},
{name: "whitespace is trimmed", acmeDomain: " acme.example.com ", publicHost: "", envDomain: "", want: "acme.example.com"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("HY2XS_DOMAIN", tc.envDomain)
if got := resolveShareSni(tc.acmeDomain, tc.publicHost); got != tc.want {
t.Fatalf("resolveShareSni(%q, %q) with HY2XS_DOMAIN=%q = %q, want %q",
tc.acmeDomain, tc.publicHost, tc.envDomain, got, tc.want)
}
})
}
}
func TestObfsShare_FromServerConfig(t *testing.T) {
tests := []struct {
name string
yamlConfig string
wantType string
wantPassword string
}{
{
name: "gecko",
yamlConfig: "obfs:\n type: gecko\n gecko:\n password: gecko-pass\n minPacketSize: 512\n maxPacketSize: 1200\n",
wantType: "gecko",
wantPassword: "gecko-pass",
},
{
name: "salamander",
yamlConfig: "obfs:\n type: salamander\n salamander:\n password: salamander-pass\n",
wantType: "salamander",
wantPassword: "salamander-pass",
},
{
name: "no obfs section",
yamlConfig: "listen: 0.0.0.0:443\n",
},
{
name: "type without matching subsection",
yamlConfig: "obfs:\n type: gecko\n salamander:\n password: mismatched\n",
},
{
name: "unknown type",
yamlConfig: "obfs:\n type: future\n gecko:\n password: p\n",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var config bo.Hysteria2ServerConfig
if err := yaml.Unmarshal([]byte(tc.yamlConfig), &config); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
got := config.ObfsShare()
if got.Type != tc.wantType || got.Password != tc.wantPassword {
t.Fatalf("ObfsShare() = %+v, want type=%q password=%q", got, tc.wantType, tc.wantPassword)
}
})
}
}
func TestHysteria2ServerConfig_ParsesModernUpstreamSchema(t *testing.T) {
raw := `listen: 0.0.0.0:443
tls:
cert: /etc/hysteria/server.crt
key: /etc/hysteria/server.key
clientCA: /etc/hysteria/client-ca.crt
ech:
keyPath: /etc/hysteria/ech.pem
obfs:
type: gecko
gecko:
password: p
minPacketSize: 512
maxPacketSize: 1200
bandwidth:
up: 50 mbps
down: 50 mbps
disableLossCompensation: false
congestion:
type: bbr
bbrProfile: standard
quic:
disableStatelessReset: false
mimic:
enabled: false
interface: eth0
xdpMode: skb
realm:
stunServers:
- stun.example.com:3478
ipMode: dual
portMapping:
enabled: false
timeout: 10s
masquerade:
type: proxy
proxy:
url: https://example.com
rewriteHost: true
insecure: false
xForwarded: true
trafficStats:
listen: 127.0.0.1:36712
`
var config bo.Hysteria2ServerConfig
if err := yaml.Unmarshal([]byte(raw), &config); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if config.ECH == nil || config.ECH.KeyPath == nil || *config.ECH.KeyPath != "/etc/hysteria/ech.pem" {
t.Fatal("ech.keyPath was not parsed")
}
if config.TLS == nil || config.TLS.ClientCA == nil || *config.TLS.ClientCA != "/etc/hysteria/client-ca.crt" {
t.Fatal("tls.clientCA was not parsed")
}
if config.Congestion == nil || config.Congestion.Type == nil || *config.Congestion.Type != "bbr" {
t.Fatal("congestion.type was not parsed")
}
if config.Congestion.BBRProfile == nil || *config.Congestion.BBRProfile != "standard" {
t.Fatal("congestion.bbrProfile was not parsed")
}
if config.Bandwidth == nil || config.Bandwidth.DisableLossCompensation == nil || *config.Bandwidth.DisableLossCompensation {
t.Fatal("bandwidth.disableLossCompensation was not parsed")
}
if config.QUIC == nil || config.QUIC.DisableStatelessReset == nil || *config.QUIC.DisableStatelessReset {
t.Fatal("quic.disableStatelessReset was not parsed")
}
if config.Mimic == nil || config.Mimic.Enabled == nil || *config.Mimic.Enabled {
t.Fatal("mimic section was not parsed")
}
if config.Realm == nil || len(config.Realm.StunServers) != 1 || config.Realm.PortMapping == nil {
t.Fatal("realm section was not parsed")
}
if config.Masquerade == nil || config.Masquerade.Proxy == nil ||
config.Masquerade.Proxy.XForwarded == nil || !*config.Masquerade.Proxy.XForwarded {
t.Fatal("masquerade.proxy.xForwarded was not parsed")
}
}
+208
View File
@@ -0,0 +1,208 @@
package service
import (
"errors"
"net/url"
"os"
"strings"
"gopkg.in/yaml.v3"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
)
// RedactedPlaceholder — маркер вырезанного секрета в экспортируемом конфиге.
const RedactedPlaceholder = "<redacted>"
// GetRawHysteria2Config возвращает исходный YAML серверного конфига без
// прохода через типизированную модель.
//
// Это отдельный слой от GetHysteria2Config намеренно: типизированная модель
// отражает известные HY2XS поля и используется для отображения, а сырой YAML
// нужен там, где нельзя потерять поля, о которых HY2XS пока не знает.
func GetRawHysteria2Config() (string, error) {
config, err := dao.GetConfig("key = ?", constant.Hysteria2Config)
if err != nil {
return "", err
}
if config.Value != nil && strings.TrimSpace(*config.Value) != "" {
return *config.Value, nil
}
content, readErr := os.ReadFile(constant.Hysteria2ConfigPath)
if readErr != nil {
return "", readErr
}
return string(content), nil
}
// ExportHysteria2ConfigYaml готовит серверный конфиг к выгрузке оператору.
//
// Гарантии:
// 1. неизвестные upstream-поля сохраняются — будущие версии Hysteria не
// обрезаются только потому, что HY2XS ещё не научился их показывать;
// 2. секреты не покидают сервер в открытом виде.
func ExportHysteria2ConfigYaml() ([]byte, error) {
raw, err := GetRawHysteria2Config()
if err != nil {
return nil, err
}
return SanitizeHysteria2ConfigYaml(raw)
}
// SanitizeHysteria2ConfigYaml вырезает секреты из YAML, сохраняя структуру и
// все прочие поля документа.
func SanitizeHysteria2ConfigYaml(raw string) ([]byte, error) {
if strings.TrimSpace(raw) == "" {
return nil, errors.New("hysteria2 config is empty")
}
var document yaml.Node
if err := yaml.Unmarshal([]byte(raw), &document); err != nil {
return nil, err
}
redactNode(&document, nil)
out, err := yaml.Marshal(&document)
if err != nil {
return nil, err
}
return out, nil
}
// isSecretKey — обобщённое правило. Оно важно именно потому, что экспорт
// сохраняет неизвестные поля: новое upstream-поле с секретом будет вырезано
// ещё до того, как HY2XS про него узнает.
func isSecretKey(key string) bool {
lowered := strings.ToLower(key)
for _, marker := range []string{"password", "passwd", "secret", "token", "credential"} {
if strings.Contains(lowered, marker) {
return true
}
}
return false
}
// isSecretMapPath — узлы, где секретом являются все значения карты, а не ключ.
func isSecretMapPath(path []string) bool {
joined := strings.Join(path, ".")
switch joined {
case "auth.userpass", "acme.dns.config":
return true
}
return false
}
func redactNode(node *yaml.Node, path []string) {
if node == nil {
return
}
switch node.Kind {
case yaml.DocumentNode:
for _, child := range node.Content {
redactNode(child, path)
}
case yaml.SequenceNode:
for _, child := range node.Content {
// Индекс не попадает в путь: правила формулируются по именам полей.
redactNode(child, path)
}
case yaml.MappingNode:
if isSecretMapPath(path) {
redactAllScalarValues(node)
return
}
for i := 0; i+1 < len(node.Content); i += 2 {
key := node.Content[i].Value
value := node.Content[i+1]
childPath := append(append([]string{}, path...), key)
if isSecretKey(key) {
redactSubtree(value)
continue
}
if value.Kind == yaml.ScalarNode && looksLikeURLKey(key) {
value.Value = sanitizeURLValue(value.Value)
value.Tag = "!!str"
value.Style = 0
continue
}
redactNode(value, childPath)
}
}
}
func looksLikeURLKey(key string) bool {
lowered := strings.ToLower(key)
return lowered == "url" || lowered == "addr"
}
func redactSubtree(node *yaml.Node) {
if node == nil {
return
}
switch node.Kind {
case yaml.ScalarNode:
setRedacted(node)
case yaml.MappingNode, yaml.SequenceNode, yaml.DocumentNode:
redactAllScalarValues(node)
}
}
func redactAllScalarValues(node *yaml.Node) {
switch node.Kind {
case yaml.MappingNode:
for i := 0; i+1 < len(node.Content); i += 2 {
redactSubtree(node.Content[i+1])
}
case yaml.SequenceNode, yaml.DocumentNode:
for _, child := range node.Content {
redactSubtree(child)
}
case yaml.ScalarNode:
setRedacted(node)
}
}
func setRedacted(node *yaml.Node) {
node.Value = RedactedPlaceholder
node.Tag = "!!str"
node.Style = 0
}
// sanitizeURLValue убирает из URL встроенные учётные данные и секретные
// query-параметры, сохраняя остальную часть адреса читаемой.
func sanitizeURLValue(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return raw
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" {
return raw
}
if parsed.User != nil {
parsed.User = url.User(RedactedPlaceholder)
}
query := parsed.Query()
changed := false
for key := range query {
if isSecretKey(key) {
query.Set(key, RedactedPlaceholder)
changed = true
}
}
if changed {
parsed.RawQuery = query.Encode()
}
return parsed.String()
}
+247
View File
@@ -0,0 +1,247 @@
package service
import (
"strings"
"testing"
"gopkg.in/yaml.v3"
)
const exportSampleConfig = `listen: 0.0.0.0:443
acme:
domains:
- vpn.example.com
email: admin@example.com
ca: letsencrypt
dir: /var/lib/hysteria/acme
listenHost: 0.0.0.0
type: http
dns:
name: cloudflare
config:
cloudflare_api_token: super-secret-token
zone: example.com
auth:
type: http
http:
url: http://127.0.0.1:8080/hui/hysteria2/auth?access_token=machine-secret
insecure: false
userpass:
alice: alice-password
bob: bob-password
obfs:
type: gecko
gecko:
password: gecko-obfs-secret
minPacketSize: 512
maxPacketSize: 1200
bandwidth:
up: 50 mbps
down: 50 mbps
disableLossCompensation: false
congestion:
type: bbr
bbrProfile: standard
trafficStats:
listen: 127.0.0.1:36712
secret: traffic-stats-secret
outbounds:
- name: upstream
type: socks5
socks5:
addr: 10.0.0.1:1080
username: proxyuser
password: proxy-password
quic:
initStreamReceiveWindow: 8388608
disableStatelessReset: false
someFutureUpstreamFeature:
enabled: true
nested:
tuning: 42
list:
- a
- b
`
func sanitizeForTest(t *testing.T, raw string) string {
t.Helper()
out, err := SanitizeHysteria2ConfigYaml(raw)
if err != nil {
t.Fatalf("sanitize failed: %v", err)
}
return string(out)
}
func TestSanitizeHysteria2ConfigYaml_RemovesSecrets(t *testing.T) {
sanitized := sanitizeForTest(t, exportSampleConfig)
leaked := []string{
"gecko-obfs-secret",
"traffic-stats-secret",
"machine-secret",
"alice-password",
"bob-password",
"proxy-password",
"super-secret-token",
}
for _, secret := range leaked {
if strings.Contains(sanitized, secret) {
t.Fatalf("exported config leaks secret %q:\n%s", secret, sanitized)
}
}
}
func TestSanitizeHysteria2ConfigYaml_PreservesUnknownUpstreamFields(t *testing.T) {
sanitized := sanitizeForTest(t, exportSampleConfig)
var parsed map[string]any
if err := yaml.Unmarshal([]byte(sanitized), &parsed); err != nil {
t.Fatalf("sanitized output is not valid yaml: %v", err)
}
future, ok := parsed["someFutureUpstreamFeature"].(map[string]any)
if !ok {
t.Fatalf("unknown upstream section was dropped:\n%s", sanitized)
}
if future["enabled"] != true {
t.Fatalf("unknown upstream scalar was dropped: %+v", future)
}
nested, ok := future["nested"].(map[string]any)
if !ok {
t.Fatalf("nested unknown section was dropped: %+v", future)
}
if nested["tuning"] != 42 {
t.Fatalf("nested unknown value was dropped: %+v", nested)
}
if list, ok := nested["list"].([]any); !ok || len(list) != 2 {
t.Fatalf("nested unknown list was dropped: %+v", nested)
}
}
func TestSanitizeHysteria2ConfigYaml_KeepsNonSecretOperationalFields(t *testing.T) {
sanitized := sanitizeForTest(t, exportSampleConfig)
kept := []string{
"listen: 0.0.0.0:443",
"vpn.example.com",
"type: gecko",
"minPacketSize: 512",
"maxPacketSize: 1200",
"bbrProfile: standard",
"disableLossCompensation: false",
"disableStatelessReset: false",
"127.0.0.1:36712",
}
for _, fragment := range kept {
if !strings.Contains(sanitized, fragment) {
t.Fatalf("exported config lost operational field %q:\n%s", fragment, sanitized)
}
}
}
func TestSanitizeHysteria2ConfigYaml_StripsAccessTokenButKeepsUrlShape(t *testing.T) {
sanitized := sanitizeForTest(t, exportSampleConfig)
if !strings.Contains(sanitized, "127.0.0.1:8080/hui/hysteria2/auth") {
t.Fatalf("auth url shape was lost:\n%s", sanitized)
}
if !strings.Contains(sanitized, "access_token="+RedactedPlaceholder) &&
!strings.Contains(sanitized, "access_token=%3Credacted%3E") {
t.Fatalf("access_token was not redacted:\n%s", sanitized)
}
}
func TestSanitizeURLValue(t *testing.T) {
tests := []struct {
name string
in string
mustKeep []string
mustRemove []string
}{
{
name: "strips access token",
in: "http://127.0.0.1:8080/hui/hysteria2/auth?access_token=abc123",
mustKeep: []string{"127.0.0.1:8080", "/hui/hysteria2/auth"},
mustRemove: []string{"abc123"},
},
{
name: "strips embedded credentials",
in: "https://user:p4ssw0rd@proxy.example.com:8443/path",
mustKeep: []string{"proxy.example.com:8443", "/path"},
mustRemove: []string{"p4ssw0rd"},
},
{
name: "leaves clean url untouched",
in: "https://example.com/masq",
mustKeep: []string{"https://example.com/masq"},
},
{
name: "leaves plain host:port untouched",
in: "10.0.0.1:1080",
mustKeep: []string{"10.0.0.1:1080"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := sanitizeURLValue(tc.in)
for _, fragment := range tc.mustKeep {
if !strings.Contains(got, fragment) {
t.Fatalf("sanitizeURLValue(%q) = %q, expected to keep %q", tc.in, got, fragment)
}
}
for _, fragment := range tc.mustRemove {
if strings.Contains(got, fragment) {
t.Fatalf("sanitizeURLValue(%q) = %q, expected to remove %q", tc.in, got, fragment)
}
}
})
}
}
func TestIsSecretKey(t *testing.T) {
secret := []string{"password", "Password", "obfs_password", "secret", "trafficSecret", "access_token", "apiToken", "credentials"}
for _, key := range secret {
if !isSecretKey(key) {
t.Fatalf("expected %q to be treated as secret", key)
}
}
// Пути к файлам не являются секретами и должны остаться читаемыми.
notSecret := []string{"key", "keyPath", "cert", "clientCA", "listen", "url", "type", "dir"}
for _, key := range notSecret {
if isSecretKey(key) {
t.Fatalf("expected %q to stay visible in export", key)
}
}
}
func TestSanitizeHysteria2ConfigYaml_RejectsEmptyInput(t *testing.T) {
if _, err := SanitizeHysteria2ConfigYaml(" \n"); err == nil {
t.Fatal("expected an error for empty config")
}
}
func TestSanitizeHysteria2ConfigYaml_RedactsUnknownFutureSecretField(t *testing.T) {
// Обратная сторона сохранения неизвестных полей: новое upstream-поле с
// секретом должно вырезаться до того, как HY2XS про него узнает.
raw := "listen: 0.0.0.0:443\nfutureFeature:\n apiSecret: leak-me\n nested:\n userPassword: leak-me-too\n"
sanitized := sanitizeForTest(t, raw)
if strings.Contains(sanitized, "leak-me") {
t.Fatalf("unknown future secret field leaked:\n%s", sanitized)
}
if !strings.Contains(sanitized, "futureFeature") {
t.Fatalf("unknown future section was dropped:\n%s", sanitized)
}
}