diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index b70ea90..c816108 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -552,14 +552,14 @@ pub async fn update_settings( if let Some(value) = input .turnstile_site_key .as_deref() - .filter(|value| value.trim() != SECRET_MASK) + .filter(|value| !value.trim().is_empty() && value.trim() != SECRET_MASK) { updates.push(("turnstile_site_key", value.trim().to_string())); } if let Some(value) = input .turnstile_secret_key .as_deref() - .filter(|value| value.trim() != SECRET_MASK) + .filter(|value| !value.trim().is_empty() && value.trim() != SECRET_MASK) { updates.push(("turnstile_secret_key", value.trim().to_string())); } @@ -811,6 +811,52 @@ fn mask_secret(value: &str) -> String { mod tests { use super::*; + #[tokio::test] + async fn blank_or_masked_turnstile_keys_preserve_saved_values() { + let db = connect("sqlite::memory:").await.unwrap(); + db.migrate().await.unwrap(); + set_setting(&db, "turnstile_site_key", "saved-site") + .await + .unwrap(); + set_setting(&db, "turnstile_secret_key", "saved-secret") + .await + .unwrap(); + for value in ["", " ", SECRET_MASK] { + let input: SettingsInput = serde_json::from_value(serde_json::json!({ + "turnstile_site_key": value, "turnstile_secret_key": value, + "turnstile_login_enabled": false + })) + .unwrap(); + update_settings(&db, &input, None).await.unwrap(); + assert_eq!( + get_setting(&db, "turnstile_site_key") + .await + .unwrap() + .as_deref(), + Some("saved-site") + ); + assert_eq!( + get_setting(&db, "turnstile_secret_key") + .await + .unwrap() + .as_deref(), + Some("saved-secret") + ); + } + let input: SettingsInput = serde_json::from_value(serde_json::json!({ + "turnstile_site_key": "new-site", "turnstile_secret_key": "new-secret" + })) + .unwrap(); + update_settings(&db, &input, None).await.unwrap(); + assert_eq!( + get_setting(&db, "turnstile_secret_key") + .await + .unwrap() + .as_deref(), + Some("new-secret") + ); + } + #[tokio::test] async fn sqlite_file_pool_configures_every_connection() { let directory = tempfile::tempdir().unwrap(); diff --git a/backend/src/main.rs b/backend/src/main.rs index 18657af..8dc56ff 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -155,6 +155,11 @@ async fn main() -> Result<()> { let bind_addr = args .bind .unwrap_or(config.bind_addr.parse().context("invalid bind_addr")?); + let listener = tokio::net::TcpListener::bind(bind_addr) + .await + .with_context(|| { + format!("failed to bind {bind_addr}; check whether the port is already in use") + })?; let database = db::connect(&config.database_url).await?; tracing::info!(database = ?database.kind(), "connected to database"); @@ -353,7 +358,6 @@ async fn main() -> Result<()> { .with_state(Arc::clone(&state)); spawn_maintenance(Arc::clone(&state)); - let listener = tokio::net::TcpListener::bind(bind_addr).await?; tracing::info!(address = %bind_addr, "NodeFlare listening"); axum::serve( listener, diff --git a/backend/src/routes/admin.rs b/backend/src/routes/admin.rs index 5b0a9b3..f3e03f9 100644 --- a/backend/src/routes/admin.rs +++ b/backend/src/routes/admin.rs @@ -1041,7 +1041,7 @@ async fn validate_settings( fn submitted_secret<'a>(submitted: Option<&'a str>, current: &'a str) -> &'a str { match submitted.map(str::trim) { - None | Some(SECRET_MASK) => current, + None | Some("") | Some(SECRET_MASK) => current, Some(value) => value, } } diff --git a/backend/src/turnstile.rs b/backend/src/turnstile.rs index efe948a..e825a30 100644 --- a/backend/src/turnstile.rs +++ b/backend/src/turnstile.rs @@ -16,6 +16,8 @@ struct VerifyResponse { hostname: String, #[serde(default)] action: String, + #[serde(default, rename = "error-codes")] + error_codes: Vec, } pub async fn verify( @@ -26,7 +28,7 @@ pub async fn verify( hostname: &str, expected_action: &str, ) -> bool { - if token.is_empty() || secret.is_empty() { + if token.is_empty() || token.len() > 2048 || secret.is_empty() { return false; } let result = client @@ -41,12 +43,58 @@ pub async fn verify( .await .and_then(reqwest::Response::error_for_status); let Ok(response) = result else { + tracing::warn!("Turnstile Siteverify request failed"); return false; }; let Ok(result) = response.json::().await else { + tracing::warn!("Turnstile Siteverify returned an invalid response"); return false; }; - result.success - && (result.hostname.is_empty() || result.hostname.eq_ignore_ascii_case(hostname)) - && (result.action.is_empty() || result.action == expected_action) + let valid = valid_response(&result, hostname, expected_action); + if !valid { + tracing::warn!(success = result.success, error_codes = ?result.error_codes, + hostname_matches = result.hostname.eq_ignore_ascii_case(hostname), + action_matches = result.action == expected_action, + "Turnstile verification rejected"); + } + valid +} + +fn valid_response(result: &VerifyResponse, hostname: &str, expected_action: &str) -> bool { + result.success + && !hostname.is_empty() + && result.hostname.eq_ignore_ascii_case(hostname) + && result.action == expected_action +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn checks_success_hostname_and_action() { + for action in ["admin_login", "public_dashboard"] { + let mut result = VerifyResponse { + success: true, + hostname: "dash.example.com".into(), + action: action.into(), + error_codes: Vec::new(), + }; + assert!(valid_response(&result, "DASH.example.com", action)); + assert!(!valid_response(&result, "other.example.com", action)); + assert!(!valid_response( + &result, + "dash.example.com", + &action.replace('_', "-") + )); + result.success = false; + assert!(!valid_response(&result, "dash.example.com", action)); + result.success = true; + result.action.clear(); + assert!(!valid_response(&result, "dash.example.com", action)); + result.action = action.into(); + result.hostname.clear(); + assert!(!valid_response(&result, "dash.example.com", action)); + } + } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e8318a0..1fe43f3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -54,7 +54,7 @@ function LoginGate() { {totpRequired ? : null} - {config.turnstile_login_enabled ?
: null} + {config.turnstile_login_enabled ?
: null} {error ?

{error}

: null} @@ -65,6 +65,7 @@ function LoginGate() { function VerificationGate() { const { config, dark, error, setError, verify } = useApp(); const [busy, setBusy] = useState(false); + const [resetKey, setResetKey] = useState(0); const locale = config.locale; async function run(token: string) { @@ -85,10 +86,11 @@ function VerificationGate() {

{ui(locale, "访问验证", "Access verification")}

{config.site_name}

{config.turnstile_site_key - ? void run(token)} onError={setError} /> + ? void run(token)} onError={setError} /> :

{ui(locale, "Turnstile 尚未正确配置", "Turnstile is not configured")}

} {busy ? {ui(locale, "正在验证", "Verifying")} : null} {error ?

{error}

: null} + {error && !busy ? : null} ); } diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 9b5ac36..8d2f7ea 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -49,6 +49,8 @@ import { SiteLogo } from "./SiteLogo"; import { LatencyManager } from "./LatencyManager"; import { AlertRuleManager } from "./AlertRuleManager"; import { TelegramSettings } from "./TelegramSettings"; +import { PasswordInput } from "./PasswordInput"; +import { useVerification } from "./useVerification"; import { Flag } from "./Flag"; import pkg from "../../package.json"; @@ -305,12 +307,9 @@ export function AdminPanel({ const [remoteQuery, setRemoteQuery] = useState(""); const [remoteCommand, setRemoteCommand] = useState(""); const [remoteTasks, setRemoteTasks] = useState([]); - const [remoteTotpCode, setRemoteTotpCode] = useState(""); const [twoFactorStatus, setTwoFactorStatus] = useState(null); const [twoFactorSetup, setTwoFactorSetup] = useState(null); - const [twoFactorCode, setTwoFactorCode] = useState(""); - const [securityVerification, setSecurityVerification] = useState(""); - const [databaseVerification, setDatabaseVerification] = useState(""); + const [newPasswordConfirmation, setNewPasswordConfirmation] = useState(""); const [twoFactorSecretCopied, setTwoFactorSecretCopied] = useState(false); const [loginSessions, setLoginSessions] = useState([]); const [loginSessionsLoaded, setLoginSessionsLoaded] = useState(false); @@ -321,8 +320,8 @@ export function AdminPanel({ const remoteTasksRequestRef = useRef(0); const remoteTasksActive = useMemo(() => hasActiveRemoteTasks(remoteTasks), [remoteTasks]); const remotePayloadReady = Boolean(remoteCommand.trim()); - const remoteTotpReady = twoFactorStatus?.enabled === true && /^\d{6}$/.test(remoteTotpCode); const loginTotpRequired = config.totp_login_enabled || loginTotpChallenge; + const verificationDialog = useVerification(authenticated); remoteTasksRef.current = remoteTasks; @@ -589,6 +588,7 @@ export function AdminPanel({ delete payload.current_totp_code; try { if (payload.new_password) { + if (payload.new_password !== newPasswordConfirmation) throw new Error("两次输入的新密码不一致"); payload.new_password_derived = await derivePassword(payload.new_password, config.password_client_salt); delete payload.new_password; } else { @@ -596,21 +596,17 @@ export function AdminPanel({ delete payload.new_password_derived; } if (tab === "security") { - const verification = securityVerification.trim(); - if (twoFactorStatus?.enabled) { - if (!/^\d{6}$/.test(verification)) throw new Error("请输入当前 6 位两步验证码"); - payload.current_totp_code = verification; + const proof = await sensitiveProof("保存设置"); + if (!proof) return; + if (proof.totpCode) { + payload.current_totp_code = proof.totpCode; } else { - if (!verification) throw new Error("请输入当前管理员密码"); - payload.current_password_derived = await derivePassword( - verification, - config.password_client_salt, - ); + payload.current_password_derived = proof.passwordDerived; } } const result = await api.saveSettings(payload); setSettings(result); - setSecurityVerification(""); + setNewPasswordConfirmation(""); if (tab === "security") await loadLoginSessions(); setNotice("设置已保存"); onChanged(); @@ -630,16 +626,12 @@ export function AdminPanel({ finally { setBusy(false); } } - async function sensitiveProof(value: string, action: string) { - const verification = value.trim(); - if (twoFactorStatus?.enabled) { - if (!/^\d{6}$/.test(verification)) throw new Error(`${action}请输入当前 6 位两步验证码`); - return { totpCode: verification }; - } - if (!verification) throw new Error(`${action}请输入当前管理员密码`); - return { - passwordDerived: await derivePassword(verification, config.password_client_salt), - }; + async function sensitiveProof(action: string) { + if (!twoFactorStatus) throw new Error("正在读取两步验证状态,请稍候"); + const entered = await verificationDialog.ask(action, twoFactorStatus.enabled); + if (entered === null) return null; + if (twoFactorStatus.enabled) return { totpCode: entered }; + return { passwordDerived: await derivePassword(entered, config.password_client_salt) }; } async function reclaimDatabase() { @@ -661,10 +653,10 @@ export function AdminPanel({ if (!window.confirm(`将 ${source} 迁移到 ${target}?目标库中已有的 NodeFlare 数据会被覆盖。`)) return; setBusy(true); setError(""); setNotice(""); try { - const proof = await sensitiveProof(databaseVerification, "迁移数据库前,"); + const proof = await sensitiveProof("迁移数据库"); + if (!proof) return; const result = await api.migrateDatabase(databaseUrl, proof); setDatabaseMigrationUrl(""); - setDatabaseVerification(""); setDatabaseMigrationResult(result); setDatabase((current) => current ? { ...current, restart_required: result.restart_required } : current); const targetName = result.target_kind === "postgresql" ? "PostgreSQL" : "SQLite"; @@ -706,9 +698,9 @@ export function AdminPanel({ async function exportDatabaseBackup() { setBusy(true); setError(""); setNotice(""); try { - const proof = await sensitiveProof(databaseVerification, "导出备份前,"); + const proof = await sensitiveProof("导出备份"); + if (!proof) return; const { blob, filename } = await api.databaseBackup(proof); - setDatabaseVerification(""); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; @@ -730,9 +722,9 @@ export function AdminPanel({ if (!window.confirm("恢复将覆盖当前数据库,并使现有登录会话失效。确认继续?")) return; setBusy(true); setError(""); setNotice(""); try { - const proof = await sensitiveProof(databaseVerification, "恢复备份前,"); + const proof = await sensitiveProof("恢复备份"); + if (!proof) return; const result = await api.restoreDatabaseBackup(file, proof); - setDatabaseVerification(""); window.alert(`数据库已恢复 ${result.restored_rows.toLocaleString()} 行,请使用备份中的管理员账号重新登录。`); window.location.reload(); } catch (reason) { setError(reason instanceof Error ? reason.message : "恢复数据库备份失败"); } @@ -841,7 +833,7 @@ export function AdminPanel({ try { await api.logout(); } finally { setAuthenticated(false); setServers([]); setSettings(null); setSelectedIds([]); - setTwoFactorStatus(null); setTwoFactorSetup(null); setTwoFactorCode(""); setRemoteTotpCode(""); + setTwoFactorStatus(null); setTwoFactorSetup(null); setNewPasswordConfirmation(""); setTwoFactorSecretCopied(false); setLoginSessions([]); setLoginSessionsLoaded(false); setRevokingSessionId(""); setRemoteSelectedIds([]); setRemoteCommand(""); setRemoteTasks([]); setRemoteQuery(""); @@ -851,12 +843,11 @@ export function AdminPanel({ async function setupTwoFactor() { setBusy(true); setError(""); setNotice(""); try { - const proof = await sensitiveProof(securityVerification, "生成两步验证密钥前,"); + const proof = await sensitiveProof("生成两步验证密钥"); + if (!proof) return; const setup = await api.setupTwoFactor(proof); - setSecurityVerification(""); setTwoFactorSetup(setup); setTwoFactorStatus({ enabled: false, has_secret: true }); - setTwoFactorCode(""); setTwoFactorSecretCopied(false); setNotice("两步验证密钥已生成,请先加入验证器再启用"); } catch (reason) { setError(reason instanceof Error ? reason.message : "生成两步验证密钥失败"); } @@ -864,13 +855,13 @@ export function AdminPanel({ } async function enableTwoFactor() { - if (!/^\d{6}$/.test(twoFactorCode)) { setError("请输入 6 位两步验证码"); return; } setBusy(true); setError(""); setNotice(""); try { - await api.enableTwoFactor(twoFactorCode); + const code = await verificationDialog.ask("启用两步验证", true); + if (code === null) return; + await api.enableTwoFactor(code); setTwoFactorStatus({ enabled: true, has_secret: true }); setTwoFactorSetup(null); - setTwoFactorCode(""); setTwoFactorSecretCopied(false); setNotice("两步验证已启用"); onChanged(); @@ -879,12 +870,12 @@ export function AdminPanel({ } async function disableTwoFactor() { - if (!/^\d{6}$/.test(twoFactorCode)) { setError("请输入 6 位两步验证码"); return; } setBusy(true); setError(""); setNotice(""); try { - await api.disableTwoFactor(twoFactorCode); + const code = await verificationDialog.ask("禁用两步验证", true); + if (code === null) return; + await api.disableTwoFactor(code); setTwoFactorStatus({ enabled: false, has_secret: true }); - setTwoFactorCode(""); setNotice("两步验证已禁用"); onChanged(); } catch (reason) { setError(reason instanceof Error ? reason.message : "禁用两步验证失败"); } @@ -988,19 +979,17 @@ export function AdminPanel({ setError("请先在登录与安全中启用 TOTP 两步验证"); return; } - if (!/^\d{6}$/.test(remoteTotpCode)) { - setError("远程执行需要 6 位两步验证码"); - return; - } setBusy(true); setError(""); try { + const code = await verificationDialog.ask("确认执行远程命令", true); + if (code === null) return; const command = remoteCommand.trim(); const data = await api.createRemoteTask({ server_ids: remoteSelectedIds, command, - totp_code: remoteTotpCode, + totp_code: code, }); const requestedAt = Math.floor(Date.now() / 1000); const tasks = data.tasks.map(({ server_id, task_id }) => ({ @@ -1019,7 +1008,6 @@ export function AdminPanel({ remoteTasksRef.current = tasks; setRemoteTasks(tasks); setRemoteCommand(""); - setRemoteTotpCode(""); setNotice(`已向 ${tasks.length} 台服务器下发命令`); await refreshRemoteTasks(true, true); } catch (err) { @@ -1098,7 +1086,7 @@ export function AdminPanel({ {loginTotpRequired ? : null} - {config.turnstile_login_enabled ?
: null} + {config.turnstile_login_enabled ?
: null} {error ? : null} @@ -1214,15 +1202,15 @@ export function AdminPanel({ {tab === "security" ? <>
账号与 Cloudflare 防护
-
+
TOTP 两步验证

启用后管理员登录和每次远程执行都必须提交验证器生成的 6 位动态码。

{twoFactorStatus?.enabled ? "已启用" : twoFactorStatus ? "未启用" : "读取中"}
{twoFactorSetup ?

在 Google Authenticator、Aegis、2FAS 等验证器中手动输入该密钥,再填写当前 6 位验证码确认。

: null} - {twoFactorStatus?.enabled ?
:
- {twoFactorSetup ? :

{twoFactorStatus?.has_secret ? "已有未启用的密钥;重新生成后,旧密钥会失效。" : "尚未生成两步验证密钥。"}

} + {twoFactorStatus?.enabled ?
:
+ {!twoFactorSetup ?

{twoFactorStatus?.has_secret ? "已有未启用的密钥;重新生成后,旧密钥会失效。" : "尚未生成两步验证密钥。"}

: null} {twoFactorSetup ? : null}
} @@ -1236,7 +1224,7 @@ export function AdminPanel({ : null} {tab === "data" ? <> -

数据库维护

备份包含节点、设置、历史、主题文件、任务及安全配置,请妥善保管。
{ const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (file) void restoreDatabaseBackup(file); }} />
+

数据库维护

备份包含节点、设置、历史、主题文件、任务及安全配置,请妥善保管。
{ const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (file) void restoreDatabaseBackup(file); }} />
数据库大小{database ? formatBytes(database.size_bytes) : "读取中..."}{database ? {database.kind === "postgresql" ? "PostgreSQL" : "SQLite"}{database.reclaimable_bytes ? ` · 可回收 ${formatBytes(database.reclaimable_bytes)}` : ""} : null}
数据库迁移

将当前 {database?.kind === "postgresql" ? "PostgreSQL" : "SQLite"} 数据复制到 {database?.kind === "postgresql" ? "SQLite" : "PostgreSQL"},完成后自动更新配置,重启服务后生效。

@@ -1248,7 +1236,7 @@ export function AdminPanel({
: null} - {tab !== "data" ?
: null} + {tab !== "data" ?
: null} ) : tab === "remote" ? (
@@ -1271,7 +1259,7 @@ export function AdminPanel({
- {twoFactorStatus === null ?

正在确认 TOTP 两步验证状态…

: twoFactorStatus.enabled ?
:
远程执行需要 TOTP 两步验证启用后才能向 Agent 发送命令。
} + {twoFactorStatus === null ?

正在确认 TOTP 两步验证状态…

: twoFactorStatus.enabled ?
:
远程执行需要 TOTP 两步验证启用后才能向 Agent 发送命令。
} {remoteTasks.length > 0 ?
@@ -1314,6 +1302,7 @@ export function AdminPanel({
: null} {installCommand ?
Agent 部署

安装命令

{installCommand}
: null} + {verificationDialog.dialog}
); } diff --git a/frontend/src/components/PasswordInput.tsx b/frontend/src/components/PasswordInput.tsx new file mode 100644 index 0000000..4bd0059 --- /dev/null +++ b/frontend/src/components/PasswordInput.tsx @@ -0,0 +1,10 @@ +import { Eye, EyeOff } from "lucide-react"; +import { useState, type InputHTMLAttributes } from "react"; + +export function PasswordInput(props: Omit, "type">) { + const [visible, setVisible] = useState(false); + return
+ + +
; +} diff --git a/frontend/src/components/TurnstileWidget.tsx b/frontend/src/components/TurnstileWidget.tsx index fac58d9..cd7cfbd 100644 --- a/frontend/src/components/TurnstileWidget.tsx +++ b/frontend/src/components/TurnstileWidget.tsx @@ -41,7 +41,7 @@ export function TurnstileWidget({ onError, }: { siteKey: string; - action: "admin-login" | "public-dashboard"; + action: "admin_login" | "public_dashboard"; theme: "light" | "dark"; resetKey?: number; onVerify: (token: string) => void; @@ -58,6 +58,7 @@ export function TurnstileWidget({ useEffect(() => { let cancelled = false; setVerified(false); + verifyCallback.current(""); void loadScript().then(() => { if (cancelled || !target.current || !window.turnstile) return; target.current.replaceChildren(); @@ -74,7 +75,11 @@ export function TurnstileWidget({ setVerified(false); verifyCallback.current(""); }, - "error-callback": () => errorCallback.current("Cloudflare 验证暂时不可用,请刷新重试"), + "error-callback": () => { + setVerified(false); + verifyCallback.current(""); + errorCallback.current("Cloudflare 验证暂时不可用,请刷新重试"); + }, }); }).catch((reason) => errorCallback.current(reason instanceof Error ? reason.message : "验证组件加载失败")); return () => { diff --git a/frontend/src/components/useDialog.ts b/frontend/src/components/useDialog.ts index 142bfcb..9ca864a 100644 --- a/frontend/src/components/useDialog.ts +++ b/frontend/src/components/useDialog.ts @@ -28,7 +28,7 @@ export function useDialog(open: boolean, onClose: () => v ? document.activeElement : null; const focusFrame = window.requestAnimationFrame(() => { - const initial = dialogElement.querySelector("[autofocus]") + const initial = dialogElement.querySelector("[autofocus], [data-dialog-autofocus]") ?? focusableElements(dialogElement)[0] ?? dialogElement; initial.focus(); diff --git a/frontend/src/components/useVerification.tsx b/frontend/src/components/useVerification.tsx new file mode 100644 index 0000000..76943a0 --- /dev/null +++ b/frontend/src/components/useVerification.tsx @@ -0,0 +1,38 @@ +import { ShieldCheck, X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { PasswordInput } from "./PasswordInput"; +import { useDialog } from "./useDialog"; + +export function useVerification(active: boolean) { + const [request, setRequest] = useState<{ title: string; totp: boolean } | null>(null); + const [value, setValue] = useState(""); + const resolver = useRef<((value: string | null) => void) | null>(null); + const finish = useCallback((result: string | null) => { + resolver.current?.(result); + resolver.current = null; + setRequest(null); + setValue(""); + }, []); + const dialog = useDialog(request !== null, () => finish(null)); + useEffect(() => () => { resolver.current?.(null); }, []); + useEffect(() => { if (!active) finish(null); }, [active, finish]); + const ask = useCallback((title: string, totp: boolean) => new Promise((resolve) => { + resolver.current?.(null); + resolver.current = resolve; + setValue(""); + setRequest({ title, totp }); + }), []); + + return { + ask, + dialog: request ?
+
{ event.preventDefault(); finish(value); }}> +

{request.title}

+ +
+
+
: null, + }; +} diff --git a/frontend/src/install.test.ts b/frontend/src/install.test.ts new file mode 100644 index 0000000..e9cc6b4 --- /dev/null +++ b/frontend/src/install.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; + +const installer = readFileSync(new URL("../../install.sh", import.meta.url), "utf8"); +const startFunction = installer.slice(installer.indexOf("start_server() {"), installer.indexOf("\nuninstall_server() {")); + +function start(scenario: string) { + return spawnSync("sh", ["-c", ` + set -eu + init_system=systemd + tick=0 + sleep() { tick=$((tick + 1)); } + systemctl() { + case "$1" in + daemon-reload) [ "$SCENARIO" != command-failure ] ;; + enable|restart) return 0 ;; + is-active) [ "$SCENARIO" != exits ] || [ "$tick" -lt 2 ] ;; + show) + if [ "$SCENARIO" = restarts ] && [ "$tick" -ge 2 ]; then + printf '456\n' + else + printf '123\n' + fi ;; + *) return 99 ;; + esac + } + ${startFunction} + if start_server; then printf 'ready:%s\n' "$tick"; else exit 1; fi + `], { env: { ...process.env, SCENARIO: scenario }, encoding: "utf8" }); +} + +describe("installer startup verification", () => { + test("waits for the same process to remain active", () => { + const result = start("healthy"); + expect(result.status).toBe(0); + expect(result.stdout).toBe("ready:10\n"); + }); + for (const scenario of ["exits", "restarts", "command-failure"]) { + test(`rejects ${scenario} even when called in an if condition`, () => { + expect(start(scenario).status).toBe(1); + }); + } +}); diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index 633416d..fa670cc 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -118,7 +118,6 @@ .row-actions { display: flex; align-items: center; gap: 3px; } .section-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; } .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; margin: -1px; clip: rect(0, 0, 0, 0); white-space: nowrap; } -.compact-verification-input { width: 130px; min-height: 34px; padding: 6px 9px; } .batch-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; justify-content: space-between; min-height: 44px; border-block: 1px solid var(--admin-border); color: var(--admin-muted); font-size: 13px; } .select-all { display: inline-flex; align-items: center; flex-direction: row; gap: 7px; color: var(--admin-text); font-size: 13px; } @@ -163,7 +162,6 @@ .copy-secret-btn { min-width: 102px; flex: 0 0 auto; justify-content: center; transition: color .15s ease, border-color .15s ease, background-color .15s ease; } .copy-secret-btn.copied { border-color: color-mix(in srgb, var(--admin-success) 34%, var(--admin-border)); color: var(--admin-success); background: var(--admin-success-bg); } .two-factor-actions { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 9px; } -.two-factor-actions label { min-width: min(220px, 100%); flex: 1; } .two-factor-generate-btn { margin-left: auto; } .login-devices-panel { display: flex; flex-direction: column; gap: 12px; border: 1px solid var(--admin-border); border-radius: 7px; padding: 14px; background: var(--admin-surface); } .login-devices-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } @@ -255,6 +253,12 @@ .latency-task-name small, .latency-task-meta { color: var(--admin-muted); font-size: 12px; } .latency-editor { display: flex; width: min(620px, 100%); max-height: calc(100dvh - 48px); overflow: auto; flex-direction: column; gap: 16px; border: 1px solid var(--admin-border); border-radius: 8px; padding: 22px; color: var(--admin-text); background: var(--admin-surface); } .latency-editor > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.latency-editor > * { flex-shrink: 0; } +.password-field { position: relative; min-width: 0; } +.password-field input { padding-right: 44px; } +.password-field .icon-btn { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); width: 32px; height: 32px; } +.submodal-backdrop.verification-backdrop { z-index: 100; align-items: center; padding: 16px; } +.verification-backdrop .verification-modal { width: min(400px, 100%); max-height: calc(100dvh - 32px); border: 1px solid var(--admin-border); border-radius: 8px; } .task-type-control { height: 36px; } .task-type-control button { flex: 1; } .server-picker { overflow: hidden; border: 1px solid var(--admin-border-strong); border-radius: 7px; } @@ -395,8 +399,7 @@ .remote-server-picker .server-picker-list { max-height: 280px; } .remote-server-picker-row { grid-template-columns: 20px 9px minmax(0, 1fr); } .remote-server-picker-row > .status-dot { width: 8px; height: 8px; } -.remote-confirm-row { display: grid; align-items: end; gap: 12px; grid-template-columns: minmax(180px, 320px) auto; justify-content: space-between; } -.remote-confirm-row label { width: 100%; } +.remote-confirm-row { display: flex; justify-content: flex-end; } .remote-submit { min-width: 160px; min-height: 40px; } .remote-results { margin-top: 22px; border-top: 1px solid var(--admin-border); padding-top: 20px; } .remote-results > .section-head { margin-bottom: 12px; } @@ -431,11 +434,9 @@ @keyframes remote-refresh-spin { to { transform: rotate(360deg); } } @media (max-width: 639px) { - .remote-confirm-row { grid-template-columns: 1fr; } .remote-submit { width: 100%; } .database-section-head .section-actions, .usage-head-actions { width: 100%; } .database-section-head .section-actions > label { width: 100%; } - .compact-verification-input { width: 100%; } .database-section-head .section-actions > button, .usage-head-actions > button { flex: 1; } .database-storage { align-items: stretch; flex-direction: column; } .database-storage button { width: 100%; } diff --git a/install.ps1 b/install.ps1 index b62ba51..7725d10 100644 --- a/install.ps1 +++ b/install.ps1 @@ -209,6 +209,13 @@ try { if ($Task.State -ne "Running") { Stop-Install "NodeFlare 服务启动失败(状态:$($Task.State))" } + for ($Attempt = 0; $Attempt -lt 10; $Attempt++) { + Start-Sleep -Seconds 1 + $Task = Get-ScheduledTask -TaskName $TaskName + if ($Task.State -ne "Running") { + Stop-Install "NodeFlare 启动后退出,请检查 bind_addr 端口占用及数据库连接" + } + } Write-Host "" Write-Host "NodeFlare 安装完成" diff --git a/install.sh b/install.sh index c76eb80..5eb1d6f 100755 --- a/install.sh +++ b/install.sh @@ -474,10 +474,19 @@ install_service() { start_server() { case "$init_system" in systemd) - systemctl daemon-reload - systemctl enable nodeflare.service >/dev/null - systemctl restart nodeflare.service - systemctl is-active --quiet nodeflare.service + systemctl daemon-reload || return 1 + systemctl enable nodeflare.service >/dev/null || return 1 + systemctl restart nodeflare.service || return 1 + started_pid=$(systemctl show -p MainPID --value nodeflare.service) || return 1 + [ "$started_pid" -gt 0 ] || return 1 + attempt=1 + while [ "$attempt" -le 10 ]; do + sleep 1 + systemctl is-active --quiet nodeflare.service || return 1 + current_pid=$(systemctl show -p MainPID --value nodeflare.service) || return 1 + [ "$current_pid" = "$started_pid" ] || return 1 + attempt=$((attempt + 1)) + done ;; openrc) rc-update add nodeflare default >/dev/null @@ -604,7 +613,10 @@ else fi if ! start_server; then - fail "NodeFlare 服务启动失败" + if [ "$init_system" = systemd ]; then + journalctl -u nodeflare.service -n 30 --no-pager >&2 || true + fi + fail "NodeFlare 服务启动失败,请检查配置中的 bind_addr 是否被占用及数据库连接" fi rollback_ready=false