diff --git a/apps/frontend/src/lang/index.ts b/apps/frontend/src/lang/index.ts index 899af53..52556d1 100644 --- a/apps/frontend/src/lang/index.ts +++ b/apps/frontend/src/lang/index.ts @@ -1,10 +1,15 @@ import { createI18n } from "vue-i18n"; -import { useAppStore } from "@/store/modules/app"; - -const appStore = useAppStore(); +import defaultSettings from "@/settings"; import enLocale from "./package/en"; import ruLocale from "./package/ru"; +function initialLocale(): string { + if (typeof window === "undefined") { + return defaultSettings.language || "ru"; + } + return window.localStorage.getItem("language") || defaultSettings.language || "ru"; +} + const messages = { ru: { ...ruLocale, @@ -16,7 +21,8 @@ const messages = { const i18n = createI18n({ legacy: false, - locale: appStore.language, + locale: initialLocale(), + fallbackLocale: "ru", messages: messages, globalInjection: true, }); diff --git a/apps/frontend/src/permission.ts b/apps/frontend/src/permission.ts index 146ef81..5f5d62c 100644 --- a/apps/frontend/src/permission.ts +++ b/apps/frontend/src/permission.ts @@ -7,8 +7,6 @@ import "nprogress/nprogress.css"; NProgress.configure({ showSpinner: false }); // Индикатор загрузки -const permissionStore = usePermissionStoreHook(); - // Разрешённые маршруты const whiteList = ["/login", "/register"]; @@ -37,6 +35,7 @@ router.beforeEach(async (to, from, next) => { } else { try { const { roles, forcePasswordChange } = await adminStore.getAdminInfo(); + const permissionStore = usePermissionStoreHook(); const accessRoutes = permissionStore.generateRoutes(roles); accessRoutes.forEach((route) => { router.addRoute(route); @@ -68,4 +67,3 @@ router.beforeEach(async (to, from, next) => { router.afterEach(() => { NProgress.done(); }); - diff --git a/docs/12-operations-and-troubleshooting.md b/docs/12-operations-and-troubleshooting.md index 9b6f670..57b4eea 100644 --- a/docs/12-operations-and-troubleshooting.md +++ b/docs/12-operations-and-troubleshooting.md @@ -107,6 +107,50 @@ curl -sS \ - совпадает ли `HUI_INSTALL_DIR` с реальностью - не сломан ли bind host / port +### Admin UI access via SSH tunnel + +Production-модель для UI: `HY2XS_UI_BIND_HOST=127.0.0.1`, внешний доступ к `8080/tcp` не открывается. +Доступ оператора выполняется через SSH local forwarding. + +Windows-команда туннеля: + +```bash +ssh -p 2323 \ + -i C:\Users\kirap\.ssh\id_ed25519_uk1 \ + -N \ + -L 127.0.0.1:8080:127.0.0.1:8080 \ + root@185.156.108.141 +``` + +После запуска открыть `http://127.0.0.1:8080/#/login`. + +Если SSH-туннель не поднимается (`administratively prohibited`), проверить effective SSH policy: + +```bash +sshd -T | grep -E '^(port|allowtcpforwarding|permitopen|gatewayports|passwordauthentication|permitrootlogin) ' +``` + +Рекомендуемый фрагмент hardening `sshd_config`: + +```sshconfig +Port 2323 +PubkeyAuthentication yes +PasswordAuthentication no +KbdInteractiveAuthentication no +PermitRootLogin prohibit-password + +AllowTcpForwarding local +PermitOpen 127.0.0.1:8080 localhost:8080 +GatewayPorts no + +X11Forwarding no +AllowAgentForwarding no +MaxAuthTries 3 +LoginGraceTime 20 +ClientAliveInterval 300 +ClientAliveCountMax 2 +``` + ### Hysteria скачалась, но не стартует Проверить: - валиден ли config diff --git a/docs/13-production-runbook.md b/docs/13-production-runbook.md index 000ccc1..bb9a465 100644 --- a/docs/13-production-runbook.md +++ b/docs/13-production-runbook.md @@ -99,7 +99,51 @@ hy2xs-orchestrator doctor --package-dir /usr/local/lib/hy2xs/package --config /e Команда выполняет preflight + smoke как post-install/post-reboot validation. -## 13. Secret-safe config sharing +## 13. Admin UI access via SSH tunnel + +Production policy: UI остаётся loopback-only (`HY2XS_UI_BIND_HOST=127.0.0.1`), внешний доступ к `8080/tcp` не открывается. +Операторский доступ выполняется через SSH local forwarding. + +Windows tunnel command: + +```bash +ssh -p 2323 \ + -i C:\Users\kirap\.ssh\id_ed25519_uk1 \ + -N \ + -L 127.0.0.1:8080:127.0.0.1:8080 \ + root@185.156.108.141 +``` + +Open in browser: `http://127.0.0.1:8080/#/login`. + +Если туннель падает с `administratively prohibited`, проверить effective sshd-конфиг: + +```bash +sshd -T | grep -E '^(port|allowtcpforwarding|permitopen|gatewayports|passwordauthentication|permitrootlogin) ' +``` + +Recommended sshd hardening fragment: + +```sshconfig +Port 2323 +PubkeyAuthentication yes +PasswordAuthentication no +KbdInteractiveAuthentication no +PermitRootLogin prohibit-password + +AllowTcpForwarding local +PermitOpen 127.0.0.1:8080 localhost:8080 +GatewayPorts no + +X11Forwarding no +AllowAgentForwarding no +MaxAuthTries 3 +LoginGraceTime 20 +ClientAliveInterval 300 +ClientAliveCountMax 2 +``` + +## 14. Secret-safe config sharing Для передачи конфигов в тикеты/чаты используйте встроенную redaction-команду: diff --git a/orchestrator/src/commands/doctor.ts b/orchestrator/src/commands/doctor.ts index 5d6d0d8..615b8a8 100644 --- a/orchestrator/src/commands/doctor.ts +++ b/orchestrator/src/commands/doctor.ts @@ -1,10 +1,61 @@ import type { ReconfigureContext, ReconfigureOptions } from "../types/context"; import { readText } from "../lib/fs"; -import { setOperationContext, step, stepDone } from "../lib/log"; +import { info, setOperationContext, step, stepDone } from "../lib/log"; import { parseRuntimeEnv } from "../config/env"; import { preflight } from "../steps/preflight"; import { smoke } from "../steps/smoke"; import { readInstalledHysteriaVersion, readPackageValue } from "../lib/packageMeta"; +import { run } from "../lib/process"; + +function hasPermitOpenForLocalUi(value: string): boolean { + const normalized = value.trim().toLowerCase(); + if (!normalized) { + return false; + } + if (normalized === "any") { + return true; + } + return normalized.split(/\s+/).includes("127.0.0.1:8080") || normalized.split(/\s+/).includes("localhost:8080"); +} + +async function checkSshForwardingForLocalUi(uiBindHost: string): Promise { + if (uiBindHost !== "127.0.0.1") { + return; + } + + try { + const sshdConfigText = await run`sshd -T`; + const lines = sshdConfigText.split(/\r?\n/); + const effective = new Map(); + for (const line of lines) { + const normalized = line.trim(); + if (!normalized) { + continue; + } + const separator = normalized.indexOf(" "); + if (separator <= 0) { + continue; + } + const key = normalized.slice(0, separator).trim(); + const value = normalized.slice(separator + 1).trim(); + effective.set(key, value); + } + + const allowTcpForwarding = (effective.get("allowtcpforwarding") || "").toLowerCase(); + const permitOpen = effective.get("permitopen") || ""; + + const forwardingEnabled = allowTcpForwarding === "yes" || allowTcpForwarding === "all" || allowTcpForwarding === "local"; + const permitOpenValid = hasPermitOpenForLocalUi(permitOpen); + + if (!forwardingEnabled || !permitOpenValid) { + info( + "WARNING: UI is local-only, but SSH local forwarding is disabled or restricted (allowtcpforwarding/permitopen). Verify sshd policy for 127.0.0.1:8080 tunnel access." + ); + } + } catch { + info("WARNING: unable to read effective sshd config via `sshd -T`; skipping SSH forwarding advisory check."); + } +} export async function doctor(options: ReconfigureOptions): Promise { setOperationContext(`doctor-${Date.now().toString(36)}`); @@ -24,6 +75,9 @@ export async function doctor(options: ReconfigureOptions): Promise { step("doctor preflight"); await preflight(context); stepDone("doctor preflight"); + + await checkSshForwardingForLocalUi(context.config.uiBindHost); + step("doctor smoke"); await smoke(context); stepDone("doctor smoke"); diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index 642a156..972c897 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -40,6 +40,9 @@ run_fix20_acceptance_subset() { log_step "Acceptance: bootstrap peer can pass auth smoke" grep -q 'quota := int64(-1)' apps/dao/sqlite.go || fail "acceptance: bootstrap peer quota must be unlimited (-1), otherwise install auth smoke fails" + log_step "Acceptance: frontend i18n does not touch Pinia at module import" + ! grep -q 'useAppStore' apps/frontend/src/lang/index.ts || fail "acceptance: lang/index.ts must not import/use Pinia store" + log_step "Acceptance: env rendering maps machine token and fails on unresolved placeholders" grep -q 'HYSTERIA_API_SECRET: context.config.hysteriaTrafficStatsSecret' orchestrator/src/steps/env.ts || fail "acceptance: writePostInstallEnv must pass HYSTERIA_API_SECRET" grep -q 'template render failed: unresolved placeholders' orchestrator/src/lib/fs.ts || fail "acceptance: renderTemplate must fail on unresolved placeholders"