修复修复 Turnstile验证报错问题
This commit is contained in:
1 parent
d833ce9f34
commit
1ea4df69de
14 files changed
+284
-78
No files matched your search
+48
-2
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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%; }
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user