fix(v1): разблокировать сборку, починить жизненный цикл cron и закрыть каналы утечки

Сборка не собиралась: два контракта приёмки роняли её на корректном коде.

verify_api_namespace_contract искал возвращение legacy-пространства имён
через grep по '/hui' и находил router_test.go, который ПЕРЕЧИСЛЯЕТ этот
префикс, чтобы доказать отсутствие маршрута, и сам versions.sh, где строка
стоит в тексте проверки. Падение приходило шестым шагом из четырнадцати, до
резолва Hysteria. За ним прятался второй такой же: проверка транзакционности
импорта пиров брала файл от начала applyPeerImportEntry и до конца, захватывая
объявленные ниже ExistPeerName и UpdatePeerLastConnectionAt.

Обе проверки теперь смотрят на код, а не на упоминания: добавлены помощники
code_without_comments и code_mentions_in, а отсутствие legacy-маршрута
доказывает тест на таблице маршрутов собранного роутера.

Планировщик стал собственностью процесса. InitCron вызывался из runServer и
на каждом вызове создавал новый cron.New(), не сохраняя ссылку; cron.Stop()
не вызывался нигде. Смена RESET_TRAFFIC_CRON выполняла StopServer(), точка
входа крутила for { runServer() } — и каждая правка добавляла целый
дублирующий набор джоб, а старое расписание сброса продолжало работать.
Фиксированные джобы регистрируются один раз, расписание переносится на месте
по EntryID, HTTP-сервер не трогается. Добавлено штатное завершение по SIGTERM.

Выражение проверяется до записи в базу тем же парсером (cron.ParseStandard),
которым его разбирает планировщик: раньше невалидная строка сохранялась, API
отвечал успехом, а сброс трафика молча исчезал.

updateConfigs стал атомарным: полная проверка партии, одна транзакция,
применение к рантайму. Прежний тест ставил запрещённый ключ первым и не
смотрел в базу — поймать частичное применение он был неспособен.

Удалены четыре ключа таблицы config без единого потребителя: HYSTERIA2_ENABLE,
HYSTERIA2_CONFIG (второй источник истины, читался первым), HYSTERIA2_TRAFFIC_TIME
и HYSTERIA2_CONFIG_REMARK. Имя профиля в share URI выводится из имени пира.

Безопасность:
- bootstrap-пароль администратора больше не генерируется и не пишется в журнал,
  который отдаётся кнопкой выгрузки; отсутствие env — отказ старта;
- собственный журнал админки санитизируется наравне с чужим;
- golang-jwt/jwt v3 -> v5: GO-2025-3553 не имеет исправленной версии в v3 и
  достижима с неаутентифицированного запроса; набор алгоритмов подписи
  зафиксирован через WithValidMethods;
- удалён вход по несолёному SHA-224 из предыдущего поколения;
- убран modulo bias в util.RandomString — единственном генераторе секретов;
- пир установщика защищён во всех путях записи, а не только в импорте;
- удалена латентная паника в service.GetToken и недостижимая ветка GetAdminInfo,
  проверявшая меньше, чем middleware.

