修复修复 Turnstile验证报错问题

This commit is contained in:
imengying committed 2026-09-05 16:49:56 +08:00
1 parent d833ce9f34
commit 1ea4df69de
14 files changed
+284 -78

No files matched your search

+48 -2
View File
@@ -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();
+5 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
}
}
+52 -4
View File
@@ -16,6 +16,8 @@ struct VerifyResponse {
hostname: String,
#[serde(default)]
action: String,
#[serde(default, rename = "error-codes")]
error_codes: Vec<String>,
}
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::<VerifyResponse>().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));
}
}
}
+4 -2
View File
@@ -54,7 +54,7 @@ function LoginGate() {
<label><span>{ui(locale, "用户名", "Username")}</span><input autoFocus autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} required /></label>
<label><span>{ui(locale, "密码", "Password")}</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required /></label>
{totpRequired ? <label><span>{ui(locale, "两步验证码", "Two-factor code")}</span><input autoFocus inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={totpCode} onChange={(event) => setTotpCode(event.target.value.replace(/\D/g, "").slice(0, 6))} required /></label> : null}
{config.turnstile_login_enabled ? <div className="dashboard-login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} action="admin-login" theme={dark ? "dark" : "light"} resetKey={turnstileReset} onVerify={setTurnstileToken} onError={setError} /></div> : null}
{config.turnstile_login_enabled ? <div className="dashboard-login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} action="admin_login" theme={dark ? "dark" : "light"} resetKey={turnstileReset} onVerify={setTurnstileToken} onError={setError} /></div> : null}
{error ? <p className="form-error">{error}</p> : null}
<button className="primary-btn dashboard-login-submit" disabled={busy || (totpRequired && !/^\d{6}$/.test(totpCode)) || (config.turnstile_login_enabled && !turnstileToken)} type="submit"><KeyRound size={16} />{busy ? ui(locale, "登录中", "Signing in") : ui(locale, "登录", "Sign in")}</button>
</form>
@@ -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() {
<SiteLogo src={config.logo_url} alt="" width="52" height="52" />
<div><h1>{ui(locale, "访问验证", "Access verification")}</h1><p>{config.site_name}</p></div>
{config.turnstile_site_key
? <TurnstileWidget siteKey={config.turnstile_site_key} action="public-dashboard" theme={dark ? "dark" : "light"} onVerify={(token) => void run(token)} onError={setError} />
? <TurnstileWidget siteKey={config.turnstile_site_key} action="public_dashboard" theme={dark ? "dark" : "light"} resetKey={resetKey} onVerify={(token) => void run(token)} onError={setError} />
: <p className="form-error">{ui(locale, "Turnstile 尚未正确配置", "Turnstile is not configured")}</p>}
{busy ? <span className="verification-status">{ui(locale, "正在验证", "Verifying")}</span> : null}
{error ? <p className="form-error">{error}</p> : null}
{error && !busy ? <button className="secondary-btn" type="button" onClick={() => { setError(""); setResetKey((value) => value + 1); }}>{ui(locale, "重试", "Retry")}</button> : null}
</section>
);
}
+43 -54
View File
@@ -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<RemoteTask[]>([]);
const [remoteTotpCode, setRemoteTotpCode] = useState("");
const [twoFactorStatus, setTwoFactorStatus] = useState<TotpStatus | null>(null);
const [twoFactorSetup, setTwoFactorSetup] = useState<TotpSetup | null>(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<LoginSession[]>([]);
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({
<label><span>用户名</span><input autoFocus type="text" autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} required aria-invalid={error ? true : undefined} aria-describedby={error ? "login-error" : undefined} /></label>
<label><span>密码</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required aria-invalid={error ? true : undefined} aria-describedby={error ? "login-error" : undefined} /></label>
{loginTotpRequired ? <label><span>两步验证码</span><input autoFocus inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={loginTotpCode} onChange={(event) => setLoginTotpCode(event.target.value.replace(/\D/g, "").slice(0, 6))} required aria-invalid={error ? true : undefined} aria-describedby={error ? "login-error" : undefined} /></label> : null}
{config.turnstile_login_enabled ? <div className="login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} action="admin-login" theme={dark ? "dark" : "light"} resetKey={turnstileReset} onVerify={setTurnstileToken} onError={setError} /></div> : null}
{config.turnstile_login_enabled ? <div className="login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} action="admin_login" theme={dark ? "dark" : "light"} resetKey={turnstileReset} onVerify={setTurnstileToken} onError={setError} /></div> : null}
{error ? <p className="login-error" id="login-error" role="alert"><CircleAlert size={15} aria-hidden="true" />{error}</p> : null}
<button className="primary-btn login-submit" disabled={busy || (loginTotpRequired && !/^\d{6}$/.test(loginTotpCode)) || (config.turnstile_login_enabled && !turnstileToken)} type="submit"><KeyRound size={15} />{busy ? "验证中" : "登录"}</button>
</form>
@@ -1214,15 +1202,15 @@ export function AdminPanel({
{tab === "security" ? <>
<div className="section-title"><ShieldCheck size={15} />账号与 Cloudflare 防护</div>
<div className="form-grid three"><label><span>管理员用户名</span><input autoComplete="username" value={settings.admin_username} onChange={(event) => updateSettings("admin_username", event.target.value)} /></label><label><span>新密码(留空不修改)</span><input autoComplete="new-password" type="password" minLength={8} maxLength={128} value={settings.new_password || ""} onChange={(event) => updateSettings("new_password", event.target.value)} placeholder="至少 8 个字符" /></label><label><span>{twoFactorStatus?.enabled ? "当前两步验证码" : "当前管理员密码"}</span><input type="password" autoComplete={twoFactorStatus?.enabled ? "one-time-code" : "current-password"} inputMode={twoFactorStatus?.enabled ? "numeric" : undefined} maxLength={twoFactorStatus?.enabled ? 6 : 128} value={securityVerification} onChange={(event) => setSecurityVerification(twoFactorStatus?.enabled ? event.target.value.replace(/\D/g, "").slice(0, 6) : event.target.value)} required /></label></div>
<div className="form-grid three"><label><span>管理员用户名</span><input autoComplete="username" value={settings.admin_username} onChange={(event) => updateSettings("admin_username", event.target.value)} /></label><label><span>新密码(留空不修改)</span><PasswordInput autoComplete="new-password" minLength={8} maxLength={128} value={settings.new_password || ""} onChange={(event) => { updateSettings("new_password", event.target.value); setNewPasswordConfirmation(""); }} placeholder="至少 8 个字符" /></label><label><span>确认新密码</span><PasswordInput autoComplete="new-password" minLength={8} maxLength={128} value={newPasswordConfirmation} onChange={(event) => setNewPasswordConfirmation(event.target.value)} placeholder="再次输入新密码" /></label></div>
<div className="two-factor-panel">
<div className="two-factor-head"><div><div className="section-subtitle">TOTP 两步验证</div><p className="settings-hint">启用后管理员登录和每次远程执行都必须提交验证器生成的 6 位动态码。</p></div><span className={`two-factor-status ${twoFactorStatus?.enabled ? "enabled" : ""}`}>{twoFactorStatus?.enabled ? "已启用" : twoFactorStatus ? "未启用" : "读取中"}</span></div>
{twoFactorSetup ? <div className="two-factor-setup">
<label><span>验证器密钥</span><div className="copy-field"><input readOnly value={twoFactorSetup.secret} onFocus={(event) => event.currentTarget.select()} onClick={(event) => event.currentTarget.select()} /><button type="button" className={`secondary-btn compact copy-secret-btn ${twoFactorSecretCopied ? "copied" : ""}`} aria-live="polite" onClick={() => void copyTwoFactorSecret()}>{twoFactorSecretCopied ? <Check size={14} /> : <Copy size={14} />}{twoFactorSecretCopied ? "已复制" : "复制密钥"}</button></div></label>
<p className="settings-hint">在 Google Authenticator、Aegis、2FAS 等验证器中手动输入该密钥,再填写当前 6 位验证码确认。</p>
</div> : null}
{twoFactorStatus?.enabled ? <div className="two-factor-actions"><label><span>当前验证码</span><input inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={twoFactorCode} onChange={(event) => setTwoFactorCode(event.target.value.replace(/\D/g, "").slice(0, 6))} placeholder="000000" /></label><button type="button" className="danger-btn" disabled={busy} onClick={() => void disableTwoFactor()}>禁用两步验证</button></div> : <div className="two-factor-actions">
{twoFactorSetup ? <label><span>当前验证码</span><input inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={twoFactorCode} onChange={(event) => setTwoFactorCode(event.target.value.replace(/\D/g, "").slice(0, 6))} placeholder="000000" /></label> : <p className="settings-hint">{twoFactorStatus?.has_secret ? "已有未启用的密钥;重新生成后,旧密钥会失效。" : "尚未生成两步验证密钥。"}</p>}
{twoFactorStatus?.enabled ? <div className="two-factor-actions"><button type="button" className="danger-btn" disabled={busy} onClick={() => void disableTwoFactor()}>禁用两步验证</button></div> : <div className="two-factor-actions">
{!twoFactorSetup ? <p className="settings-hint">{twoFactorStatus?.has_secret ? "已有未启用的密钥;重新生成后,旧密钥会失效。" : "尚未生成两步验证密钥。"}</p> : null}
<button type="button" className="secondary-btn two-factor-generate-btn" disabled={busy} onClick={() => void setupTwoFactor()}>{twoFactorStatus?.has_secret ? "重新生成密钥" : "生成密钥"}</button>
{twoFactorSetup ? <button type="button" className="primary-btn" disabled={busy} onClick={() => void enableTwoFactor()}>启用两步验证</button> : null}
</div>}
@@ -1236,7 +1224,7 @@ export function AdminPanel({
</> : null}
{tab === "data" ? <>
<div className="section-head database-section-head"><div><h3>数据库维护</h3><span>备份包含节点、设置、历史、主题文件、任务及安全配置,请妥善保管。</span></div><div className="section-actions"><label><span className="sr-only">{twoFactorStatus?.enabled ? "当前两步验证码" : "当前管理员密码"}</span><input className="compact-verification-input" type="password" autoComplete={twoFactorStatus?.enabled ? "one-time-code" : "current-password"} inputMode={twoFactorStatus?.enabled ? "numeric" : undefined} maxLength={twoFactorStatus?.enabled ? 6 : 128} value={databaseVerification} onChange={(event) => setDatabaseVerification(twoFactorStatus?.enabled ? event.target.value.replace(/\D/g, "").slice(0, 6) : event.target.value)} placeholder={twoFactorStatus?.enabled ? "当前验证码" : "当前密码"} /></label><button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => void exportDatabaseBackup()}><Download size={15} />导出备份</button><button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => databaseRestoreInputRef.current?.click()}><Upload size={15} />恢复备份</button><input ref={databaseRestoreInputRef} hidden type="file" accept=".zip,application/zip" onChange={(event) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (file) void restoreDatabaseBackup(file); }} /></div></div>
<div className="section-head database-section-head"><div><h3>数据库维护</h3><span>备份包含节点、设置、历史、主题文件、任务及安全配置,请妥善保管。</span></div><div className="section-actions"><button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => void exportDatabaseBackup()}><Download size={15} />导出备份</button><button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => databaseRestoreInputRef.current?.click()}><Upload size={15} />恢复备份</button><input ref={databaseRestoreInputRef} hidden type="file" accept=".zip,application/zip" onChange={(event) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (file) void restoreDatabaseBackup(file); }} /></div></div>
<div className="database-storage"><div><span>数据库大小</span><strong>{database ? formatBytes(database.size_bytes) : "读取中..."}</strong>{database ? <small>{database.kind === "postgresql" ? "PostgreSQL" : "SQLite"}{database.reclaimable_bytes ? ` · 可回收 ${formatBytes(database.reclaimable_bytes)}` : ""}</small> : null}</div><button type="button" className="secondary-btn" disabled={busy || !database || database.restart_required} onClick={() => void reclaimDatabase()}><RotateCw size={15} />回收空间</button></div>
<div className="database-migration">
<div><div className="section-title"><ArrowRightLeft size={15} />数据库迁移</div><p className="settings-hint">将当前 {database?.kind === "postgresql" ? "PostgreSQL" : "SQLite"} 数据复制到 {database?.kind === "postgresql" ? "SQLite" : "PostgreSQL"},完成后自动更新配置,重启服务后生效。</p></div>
@@ -1248,7 +1236,7 @@ export function AdminPanel({
</div>
</> : null}
{tab !== "data" ? <div className="form-actions"><button className="primary-btn" disabled={busy}><Save size={15} />保存{tab === "security" ? "账号与安全设置" : "设置"}</button></div> : null}
{tab !== "data" ? <div className="form-actions"><button className="primary-btn" disabled={busy}><Save size={15} />保存设置</button></div> : null}
</form>
) : tab === "remote" ? (
<div className="admin-section remote-section">
@@ -1271,7 +1259,7 @@ export function AdminPanel({
</div>
</div>
{twoFactorStatus === null ? <p className="settings-hint">正在确认 TOTP 两步验证状态…</p> : twoFactorStatus.enabled ? <div className="remote-confirm-row"><label><span>2FA 验证码</span><input inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={remoteTotpCode} onChange={(event) => setRemoteTotpCode(event.target.value.replace(/\D/g, "").slice(0, 6))} placeholder="6 位验证码" required /></label><button className="primary-btn remote-submit" disabled={busy || !remoteSelectedIds.length || !remotePayloadReady || !remoteTotpReady}><Terminal size={15} />{busy ? "下发中" : `确认执行${remoteSelectedIds.length ? ` (${remoteSelectedIds.length})` : ""}`}</button></div> : <div className="remote-2fa-required"><ShieldCheck size={18} /><div><strong>远程执行需要 TOTP 两步验证</strong><span>启用后才能向 Agent 发送命令。</span></div><button type="button" className="secondary-btn compact" onClick={() => selectTab("security")}>前往启用</button></div>}
{twoFactorStatus === null ? <p className="settings-hint">正在确认 TOTP 两步验证状态…</p> : twoFactorStatus.enabled ? <div className="remote-confirm-row"><button className="primary-btn remote-submit" disabled={busy || !remoteSelectedIds.length || !remotePayloadReady}><Terminal size={15} />{busy ? "下发中" : `确认执行${remoteSelectedIds.length ? ` (${remoteSelectedIds.length})` : ""}`}</button></div> : <div className="remote-2fa-required"><ShieldCheck size={18} /><div><strong>远程执行需要 TOTP 两步验证</strong><span>启用后才能向 Agent 发送命令。</span></div><button type="button" className="secondary-btn compact" onClick={() => selectTab("security")}>前往启用</button></div>}
</form>
{remoteTasks.length > 0 ? <div className="remote-results">
@@ -1314,6 +1302,7 @@ export function AdminPanel({
</form></div> : null}
{installCommand ? <div className="submodal-backdrop" role="presentation" onMouseDown={installDialog.onBackdropMouseDown}><section ref={installDialog.dialogRef} className="install-modal glass-panel" role="dialog" aria-modal="true" aria-labelledby="install-dialog-title" tabIndex={-1}><header><div><span className="eyebrow">Agent 部署</span><h3 id="install-dialog-title">安装命令</h3></div><div className="segmented install-platform" role="group" aria-label="Agent 平台"><button type="button" className={installPlatform === "linux" ? "active" : ""} aria-pressed={installPlatform === "linux"} onClick={() => setInstallPlatform("linux")}>Linux</button><button type="button" className={installPlatform === "windows" ? "active" : ""} aria-pressed={installPlatform === "windows"} onClick={() => setInstallPlatform("windows")}>Windows</button><button type="button" className={installPlatform === "macos" ? "active" : ""} aria-pressed={installPlatform === "macos"} onClick={() => setInstallPlatform("macos")}>macOS ARM</button><button type="button" className={installPlatform === "freebsd" ? "active" : ""} aria-pressed={installPlatform === "freebsd"} onClick={() => setInstallPlatform("freebsd")}>FreeBSD</button></div></header><div className="install-list"><pre>{installCommand}</pre></div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall(null)}>关闭</button><button className="primary-btn" type="button" onClick={() => void copyInstallCommand()}><Copy size={15} />复制</button></div></section></div> : null}
{verificationDialog.dialog}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { Eye, EyeOff } from "lucide-react";
import { useState, type InputHTMLAttributes } from "react";
export function PasswordInput(props: Omit<InputHTMLAttributes<HTMLInputElement>, "type">) {
const [visible, setVisible] = useState(false);
return <div className="password-field">
<input {...props} type={visible ? "text" : "password"} />
<button type="button" className="icon-btn" title={visible ? "隐藏密码" : "显示密码"} aria-label={visible ? "隐藏密码" : "显示密码"} aria-pressed={visible} onClick={() => setVisible(!visible)}>{visible ? <EyeOff size={16} /> : <Eye size={16} />}</button>
</div>;
}
+7 -2
View File
@@ -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 () => {
+1 -1
View File
@@ -28,7 +28,7 @@ export function useDialog<T extends HTMLElement>(open: boolean, onClose: () => v
? document.activeElement
: null;
const focusFrame = window.requestAnimationFrame(() => {
const initial = dialogElement.querySelector<HTMLElement>("[autofocus]")
const initial = dialogElement.querySelector<HTMLElement>("[autofocus], [data-dialog-autofocus]")
?? focusableElements(dialogElement)[0]
?? dialogElement;
initial.focus();
@@ -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<HTMLFormElement>(request !== null, () => finish(null));
useEffect(() => () => { resolver.current?.(null); }, []);
useEffect(() => { if (!active) finish(null); }, [active, finish]);
const ask = useCallback((title: string, totp: boolean) => new Promise<string | null>((resolve) => {
resolver.current?.(null);
resolver.current = resolve;
setValue("");
setRequest({ title, totp });
}), []);
return {
ask,
dialog: request ? <div className="submodal-backdrop verification-backdrop" onMouseDown={dialog.onBackdropMouseDown}>
<form ref={dialog.dialogRef} className="editor-modal verification-modal" role="dialog" aria-modal="true" aria-labelledby="verification-title" tabIndex={-1} onSubmit={(event) => { event.preventDefault(); finish(value); }}>
<header><h3 id="verification-title">{request.title}</h3><button type="button" className="icon-btn" title="关闭" aria-label="关闭" onClick={() => finish(null)}><X size={18} /></button></header>
<label><span>{request.totp ? "两步验证码" : "当前管理员密码"}</span>{request.totp
? <input autoFocus data-dialog-autofocus required inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={value} onChange={(event) => setValue(event.target.value.replace(/\D/g, "").slice(0, 6))} />
: <PasswordInput autoFocus data-dialog-autofocus required autoComplete="current-password" maxLength={128} value={value} onChange={(event) => setValue(event.target.value)} />}</label>
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => finish(null)}>取消</button><button className="primary-btn" disabled={request.totp ? !/^\d{6}$/.test(value) : !value}><ShieldCheck size={15} />确认</button></div>
</form>
</div> : null,
};
}
+44
View File
@@ -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);
});
}
});
+7 -6
View File
@@ -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%; }
+7
View File
@@ -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 安装完成"
+17 -5
View File
@@ -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