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
+162 -15
View File
@@ -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;
+39 -15
View File
@@ -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>