Toolchain: Go 1.21.13 -> 1.26.7, Node 20.19.0 (EOL) -> 24.20.0. На прежнем
графе govulncheck находил 21 вызываемую уязвимость, 17 из них в stdlib,
попадающей в production-бинарь. Сейчас — ноль. Добавлен обязательный шаг
проверки зависимостей (govulncheck + pnpm audit) с записью результата в
metadata пакета.
This commit is contained in:
2026-08-29 21:37:38 +05:00
parent 672d455467
commit b99be7d514
50 changed files with 3907 additions and 1622 deletions
+2 -7
View File
@@ -129,7 +129,7 @@ export default {
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.",
"Short peer identifier. Use latin letters, digits and hyphens — the name becomes part of the auto-generated secret and is shown to the client as the profile name.",
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",
@@ -185,13 +185,11 @@ export default {
},
config: {
orchestratorManaged: "Managed by hy2xs-orchestrator reconfigure",
hysteria2TrafficTime: "Hysteria2 Traffic Time",
resetTrafficCron: "Reset traffic schedule task",
resetTrafficCronTip:
"Scheduled task expression, reference: https://pkg.go.dev/github.com/robfig/cron/v3",
"Scheduled task expression, reference: https://pkg.go.dev/github.com/robfig/cron/v3. An empty value disables the automatic reset",
resetTrafficMonth: "Run once a month, midnight, first of month",
resetTrafficWeek: "Run once a week, midnight between Sat/Sun",
invalidTrafficTime: "Field must be a number with up to one decimal place",
},
monitor: {
cpuPercent: "CPU Usage",
@@ -229,7 +227,6 @@ export default {
hysteria2Version: "Hysteria2 Version",
hysteria2Running: "Hysteria2 Status",
addOutbound: "Add Outbound",
extension: "Extension",
listen: "Listen",
tls: "TLS",
obfs: "Obfuscation",
@@ -245,8 +242,6 @@ export default {
http: "Traffic Stats API (HTTP)",
masquerade: "Masquerade",
config: {
enable: "Enable/Disable",
remark: "Remark",
listen:
"When the IP address is omitted, the server will listen on all interfaces, both IPv4 and IPv6. To listen on IPv4 only, you can use 0.0.0.0:443. To listen on IPv6 only, you can use [::]:443.",
tlsType: "TLS type",
+3 -8
View File
@@ -125,7 +125,7 @@ export default {
form: {
namePlaceholder: "например, ivan-laptop",
nameHint:
"Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет.",
"Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
remarkPlaceholder: "например, Ноутбук Ивана, отдел продаж",
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
secretPlaceholder: "оставьте пустым — сгенерируем автоматически",
@@ -181,13 +181,11 @@ export default {
},
config: {
orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure",
hysteria2TrafficTime: "Период учёта трафика Hysteria2",
resetTrafficCron: "Расписание сброса трафика",
resetTrafficCronTip: "Cron-выражение для планового сброса трафика",
resetTrafficCronTip:
"Cron-выражение для планового сброса трафика. Пустое значение отключает автоматический сброс",
resetTrafficMonth: "Раз в месяц, в полночь первого дня",
resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем",
invalidTrafficTime:
"Поле должно быть числом максимум с одним знаком после запятой",
},
monitor: {
cpuPercent: "CPU",
@@ -225,7 +223,6 @@ export default {
hysteria2Version: "Версия Hysteria2",
hysteria2Running: "Статус Hysteria2",
addOutbound: "Добавить outbound",
extension: "Расширение",
listen: "Адрес прослушивания",
tls: "TLS",
obfs: "Маскировка",
@@ -241,8 +238,6 @@ export default {
http: "Traffic Stats API (HTTP)",
masquerade: "Masquerade",
config: {
enable: "Включить/отключить",
remark: "Комментарий",
listen: "Адрес и порт прослушивания. Для IPv4 используйте 0.0.0.0:443.",
tlsType: "Тип TLS",
tls: {
File diff suppressed because it is too large Load Diff
+53 -55
View File
@@ -1,65 +1,63 @@
// generated by unplugin-vue-components
// We suggest you to commit this file into source control
// Read more: https://github.com/vuejs/core/pull/3399
import "@vue/runtime-core";
import '@vue/runtime-core'
export {};
export {}
declare module "@vue/runtime-core" {
declare module '@vue/runtime-core' {
export interface GlobalComponents {
ElAlert: typeof import("element-plus/es")["ElAlert"];
ElAvatar: typeof import("element-plus/es")["ElAvatar"];
ElButton: typeof import("element-plus/es")["ElButton"];
ElCard: typeof import("element-plus/es")["ElCard"];
ElCol: typeof import("element-plus/es")["ElCol"];
ElDatePicker: typeof import("element-plus/es")["ElDatePicker"];
ElDialog: typeof import("element-plus/es")["ElDialog"];
ElDrawer: typeof import("element-plus/es")["ElDrawer"];
ElDropdown: typeof import("element-plus/es")["ElDropdown"];
ElDropdownItem: typeof import("element-plus/es")["ElDropdownItem"];
ElDropdownMenu: typeof import("element-plus/es")["ElDropdownMenu"];
ElEmpty: typeof import("element-plus/es")["ElEmpty"];
ElForm: typeof import("element-plus/es")["ElForm"];
ElFormItem: typeof import("element-plus/es")["ElFormItem"];
ElInput: typeof import("element-plus/es")["ElInput"];
ElInputNumber: typeof import("element-plus/es")["ElInputNumber"];
ElMenu: typeof import("element-plus/es")["ElMenu"];
ElMenuItem: typeof import("element-plus/es")["ElMenuItem"];
ElOption: typeof import("element-plus/es")["ElOption"];
ElPagination: typeof import("element-plus/es")["ElPagination"];
ElProgress: typeof import("element-plus/es")["ElProgress"];
ElRadioButton: typeof import("element-plus/es")["ElRadioButton"];
ElRadioGroup: typeof import("element-plus/es")["ElRadioGroup"];
ElRow: typeof import("element-plus/es")["ElRow"];
ElScrollbar: typeof import("element-plus/es")["ElScrollbar"];
ElSelect: typeof import("element-plus/es")["ElSelect"];
ElSubMenu: typeof import("element-plus/es")["ElSubMenu"];
ElSwitch: typeof import("element-plus/es")["ElSwitch"];
ElTable: typeof import("element-plus/es")["ElTable"];
ElTableColumn: typeof import("element-plus/es")["ElTableColumn"];
ElTabPane: typeof import("element-plus/es")["ElTabPane"];
ElTabs: typeof import("element-plus/es")["ElTabs"];
ElTag: typeof import("element-plus/es")["ElTag"];
ElTooltip: typeof import("element-plus/es")["ElTooltip"];
ElUpload: typeof import("element-plus/es")["ElUpload"];
Hamburger: typeof import("./../components/Hamburger/index.vue")["default"];
IEpCaretBottom: typeof import("~icons/ep/caret-bottom")["default"];
IEpDownload: typeof import("~icons/ep/download")["default"];
IEpMoreFilled: typeof import("~icons/ep/more-filled")["default"];
IEpRefresh: typeof import("~icons/ep/refresh")["default"];
IEpRefreshRight: typeof import("~icons/ep/refresh-right")["default"];
IEpUpload: typeof import("~icons/ep/upload")["default"];
IEpUserFilled: typeof import("~icons/ep/user-filled")["default"];
ImputMultiple: typeof import("./../components/ImputMultiple/index.vue")["default"];
LangSelect: typeof import("./../components/LangSelect/index.vue")["default"];
MapAdd: typeof import("./../components/MapAdd/index.vue")["default"];
Pagination: typeof import("./../components/Pagination/index.vue")["default"];
RouterLink: typeof import("vue-router")["RouterLink"];
RouterView: typeof import("vue-router")["RouterView"];
SvgIcon: typeof import("./../components/SvgIcon/index.vue")["default"];
UnitSelect: typeof import("./../components/UnitSelect/index.vue")["default"];
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload']
Hamburger: typeof import('./../components/Hamburger/index.vue')['default']
IEpCaretBottom: typeof import('~icons/ep/caret-bottom')['default']
IEpDownload: typeof import('~icons/ep/download')['default']
IEpMoreFilled: typeof import('~icons/ep/more-filled')['default']
IEpRefresh: typeof import('~icons/ep/refresh')['default']
IEpUserFilled: typeof import('~icons/ep/user-filled')['default']
ImputMultiple: typeof import('./../components/ImputMultiple/index.vue')['default']
LangSelect: typeof import('./../components/LangSelect/index.vue')['default']
MapAdd: typeof import('./../components/MapAdd/index.vue')['default']
Pagination: typeof import('./../components/Pagination/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SvgIcon: typeof import('./../components/SvgIcon/index.vue')['default']
UnitSelect: typeof import('./../components/UnitSelect/index.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: typeof import("element-plus/es")["ElLoadingDirective"];
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
+19 -34
View File
@@ -24,16 +24,6 @@
:model="dataForm"
label-position="top"
>
<el-form-item
:label="$t('config.hysteria2TrafficTime')"
prop="hysteria2TrafficTime"
>
<el-input
v-model="dataForm.hysteria2TrafficTime"
:placeholder="$t('config.hysteria2TrafficTime')"
clearable
/>
</el-form-item>
<el-tooltip
:content="$t('config.resetTrafficCronTip')"
placement="bottom"
@@ -79,6 +69,14 @@ export default {
//
// Generic import/export конфигурации удалён: он выгружал таблицу `config`
// целиком, вместе с JWT- и peer-ключами.
//
// Поля «Период учёта трафика Hysteria2» здесь тоже больше нет. Ключ
// HYSTERIA2_TRAFFIC_TIME не имел в рантайме ни одного потребителя: интервал
// сбора метрик задан в коде админки, а traffic accounting его не читал.
// Настройка меняла строку в базе и ничего больше — то есть панель обещала
// оператору управление, которого не существовало.
//
// Осталась ровно одна настройка, которой панель действительно владеет.
import { Select } from "@element-plus/icons-vue";
import { listConfigApi, updateConfigsApi } from "@/api/config";
import { ConfigsUpdateDto } from "@/api/config/types";
@@ -88,7 +86,6 @@ const { t } = useI18n();
const dataFormRef = ref(ElForm);
const hysteria2TrafficTimeKey = "HYSTERIA2_TRAFFIC_TIME";
const resetTrafficCronKey = "RESET_TRAFFIC_CRON";
const cronResetTraffic = [
@@ -96,24 +93,18 @@ const cronResetTraffic = [
{ key: t("config.resetTrafficWeek"), value: "@weekly" },
];
const dataFormRules = {
hysteria2TrafficTime: [
{
required: true,
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: /^\d+(\.\d)?$/,
message: t("config.invalidTrafficTime"),
trigger: ["change", "blur"],
},
],
};
// Правил формы здесь нет намеренно.
//
// Поле — el-select с allow-create, то есть значение произвольное, и
// единственная осмысленная проверка cron-выражения — разбор его тем же
// парсером, которым пользуется планировщик. Такой парсер живёт на сервере;
// вторая, приблизительная реализация на фронтенде расходилась бы с ним и
// давала бы либо ложные отказы, либо ложные разрешения. Пустое значение
// легально и означает «автоматический сброс выключен».
const dataFormRules = {};
const state = reactive({
dataForm: {
hysteria2TrafficTime: "1",
resetTrafficCron: "",
},
});
@@ -124,10 +115,6 @@ const submitForm = () => {
dataFormRef.value.validate((valid: boolean) => {
if (valid) {
let configs: ConfigsUpdateDto[] = [
{
key: hysteria2TrafficTimeKey,
value: state.dataForm.hysteria2TrafficTime,
},
{
key: resetTrafficCronKey,
value: state.dataForm.resetTrafficCron,
@@ -143,13 +130,11 @@ const submitForm = () => {
const setConfig = async () => {
const { data } = await listConfigApi({
keys: [hysteria2TrafficTimeKey, resetTrafficCronKey],
keys: [resetTrafficCronKey],
});
data.forEach((configVo) => {
if (configVo.key === hysteria2TrafficTimeKey) {
state.dataForm.hysteria2TrafficTime = configVo.value;
} else if (configVo.key === resetTrafficCronKey) {
if (configVo.key === resetTrafficCronKey) {
state.dataForm.resetTrafficCron = configVo.value;
}
});
@@ -51,16 +51,6 @@
:disabled="true"
>
<el-tabs v-model="activeName" class="tabs">
<el-tab-pane :label="$t('hysteria.extension')" name="extension">
<el-tooltip
:content="$t('hysteria.config.remark')"
placement="bottom"
>
<el-form-item label="remark" prop="remark">
<el-input v-model="configForm.remark" clearable />
</el-form-item>
</el-tooltip>
</el-tab-pane>
<el-tab-pane :label="$t('hysteria.listen')" name="listen">
<el-tooltip
:content="$t('hysteria.config.listen')"
@@ -1062,18 +1052,19 @@ import {
Hysteria2ServerConfig,
} from "@/api/config/types";
import Outbounds from "./components/Outbounds/index.vue";
import {
exportHysteria2ConfigApi,
getHysteria2ConfigApi,
listConfigApi,
} from "@/api/config";
import { exportHysteria2ConfigApi, getHysteria2ConfigApi } from "@/api/config";
import { useI18n } from "vue-i18n";
import { assignWith, deepCopy } from "@/utils/copy";
import { dashboardSummaryApi } from "@/api/dashboard";
const { t } = useI18n();
const hysteria2Remark = "HYSTERIA2_CONFIG_REMARK";
// Вкладки «extension» с полем remark здесь больше нет.
//
// Она показывала HYSTERIA2_CONFIG_REMARK — пустую строку в таблице `config`,
// которую clean install создавал один раз и которую не записывал никто. Имя
// профиля в клиентской ссылке теперь выводится из имени пира на сервере
// (см. service/hysteria2_api.go), поэтому настраивать здесь нечего.
const dataFormRef = ref(ElForm);
const dataFormRules = {
@@ -1118,11 +1109,8 @@ const resolverTypes = ref<string[]>(["tcp", "udp", "tls", "https"]);
const masqueradeTypes = ref<string[]>(["file", "proxy", "string"]);
const state = reactive({
configForm: {
remark: "",
},
dataForm: { ...defaultHysteria2ServerConfig } as Hysteria2ServerConfig,
activeName: "extension",
activeName: "listen",
tlsType: "acme",
aclType: "inline",
obfs: false,
@@ -1143,7 +1131,6 @@ const state = reactive({
});
const {
configForm,
activeName,
dataForm,
tlsType,
@@ -1183,17 +1170,6 @@ const handleExport = async () => {
};
const setConfig = () => {
listConfigApi({
keys: [hysteria2Remark],
}).then((response) => {
const data = response.data;
data.forEach((configVo) => {
if (configVo.key === hysteria2Remark) {
state.configForm.remark = configVo.value;
}
});
});
getHysteria2ConfigApi().then((response) => {
const data = response.data;
if (data) {