fix(admin): убрать каналы утечки секретов и остатки H UI из runtime

Экспорт в панели формировался через os.Create в /var/lib/hy2xs-admin/export,
и файл там оставался навсегда. При includeSecrets=true это означало
расшифрованные секреты пиров — фактические учётные данные доступа — в
открытом виде на диске, накапливающиеся с каждым нажатием кнопки. Выгрузки
формируются в памяти, каталога export/ больше нет.

Generic export/import таблицы config удалён целиком. Он исключал только сырой
Hysteria YAML, а в той же таблице лежат JWT_SECRET, PEER_SECRET_KEY,
PEER_SECRET_ENCRYPTION_KEY и HYSTERIA2_TRAFFIC_STATS_SECRET: кнопка Export
выгружала их открытым текстом, импорт позволял подменить. Для
PEER_SECRET_ENCRYPTION_KEY подмена ломает расшифровку секретов уже
существующих пиров. Production-сценария у этой пары не было.

Импорт пиров шёл мимо всей валидации, которую проходит обычное создание пира:
в базу попадало имя любой длины и с любыми символами, disabled с произвольным
числом, отрицательные счётчики. Файл применялся построчно, поэтому ошибка в
середине оставляла список наполовину изменённым, а импорт мог перезаписать
bootstrap-admin-peer, чей секрет продублирован в bootstrap-admin.secret.
Партия проверяется целиком до первой записи, неизвестные поля отклоняются.

Убран слой сетевых настроек панели: H_UI_WEB_PORT, H_UI_WEB_CONTEXT,
H_UI_CRT_PATH, H_UI_KEY_PATH и собственный TLS. Оркестратор передавал порт
аргументом, админка писала его в SQLite и тут же читала обратно, а UI
показывал поля disabled — второй источник истины, из которого ничего нельзя
было изменить. HUI_DATA/HUI_LOG заменены на HY2XS_DATA_DIR/HY2XS_LOG_DIR,
база переименована в hy2xs-admin.db, reference-схема — в schema.sql.

API namespace разделён по природе маршрутов: операторский API на /api,
machine-auth Hysteria на /internal/hysteria/auth. Путь machine-auth —
runtime-контракт, он уезжает в config.yaml и post-install.env, поэтому
объявлен одной константой на компонент.

Go-санитайзер экспорта вырезал секреты из URL только у ключей url/addr:
будущее upstream-поле с другим именем уносило учётные данные и access_token
целиком, а URL внутри списков не обрабатывались вовсе. Граница определяется
значением, а не именем ключа — как в TS-санитайзере оркестратора.

