diff --git a/apps/controller/config.go b/apps/controller/config.go index 36ff568..fd4ebad 100644 --- a/apps/controller/config.go +++ b/apps/controller/config.go @@ -228,6 +228,9 @@ func ExportHysteria2Config(c *gin.Context) { 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")) diff --git a/apps/frontend/src/lang/package/en.ts b/apps/frontend/src/lang/package/en.ts index 3ffa0fb..ed0b29d 100644 --- a/apps/frontend/src/lang/package/en.ts +++ b/apps/frontend/src/lang/package/en.ts @@ -169,6 +169,7 @@ export default { more: "More", }, config: { + orchestratorManaged: "Managed by hy2xs-orchestrator reconfigure", huiWebPort: "HY2XS admin Web Port", huiWebContext: "HY2XS admin Web Context", hysteria2TrafficTime: "Hysteria2 Traffic Time", @@ -185,6 +186,10 @@ export default { "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", @@ -200,6 +205,21 @@ export default { }, log: { numLine: "Number of lines", + level: "Level", + message: "Message", + time: "Time", + }, + errorPage: { + back: "Back", + oops: "Oops!", + forbiddenTitle: "You do not have permission to access this page", + forbiddenGo: "Or you can go:", + backHome: "Back to Home", + forbiddenImageAlt: "Girl has dropped her ice cream.", + notFoundHeadline: "The webmaster said that you can not enter this page...", + notFoundInfo: + "Please check that the URL you entered is correct, or click the button below to return to the homepage.", + notFoundBackHome: "Back to home", }, hysteria: { enable: "Enable", diff --git a/apps/frontend/src/lang/package/ru.ts b/apps/frontend/src/lang/package/ru.ts index 00b8be7..fb25315 100644 --- a/apps/frontend/src/lang/package/ru.ts +++ b/apps/frontend/src/lang/package/ru.ts @@ -164,6 +164,7 @@ export default { releaseKickTip: "Снять офлайн-статус", }, config: { + orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure", huiWebPort: "Порт HY2XS admin", huiWebContext: "Web-контекст HY2XS admin", hysteria2TrafficTime: "Период учёта трафика Hysteria2", @@ -179,6 +180,10 @@ export default { resetTrafficCronTip: "Cron-выражение для планового сброса трафика", resetTrafficMonth: "Раз в месяц, в полночь первого дня", resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем", + mustBeInteger: "Поле должно быть целым числом", + invalidWebContext: + "Поле должно начинаться с / и содержать только строчные буквы (a-z) и цифры (0-9)", + invalidTrafficTime: "Поле должно быть числом максимум с одним знаком после запятой", }, monitor: { huiVersion: "Версия HY2XS admin", @@ -194,6 +199,21 @@ export default { }, log: { numLine: "Количество строк", + level: "Уровень", + message: "Сообщение", + time: "Время", + }, + errorPage: { + back: "Назад", + oops: "Упс!", + forbiddenTitle: "У вас нет прав для доступа к этой странице", + forbiddenGo: "Или вы можете перейти:", + backHome: "На главную", + forbiddenImageAlt: "Девочка уронила своё мороженое.", + notFoundHeadline: "Вебмастер сообщил, что вам нельзя на эту страницу...", + notFoundInfo: + "Проверьте правильность URL или нажмите кнопку ниже, чтобы вернуться на главную страницу.", + notFoundBackHome: "На главную", }, hysteria: { enable: "Включить", diff --git a/apps/frontend/src/views/config/list/index.vue b/apps/frontend/src/views/config/list/index.vue index 7f4b051..a5b9db5 100644 --- a/apps/frontend/src/views/config/list/index.vue +++ b/apps/frontend/src/views/config/list/index.vue @@ -44,7 +44,7 @@ { } }; -const xAxisLabels = computed(() => { +const trafficXAxisLabels = computed(() => { const allTs = new Set(); for (const item of timeseries.value.traffic || []) { allTs.add(item.ts); } + return Array.from(allTs).sort((a, b) => a - b); +}); + +const systemXAxisLabels = computed(() => { + const allTs = new Set(); for (const item of timeseries.value.system || []) { allTs.add(item.ts); } @@ -199,7 +204,7 @@ const trafficChartOption = computed(() => ({ xAxis: { type: "category", boundaryGap: false, - data: xAxisLabels.value.map((ts) => timestampToDateTime(ts)), + data: trafficXAxisLabels.value.map((ts) => timestampToDateTime(ts)), }, yAxis: { type: "value", @@ -213,14 +218,14 @@ const trafficChartOption = computed(() => ({ type: "line", smooth: true, showSymbol: false, - data: xAxisLabels.value.map((ts) => trafficMap.value.get(ts)?.download || 0), + data: trafficXAxisLabels.value.map((ts) => trafficMap.value.get(ts)?.download || 0), }, { name: t("dashboard.upload"), type: "line", smooth: true, showSymbol: false, - data: xAxisLabels.value.map((ts) => trafficMap.value.get(ts)?.upload || 0), + data: trafficXAxisLabels.value.map((ts) => trafficMap.value.get(ts)?.upload || 0), }, ], })); @@ -237,7 +242,7 @@ const systemChartOption = computed(() => ({ xAxis: { type: "category", boundaryGap: false, - data: xAxisLabels.value.map((ts) => timestampToDateTime(ts)), + data: systemXAxisLabels.value.map((ts) => timestampToDateTime(ts)), }, yAxis: { type: "value", @@ -251,14 +256,14 @@ const systemChartOption = computed(() => ({ type: "line", smooth: true, showSymbol: false, - data: xAxisLabels.value.map((ts) => systemMap.value.get(ts)?.cpu || 0), + data: systemXAxisLabels.value.map((ts) => systemMap.value.get(ts)?.cpu || 0), }, { name: t("dashboard.ram"), type: "line", smooth: true, showSymbol: false, - data: xAxisLabels.value.map((ts) => systemMap.value.get(ts)?.mem || 0), + data: systemXAxisLabels.value.map((ts) => systemMap.value.get(ts)?.mem || 0), }, ], })); diff --git a/apps/frontend/src/views/error-page/401.vue b/apps/frontend/src/views/error-page/401.vue index 5cff666..8a3cfda 100644 --- a/apps/frontend/src/views/error-page/401.vue +++ b/apps/frontend/src/views/error-page/401.vue @@ -8,6 +8,9 @@ export default { @@ -37,13 +41,12 @@ function message() { />
-
OOPS!
+
{{ $t("errorPage.oops") }}
{{ message() }}
- Please check that the URL you entered is correct, or click the button - below to return to the homepage. + {{ $t("errorPage.notFoundInfo") }}
- Back to home + {{ $t("errorPage.notFoundBackHome") }}
diff --git a/apps/frontend/src/views/peer/list/index.vue b/apps/frontend/src/views/peer/list/index.vue index 4f14525..b5b476b 100644 --- a/apps/frontend/src/views/peer/list/index.vue +++ b/apps/frontend/src/views/peer/list/index.vue @@ -15,6 +15,18 @@
{{ $t("common.add") }} + + {{ $t("common.import") }} + + {{ $t("common.export") }}
@@ -88,7 +100,7 @@ - + @@ -127,21 +139,24 @@ import { computed, onMounted, reactive, ref } from "vue"; import { useI18n } from "vue-i18n"; import copy from "copy-to-clipboard"; -import { ElMessageBox } from "element-plus"; +import { ElMessage, ElMessageBox } from "element-plus"; import { formatBytes } from "@/utils/byte"; import { getMonthLater, timestampToDateTime } from "@/utils/time"; import { deletePeerApi, getPeerApi, getPeerClientConfigApi, + importPeerApi, kickPeerApi, pagePeerApi, releaseKickPeerApi, resetPeerTrafficApi, savePeerApi, updatePeerApi, + exportPeerApi, } from "@/api/peer"; import { PeerPageDto, PeerSaveDto, PeerUpdateDto, PeerVo } from "@/api/peer/types"; +import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus/lib/components"; const { t } = useI18n(); const loading = ref(false); @@ -149,6 +164,7 @@ const total = ref(0); const records = ref([]); const qrDialog = ref(false); const qrSrc = ref(""); +const importFileList = ref([]); const overviewQrSrc = ref(""); const overviewClientUrl = ref(""); const formRef = ref(); @@ -281,6 +297,47 @@ async function showQr(row: PeerVo) { qrDialog.value = true; } +async function handleImport(params: UploadRequestOptions) { + if (importFileList.value.length <= 0) { + return; + } + const formData = new FormData(); + formData.append("file", params.file); + await importPeerApi(formData); + importFileList.value = []; + await handleQuery(); +} + +function 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; + } + return true; +} + +async function handleExport() { + try { + const response = await exportPeerApi(); + const blob = new Blob([response.data], { type: "application/octet-stream" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + document.body.appendChild(a); + a.href = url; + const dis = response.headers["content-disposition"] || ""; + a.download = dis.split("attachment; filename=")[1] || "peers-export.json"; + a.click(); + window.URL.revokeObjectURL(url); + ElMessage.success(t("common.downloadSuccess")); + } catch { + ElMessage.error(t("common.invalid")); + } +} + onMounted(handleQuery); diff --git a/apps/middleware/machine_auth.go b/apps/middleware/machine_auth.go new file mode 100644 index 0000000..fd6317b --- /dev/null +++ b/apps/middleware/machine_auth.go @@ -0,0 +1,53 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "hy2xs-admin/dao" + "hy2xs-admin/model/constant" +) + +func MachineAuthHandler() gin.HandlerFunc { + return func(c *gin.Context) { + secretCfg, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret) + if err != nil || secretCfg.Value == nil { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "code": http.StatusForbidden, + "type": "no", + "message": "machine auth failed", + "data": nil, + }) + return + } + + expected := strings.TrimSpace(*secretCfg.Value) + if expected == "" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "code": http.StatusForbidden, + "type": "no", + "message": "machine auth not configured", + "data": nil, + }) + return + } + + provided := strings.TrimSpace(c.Query("access_token")) + if provided == "" { + provided = strings.TrimSpace(c.GetHeader("X-HY2XS-Machine-Token")) + } + + if provided == "" || provided != expected { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "code": http.StatusForbidden, + "type": "no", + "message": "machine auth failed", + "data": nil, + }) + return + } + + c.Next() + } +} diff --git a/apps/router/router.go b/apps/router/router.go index 4fd9d3f..a81d9b7 100644 --- a/apps/router/router.go +++ b/apps/router/router.go @@ -46,7 +46,7 @@ func Router(router *gin.Engine, huiWebContext *string) { globalGroup := router.Group(relativePath) machineApi := globalGroup.Group("/hui") - machineApi.Use(middleware.LocalOnlyHandler(), middleware.LogHandler()) + machineApi.Use(middleware.LocalOnlyHandler(), middleware.MachineAuthHandler(), middleware.LogHandler()) initHysteria2MachineAuthRouter(machineApi) authApi := globalGroup.Group("/hui") diff --git a/apps/service/config.go b/apps/service/config.go index 9c656bd..5cf0dec 100644 --- a/apps/service/config.go +++ b/apps/service/config.go @@ -9,6 +9,8 @@ import ( "hy2xs-admin/model/bo" "hy2xs-admin/model/constant" "hy2xs-admin/model/entity" + "net" + "net/url" "os" "strconv" "strings" @@ -88,6 +90,9 @@ func UpdateHysteria2Config(hysteria2ServerConfig bo.Hysteria2ServerConfig) error http.Insecure = &authHttpInsecure auth.HTTP = &http hysteria2ServerConfig.Auth = &auth + if hysteria2ServerConfig.TrafficStats == nil { + hysteria2ServerConfig.TrafficStats = &bo.ServerConfigTrafficStats{} + } hysteria2ServerConfig.TrafficStats.Secret = &trafficStatsSecret yamlConfig, err := yaml.Marshal(&hysteria2ServerConfig) @@ -119,15 +124,40 @@ func GetHysteria2ApiPort() (int64, error) { logrus.Errorf(errMsg) return 0, errors.New(errMsg) } - apiPort, err := strconv.ParseInt(strings.Split(*hysteria2Config.TrafficStats.Listen, ":")[1], 10, 64) + apiPort, err := parseTrafficStatsPort(*hysteria2Config.TrafficStats.Listen) if err != nil { - errMsg := fmt.Sprintf("apiPort: %s is invalid", *hysteria2Config.TrafficStats.Listen) + errMsg := fmt.Sprintf("apiPort: %s is invalid: %v", *hysteria2Config.TrafficStats.Listen, err) logrus.Errorf(errMsg) return 0, errors.New(errMsg) } return apiPort, nil } +func parseTrafficStatsPort(listen string) (int64, error) { + trimmed := strings.TrimSpace(listen) + if trimmed == "" { + return 0, errors.New("empty listen") + } + + hostPort := trimmed + if strings.HasPrefix(trimmed, ":") { + hostPort = "127.0.0.1" + trimmed + } + + _, portStr, err := net.SplitHostPort(hostPort) + if err != nil { + return 0, err + } + port, err := strconv.ParseInt(portStr, 10, 64) + if err != nil { + return 0, err + } + if port <= 0 || port > 65535 { + return 0, errors.New("port out of range") + } + return port, nil +} + func GetPortAndCert() (int64, string, string, error) { configs, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.HUICrtPath, constant.HUIKeyPath}) if err != nil { @@ -173,5 +203,13 @@ func GetAuthHttpUrl() (string, error) { if config.Value != nil && *config.Value != "/" && strings.HasPrefix(*config.Value, "/") { webContext = *config.Value } - return fmt.Sprintf("%s://127.0.0.1:%d%s/hui/hysteria2/auth", protocol, port, webContext), nil + trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret) + if err != nil { + return "", err + } + authURL := fmt.Sprintf("%s://127.0.0.1:%d%s/hui/hysteria2/auth", protocol, port, webContext) + if trafficSecretConfig.Value != nil && strings.TrimSpace(*trafficSecretConfig.Value) != "" { + authURL = fmt.Sprintf("%s?access_token=%s", authURL, url.QueryEscape(strings.TrimSpace(*trafficSecretConfig.Value))) + } + return authURL, nil }