diff --git a/README.md b/README.md index 6fb6c8b..9161546 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ NodeFlare 是一个自部署的服务器监控面板。服务端使用 Rust/Axum sudo ./install.sh ``` -首次安装会在终端询问管理员用户名,并隐藏输入、二次确认管理员密码。脚本会构建 NodeFlare,将后端安装为 `/opt/nodeflare/nodeflare`、静态资源安装到 `/opt/nodeflare/share`、systemd 服务安装为 `nodeflare.service`,并将配置写入 `/etc/nodeflare/config.toml`。重复运行安装脚本会保留已有配置和数据库。 +首次安装会在终端询问管理员用户名,并隐藏输入、二次确认管理员密码;随后可直接填写 SQLite 或 PostgreSQL URL,回车则使用 `/etc/nodeflare/nodeflare.db`。脚本会构建 NodeFlare,将后端安装为 `/opt/nodeflare/nodeflare`、静态资源安装到 `/opt/nodeflare/share`、systemd 服务安装为 `nodeflare.service`,并将配置和服务端持久数据统一放在 `/etc/nodeflare`。重复运行安装脚本会保留已有配置和数据库。 面板不会创建额外的 Linux 系统用户,systemd 服务的运行方式与 Agent 一致。 Linux 默认目录结构: @@ -21,12 +21,13 @@ Linux 默认目录结构: /opt/nodeflare/nodeflare # 后端 /opt/nodeflare/agent # Agent(安装时才存在) /opt/nodeflare/share/ # 前端与安装脚本 -/opt/nodeflare/data/server/ # 数据库与主题 /opt/nodeflare/data/agent/ # Agent 暂存数据 /etc/nodeflare/config.toml # 后端配置 +/etc/nodeflare/nodeflare.db # 默认 SQLite 数据库 +/etc/nodeflare/themes/ # 已安装主题 ``` -默认可从 `http://127.0.0.1:8080` 访问公开面板,管理页位于 `/admin`。首次登录后可在“登录与安全”中启用 TOTP 两步验证;远程执行使用一次性命令,可同时选择多台服务器,每次下发都必须提交当前 TOTP 验证码。 +默认可从 `http://127.0.0.1:8080` 访问公开面板,管理页位于 `/admin`。“登录与安全”可启用 TOTP、查看登录设备并将指定设备踢下线;远程执行使用一次性命令,可同时选择多台服务器,每次下发都必须提交当前 TOTP 验证码。等待超过 24 小时的命令会自动取消,已完成结果随历史保留周期清理。 后台每个侧边栏入口都有独立地址,例如远程执行为 `/admin/remote`、主题商店为 `/admin/themes`、关于为 `/admin/about`。刷新页面或使用浏览器前进、后退时会保留当前栏目。 @@ -44,7 +45,7 @@ journalctl -u nodeflare -f sudo ./install.sh --uninstall ``` -连同 `/etc/nodeflare` 和 `/opt/nodeflare/data/server` 中的服务端数据彻底删除: +连同 `/etc/nodeflare` 中的配置和服务端持久数据彻底删除: ```bash sudo ./install.sh --uninstall --purge @@ -52,26 +53,26 @@ sudo ./install.sh --uninstall --purge ## 配置 -正式安装的配置文件是 `/etc/nodeflare/config.toml`。安装时只交互式询问用户名和密码;监听地址、数据库、Turnstile、静态资源与会话时长等均保留在该 TOML 文件中。相对路径以配置文件所在目录为基准解析。 +正式安装的配置文件是 `/etc/nodeflare/config.toml`。安装时会交互式询问用户名、密码和可跳过的数据库 URL;监听地址、数据库、Turnstile、静态资源与会话时长等均可在该 TOML 文件中修改。相对路径以配置文件所在目录为基准解析。 SQLite 示例: ```toml -database_url = "sqlite:///opt/nodeflare/data/server/nodeflare.db" +database_url = "sqlite:///etc/nodeflare/nodeflare.db" bind_addr = "127.0.0.1:8080" admin_username = "admin" admin_password = "replace-with-a-long-random-password" frontend_dir = "/opt/nodeflare/share/frontend" admin_frontend_dir = "/opt/nodeflare/share/admin" agent_dir = "/opt/nodeflare/share/agent" -theme_dir = "/opt/nodeflare/data/server/themes" +theme_dir = "/etc/nodeflare/themes" session_ttl_hours = 168 ``` PostgreSQL 示例: ```toml -database_url = "postgres://nodeflare:password@127.0.0.1/nodeflare" +database_url = "postgres://nodeflare:password@127.0.0.1:5432/nodeflare?sslmode=prefer" bind_addr = "127.0.0.1:8080" admin_username = "admin" admin_password = "replace-with-a-long-random-password" @@ -92,9 +93,11 @@ curl -fsSL https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/agen Agent 通过 `/api/agent/ws` 建立 WebSocket 连接。除 localhost 调试外,安装脚本要求 HTTPS。 -Agent Token 由面板生成,安装脚本将可执行文件安装为 `agent`(Windows 为 `agent.exe`),服务名为 `nodeflare-agent`。Linux 使用 `/opt/nodeflare`;macOS 与 FreeBSD 使用 `/usr/local/libexec/nodeflare`;Windows 使用 `%ProgramFiles%\NodeFlare`。程序、静态资源和持久数据均收拢在各平台的 NodeFlare 目录中,Agent 暂存数据位于其中的 `data/agent`。Token 只作为 systemd、OpenRC、launchd 或 Windows 计划任务的启动参数保存,不会另行生成 token 或 Agent 配置文件。 +Agent Token 由面板生成,安装脚本将可执行文件安装为 `agent`(Windows 为 `agent.exe`),服务名为 `nodeflare-agent`。Linux 使用 `/opt/nodeflare`;macOS 与 FreeBSD 使用 `/usr/local/libexec/nodeflare`;Windows 使用 `%ProgramFiles%\NodeFlare`。程序、静态资源和持久数据均收拢在各平台的 NodeFlare 目录中,Agent 暂存数据位于其中的 `data/agent`。服务端只保存 Token 哈希,Token 仅在创建节点或主动重置时显示;重置会使旧 Agent 立即离线。Unix 服务通过受保护的服务环境传入 Token,避免出现在进程命令行;Windows 计划任务仍保存为 SYSTEM 任务参数。安装过程不会额外生成 Agent 配置文件。 -主题商店支持直接上传 ZIP,也支持填写 GitHub 仓库主页地址并安装该仓库 latest Release 中的 ZIP。主题解压后保存在 `theme_dir`,ZIP 根目录必须包含 `index.html`,也允许外层仅有一个打包目录。 +远程执行由系统级 Agent 服务直接运行:Linux、macOS、FreeBSD 默认是 `root`,Windows 是 `SYSTEM`,因此等同于服务器控制权。请只通过 HTTPS 暴露 NodeFlare、启用 TOTP、保护管理员会话,并仅安装可信主题;Linux Agent 不启用文件系统隔离,否则运维命令无法管理宿主机。 + +主题商店支持直接上传 ZIP,也支持填写 GitHub 仓库主页地址并安装该仓库 latest Release 中的 ZIP。主题解压后保存在 `theme_dir`,ZIP 根目录必须包含 `index.html`,也允许外层仅有一个打包目录。第三方主题包含可执行前端代码,应按插件对待并只安装可信来源。 ## 开发与构建 @@ -163,21 +166,21 @@ server { } ``` -`X-Forwarded-Proto: https` 也用于给管理员会话 Cookie 添加 `Secure`。NodeFlare 自身会为 API 和静态资源添加 CSP、禁止 iframe、MIME 嗅探限制等安全响应头。 +NodeFlare 只在直接连接来自回环地址时信任代理 IP 头,并优先采用 `X-Real-IP`;反向代理必须像上例一样覆盖这些头,不能透传客户端自带值。`X-Forwarded-Proto: https` 也用于给管理员会话 Cookie 添加 `Secure`。NodeFlare 自身会为 API 和静态资源添加 CSP、禁止 iframe、MIME 嗅探限制等安全响应头。 ## 备份 -SQLite 正式安装的默认文件是 `/opt/nodeflare/data/server/nodeflare.db`。可在服务运行时使用 SQLite 的一致性备份命令: +SQLite 正式安装的默认文件是 `/etc/nodeflare/nodeflare.db`。管理后台的“数据库”页面可直接导出和恢复 ZIP;也可使用 SQLite 的一致性备份命令: ```bash -sqlite3 /opt/nodeflare/data/server/nodeflare.db ".backup '/var/backups/nodeflare.db'" +sqlite3 /etc/nodeflare/nodeflare.db ".backup '/var/backups/nodeflare.db'" ``` PostgreSQL 使用标准工具: ```bash pg_dump --format=custom --file=/var/backups/nodeflare.dump \ - 'postgres://nodeflare:password@127.0.0.1/nodeflare' + 'postgres://nodeflare:password@127.0.0.1:5432/nodeflare?sslmode=prefer' ``` 恢复前请停止 NodeFlare,并先在独立环境验证备份。配置文件包含管理员初始凭据和可选的 Turnstile 密钥,也应通过权限受控的方式单独备份。 diff --git a/agent/Cargo.lock b/agent/Cargo.lock index 84a11af..5abab8b 100644 --- a/agent/Cargo.lock +++ b/agent/Cargo.lock @@ -53,9 +53,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures", @@ -138,9 +138,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -390,9 +390,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" dependencies = [ "equivalent", "hashbrown", @@ -673,9 +673,9 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" [[package]] name = "stable_deref_trait" diff --git a/agent/agent.sh b/agent/agent.sh index 0794f04..e47a3a0 100644 --- a/agent/agent.sh +++ b/agent/agent.sh @@ -214,15 +214,12 @@ install_agent() { '' \ '[Service]' \ 'Type=simple' \ - "ExecStart=$AGENT_FILE -e $endpoint -t $token -i $interval" \ + "ExecStart=$AGENT_FILE -e $endpoint -i $interval" \ 'Restart=always' \ 'RestartSec=10' \ 'NoNewPrivileges=true' \ - 'PrivateTmp=true' \ - 'ProtectHome=true' \ - 'ProtectSystem=strict' \ + "Environment=NODEFLARE_AGENT_TOKEN=$token" \ "Environment=NODEFLARE_STATE_DIR=$STATE_DIR" \ - "ReadWritePaths=$INSTALL_DIR $STATE_DIR" \ '' \ '[Install]' \ 'WantedBy=multi-user.target' > "$SERVICE_FILE" @@ -238,8 +235,9 @@ install_agent() { '#!/sbin/openrc-run' \ "name=\"$SERVICE_NAME\"" \ "command=\"$AGENT_FILE\"" \ - "command_args=\"-e $endpoint -t $token -i $interval\"" \ + "command_args=\"-e $endpoint -i $interval\"" \ "command_user=\"root\"" \ + "export NODEFLARE_AGENT_TOKEN=\"$token\"" \ "export NODEFLARE_STATE_DIR=\"$STATE_DIR\"" \ "supervisor=\"supervise-daemon\"" \ "respawn_delay=10" \ diff --git a/agent/install-freebsd.sh b/agent/install-freebsd.sh index fc9238d..5eca97d 100755 --- a/agent/install-freebsd.sh +++ b/agent/install-freebsd.sh @@ -202,12 +202,13 @@ cat > "$SERVICE_FILE" < "$PLIST_FILE" < Label$LABEL -ProgramArguments$AGENT_FILE-e$endpoint-t$token-i$interval -EnvironmentVariablesNODEFLARE_STATE_DIR$STATE_DIR +ProgramArguments$AGENT_FILE-e$endpoint-i$interval +EnvironmentVariablesNODEFLARE_AGENT_TOKEN$tokenNODEFLARE_STATE_DIR$STATE_DIR KeepAliveRunAtLoad StandardOutPath/var/log/nodeflare-agent.log StandardErrorPath/var/log/nodeflare-agent.log diff --git a/agent/src/main.rs b/agent/src/main.rs index ace4ea0..28be00b 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -11,9 +11,7 @@ use std::os::unix::fs::PermissionsExt; #[cfg(unix)] use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; -use std::process::Command; -#[cfg(target_os = "windows")] -use std::process::Stdio; +use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; use std::sync::{Arc, Condvar, Mutex, mpsc}; use std::thread; @@ -43,7 +41,10 @@ const LATEST_RELEASE_API: &str = "https://api.github.com/repos/imengying/NodeFla const PROBE_ATTEMPTS: usize = 4; const PROBE_TIMEOUT: Duration = Duration::from_secs(1); const MAX_AGENT_BINARY_BYTES: u64 = 64 * 1024 * 1024; -const MAX_LATENCY_TASKS: usize = 128; +const REMOTE_TASK_TIMEOUT: Duration = Duration::from_secs(10 * 60); +const REMOTE_STREAM_OUTPUT_BYTES: u64 = 512 * 1024; +const REMOTE_RESULT_OUTPUT_BYTES: usize = 900 * 1024; +const REMOTE_OUTPUT_DRAIN_TIMEOUT: Duration = Duration::from_secs(2); const LATENCY_WORKERS: usize = 4; const MAX_PENDING_LATENCY_RESULTS: usize = 4096; const MAX_REPORT_AGE_SECONDS: i64 = 7_000; @@ -95,7 +96,7 @@ struct CliOptions { endpoint: String, /// Agent token #[arg(short = 't', value_name = "TOKEN")] - token: String, + token: Option, /// Initial report interval in seconds (15-3600) #[arg(short = 'i', value_name = "SECONDS", default_value_t = 60)] interval: u64, @@ -127,7 +128,7 @@ struct LatencyResult { } struct LatencyExecutor { - task_tx: mpsc::SyncSender, + task_tx: mpsc::Sender, result_rx: mpsc::Receiver, in_flight: HashSet, stats: Arc, @@ -135,7 +136,7 @@ struct LatencyExecutor { impl LatencyExecutor { fn new(stats: Arc) -> Result { - let (task_tx, task_rx) = mpsc::sync_channel::(MAX_LATENCY_TASKS); + let (task_tx, task_rx) = mpsc::channel::(); let (result_tx, result_rx) = mpsc::channel(); let task_rx = Arc::new(Mutex::new(task_rx)); @@ -175,12 +176,12 @@ impl LatencyExecutor { return false; } let task_id = task.id.clone(); - match self.task_tx.try_send(task) { + match self.task_tx.send(task) { Ok(()) => { self.in_flight.insert(task_id); true } - Err(mpsc::TrySendError::Full(_)) | Err(mpsc::TrySendError::Disconnected(_)) => { + Err(mpsc::SendError(_)) => { self.stats.latency_queue_rejected(); false } @@ -1707,7 +1708,11 @@ fn runtime_config(options: &CliOptions) -> Result { if !(15..=3600).contains(&interval) { return Err("interval must be between 15 and 3600 seconds".into()); } - let token = options.token.clone(); + let token = options + .token + .clone() + .or_else(|| env::var("NODEFLARE_AGENT_TOKEN").ok()) + .unwrap_or_default(); let endpoint = options.endpoint.clone(); if token.is_empty() || token.len() > 512 || token.chars().any(char::is_whitespace) { return Err("token is invalid".into()); @@ -1786,7 +1791,7 @@ struct LiveAck { persistence_error: bool, #[serde(rename = "persistedThroughTs")] persisted_through_ts: i64, - #[serde(rename = "nextD1WriteAfterMs")] + #[serde(rename = "nextPersistAfterMs")] next_persist_after_ms: u64, #[serde(rename = "nextWssReportAfterMs")] next_wss_report_after_ms: u64, @@ -1822,59 +1827,294 @@ struct TaskResultMessage { exit_code: Option, } -fn execute_remote_task(task: &RemoteTaskMessage) -> TaskResultMessage { +#[derive(Default)] +struct CapturedOutput { + bytes: Vec, + truncated: bool, +} + +fn capture_remote_output(reader: R) -> mpsc::Receiver +where + R: Read + Send + 'static, +{ + let (sender, receiver) = mpsc::sync_channel(1); + thread::spawn(move || { + let mut bytes = Vec::new(); + let mut reader = reader.take(REMOTE_STREAM_OUTPUT_BYTES + 1); + let _ = reader.read_to_end(&mut bytes); + let truncated = bytes.len() > REMOTE_STREAM_OUTPUT_BYTES as usize; + bytes.truncate(REMOTE_STREAM_OUTPUT_BYTES as usize); + let _ = sender.send(CapturedOutput { bytes, truncated }); + }); + receiver +} + +fn terminate_remote_process_tree(child: &mut Child) { + #[cfg(unix)] + { + let process_group = format!("-{}", child.id()); + let _ = Command::new("kill") + .args(["-KILL", "--", process_group.as_str()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } + #[cfg(target_os = "windows")] + { + let _ = Command::new("taskkill") + .args(["/PID", &child.id().to_string(), "/T", "/F"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } + let _ = child.kill(); + let _ = child.wait(); +} + +fn receive_remote_output( + receiver: &mpsc::Receiver, + child: &mut Child, +) -> CapturedOutput { + match receiver.recv_timeout(REMOTE_OUTPUT_DRAIN_TIMEOUT) { + Ok(output) => output, + Err(_) => { + // A background descendant can keep the inherited pipe open after + // the shell exits. End the task's process group so result + // collection itself cannot hang the Agent. + terminate_remote_process_tree(child); + receiver + .recv_timeout(REMOTE_OUTPUT_DRAIN_TIMEOUT) + .unwrap_or_default() + } + } +} + +fn remote_result_text(stdout: CapturedOutput, stderr: CapturedOutput) -> String { + let truncated = stdout.truncated || stderr.truncated; + let stdout = String::from_utf8_lossy(&stdout.bytes); + let stderr = String::from_utf8_lossy(&stderr.bytes); + let mut combined = if stderr.is_empty() { + stdout.trim().to_string() + } else if stdout.is_empty() { + stderr.trim().to_string() + } else { + format!("{}\n{}", stdout.trim(), stderr.trim()) + }; + let result_truncated = combined.len() > REMOTE_RESULT_OUTPUT_BYTES; + if result_truncated { + let mut end = REMOTE_RESULT_OUTPUT_BYTES; + while !combined.is_char_boundary(end) { + end -= 1; + } + combined.truncate(end); + } + if truncated || result_truncated { + combined.push_str("\n[输出已截断]"); + } + combined +} + +fn wait_for_remote_child( + child: &mut Child, + timeout: Duration, +) -> io::Result> { + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = child.try_wait()? { + return Ok(Some(status)); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Ok(None); + } + thread::sleep(remaining.min(Duration::from_millis(50))); + } +} + +fn execute_remote_task_with_timeout( + task: &RemoteTaskMessage, + timeout: Duration, +) -> TaskResultMessage { let task_id = task.task_id.clone(); - let (shell, args) = if !task.command.is_empty() { - #[cfg(unix)] - { - ("sh", vec!["-c", task.command.as_str()]) - } - #[cfg(target_os = "windows")] - { - ("cmd.exe", vec!["/C", task.command.as_str()]) - } - } else { + if task.command.trim().is_empty() || task.command.len() > 16_384 { return TaskResultMessage { message_type: "task_result".to_string(), task_id, status: "failed".to_string(), - result: "No command provided".to_string(), + result: "命令为空或长度超出限制".to_string(), + exit_code: Some(-1), + }; + } + + #[cfg(unix)] + let mut command = { + let mut command = Command::new("sh"); + command.args(["-c", task.command.as_str()]); + command.process_group(0); + command + }; + #[cfg(target_os = "windows")] + let mut command = { + let mut command = Command::new("cmd.exe"); + command.args(["/C", task.command.as_str()]); + command + }; + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + return TaskResultMessage { + message_type: "task_result".to_string(), + task_id, + status: "failed".to_string(), + result: format!("无法启动命令:{error}"), + exit_code: Some(-1), + }; + } + }; + let stdout = capture_remote_output(child.stdout.take().expect("stdout is piped")); + let stderr = capture_remote_output(child.stderr.take().expect("stderr is piped")); + + let status = match wait_for_remote_child(&mut child, timeout) { + Ok(Some(status)) => Some(status), + Ok(None) => { + terminate_remote_process_tree(&mut child); + None + } + Err(error) => { + terminate_remote_process_tree(&mut child); + let stdout = receive_remote_output(&stdout, &mut child); + let stderr = receive_remote_output(&stderr, &mut child); + let output = remote_result_text(stdout, stderr); + return TaskResultMessage { + message_type: "task_result".to_string(), + task_id, + status: "failed".to_string(), + result: if output.is_empty() { + format!("等待命令结束失败:{error}") + } else { + format!("{output}\n等待命令结束失败:{error}") + }, + exit_code: Some(-1), + }; + } + }; + let stdout = receive_remote_output(&stdout, &mut child); + let stderr = receive_remote_output(&stderr, &mut child); + let output = remote_result_text(stdout, stderr); + + let Some(status) = status else { + let timeout_seconds = timeout.as_secs().max(1); + return TaskResultMessage { + message_type: "task_result".to_string(), + task_id, + status: "failed".to_string(), + result: if output.is_empty() { + format!("命令执行超过 {timeout_seconds} 秒,已终止") + } else { + format!("{output}\n命令执行超过 {timeout_seconds} 秒,已终止") + }, exit_code: Some(-1), }; }; - match Command::new(shell).args(&args).output() { - Ok(output) => { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let combined = if stderr.is_empty() { - stdout.to_string() - } else { - format!("{}\n{}", stdout.trim(), stderr.trim()) - }; + TaskResultMessage { + message_type: "task_result".to_string(), + task_id, + status: if status.success() { + "success" + } else { + "failed" + } + .to_string(), + result: output, + exit_code: status.code().map(i64::from), + } +} - TaskResultMessage { - message_type: "task_result".to_string(), - task_id, - status: if output.status.success() { - "success" - } else { - "failed" +fn execute_remote_task(task: &RemoteTaskMessage) -> TaskResultMessage { + execute_remote_task_with_timeout(task, REMOTE_TASK_TIMEOUT) +} + +struct RemoteExecutor { + task_tx: mpsc::SyncSender, + result_rx: mpsc::Receiver, + seen: HashSet, + seen_order: VecDeque, +} + +impl RemoteExecutor { + fn new() -> Result { + let (task_tx, task_rx) = mpsc::sync_channel::(16); + let (result_tx, result_rx) = mpsc::channel::(); + thread::Builder::new() + .name("nodeflare-remote".to_string()) + .spawn(move || { + while let Ok(task) = task_rx.recv() { + if result_tx.send(execute_remote_task(&task)).is_err() { + return; + } } - .to_string(), - result: combined.trim().to_string(), - exit_code: output.status.code().map(|c| c as i64), + })?; + Ok(Self { + task_tx, + result_rx, + seen: HashSet::new(), + seen_order: VecDeque::new(), + }) + } + + fn enqueue(&mut self, task: RemoteTaskMessage) -> Option { + if self.seen.contains(&task.task_id) { + return None; + } + let task_id = task.task_id.clone(); + self.seen.insert(task_id.clone()); + self.seen_order.push_back(task_id.clone()); + while self.seen_order.len() > 1024 { + if let Some(expired) = self.seen_order.pop_front() { + self.seen.remove(&expired); } } - Err(error) => TaskResultMessage { - message_type: "task_result".to_string(), - task_id, - status: "failed".to_string(), - result: format!("Execution error: {}", error), - exit_code: Some(-1), - }, + match self.task_tx.try_send(task) { + Ok(()) => None, + Err(mpsc::TrySendError::Full(_)) => Some(TaskResultMessage { + message_type: "task_result".to_string(), + task_id, + status: "failed".to_string(), + result: "远程命令队列已满,请稍后重试".to_string(), + exit_code: Some(-1), + }), + Err(mpsc::TrySendError::Disconnected(_)) => Some(TaskResultMessage { + message_type: "task_result".to_string(), + task_id, + status: "failed".to_string(), + result: "远程命令执行线程不可用".to_string(), + exit_code: Some(-1), + }), + } } + + fn drain(&self) -> impl Iterator + '_ { + self.result_rx.try_iter() + } +} + +fn flush_remote_results( + socket: &mut LiveSocket, + executor: &RemoteExecutor, + pending: &mut VecDeque, +) -> Result<()> { + pending.extend(executor.drain()); + while let Some(result) = pending.front() { + socket.send(Message::Text(serde_json::to_string(result)?.into()))?; + pending.pop_front(); + } + Ok(()) } fn ack_wss_interval(ack: &LiveAck) -> Duration { @@ -1882,7 +2122,7 @@ fn ack_wss_interval(ack: &LiveAck) -> Duration { .clamp(Duration::from_secs(1), Duration::from_secs(3600)) } -fn ack_d1_interval(ack: &LiveAck) -> Duration { +fn ack_persist_interval(ack: &LiveAck) -> Duration { Duration::from_millis(ack.next_persist_after_ms) .clamp(Duration::from_secs(1), Duration::from_secs(3600)) } @@ -1924,7 +2164,12 @@ fn read_live_ack(socket: &mut LiveSocket) -> Result { let Ok(task) = serde_json::from_value::(value) else { return Ok(LiveRead::Pending); }; - if task.message_type != "remote_task" || task.task_id.is_empty() { + if task.message_type != "remote_task" + || task.task_id.is_empty() + || task.task_id.len() > 80 + || task.command.trim().is_empty() + || task.command.len() > 16_384 + { return Ok(LiveRead::Pending); } Ok(LiveRead::RemoteTask(task)) @@ -1950,6 +2195,8 @@ fn read_live_ack(socket: &mut LiveSocket) -> Result { fn wait_for_live_ack( socket: &mut LiveSocket, remote_config: &Arc>>, + remote_executor: &mut RemoteExecutor, + pending_remote_results: &mut VecDeque, ) -> Result { let deadline = Instant::now() + LIVE_ACK_READ_TIMEOUT; loop { @@ -1968,10 +2215,8 @@ fn wait_for_live_ack( } } LiveRead::RemoteTask(task) => { - // 执行任务并发送结果 - let result = execute_remote_task(&task); - if let Ok(payload) = serde_json::to_string(&result) { - let _ = socket.send(Message::Text(payload.into())); + if let Some(result) = remote_executor.enqueue(task) { + pending_remote_results.push_back(result); } } LiveRead::Closed => return Ok(LiveRead::Closed), @@ -2039,6 +2284,14 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) { let mut accepted_through = persisted_through.load(Ordering::Acquire); let mut next_send_at = Instant::now(); let mut next_probe_at = Instant::now(); + let mut remote_executor = match RemoteExecutor::new() { + Ok(executor) => executor, + Err(error) => { + eprintln!("remote executor failed to start: {error}"); + return; + } + }; + let mut pending_remote_results = VecDeque::new(); 'sender: loop { if socket.is_none() { match connect_live(endpoint, token) { @@ -2063,6 +2316,15 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) { } } + if let Some(connected) = socket.as_mut() + && flush_remote_results(connected, &remote_executor, &mut pending_remote_results) + .is_err() + { + socket = None; + thread::sleep(LIVE_RECONNECT_DELAY); + continue; + } + let (queue_lock, ready) = &*pending; let mut queue = match queue_lock.lock() { Ok(queue) => queue, @@ -2100,7 +2362,10 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) { let mut drop_socket = false; if let Some(connected) = socket.as_mut() { - if set_live_read_timeout(connected, Some(LIVE_HINT_READ_TIMEOUT)).is_err() { + if flush_remote_results(connected, &remote_executor, &mut pending_remote_results) + .is_err() + || set_live_read_timeout(connected, Some(LIVE_HINT_READ_TIMEOUT)).is_err() + { drop_socket = true; } else { match read_live_ack(connected) { @@ -2115,7 +2380,7 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) { accepted_through = accepted_through.max(ack.persisted_through_ts); wss_interval = ack_wss_interval(&ack); next_send_at = Instant::now() + wss_interval; - next_probe_at = Instant::now() + ack_d1_interval(&ack); + next_probe_at = Instant::now() + ack_persist_interval(&ack); } } Ok(LiveRead::Config(config)) => { @@ -2124,9 +2389,8 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) { } } Ok(LiveRead::RemoteTask(task)) => { - let result = execute_remote_task(&task); - if let Ok(payload) = serde_json::to_string(&result) { - let _ = connected.send(Message::Text(payload.into())); + if let Some(result) = remote_executor.enqueue(task) { + pending_remote_results.push_back(result); } } Ok(LiveRead::Pending) => {} @@ -2179,7 +2443,12 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) { let mut drop_socket = false; if let Some(socket) = socket.as_mut() { - match wait_for_live_ack(socket, &remote_config) { + match wait_for_live_ack( + socket, + &remote_config, + &mut remote_executor, + &mut pending_remote_results, + ) { Ok(LiveRead::Closed) => drop_socket = true, Ok(LiveRead::Ack(ack)) => { observe_persisted_through(&persisted_through, &ack); @@ -2194,7 +2463,7 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) { accepted_through = accepted_through.max(ack.persisted_through_ts); wss_interval = ack_wss_interval(&ack); next_send_at = Instant::now() + wss_interval; - next_probe_at = Instant::now() + ack_d1_interval(&ack); + next_probe_at = Instant::now() + ack_persist_interval(&ack); } } Ok(LiveRead::Config(config)) => { @@ -2261,7 +2530,6 @@ fn sanitize_latency_tasks(tasks: &[LatencyTask]) -> Vec { && parse_probe_target(&task.target, None).is_some() && seen.insert(task.id.clone()) }) - .take(MAX_LATENCY_TASKS) .cloned() .collect() } @@ -2864,16 +3132,17 @@ mod tests { use std::time::{Duration, Instant}; use super::{ - CLOCK_CALIBRATION_MAX_AGE, CliOptions, ClockCalibration, GithubReleaseAsset, LatencyResult, - LatencyTask, LiveAck, MAX_LATENCY_TASKS, MAX_PENDING_LATENCY_RESULTS, - PUBLIC_IP_STALE_AFTER, PublicIpValue, Report, UPDATE_CHECK_JITTER_MAX_SECONDS, - ack_d1_interval, ack_wss_interval, advance_deadline, clock_offset_from_http_date, - corrected_timestamp, gpu_name_from_uevent, is_public_probe_ip, live_endpoint, + CLOCK_CALIBRATION_MAX_AGE, CapturedOutput, CliOptions, ClockCalibration, + GithubReleaseAsset, LatencyResult, LatencyTask, LiveAck, MAX_PENDING_LATENCY_RESULTS, + PUBLIC_IP_STALE_AFTER, PublicIpValue, REMOTE_RESULT_OUTPUT_BYTES, RemoteTaskMessage, + Report, UPDATE_CHECK_JITTER_MAX_SECONDS, ack_persist_interval, ack_wss_interval, + advance_deadline, clock_offset_from_http_date, corrected_timestamp, + execute_remote_task_with_timeout, gpu_name_from_uevent, is_public_probe_ip, live_endpoint, live_update_payload, monotonic_report_timestamp, normalized_version, parse_lspci_gpu_names, parse_pciconf_gpu_names, parse_probe_target, parse_public_ip, parse_system_profiler_gpu_names, ping_latencies, ping_latency, prune_report_samples, - release_asset_sha256, sanitize_latency_tasks, update_check_jitter, valid_endpoint, - version_triplet, + release_asset_sha256, remote_result_text, sanitize_latency_tasks, update_check_jitter, + valid_endpoint, version_triplet, }; #[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))] @@ -3021,22 +3290,67 @@ mod tests { #[test] fn accepts_server_realtime_ack_interval() { let ack: LiveAck = serde_json::from_str( - r#"{"type":"ack","ts":100,"persisted":false,"persistenceError":false,"persistedThroughTs":90,"nextD1WriteAfterMs":60000,"nextWssReportAfterMs":5000,"realtimeHint":false}"#, + r#"{"type":"ack","ts":100,"persisted":false,"persistenceError":false,"persistedThroughTs":90,"nextPersistAfterMs":60000,"nextWssReportAfterMs":5000,"realtimeHint":false}"#, ) .unwrap(); assert_eq!(ack_wss_interval(&ack), Duration::from_secs(5)); - assert_eq!(ack_d1_interval(&ack), Duration::from_secs(60)); + assert_eq!(ack_persist_interval(&ack), Duration::from_secs(60)); let slow: LiveAck = serde_json::from_str( - r#"{"type":"ack","ts":100,"persisted":true,"persistenceError":false,"persistedThroughTs":100,"nextD1WriteAfterMs":60000,"nextWssReportAfterMs":1,"realtimeHint":true}"#, + r#"{"type":"ack","ts":100,"persisted":true,"persistenceError":false,"persistedThroughTs":100,"nextPersistAfterMs":60000,"nextWssReportAfterMs":1,"realtimeHint":true}"#, ) .unwrap(); assert_eq!(ack_wss_interval(&slow), Duration::from_secs(1)); assert!(slow.realtime_hint); let idle: LiveAck = serde_json::from_str( - r#"{"type":"ack","ts":100,"persisted":true,"persistenceError":false,"persistedThroughTs":100,"nextD1WriteAfterMs":120000,"nextWssReportAfterMs":3600000,"realtimeHint":false}"#, + r#"{"type":"ack","ts":100,"persisted":true,"persistenceError":false,"persistedThroughTs":100,"nextPersistAfterMs":120000,"nextWssReportAfterMs":3600000,"realtimeHint":false}"#, ) .unwrap(); - assert_eq!(ack_d1_interval(&idle), Duration::from_secs(120)); + assert_eq!(ack_persist_interval(&idle), Duration::from_secs(120)); + } + + #[cfg(unix)] + #[test] + fn remote_commands_capture_output_and_exit_status() { + let result = execute_remote_task_with_timeout( + &RemoteTaskMessage { + message_type: "remote_task".to_string(), + task_id: "task-output".to_string(), + command: "printf stdout; printf stderr >&2; exit 7".to_string(), + }, + Duration::from_secs(2), + ); + assert_eq!(result.status, "failed"); + assert_eq!(result.exit_code, Some(7)); + assert_eq!(result.result, "stdout\nstderr"); + } + + #[cfg(unix)] + #[test] + fn remote_commands_are_terminated_after_the_deadline() { + let result = execute_remote_task_with_timeout( + &RemoteTaskMessage { + message_type: "remote_task".to_string(), + task_id: "task-timeout".to_string(), + command: "sleep 5".to_string(), + }, + Duration::from_millis(50), + ); + assert_eq!(result.status, "failed"); + assert_eq!(result.exit_code, Some(-1)); + assert!(result.result.contains("已终止")); + } + + #[test] + fn remote_command_output_is_bounded() { + let result = remote_result_text( + CapturedOutput { + bytes: vec![b'x'; REMOTE_RESULT_OUTPUT_BYTES + 100], + truncated: true, + }, + CapturedOutput::default(), + ); + assert!(result.len() < REMOTE_RESULT_OUTPUT_BYTES + 64); + assert!(result.ends_with("[输出已截断]")); } #[test] @@ -3103,7 +3417,7 @@ mod tests { ]) .unwrap(); assert_eq!(parsed.endpoint, "https://monitor.example.com"); - assert_eq!(parsed.token, "agent-token"); + assert_eq!(parsed.token.as_deref(), Some("agent-token")); assert_eq!(parsed.interval, 60); assert!(parsed.once); assert!(CliOptions::try_parse_from(["agent", "-t"]).is_err()); @@ -3124,7 +3438,7 @@ mod tests { } #[test] - fn bounds_and_filters_remote_latency_tasks() { + fn filters_and_deduplicates_remote_latency_tasks_without_a_count_limit() { let valid = LatencyTask { id: "task-1".to_string(), name: "Cloudflare".to_string(), @@ -3142,7 +3456,7 @@ mod tests { port: Some(443), interval_seconds: 1, }); - for index in 2..=MAX_LATENCY_TASKS + 10 { + for index in 2..=300 { tasks.push(LatencyTask { id: format!("task-{index}"), name: format!("Task {index}"), @@ -3153,7 +3467,7 @@ mod tests { }); } let sanitized = sanitize_latency_tasks(&tasks); - assert_eq!(sanitized.len(), MAX_LATENCY_TASKS); + assert_eq!(sanitized.len(), 300); assert_eq!(sanitized[0].id, "task-1"); assert!(!sanitized.iter().any(|task| task.id == "bad")); } diff --git a/backend/Cargo.lock b/backend/Cargo.lock index eb7f1f1..917d519 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aho-corasick" version = "1.1.5" @@ -79,38 +73,18 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "argon2" -version = "0.5.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.2.17", + "cpufeatures 0.3.1", "password-hash", ] -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", -] - [[package]] name = "atoi" version = "2.0.0" @@ -134,20 +108,20 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" -version = "0.7.9" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "async-trait", "axum-core", "axum-macros", - "base64 0.22.1", + "base64", "bytes", + "form_urlencoded", "futures-util", - "http 1.5.0", - "http-body 1.1.0", + "http", + "http-body", "http-body-util", - "hyper 1.11.1", + "hyper", "hyper-util", "itoa", "matchit", @@ -155,13 +129,12 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", "sha1 0.10.7", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tokio-tungstenite", "tower", @@ -172,20 +145,18 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ - "async-trait", "bytes", - "futures-util", - "http 1.5.0", - "http-body 1.1.0", + "futures-core", + "http", + "http-body", "http-body-util", "mime", "pin-project-lite", - "rustversion", - "sync_wrapper 1.0.2", + "sync_wrapper", "tower-layer", "tower-service", "tracing", @@ -193,21 +164,15 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.4.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", ] -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -220,12 +185,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.13.1" @@ -237,11 +196,11 @@ dependencies = [ [[package]] name = "blake2" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ - "digest 0.10.7", + "digest 0.11.3", ] [[package]] @@ -303,7 +262,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -359,6 +318,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -367,9 +336,9 @@ checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] name = "core-foundation" -version = "0.9.4" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", @@ -392,9 +361,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -481,17 +450,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "digest" version = "0.10.7" @@ -541,15 +499,6 @@ dependencies = [ "serde", ] -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -586,6 +535,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -598,8 +553,7 @@ version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ - "crc32fast", - "miniz_oxide", + "zlib-rs", ] [[package]] @@ -613,12 +567,6 @@ dependencies = [ "spin", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.2.0" @@ -738,6 +686,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -746,29 +706,10 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.1", ] -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.16.1" @@ -834,17 +775,6 @@ dependencies = [ "digest 0.11.3", ] -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - [[package]] name = "http" version = "1.5.0" @@ -855,17 +785,6 @@ dependencies = [ "itoa", ] -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - [[package]] name = "http-body" version = "1.1.0" @@ -873,7 +792,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", - "http 1.5.0", + "http", ] [[package]] @@ -884,8 +803,8 @@ checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", - "http 1.5.0", - "http-body 1.1.0", + "http", + "http-body", "pin-project-lite", ] @@ -910,30 +829,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - [[package]] name = "hyper" version = "1.11.1" @@ -944,28 +839,30 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "http 1.5.0", - "http-body 1.1.0", + "http", + "http-body", "httparse", "httpdate", "itoa", "pin-project-lite", "smallvec", "tokio", + "want", ] [[package]] name = "hyper-rustls" -version = "0.24.2" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.32", - "rustls 0.21.12", + "http", + "hyper", + "hyper-util", + "rustls", "tokio", "tokio-rustls", + "tower-service", ] [[package]] @@ -974,13 +871,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", - "http 1.5.0", - "http-body 1.1.0", - "hyper 1.11.1", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -1115,6 +1020,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "js-sys" version = "0.3.104" @@ -1149,6 +1103,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -1181,9 +1141,9 @@ dependencies = [ [[package]] name = "matchit" -version = "0.7.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "md-5" @@ -1207,16 +1167,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "miniz_oxide" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "mio" version = "1.2.3" @@ -1240,18 +1190,20 @@ dependencies = [ "futures-util", "hex", "pbkdf2", - "rand 0.8.8", + "rand 0.10.2", "reqwest", + "rustls", "serde", "serde_json", "sha2 0.11.0", "sqlx", + "tempfile", "time", "tokio", + "tokio-util", "toml", "totp-lite", - "tower", - "tower-http", + "tower-http 0.7.1", "tracing", "tracing-subscriber", "url", @@ -1295,6 +1247,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "parking" version = "2.2.1" @@ -1326,13 +1284,12 @@ dependencies = [ [[package]] name = "password-hash" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", + "getrandom 0.4.3", + "phc", ] [[package]] @@ -1351,6 +1308,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1405,6 +1373,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1413,13 +1387,12 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.8" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "libc", "rand_chacha", - "rand_core 0.6.4", + "rand_core 0.9.5", ] [[package]] @@ -1435,21 +1408,21 @@ dependencies = [ [[package]] name = "rand_chacha" -version = "0.3.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", + "rand_core 0.9.5", ] [[package]] name = "rand_core" -version = "0.6.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom 0.2.17", + "getrandom 0.3.4", ] [[package]] @@ -1464,7 +1437,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags", ] [[package]] @@ -1486,43 +1459,39 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.11.27" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64 0.21.7", + "base64", "bytes", - "encoding_rs", "futures-core", - "futures-util", - "h2", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", + "http", + "http-body", + "http-body-util", + "hyper", "hyper-rustls", - "ipnet", + "hyper-util", "js-sys", "log", - "mime", - "once_cell", "percent-encoding", "pin-project-lite", - "rustls 0.21.12", - "rustls-pemfile", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 0.1.2", - "system-configuration", + "sync_wrapper", "tokio", "tokio-rustls", + "tower", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 0.25.4", - "winreg", ] [[package]] @@ -1540,15 +1509,25 @@ dependencies = [ ] [[package]] -name = "rustls" -version = "0.21.12" +name = "rustc_version" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1560,18 +1539,21 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.15", + "rustls-webpki", "subtle", "zeroize", ] [[package]] -name = "rustls-pemfile" -version = "1.0.4" +name = "rustls-native-certs" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ - "base64 0.21.7", + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", ] [[package]] @@ -1584,15 +1566,32 @@ dependencies = [ ] [[package]] -name = "rustls-webpki" -version = "0.101.7" +name = "rustls-platform-verifier" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "ring", - "untrusted", + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", ] +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.15" @@ -1616,6 +1615,24 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1623,15 +1640,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "sct" -version = "0.7.1" +name = "security-framework" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "ring", - "untrusted", + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", ] +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -1688,11 +1724,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.9" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -1725,7 +1761,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -1747,7 +1783,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -1782,6 +1818,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -1790,23 +1842,13 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" dependencies = [ "serde", ] -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.5" @@ -1845,7 +1887,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "cfg-if", "crc", @@ -1862,17 +1904,17 @@ dependencies = [ "log", "memchr", "percent-encoding", - "rustls 0.23.43", + "rustls", "serde", "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.20", + "thiserror", "tokio", "tokio-stream", "tracing", "url", - "webpki-roots 1.0.9", + "webpki-roots", ] [[package]] @@ -1909,7 +1951,7 @@ dependencies = [ "sqlx-postgres", "sqlx-sqlite", "syn 2.0.119", - "thiserror 2.0.20", + "thiserror", "tokio", "url", ] @@ -1920,7 +1962,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "bitflags 2.13.1", + "bitflags", "byteorder", "bytes", "crc", @@ -1936,7 +1978,7 @@ dependencies = [ "sha1 0.11.0", "sha2 0.11.0", "sqlx-core", - "thiserror 2.0.20", + "thiserror", "tracing", ] @@ -1947,8 +1989,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", - "base64 0.22.1", - "bitflags 2.13.1", + "base64", + "bitflags", "byteorder", "crc", "dotenvy", @@ -1970,7 +2012,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.20", + "thiserror", "tracing", "whoami", ] @@ -1994,7 +2036,7 @@ dependencies = [ "percent-encoding", "serde", "sqlx-core", - "thiserror 2.0.20", + "thiserror", "tracing", "url", ] @@ -2050,17 +2092,14 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - [[package]] name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -2074,33 +2113,16 @@ dependencies = [ ] [[package]] -name = "system-configuration" -version = "0.5.1" +name = "tempfile" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", ] [[package]] @@ -2109,18 +2131,7 @@ version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.20", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "thiserror-impl", ] [[package]] @@ -2185,9 +2196,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" dependencies = [ "tinyvec_macros", ] @@ -2210,7 +2221,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.5", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -2228,11 +2239,11 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.24.1" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.21.12", + "rustls", "tokio", ] @@ -2249,9 +2260,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.24.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", @@ -2268,51 +2279,48 @@ dependencies = [ "bytes", "futures-core", "futures-sink", - "libc", "pin-project-lite", "tokio", ] [[package]] name = "toml" -version = "0.8.23" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" dependencies = [ "indexmap", - "serde", + "serde_core", "serde_spanned", "toml_datetime", - "toml_write", + "toml_parser", + "toml_writer", "winnow", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "totp-lite" @@ -2335,7 +2343,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tower-layer", "tower-service", @@ -2344,15 +2352,33 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.5.2" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.1", + "bitflags", "bytes", - "http 1.5.0", - "http-body 1.1.0", - "http-body-util", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-http" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "percent-encoding", "pin-project-lite", "tower-layer", "tower-service", @@ -2441,22 +2467,26 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.24.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ - "byteorder", "bytes", "data-encoding", - "http 1.5.0", + "http", "httparse", "log", - "rand 0.8.8", + "rand 0.9.5", "sha1 0.10.7", - "thiserror 1.0.69", - "utf-8", + "thiserror", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.20.1" @@ -2508,12 +2538,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -2555,6 +2579,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2570,6 +2604,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -2636,10 +2679,13 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "0.25.4" +name = "webpki-root-certs" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] [[package]] name = "webpki-roots" @@ -2656,28 +2702,28 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -2689,67 +2735,34 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2762,48 +2775,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2812,22 +2801,15 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.15" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" [[package]] -name = "winreg" -version = "0.50.0" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" @@ -2940,21 +2922,24 @@ dependencies = [ [[package]] name = "zip" -version = "2.4.2" +version = "8.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ - "arbitrary", "crc32fast", - "crossbeam-utils", - "displaydoc", "flate2", "indexmap", "memchr", - "thiserror 2.0.20", + "typed-path", "zopfli", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index af3d66f..fb6319d 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -11,14 +11,14 @@ name = "nodeflare" path = "src/main.rs" [dependencies] -axum = { version = "0.7", features = ["ws", "macros"] } +axum = { version = "0.8.9", features = ["ws", "macros"] } tokio = { version = "1", features = ["full"] } -tower = { version = "0.5", features = ["util"] } -tower-http = { version = "0.5", features = ["trace"] } +tokio-util = { version = "0.7", features = ["io"] } +tower-http = { version = "0.7.1", features = ["trace"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring", "any", "sqlite-bundled", "postgres", "migrate", "macros", "json"] } -toml = "0.8" +toml = "1.1.5" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } futures-util = "0.3" @@ -28,14 +28,16 @@ pbkdf2 = "0.13" totp-lite = "2" clap = { version = "4", features = ["derive"] } anyhow = "1" -argon2 = "0.5" -rand = "0.8" +argon2 = "0.6" +rand = "0.10" hex = "0.4" -reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "json"] } +reqwest = { version = "0.13.4", default-features = false, features = ["rustls-no-provider", "json", "form"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } data-encoding = "2" uuid = { version = "1", features = ["v4"] } url = "2.5" -zip = { version = "2.4.2", default-features = false, features = ["deflate"] } +zip = { version = "8.6.0", default-features = false, features = ["deflate"] } +tempfile = "3" [profile.release] opt-level = 3 diff --git a/backend/README.md b/backend/README.md index 6318ede..81df908 100644 --- a/backend/README.md +++ b/backend/README.md @@ -32,7 +32,7 @@ cargo run --locked --manifest-path backend/Cargo.toml -- \ 配置使用扁平 TOML 字段: ```toml -database_url = "sqlite:///opt/nodeflare/data/server/nodeflare.db" +database_url = "sqlite:///etc/nodeflare/nodeflare.db" bind_addr = "127.0.0.1:8080" admin_username = "admin" admin_password = "replace-with-a-long-random-password" @@ -43,11 +43,11 @@ turnstile_secret_key = "" frontend_dir = "/opt/nodeflare/share/frontend" admin_frontend_dir = "/opt/nodeflare/share/admin" agent_dir = "/opt/nodeflare/share/agent" -theme_dir = "/opt/nodeflare/data/server/themes" +theme_dir = "/etc/nodeflare/themes" session_ttl_hours = 168 ``` -PostgreSQL 可将 `database_url` 改为 `postgres://user:password@host/database`。相对 SQLite 路径和静态资源路径均相对于配置文件目录解析。安装脚本仅询问管理员用户名和密码,其他项直接编辑 `/etc/nodeflare/config.toml`。面板服务不创建额外的系统用户。 +PostgreSQL 可将 `database_url` 改为 `postgres://user:password@host:5432/database?sslmode=prefer`。相对 SQLite 路径和静态资源路径均相对于配置文件目录解析;URL 中的特殊字符需要进行百分号编码。安装脚本询问管理员用户名、密码和数据库 URL,其他项直接编辑 `/etc/nodeflare/config.toml`。面板服务不创建额外的系统用户。 ## 主要端点 @@ -56,8 +56,8 @@ PostgreSQL 可将 `database_url` 改为 `postgres://user:password@host/database` - `GET /api/bootstrap` - `GET /api/config` - `GET /api/servers` -- `GET /api/history/:id` -- `GET /api/latency/:id` +- `GET /api/history/{id}` +- `GET /api/latency/{id}` - `GET /api/exchange-rates` - `POST /api/turnstile/verify` - `POST /api/admin/login` @@ -65,16 +65,19 @@ PostgreSQL 可将 `database_url` 改为 `postgres://user:password@host/database` 管理员接口: - `POST /api/admin/logout` +- `GET|DELETE /api/admin/sessions*` - `GET|PATCH /api/admin/settings` - `GET|POST /api/admin/2fa/*` - `GET|POST|PATCH|DELETE /api/admin/servers*` - `GET|POST|PATCH|DELETE /api/admin/latency-tasks*` - `GET|POST|PATCH|DELETE /api/admin/alert-rules*` - `POST /api/admin/remote/task` -- `GET /api/admin/remote/task/:id` +- `GET /api/admin/remote/task/{id}` `POST /api/admin/remote/task` 接收一条命令和 `server_ids` 数组,一次验证后为每个节点创建独立执行结果。创建远程任务前必须为管理员启用 TOTP,并在请求中提交当前 6 位验证码。 +Agent Token 只以哈希形式保存在数据库中。创建节点时会返回一次 Token;`POST /api/admin/servers/{id}/token` 会生成新 Token、断开旧 Agent,旧 Token 随即失效。 + WebSocket: - `GET /api/agent/ws`:Agent 上报、配置和远程任务通道 @@ -82,7 +85,7 @@ WebSocket: ## 数据库迁移 -启动时会根据 URL 类型自动运行 `migrations/sqlite` 或 `migrations/postgres`。开发新迁移时必须分别考虑两种数据库的 SQL 语法和类型差异。 +启动时会根据 URL 类型自动运行 `migrations/sqlite` 或 `migrations/postgres`。当前按全新项目维护,两种数据库都只有一份完整初始结构;正式发布并需要保留用户数据后,再追加编号迁移。 ## 质量检查 diff --git a/backend/config.example.toml b/backend/config.example.toml index 6af5d12..aff4d08 100644 --- a/backend/config.example.toml +++ b/backend/config.example.toml @@ -2,8 +2,8 @@ # 正式安装请运行仓库根目录的 install.sh,配置将写入 /etc/nodeflare/config.toml # 数据库连接 URL -# SQLite: sqlite://path/to/db.sqlite 或 sqlite://nodeflare.db -# PostgreSQL: postgres://user:password@localhost/nodeflare +# SQLite: sqlite:///path/to/database.db 或 sqlite://nodeflare.db +# PostgreSQL: postgres://user:password@127.0.0.1:5432/nodeflare?sslmode=prefer database_url = "sqlite://nodeflare.db" # 服务器监听地址 diff --git a/backend/config.test.toml b/backend/config.test.toml deleted file mode 100644 index 7763811..0000000 --- a/backend/config.test.toml +++ /dev/null @@ -1,6 +0,0 @@ -database_url = "sqlite://test_nodeflare.db" -bind_addr = "127.0.0.1:8080" -admin_username = "admin" -admin_password = "admin123" -turnstile_site_key = "" -turnstile_secret_key = "" diff --git a/backend/migrations/postgres/01_initial.sql b/backend/migrations/postgres/01_initial.sql index 63a7b02..c9ea140 100644 --- a/backend/migrations/postgres/01_initial.sql +++ b/backend/migrations/postgres/01_initial.sql @@ -30,7 +30,7 @@ CREATE TABLE servers ( agent_mirror TEXT NOT NULL DEFAULT '', offline_notify_disabled BIGINT NOT NULL DEFAULT 0, auto_update BIGINT NOT NULL DEFAULT 1, - token TEXT NOT NULL UNIQUE, + token_hash TEXT NOT NULL UNIQUE, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL ); @@ -85,6 +85,8 @@ CREATE TABLE server_latest_state ( last_batch_id TEXT NOT NULL DEFAULT '' ); +CREATE INDEX server_latest_state_time ON server_latest_state(latest_timestamp); + CREATE TABLE admin_2fa ( username TEXT PRIMARY KEY, totp_secret TEXT NOT NULL, @@ -105,8 +107,14 @@ CREATE TABLE remote_tasks ( exit_code BIGINT ); -CREATE INDEX remote_tasks_server ON remote_tasks(server_id, requested_at DESC); -CREATE INDEX remote_tasks_status ON remote_tasks(status, requested_at DESC); +CREATE INDEX remote_tasks_server_status_time +ON remote_tasks(server_id, status, requested_at); + +CREATE INDEX remote_tasks_status_completed +ON remote_tasks(status, completed_at); + +CREATE INDEX remote_tasks_status_requested +ON remote_tasks(status, requested_at); CREATE TABLE exchange_rates ( base_currency TEXT PRIMARY KEY, @@ -118,13 +126,18 @@ CREATE TABLE exchange_rates ( ); CREATE TABLE sessions ( - token_hash TEXT PRIMARY KEY, + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, username TEXT NOT NULL, + ip_address TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', created_at BIGINT NOT NULL, + last_seen_at BIGINT NOT NULL, expires_at BIGINT NOT NULL ); CREATE INDEX sessions_expires_at ON sessions(expires_at); +CREATE INDEX sessions_user_activity ON sessions(username, last_seen_at DESC); CREATE TABLE dashboard_proofs ( token_hash TEXT PRIMARY KEY, @@ -159,6 +172,8 @@ CREATE TABLE latency_tasks ( CHECK((task_type = 'tcp' AND port IS NOT NULL) OR (task_type = 'icmp' AND port IS NULL)) ); +CREATE INDEX latency_tasks_sort ON latency_tasks(sort_order, created_at); + CREATE TABLE latency_task_servers ( task_id TEXT NOT NULL REFERENCES latency_tasks(id) ON DELETE CASCADE, server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, @@ -178,6 +193,7 @@ CREATE TABLE latency_results ( ); CREATE INDEX latency_results_server_time ON latency_results(server_id, timestamp DESC); +CREATE INDEX latency_results_time ON latency_results(timestamp); CREATE TABLE alert_rules ( id TEXT PRIMARY KEY, @@ -191,12 +207,16 @@ CREATE TABLE alert_rules ( updated_at BIGINT NOT NULL ); +CREATE INDEX alert_rules_created_at ON alert_rules(created_at); + CREATE TABLE alert_rule_servers ( rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE, server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, PRIMARY KEY(rule_id, server_id) ); +CREATE INDEX alert_rule_servers_server ON alert_rule_servers(server_id, rule_id); + CREATE TABLE alert_states ( state_key TEXT PRIMARY KEY, active BIGINT NOT NULL DEFAULT 0, @@ -204,6 +224,8 @@ CREATE TABLE alert_states ( details_json TEXT NOT NULL DEFAULT '{}' ); +CREATE INDEX alert_states_active_time ON alert_states(active, updated_at); + CREATE TABLE notification_telegram ( id BIGINT PRIMARY KEY CHECK(id = 1), bot_token TEXT NOT NULL, diff --git a/backend/migrations/sqlite/01_initial.sql b/backend/migrations/sqlite/01_initial.sql index 2431b2e..621e43b 100644 --- a/backend/migrations/sqlite/01_initial.sql +++ b/backend/migrations/sqlite/01_initial.sql @@ -1,14 +1,11 @@ --- SQLite migration for NodeFlare backend PRAGMA foreign_keys = ON; --- Settings table (key-value store) -CREATE TABLE IF NOT EXISTS settings ( +CREATE TABLE settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ) WITHOUT ROWID; --- Servers table -CREATE TABLE IF NOT EXISTS servers ( +CREATE TABLE servers ( id TEXT PRIMARY KEY, name TEXT NOT NULL, region TEXT NOT NULL DEFAULT '', @@ -24,6 +21,8 @@ CREATE TABLE IF NOT EXISTS servers ( currency TEXT NOT NULL DEFAULT 'CNY', auto_renewal INTEGER NOT NULL DEFAULT 0, last_ip TEXT NOT NULL DEFAULT '', + ip_v4 TEXT NOT NULL DEFAULT '', + ip_v6 TEXT NOT NULL DEFAULT '', network_interface TEXT NOT NULL DEFAULT '', reset_day INTEGER NOT NULL DEFAULT 1, report_interval INTEGER NOT NULL DEFAULT 60, @@ -33,15 +32,14 @@ CREATE TABLE IF NOT EXISTS servers ( agent_mirror TEXT NOT NULL DEFAULT '', offline_notify_disabled INTEGER NOT NULL DEFAULT 0, auto_update INTEGER NOT NULL DEFAULT 1, - token TEXT NOT NULL UNIQUE, + token_hash TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) WITHOUT ROWID; -CREATE INDEX IF NOT EXISTS idx_servers_sort ON servers(sort_order, created_at); +CREATE INDEX servers_sort ON servers(sort_order, created_at); --- Minute-level metrics history -CREATE TABLE IF NOT EXISTS metric_history ( +CREATE TABLE metric_history ( server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, timestamp INTEGER NOT NULL, cpu REAL NOT NULL DEFAULT 0, @@ -62,11 +60,19 @@ CREATE TABLE IF NOT EXISTS metric_history ( processes INTEGER NOT NULL DEFAULT 0, tcp_connections INTEGER NOT NULL DEFAULT 0, udp_connections INTEGER NOT NULL DEFAULT 0, + gpu_usage REAL NOT NULL DEFAULT 0, + disk_read_bps REAL NOT NULL DEFAULT 0, + disk_write_bps REAL NOT NULL DEFAULT 0, + disk_read_iops REAL NOT NULL DEFAULT 0, + disk_write_iops REAL NOT NULL DEFAULT 0, + disk_await_ms REAL NOT NULL DEFAULT 0, + disk_utilization REAL NOT NULL DEFAULT 0, PRIMARY KEY(server_id, timestamp) ) WITHOUT ROWID; --- Latest state per server -CREATE TABLE IF NOT EXISTS server_latest_state ( +CREATE INDEX metric_history_time ON metric_history(timestamp); + +CREATE TABLE server_latest_state ( server_id TEXT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE, latest_timestamp INTEGER NOT NULL, cpu REAL NOT NULL DEFAULT 0, @@ -76,19 +82,21 @@ CREATE TABLE IF NOT EXISTS server_latest_state ( disk_total INTEGER NOT NULL DEFAULT 0, net_in REAL NOT NULL DEFAULT 0, net_out REAL NOT NULL DEFAULT 0, - uptime INTEGER NOT NULL DEFAULT 0 + uptime INTEGER NOT NULL DEFAULT 0, + latest_json TEXT NOT NULL DEFAULT '{}', + last_batch_id TEXT NOT NULL DEFAULT '' ) WITHOUT ROWID; --- 2FA configuration -CREATE TABLE IF NOT EXISTS admin_2fa ( +CREATE INDEX server_latest_state_time ON server_latest_state(latest_timestamp); + +CREATE TABLE admin_2fa ( username TEXT PRIMARY KEY, totp_secret TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL ) WITHOUT ROWID; --- Remote execution tasks -CREATE TABLE IF NOT EXISTS remote_tasks ( +CREATE TABLE remote_tasks ( id TEXT PRIMARY KEY, server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, command TEXT NOT NULL, @@ -101,11 +109,16 @@ CREATE TABLE IF NOT EXISTS remote_tasks ( exit_code INTEGER ) WITHOUT ROWID; -CREATE INDEX IF NOT EXISTS idx_remote_tasks_server ON remote_tasks(server_id, requested_at DESC); -CREATE INDEX IF NOT EXISTS idx_remote_tasks_status ON remote_tasks(status, requested_at DESC); +CREATE INDEX remote_tasks_server_status_time +ON remote_tasks(server_id, status, requested_at); --- Exchange rates -CREATE TABLE IF NOT EXISTS exchange_rates ( +CREATE INDEX remote_tasks_status_completed +ON remote_tasks(status, completed_at); + +CREATE INDEX remote_tasks_status_requested +ON remote_tasks(status, requested_at); + +CREATE TABLE exchange_rates ( base_currency TEXT PRIMARY KEY, rates_json TEXT NOT NULL CHECK(json_valid(rates_json)), source TEXT NOT NULL, @@ -113,3 +126,137 @@ CREATE TABLE IF NOT EXISTS exchange_rates ( fetched_at INTEGER NOT NULL, attempted_at INTEGER NOT NULL ) WITHOUT ROWID; + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + username TEXT NOT NULL, + ip_address TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +) WITHOUT ROWID; + +CREATE INDEX sessions_expires_at ON sessions(expires_at); +CREATE INDEX sessions_user_activity ON sessions(username, last_seen_at DESC); + +CREATE TABLE dashboard_proofs ( + token_hash TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +) WITHOUT ROWID; + +CREATE INDEX dashboard_proofs_expires_at ON dashboard_proofs(expires_at); + +CREATE TABLE server_traffic_state ( + server_id TEXT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE, + cycle_key INTEGER NOT NULL DEFAULT 0, + reset_day INTEGER NOT NULL DEFAULT 1, + timestamp INTEGER NOT NULL DEFAULT 0, + raw_rx INTEGER NOT NULL DEFAULT 0, + raw_tx INTEGER NOT NULL DEFAULT 0, + used_rx INTEGER NOT NULL DEFAULT 0, + used_tx INTEGER NOT NULL DEFAULT 0 +) WITHOUT ROWID; + +CREATE TABLE latency_tasks ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + task_type TEXT NOT NULL CHECK(task_type IN ('tcp', 'icmp')), + target TEXT NOT NULL, + port INTEGER CHECK(port IS NULL OR port BETWEEN 1 AND 65535), + interval_seconds INTEGER NOT NULL CHECK(interval_seconds BETWEEN 30 AND 3600), + default_enabled INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + CHECK((task_type = 'tcp' AND port IS NOT NULL) OR (task_type = 'icmp' AND port IS NULL)) +) WITHOUT ROWID; + +CREATE INDEX latency_tasks_sort ON latency_tasks(sort_order, created_at); + +CREATE TABLE latency_task_servers ( + task_id TEXT NOT NULL REFERENCES latency_tasks(id) ON DELETE CASCADE, + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + assigned_at INTEGER NOT NULL, + PRIMARY KEY(task_id, server_id) +) WITHOUT ROWID; + +CREATE INDEX latency_task_servers_server ON latency_task_servers(server_id, task_id); + +CREATE TABLE latency_results ( + task_id TEXT NOT NULL REFERENCES latency_tasks(id) ON DELETE CASCADE, + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + timestamp INTEGER NOT NULL, + latency_ms REAL NOT NULL, + packet_loss REAL NOT NULL, + PRIMARY KEY(task_id, server_id, timestamp) +) WITHOUT ROWID; + +CREATE INDEX latency_results_server_time +ON latency_results(server_id, timestamp DESC); + +CREATE INDEX latency_results_time ON latency_results(timestamp); + +CREATE TABLE alert_rules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + metric TEXT NOT NULL CHECK(metric IN ('cpu', 'memory', 'disk', 'net_in', 'net_out')), + threshold REAL NOT NULL CHECK(threshold > 0), + duration_minutes INTEGER NOT NULL CHECK(duration_minutes BETWEEN 1 AND 1440), + aggregation TEXT NOT NULL CHECK(aggregation IN ('average', 'continuous')), + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +) WITHOUT ROWID; + +CREATE INDEX alert_rules_created_at ON alert_rules(created_at); + +CREATE TABLE alert_rule_servers ( + rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE, + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + PRIMARY KEY(rule_id, server_id) +) WITHOUT ROWID; + +CREATE INDEX alert_rule_servers_server ON alert_rule_servers(server_id, rule_id); + +CREATE TABLE alert_states ( + state_key TEXT PRIMARY KEY, + active INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + details_json TEXT NOT NULL DEFAULT '{}' +) WITHOUT ROWID; + +CREATE INDEX alert_states_active_time ON alert_states(active, updated_at); + +CREATE TABLE notification_telegram ( + id INTEGER PRIMARY KEY CHECK(id = 1), + bot_token TEXT NOT NULL, + chat_id TEXT NOT NULL, + message_thread_id INTEGER, + template TEXT NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE themes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL UNIQUE, + resolved_url TEXT NOT NULL, + version TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL +) WITHOUT ROWID; + +CREATE INDEX themes_created_at ON themes(created_at DESC); + +CREATE TABLE theme_previews ( + token_hash TEXT PRIMARY KEY, + theme_id TEXT NOT NULL REFERENCES themes(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL +) WITHOUT ROWID; + +CREATE INDEX theme_previews_expires_at ON theme_previews(expires_at); + +PRAGMA optimize; diff --git a/backend/migrations/sqlite/02_complete_standalone.sql b/backend/migrations/sqlite/02_complete_standalone.sql deleted file mode 100644 index 1b26301..0000000 --- a/backend/migrations/sqlite/02_complete_standalone.sql +++ /dev/null @@ -1,133 +0,0 @@ -PRAGMA foreign_keys = ON; - -ALTER TABLE servers ADD COLUMN ip_v4 TEXT NOT NULL DEFAULT ''; -ALTER TABLE servers ADD COLUMN ip_v6 TEXT NOT NULL DEFAULT ''; - -ALTER TABLE metric_history ADD COLUMN gpu_usage REAL NOT NULL DEFAULT 0; -ALTER TABLE metric_history ADD COLUMN disk_read_bps REAL NOT NULL DEFAULT 0; -ALTER TABLE metric_history ADD COLUMN disk_write_bps REAL NOT NULL DEFAULT 0; -ALTER TABLE metric_history ADD COLUMN disk_read_iops REAL NOT NULL DEFAULT 0; -ALTER TABLE metric_history ADD COLUMN disk_write_iops REAL NOT NULL DEFAULT 0; -ALTER TABLE metric_history ADD COLUMN disk_await_ms REAL NOT NULL DEFAULT 0; -ALTER TABLE metric_history ADD COLUMN disk_utilization REAL NOT NULL DEFAULT 0; - -ALTER TABLE server_latest_state ADD COLUMN latest_json TEXT NOT NULL DEFAULT '{}'; -ALTER TABLE server_latest_state ADD COLUMN last_batch_id TEXT NOT NULL DEFAULT ''; - -CREATE TABLE sessions ( - token_hash TEXT PRIMARY KEY, - username TEXT NOT NULL, - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL -) WITHOUT ROWID; - -CREATE INDEX sessions_expires_at ON sessions(expires_at); - -CREATE TABLE dashboard_proofs ( - token_hash TEXT PRIMARY KEY, - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL -) WITHOUT ROWID; - -CREATE INDEX dashboard_proofs_expires_at ON dashboard_proofs(expires_at); - -CREATE TABLE server_traffic_state ( - server_id TEXT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE, - cycle_key INTEGER NOT NULL DEFAULT 0, - reset_day INTEGER NOT NULL DEFAULT 1, - timestamp INTEGER NOT NULL DEFAULT 0, - raw_rx INTEGER NOT NULL DEFAULT 0, - raw_tx INTEGER NOT NULL DEFAULT 0, - used_rx INTEGER NOT NULL DEFAULT 0, - used_tx INTEGER NOT NULL DEFAULT 0 -) WITHOUT ROWID; - -CREATE TABLE latency_tasks ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - task_type TEXT NOT NULL CHECK(task_type IN ('tcp', 'icmp')), - target TEXT NOT NULL, - port INTEGER CHECK(port IS NULL OR port BETWEEN 1 AND 65535), - interval_seconds INTEGER NOT NULL CHECK(interval_seconds BETWEEN 30 AND 3600), - default_enabled INTEGER NOT NULL DEFAULT 0, - sort_order INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - CHECK((task_type = 'tcp' AND port IS NOT NULL) OR (task_type = 'icmp' AND port IS NULL)) -); - -CREATE TABLE latency_task_servers ( - task_id TEXT NOT NULL REFERENCES latency_tasks(id) ON DELETE CASCADE, - server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, - assigned_at INTEGER NOT NULL, - PRIMARY KEY(task_id, server_id) -) WITHOUT ROWID; - -CREATE INDEX latency_task_servers_server ON latency_task_servers(server_id, task_id); - -CREATE TABLE latency_results ( - task_id TEXT NOT NULL REFERENCES latency_tasks(id) ON DELETE CASCADE, - server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, - timestamp INTEGER NOT NULL, - latency_ms REAL NOT NULL, - packet_loss REAL NOT NULL, - PRIMARY KEY(task_id, server_id, timestamp) -) WITHOUT ROWID; - -CREATE INDEX latency_results_server_time ON latency_results(server_id, timestamp DESC); - -CREATE TABLE alert_rules ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - metric TEXT NOT NULL CHECK(metric IN ('cpu', 'memory', 'disk', 'net_in', 'net_out')), - threshold REAL NOT NULL CHECK(threshold > 0), - duration_minutes INTEGER NOT NULL CHECK(duration_minutes BETWEEN 1 AND 1440), - aggregation TEXT NOT NULL CHECK(aggregation IN ('average', 'continuous')), - enabled INTEGER NOT NULL DEFAULT 1, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL -); - -CREATE TABLE alert_rule_servers ( - rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE, - server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, - PRIMARY KEY(rule_id, server_id) -) WITHOUT ROWID; - -CREATE TABLE alert_states ( - state_key TEXT PRIMARY KEY, - active INTEGER NOT NULL DEFAULT 0, - updated_at INTEGER NOT NULL, - details_json TEXT NOT NULL DEFAULT '{}' -) WITHOUT ROWID; - -CREATE TABLE notification_telegram ( - id INTEGER PRIMARY KEY CHECK(id = 1), - bot_token TEXT NOT NULL, - chat_id TEXT NOT NULL, - message_thread_id INTEGER, - template TEXT NOT NULL, - updated_at INTEGER NOT NULL -); - -CREATE TABLE themes ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - url TEXT NOT NULL UNIQUE, - resolved_url TEXT NOT NULL, - version TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL -); - -CREATE INDEX themes_created_at ON themes(created_at DESC); - -CREATE TABLE theme_previews ( - token_hash TEXT PRIMARY KEY, - theme_id TEXT NOT NULL REFERENCES themes(id) ON DELETE CASCADE, - expires_at INTEGER NOT NULL -) WITHOUT ROWID; - -CREATE INDEX theme_previews_expires_at ON theme_previews(expires_at); - -PRAGMA optimize; diff --git a/backend/src/auth.rs b/backend/src/auth.rs index c9df2c6..f20e6f0 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -1,17 +1,15 @@ use argon2::{ Argon2, - password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, + password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash}, }; use pbkdf2::pbkdf2_hmac; -use rand::RngCore; use sha2::{Digest, Sha256}; pub const CLIENT_PASSWORD_ROUNDS: u32 = 600_000; pub fn hash_password(password_derived: &str) -> anyhow::Result { - let salt = SaltString::generate(&mut OsRng); Ok(Argon2::default() - .hash_password(password_derived.as_bytes(), &salt) + .hash_password(password_derived.as_bytes()) .map_err(|error| anyhow::anyhow!(error.to_string()))? .to_string()) } @@ -37,7 +35,7 @@ pub fn derive_client_password(password: &str, deployment_salt: &str) -> String { pub fn random_token(bytes: usize) -> String { let mut value = vec![0_u8; bytes]; - rand::rngs::OsRng.fill_bytes(&mut value); + rand::fill(&mut value); hex::encode(value) } diff --git a/backend/src/backup.rs b/backend/src/backup.rs new file mode 100644 index 0000000..efbfcef --- /dev/null +++ b/backend/src/backup.rs @@ -0,0 +1,624 @@ +use crate::db::{Database, DatabaseKind}; +use anyhow::{Context, Result, bail}; +use futures_util::TryStreamExt; +use serde::{Deserialize, Serialize}; +use serde_json::{Number, Value}; +use sqlx::any::{AnyArguments, AnyRow, AnyTypeInfoKind}; +use sqlx::{Arguments, AssertSqlSafe, Column, Executor, Row, SqlSafeStr}; +use std::collections::HashSet; +use std::fs::File; +use std::io::{BufRead, BufReader, Cursor, Read, Seek, SeekFrom, Write}; +use zip::write::SimpleFileOptions; +use zip::{CompressionMethod, ZipArchive, ZipWriter}; + +pub const DATABASE_BACKUP_MAX_BYTES: usize = 512 * 1024 * 1024; +const DATABASE_BACKUP_MAX_EXTRACTED_BYTES: u64 = 4 * 1024 * 1024 * 1024; +const DATABASE_BACKUP_MAX_ENTRIES: usize = 64; +const DATABASE_BACKUP_FORMAT: &str = "nodeflare-database-backup"; +const DATABASE_BACKUP_VERSION: u32 = 1; +const INSERT_BATCH_ROWS: usize = 100; +const MAX_NDJSON_LINE_BYTES: usize = 16 * 1024 * 1024; + +const BACKUP_TABLES: &[&str] = &[ + "settings", + "servers", + "themes", + "metric_history", + "server_latest_state", + "admin_2fa", + "remote_tasks", + "exchange_rates", + "server_traffic_state", + "latency_tasks", + "latency_task_servers", + "latency_results", + "alert_rules", + "alert_rule_servers", + "alert_states", + "notification_telegram", +]; + +const CLEAR_TABLES: &[&str] = &[ + "theme_previews", + "dashboard_proofs", + "sessions", + "alert_rule_servers", + "alert_states", + "latency_results", + "latency_task_servers", + "remote_tasks", + "server_traffic_state", + "server_latest_state", + "metric_history", + "notification_telegram", + "alert_rules", + "latency_tasks", + "admin_2fa", + "exchange_rates", + "themes", + "servers", + "settings", +]; + +const POSTGRES_RESTORE_LOCK: &str = "LOCK TABLE settings, servers, metric_history, \ + server_latest_state, admin_2fa, remote_tasks, exchange_rates, sessions, dashboard_proofs, \ + server_traffic_state, latency_tasks, latency_task_servers, latency_results, alert_rules, \ + alert_rule_servers, alert_states, notification_telegram, themes, theme_previews \ + IN ACCESS EXCLUSIVE MODE"; + +pub struct DatabaseArchive { + pub file: File, + pub filename: String, + pub size: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +struct BackupManifest { + format: String, + version: u32, + created_at: i64, + source: String, + tables: Vec, + excluded_ephemeral_tables: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum BackupKind { + Bool, + SmallInt, + Integer, + BigInt, + Real, + Double, + Text, + Blob, +} + +impl BackupKind { + fn from_any(kind: AnyTypeInfoKind) -> Result { + Ok(match kind { + AnyTypeInfoKind::Bool => Self::Bool, + AnyTypeInfoKind::SmallInt => Self::SmallInt, + AnyTypeInfoKind::Integer => Self::Integer, + AnyTypeInfoKind::BigInt => Self::BigInt, + AnyTypeInfoKind::Real => Self::Real, + AnyTypeInfoKind::Double => Self::Double, + AnyTypeInfoKind::Text => Self::Text, + AnyTypeInfoKind::Blob => Self::Blob, + AnyTypeInfoKind::Null => bail!("数据库列类型不能为 NULL"), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct BackupColumn { + name: String, + kind: BackupKind, +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +struct BackupTableHeader { + table: String, + columns: Vec, +} + +fn table_entry_name(table: &str) -> String { + format!("tables/{table}.ndjson") +} + +async fn table_columns(db: &Database, table: &str) -> Result> { + let sql = format!("SELECT * FROM {table} LIMIT 0"); + // `table` only comes from BACKUP_TABLES. No request data enters this SQL. + let description = db + .pool() + .describe(AssertSqlSafe(sql).into_sql_str()) + .await?; + description + .columns() + .iter() + .map(|column| { + Ok(BackupColumn { + name: column.name().to_string(), + kind: BackupKind::from_any(column.type_info().kind())?, + }) + }) + .collect() +} + +async fn database_schema(db: &Database) -> Result>> { + let mut schema = Vec::with_capacity(BACKUP_TABLES.len()); + for table in BACKUP_TABLES { + schema.push(table_columns(db, table).await?); + } + Ok(schema) +} + +fn finite_json_number(value: f64) -> Result { + Number::from_f64(value) + .map(Value::Number) + .context("数据库包含无法备份的非有限浮点数") +} + +fn row_values(row: &AnyRow, columns: &[BackupColumn]) -> Result> { + columns + .iter() + .enumerate() + .map(|(index, column)| { + Ok(match column.kind { + BackupKind::Bool => row + .try_get::, _>(index)? + .map(Value::Bool) + .unwrap_or(Value::Null), + BackupKind::SmallInt => row + .try_get::, _>(index)? + .map(|value| Value::Number(i64::from(value).into())) + .unwrap_or(Value::Null), + BackupKind::Integer => row + .try_get::, _>(index)? + .map(|value| Value::Number(i64::from(value).into())) + .unwrap_or(Value::Null), + BackupKind::BigInt => row + .try_get::, _>(index)? + .map(|value| Value::Number(value.into())) + .unwrap_or(Value::Null), + BackupKind::Real => match row.try_get::, _>(index)? { + Some(value) => finite_json_number(f64::from(value))?, + None => Value::Null, + }, + BackupKind::Double => match row.try_get::, _>(index)? { + Some(value) => finite_json_number(value)?, + None => Value::Null, + }, + BackupKind::Text => row + .try_get::, _>(index)? + .map(Value::String) + .unwrap_or(Value::Null), + BackupKind::Blob => row + .try_get::>, _>(index)? + .map(|value| Value::String(hex::encode(value))) + .unwrap_or(Value::Null), + }) + }) + .collect() +} + +pub async fn export_archive(db: &Database) -> Result { + let schema = database_schema(db).await?; + let created_at = crate::db::now(); + let manifest = BackupManifest { + format: DATABASE_BACKUP_FORMAT.to_string(), + version: DATABASE_BACKUP_VERSION, + created_at, + source: match db.kind() { + DatabaseKind::Sqlite => "sqlite", + DatabaseKind::Postgres => "postgresql", + } + .to_string(), + tables: BACKUP_TABLES + .iter() + .map(|table| (*table).to_string()) + .collect(), + excluded_ephemeral_tables: vec![ + "sessions".to_string(), + "dashboard_proofs".to_string(), + "theme_previews".to_string(), + ], + }; + + let mut transaction = db.pool().begin().await?; + if db.is_postgres() { + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY") + .execute(&mut *transaction) + .await?; + } + + let file = tempfile::tempfile().context("无法创建数据库备份临时文件")?; + let mut writer = ZipWriter::new(file); + let options = SimpleFileOptions::default() + .compression_method(CompressionMethod::Deflated) + .unix_permissions(0o600); + writer.start_file("manifest.json", options)?; + serde_json::to_writer(&mut writer, &manifest)?; + writer.write_all(b"\n")?; + + for ((table, columns), index) in BACKUP_TABLES.iter().zip(schema.iter()).zip(0..) { + writer.start_file(table_entry_name(table), options)?; + serde_json::to_writer( + &mut writer, + &BackupTableHeader { + table: (*table).to_string(), + columns: columns.clone(), + }, + )?; + writer.write_all(b"\n")?; + + let sql = format!("SELECT * FROM {table}"); + // `table` only comes from BACKUP_TABLES. No request data enters this SQL. + let mut rows = sqlx::query(AssertSqlSafe(sql)).fetch(&mut *transaction); + while let Some(row) = rows.try_next().await? { + serde_json::to_writer(&mut writer, &row_values(&row, columns)?)?; + writer.write_all(b"\n")?; + } + drop(rows); + tracing::debug!(table, index, "database backup table exported"); + } + + transaction.commit().await?; + let mut file = writer.finish()?; + let size = file.metadata()?.len(); + file.seek(SeekFrom::Start(0))?; + Ok(DatabaseArchive { + file, + filename: format!("nodeflare-database-{created_at}.zip"), + size, + }) +} + +fn validate_archive(archive: &mut ZipArchive) -> Result { + if archive.is_empty() || archive.len() > DATABASE_BACKUP_MAX_ENTRIES { + bail!("备份 ZIP 文件条目数量无效"); + } + let mut expected = BACKUP_TABLES + .iter() + .map(|table| table_entry_name(table)) + .collect::>(); + expected.insert("manifest.json".to_string()); + let mut seen = HashSet::new(); + let mut extracted_bytes = 0_u64; + for index in 0..archive.len() { + let entry = archive.by_index(index)?; + let name = entry + .enclosed_name() + .and_then(|path| path.to_str().map(str::to_string)) + .context("备份 ZIP 包含非法路径")?; + if entry.is_dir() || !expected.contains(&name) || !seen.insert(name) { + bail!("备份 ZIP 文件结构无效"); + } + extracted_bytes = extracted_bytes + .checked_add(entry.size()) + .context("备份 ZIP 解压大小溢出")?; + if extracted_bytes > DATABASE_BACKUP_MAX_EXTRACTED_BYTES { + bail!("备份 ZIP 解压后不能超过 4 GiB"); + } + } + if seen != expected { + bail!("备份 ZIP 缺少必要文件"); + } + + let mut entry = archive.by_name("manifest.json")?; + if entry.size() > 64 * 1024 { + bail!("备份清单过大"); + } + let mut content = String::new(); + entry.read_to_string(&mut content)?; + let manifest: BackupManifest = serde_json::from_str(&content).context("备份清单格式无效")?; + if manifest.format != DATABASE_BACKUP_FORMAT + || manifest.version != DATABASE_BACKUP_VERSION + || manifest.tables + != BACKUP_TABLES + .iter() + .map(|table| (*table).to_string()) + .collect::>() + { + bail!("不是受支持的 NodeFlare 数据库备份"); + } + Ok(manifest) +} + +fn add_argument(arguments: &mut AnyArguments, kind: BackupKind, value: &Value) -> Result<()> { + let result = match kind { + BackupKind::Bool => arguments.add(if value.is_null() { + None + } else { + Some(value.as_bool().context("布尔字段格式无效")?) + }), + BackupKind::SmallInt => arguments.add(if value.is_null() { + None + } else { + Some(i16::try_from(value.as_i64().context("整数字段格式无效")?)?) + }), + BackupKind::Integer => arguments.add(if value.is_null() { + None + } else { + Some(i32::try_from(value.as_i64().context("整数字段格式无效")?)?) + }), + BackupKind::BigInt => arguments.add(if value.is_null() { + None + } else { + Some(value.as_i64().context("整数字段格式无效")?) + }), + BackupKind::Real => arguments.add(if value.is_null() { + None + } else { + let value = value.as_f64().context("浮点字段格式无效")? as f32; + if !value.is_finite() { + bail!("浮点字段超出范围"); + } + Some(value) + }), + BackupKind::Double => arguments.add(if value.is_null() { + None + } else { + let value = value.as_f64().context("浮点字段格式无效")?; + if !value.is_finite() { + bail!("浮点字段超出范围"); + } + Some(value) + }), + BackupKind::Text => arguments.add(if value.is_null() { + None + } else { + Some(value.as_str().context("文本字段格式无效")?.to_string()) + }), + BackupKind::Blob => arguments.add(if value.is_null() { + None + } else { + Some(hex::decode(value.as_str().context("二进制字段格式无效")?)?) + }), + }; + result.map_err(|error| anyhow::anyhow!("无法编码数据库备份字段:{error}")) +} + +async fn insert_rows( + db: &Database, + transaction: &mut sqlx::Transaction<'_, sqlx::Any>, + table: &str, + columns: &[BackupColumn], + rows: &[Vec], +) -> Result<()> { + if rows.is_empty() { + return Ok(()); + } + let mut parameter_index = 1_usize; + let mut placeholders = Vec::with_capacity(rows.len()); + let mut arguments = AnyArguments::default(); + for row in rows { + if row.len() != columns.len() { + bail!("{table} 备份行字段数量不匹配"); + } + let mut row_placeholders = Vec::with_capacity(columns.len()); + for (column, value) in columns.iter().zip(row) { + row_placeholders.push(if db.is_postgres() { + let placeholder = format!("${parameter_index}"); + parameter_index += 1; + placeholder + } else { + "?".to_string() + }); + add_argument(&mut arguments, column.kind, value)?; + } + placeholders.push(format!("({})", row_placeholders.join(","))); + } + let column_names = columns + .iter() + .map(|column| column.name.as_str()) + .collect::>() + .join(","); + let sql = format!( + "INSERT INTO {table} ({column_names}) VALUES {}", + placeholders.join(",") + ); + // Table and column names were matched against the current database schema. + sqlx::query_with(AssertSqlSafe(sql), arguments) + .execute(&mut **transaction) + .await?; + Ok(()) +} + +fn extract_table_entry(archive: &mut ZipArchive, table: &str) -> Result { + let mut entry = archive.by_name(&table_entry_name(table))?; + let mut extracted = tempfile::tempfile().context("无法创建恢复临时文件")?; + std::io::copy(&mut entry, &mut extracted)?; + extracted.seek(SeekFrom::Start(0))?; + Ok(extracted) +} + +async fn restore_table( + archive: &mut ZipArchive, + db: &Database, + transaction: &mut sqlx::Transaction<'_, sqlx::Any>, + table: &str, + columns: &[BackupColumn], +) -> Result { + let mut reader = BufReader::new(extract_table_entry(archive, table)?); + let mut line = String::new(); + if reader.read_line(&mut line)? == 0 || line.len() > MAX_NDJSON_LINE_BYTES { + bail!("{table} 备份表头无效"); + } + let header: BackupTableHeader = serde_json::from_str(line.trim_end())?; + if header.table != table || header.columns != columns { + bail!("{table} 备份结构与当前数据库不兼容"); + } + + let mut restored = 0_usize; + let mut batch = Vec::with_capacity(INSERT_BATCH_ROWS); + loop { + line.clear(); + let read = reader.read_line(&mut line)?; + if read == 0 { + break; + } + if line.len() > MAX_NDJSON_LINE_BYTES { + bail!("{table} 备份记录过大"); + } + let trimmed = line.trim_end(); + if trimmed.is_empty() { + continue; + } + batch.push(serde_json::from_str::>(trimmed)?); + if batch.len() >= INSERT_BATCH_ROWS { + insert_rows(db, transaction, table, columns, &batch).await?; + restored += batch.len(); + batch.clear(); + } + } + if !batch.is_empty() { + insert_rows(db, transaction, table, columns, &batch).await?; + restored += batch.len(); + } + Ok(restored) +} + +pub async fn restore_archive(db: &Database, archive: &[u8]) -> Result { + if archive.is_empty() || archive.len() > DATABASE_BACKUP_MAX_BYTES { + bail!("数据库备份 ZIP 大小无效"); + } + let mut archive = ZipArchive::new(Cursor::new(archive)).context("文件不是有效的 ZIP")?; + let _manifest = validate_archive(&mut archive)?; + let schema = database_schema(db).await?; + + let mut transaction = db.pool().begin().await?; + if db.is_postgres() { + sqlx::query(POSTGRES_RESTORE_LOCK) + .execute(&mut *transaction) + .await?; + } + for table in CLEAR_TABLES { + let sql = format!("DELETE FROM {table}"); + // `table` only comes from CLEAR_TABLES. No request data enters this SQL. + sqlx::query(AssertSqlSafe(sql)) + .execute(&mut *transaction) + .await?; + } + + let mut restored = 0_usize; + for (table, columns) in BACKUP_TABLES.iter().zip(schema.iter()) { + restored += restore_table(&mut archive, db, &mut transaction, table, columns).await?; + } + let required_settings = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM settings WHERE key IN \ + ('admin_username','admin_password_hash','password_client_salt','password_scheme')", + ) + .fetch_one(&mut *transaction) + .await?; + if required_settings != 4 { + bail!("备份缺少管理员登录设置"); + } + sqlx::query(db.sql( + "UPDATE remote_tasks SET status='failed', completed_at=?, \ + result=CASE WHEN result='' THEN '数据库恢复后已取消未完成任务' ELSE result END \ + WHERE status IN ('pending','sent','running')", + )) + .bind(crate::db::now()) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(restored) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn sqlite_backup_round_trip_restores_data_and_clears_sessions() { + let db = crate::db::connect("sqlite::memory:").await.unwrap(); + db.migrate().await.unwrap(); + sqlx::query( + "INSERT INTO settings(key, value) VALUES \ + ('site_name', 'Before backup'), \ + ('admin_username', 'admin'), \ + ('admin_password_hash', 'hash'), \ + ('password_client_salt', 'salt'), \ + ('password_scheme', 'argon2-client-pbkdf2-v1')", + ) + .execute(db.pool()) + .await + .unwrap(); + + let mut exported = export_archive(&db).await.unwrap(); + let mut bytes = Vec::new(); + exported.file.read_to_end(&mut bytes).unwrap(); + + sqlx::query("UPDATE settings SET value='After backup' WHERE key='site_name'") + .execute(db.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO sessions(\ + id, token_hash, username, ip_address, user_agent, created_at, last_seen_at, expires_at\ + ) VALUES ('session-id', 'session', 'admin', '127.0.0.1', 'test', 1, 1, 9999999999)", + ) + .execute(db.pool()) + .await + .unwrap(); + + assert_eq!(restore_archive(&db, &bytes).await.unwrap(), 5); + let site_name = + sqlx::query_scalar::<_, String>("SELECT value FROM settings WHERE key='site_name'") + .fetch_one(db.pool()) + .await + .unwrap(); + let sessions = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sessions") + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(site_name, "Before backup"); + assert_eq!(sessions, 0); + } + + #[tokio::test] + async fn invalid_backup_rolls_back_existing_database() { + let db = crate::db::connect("sqlite::memory:").await.unwrap(); + db.migrate().await.unwrap(); + sqlx::query( + "INSERT INTO settings(key, value) VALUES \ + ('site_name', 'Invalid backup'), \ + ('admin_username', 'admin')", + ) + .execute(db.pool()) + .await + .unwrap(); + + let mut exported = export_archive(&db).await.unwrap(); + let mut bytes = Vec::new(); + exported.file.read_to_end(&mut bytes).unwrap(); + + sqlx::query("UPDATE settings SET value='Current database' WHERE key='site_name'") + .execute(db.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO sessions(\ + id, token_hash, username, ip_address, user_agent, created_at, last_seen_at, expires_at\ + ) VALUES ('session-id', 'session', 'admin', '127.0.0.1', 'test', 1, 1, 9999999999)", + ) + .execute(db.pool()) + .await + .unwrap(); + + assert!(restore_archive(&db, &bytes).await.is_err()); + let site_name = + sqlx::query_scalar::<_, String>("SELECT value FROM settings WHERE key='site_name'") + .fetch_one(db.pool()) + .await + .unwrap(); + let sessions = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sessions") + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(site_name, "Current database"); + assert_eq!(sessions, 1); + } +} diff --git a/backend/src/config.rs b/backend/src/config.rs index ee7469a..4d00916 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -50,7 +50,7 @@ pub struct Config { } fn default_database_url() -> String { - "sqlite:///opt/nodeflare/data/server/nodeflare.db".to_string() + "sqlite:///etc/nodeflare/nodeflare.db".to_string() } fn default_bind_addr() -> String { @@ -70,7 +70,7 @@ fn default_agent_dir() -> PathBuf { } fn default_theme_dir() -> PathBuf { - PathBuf::from("/opt/nodeflare/data/server/themes") + PathBuf::from("/etc/nodeflare/themes") } fn default_session_hours() -> i64 { @@ -135,11 +135,7 @@ fn resolve_database_url(base: &Path, value: &str) -> Result { return Ok(value.to_string()); } if value.starts_with("sqlite:///") { - return Ok(if value.contains('?') { - value.to_string() - } else { - format!("{value}?mode=rwc") - }); + return Ok(value.to_string()); } let Some(relative) = value.strip_prefix("sqlite://") else { anyhow::bail!("database_url must use sqlite://, postgres://, or postgresql://"); @@ -153,7 +149,7 @@ fn resolve_database_url(base: &Path, value: &str) -> Result { .to_str() .context("SQLite database path is not valid UTF-8")?; Ok(if query.is_empty() { - format!("sqlite://{resolved}?mode=rwc") + format!("sqlite://{resolved}") } else { format!("sqlite://{resolved}?{query}") }) @@ -175,7 +171,7 @@ mod tests { assert_eq!(args.config, Path::new("/etc/nodeflare/config.toml")); assert_eq!( default_database_url(), - "sqlite:///opt/nodeflare/data/server/nodeflare.db" + "sqlite:///etc/nodeflare/nodeflare.db" ); assert_eq!(default_bind_addr(), "127.0.0.1:8080"); assert_eq!( @@ -187,10 +183,7 @@ mod tests { Path::new("/opt/nodeflare/share/admin") ); assert_eq!(default_agent_dir(), Path::new("/opt/nodeflare/share/agent")); - assert_eq!( - default_theme_dir(), - Path::new("/opt/nodeflare/data/server/themes") - ); + assert_eq!(default_theme_dir(), Path::new("/etc/nodeflare/themes")); } #[test] @@ -198,15 +191,23 @@ mod tests { assert_eq!( resolve_database_url(Path::new("/srv/nodeflare/backend"), "sqlite://nodeflare.db") .unwrap(), - "sqlite:///srv/nodeflare/backend/nodeflare.db?mode=rwc" + "sqlite:///srv/nodeflare/backend/nodeflare.db" ); } #[test] - fn accepts_postgres_urls() { + fn accepts_database_url_examples() { assert_eq!( - resolve_database_url(Path::new("."), "postgres://localhost/nodeflare").unwrap(), - "postgres://localhost/nodeflare" + resolve_database_url(Path::new("."), "sqlite:///etc/nodeflare/nodeflare.db").unwrap(), + "sqlite:///etc/nodeflare/nodeflare.db" + ); + assert_eq!( + resolve_database_url( + Path::new("."), + "postgres://nodeflare:password@127.0.0.1:5432/nodeflare?sslmode=prefer", + ) + .unwrap(), + "postgres://nodeflare:password@127.0.0.1:5432/nodeflare?sslmode=prefer" ); } diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 94ba164..7b5a9f7 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -2,16 +2,21 @@ pub mod queries; use crate::auth; use crate::config::Config; -use crate::models::{PublicConfig, SettingsInput, SettingsView}; -use anyhow::Result; +use crate::models::{LoginSessionView, PublicConfig, SettingsInput, SettingsView}; +use anyhow::{Context, Result}; use sqlx::any::{AnyPoolOptions, install_default_drivers}; use sqlx::migrate::Migrator; +use sqlx::sqlite::SqliteConnectOptions; use sqlx::{AnyPool, Row}; use std::collections::HashMap; +use std::fs::OpenOptions; +use std::str::FromStr; use std::sync::{Mutex, OnceLock}; +use std::time::Duration; pub const SECRET_MASK: &str = "********"; const PASSWORD_SCHEME: &str = "argon2-client-pbkdf2-v1"; +const SESSION_ACTIVITY_WRITE_INTERVAL_SECONDS: i64 = 60; static SQLITE_MIGRATOR: Migrator = sqlx::migrate!("./migrations/sqlite"); static POSTGRES_MIGRATOR: Migrator = sqlx::migrate!("./migrations/postgres"); @@ -28,6 +33,18 @@ pub struct Database { kind: DatabaseKind, } +#[derive(Debug, Clone)] +pub struct SessionDevice { + pub ip_address: String, + pub user_agent: String, +} + +#[derive(Debug, Clone)] +pub struct SessionIdentity { + pub id: String, + pub username: String, +} + impl Database { pub fn pool(&self) -> &AnyPool { &self.pool @@ -41,6 +58,16 @@ impl Database { self.kind == DatabaseKind::Postgres } + pub async fn optimize(&self) -> Result<()> { + if self.kind == DatabaseKind::Sqlite { + sqlx::query("PRAGMA optimize").execute(&self.pool).await?; + sqlx::query("PRAGMA incremental_vacuum(256)") + .execute(&self.pool) + .await?; + } + Ok(()) + } + pub fn sql(&self, statement: &'static str) -> &'static str { if self.kind == DatabaseKind::Sqlite { return statement; @@ -181,24 +208,68 @@ pub async fn connect(database_url: &str) -> Result { } else { anyhow::bail!("unsupported database URL"); }; - let pool = AnyPoolOptions::new() - .max_connections(if kind == DatabaseKind::Sqlite { 1 } else { 10 }) - .connect(database_url) - .await?; - if kind == DatabaseKind::Sqlite { - sqlx::query("PRAGMA foreign_keys = ON") - .execute(&pool) - .await?; - sqlx::query("PRAGMA journal_mode = WAL") - .execute(&pool) - .await?; - sqlx::query("PRAGMA busy_timeout = 10000") - .execute(&pool) - .await?; + let sqlite_in_memory = + kind == DatabaseKind::Sqlite && database_url.trim_start().starts_with("sqlite::memory:"); + if kind == DatabaseKind::Sqlite && !sqlite_in_memory { + ensure_sqlite_file(database_url)?; } + let max_connections = match kind { + DatabaseKind::Sqlite if sqlite_in_memory => 1, + DatabaseKind::Sqlite => 4, + DatabaseKind::Postgres => 10, + }; + let mut options = AnyPoolOptions::new() + .min_connections(1) + .max_connections(max_connections) + .acquire_timeout(Duration::from_secs(10)); + if kind == DatabaseKind::Sqlite { + options = options.after_connect(|connection, _| { + Box::pin(async move { + for statement in [ + "PRAGMA auto_vacuum = INCREMENTAL", + "PRAGMA foreign_keys = ON", + "PRAGMA journal_mode = WAL", + "PRAGMA synchronous = NORMAL", + "PRAGMA busy_timeout = 10000", + ] { + sqlx::query(statement).execute(&mut *connection).await?; + } + Ok(()) + }) + }); + } + let pool = options.connect(database_url).await?; Ok(Database { pool, kind }) } +fn ensure_sqlite_file(database_url: &str) -> Result<()> { + let has_mode = database_url + .split_once('?') + .is_some_and(|(_, query)| query.split('&').any(|item| item.starts_with("mode="))); + if has_mode { + return Ok(()); + } + let options = + SqliteConnectOptions::from_str(database_url).context("无法解析 SQLite 数据库 URL")?; + let path = options.get_filename(); + if path.exists() { + return Ok(()); + } + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + && !parent.is_dir() + { + anyhow::bail!("SQLite 数据库目录不存在:{}", parent.display()); + } + OpenOptions::new() + .create_new(true) + .write(true) + .open(path) + .with_context(|| format!("无法创建 SQLite 数据库文件:{}", path.display()))?; + Ok(()) +} + pub async fn initialize(pool: &Database, config: &Config) -> Result<()> { let now = now(); let salt = get_setting(pool, "password_client_salt") @@ -468,15 +539,27 @@ pub async fn set_setting(pool: &Database, key: &str, value: &str) -> Result<()> Ok(()) } -pub async fn create_session(pool: &Database, username: &str, ttl_hours: i64) -> Result { +pub async fn create_session( + pool: &Database, + username: &str, + ttl_hours: i64, + device: &SessionDevice, +) -> Result { + let id = uuid::Uuid::new_v4().to_string(); let token = auth::random_token(32); let created_at = now(); let expires_at = created_at.saturating_add(ttl_hours.clamp(1, 24 * 90) * 3600); sqlx::query(pool.sql( - "INSERT INTO sessions(token_hash, username, created_at, expires_at) VALUES (?, ?, ?, ?)", + "INSERT INTO sessions(\ + id, token_hash, username, ip_address, user_agent, created_at, last_seen_at, expires_at\ + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", )) + .bind(id) .bind(auth::token_hash(&token)) .bind(username) + .bind(&device.ip_address) + .bind(&device.user_agent) + .bind(created_at) .bind(created_at) .bind(expires_at) .execute(pool.pool()) @@ -484,18 +567,84 @@ pub async fn create_session(pool: &Database, username: &str, ttl_hours: i64) -> Ok(token) } -pub async fn session_username(pool: &Database, token: &str) -> Result> { +pub async fn session_identity(pool: &Database, token: &str) -> Result> { if token.is_empty() || token.len() > 512 { return Ok(None); } let current = now(); - Ok(sqlx::query_scalar::<_, String>( - pool.sql("SELECT username FROM sessions WHERE token_hash = ? AND expires_at > ?"), - ) + let row = sqlx::query(pool.sql( + "SELECT id, username, last_seen_at FROM sessions \ + WHERE token_hash=? AND expires_at>?", + )) .bind(auth::token_hash(token)) .bind(current) .fetch_optional(pool.pool()) - .await?) + .await?; + let Some(row) = row else { + return Ok(None); + }; + let id: String = row.try_get("id")?; + let username: String = row.try_get("username")?; + let last_seen_at: i64 = row.try_get("last_seen_at")?; + if current.saturating_sub(last_seen_at) >= SESSION_ACTIVITY_WRITE_INTERVAL_SECONDS { + sqlx::query(pool.sql("UPDATE sessions SET last_seen_at=? WHERE id=? AND last_seen_at=?")) + .bind(current) + .bind(&id) + .bind(last_seen_at) + .execute(pool.pool()) + .await?; + } + Ok(Some(SessionIdentity { id, username })) +} + +pub async fn session_username(pool: &Database, token: &str) -> Result> { + Ok(session_identity(pool, token) + .await? + .map(|identity| identity.username)) +} + +pub async fn login_sessions( + pool: &Database, + username: &str, + current_session_id: &str, +) -> Result> { + let rows = sqlx::query(pool.sql( + "SELECT id, ip_address, user_agent, created_at, last_seen_at, expires_at \ + FROM sessions WHERE username=? AND expires_at>? \ + ORDER BY last_seen_at DESC, created_at DESC", + )) + .bind(username) + .bind(now()) + .fetch_all(pool.pool()) + .await?; + rows.into_iter() + .map(|row| { + let id: String = row.try_get("id")?; + Ok(LoginSessionView { + current: id == current_session_id, + id, + ip_address: row.try_get("ip_address")?, + user_agent: row.try_get("user_agent")?, + created_at: row.try_get("created_at")?, + last_seen_at: row.try_get("last_seen_at")?, + expires_at: row.try_get("expires_at")?, + }) + }) + .collect::, sqlx::Error>>() + .map_err(Into::into) +} + +pub async fn revoke_login_session( + pool: &Database, + username: &str, + session_id: &str, +) -> Result { + let result = sqlx::query(pool.sql("DELETE FROM sessions WHERE id=? AND username=?")) + .bind(session_id) + .bind(username) + .execute(pool.pool()) + .await?; + Ok(result.rows_affected() > 0) } pub async fn revoke_session(pool: &Database, token: &str) -> Result<()> { @@ -581,3 +730,76 @@ fn mask_secret(value: &str) -> String { SECRET_MASK.to_string() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn sqlite_file_pool_configures_every_connection() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("nodeflare.db"); + let database_url = format!("sqlite://{}", path.display()); + let db = connect(&database_url).await.unwrap(); + assert!(path.is_file()); + db.migrate().await.unwrap(); + + let mut connections = Vec::new(); + for _ in 0..4 { + connections.push(db.pool().acquire().await.unwrap()); + } + for connection in &mut connections { + let foreign_keys = sqlx::query_scalar::<_, i64>("PRAGMA foreign_keys") + .fetch_one(&mut **connection) + .await + .unwrap(); + let synchronous = sqlx::query_scalar::<_, i64>("PRAGMA synchronous") + .fetch_one(&mut **connection) + .await + .unwrap(); + let busy_timeout = sqlx::query_scalar::<_, i64>("PRAGMA busy_timeout") + .fetch_one(&mut **connection) + .await + .unwrap(); + assert_eq!(foreign_keys, 1); + assert_eq!(synchronous, 1); + assert_eq!(busy_timeout, 10_000); + } + drop(connections); + + let journal_mode = sqlx::query_scalar::<_, String>("PRAGMA journal_mode") + .fetch_one(db.pool()) + .await + .unwrap(); + let auto_vacuum = sqlx::query_scalar::<_, i64>("PRAGMA auto_vacuum") + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(journal_mode, "wal"); + assert_eq!(auto_vacuum, 2); + } + + #[tokio::test] + async fn login_sessions_can_be_listed_and_revoked() { + let db = connect("sqlite::memory:").await.unwrap(); + db.migrate().await.unwrap(); + let device = SessionDevice { + ip_address: "203.0.113.8".to_string(), + user_agent: "Test Browser".to_string(), + }; + let token = create_session(&db, "admin", 24, &device).await.unwrap(); + let identity = session_identity(&db, &token).await.unwrap().unwrap(); + let sessions = login_sessions(&db, "admin", &identity.id).await.unwrap(); + + assert_eq!(sessions.len(), 1); + assert!(sessions[0].current); + assert_eq!(sessions[0].ip_address, "203.0.113.8"); + assert_eq!(sessions[0].user_agent, "Test Browser"); + assert!( + revoke_login_session(&db, "admin", &identity.id) + .await + .unwrap() + ); + assert!(session_identity(&db, &token).await.unwrap().is_none()); + } +} diff --git a/backend/src/db/queries.rs b/backend/src/db/queries.rs index 61abad6..5752d68 100644 --- a/backend/src/db/queries.rs +++ b/backend/src/db/queries.rs @@ -6,9 +6,19 @@ use crate::models::{ ServerInput, ServerView, TelegramSettingsInput, TelegramSettingsView, ThemeInput, ThemeView, }; use anyhow::{Context, Result}; -use sqlx::{Any, Row, Transaction}; +use sqlx::any::AnyArguments; +use sqlx::{Any, Arguments, AssertSqlSafe, Row, Transaction}; use std::collections::{HashMap, HashSet}; +const REMOTE_TASK_ACTIVE_TTL_SECONDS: i64 = 24 * 60 * 60; +const AGENT_REPORT_MAX_AGE_SECONDS: i64 = 2 * 60 * 60; +const AGENT_REPORT_MAX_BYTES: usize = 256 * 1024; +const AGENT_REPORT_MAX_DISKS: usize = 128; +const AGENT_REPORT_MAX_GPUS: usize = 32; +const AGENT_REPORT_MAX_LATENCY_RESULTS: usize = 2048; +const HISTORY_INSERT_BATCH_ROWS: usize = 500; +const LATENCY_INSERT_BATCH_ROWS: usize = 500; + #[derive(Debug, Clone)] pub struct AgentIdentity { pub server_id: String, @@ -152,11 +162,12 @@ pub async fn all_server_ids(db: &Database) -> Result> { pub async fn create_server(db: &Database, input: &ServerInput) -> Result<(String, String)> { let id = uuid::Uuid::new_v4().to_string(); let token = auth::random_token(32); + let token_hash = auth::token_hash(&token); let timestamp = now(); let mut transaction = db.pool().begin().await?; sqlx::query(db.sql( "INSERT INTO servers( \ - id, token, name, region, group_name, tags, hidden, expires_at, traffic_limit, \ + id, token_hash, name, region, group_name, tags, hidden, expires_at, traffic_limit, \ traffic_limit_type, price, billing_cycle, currency, auto_renewal, last_ip, ip_v4, ip_v6, \ network_interface, reset_day, report_interval, collect_interval, rx_correction, \ tx_correction, agent_mirror, offline_notify_disabled, auto_update, created_at, updated_at, \ @@ -164,7 +175,7 @@ pub async fn create_server(db: &Database, input: &ServerInput) -> Result<(String ?, ?, ?, ?, ?, ?, (SELECT COALESCE(MAX(sort_order), -1) + 1 FROM servers))", )) .bind(&id) - .bind(&token) + .bind(&token_hash) .bind(input.name.trim()) .bind(input.region.trim()) .bind(input.group_name.trim()) @@ -273,9 +284,20 @@ pub async fn reorder_servers(db: &Database, ids: &[String]) -> Result<()> { Ok(()) } -pub async fn server_token(db: &Database, id: &str) -> Result> { +pub async fn rotate_server_token(db: &Database, id: &str) -> Result> { + let token = auth::random_token(32); + let result = sqlx::query(db.sql("UPDATE servers SET token_hash=?, updated_at=? WHERE id=?")) + .bind(auth::token_hash(&token)) + .bind(now()) + .bind(id) + .execute(db.pool()) + .await?; + Ok((result.rows_affected() > 0).then_some(token)) +} + +pub async fn server_name(db: &Database, id: &str) -> Result> { Ok( - sqlx::query_scalar::<_, String>(db.sql("SELECT token FROM servers WHERE id=?")) + sqlx::query_scalar::<_, String>(db.sql("SELECT name FROM servers WHERE id=?")) .bind(id) .fetch_optional(db.pool()) .await?, @@ -285,9 +307,9 @@ pub async fn server_token(db: &Database, id: &str) -> Result> { pub async fn agent_identity(db: &Database, token: &str) -> Result> { let row = sqlx::query(db.sql( "SELECT id, hidden, report_interval, collect_interval, reset_day, rx_correction, \ - tx_correction FROM servers WHERE token=?", + tx_correction FROM servers WHERE token_hash=?", )) - .bind(token) + .bind(auth::token_hash(token)) .fetch_optional(db.pool()) .await?; row.map(|row| { @@ -340,8 +362,9 @@ pub async fn save_agent_batch( let current = now(); reports.retain(|report| { report.timestamp > 0 - && report.timestamp >= current - 7 * 86_400 + && report.timestamp >= current - AGENT_REPORT_MAX_AGE_SECONDS && report.timestamp <= current + 300 + && valid_agent_report(report, current) }); if reports.is_empty() { anyhow::bail!("report batch contains no valid samples"); @@ -382,15 +405,9 @@ pub async fn save_agent_batch( let mut traffic = load_traffic_state(db, &mut transaction, &identity.server_id).await?; for report in &mut reports { apply_traffic(report, &mut traffic, identity); - save_history_row(db, &mut transaction, &identity.server_id, report).await?; - save_latency_rows( - db, - &mut transaction, - &identity.server_id, - &report.latency_results, - ) - .await?; } + save_history_rows(db, &mut transaction, &identity.server_id, &reports).await?; + save_latency_rows(db, &mut transaction, &identity.server_id, &reports).await?; let latest = reports.last().context("report batch became empty")?; let persisted_through = latest.timestamp; let latest_json = serde_json::to_string(latest)?; @@ -439,6 +456,96 @@ pub async fn save_agent_batch( }) } +fn valid_agent_report(report: &AgentReport, current: i64) -> bool { + fn finite_between(value: f64, minimum: f64, maximum: f64) -> bool { + value.is_finite() && (minimum..=maximum).contains(&value) + } + + fn valid_capacity(used: i64, total: i64) -> bool { + used >= 0 && total >= 0 && (total == 0 || used <= total) + } + + if !finite_between(report.cpu, 0.0, 100.0) + || !finite_between(report.load1, 0.0, 1_000_000.0) + || !finite_between(report.load5, 0.0, 1_000_000.0) + || !finite_between(report.load15, 0.0, 1_000_000.0) + || !valid_capacity(report.mem_used, report.mem_total) + || !valid_capacity(report.swap_used, report.swap_total) + || !valid_capacity(report.disk_used, report.disk_total) + || !finite_between(report.net_in, 0.0, 1.0e18) + || !finite_between(report.net_out, 0.0, 1.0e18) + || report.net_rx_total < 0 + || report.net_tx_total < 0 + || report.uptime < 0 + || report.processes < 0 + || report.tcp_connections < 0 + || report.udp_connections < 0 + || !(0..=1_000_000).contains(&report.cpu_cores) + || !finite_between(report.gpu_usage, 0.0, 100.0) + || !finite_between(report.disk_read_bps, 0.0, 1.0e18) + || !finite_between(report.disk_write_bps, 0.0, 1.0e18) + || !finite_between(report.disk_read_iops, 0.0, 1.0e12) + || !finite_between(report.disk_write_iops, 0.0, 1.0e12) + || !finite_between(report.disk_await_ms, 0.0, 86_400_000.0) + || !finite_between(report.disk_utilization, 0.0, 100.0) + || report.cpu_model.len() > 512 + || report.os.len() > 128 + || report.kernel.len() > 256 + || report.arch.len() > 64 + || report.virtualization.len() > 128 + || report.gpu_model.len() > 512 + || report.agent_version.len() > 64 + || report + .ip_v4 + .parse::() + .is_err_and(|_| !report.ip_v4.is_empty()) + || report + .ip_v6 + .parse::() + .is_err_and(|_| !report.ip_v6.is_empty()) + || report.disks.len() > AGENT_REPORT_MAX_DISKS + || report.gpus.len() > AGENT_REPORT_MAX_GPUS + || report.latency_results.len() > AGENT_REPORT_MAX_LATENCY_RESULTS + { + return false; + } + + if report.disks.iter().any(|disk| { + disk.name.len() > 128 + || disk.mount_point.len() > 512 + || !valid_capacity(disk.used, disk.total) + || !finite_between(disk.read_bps, 0.0, 1.0e18) + || !finite_between(disk.write_bps, 0.0, 1.0e18) + || !finite_between(disk.read_iops, 0.0, 1.0e12) + || !finite_between(disk.write_iops, 0.0, 1.0e12) + || !finite_between(disk.await_ms, 0.0, 86_400_000.0) + || !finite_between(disk.utilization, 0.0, 100.0) + }) || report.gpus.iter().any(|gpu| { + gpu.model.len() > 512 + || gpu + .usage + .is_some_and(|usage| !finite_between(usage, 0.0, 100.0)) + || !valid_capacity(gpu.memory_used, gpu.memory_total) + }) { + return false; + } + + let mut latency_ids = HashSet::with_capacity(report.latency_results.len()); + if report.latency_results.iter().any(|latency| { + latency.task_id.is_empty() + || latency.task_id.len() > 80 + || !latency_ids.insert(&latency.task_id) + || latency.timestamp < current - AGENT_REPORT_MAX_AGE_SECONDS + || latency.timestamp > current + 300 + || !finite_between(latency.latency_ms, -1.0, 86_400_000.0) + || !finite_between(latency.packet_loss, -1.0, 100.0) + }) { + return false; + } + + serde_json::to_vec(report).is_ok_and(|encoded| encoded.len() <= AGENT_REPORT_MAX_BYTES) +} + async fn load_traffic_state( db: &Database, transaction: &mut Transaction<'_, Any>, @@ -529,59 +636,88 @@ async fn save_traffic_state( Ok(()) } -async fn save_history_row( +async fn save_history_rows( db: &Database, transaction: &mut Transaction<'_, Any>, server_id: &str, - report: &AgentReport, + reports: &[AgentReport], ) -> Result<()> { - sqlx::query(db.sql( - "INSERT INTO metric_history(server_id, timestamp, cpu, load1, load5, load15, mem_used, \ - mem_total, swap_used, swap_total, disk_used, disk_total, net_in, net_out, net_rx_total, \ - net_tx_total, uptime, processes, tcp_connections, udp_connections, gpu_usage, \ - disk_read_bps, disk_write_bps, disk_read_iops, disk_write_iops, disk_await_ms, \ - disk_utilization) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \ - ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(server_id, timestamp) DO UPDATE SET cpu=excluded.cpu, \ - load1=excluded.load1, load5=excluded.load5, load15=excluded.load15, \ - mem_used=excluded.mem_used, mem_total=excluded.mem_total, swap_used=excluded.swap_used, \ - swap_total=excluded.swap_total, disk_used=excluded.disk_used, disk_total=excluded.disk_total, \ - net_in=excluded.net_in, net_out=excluded.net_out, net_rx_total=excluded.net_rx_total, \ - net_tx_total=excluded.net_tx_total, uptime=excluded.uptime, processes=excluded.processes, \ - tcp_connections=excluded.tcp_connections, udp_connections=excluded.udp_connections, \ - gpu_usage=excluded.gpu_usage, disk_read_bps=excluded.disk_read_bps, \ - disk_write_bps=excluded.disk_write_bps, disk_read_iops=excluded.disk_read_iops, \ - disk_write_iops=excluded.disk_write_iops, disk_await_ms=excluded.disk_await_ms, \ - disk_utilization=excluded.disk_utilization", - )) - .bind(server_id) - .bind(report.timestamp) - .bind(report.cpu) - .bind(report.load1) - .bind(report.load5) - .bind(report.load15) - .bind(report.mem_used) - .bind(report.mem_total) - .bind(report.swap_used) - .bind(report.swap_total) - .bind(report.disk_used) - .bind(report.disk_total) - .bind(report.net_in) - .bind(report.net_out) - .bind(report.net_rx_total) - .bind(report.net_tx_total) - .bind(report.uptime) - .bind(report.processes) - .bind(report.tcp_connections) - .bind(report.udp_connections) - .bind(report.gpu_usage) - .bind(report.disk_read_bps) - .bind(report.disk_write_bps) - .bind(report.disk_read_iops) - .bind(report.disk_write_iops) - .bind(report.disk_await_ms) - .bind(report.disk_utilization) - .execute(&mut **transaction) - .await?; + for batch in reports.chunks(HISTORY_INSERT_BATCH_ROWS) { + let mut arguments = AnyArguments::default(); + let mut parameter_index = 1_usize; + let mut value_groups = Vec::with_capacity(batch.len()); + for report in batch { + let mut placeholders = Vec::with_capacity(27); + for _ in 0..27 { + placeholders.push(if db.is_postgres() { + let placeholder = format!("${parameter_index}"); + parameter_index += 1; + placeholder + } else { + "?".to_string() + }); + } + value_groups.push(format!("({})", placeholders.join(","))); + macro_rules! add { + ($value:expr) => { + arguments.add($value).map_err(|error| { + anyhow::anyhow!("无法编码历史指标数据库字段:{error}") + })? + }; + } + add!(server_id.to_string()); + add!(report.timestamp); + add!(report.cpu); + add!(report.load1); + add!(report.load5); + add!(report.load15); + add!(report.mem_used); + add!(report.mem_total); + add!(report.swap_used); + add!(report.swap_total); + add!(report.disk_used); + add!(report.disk_total); + add!(report.net_in); + add!(report.net_out); + add!(report.net_rx_total); + add!(report.net_tx_total); + add!(report.uptime); + add!(report.processes); + add!(report.tcp_connections); + add!(report.udp_connections); + add!(report.gpu_usage); + add!(report.disk_read_bps); + add!(report.disk_write_bps); + add!(report.disk_read_iops); + add!(report.disk_write_iops); + add!(report.disk_await_ms); + add!(report.disk_utilization); + } + let sql = format!( + "INSERT INTO metric_history(server_id, timestamp, cpu, load1, load5, load15, \ + mem_used, mem_total, swap_used, swap_total, disk_used, disk_total, net_in, net_out, \ + net_rx_total, net_tx_total, uptime, processes, tcp_connections, udp_connections, \ + gpu_usage, disk_read_bps, disk_write_bps, disk_read_iops, disk_write_iops, \ + disk_await_ms, disk_utilization) VALUES {} \ + ON CONFLICT(server_id, timestamp) DO UPDATE SET cpu=excluded.cpu, \ + load1=excluded.load1, load5=excluded.load5, load15=excluded.load15, \ + mem_used=excluded.mem_used, mem_total=excluded.mem_total, \ + swap_used=excluded.swap_used, swap_total=excluded.swap_total, \ + disk_used=excluded.disk_used, disk_total=excluded.disk_total, net_in=excluded.net_in, \ + net_out=excluded.net_out, net_rx_total=excluded.net_rx_total, \ + net_tx_total=excluded.net_tx_total, uptime=excluded.uptime, \ + processes=excluded.processes, tcp_connections=excluded.tcp_connections, \ + udp_connections=excluded.udp_connections, gpu_usage=excluded.gpu_usage, \ + disk_read_bps=excluded.disk_read_bps, disk_write_bps=excluded.disk_write_bps, \ + disk_read_iops=excluded.disk_read_iops, disk_write_iops=excluded.disk_write_iops, \ + disk_await_ms=excluded.disk_await_ms, disk_utilization=excluded.disk_utilization", + value_groups.join(",") + ); + // SQL identifiers are fixed above; only generated placeholders are interpolated. + sqlx::query_with(AssertSqlSafe(sql), arguments) + .execute(&mut **transaction) + .await?; + } Ok(()) } @@ -589,31 +725,64 @@ async fn save_latency_rows( db: &Database, transaction: &mut Transaction<'_, Any>, server_id: &str, - results: &[AgentLatencyResult], + reports: &[AgentReport], ) -> Result<()> { - for result in results { - if result.task_id.is_empty() - || result.timestamp <= 0 - || !result.latency_ms.is_finite() - || !result.packet_loss.is_finite() + let mut unique = HashMap::<(String, i64), AgentLatencyResult>::new(); + for result in reports.iter().flat_map(|report| &report.latency_results) { + if !result.task_id.is_empty() + && result.timestamp > 0 + && result.latency_ms.is_finite() + && result.packet_loss.is_finite() { - continue; + unique.insert((result.task_id.clone(), result.timestamp), result.clone()); } - sqlx::query(db.sql( - "INSERT INTO latency_results(task_id, server_id, timestamp, latency_ms, packet_loss) \ - SELECT ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM latency_task_servers \ - WHERE task_id=? AND server_id=?) ON CONFLICT(task_id, server_id, timestamp) \ - DO UPDATE SET latency_ms=excluded.latency_ms, packet_loss=excluded.packet_loss", - )) - .bind(&result.task_id) - .bind(server_id) - .bind(result.timestamp) - .bind(result.latency_ms) - .bind(result.packet_loss) - .bind(&result.task_id) - .bind(server_id) - .execute(&mut **transaction) - .await?; + } + let results = unique.into_values().collect::>(); + for batch in results.chunks(LATENCY_INSERT_BATCH_ROWS) { + let mut arguments = AnyArguments::default(); + let mut parameter_index = 1_usize; + let mut value_groups = Vec::with_capacity(batch.len()); + for result in batch { + let mut placeholders = Vec::with_capacity(5); + for _ in 0..5 { + placeholders.push(if db.is_postgres() { + let placeholder = format!("${parameter_index}"); + parameter_index += 1; + placeholder + } else { + "?".to_string() + }); + } + value_groups.push(format!("({})", placeholders.join(","))); + for value in [result.task_id.clone(), server_id.to_string()] { + arguments + .add(value) + .map_err(|error| anyhow::anyhow!("无法编码延迟结果数据库字段:{error}"))?; + } + arguments + .add(result.timestamp) + .map_err(|error| anyhow::anyhow!("无法编码延迟结果数据库字段:{error}"))?; + arguments + .add(result.latency_ms) + .map_err(|error| anyhow::anyhow!("无法编码延迟结果数据库字段:{error}"))?; + arguments + .add(result.packet_loss) + .map_err(|error| anyhow::anyhow!("无法编码延迟结果数据库字段:{error}"))?; + } + let sql = format!( + "WITH incoming(task_id, server_id, timestamp, latency_ms, packet_loss) AS (VALUES {}) \ + INSERT INTO latency_results(task_id, server_id, timestamp, latency_ms, packet_loss) \ + SELECT task_id, server_id, timestamp, latency_ms, packet_loss FROM incoming \ + WHERE EXISTS (SELECT 1 FROM latency_task_servers assigned \ + WHERE assigned.task_id=incoming.task_id AND assigned.server_id=incoming.server_id) \ + ON CONFLICT(task_id, server_id, timestamp) DO UPDATE SET \ + latency_ms=excluded.latency_ms, packet_loss=excluded.packet_loss", + value_groups.join(",") + ); + // SQL identifiers are fixed above; only generated placeholders are interpolated. + sqlx::query_with(AssertSqlSafe(sql), arguments) + .execute(&mut **transaction) + .await?; } Ok(()) } @@ -693,16 +862,40 @@ pub async fn clear_history(db: &Database) -> Result<()> { Ok(()) } -pub async fn cleanup_history(db: &Database, retention_days: i64) -> Result<()> { - let cutoff = now() - retention_days.clamp(1, 3650) * 86_400; +pub async fn cleanup_database(db: &Database, retention_days: i64) -> Result<()> { + let current = now(); + let cutoff = current - retention_days.clamp(1, 3650) * 86_400; + let active_task_cutoff = current - REMOTE_TASK_ACTIVE_TTL_SECONDS; + let mut transaction = db.pool().begin().await?; + sqlx::query(db.sql( + "UPDATE remote_tasks SET status='failed', completed_at=?, \ + result=CASE WHEN result='' THEN '任务等待超过 24 小时,已自动取消' ELSE result END \ + WHERE status IN ('pending','sent','running') AND requested_at i64 { let hours = hours.clamp(1, 24 * 365); - let task_count = task_count.clamp(1, 128); + let task_count = task_count.max(1); let base = match hours { 1 => 60, 2..=4 => 120, @@ -1343,14 +1536,28 @@ pub async fn remote_task(db: &Database, id: &str) -> Result Result> { + let current = now(); + let cutoff = current - REMOTE_TASK_ACTIVE_TTL_SECONDS; + let mut transaction = db.pool().begin().await?; + sqlx::query(db.sql( + "UPDATE remote_tasks SET status='failed', completed_at=?, \ + result=CASE WHEN result='' THEN '任务等待超过 24 小时,已自动取消' ELSE result END \ + WHERE server_id=? AND status='pending' AND requested_at, _>>() @@ -1397,7 +1604,7 @@ pub async fn update_remote_task_result( ) -> Result { let result = sqlx::query(db.sql( "UPDATE remote_tasks SET status=?, result=?, exit_code=?, completed_at=? \ - WHERE id=? AND server_id=?", + WHERE id=? AND server_id=? AND status IN ('pending','sent','running')", )) .bind(status) .bind(result) @@ -1685,12 +1892,42 @@ mod tests { } } + #[test] + fn validates_agent_report_bounds() { + let current = now(); + let mut report = AgentReport { + timestamp: current, + ..AgentReport::default() + }; + assert!(valid_agent_report(&report, current)); + + report.cpu = f64::NAN; + assert!(!valid_agent_report(&report, current)); + report.cpu = 10.0; + report.cpu_model = "x".repeat(513); + assert!(!valid_agent_report(&report, current)); + report.cpu_model.clear(); + report.latency_results = (0..=AGENT_REPORT_MAX_LATENCY_RESULTS) + .map(|index| AgentLatencyResult { + task_id: format!("task-{index}"), + timestamp: current, + latency_ms: 10.0, + packet_loss: 0.0, + }) + .collect(); + assert!(!valid_agent_report(&report, current)); + } + #[tokio::test] async fn sqlite_preserves_large_traffic_limits() { let db = super::super::connect("sqlite::memory:").await.unwrap(); db.migrate().await.unwrap(); let expected = 100_i64 * 1024 * 1024 * 1024; let (id, _) = create_server(&db, &server_input(expected)).await.unwrap(); + assert_eq!( + server_name(&db, &id).await.unwrap().as_deref(), + Some("Large traffic node") + ); let raw = sqlx::query_scalar::<_, i64>(db.sql("SELECT traffic_limit FROM servers WHERE id=?")) .bind(&id) @@ -1706,4 +1943,147 @@ mod tests { .unwrap(); assert_eq!(server.traffic_limit, expected); } + + #[tokio::test] + async fn sqlite_persists_large_agent_batches() { + let db = super::super::connect("sqlite::memory:").await.unwrap(); + db.migrate().await.unwrap(); + let (id, token) = create_server(&db, &server_input(0)).await.unwrap(); + let latency_task_id = create_latency_task( + &db, + &LatencyTaskInput { + name: "Batch latency".to_string(), + task_type: "tcp".to_string(), + target: "example.com".to_string(), + port: Some(443), + interval_seconds: 60, + default_enabled: false, + server_ids: vec![id.clone()], + }, + ) + .await + .unwrap(); + let identity = agent_identity(&db, &token).await.unwrap().unwrap(); + let start = now() - 600; + let reports = (0..600) + .map(|offset| AgentReport { + timestamp: start + offset, + cpu: offset as f64 / 10.0, + latency_results: vec![AgentLatencyResult { + task_id: latency_task_id.clone(), + timestamp: start + offset, + latency_ms: 10.0 + offset as f64 / 100.0, + packet_loss: 0.0, + }], + ..AgentReport::default() + }) + .collect::>(); + + let result = save_agent_batch(&db, &identity, "large-batch", &reports, "127.0.0.1") + .await + .unwrap(); + let rows = sqlx::query_scalar::<_, i64>( + db.sql("SELECT COUNT(*) FROM metric_history WHERE server_id=?"), + ) + .bind(&id) + .fetch_one(db.pool()) + .await + .unwrap(); + let latency_rows = sqlx::query_scalar::<_, i64>( + db.sql("SELECT COUNT(*) FROM latency_results WHERE server_id=?"), + ) + .bind(&id) + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(result.reports.len(), 600); + assert_eq!(rows, 600); + assert_eq!(latency_rows, 600); + } + + #[tokio::test] + async fn database_cleanup_expires_tasks_and_removes_retained_data() { + let db = super::super::connect("sqlite::memory:").await.unwrap(); + db.migrate().await.unwrap(); + let (server_id, _) = create_server(&db, &server_input(0)).await.unwrap(); + let old = now() - 3 * 86_400; + sqlx::query( + "INSERT INTO remote_tasks(\ + id, server_id, command, status, requested_by, requested_at, completed_at, result\ + ) VALUES \ + ('pending-old', ?, 'uptime', 'pending', 'admin', ?, NULL, ''), \ + ('success-old', ?, 'uptime', 'success', 'admin', ?, ?, 'done')", + ) + .bind(&server_id) + .bind(old) + .bind(&server_id) + .bind(old) + .bind(old) + .execute(db.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO alert_states(state_key, active, updated_at, details_json) VALUES \ + ('inactive-old', 0, ?, '{}'), ('active-old', 1, ?, '{}')", + ) + .bind(old) + .bind(old) + .execute(db.pool()) + .await + .unwrap(); + + cleanup_database(&db, 1).await.unwrap(); + + let pending_status = sqlx::query_scalar::<_, String>( + "SELECT status FROM remote_tasks WHERE id='pending-old'", + ) + .fetch_one(db.pool()) + .await + .unwrap(); + let completed_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM remote_tasks WHERE id='success-old'", + ) + .fetch_one(db.pool()) + .await + .unwrap(); + let inactive_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM alert_states WHERE state_key='inactive-old'", + ) + .fetch_one(db.pool()) + .await + .unwrap(); + let active_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM alert_states WHERE state_key='active-old'", + ) + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(pending_status, "failed"); + assert_eq!(completed_count, 0); + assert_eq!(inactive_count, 0); + assert_eq!(active_count, 1); + } + + #[tokio::test] + async fn sqlite_migration_contains_optimized_indexes() { + let db = super::super::connect("sqlite::memory:").await.unwrap(); + db.migrate().await.unwrap(); + let indexes = sqlx::query_scalar::<_, String>( + "SELECT name FROM sqlite_master WHERE type='index' AND name IN (\ + 'metric_history_time',\ + 'latency_results_time',\ + 'remote_tasks_server_status_time')", + ) + .fetch_all(db.pool()) + .await + .unwrap() + .into_iter() + .collect::>(); + assert_eq!(indexes.len(), 3); + } + + #[test] + fn latency_history_bucket_scales_beyond_128_tasks() { + assert!(latency_history_bucket_seconds(1, 256) > latency_history_bucket_seconds(1, 128)); + } } diff --git a/backend/src/main.rs b/backend/src/main.rs index 0654594..b9d21a3 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,4 +1,5 @@ mod auth; +mod backup; mod config; mod db; mod exchange; @@ -6,6 +7,7 @@ mod middleware; mod models; mod notify; mod routes; +mod security; mod theme; mod totp; mod turnstile; @@ -20,7 +22,8 @@ use clap::Parser; use std::collections::HashMap; use std::net::SocketAddr; use std::sync::Arc; -use tokio::sync::{RwLock, broadcast}; +use std::sync::atomic::AtomicBool; +use tokio::sync::{Mutex, RwLock, Semaphore, broadcast}; use tower_http::trace::TraceLayer; pub struct AppState { @@ -29,6 +32,11 @@ pub struct AppState { pub http: reqwest::Client, pub agents: RwLock>, pub dashboard_tx: broadcast::Sender, + pub database_maintenance: Mutex<()>, + pub database_restoring: AtomicBool, + pub login_attempts: security::AttemptLimiter, + pub remote_totp_attempts: security::AttemptLimiter, + pub password_verifications: Arc, } impl AppState { @@ -52,12 +60,12 @@ impl AppState { .to_string(); let _ = connection .sender - .send(websocket::AgentCommand::Text(payload)); + .try_send(websocket::AgentCommand::Text(payload)); } pub async fn disconnect_agent(&self, server_id: &str) { if let Some(connection) = self.agents.write().await.remove(server_id) { - let _ = connection.sender.send(websocket::AgentCommand::Close); + let _ = connection.sender.try_send(websocket::AgentCommand::Close); } } @@ -71,14 +79,14 @@ impl AppState { "persisted": false, "persistenceError": false, "persistedThroughTs": 0, - "nextD1WriteAfterMs": connection.report_interval.clamp(15, 3600) * 1000, + "nextPersistAfterMs": connection.report_interval.clamp(15, 3600) * 1000, "nextWssReportAfterMs": connection.collect_interval.clamp(1, 60) * 1000, "realtimeHint": true, }) .to_string(); let _ = connection .sender - .send(websocket::AgentCommand::Text(payload)); + .try_send(websocket::AgentCommand::Text(payload)); } pub async fn send_remote_task(&self, task: &models::RemoteTaskInfo) -> bool { @@ -92,10 +100,13 @@ impl AppState { #[tokio::main] async fn main() -> Result<()> { + rustls::crypto::ring::default_provider() + .install_default() + .map_err(|_| anyhow::anyhow!("failed to install the Rustls Ring crypto provider"))?; tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "nodeflare_backend=info,tower_http=info".into()), + .unwrap_or_else(|_| "nodeflare=info,tower_http=info".into()), ) .init(); @@ -112,6 +123,7 @@ async fn main() -> Result<()> { tracing::info!(database = ?database.kind(), "connected to database"); database.migrate().await?; db::initialize(&database, &config).await?; + database.optimize().await?; for (label, path) in [ ("public frontend", &config.frontend_dir), @@ -134,10 +146,28 @@ async fn main() -> Result<()> { http, agents: RwLock::new(HashMap::new()), dashboard_tx, + database_maintenance: Mutex::new(()), + database_restoring: AtomicBool::new(false), + login_attempts: security::AttemptLimiter::new( + 5, + std::time::Duration::from_secs(5 * 60), + std::time::Duration::from_secs(5 * 60), + ), + remote_totp_attempts: security::AttemptLimiter::new( + 5, + std::time::Duration::from_secs(5 * 60), + std::time::Duration::from_secs(10 * 60), + ), + password_verifications: Arc::new(Semaphore::new(4)), }); let protected = Router::new() .route("/api/admin/logout", post(routes::auth::logout)) + .route("/api/admin/sessions", get(routes::auth::sessions_get)) + .route( + "/api/admin/sessions/{id}", + delete(routes::auth::session_delete), + ) .route("/api/admin/2fa/status", get(routes::auth::get_2fa_status)) .route("/api/admin/2fa/setup", post(routes::auth::setup_2fa)) .route("/api/admin/2fa/enable", post(routes::auth::enable_2fa)) @@ -153,11 +183,11 @@ async fn main() -> Result<()> { patch(routes::admin::servers_order), ) .route( - "/api/admin/servers/:id/token", - get(routes::admin::server_token), + "/api/admin/servers/{id}/token", + post(routes::admin::server_token_rotate), ) .route( - "/api/admin/servers/:id", + "/api/admin/servers/{id}", patch(routes::admin::server_patch).delete(routes::admin::server_delete), ) .route( @@ -169,7 +199,7 @@ async fn main() -> Result<()> { get(routes::admin::latency_tasks_get).post(routes::admin::latency_tasks_post), ) .route( - "/api/admin/latency-tasks/:id", + "/api/admin/latency-tasks/{id}", patch(routes::admin::latency_task_patch).delete(routes::admin::latency_task_delete), ) .route( @@ -177,7 +207,7 @@ async fn main() -> Result<()> { get(routes::admin::alert_rules_get).post(routes::admin::alert_rules_post), ) .route( - "/api/admin/alert-rules/:id", + "/api/admin/alert-rules/{id}", patch(routes::admin::alert_rule_patch).delete(routes::admin::alert_rule_delete), ) .route( @@ -197,14 +227,17 @@ async fn main() -> Result<()> { post(routes::admin::themes_upload), ) .route( - "/api/admin/themes/:id/activate", + "/api/admin/themes/{id}/activate", post(routes::admin::theme_activate), ) .route( - "/api/admin/themes/:id/preview", + "/api/admin/themes/{id}/preview", post(routes::admin::theme_preview), ) - .route("/api/admin/themes/:id", delete(routes::admin::theme_delete)) + .route( + "/api/admin/themes/{id}", + delete(routes::admin::theme_delete), + ) .route( "/api/admin/theme-settings", get(routes::admin::theme_settings), @@ -214,9 +247,17 @@ async fn main() -> Result<()> { post(routes::admin::exchange_refresh), ) .route("/api/admin/database", get(routes::admin::database_stats)) + .route( + "/api/admin/database/backup", + get(routes::admin::database_backup), + ) + .route( + "/api/admin/database/restore", + post(routes::admin::database_restore), + ) .route("/api/admin/history", delete(routes::admin::history_delete)) .route("/api/admin/remote/task", post(routes::remote::create_task)) - .route("/api/admin/remote/task/:id", get(routes::remote::get_task)) + .route("/api/admin/remote/task/{id}", get(routes::remote::get_task)) .layer(axum_middleware::from_fn_with_state( Arc::clone(&state), middleware::auth_middleware, @@ -226,8 +267,8 @@ async fn main() -> Result<()> { .route("/api/bootstrap", get(routes::public::bootstrap)) .route("/api/config", get(routes::public::config)) .route("/api/servers", get(routes::public::servers)) - .route("/api/history/:id", get(routes::public::history)) - .route("/api/latency/:id", get(routes::public::latency_history)) + .route("/api/history/{id}", get(routes::public::history)) + .route("/api/latency/{id}", get(routes::public::latency_history)) .route("/api/exchange-rates", get(routes::public::exchange_rates)) .route("/api/live/wake", post(routes::public::wake_servers)) .route( @@ -259,6 +300,8 @@ async fn main() -> Result<()> { fn spawn_maintenance(state: Arc) { tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut maintenance_runs = 0_u64; loop { interval.tick().await; if let Err(error) = db::cleanup_auth(&state.db).await { @@ -277,10 +320,19 @@ fn spawn_maintenance(state: Arc) { if let Err(error) = notify::run_periodic(&state.db, &state.http, &settings).await { tracing::error!(%error, "notification maintenance failed"); } - if let Err(error) = - db::queries::cleanup_history(&state.db, settings.history_retention_days).await + maintenance_runs = maintenance_runs.wrapping_add(1); { - tracing::error!(%error, "history cleanup failed"); + let _maintenance = state.database_maintenance.lock().await; + if let Err(error) = + db::queries::cleanup_database(&state.db, settings.history_retention_days).await + { + tracing::error!(%error, "database cleanup failed"); + } + if maintenance_runs.is_multiple_of(6 * 60) + && let Err(error) = state.db.optimize().await + { + tracing::error!(%error, "database optimization failed"); + } } if let Err(error) = exchange::refresh(&state.db, &state.http, false).await { tracing::warn!(%error, "scheduled exchange-rate refresh failed"); diff --git a/backend/src/middleware.rs b/backend/src/middleware.rs index 5d9ac22..9eb030c 100644 --- a/backend/src/middleware.rs +++ b/backend/src/middleware.rs @@ -17,11 +17,12 @@ pub async fn auth_middleware( let Some(token) = bearer_or_cookie(request.headers()) else { return ApiResponse::unauthorized("请先登录").into_response(); }; - match crate::db::session_username(&state.db, &token).await { - Ok(Some(username)) => { - request - .extensions_mut() - .insert(AuthenticatedUser { username }); + match crate::db::session_identity(&state.db, &token).await { + Ok(Some(identity)) => { + request.extensions_mut().insert(AuthenticatedUser { + username: identity.username, + session_id: identity.id, + }); next.run(request).await } Ok(None) => ApiResponse::unauthorized("登录状态已过期").into_response(), @@ -32,6 +33,7 @@ pub async fn auth_middleware( #[derive(Clone)] pub struct AuthenticatedUser { pub username: String, + pub session_id: String, } pub async fn security_headers(request: Request, next: Next) -> Response { diff --git a/backend/src/models.rs b/backend/src/models.rs index ee7296b..86b05b0 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -5,7 +5,6 @@ use std::collections::BTreeMap; #[serde(deny_unknown_fields)] pub struct LoginRequest { pub username: String, - pub password: String, pub password_derived: String, #[serde(default)] pub turnstile_token: String, @@ -71,7 +70,6 @@ pub struct SettingsInput { pub show_latency: Option, pub show_uptime: Option, pub admin_username: Option, - pub new_password: Option, pub new_password_derived: Option, pub turnstile_enabled: Option, pub turnstile_login_enabled: Option, @@ -128,6 +126,17 @@ pub struct SettingsView { pub traffic_alert_percentage: i64, } +#[derive(Debug, Clone, Serialize)] +pub struct LoginSessionView { + pub id: String, + pub ip_address: String, + pub user_agent: String, + pub created_at: i64, + pub last_seen_at: i64, + pub expires_at: i64, + pub current: bool, +} + #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(default, deny_unknown_fields)] pub struct AgentReport { @@ -445,7 +454,6 @@ pub struct Enable2FaRequest { #[derive(Debug, Serialize)] pub struct TotpSetupResponse { pub secret: String, - pub uri: String, pub enabled: bool, } diff --git a/backend/src/routes/admin.rs b/backend/src/routes/admin.rs index 02c1649..3f189d7 100644 --- a/backend/src/routes/admin.rs +++ b/backend/src/routes/admin.rs @@ -7,16 +7,35 @@ use crate::models::{ SettingsInput, TelegramSettingsInput, ThemeInput, ThemeUploadInput, }; use axum::Json; -use axum::body::to_bytes; -use axum::extract::{Extension, Path, Query, Request, State}; -use axum::http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE}; +use axum::body::{Body, to_bytes}; +use axum::extract::{ConnectInfo, Extension, Path, Query, Request, State}; +use axum::http::{ + HeaderMap, HeaderValue, StatusCode, + header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_TYPE, SET_COOKIE}, +}; use axum::response::{IntoResponse, Response}; +use serde::Deserialize; use sha2::{Digest, Sha256}; use std::collections::HashSet; -use std::net::Ipv4Addr; +use std::net::{Ipv4Addr, SocketAddr}; use std::sync::Arc; +use std::sync::atomic::Ordering; +use tokio_util::io::ReaderStream; use url::Url; +#[derive(Deserialize)] +pub struct DatabaseRestoreInput { + filename: String, +} + +struct DatabaseRestoreFlag<'a>(&'a std::sync::atomic::AtomicBool); + +impl Drop for DatabaseRestoreFlag<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + pub async fn servers_get(State(state): State>) -> Result { let servers = crate::db::queries::list_servers(&state.db, true) .await @@ -102,15 +121,16 @@ pub async fn servers_order( Ok(StatusCode::NO_CONTENT.into_response()) } -pub async fn server_token( +pub async fn server_token_rotate( State(state): State>, Path(id): Path, ) -> Result { validate_id(&id)?; - let token = crate::db::queries::server_token(&state.db, &id) + let token = crate::db::queries::rotate_server_token(&state.db, &id) .await .map_err(ApiResponse::internal)? .ok_or_else(|| ApiResponse::not_found("节点不存在"))?; + state.disconnect_agent(&id).await; Ok(Json(serde_json::json!({"agent_token": token})).into_response()) } @@ -123,6 +143,7 @@ pub async fn settings_get(State(state): State>) -> Result>, + ConnectInfo(peer): ConnectInfo, Extension(user): Extension, headers: HeaderMap, Json(mut input): Json, @@ -131,17 +152,12 @@ pub async fn settings_patch( .await .map_err(ApiResponse::internal)?; validate_settings(&state, ¤t, &input).await?; - let password_hash = match input - .new_password + let password_hash = input + .new_password_derived .as_deref() - .filter(|value| !value.is_empty()) - { - Some(_) => Some( - crate::auth::hash_password(input.new_password_derived.as_deref().unwrap_or_default()) - .map_err(ApiResponse::internal)?, - ), - None => None, - }; + .map(crate::auth::hash_password) + .transpose() + .map_err(ApiResponse::internal)?; let next_username = input .admin_username .as_deref() @@ -161,9 +177,14 @@ pub async fn settings_patch( let credentials_changed = password_hash.is_some() || username_changed; let token = if credentials_changed { Some( - crate::db::create_session(&state.db, &next_username, state.config.session_ttl_hours) - .await - .map_err(ApiResponse::internal)?, + crate::db::create_session( + &state.db, + &next_username, + state.config.session_ttl_hours, + &super::auth::session_device(&headers, peer), + ) + .await + .map_err(ApiResponse::internal)?, ) } else { None @@ -204,14 +225,6 @@ pub async fn latency_tasks_post( Json(input): Json, ) -> Result { validate_latency_task(&state, &input).await?; - if crate::db::queries::list_latency_tasks(&state.db) - .await - .map_err(ApiResponse::internal)? - .len() - >= 128 - { - return Err(ApiResponse::bad_request("最多可创建 128 个延迟任务")); - } let id = crate::db::queries::create_latency_task(&state.db, &input) .await .map_err(ApiResponse::internal)?; @@ -612,7 +625,83 @@ pub async fn database_stats(State(state): State>) -> Result>) -> Result { + let _maintenance = state.database_maintenance.lock().await; + let archive = crate::backup::export_archive(&state.db) + .await + .map_err(ApiResponse::internal)?; + let disposition = + HeaderValue::from_str(&format!("attachment; filename=\"{}\"", archive.filename)) + .map_err(ApiResponse::internal)?; + let body = Body::from_stream(ReaderStream::new(tokio::fs::File::from_std(archive.file))); + Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/zip") + .header(CONTENT_DISPOSITION, disposition) + .header(CONTENT_LENGTH, archive.size) + .header(CACHE_CONTROL, "no-store") + .body(body) + .map_err(ApiResponse::internal) +} + +pub async fn database_restore( + State(state): State>, + Query(input): Query, + request: Request, +) -> Result { + let filename = input.filename.trim(); + if filename.is_empty() + || filename.chars().count() > 255 + || filename.contains('/') + || filename.contains('\\') + || filename.chars().any(char::is_control) + || !filename.to_ascii_lowercase().ends_with(".zip") + { + return Err(ApiResponse::bad_request("请选择有效的数据库备份 ZIP")); + } + if request + .headers() + .get(CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .is_some_and(|length| length > crate::backup::DATABASE_BACKUP_MAX_BYTES) + { + return Err(ApiResponse::error( + StatusCode::PAYLOAD_TOO_LARGE, + "数据库备份 ZIP 不能超过 512 MiB", + )); + } + let archive = to_bytes( + request.into_body(), + crate::backup::DATABASE_BACKUP_MAX_BYTES, + ) + .await + .map_err(|_| { + ApiResponse::error( + StatusCode::PAYLOAD_TOO_LARGE, + "数据库备份 ZIP 不能超过 512 MiB", + ) + })?; + + let _maintenance = state.database_maintenance.lock().await; + if state.database_restoring.swap(true, Ordering::AcqRel) { + return Err(ApiResponse::conflict("数据库正在恢复")); + } + let _restore_flag = DatabaseRestoreFlag(&state.database_restoring); + let agents = std::mem::take(&mut *state.agents.write().await); + for connection in agents.into_values() { + let _ = connection + .sender + .try_send(crate::websocket::AgentCommand::Close); + } + let restored_rows = crate::backup::restore_archive(&state.db, &archive) + .await + .map_err(|error| ApiResponse::unprocessable(format!("恢复失败:{error}")))?; + Ok(Json(serde_json::json!({"restored_rows": restored_rows})).into_response()) +} + pub async fn history_delete(State(state): State>) -> Result { + let _maintenance = state.database_maintenance.lock().await; crate::db::queries::clear_history(&state.db) .await .map_err(ApiResponse::internal)?; @@ -792,19 +881,11 @@ async fn validate_settings( return Err(ApiResponse::bad_request("管理员用户名格式无效")); } if input - .new_password - .as_ref() - .is_some_and(|value| !value.is_empty() && !(8..=128).contains(&value.chars().count())) - || input - .new_password - .as_ref() - .is_some_and(|value| !value.is_empty()) - && input - .new_password_derived - .as_deref() - .is_none_or(|value| !crate::auth::valid_password_derived(value)) + .new_password_derived + .as_deref() + .is_some_and(|value| !crate::auth::valid_password_derived(value)) { - return Err(ApiResponse::bad_request("新密码格式无效")); + return Err(ApiResponse::bad_request("新密码摘要格式无效")); } if input.theme_options.as_ref().is_some_and(|value| { let Some(object) = value.as_object() else { diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index e2eb101..f5aabb6 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -1,16 +1,15 @@ -use super::{ - ApiResponse, admin_cookie, bearer_or_cookie, forwarded_ip, hostname, request_is_secure, -}; +use super::{ApiResponse, admin_cookie, bearer_or_cookie, client_ip, hostname, request_is_secure}; use crate::AppState; use crate::middleware::AuthenticatedUser; use crate::models::{ Enable2FaRequest, LoginRequest, TotpSetupResponse, TotpStatusResponse, TurnstileVerifyRequest, }; use axum::Json; -use axum::extract::{Extension, State}; -use axum::http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE}; +use axum::extract::{ConnectInfo, Extension, Path, State}; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE, header::USER_AGENT}; use axum::response::{IntoResponse, Response}; use serde::Serialize; +use std::net::SocketAddr; use std::sync::Arc; #[derive(Serialize)] @@ -20,13 +19,21 @@ struct LoginResponse { pub async fn login( State(state): State>, + ConnectInfo(peer): ConnectInfo, headers: HeaderMap, Json(input): Json, ) -> Result { + let client_ip = client_ip(&headers, peer); + if let Some(seconds) = state.login_attempts.retry_after(&client_ip) { + return Err(ApiResponse::error( + StatusCode::TOO_MANY_REQUESTS, + format!("登录尝试过多,请在 {seconds} 秒后重试"), + )); + } if input.username.trim().is_empty() - || input.password.is_empty() || !crate::auth::valid_password_derived(&input.password_derived) { + state.login_attempts.record_failure(&client_ip); return Err(ApiResponse::bad_request("请输入用户名和密码")); } let settings = crate::db::load_settings(&state.db) @@ -39,18 +46,32 @@ pub async fn login( &state.http, &input.turnstile_token, &settings.turnstile_secret_key, - forwarded_ip(&headers).as_deref(), + Some(&client_ip), &host, "admin_login", ) .await; if !valid { + state.login_attempts.record_failure(&client_ip); return Err(ApiResponse::forbidden("Cloudflare 人机验证失败,请重试")); } } - if !secure_eq(input.username.trim(), settings.admin_username.trim()) - || !crate::auth::verify_password(&input.password_derived, &settings.admin_password_hash) - { + let username_valid = secure_eq(input.username.trim(), settings.admin_username.trim()); + let password_derived = input.password_derived.clone(); + let password_hash = settings.admin_password_hash.clone(); + let permit = Arc::clone(&state.password_verifications) + .try_acquire_owned() + .map_err(|_| { + ApiResponse::error(StatusCode::TOO_MANY_REQUESTS, "登录验证繁忙,请稍后重试") + })?; + let password_valid = tokio::task::spawn_blocking(move || { + let _permit = permit; + crate::auth::verify_password(&password_derived, &password_hash) + }) + .await + .map_err(ApiResponse::internal)?; + if !username_valid || !password_valid { + state.login_attempts.record_failure(&client_ip); return Err(ApiResponse::unauthorized("用户名或密码错误")); } if let Some((secret, enabled)) = @@ -68,13 +89,16 @@ pub async fn login( if !crate::totp::verify_totp(&secret, input.totp_code.trim()) .map_err(ApiResponse::internal)? { + state.login_attempts.record_failure(&client_ip); return Err(ApiResponse::unauthorized("两步验证码错误")); } } + state.login_attempts.clear(&client_ip); let token = crate::db::create_session( &state.db, &settings.admin_username, state.config.session_ttl_hours, + &session_device(&headers, peer), ) .await .map_err(ApiResponse::internal)?; @@ -94,6 +118,33 @@ pub async fn login( Ok(response) } +pub async fn sessions_get( + State(state): State>, + Extension(user): Extension, +) -> Result { + let sessions = crate::db::login_sessions(&state.db, &user.username, &user.session_id) + .await + .map_err(ApiResponse::internal)?; + Ok(Json(serde_json::json!({"sessions": sessions})).into_response()) +} + +pub async fn session_delete( + State(state): State>, + Extension(user): Extension, + Path(id): Path, +) -> Result { + if id.len() > 80 || uuid::Uuid::parse_str(&id).is_err() { + return Err(ApiResponse::bad_request("登录设备 ID 无效")); + } + if !crate::db::revoke_login_session(&state.db, &user.username, &id) + .await + .map_err(ApiResponse::internal)? + { + return Err(ApiResponse::not_found("登录设备不存在")); + } + Ok(StatusCode::NO_CONTENT.into_response()) +} + pub async fn logout( State(state): State>, headers: HeaderMap, @@ -114,6 +165,7 @@ pub async fn logout( pub async fn verify_turnstile( State(state): State>, + ConnectInfo(peer): ConnectInfo, headers: HeaderMap, Json(input): Json, ) -> Result { @@ -124,11 +176,12 @@ pub async fn verify_turnstile( return Err(ApiResponse::bad_request("公开仪表盘未启用人机验证")); } let host = hostname(&headers).ok_or_else(|| ApiResponse::bad_request("请求主机名无效"))?; + let client_ip = client_ip(&headers, peer); if !crate::turnstile::verify( &state.http, &input.token, &settings.turnstile_secret_key, - forwarded_ip(&headers).as_deref(), + Some(&client_ip), &host, "public_dashboard", ) @@ -182,13 +235,11 @@ pub async fn setup_2fa( )); } let secret = crate::totp::generate_secret(); - let uri = crate::totp::generate_uri(&secret, "NodeFlare", &user.username); crate::db::queries::save_totp_secret(&state.db, &user.username, &secret, false) .await .map_err(ApiResponse::internal)?; Ok(Json(TotpSetupResponse { secret, - uri, enabled: false, })) } @@ -235,3 +286,24 @@ pub async fn disable_2fa( fn secure_eq(left: &str, right: &str) -> bool { crate::auth::token_hash(left) == crate::auth::token_hash(right) } + +pub(crate) fn session_device(headers: &HeaderMap, peer: SocketAddr) -> crate::db::SessionDevice { + let ip_address = client_ip(headers, peer); + let user_agent = headers + .get(USER_AGENT) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| { + value + .chars() + .filter(|character| !character.is_control()) + .take(512) + .collect() + }) + .unwrap_or_else(|| "未知客户端".to_string()); + crate::db::SessionDevice { + ip_address, + user_agent, + } +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 7b4d20b..4fb4207 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -94,7 +94,7 @@ pub fn hostname(headers: &HeaderMap) -> Option { } pub fn forwarded_ip(headers: &HeaderMap) -> Option { - ["cf-connecting-ip", "x-real-ip"].iter().find_map(|name| { + ["x-real-ip", "cf-connecting-ip"].iter().find_map(|name| { headers .get(*name) .and_then(|value| value.to_str().ok()) @@ -104,6 +104,15 @@ pub fn forwarded_ip(headers: &HeaderMap) -> Option { }) } +pub fn client_ip(headers: &HeaderMap, peer: std::net::SocketAddr) -> String { + if peer.ip().is_loopback() + && let Some(forwarded) = forwarded_ip(headers) + { + return forwarded; + } + peer.ip().to_string() +} + pub fn request_is_secure(headers: &HeaderMap) -> bool { headers .get("x-forwarded-proto") @@ -118,3 +127,27 @@ pub fn admin_cookie(token: &str, max_age: i64, secure: bool) -> String { if secure { "; Secure" } else { "" } ) } + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + #[test] + fn forwarded_ip_prefers_the_reverse_proxy_value() { + let mut headers = HeaderMap::new(); + headers.insert("x-real-ip", HeaderValue::from_static("203.0.113.7")); + headers.insert("cf-connecting-ip", HeaderValue::from_static("198.51.100.9")); + let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 12345); + assert_eq!(client_ip(&headers, peer), "203.0.113.7"); + } + + #[test] + fn public_peers_cannot_spoof_forwarded_headers() { + let mut headers = HeaderMap::new(); + headers.insert("x-real-ip", HeaderValue::from_static("203.0.113.7")); + let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 20)), 12345); + assert_eq!(client_ip(&headers, peer), "198.51.100.20"); + } +} diff --git a/backend/src/routes/remote.rs b/backend/src/routes/remote.rs index a02c484..7e8a7e8 100644 --- a/backend/src/routes/remote.rs +++ b/backend/src/routes/remote.rs @@ -53,6 +53,12 @@ pub async fn create_task( Extension(user): Extension, Json(input): Json, ) -> Result { + if let Some(seconds) = state.remote_totp_attempts.retry_after(&user.session_id) { + return Err(ApiResponse::error( + StatusCode::TOO_MANY_REQUESTS, + format!("验证码尝试过多,请在 {seconds} 秒后重试"), + )); + } let command = input.command.trim(); let server_ids = remote_server_ids(&input); if server_ids.is_empty() @@ -66,11 +72,17 @@ pub async fn create_task( let totp = crate::db::queries::get_totp_secret(&state.db, &user.username) .await .map_err(ApiResponse::internal)?; - validate_remote_totp( + if let Err(error) = validate_remote_totp( totp.as_ref() .map(|(secret, enabled)| (secret.as_str(), *enabled)), &input.totp_code, - )?; + ) { + if error.status == StatusCode::UNPROCESSABLE_ENTITY { + state.remote_totp_attempts.record_failure(&user.session_id); + } + return Err(error); + } + state.remote_totp_attempts.clear(&user.session_id); let known_server_ids = crate::db::queries::all_server_ids(&state.db) .await .map_err(ApiResponse::internal)? diff --git a/backend/src/security.rs b/backend/src/security.rs new file mode 100644 index 0000000..294db0b --- /dev/null +++ b/backend/src/security.rs @@ -0,0 +1,94 @@ +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +#[derive(Clone, Copy)] +struct AttemptState { + failures: u32, + window_started: Instant, + blocked_until: Option, + last_seen: Instant, +} + +pub struct AttemptLimiter { + entries: Mutex>, + maximum_failures: u32, + window: Duration, + block_for: Duration, + retention: Duration, +} + +impl AttemptLimiter { + pub fn new(maximum_failures: u32, window: Duration, block_for: Duration) -> Self { + Self { + entries: Mutex::new(HashMap::new()), + maximum_failures: maximum_failures.max(1), + window, + block_for, + retention: window.max(block_for).saturating_mul(4), + } + } + + pub fn retry_after(&self, key: &str) -> Option { + let now = Instant::now(); + let mut entries = self.entries.lock().ok()?; + entries.retain(|_, state| now.duration_since(state.last_seen) <= self.retention); + let state = entries.get_mut(key)?; + state.last_seen = now; + match state.blocked_until { + Some(until) if until > now => Some(until.duration_since(now).as_secs().max(1)), + Some(_) => { + entries.remove(key); + None + } + None => None, + } + } + + pub fn record_failure(&self, key: &str) { + let now = Instant::now(); + let Ok(mut entries) = self.entries.lock() else { + return; + }; + let state = entries.entry(key.to_string()).or_insert(AttemptState { + failures: 0, + window_started: now, + blocked_until: None, + last_seen: now, + }); + if now.duration_since(state.window_started) > self.window { + state.failures = 0; + state.window_started = now; + state.blocked_until = None; + } + state.failures = state.failures.saturating_add(1); + state.last_seen = now; + if state.failures >= self.maximum_failures { + state.blocked_until = Some(now + self.block_for); + } + } + + pub fn clear(&self, key: &str) { + if let Ok(mut entries) = self.entries.lock() { + entries.remove(key); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blocks_after_the_configured_number_of_failures() { + let limiter = AttemptLimiter::new(3, Duration::from_secs(60), Duration::from_secs(60)); + assert_eq!(limiter.retry_after("client"), None); + limiter.record_failure("client"); + limiter.record_failure("client"); + assert_eq!(limiter.retry_after("client"), None); + limiter.record_failure("client"); + assert!(limiter.retry_after("client").is_some()); + limiter.clear("client"); + assert_eq!(limiter.retry_after("client"), None); + } +} diff --git a/backend/src/totp.rs b/backend/src/totp.rs index cf8aba8..43db4a7 100644 --- a/backend/src/totp.rs +++ b/backend/src/totp.rs @@ -1,5 +1,4 @@ use anyhow::{Result, anyhow}; -use rand::Rng; use totp_lite::{Sha1, totp_custom}; const TOTP_DIGITS: u32 = 6; @@ -7,20 +6,10 @@ const TOTP_STEP: u64 = 30; /// 生成一个新的 TOTP secret(base32 编码) pub fn generate_secret() -> String { - let mut rng = rand::thread_rng(); - let secret: [u8; 20] = rng.r#gen(); + let secret: [u8; 20] = rand::random(); data_encoding::BASE32_NOPAD.encode(&secret) } -/// 生成 TOTP URI(用于显示二维码) -pub fn generate_uri(secret: &str, issuer: &str, account_name: &str) -> String { - let issuer = url::form_urlencoded::byte_serialize(issuer.as_bytes()).collect::(); - let account = url::form_urlencoded::byte_serialize(account_name.as_bytes()).collect::(); - format!( - "otpauth://totp/{issuer}:{account}?secret={secret}&issuer={issuer}&digits={TOTP_DIGITS}&period={TOTP_STEP}", - ) -} - /// 验证 TOTP 代码(允许前后一个时间窗口的误差) pub fn verify_totp(secret: &str, code: &str) -> Result { let now = std::time::SystemTime::now() @@ -63,16 +52,6 @@ mod tests { ); } - #[test] - fn test_generate_uri() { - let secret = "JBSWY3DPEHPK3PXP"; - let uri = generate_uri(secret, "NodeFlare", "admin"); - assert!(uri.starts_with("otpauth://totp/")); - assert!(uri.contains("secret=JBSWY3DPEHPK3PXP")); - assert!(uri.contains("digits=6")); - assert!(uri.contains("period=30")); - } - #[test] fn test_verify_totp() { let secret = "JBSWY3DPEHPK3PXP"; diff --git a/backend/src/websocket/agent.rs b/backend/src/websocket/agent.rs index cc5a88f..0f7a9f3 100644 --- a/backend/src/websocket/agent.rs +++ b/backend/src/websocket/agent.rs @@ -2,7 +2,7 @@ use super::{AgentCommand, AgentConnection, DashboardEvent}; use crate::AppState; use crate::db::queries::{AgentIdentity, PersistResult}; use crate::models::{AgentReport, RemoteTaskInfo}; -use crate::routes::{ApiResponse, forwarded_ip}; +use crate::routes::{ApiResponse, client_ip}; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::{ConnectInfo, State}; use axum::http::HeaderMap; @@ -11,6 +11,7 @@ use futures_util::{SinkExt, StreamExt}; use serde::Deserialize; use std::net::SocketAddr; use std::sync::Arc; +use std::sync::atomic::Ordering; use tokio::sync::mpsc; const MAX_AGENT_MESSAGE_BYTES: usize = 2 * 1024 * 1024; @@ -43,6 +44,13 @@ pub async fn handle( ConnectInfo(peer): ConnectInfo, headers: HeaderMap, ) -> Response { + if state.database_restoring.load(Ordering::Acquire) { + return ApiResponse::error( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "数据库正在恢复,请稍后重试", + ) + .into_response(); + } let token = headers .get(axum::http::header::AUTHORIZATION) .and_then(|value| value.to_str().ok()) @@ -56,17 +64,24 @@ pub async fn handle( Ok(None) => return ApiResponse::unauthorized("Agent Token 无效").into_response(), Err(error) => return ApiResponse::internal(error).into_response(), }; - let remote_ip = forwarded_ip(&headers).unwrap_or_else(|| peer.ip().to_string()); + let remote_ip = client_ip(&headers, peer); + let token = token.to_string(); ws.max_message_size(MAX_AGENT_MESSAGE_BYTES) .max_frame_size(MAX_AGENT_MESSAGE_BYTES) - .on_upgrade(move |socket| run(socket, state, identity, remote_ip)) + .on_upgrade(move |socket| run(socket, state, identity, remote_ip, token)) } -async fn run(socket: WebSocket, state: Arc, identity: AgentIdentity, remote_ip: String) { +async fn run( + socket: WebSocket, + state: Arc, + identity: AgentIdentity, + remote_ip: String, + token: String, +) { let connection_id = uuid::Uuid::new_v4().to_string(); let (mut websocket_tx, mut websocket_rx) = socket.split(); - let (outbound_tx, mut outbound_rx) = mpsc::unbounded_channel::(); - state.agents.write().await.insert( + let (outbound_tx, mut outbound_rx) = mpsc::channel::(256); + let previous = state.agents.write().await.insert( identity.server_id.clone(), AgentConnection { connection_id: connection_id.clone(), @@ -75,6 +90,31 @@ async fn run(socket: WebSocket, state: Arc, identity: AgentIdentity, r collect_interval: identity.collect_interval, }, ); + if let Some(previous) = previous { + let _ = previous.sender.try_send(AgentCommand::Close); + } + + // The HTTP upgrade and the async socket task are separate scheduling points. + // Revalidate after registration so a token rotated in between cannot leave a + // stale authenticated socket alive. Token rotation either closes this entry, + // or this check observes the new hash and rejects the connection itself. + let still_authorized = matches!( + crate::db::queries::agent_identity(&state.db, &token).await, + Ok(Some(current)) if current.server_id == identity.server_id + ); + if !still_authorized { + let mut agents = state.agents.write().await; + if agents + .get(&identity.server_id) + .is_some_and(|connection| connection.connection_id == connection_id) + { + agents.remove(&identity.server_id); + } + drop(agents); + let _ = websocket_tx.send(Message::Close(None)).await; + tracing::warn!(server_id = %identity.server_id, "rejected stale Agent connection"); + return; + } tracing::info!(server_id = %identity.server_id, remote_ip, "Agent connected"); if let Some(config) = crate::db::queries::agent_config(&state.db, &identity.server_id) @@ -82,7 +122,7 @@ async fn run(socket: WebSocket, state: Arc, identity: AgentIdentity, r .ok() .flatten() { - let _ = outbound_tx.send(AgentCommand::Text( + let _ = outbound_tx.try_send(AgentCommand::Text( serde_json::json!({"type": "config", "ts": crate::db::now(), "config": config}) .to_string(), )); @@ -106,12 +146,16 @@ async fn run(socket: WebSocket, state: Arc, identity: AgentIdentity, r while let Some(command) = outbound_rx.recv().await { match command { AgentCommand::Text(text) => { - if websocket_tx.send(Message::Text(text)).await.is_err() { + if websocket_tx.send(Message::Text(text.into())).await.is_err() { break; } } AgentCommand::Pong(payload) => { - if websocket_tx.send(Message::Pong(payload)).await.is_err() { + if websocket_tx + .send(Message::Pong(payload.into())) + .await + .is_err() + { break; } } @@ -129,7 +173,7 @@ async fn run(socket: WebSocket, state: Arc, identity: AgentIdentity, r handle_text(&state, &identity, &remote_ip, &outbound_tx, &text).await; } Ok(Message::Ping(payload)) => { - let _ = outbound_tx.send(AgentCommand::Pong(payload)); + let _ = outbound_tx.try_send(AgentCommand::Pong(payload.to_vec())); } Ok(Message::Close(_)) | Err(_) => break, _ => {} @@ -150,9 +194,13 @@ async fn handle_text( state: &Arc, identity: &AgentIdentity, remote_ip: &str, - outbound: &mpsc::UnboundedSender, + outbound: &mpsc::Sender, text: &str, ) { + if state.database_restoring.load(Ordering::Acquire) { + send_persistence_error(outbound, 0); + return; + } let Ok(value) = serde_json::from_str::(text) else { return; }; @@ -189,16 +237,15 @@ async fn handle_text( return; } }; - let server_name = crate::db::queries::list_servers(&state.db, true) - .await - .ok() - .and_then(|servers| { - servers - .into_iter() - .find(|server| server.id == server_id) - .map(|server| server.name) - }) - .unwrap_or_else(|| server_id.clone()); + if !settings.notification_enabled { + return; + } + let server_name = + crate::db::queries::server_name(&state.db, &server_id) + .await + .ok() + .flatten() + .unwrap_or_else(|| server_id.clone()); if let Err(error) = crate::notify::evaluate_resource_alerts( &state.db, &state.http, @@ -247,9 +294,9 @@ async fn handle_text( } } -fn send_task(outbound: &mpsc::UnboundedSender, task: &RemoteTaskInfo) -> bool { +fn send_task(outbound: &mpsc::Sender, task: &RemoteTaskInfo) -> bool { outbound - .send(AgentCommand::Text( + .try_send(AgentCommand::Text( serde_json::json!({ "type": "remote_task", "task_id": task.id, @@ -261,20 +308,20 @@ fn send_task(outbound: &mpsc::UnboundedSender, task: &RemoteTaskIn } fn send_ack( - outbound: &mpsc::UnboundedSender, + outbound: &mpsc::Sender, identity: &AgentIdentity, result: &PersistResult, ) { let report_interval = identity.report_interval.clamp(15, 3600) * 1000; let live_interval = identity.collect_interval.clamp(1, 60) * 1000; - let _ = outbound.send(AgentCommand::Text( + let _ = outbound.try_send(AgentCommand::Text( serde_json::json!({ "type": "ack", "ts": crate::db::now(), "persisted": true, "persistenceError": false, "persistedThroughTs": result.persisted_through, - "nextD1WriteAfterMs": report_interval, + "nextPersistAfterMs": report_interval, "nextWssReportAfterMs": live_interval, "realtimeHint": false, }) @@ -282,15 +329,15 @@ fn send_ack( )); } -fn send_persistence_error(outbound: &mpsc::UnboundedSender, persisted: i64) { - let _ = outbound.send(AgentCommand::Text( +fn send_persistence_error(outbound: &mpsc::Sender, persisted: i64) { + let _ = outbound.try_send(AgentCommand::Text( serde_json::json!({ "type": "ack", "ts": crate::db::now(), "persisted": false, "persistenceError": true, "persistedThroughTs": persisted, - "nextD1WriteAfterMs": 5000, + "nextPersistAfterMs": 5000, "nextWssReportAfterMs": 5000, "realtimeHint": false, }) diff --git a/backend/src/websocket/dashboard.rs b/backend/src/websocket/dashboard.rs index 0705a6a..d4da35c 100644 --- a/backend/src/websocket/dashboard.rs +++ b/backend/src/websocket/dashboard.rs @@ -9,6 +9,8 @@ use futures_util::{SinkExt, StreamExt}; use serde::Deserialize; use std::sync::Arc; +const MAX_DASHBOARD_MESSAGE_BYTES: usize = 8 * 1024; + #[derive(Deserialize)] pub struct DashboardQuery { server_id: Option, @@ -37,7 +39,9 @@ pub async fn handle( let server_id = query .server_id .filter(|value| !value.is_empty() && value.len() <= 80 && !value.contains('/')); - ws.on_upgrade(move |socket| run(socket, state, server_id)) + ws.max_message_size(MAX_DASHBOARD_MESSAGE_BYTES) + .max_frame_size(MAX_DASHBOARD_MESSAGE_BYTES) + .on_upgrade(move |socket| run(socket, state, server_id)) } async fn run(socket: WebSocket, state: Arc, server_id: Option) { @@ -47,7 +51,7 @@ async fn run(socket: WebSocket, state: Arc, server_id: Option) tokio::select! { update = updates.recv() => match update { Ok(update) if server_id.as_ref().is_none_or(|id| id == &update.server_id) => { - if sender.send(Message::Text(update.payload)).await.is_err() { + if sender.send(Message::Text(update.payload.into())).await.is_err() { break; } } @@ -57,7 +61,7 @@ async fn run(socket: WebSocket, state: Arc, server_id: Option) }, incoming = receiver.next() => match incoming { Some(Ok(Message::Text(text))) if text == "ping" => { - if sender.send(Message::Text("pong".to_string())).await.is_err() { + if sender.send(Message::Text("pong".into())).await.is_err() { break; } } diff --git a/backend/src/websocket/mod.rs b/backend/src/websocket/mod.rs index 106d4d9..e46f768 100644 --- a/backend/src/websocket/mod.rs +++ b/backend/src/websocket/mod.rs @@ -6,7 +6,7 @@ use tokio::sync::mpsc; #[derive(Clone)] pub struct AgentConnection { pub connection_id: String, - pub sender: mpsc::UnboundedSender, + pub sender: mpsc::Sender, pub report_interval: i64, pub collect_interval: i64, } diff --git a/bun.lock b/bun.lock index feb2624..c4085cc 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ "name": "nodeflare-ui", "version": "0.0.1", "dependencies": { - "lucide-react": "^1.34.0", + "lucide-react": "^1.40.0", "react": "^19.2.8", "react-dom": "^19.2.8", "recharts": "^3.10.1", @@ -20,8 +20,8 @@ "devDependencies": { "@types/bun": "^1.4.0", "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.5", - "@vitejs/plugin-react": "^6.1.0", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", "typescript": "^7.0.2", "vite": "^8.2.2", }, @@ -95,7 +95,7 @@ "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], - "@types/react-dom": ["@types/react-dom@19.2.5", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg=="], + "@types/react-dom": ["@types/react-dom@19.2.7", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ=="], "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], @@ -139,7 +139,7 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.0", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="], "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], @@ -209,7 +209,7 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], - "lucide-react": ["lucide-react@1.34.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg=="], + "lucide-react": ["lucide-react@1.40.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MaG+8WnOXkDWz9XeElj7TnQ890tTZUB0a36i03aRRCKGWE6e7jJpmdCvtxxuxcjjdqyN6m4sL6qlHVuSUDtYgg=="], "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], diff --git a/deploy/nodeflare.service b/deploy/nodeflare.service index f6db41c..bc58bce 100644 --- a/deploy/nodeflare.service +++ b/deploy/nodeflare.service @@ -5,16 +5,27 @@ Wants=network-online.target [Service] Type=simple -WorkingDirectory=/opt/nodeflare/data/server +WorkingDirectory=/etc/nodeflare ExecStart=/opt/nodeflare/nodeflare --config /etc/nodeflare/config.toml Restart=on-failure RestartSec=5s -UMask=0027 +UMask=0077 NoNewPrivileges=true PrivateTmp=true +PrivateDevices=true ProtectHome=true -ProtectSystem=full -ReadWritePaths=/opt/nodeflare/data/server +ProtectSystem=strict +ProtectClock=true +ProtectControlGroups=true +ProtectHostname=true +ProtectKernelLogs=true +ProtectKernelModules=true +ProtectKernelTunables=true +LockPersonality=true +RestrictRealtime=true +RestrictSUIDSGID=true +SystemCallArchitectures=native +ReadWritePaths=/etc/nodeflare [Install] WantedBy=multi-user.target diff --git a/frontend/package.json b/frontend/package.json index 98fc25e..4af9ae7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,7 @@ "preview": "vite preview" }, "dependencies": { - "lucide-react": "^1.34.0", + "lucide-react": "^1.40.0", "react": "^19.2.8", "react-dom": "^19.2.8", "recharts": "^3.10.1" @@ -21,8 +21,8 @@ "devDependencies": { "@types/bun": "^1.4.0", "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.5", - "@vitejs/plugin-react": "^6.1.0", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", "typescript": "^7.0.2", "vite": "^8.2.2" } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 738a534..1b54f96 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -182,7 +182,7 @@ export default function App() {
{config.site_name}
- +
diff --git a/frontend/src/AppProvider.tsx b/frontend/src/AppProvider.tsx index dce0bb3..5877890 100644 --- a/frontend/src/AppProvider.tsx +++ b/frontend/src/AppProvider.tsx @@ -147,7 +147,7 @@ export function AppProvider({ children }: { children: ReactNode }) { const login = useCallback(async (username: string, password: string, turnstileToken: string, totpCode: string) => { const derived = await derivePassword(password, config.password_client_salt); - const result = await api.login(username.trim(), password, derived, turnstileToken, totpCode); + const result = await api.login(username.trim(), derived, turnstileToken, totpCode); setToken(result.token); setAccess("ok"); await reload(); diff --git a/frontend/src/adminRoutes.test.ts b/frontend/src/adminRoutes.test.ts index d14b067..6caa89c 100644 --- a/frontend/src/adminRoutes.test.ts +++ b/frontend/src/adminRoutes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { adminTabFromPath, adminTabPaths, type AdminTab } from "./adminRoutes"; +import { ADMIN_LOGIN_PATH, adminTabFromPath, adminTabPaths, canonicalAdminPath, type AdminTab } from "./adminRoutes"; describe("admin routes", () => { test("maps every sidebar entry to its own stable URL", () => { @@ -13,4 +13,18 @@ describe("admin routes", () => { expect(adminTabFromPath("/admin")).toBe("servers"); expect(adminTabFromPath("/admin/not-found")).toBe("servers"); }); + + test("sends every unauthenticated admin route to the login page", () => { + expect(canonicalAdminPath("/admin", false)).toBe(ADMIN_LOGIN_PATH); + expect(canonicalAdminPath("/admin/servers", false)).toBe(ADMIN_LOGIN_PATH); + expect(canonicalAdminPath("/admin/about", false)).toBe(ADMIN_LOGIN_PATH); + expect(canonicalAdminPath("/admin/login/", false)).toBe(ADMIN_LOGIN_PATH); + }); + + test("sends an authenticated login page to servers and preserves valid pages", () => { + expect(canonicalAdminPath("/admin/login", true)).toBe(adminTabPaths.servers); + expect(canonicalAdminPath("/admin/login/", true)).toBe(adminTabPaths.servers); + expect(canonicalAdminPath("/admin/about", true)).toBe(adminTabPaths.about); + expect(canonicalAdminPath("/admin/not-found", true)).toBe(adminTabPaths.servers); + }); }); diff --git a/frontend/src/adminRoutes.ts b/frontend/src/adminRoutes.ts index 96bd358..e6b5415 100644 --- a/frontend/src/adminRoutes.ts +++ b/frontend/src/adminRoutes.ts @@ -1,5 +1,7 @@ export type AdminTab = "servers" | "latency" | "appearance" | "themes" | "themeSettings" | "alerts" | "security" | "data" | "remote" | "about"; +export const ADMIN_LOGIN_PATH = "/admin/login"; + export const adminTabPaths: Record = { servers: "/admin/servers", latency: "/admin/latency", @@ -17,3 +19,10 @@ export function adminTabFromPath(pathname: string): AdminTab { const normalized = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname; return (Object.entries(adminTabPaths).find(([, path]) => path === normalized)?.[0] as AdminTab | undefined) ?? "servers"; } + +export function canonicalAdminPath(pathname: string, authenticated: boolean) { + if (!authenticated) return ADMIN_LOGIN_PATH; + const normalized = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname; + if (normalized === ADMIN_LOGIN_PATH) return adminTabPaths.servers; + return adminTabPaths[adminTabFromPath(normalized)]; +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index aed6fd1..deae784 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { AdminServer, AlertRule, AlertRuleInput, Bootstrap, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, RemoteTask, RemoteTaskCreated, RemoteTaskInput, Server, ServerInput, Settings, TelegramSettings, TelegramSettingsInput, Theme, ThemeSettingsSchema, TotpSetup, TotpStatus } from "./types"; +import type { AdminServer, AlertRule, AlertRuleInput, Bootstrap, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, LoginSession, RemoteTask, RemoteTaskCreated, RemoteTaskInput, Server, ServerInput, Settings, TelegramSettings, TelegramSettingsInput, Theme, ThemeSettingsSchema, TotpSetup, TotpStatus } from "./types"; const TOKEN_KEY = "nodeflare-admin-token"; export const ADMIN_UNAUTHORIZED_EVENT = "nodeflare:admin-unauthorized"; @@ -18,7 +18,7 @@ export function setToken(token: string) { else localStorage.removeItem(TOKEN_KEY); } -async function request(path: string, init: RequestInit = {}, admin = false): Promise { +async function requestResponse(path: string, init: RequestInit = {}, admin = false): Promise { const headers = new Headers(init.headers); if (typeof init.body === "string" && !headers.has("Content-Type")) headers.set("Content-Type", "application/json"); const token = getToken(); @@ -33,6 +33,11 @@ async function request(path: string, init: RequestInit = {}, admin = false): } throw new ApiError(payload.error ?? "请求失败", response.status); } + return response; +} + +async function request(path: string, init: RequestInit = {}, admin = false): Promise { + const response = await requestResponse(path, init, admin); if (response.status === 204) return undefined as T; return response.json() as Promise; } @@ -54,12 +59,15 @@ export const api = { request<{ tasks: LatencyTestPoint[]; points: LatencySample[] }>(`/api/latency/${encodeURIComponent(id)}?hours=${hours}`), verifyTurnstile: (token: string) => request("/api/turnstile/verify", { method: "POST", body: JSON.stringify({ token }) }), - login: (username: string, password: string, passwordDerived: string, turnstileToken: string, totpCode = "") => + login: (username: string, passwordDerived: string, turnstileToken: string, totpCode = "") => request<{ token: string }>("/api/admin/login", { method: "POST", - body: JSON.stringify({ username, password, password_derived: passwordDerived, turnstile_token: turnstileToken, totp_code: totpCode }), + body: JSON.stringify({ username, password_derived: passwordDerived, turnstile_token: turnstileToken, totp_code: totpCode }), }), logout: () => request("/api/admin/logout", { method: "POST" }), + loginSessions: () => request<{ sessions: LoginSession[] }>("/api/admin/sessions", {}, true), + revokeLoginSession: (id: string) => + request(`/api/admin/sessions/${encodeURIComponent(id)}`, { method: "DELETE" }, true), settings: () => request("/api/admin/settings", {}, true), twoFactorStatus: () => request("/api/admin/2fa/status", {}, true), setupTwoFactor: () => request("/api/admin/2fa/setup", { method: "POST" }, true), @@ -111,8 +119,8 @@ export const api = { { method: "POST", body: JSON.stringify(input) }, true, ), - serverToken: (id: string) => - request<{ agent_token: string }>(`/api/admin/servers/${encodeURIComponent(id)}/token`, {}, true), + rotateServerToken: (id: string) => + request<{ agent_token: string }>(`/api/admin/servers/${encodeURIComponent(id)}/token`, { method: "POST" }, true), updateServer: (id: string, input: ServerInput) => request( `/api/admin/servers/${encodeURIComponent(id)}`, @@ -137,6 +145,16 @@ export const api = { true, ), databaseStats: () => request("/api/admin/database", {}, true), + databaseBackup: async () => { + const response = await requestResponse("/api/admin/database/backup", {}, true); + const disposition = response.headers.get("Content-Disposition") ?? ""; + const filename = /filename="?([^";]+)"?/i.exec(disposition)?.[1] ?? "nodeflare-database-backup.zip"; + return { blob: await response.blob(), filename }; + }, + restoreDatabaseBackup: (file: File) => { + const query = new URLSearchParams({ filename: file.name }); + return request<{ restored_rows: number }>(`/api/admin/database/restore?${query}`, { method: "POST", body: file }, true); + }, clearHistory: () => request("/api/admin/history", { method: "DELETE" }, true), createRemoteTask: (input: RemoteTaskInput) => request<{ tasks: RemoteTaskCreated[] }>("/api/admin/remote/task", { method: "POST", diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 08e9014..0e009d8 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -14,6 +14,7 @@ import { GripVertical, KeyRound, LogOut, + MonitorSmartphone, Moon, Palette, Pencil, @@ -34,11 +35,11 @@ import { } from "lucide-react"; import { ChangeEvent, DragEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ADMIN_UNAUTHORIZED_EVENT, api, ApiError, getToken, setToken } from "../api"; -import { adminTabFromPath, adminTabPaths, type AdminTab } from "../adminRoutes"; +import { adminTabFromPath, adminTabPaths, canonicalAdminPath, type AdminTab } from "../adminRoutes"; import { formatByteSize, isOnline, parseByteSize } from "../format"; import { derivePassword } from "../password"; import { hasActiveRemoteTasks, isRemoteTaskActive, REMOTE_TASK_POLL_INTERVAL_MS } from "../refresh"; -import { ASSET_CURRENCIES, type AdminServer, type Config, type DatabaseStats, type ExchangeRates, type RemoteTask, type ServerInput, type Settings, type Theme, type ThemeSettingField, type ThemeSettingsSchema, type ThemeSettingValue, type TotpSetup, type TotpStatus } from "../types"; +import { ASSET_CURRENCIES, type AdminServer, type Config, type DatabaseStats, type ExchangeRates, type LoginSession, type RemoteTask, type ServerInput, type Settings, type Theme, type ThemeSettingField, type ThemeSettingsSchema, type ThemeSettingValue, type TotpSetup, type TotpStatus } from "../types"; import { Checkbox } from "./Checkbox"; import { TurnstileWidget } from "./TurnstileWidget"; import { useDialog } from "./useDialog"; @@ -66,9 +67,9 @@ const adminPages: Record = { themes: { title: "主题商店", description: "选择内置主题或安装本地主题包" }, themeSettings: { title: "主题设置", description: "调整当前前端主题提供的显示选项" }, alerts: { title: "通知", description: "配置 Telegram 通知和资源告警阈值" }, - security: { title: "登录与安全", description: "管理管理员账号和 Cloudflare Turnstile 防护" }, - data: { title: "监控数据库", description: "查看数据库统计、汇率状态并维护历史数据" }, - remote: { title: "远程执行", description: "输入一次性命令,选择服务器并通过 TOTP 验证后下发" }, + security: { title: "登录与安全", description: "管理管理员账号、登录设备和安全验证" }, + data: { title: "数据库", description: "查看状态、备份恢复并维护历史数据" }, + remote: { title: "远程执行", description: "输入命令并执行" }, about: { title: "关于", description: "版本信息与项目地址" }, }; @@ -81,7 +82,7 @@ const adminNavigation = [ { tab: "themeSettings" as const, label: "主题设置", icon: SlidersHorizontal }, { tab: "appearance" as const, label: "站点设置", icon: Eye }, { tab: "security" as const, label: "登录与安全", icon: ShieldCheck }, - { tab: "data" as const, label: "监控数据库", icon: Database }, + { tab: "data" as const, label: "数据库", icon: Database }, { tab: "about" as const, label: "关于", icon: Info }, ]; @@ -177,6 +178,25 @@ function formatDate(value: number | null) { return value ? new Date(value * 1000).toISOString().slice(0, 10) : ""; } +function formatSessionTime(value: number) { + return new Date(value * 1000).toLocaleString(); +} + +function describeLoginDevice(userAgent: string) { + const browser = userAgent.includes("Edg/") ? "Edge" + : userAgent.includes("Firefox/") ? "Firefox" + : userAgent.includes("Chrome/") ? "Chrome" + : userAgent.includes("Safari/") ? "Safari" + : "其他客户端"; + const system = userAgent.includes("Android") ? "Android" + : /iPhone|iPad/.test(userAgent) ? "iOS" + : userAgent.includes("Windows") ? "Windows" + : userAgent.includes("Mac OS") ? "macOS" + : userAgent.includes("Linux") ? "Linux" + : ""; + return system ? `${browser} · ${system}` : browser; +} + function settingPatch(settings: Settings, key: keyof Settings, value: unknown) { return { ...settings, [key]: value } as Settings; } @@ -239,6 +259,7 @@ export function AdminPanel({ const [settings, setSettings] = useState(null); const [database, setDatabase] = useState(null); const [exchangeRates, setExchangeRates] = useState(null); + const [exchangeRatesExpanded, setExchangeRatesExpanded] = useState(false); const [themes, setThemes] = useState([]); const [themeName, setThemeName] = useState(""); const [themeDescription, setThemeDescription] = useState(""); @@ -267,7 +288,11 @@ export function AdminPanel({ const [twoFactorSetup, setTwoFactorSetup] = useState(null); const [twoFactorCode, setTwoFactorCode] = useState(""); const [twoFactorSecretCopied, setTwoFactorSecretCopied] = useState(false); + const [loginSessions, setLoginSessions] = useState([]); + const [loginSessionsLoaded, setLoginSessionsLoaded] = useState(false); + const [revokingSessionId, setRevokingSessionId] = useState(""); const themeFileInputRef = useRef(null); + const databaseRestoreInputRef = useRef(null); const remoteTasksRef = useRef([]); const remoteTasksRequestRef = useRef(0); const remoteTasksActive = useMemo(() => hasActiveRemoteTasks(remoteTasks), [remoteTasks]); @@ -311,23 +336,26 @@ export function AdminPanel({ useEffect(() => { void load(); }, [load]); useEffect(() => { - const canonicalPath = adminTabPaths[adminTabFromPath(window.location.pathname)]; - if (window.location.pathname !== canonicalPath) { - window.history.replaceState(null, "", canonicalPath); - } - const handlePopState = () => { - setTab(adminTabFromPath(window.location.pathname)); + const syncAdminPath = () => { + const canonicalPath = canonicalAdminPath(window.location.pathname, authenticated); + if (window.location.pathname !== canonicalPath) { + window.history.replaceState(null, "", canonicalPath); + } + setTab(adminTabFromPath(canonicalPath)); setNotice(""); setError(""); }; + syncAdminPath(); + const handlePopState = () => syncAdminPath(); window.addEventListener("popstate", handlePopState); return () => window.removeEventListener("popstate", handlePopState); - }, []); + }, [authenticated]); useEffect(() => { if (!authenticated) return; if (tab === "themeSettings") void loadThemeSettings(); if (tab === "data" && !database) void loadDatabase(); + if (tab === "security") void loadLoginSessions(); }, [authenticated, tab]); useEffect(() => { @@ -364,7 +392,7 @@ export function AdminPanel({ setError(""); try { const passwordDerived = await derivePassword(password, config.password_client_salt); - const result = await api.login(username.trim(), password, passwordDerived, turnstileToken, loginTotpCode); + const result = await api.login(username.trim(), passwordDerived, turnstileToken, loginTotpCode); setToken(result.token); setPassword(""); setTurnstileToken(""); @@ -463,13 +491,14 @@ export function AdminPanel({ } async function showInstallCommand(server: AdminServer) { + if (!window.confirm(`重新生成“${server.name}”的 Agent Token?当前 Agent 会立即离线,需使用新安装命令重新连接。`)) return; setBusy(true); setError(""); try { - const { agent_token } = await api.serverToken(server.id); + const { agent_token } = await api.rotateServerToken(server.id); setInstall({ agent_token, agent_mirror: server.agent_mirror }); } catch (reason) { - setError(reason instanceof Error ? reason.message : "读取 Agent Token 失败"); + setError(reason instanceof Error ? reason.message : "重置 Agent Token 失败"); } finally { setBusy(false); } @@ -538,6 +567,7 @@ export function AdminPanel({ try { if (payload.new_password) { payload.new_password_derived = await derivePassword(payload.new_password, config.password_client_salt); + delete payload.new_password; } else { delete payload.new_password; delete payload.new_password_derived; @@ -545,6 +575,7 @@ export function AdminPanel({ const result = await api.saveSettings(payload); if (result.token) setToken(result.token); setSettings(result.settings); + if (tab === "security") await loadLoginSessions(); setNotice("设置已保存"); onChanged(); } catch (reason) { setError(reason instanceof Error ? reason.message : "保存设置失败"); } @@ -572,6 +603,39 @@ export function AdminPanel({ finally { setBusy(false); } } + async function exportDatabaseBackup() { + setBusy(true); setError(""); setNotice(""); + try { + const { blob, filename } = await api.databaseBackup(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); + setNotice("数据库备份已导出"); + } catch (reason) { setError(reason instanceof Error ? reason.message : "导出数据库备份失败"); } + finally { setBusy(false); } + } + + async function restoreDatabaseBackup(file: File) { + if (file.size > 512 * 1024 * 1024) { + setError("数据库备份 ZIP 不能超过 512 MiB"); + return; + } + if (!window.confirm("恢复将覆盖当前数据库,并使现有登录会话失效。确认继续?")) return; + setBusy(true); setError(""); setNotice(""); + try { + const result = await api.restoreDatabaseBackup(file); + setToken(""); + window.alert(`数据库已恢复 ${result.restored_rows.toLocaleString()} 行,请使用备份中的管理员账号重新登录。`); + window.location.reload(); + } catch (reason) { setError(reason instanceof Error ? reason.message : "恢复数据库备份失败"); } + finally { setBusy(false); } + } + async function loadThemeSettings() { setBusy(true); setError(""); try { setThemeSettingsSchema(await api.themeSettings()); } @@ -654,12 +718,37 @@ export function AdminPanel({ finally { setBusy(false); } } + async function loadLoginSessions() { + try { + const result = await api.loginSessions(); + setLoginSessions(result.sessions); + setLoginSessionsLoaded(true); + } catch (reason) { + if (!(reason instanceof ApiError && reason.status === 401)) { + setError(reason instanceof Error ? reason.message : "读取登录设备失败"); + } + } + } + + async function revokeLoginSession(session: LoginSession) { + if (session.current || !window.confirm(`确认让“${describeLoginDevice(session.user_agent)}”下线?`)) return; + setRevokingSessionId(session.id); setError(""); setNotice(""); + try { + await api.revokeLoginSession(session.id); + setLoginSessions((current) => current.filter((item) => item.id !== session.id)); + setNotice("设备已踢下线"); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "设备下线失败"); + } finally { setRevokingSessionId(""); } + } + async function logout() { try { await api.logout(); } finally { setToken(""); setAuthenticated(false); setServers([]); setSettings(null); setSelectedIds([]); setTwoFactorStatus(null); setTwoFactorSetup(null); setTwoFactorCode(""); setRemoteTotpCode(""); setTwoFactorSecretCopied(false); + setLoginSessions([]); setLoginSessionsLoaded(false); setRevokingSessionId(""); setRemoteSelectedIds([]); setRemoteCommand(""); setRemoteTasks([]); setRemoteQuery(""); } } @@ -928,13 +1017,10 @@ export function AdminPanel({
))} {!servers.length && !busy ?
暂无节点
: null} @@ -990,13 +1076,13 @@ export function AdminPanel({
-

安装主题

主题会安装到本机,不依赖运行时远程资源。
+

安装主题

主题包含可执行前端代码,只安装可信来源;安装后不依赖运行时远程资源。
- +

{themeSourceMode === "repository" ? "填写仓库主页地址,NodeFlare 会下载 latest Release 中的第一个 ZIP 文件。" : "ZIP 根目录需包含 index.html,也支持外层只有一个目录的打包方式;最大 32 MiB。"}

-
{themeSourceMode === "repository" ? : }
+
{themeSourceMode === "repository" ? : }