Заодно индикатор загрузки и цвета 401/404 переведены на брендовый токен:
NProgress приходил со своим #29d и был единственным элементом вне палитры.
This commit is contained in:
2026-08-27 20:49:07 +05:00
parent 3a4ce9c751
commit b52fac1394
33 changed files with 677 additions and 608 deletions
+3 -19
View File
@@ -11,6 +11,9 @@ import {
// Серверный конфиг Hysteria доступен панели только на чтение и на выгрузку:
// им владеет install-оркестратор. Клиентов записи, импорта, перезапуска и
// загрузки сертификатов здесь нет — соответствующих маршрутов не существует.
//
// Generic-выгрузки таблицы `config` здесь тоже нет: она отдавала JWT- и
// peer-ключи в открытом виде. Перенос пиров делают ImportPeer/ExportPeer.
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ServerConfig> {
return request({
@@ -43,25 +46,6 @@ export function updateConfigsApi(data: ConfigUpdateDto): AxiosPromise {
});
}
export function exportConfigApi(): AxiosPromise {
return request({
url: "/config/exportConfig",
method: "post",
responseType: "blob",
});
}
export function importConfigApi(data: FormData): AxiosPromise {
return request({
url: "/config/importConfig",
method: "post",
headers: {
"Content-Type": "multipart/form-data",
},
data: data,
});
}
export function exportHysteria2ConfigApi(): AxiosPromise {
return request({
url: "/config/exportHysteria2Config",
-12
View File
@@ -97,8 +97,6 @@ export default {
yes: "Yes",
no: "No",
securityRisk: "Security Risks",
defaultPassTip: `Please change the default login password as soon as possible, It is recommended to set a strong password to protect your account security. <a href="/#/admin/change-password" style="color: #00BFFF">Click here</a> to change`,
noHttpsTip: `Your website is not using HTTPS, making data transmission insecure, Please enable HTTPS as soon as possible to protect user information. <a href="/#/config/list?focus=huiHttps" style="color: #00BFFF">Click here</a> to enable`,
required: "Required",
warning: "Warning",
fileFormatUnsupported: "File format not supported",
@@ -180,25 +178,15 @@ export default {
},
config: {
orchestratorManaged: "Managed by hy2xs-orchestrator reconfigure",
huiWebPort: "HY2XS admin Web Port",
huiWebContext: "HY2XS admin Web Context",
hysteria2TrafficTime: "Hysteria2 Traffic Time",
huiCrtPath: "HY2XS admin CRT File Path",
huiKeyPath: "HY2XS admin KEY File Path",
useHysteria2Cert: "Use Hysteria2 cert",
huiHttps: "Open https on the panel",
resetTrafficCron: "Reset traffic schedule task",
resetTrafficCronTip:
"Scheduled task expression, reference: https://pkg.go.dev/github.com/robfig/cron/v3",
resetTrafficMonth: "Run once a month, midnight, first of month",
resetTrafficWeek: "Run once a week, midnight between Sat/Sun",
mustBeInteger: "Field must be an integer",
invalidWebContext:
"Field must start with / and contain only lowercase letters (a-z) and numbers (0-9)",
invalidTrafficTime: "Field must be a number with up to one decimal place",
},
monitor: {
huiVersion: "HY2XS admin Version",
cpuPercent: "CPU Usage",
memPercent: "Memory Usage",
diskPercent: "Disk Usage",
-12
View File
@@ -94,8 +94,6 @@ export default {
yes: "Да",
no: "Нет",
securityRisk: "Риски безопасности",
defaultPassTip: `Смените пароль по умолчанию как можно скорее. <a href="/#/admin/change-password" style="color: #00BFFF">Перейти к смене</a>`,
noHttpsTip: `Панель работает без HTTPS. Включите HTTPS для защиты данных. <a href="/#/config/list?focus=huiHttps" style="color: #00BFFF">Открыть настройки</a>`,
required: "Обязательное поле",
warning: "Внимание",
fileFormatUnsupported: "Формат файла не поддерживается",
@@ -176,25 +174,15 @@ export default {
},
config: {
orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure",
huiWebPort: "Порт HY2XS admin",
huiWebContext: "Web-контекст HY2XS admin",
hysteria2TrafficTime: "Период учёта трафика Hysteria2",
huiCrtPath: "Путь к CRT HY2XS admin",
huiKeyPath: "Путь к KEY HY2XS admin",
useHysteria2Cert: "Использовать сертификат Hysteria2",
huiHttps: "Включить HTTPS панели",
resetTrafficCron: "Расписание сброса трафика",
resetTrafficCronTip: "Cron-выражение для планового сброса трафика",
resetTrafficMonth: "Раз в месяц, в полночь первого дня",
resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем",
mustBeInteger: "Поле должно быть целым числом",
invalidWebContext:
"Поле должно начинаться с / и содержать только строчные буквы (a-z) и цифры (0-9)",
invalidTrafficTime:
"Поле должно быть числом максимум с одним знаком после запятой",
},
monitor: {
huiVersion: "Версия HY2XS admin",
cpuPercent: "CPU",
memPercent: "Память",
diskPercent: "Диск",
+2
View File
@@ -3,7 +3,9 @@ import { useAdminStoreHook } from "@/store/modules/admin";
import { usePermissionStoreHook } from "@/store/modules/permission";
import NProgress from "nprogress";
// Порядок важен: сначала vendor-база, затем тема HY2XS поверх неё.
import "nprogress/nprogress.css";
import "@/styles/nprogress.scss";
NProgress.configure({ showSpinner: false }); // Индикатор загрузки
+7 -2
View File
@@ -26,9 +26,14 @@
box-sizing: content-box;
}
// Цвет фона выбранной строки
// Цвет фона выбранной строки.
//
// Раньше здесь стоял литерал `#e1f3d8b5` — бледно-зелёный из исходного
// admin-шаблона, не имеющий отношения к палитре HY2XS. Подсветка выделения —
// это семантика «активный элемент», поэтому берётся самый светлый оттенок
// брендового primary.
.el-table__body tr.current-row td {
background-color: #e1f3d8b5 !important;
background-color: var(--el-color-primary-light-9) !important;
}
// Единая высота header у card
+27
View File
@@ -0,0 +1,27 @@
// Тема NProgress.
//
// Vendor-стили nprogress приходят со своим фирменным синим `#29d`. Он никогда
// не переопределялся, поэтому индикатор загрузки был единственным элементом
// интерфейса вне брендовой палитры HY2XS.
//
// Здесь сознательно не пишется `#ff4e2f`: второй литерал того же цвета сразу
// стал бы вторым источником истины. Индикатор привязан к той же переменной,
// что и весь остальной интерфейс, и поменяется вместе с ней.
#nprogress {
.bar {
background: var(--el-color-primary);
}
.peg {
box-shadow:
0 0 10px var(--el-color-primary),
0 0 5px var(--el-color-primary);
}
// Spinner сейчас выключен через NProgress.configure({ showSpinner: false }),
// но правило оставлено: если его когда-нибудь включат, синий не вернётся.
.spinner-icon {
border-top-color: var(--el-color-primary);
border-left-color: var(--el-color-primary);
}
}
+6 -1
View File
@@ -3,7 +3,12 @@ import { useAdminStoreHook } from "@/store/modules/admin";
import i18n from "@/lang/index";
const dynamicBase = (window as any).__dynamic_base__ || "";
const API_BASE = "/hui";
// Операторский API живёт под /api. Прежний префикс «hui» был наследием H UI:
// под ним лежали и machine-to-machine auth Hysteria, и JWT-защищённый
// админский API, хотя middleware у них разные.
// Значение синхронизировано с constant.AdminAPIBase в админке и
// ADMIN_API_BASE в оркестраторе.
const API_BASE = "/api";
const t = i18n.global.t;
// Создание axios instance
const service = axios.create({
+12 -208
View File
@@ -7,31 +7,6 @@
{{ $t("common.save") }}
</el-button>
</el-form-item>
<el-form-item>
<el-upload
v-model:file-list="fileList"
:http-request="handleImport"
:show-file-list="false"
accept=".json"
:limit="1"
:before-upload="beforeImport"
>
<el-button>
<template #icon>
<i-ep-upload />
</template>
{{ $t("common.import") }}
</el-button>
</el-upload>
</el-form-item>
<el-form-item>
<el-button @click="handleExport">
<template #icon>
<i-ep-download />
</template>
{{ $t("common.export") }}
</el-button>
</el-form-item>
</el-form>
</div>
@@ -49,22 +24,6 @@
:model="dataForm"
label-position="top"
>
<el-form-item :label="$t('config.huiWebPort')" prop="huiWebPort">
<el-input
v-model="dataForm.huiWebPort"
:placeholder="$t('config.huiWebPort')"
disabled
clearable
/>
</el-form-item>
<el-form-item :label="$t('config.huiWebContext')" prop="huiWebContext">
<el-input
v-model="dataForm.huiWebContext"
:placeholder="$t('config.huiWebContext')"
disabled
clearable
/>
</el-form-item>
<el-form-item
:label="$t('config.hysteria2TrafficTime')"
prop="hysteria2TrafficTime"
@@ -75,47 +34,6 @@
clearable
/>
</el-form-item>
<el-form-item :label="$t('config.huiHttps')" prop="huiHttps">
<el-select
v-model="huiHttps"
style="width: 50%"
ref="huiHttpsRef"
disabled
>
<el-option
v-for="item in huiHttpsList"
:key="item.key"
:label="item.key"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="huiHttps"
:label="$t('config.huiCrtPath')"
prop="huiCrtPath"
>
<el-input
v-model="dataForm.huiCrtPath"
:placeholder="$t('config.huiCrtPath')"
style="width: 50%"
disabled
clearable
/>
</el-form-item>
<el-form-item
v-if="huiHttps"
:label="$t('config.huiKeyPath')"
prop="huiKeyPath"
>
<el-input
v-model="dataForm.huiKeyPath"
:placeholder="$t('config.huiKeyPath')"
style="width: 50%"
disabled
clearable
/>
</el-form-item>
<el-tooltip
:content="$t('config.resetTrafficCronTip')"
placement="bottom"
@@ -153,66 +71,32 @@ export default {
</script>
<script setup lang="ts">
// Сетевые настройки самой панели (порт, web-контекст, HTTPS, пути к
// сертификатам) здесь отсутствуют намеренно: ими владеет install-оркестратор
// через /etc/hy2xs/hy2xs.env и systemd-юнит. Раньше они лежали в SQLite и
// показывались тут в disabled-виде — второй источник истины, из которого
// ничего нельзя было изменить.
//
// Generic import/export конфигурации удалён: он выгружал таблицу `config`
// целиком, вместе с JWT- и peer-ключами.
import { Select } from "@element-plus/icons-vue";
import {
exportConfigApi,
importConfigApi,
listConfigApi,
updateConfigsApi,
} from "@/api/config";
import { listConfigApi, updateConfigsApi } from "@/api/config";
import { ConfigsUpdateDto } from "@/api/config/types";
import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus";
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
const { t } = useI18n();
const route = useRoute();
const dataFormRef = ref(ElForm);
const huiHttpsRef = ref<any>(null);
const huiWebPortKey = "H_UI_WEB_PORT";
const huiWebContext = "H_UI_WEB_CONTEXT";
const hysteria2TrafficTimeKey = "HYSTERIA2_TRAFFIC_TIME";
const huiCrtPathKey = "H_UI_CRT_PATH";
const huiKeyPathKey = "H_UI_KEY_PATH";
const resetTrafficCronKey = "RESET_TRAFFIC_CRON";
const huiHttpsList = [
{ key: t("common.yes"), value: 1 },
{ key: t("common.no"), value: 0 },
];
const cronResetTraffic = [
{ key: t("config.resetTrafficMonth"), value: "@monthly" },
{ key: t("config.resetTrafficWeek"), value: "@weekly" },
];
const dataFormRules = {
huiWebPort: [
{
required: true,
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: /^\d+$/,
message: t("config.mustBeInteger"),
trigger: ["change", "blur"],
},
],
huiWebContext: [
{
required: true,
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: /^\/([a-z0-9]+(\/[a-z0-9]+)*)?$/,
message: t("config.invalidWebContext"),
trigger: ["change", "blur"],
},
],
hysteria2TrafficTime: [
{
required: true,
@@ -229,18 +113,12 @@ const dataFormRules = {
const state = reactive({
dataForm: {
huiWebPort: "8081",
huiWebContext: "/",
hysteria2TrafficTime: "1",
huiCrtPath: "",
huiKeyPath: "",
resetTrafficCron: "",
},
huiHttps: 0,
fileList: [] as UploadFile[],
});
const { dataForm, huiHttps, fileList } = toRefs(state);
const { dataForm } = toRefs(state);
const submitForm = () => {
dataFormRef.value.validate((valid: boolean) => {
@@ -265,94 +143,20 @@ const submitForm = () => {
const setConfig = async () => {
const { data } = await listConfigApi({
keys: [
huiCrtPathKey,
huiWebContext,
huiKeyPathKey,
huiWebPortKey,
hysteria2TrafficTimeKey,
resetTrafficCronKey,
],
keys: [hysteria2TrafficTimeKey, resetTrafficCronKey],
});
data.forEach((configVo) => {
if (configVo.key === huiWebPortKey) {
state.dataForm.huiWebPort = configVo.value;
} else if (configVo.key === huiWebContext) {
state.dataForm.huiWebContext = configVo.value;
} else if (configVo.key === hysteria2TrafficTimeKey) {
if (configVo.key === hysteria2TrafficTimeKey) {
state.dataForm.hysteria2TrafficTime = configVo.value;
} else if (configVo.key === huiCrtPathKey) {
state.dataForm.huiCrtPath = configVo.value;
} else if (configVo.key === huiKeyPathKey) {
state.dataForm.huiKeyPath = configVo.value;
} else if (configVo.key === resetTrafficCronKey) {
state.dataForm.resetTrafficCron = configVo.value;
}
});
if (state.dataForm.huiCrtPath != "" && state.dataForm.huiKeyPath != "") {
state.huiHttps = 1;
}
};
const handleImport = async (params: UploadRequestOptions) => {
if (state.fileList.length > 0) {
try {
let formData = new FormData();
formData.append("file", params.file);
await importConfigApi(formData);
ElMessage.success(t("common.success"));
state.fileList = [];
} catch (e) {
/* empty */
} finally {
await setConfig();
}
}
};
const beforeImport = (file: UploadRawFile) => {
if (!file.name.endsWith(".json")) {
ElMessage.error(t("common.fileFormatUnsupported"));
return false;
}
if (file.size / 1024 / 1024 > 2) {
ElMessage.error(t("common.fileTooLarge"));
return false;
}
};
const handleExport = async () => {
try {
let response = await exportConfigApi();
const blob = new Blob([response.data], {
type: "application/octet-stream",
});
let url = window.URL.createObjectURL(blob);
let a = document.createElement("a");
document.body.appendChild(a);
a.href = url;
let dis = response.headers["content-disposition"];
a.download = dis.split("attachment; filename=")[1];
// Имитация клика для скачивания
a.click();
window.URL.revokeObjectURL(url);
ElMessage.success(t("common.success"));
} catch (e) {
/* empty */
}
};
onMounted(() => {
setConfig();
if (route.query.focus === "huiHttps") {
nextTick(() => {
const input = huiHttpsRef.value.$el.querySelector(".el-input__inner");
if (input) {
input.focus();
}
});
}
});
</script>
+2 -2
View File
@@ -61,7 +61,7 @@ function back() {
.pan-back-btn {
color: #fff;
background: #008489;
background: var(--el-color-primary);
border: none !important;
}
@@ -90,7 +90,7 @@ function back() {
}
a {
color: #008489;
color: var(--el-color-primary);
text-decoration: none;
&:hover {
+2 -2
View File
@@ -209,7 +209,7 @@ function message() {
font-size: 32px;
font-weight: bold;
line-height: 40px;
color: #1482f0;
color: var(--el-color-primary);
opacity: 0;
animation-name: slideUp;
animation-duration: 0.5s;
@@ -251,7 +251,7 @@ function message() {
color: #fff;
text-align: center;
cursor: pointer;
background: #1482f0;
background: var(--el-color-primary);
border-radius: 100px;
opacity: 0;
animation-name: slideUp;
+2 -1
View File
@@ -21,7 +21,8 @@ const pathSrc = path.resolve(__dirname, "src");
const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$&*+,:;<=>?[\]^`{|}\u007F]/g;
const DRIVE_LETTER_REGEX = /^[a-z]:/i;
const DEV_SERVER_PORT = 8080;
const API_BASE = "/hui";
// Синхронизировано с src/utils/request.ts и constant.AdminAPIBase.
const API_BASE = "/api";
export default defineConfig((): UserConfig => {
return {