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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user