diff --git a/Dockerfile b/Dockerfile index 050c602..bc3e484 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM node:22-alpine -RUN apk add --no-cache docker-cli +RUN apk add --no-cache docker-cli openssh-client sshpass RUN mkdir -p /data && chmod 700 /data diff --git a/README.md b/README.md index d83ca2f..841f029 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,9 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas | `AWG_CONTAINER` | `amnezia-awg2` | Имя контейнера Amnezia WG | | `AWG_PROFILES` | _(нет)_ | JSON-массив профилей: несколько контейнеров/путей (см. ниже). Если задан — переключатель «Инстанс» в вебе | | `SCHEDULE_DISCONNECT_MS` | `60000` | Как часто планировщик проверяет отложенное отключение из туннеля (мс) | +| `DISPLAY_TZ` | _(нет)_ | IANA пояс для строки «Сервер» в UI (например `Asia/Nicosia`), если не хотите полагаться только на `TZ` контейнера | +| `TIME_SYNC_SSH_HOST` | `172.17.0.1` | Хост для SSH root при синхронизации времени из панели (часто шлюз Docker к хосту) | +| `TIME_SYNC_DISABLED` | `0` | `1` — скрыть/отключить синхронизацию времени по SSH | | `ADMIN_PASSWORD` | _(генерируется)_ | Первый пароль вместо файла | | `SKIP_DOWNLOAD` | `0` | `1` — не качать GitHub, собрать из `INSTALL_DIR` | | `ALLOW_DEFAULT_PASSWORD` | `0` | `1` — см. раздел «Пароль» ниже | diff --git a/public/app.js b/public/app.js index 340c030..e0b8c64 100644 --- a/public/app.js +++ b/public/app.js @@ -224,6 +224,7 @@ loginForm.addEventListener("submit", async (ev) => { loginPassword.value = ""; showApp(); await loadProtocols(); + await loadTimeSyncCaps(); await loadClients(); } catch (e) { loginError.textContent = String(e.message || e); @@ -269,6 +270,23 @@ const clockFmt = new Intl.DateTimeFormat("ru-RU", { timeStyle: "medium", }); +/** Часовой пояс строки «Сервер» (IANA), как в /api/server-time */ +let serverDisplayTz = "UTC"; +/** @type {Intl.DateTimeFormat | null} */ +let serverTzFmtCached = null; + +function buildServerTzFmt(tz) { + try { + return new Intl.DateTimeFormat("ru-RU", { + dateStyle: "medium", + timeStyle: "medium", + timeZone: tz, + }); + } catch { + return null; + } +} + /** @type {ReturnType | null} */ let clockTickId = null; /** @type {ReturnType | null} */ @@ -276,7 +294,7 @@ let clockServerPollId = null; /** Метка UTC сервера (мс) по последнему ответу API */ let serverAnchorUtcMs = /** @type {number | null} */ (null); -/** Date.now() в момент установки якоря (компенсация сети только между опросами) */ +/** Date.now() в момент установки якоря */ let serverAnchorWallMs = 0; function browserTimeZoneLabel() { @@ -296,8 +314,8 @@ function tickServerClockDisplay() { const estimatedUtcMs = serverAnchorUtcMs + (Date.now() - serverAnchorWallMs); const d = new Date(estimatedUtcMs); clockServerEl.dateTime = d.toISOString(); - const tz = browserTimeZoneLabel(); - clockServerEl.textContent = tz ? `${clockFmt.format(d)} · ${tz}` : clockFmt.format(d); + const fmt = serverTzFmtCached || clockFmt; + clockServerEl.textContent = `${fmt.format(d)} · ${serverDisplayTz}`; } async function refreshServerClock() { @@ -310,9 +328,13 @@ async function refreshServerClock() { } serverAnchorUtcMs = parsed; serverAnchorWallMs = Date.now(); + serverDisplayTz = + typeof t.timeZone === "string" && t.timeZone.trim() ? t.timeZone.trim() : "UTC"; + serverTzFmtCached = buildServerTzFmt(serverDisplayTz); tickServerClockDisplay(); } catch { serverAnchorUtcMs = null; + serverTzFmtCached = null; clockServerEl.dateTime = ""; clockServerEl.textContent = "—"; } @@ -341,6 +363,8 @@ function stopClocks() { } serverAnchorUtcMs = null; serverAnchorWallMs = 0; + serverTzFmtCached = null; + serverDisplayTz = "UTC"; clockServerEl.dateTime = ""; clockLocalEl.dateTime = ""; clockServerEl.textContent = "—"; @@ -355,6 +379,44 @@ function startClocks() { clockServerPollId = setInterval(() => void refreshServerClock(), 30_000); } +async function loadTimeSyncCaps() { + const hint = document.querySelector("#sync-host-hint"); + const btn = document.querySelector("#sync-host-time"); + try { + const c = await api("/api/time-sync-capabilities"); + if (hint) { + hint.textContent = c.hostTimeSync + ? `Через SSH на root@${c.sshHost}. Часовой пояс строки «Сервер»: ${c.serverClockTimeZone}. Пароль не сохраняется.` + : `Авто-синхронизация по SSH недоступна (или TIME_SYNC_DISABLED). Пояс «Сервер»: ${c.serverClockTimeZone}. Задайте DISPLAY_TZ или TZ для контейнера панели — см. README.`; + } + if (btn) btn.disabled = !c.hostTimeSync; + } catch { + if (hint) hint.textContent = ""; + if (btn) btn.disabled = true; + } +} + +document.querySelector("#sync-host-time")?.addEventListener("click", async () => { + const inp = document.querySelector("#sync-root-pw"); + const pw = inp && typeof inp.value === "string" ? inp.value : ""; + if (!pw.trim()) { + setStatus("Введите пароль root на хосте.", true); + return; + } + try { + setStatus("Синхронизация времени хоста…", false); + await api("/api/sync-host-time", { + method: "POST", + body: JSON.stringify({ rootPassword: pw, unixMs: Date.now() }), + }); + inp.value = ""; + setStatus("Запрос выполнен. Проверьте строку «Сервер».", false); + void refreshServerClock(); + } catch (e) { + setStatus(String(e.message || e), true); + } +}); + const dtRu = new Intl.DateTimeFormat("ru-RU", { dateStyle: "short", timeStyle: "short", @@ -545,6 +607,7 @@ async function boot() { if (ok) { showApp(); await loadProtocols(); + await loadTimeSyncCaps(); await loadClients(); } else { showLogin(); diff --git a/public/index.html b/public/index.html index 900a703..8f58902 100644 --- a/public/index.html +++ b/public/index.html @@ -67,11 +67,20 @@

- Сервер показывается в вашем часовом поясе (как браузер). «Сверить» — заново запросить момент с VPS. + «Сервер» — время в часовом поясе VPS (подпись справа). «Браузер» — ваш компьютер. «Сверить» — заново запросить момент с сервера.

+
+ Синхронизировать часы VPS с этим компьютером +

+ + + +
diff --git a/public/styles.css b/public/styles.css index 2af47b2..76c66f9 100644 --- a/public/styles.css +++ b/public/styles.css @@ -268,6 +268,43 @@ h1 { align-self: flex-start; } +.clock-host-sync { + margin-top: 0.65rem; + padding-top: 0.55rem; + border-top: 1px solid var(--line); + max-width: 26rem; +} + +.clock-host-sync summary { + cursor: pointer; + font-size: 0.84rem; + color: var(--accent, #38bdf8); +} + +.clock-sync-hint { + margin: 0.45rem 0 0.5rem; + font-size: 0.76rem; + line-height: 1.4; +} + +.clock-sync-input { + display: block; + width: 100%; + max-width: 22rem; + margin-top: 0.35rem; + padding: 0.45rem 0.55rem; + border-radius: 10px; + border: 1px solid var(--line); + background: #0a0f16; + color: var(--text); + font: inherit; + font-size: 0.85rem; +} + +.clock-sync-submit { + margin-top: 0.45rem; +} + .pill { display: inline-flex; align-items: center; diff --git a/scripts/install.sh b/scripts/install.sh index 012a482..a54f8c9 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -101,6 +101,18 @@ if [[ -n "${AWG_PROFILES:-}" ]]; then RUN_ENV+=( -e "AWG_PROFILES=${AWG_PROFILES}" ) fi +if [[ -n "${DISPLAY_TZ:-}" ]]; then + RUN_ENV+=( -e "DISPLAY_TZ=${DISPLAY_TZ}" ) +fi + +if [[ -n "${TIME_SYNC_SSH_HOST:-}" ]]; then + RUN_ENV+=( -e "TIME_SYNC_SSH_HOST=${TIME_SYNC_SSH_HOST}" ) +fi + +if [[ -n "${TIME_SYNC_DISABLED:-}" ]]; then + RUN_ENV+=( -e "TIME_SYNC_DISABLED=${TIME_SYNC_DISABLED}" ) +fi + if [[ -n "${BOOT_PW}" ]]; then RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" ) elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then diff --git a/server.js b/server.js index 8635bd5..8e18858 100644 --- a/server.js +++ b/server.js @@ -461,6 +461,68 @@ function requireDisconnectedAt(raw) { return d.toISOString(); } +/** Часовой пояс для строки «Сервер» в UI (IANA). Приоритет: DISPLAY_TZ → TZ процесса Node */ +function resolveServerClockTimeZone() { + const override = process.env.DISPLAY_TZ?.trim(); + if (override) return override; + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } catch { + return "UTC"; + } +} + +function sshpassBinaryPath() { + for (const p of ["/usr/bin/sshpass", "/usr/local/bin/sshpass"]) { + try { + fs.accessSync(p, fs.constants.X_OK); + return p; + } catch { + /* next */ + } + } + return null; +} + +function hostTimeSyncConfigured() { + if (process.env.TIME_SYNC_DISABLED === "1" || process.env.TIME_SYNC_DISABLED === "true") { + return false; + } + return !!sshpassBinaryPath(); +} + +function sshRootRun(password, host, remoteCmd) { + const bin = sshpassBinaryPath(); + if (!bin) { + return Promise.reject(new Error("sshpass не установлен")); + } + return new Promise((resolve, reject) => { + const args = [ + "-p", + password, + "ssh", + "-oBatchMode=yes", + "-oStrictHostKeyChecking=no", + "-oUserKnownHostsFile=/dev/null", + "-oConnectTimeout=15", + "-oPreferredAuthentications=password", + "-oPubkeyAuthentication=no", + `root@${host}`, + remoteCmd, + ]; + const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] }); + let out = ""; + let err = ""; + child.stdout.on("data", (c) => (out += c)); + child.stderr.on("data", (c) => (err += c)); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolve(out.trim()); + else reject(new Error(err.trim() || out.trim() || `ssh код ${code}`)); + }); + }); +} + ensureDataDir(); loadOrCreateSessionSecret(); bootstrapPassword(); @@ -482,22 +544,67 @@ app.get("/api/session", (req, res) => { app.get("/api/server-time", requireAuth, (_req, res) => { const now = new Date(); - let timeZone = "UTC"; + const timeZone = resolveServerClockTimeZone(); + let formatted; try { - timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || timeZone; + formatted = now.toLocaleString("ru-RU", { + dateStyle: "medium", + timeStyle: "medium", + timeZone, + }); } catch { - /* ignore */ + formatted = now.toLocaleString("ru-RU", { + dateStyle: "medium", + timeStyle: "medium", + }); } res.json({ iso: now.toISOString(), - formatted: now.toLocaleString("ru-RU", { - dateStyle: "medium", - timeStyle: "medium", - }), + formatted, timeZone, }); }); +app.get("/api/time-sync-capabilities", requireAuth, (_req, res) => { + res.json({ + hostTimeSync: hostTimeSyncConfigured(), + sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1", + serverClockTimeZone: resolveServerClockTimeZone(), + }); +}); + +app.post("/api/sync-host-time", requireAuth, async (req, res) => { + if (!hostTimeSyncConfigured()) { + return res.status(503).json({ + error: + "Синхронизация времени хоста недоступна (нет sshpass или TIME_SYNC_DISABLED=1).", + }); + } + const pw = req.body?.rootPassword; + const unixMsRaw = req.body?.unixMs; + const unixMs = + typeof unixMsRaw === "number" && Number.isFinite(unixMsRaw) ? unixMsRaw : Date.now(); + if (typeof pw !== "string" || !pw) { + return res.status(400).json({ error: "Укажите пароль root VPS" }); + } + const unixSec = Math.floor(unixMs / 1000); + if (!Number.isFinite(unixSec)) { + return res.status(400).json({ error: "Некорректное время" }); + } + const host = process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1"; + const remoteCmd = `bash -lc 'date -u --set=@${unixSec} 2>/dev/null || date -s @${unixSec}; (command -v hwclock >/dev/null && hwclock -w --utc) || true; date -u +%Y-%m-%dT%H:%M:%SZ'`; + try { + const confirmed = await sshRootRun(pw, host, remoteCmd); + res.json({ ok: true, utc: confirmed }); + } catch { + console.warn("sync-host-time: ssh не выполнен"); + res.status(400).json({ + error: + "Не удалось выставить время по SSH. Проверьте пароль root, вход root по паролю на хосте и переменную TIME_SYNC_SSH_HOST (часто 172.17.0.1 с контейнера).", + }); + } +}); + app.post("/api/login", (req, res) => { const pw = req.body?.password; if (typeof pw !== "string" || !pw) {