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:
@@ -4,9 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/bo"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/entity"
|
||||
@@ -183,72 +180,24 @@ func UpdateHysteria2Config(c *gin.Context) {
|
||||
vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c)
|
||||
}
|
||||
|
||||
// ExportHysteria2Config отдаёт оператору фактический серверный конфиг.
|
||||
//
|
||||
// Экспорт работает от исходного YAML, а не от типизированной модели: поля,
|
||||
// о которых HY2XS ещё не знает, обязаны пережить выгрузку. Секреты при этом
|
||||
// вырезаются — файл покидает сервер.
|
||||
func ExportHysteria2Config(c *gin.Context) {
|
||||
hysteria2ServerConfig, err := service.GetHysteria2Config()
|
||||
sanitized, err := service.ExportHysteria2ConfigYaml()
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
// Значения по умолчанию
|
||||
config, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.Hysteria2TrafficStatsSecret})
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
var hUIWebPort string
|
||||
var trafficStatsSecret string
|
||||
for _, item := range config {
|
||||
if *item.Key == constant.HUIWebPort {
|
||||
hUIWebPort = *item.Value
|
||||
} else if *item.Key == constant.Hysteria2TrafficStatsSecret {
|
||||
trafficStatsSecret = *item.Value
|
||||
}
|
||||
}
|
||||
|
||||
if hUIWebPort == "" || trafficStatsSecret == "" {
|
||||
logrus.Errorf("hUIWebPort or trafficStatsSecret is nil")
|
||||
vo.Fail(constant.SysError, c)
|
||||
return
|
||||
}
|
||||
|
||||
authHttpUrl, err := service.GetAuthHttpUrl()
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
authType := "http"
|
||||
authHttpInsecure := true
|
||||
var auth bo.ServerConfigAuth
|
||||
auth.Type = &authType
|
||||
var http bo.ServerConfigAuthHTTP
|
||||
http.URL = &authHttpUrl
|
||||
http.Insecure = &authHttpInsecure
|
||||
auth.HTTP = &http
|
||||
hysteria2ServerConfig.Auth = &auth
|
||||
if hysteria2ServerConfig.TrafficStats == nil {
|
||||
hysteria2ServerConfig.TrafficStats = &bo.ServerConfigTrafficStats{}
|
||||
}
|
||||
hysteria2ServerConfig.TrafficStats.Secret = &trafficStatsSecret
|
||||
|
||||
fileName := fmt.Sprintf("Hysteria2Config-%s.yaml", time.Now().Format("20060102150405"))
|
||||
filePath := filepath.Join(constant.ExportPathDir, fileName)
|
||||
|
||||
if err = util.ExportFile(filePath, hysteria2ServerConfig, 1); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
if !util.Exists(filePath) {
|
||||
vo.Fail("file not exist", c)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Transfer-Encoding", "binary")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
|
||||
c.File(filePath)
|
||||
c.Data(200, "application/octet-stream", sanitized)
|
||||
}
|
||||
|
||||
func ImportHysteria2Config(c *gin.Context) {
|
||||
|
||||
@@ -26,6 +26,10 @@ export interface Hysteria2ServerConfig {
|
||||
cert: string;
|
||||
key: string;
|
||||
sniGuard?: string;
|
||||
clientCA?: string;
|
||||
};
|
||||
ech?: {
|
||||
keyPath?: string;
|
||||
};
|
||||
acme?: {
|
||||
domains: string[];
|
||||
@@ -51,9 +55,14 @@ export interface Hysteria2ServerConfig {
|
||||
};
|
||||
obfs?: {
|
||||
type: string;
|
||||
salamander: {
|
||||
salamander?: {
|
||||
password: string;
|
||||
};
|
||||
gecko?: {
|
||||
password: string;
|
||||
minPacketSize?: number;
|
||||
maxPacketSize?: number;
|
||||
};
|
||||
};
|
||||
quic?: {
|
||||
initStreamReceiveWindow?: number;
|
||||
@@ -63,10 +72,16 @@ export interface Hysteria2ServerConfig {
|
||||
maxIdleTimeout?: string;
|
||||
maxIncomingStreams?: number;
|
||||
disablePathMTUDiscovery?: boolean;
|
||||
disableStatelessReset?: boolean;
|
||||
};
|
||||
bandwidth?: {
|
||||
up: string;
|
||||
down: string;
|
||||
disableLossCompensation?: boolean;
|
||||
};
|
||||
congestion?: {
|
||||
type?: string;
|
||||
bbrProfile?: string;
|
||||
};
|
||||
ignoreClientBandwidth?: boolean;
|
||||
speedTest?: boolean;
|
||||
@@ -122,6 +137,7 @@ export interface Hysteria2ServerConfig {
|
||||
url: string;
|
||||
rewriteHost: boolean;
|
||||
insecure: boolean;
|
||||
xForwarded?: boolean;
|
||||
};
|
||||
string?: {
|
||||
content: string;
|
||||
@@ -132,6 +148,26 @@ export interface Hysteria2ServerConfig {
|
||||
listenHTTPS?: string;
|
||||
forceHTTPS?: boolean;
|
||||
};
|
||||
mimic?: {
|
||||
enabled?: boolean;
|
||||
interface?: string;
|
||||
xdpMode?: string;
|
||||
path?: string;
|
||||
extraArgs?: string[];
|
||||
};
|
||||
realm?: {
|
||||
stunServers?: string[];
|
||||
stunTimeout?: string;
|
||||
punchTimeout?: string;
|
||||
heartbeatInterval?: string;
|
||||
insecure?: boolean;
|
||||
ipMode?: string;
|
||||
portMapping?: {
|
||||
enabled?: boolean;
|
||||
timeout?: string;
|
||||
lifetime?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
|
||||
@@ -144,9 +180,9 @@ export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
|
||||
acme: {
|
||||
domains: [],
|
||||
email: "",
|
||||
ca: "zerossl",
|
||||
ca: "letsencrypt",
|
||||
listenHost: "0.0.0.0",
|
||||
dir: "my_acme_dir",
|
||||
dir: "/var/lib/hysteria/acme",
|
||||
type: "",
|
||||
http: {
|
||||
altPort: 8888,
|
||||
@@ -155,7 +191,7 @@ export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
|
||||
altPort: 44333,
|
||||
},
|
||||
dns: {
|
||||
name: "gomommy",
|
||||
name: "cloudflare",
|
||||
config: {},
|
||||
},
|
||||
disableHTTP: false,
|
||||
@@ -164,9 +200,11 @@ export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
|
||||
altTLSALPNPort: 443,
|
||||
},
|
||||
obfs: {
|
||||
type: "salamander",
|
||||
salamander: {
|
||||
password: "cry_me_a_r1ver",
|
||||
type: "gecko",
|
||||
gecko: {
|
||||
password: "",
|
||||
minPacketSize: 512,
|
||||
maxPacketSize: 1200,
|
||||
},
|
||||
},
|
||||
quic: {
|
||||
@@ -177,10 +215,16 @@ export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
|
||||
maxIdleTimeout: "30s",
|
||||
maxIncomingStreams: 1024,
|
||||
disablePathMTUDiscovery: false,
|
||||
disableStatelessReset: false,
|
||||
},
|
||||
bandwidth: {
|
||||
up: "1 gbps",
|
||||
down: "1 gbps",
|
||||
up: "50 mbps",
|
||||
down: "50 mbps",
|
||||
disableLossCompensation: false,
|
||||
},
|
||||
congestion: {
|
||||
type: "bbr",
|
||||
bbrProfile: "standard",
|
||||
},
|
||||
ignoreClientBandwidth: false,
|
||||
speedTest: false,
|
||||
@@ -236,6 +280,7 @@ export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
|
||||
url: "",
|
||||
rewriteHost: true,
|
||||
insecure: false,
|
||||
xForwarded: false,
|
||||
},
|
||||
string: {
|
||||
content: "hello stupid world",
|
||||
|
||||
@@ -128,6 +128,17 @@ export default {
|
||||
name: "Peer",
|
||||
remark: "Remark",
|
||||
secret: "Secret",
|
||||
form: {
|
||||
namePlaceholder: "e.g. ivan-laptop",
|
||||
nameHint:
|
||||
"Short peer identifier. Use latin letters, digits and hyphens — the name becomes part of the auto-generated secret.",
|
||||
remarkPlaceholder: "e.g. Ivan's laptop, sales team",
|
||||
remarkHint: "Optional operator note. It is never shown to the client.",
|
||||
secretPlaceholder: "leave empty to generate automatically",
|
||||
secretHint:
|
||||
"Client connection password. Leave empty to generate one automatically. If set manually: 6 to 128 characters.",
|
||||
quotaHint: "Traffic limit in bytes. Use -1 for unlimited.",
|
||||
},
|
||||
maxDevices: "Max devices",
|
||||
disabled: "Disabled",
|
||||
status: "Status",
|
||||
@@ -234,6 +245,7 @@ export default {
|
||||
obfs: "Obfuscation",
|
||||
quic: "QUIC parameters",
|
||||
bandwidth: "Bandwidth",
|
||||
congestion: "Congestion control",
|
||||
speedTest: "Speed Test",
|
||||
udp: "UDP",
|
||||
resolver: "Resolver",
|
||||
@@ -282,10 +294,17 @@ export default {
|
||||
"Alternate TLS-ALPN challenge port. (Note: If you want to use anything other than 443, you must set up port forward/SNI proxy from 443 to that port, otherwise ACME will not be able to issue the certificate.)",
|
||||
},
|
||||
obfs: {
|
||||
type: "Type",
|
||||
type: "Obfuscation type: gecko (HY2XS production default) or salamander (compatibility fallback).",
|
||||
salamander: {
|
||||
password: "Replace with a strong password of your choice.",
|
||||
},
|
||||
gecko: {
|
||||
password: "Replace with a strong password of your choice.",
|
||||
minPacketSize:
|
||||
"Minimum QUIC handshake fragment size. HY2XS baseline: 512.",
|
||||
maxPacketSize:
|
||||
"Maximum QUIC handshake fragment size. HY2XS baseline: 1200, upstream limit: 2048.",
|
||||
},
|
||||
},
|
||||
quic: {
|
||||
initStreamReceiveWindow: "The initial QUIC stream receive window size.",
|
||||
@@ -299,10 +318,18 @@ export default {
|
||||
maxIncomingStreams:
|
||||
"The maximum number of concurrent incoming streams.",
|
||||
disablePathMTUDiscovery: "Disable QUIC path MTU discovery.",
|
||||
disableStatelessReset:
|
||||
"Disable QUIC stateless reset. Kept off in the HY2XS baseline: stateless reset lets a client with a stale connection reconnect immediately after a server restart or device sleep.",
|
||||
},
|
||||
bandwidth: {
|
||||
up: "Up",
|
||||
down: "Down",
|
||||
disableLossCompensation:
|
||||
"Disable loss compensation. Kept off in the HY2XS baseline, so compensation stays active.",
|
||||
},
|
||||
congestion: {
|
||||
type: "Fallback congestion controller: bbr or reno. Used when Brutal bandwidth is not negotiated by both sides.",
|
||||
bbrProfile: "BBR profile: standard, conservative or aggressive.",
|
||||
},
|
||||
ignoreClientBandwidth:
|
||||
"When enabled, makes the server to disregard any bandwidth hints set by clients",
|
||||
|
||||
@@ -124,6 +124,17 @@ export default {
|
||||
name: "Пир",
|
||||
remark: "Комментарий",
|
||||
secret: "Секрет",
|
||||
form: {
|
||||
namePlaceholder: "например, ivan-laptop",
|
||||
nameHint:
|
||||
"Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет.",
|
||||
remarkPlaceholder: "например, Ноутбук Ивана, отдел продаж",
|
||||
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
|
||||
secretPlaceholder: "оставьте пустым — сгенерируем автоматически",
|
||||
secretHint:
|
||||
"Пароль подключения клиента. Если оставить поле пустым, секрет будет сгенерирован автоматически. При ручном вводе: от 6 до 128 символов.",
|
||||
quotaHint: "Лимит трафика в байтах. Укажите -1 для безлимита.",
|
||||
},
|
||||
maxDevices: "Лимит устройств",
|
||||
disabled: "Отключён",
|
||||
status: "Статус",
|
||||
@@ -230,6 +241,7 @@ export default {
|
||||
obfs: "Маскировка",
|
||||
quic: "Параметры QUIC",
|
||||
bandwidth: "Полоса",
|
||||
congestion: "Congestion control",
|
||||
speedTest: "Тест скорости",
|
||||
udp: "UDP",
|
||||
resolver: "DNS",
|
||||
@@ -264,8 +276,15 @@ export default {
|
||||
altTLSALPNPort: "Альтернативный TLS-ALPN-порт",
|
||||
},
|
||||
obfs: {
|
||||
type: "Тип",
|
||||
type: "Тип обфускации: gecko (production default HY2XS) или salamander (совместимость)",
|
||||
salamander: { password: "Сильный пароль Salamander" },
|
||||
gecko: {
|
||||
password: "Сильный пароль Gecko",
|
||||
minPacketSize:
|
||||
"Минимальный размер фрагмента QUIC handshake. Baseline HY2XS: 512",
|
||||
maxPacketSize:
|
||||
"Максимальный размер фрагмента QUIC handshake. Baseline HY2XS: 1200, верхний предел upstream: 2048",
|
||||
},
|
||||
},
|
||||
quic: {
|
||||
initStreamReceiveWindow: "Начальное окно приёма QUIC stream",
|
||||
@@ -275,8 +294,19 @@ export default {
|
||||
maxIdleTimeout: "Максимальный idle timeout",
|
||||
maxIncomingStreams: "Максимум входящих stream",
|
||||
disablePathMTUDiscovery: "Отключить QUIC path MTU discovery",
|
||||
disableStatelessReset:
|
||||
"Отключить QUIC stateless reset. В baseline HY2XS выключено: stateless reset ускоряет переподключение клиента после перезапуска сервера или сна устройства",
|
||||
},
|
||||
bandwidth: {
|
||||
up: "Вверх",
|
||||
down: "Вниз",
|
||||
disableLossCompensation:
|
||||
"Отключить компенсацию потерь. В baseline HY2XS выключено, то есть компенсация работает",
|
||||
},
|
||||
congestion: {
|
||||
type: "Fallback congestion controller: bbr или reno. Применяется, когда Brutal bandwidth не согласован сторонами",
|
||||
bbrProfile: "Профиль BBR: standard, conservative или aggressive",
|
||||
},
|
||||
bandwidth: { up: "Вверх", down: "Вниз" },
|
||||
ignoreClientBandwidth: "Игнорировать bandwidth, заявленный клиентом",
|
||||
speedTest: "Встроенный сервер теста скорости",
|
||||
disableUDP: "Отключить UDP forwarding",
|
||||
|
||||
@@ -361,21 +361,69 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
v-if="dataForm.obfs.type === 'salamander'"
|
||||
:content="$t('hysteria.config.obfs.salamander.password')"
|
||||
placement="bottom"
|
||||
<template
|
||||
v-if="
|
||||
dataForm.obfs.type === 'salamander' && dataForm.obfs.salamander
|
||||
"
|
||||
>
|
||||
<el-form-item
|
||||
label="obfs.salamander.password"
|
||||
prop="obfs.salamander.password"
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.obfs.salamander.password')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-input
|
||||
v-model="dataForm.obfs.salamander.password"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-form-item
|
||||
label="obfs.salamander.password"
|
||||
prop="obfs.salamander.password"
|
||||
>
|
||||
<el-input
|
||||
v-model="dataForm.obfs.salamander.password"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template
|
||||
v-if="dataForm.obfs.type === 'gecko' && dataForm.obfs.gecko"
|
||||
>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.obfs.gecko.password')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item
|
||||
label="obfs.gecko.password"
|
||||
prop="obfs.gecko.password"
|
||||
>
|
||||
<el-input v-model="dataForm.obfs.gecko.password" clearable />
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.obfs.gecko.minPacketSize')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item
|
||||
label="obfs.gecko.minPacketSize"
|
||||
prop="obfs.gecko.minPacketSize"
|
||||
>
|
||||
<el-input
|
||||
v-model.number="dataForm.obfs.gecko.minPacketSize"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.obfs.gecko.maxPacketSize')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item
|
||||
label="obfs.gecko.maxPacketSize"
|
||||
prop="obfs.gecko.maxPacketSize"
|
||||
>
|
||||
<el-input
|
||||
v-model.number="dataForm.obfs.gecko.maxPacketSize"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="$t('hysteria.quic')" name="quic" v-if="quic">
|
||||
<el-tooltip
|
||||
@@ -470,6 +518,17 @@
|
||||
<el-switch v-model="dataForm.quic.disablePathMTUDiscovery" />
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.quic.disableStatelessReset')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item
|
||||
label="quic.disableStatelessReset"
|
||||
prop="quic.disableStatelessReset"
|
||||
>
|
||||
<el-switch v-model="dataForm.quic.disableStatelessReset" />
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
:label="$t('hysteria.bandwidth')"
|
||||
@@ -494,6 +553,19 @@
|
||||
<el-input v-model="dataForm.bandwidth.down" clearable />
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.bandwidth.disableLossCompensation')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item
|
||||
label="bandwidth.disableLossCompensation"
|
||||
prop="bandwidth.disableLossCompensation"
|
||||
>
|
||||
<el-switch
|
||||
v-model="dataForm.bandwidth.disableLossCompensation"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.ignoreClientBandwidth')"
|
||||
placement="bottom"
|
||||
@@ -506,6 +578,53 @@
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
:label="$t('hysteria.congestion')"
|
||||
name="congestion"
|
||||
v-if="congestion && dataForm.congestion"
|
||||
>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.congestion.type')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="congestion.type" prop="congestion.type">
|
||||
<el-select
|
||||
v-model="dataForm.congestion.type"
|
||||
style="width: 100%"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="item in congestionTypes"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.congestion.bbrProfile')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item
|
||||
label="congestion.bbrProfile"
|
||||
prop="congestion.bbrProfile"
|
||||
>
|
||||
<el-select
|
||||
v-model="dataForm.congestion.bbrProfile"
|
||||
style="width: 100%"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="item in bbrProfiles"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
:label="$t('hysteria.speedTest')"
|
||||
name="speedTest"
|
||||
@@ -1020,15 +1139,22 @@ const tlsSniGuards = ref<string[]>(["dns-san", "disable", "strict"]);
|
||||
const aclTypes = ref<string[]>(["file", "inline"]);
|
||||
const acmeCas = ref<string[]>(["zerossl", "letsencrypt"]);
|
||||
const acmeTypes = ref<string[]>(["http", "tls", "dns"]);
|
||||
// Актуальный upstream-реестр ACME DNS-провайдеров.
|
||||
// `namedotcom` удалён в Hysteria 2.11.0 вместе с переписанным ACME-стеком:
|
||||
// конфигурация с ним больше не запускается.
|
||||
const dnsNames = ref<string[]>([
|
||||
"cloudflare",
|
||||
"duckdns",
|
||||
"gandi",
|
||||
"godaddy",
|
||||
"namedotcom",
|
||||
"namecheap",
|
||||
"njalla",
|
||||
"porkbun",
|
||||
"vultr",
|
||||
]);
|
||||
const obfsTypes = ref<string[]>(["salamander"]);
|
||||
const obfsTypes = ref<string[]>(["gecko", "salamander"]);
|
||||
const congestionTypes = ref<string[]>(["bbr", "reno"]);
|
||||
const bbrProfiles = ref<string[]>(["standard", "conservative", "aggressive"]);
|
||||
const resolverTypes = ref<string[]>(["tcp", "udp", "tls", "https"]);
|
||||
const masqueradeTypes = ref<string[]>(["file", "proxy", "string"]);
|
||||
|
||||
@@ -1043,6 +1169,7 @@ const state = reactive({
|
||||
obfs: false,
|
||||
quic: false,
|
||||
bandwidth: false,
|
||||
congestion: false,
|
||||
speedTest: false,
|
||||
udp: false,
|
||||
resolver: false,
|
||||
@@ -1067,6 +1194,7 @@ const {
|
||||
obfs,
|
||||
quic,
|
||||
bandwidth,
|
||||
congestion,
|
||||
speedTest,
|
||||
udp,
|
||||
resolver,
|
||||
@@ -1120,6 +1248,7 @@ const setConfig = () => {
|
||||
state.obfs = !!data?.obfs;
|
||||
state.quic = !!data?.quic;
|
||||
state.bandwidth = !!data?.bandwidth;
|
||||
state.congestion = !!data?.congestion;
|
||||
state.speedTest = !!data?.speedTest;
|
||||
state.udp = !!data?.disableUDP || !!data?.udpIdleTimeout;
|
||||
state.resolver = !!data?.resolver;
|
||||
@@ -1130,10 +1259,28 @@ const setConfig = () => {
|
||||
|
||||
state.dataForm = deepCopy(defaultHysteria2ServerConfig);
|
||||
assignWith(state.dataForm, data);
|
||||
dropInactiveObfsSubtype(state.dataForm, data);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Форма строится как merge дефолта и ответа API, поэтому неактивная ветка obfs
|
||||
// осталась бы от дефолта и UI показал бы блок, которого нет в конфиге сервера.
|
||||
const dropInactiveObfsSubtype = (
|
||||
form: Hysteria2ServerConfig,
|
||||
data: Hysteria2ServerConfig
|
||||
) => {
|
||||
if (!form.obfs) {
|
||||
return;
|
||||
}
|
||||
if (form.obfs.type !== "gecko" || !data?.obfs?.gecko) {
|
||||
delete form.obfs.gecko;
|
||||
}
|
||||
if (form.obfs.type !== "salamander" || !data?.obfs?.salamander) {
|
||||
delete form.obfs.salamander;
|
||||
}
|
||||
};
|
||||
|
||||
const setHysteria2Monitor = async () => {
|
||||
const { data } = await dashboardSummaryApi();
|
||||
state.hysteria2Monitor.version = data.hysteria.version;
|
||||
|
||||
@@ -65,9 +65,7 @@
|
||||
<el-table-column :label="$t('peer.traffic')" min-width="260">
|
||||
<template #default="scope">
|
||||
<div>
|
||||
{{
|
||||
formatBytes(scope.row.downloadBytes + scope.row.uploadBytes)
|
||||
}}
|
||||
{{ formatBytes(scope.row.downloadBytes + scope.row.uploadBytes) }}
|
||||
/ {{ quotaText(scope.row.quotaBytes) }}
|
||||
</div>
|
||||
<el-progress
|
||||
@@ -167,18 +165,32 @@
|
||||
:rules="rules"
|
||||
label-width="140px"
|
||||
>
|
||||
<el-form-item :label="$t('peer.name')" prop="name"
|
||||
><el-input v-model="dataForm.name"
|
||||
/></el-form-item>
|
||||
<el-form-item :label="$t('peer.remark')"
|
||||
><el-input v-model="dataForm.remark"
|
||||
/></el-form-item>
|
||||
<el-form-item :label="$t('peer.secret')" prop="secret"
|
||||
><el-input v-model="dataForm.secret" show-password
|
||||
/></el-form-item>
|
||||
<el-form-item :label="$t('peer.quota')"
|
||||
><el-input-number v-model="dataForm.quotaBytes" :min="-1"
|
||||
/></el-form-item>
|
||||
<el-form-item :label="$t('peer.name')" prop="name">
|
||||
<el-input
|
||||
v-model="dataForm.name"
|
||||
:placeholder="$t('peer.form.namePlaceholder')"
|
||||
/>
|
||||
<div class="form-hint">{{ $t("peer.form.nameHint") }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('peer.remark')">
|
||||
<el-input
|
||||
v-model="dataForm.remark"
|
||||
:placeholder="$t('peer.form.remarkPlaceholder')"
|
||||
/>
|
||||
<div class="form-hint">{{ $t("peer.form.remarkHint") }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('peer.secret')" prop="secret">
|
||||
<el-input
|
||||
v-model="dataForm.secret"
|
||||
show-password
|
||||
:placeholder="$t('peer.form.secretPlaceholder')"
|
||||
/>
|
||||
<div class="form-hint">{{ $t("peer.form.secretHint") }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('peer.quota')">
|
||||
<el-input-number v-model="dataForm.quotaBytes" :min="-1" />
|
||||
<div class="form-hint">{{ $t("peer.form.quotaHint") }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('peer.expireTime')"
|
||||
><el-date-picker
|
||||
v-model="dataForm.expiresAt"
|
||||
@@ -573,23 +585,35 @@ onMounted(handleQuery);
|
||||
.peer-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.peer-sub {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.peer-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.peer-more-btn {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.qr-dialog-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 0 20px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
+114
-1
@@ -1,12 +1,55 @@
|
||||
package bo
|
||||
|
||||
// ObfsShareConfig — та часть обфускации, которую способна описать официальная
|
||||
// схема hysteria2:// URI: только type и password.
|
||||
//
|
||||
// Gecko minPacketSize/maxPacketSize в ссылку не помещаются. Именно поэтому
|
||||
// HY2XS фиксирует их на upstream defaults 512/1200: иначе сгенерированная
|
||||
// ссылка не описывала бы подключение полностью.
|
||||
type ObfsShareConfig struct {
|
||||
Type string
|
||||
Password string
|
||||
}
|
||||
|
||||
// ObfsShare приводит любой поддерживаемый тип обфускации к единому виду для
|
||||
// генерации клиентской ссылки. Знание о подтипах живёт рядом с моделью, чтобы
|
||||
// добавление нового типа не требовало правок в слое share URI.
|
||||
func (c *Hysteria2ServerConfig) ObfsShare() ObfsShareConfig {
|
||||
if c == nil || c.Obfs == nil || c.Obfs.Type == nil {
|
||||
return ObfsShareConfig{}
|
||||
}
|
||||
|
||||
switch *c.Obfs.Type {
|
||||
case "salamander":
|
||||
if c.Obfs.Salamander != nil && c.Obfs.Salamander.Password != nil {
|
||||
return ObfsShareConfig{Type: "salamander", Password: *c.Obfs.Salamander.Password}
|
||||
}
|
||||
case "gecko":
|
||||
if c.Obfs.Gecko != nil && c.Obfs.Gecko.Password != nil {
|
||||
return ObfsShareConfig{Type: "gecko", Password: *c.Obfs.Gecko.Password}
|
||||
}
|
||||
}
|
||||
|
||||
return ObfsShareConfig{}
|
||||
}
|
||||
|
||||
// AcmeDomain возвращает первый ACME-домен, если он есть.
|
||||
func (c *Hysteria2ServerConfig) AcmeDomain() string {
|
||||
if c == nil || c.ACME == nil || len(c.ACME.Domains) == 0 {
|
||||
return ""
|
||||
}
|
||||
return c.ACME.Domains[0]
|
||||
}
|
||||
|
||||
type Hysteria2ServerConfig struct {
|
||||
Listen *string `yaml:"listen,omitempty" json:"listen" validate:"required"`
|
||||
Obfs *serverConfigObfs `yaml:"obfs,omitempty" json:"obfs" validate:"omitempty"`
|
||||
TLS *serverConfigTLS `yaml:"tls,omitempty" json:"tls" validate:"omitempty"`
|
||||
ACME *serverConfigACME `yaml:"acme,omitempty" json:"acme" validate:"omitempty"`
|
||||
ECH *serverConfigECH `yaml:"ech,omitempty" json:"ech" validate:"omitempty"`
|
||||
QUIC *serverConfigQUIC `yaml:"quic,omitempty" json:"quic" validate:"omitempty"`
|
||||
Bandwidth *serverConfigBandwidth `yaml:"bandwidth,omitempty" json:"bandwidth" validate:"omitempty"`
|
||||
Congestion *ServerConfigCongestion `yaml:"congestion,omitempty" json:"congestion" validate:"omitempty"`
|
||||
IgnoreClientBandwidth *bool `yaml:"ignoreClientBandwidth,omitempty" json:"ignoreClientBandwidth" validate:"omitempty"`
|
||||
SpeedTest *bool `yaml:"speedTest,omitempty" json:"speedTest" validate:"omitempty"`
|
||||
DisableUDP *bool `yaml:"disableUDP,omitempty" json:"disableUDP" validate:"omitempty"`
|
||||
@@ -18,21 +61,43 @@ type Hysteria2ServerConfig struct {
|
||||
Outbounds []serverConfigOutboundEntry `yaml:"outbounds,omitempty" json:"outbounds" validate:"omitempty"`
|
||||
TrafficStats *ServerConfigTrafficStats `yaml:"trafficStats,omitempty" json:"trafficStats" validate:"required"`
|
||||
Masquerade *serverConfigMasquerade `yaml:"masquerade,omitempty" json:"masquerade" validate:"omitempty"`
|
||||
Mimic *serverConfigMimic `yaml:"mimic,omitempty" json:"mimic" validate:"omitempty"`
|
||||
Realm *serverConfigRealm `yaml:"realm,omitempty" json:"realm" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type serverConfigObfsSalamander struct {
|
||||
Password *string `yaml:"password,omitempty" json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
// serverConfigObfsGecko описывает Gecko-обфускацию (Hysteria 2.9.2+).
|
||||
// Gecko достраивается поверх Salamander и дополнительно фрагментирует
|
||||
// QUIC handshake на пакеты случайного размера в диапазоне min..max.
|
||||
type serverConfigObfsGecko struct {
|
||||
Password *string `yaml:"password,omitempty" json:"password" validate:"required"`
|
||||
MinPacketSize *int `yaml:"minPacketSize,omitempty" json:"minPacketSize" validate:"omitempty"`
|
||||
MaxPacketSize *int `yaml:"maxPacketSize,omitempty" json:"maxPacketSize" validate:"omitempty"`
|
||||
}
|
||||
|
||||
// Обе ветки опциональны: в конфиге присутствует ровно одна из них,
|
||||
// соответствующая Type.
|
||||
type serverConfigObfs struct {
|
||||
Type *string `yaml:"type,omitempty" json:"type" validate:"required"`
|
||||
Salamander *serverConfigObfsSalamander `yaml:"salamander,omitempty" json:"salamander" validate:"required"`
|
||||
Salamander *serverConfigObfsSalamander `yaml:"salamander,omitempty" json:"salamander" validate:"omitempty"`
|
||||
Gecko *serverConfigObfsGecko `yaml:"gecko,omitempty" json:"gecko" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type serverConfigTLS struct {
|
||||
Cert *string `yaml:"cert,omitempty" json:"cert" validate:"required"`
|
||||
Key *string `yaml:"key,omitempty" json:"key" validate:"required"`
|
||||
SNIGuard *string `yaml:"sniGuard,omitempty" json:"sniGuard" validate:"omitempty"`
|
||||
ClientCA *string `yaml:"clientCA,omitempty" json:"clientCA" validate:"omitempty"`
|
||||
}
|
||||
|
||||
// serverConfigECH — Encrypted Client Hello (Hysteria 2.10.0+).
|
||||
// HY2XS не генерирует ECH keypair и не включает ECH в default-профиле,
|
||||
// но обязан корректно читать и сохранять этот блок.
|
||||
type serverConfigECH struct {
|
||||
KeyPath *string `yaml:"keyPath,omitempty" json:"keyPath" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type serverConfigACME struct {
|
||||
@@ -78,11 +143,25 @@ type serverConfigQUIC struct {
|
||||
MaxIdleTimeout *string `yaml:"maxIdleTimeout,omitempty" json:"maxIdleTimeout" validate:"omitempty"`
|
||||
MaxIncomingStreams *int64 `yaml:"maxIncomingStreams,omitempty" json:"maxIncomingStreams" validate:"omitempty"`
|
||||
DisablePathMTUDiscovery *bool `yaml:"disablePathMTUDiscovery,omitempty" json:"disablePathMTUDiscovery" validate:"omitempty"`
|
||||
// DisableStatelessReset появился в Hysteria 2.12.2. HY2XS оставляет
|
||||
// stateless reset включённым: клиент после перезапуска сервера или сна
|
||||
// устройства переподключается сразу, а не по таймауту.
|
||||
DisableStatelessReset *bool `yaml:"disableStatelessReset,omitempty" json:"disableStatelessReset" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type serverConfigBandwidth struct {
|
||||
Up *string `yaml:"up,omitempty" json:"up" validate:"required"`
|
||||
Down *string `yaml:"down,omitempty" json:"down" validate:"required"`
|
||||
// DisableLossCompensation появился в Hysteria 2.10.0.
|
||||
DisableLossCompensation *bool `yaml:"disableLossCompensation,omitempty" json:"disableLossCompensation" validate:"omitempty"`
|
||||
}
|
||||
|
||||
// ServerConfigCongestion — fallback congestion controller. Используется, когда
|
||||
// Brutal bandwidth не согласован сторонами. Это не то же самое, что host-level
|
||||
// BBR ядра Linux: у Hysteria собственный congestion-control контур.
|
||||
type ServerConfigCongestion struct {
|
||||
Type *string `yaml:"type,omitempty" json:"type" validate:"omitempty"`
|
||||
BBRProfile *string `yaml:"bbrProfile,omitempty" json:"bbrProfile" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type ServerConfigAuthHTTP struct {
|
||||
@@ -183,9 +262,11 @@ type serverConfigMasqueradeFile struct {
|
||||
}
|
||||
|
||||
type serverConfigMasqueradeProxy struct {
|
||||
// URL с Hysteria 2.12.2 может указывать и на unix socket.
|
||||
URL *string `yaml:"url,omitempty" json:"url" validate:"required"`
|
||||
RewriteHost *bool `yaml:"rewriteHost,omitempty" json:"rewriteHost" validate:"required"`
|
||||
Insecure *bool `yaml:"insecure,omitempty" json:"insecure" validate:"required"`
|
||||
XForwarded *bool `yaml:"xForwarded,omitempty" json:"xForwarded" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type serverConfigMasqueradeString struct {
|
||||
@@ -203,3 +284,35 @@ type serverConfigMasquerade struct {
|
||||
ListenHTTPS *string `yaml:"listenHTTPS,omitempty" json:"listenHTTPS" validate:"omitempty"`
|
||||
ForceHTTPS *bool `yaml:"forceHTTPS,omitempty" json:"forceHTTPS" validate:"omitempty"`
|
||||
}
|
||||
|
||||
// serverConfigMimic — Mimic / fake TCP (Hysteria 2.12.0+).
|
||||
// Требует отдельный сторонний бинарник, root и eBPF/XDP, поэтому в
|
||||
// production-профиле HY2XS выключен: текущий systemd-контракт запускает
|
||||
// Hysteria под непривилегированным пользователем.
|
||||
type serverConfigMimic struct {
|
||||
Enabled *bool `yaml:"enabled,omitempty" json:"enabled" validate:"omitempty"`
|
||||
Interface *string `yaml:"interface,omitempty" json:"interface" validate:"omitempty"`
|
||||
XDPMode *string `yaml:"xdpMode,omitempty" json:"xdpMode" validate:"omitempty"`
|
||||
Path *string `yaml:"path,omitempty" json:"path" validate:"omitempty"`
|
||||
ExtraArgs []string `yaml:"extraArgs,omitempty" json:"extraArgs" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type serverConfigRealmPortMapping struct {
|
||||
Enabled *bool `yaml:"enabled,omitempty" json:"enabled" validate:"omitempty"`
|
||||
Timeout *string `yaml:"timeout,omitempty" json:"timeout" validate:"omitempty"`
|
||||
Lifetime *string `yaml:"lifetime,omitempty" json:"lifetime" validate:"omitempty"`
|
||||
}
|
||||
|
||||
// serverConfigRealm — Hysteria Realms (2.9.0+), запуск сервера за NAT через
|
||||
// STUN/hole punching. Меняет фундаментальный сетевой контракт HY2XS
|
||||
// (выделенный сервер, публичный IPv4, UDP/443, own nftables), поэтому в
|
||||
// default-профиле не используется, но должен корректно читаться.
|
||||
type serverConfigRealm struct {
|
||||
StunServers []string `yaml:"stunServers,omitempty" json:"stunServers" validate:"omitempty"`
|
||||
StunTimeout *string `yaml:"stunTimeout,omitempty" json:"stunTimeout" validate:"omitempty"`
|
||||
PunchTimeout *string `yaml:"punchTimeout,omitempty" json:"punchTimeout" validate:"omitempty"`
|
||||
HeartbeatInterval *string `yaml:"heartbeatInterval,omitempty" json:"heartbeatInterval" validate:"omitempty"`
|
||||
Insecure *bool `yaml:"insecure,omitempty" json:"insecure" validate:"omitempty"`
|
||||
IPMode *string `yaml:"ipMode,omitempty" json:"ipMode" validate:"omitempty"`
|
||||
PortMapping *serverConfigRealmPortMapping `yaml:"portMapping,omitempty" json:"portMapping" validate:"omitempty"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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