Fixes and improvements
This commit is contained in:
1 parent
1c9540c13c
commit
f24efd1401
33 files changed
+2618
-1093
No files matched your search
+110
-18
@@ -1,12 +1,18 @@
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use hmac::{Hmac, Mac};
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use pbkdf2::pbkdf2_hmac;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use worker::{Date, Env, Request};
|
||||
use worker::{
|
||||
js_sys::{Function, Reflect, Uint8Array},
|
||||
wasm_bindgen::{JsCast, JsValue},
|
||||
Date, Env, Request,
|
||||
};
|
||||
|
||||
const SESSION_SECONDS: i64 = 7 * 24 * 60 * 60;
|
||||
const TURNSTILE_PROOF_SECONDS: i64 = 60 * 60;
|
||||
const PASSWORD_ROUNDS: usize = 10_000;
|
||||
const THEME_PREVIEW_SECONDS: i64 = 10 * 60;
|
||||
const SERVER_PASSWORD_ROUNDS: u32 = 10_000;
|
||||
const JWT_HEADER: &str = r#"{"alg":"HS256","typ":"JWT"}"#;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
@@ -47,36 +53,63 @@ pub fn admin_username(env: &Env, configured: &str) -> String {
|
||||
.unwrap_or_else(|_| "admin".to_string())
|
||||
}
|
||||
|
||||
fn password_digest(password: &str, salt: &str) -> String {
|
||||
let mut digest = Sha256::digest(format!("nodeflare:{salt}:{password}").as_bytes()).to_vec();
|
||||
for _ in 1..PASSWORD_ROUNDS {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&digest);
|
||||
hasher.update(salt.as_bytes());
|
||||
digest = hasher.finalize().to_vec();
|
||||
}
|
||||
fn password_digest(password_derived: &str, salt: &str, rounds: u32) -> String {
|
||||
let mut digest = [0_u8; 32];
|
||||
pbkdf2_hmac::<Sha256>(
|
||||
password_derived.as_bytes(),
|
||||
salt.as_bytes(),
|
||||
rounds,
|
||||
&mut digest,
|
||||
);
|
||||
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
pub fn hash_password(password: &str, salt: &str) -> String {
|
||||
format!("sha256${salt}${}", password_digest(password, salt))
|
||||
pub fn hash_password(password_derived: &str, salt: &str) -> String {
|
||||
format!(
|
||||
"pbkdf2_sha256_client_v1${SERVER_PASSWORD_ROUNDS}${salt}${}",
|
||||
password_digest(password_derived, salt, SERVER_PASSWORD_ROUNDS)
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_password_hash(password: &str, encoded: &str) -> bool {
|
||||
pub fn random_salt() -> Option<String> {
|
||||
let global = worker::js_sys::global();
|
||||
let crypto = Reflect::get(&global, &JsValue::from_str("crypto")).ok()?;
|
||||
let get_random_values = Reflect::get(&crypto, &JsValue::from_str("getRandomValues"))
|
||||
.ok()?
|
||||
.dyn_into::<Function>()
|
||||
.ok()?;
|
||||
let salt = Uint8Array::new_with_length(16);
|
||||
get_random_values.call1(&crypto, &salt).ok()?;
|
||||
Some(
|
||||
salt.to_vec()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_password_hash(password_derived: &str, encoded: &str) -> bool {
|
||||
let mut parts = encoded.split('$');
|
||||
let Some(algorithm) = parts.next() else {
|
||||
return false;
|
||||
};
|
||||
let Some(rounds) = parts.next().and_then(|value| value.parse::<u32>().ok()) else {
|
||||
return false;
|
||||
};
|
||||
let Some(salt) = parts.next() else {
|
||||
return false;
|
||||
};
|
||||
let Some(expected) = parts.next() else {
|
||||
return false;
|
||||
};
|
||||
if algorithm != "sha256" || salt.is_empty() || parts.next().is_some() {
|
||||
if algorithm != "pbkdf2_sha256_client_v1"
|
||||
|| rounds != SERVER_PASSWORD_ROUNDS
|
||||
|| salt.len() < 32
|
||||
|| parts.next().is_some()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
secure_eq(&password_digest(password, salt), expected)
|
||||
secure_eq(&password_digest(password_derived, salt, rounds), expected)
|
||||
}
|
||||
|
||||
pub fn verify_credentials(
|
||||
@@ -85,6 +118,7 @@ pub fn verify_credentials(
|
||||
password_hash: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
password_derived: &str,
|
||||
) -> bool {
|
||||
let expected_username = admin_username(env, configured_username);
|
||||
let username_valid = secure_eq(&expected_username, username.trim());
|
||||
@@ -93,7 +127,7 @@ pub fn verify_credentials(
|
||||
.map(|expected| secure_eq(&expected, password))
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
verify_password_hash(password, password_hash)
|
||||
verify_password_hash(password_derived, password_hash)
|
||||
};
|
||||
username_valid && password_valid
|
||||
}
|
||||
@@ -198,6 +232,43 @@ pub fn verify_turnstile_proof(value: &str, env: &Env, password_hash: &str) -> bo
|
||||
secure_eq(&expected, signature)
|
||||
}
|
||||
|
||||
pub fn create_theme_preview_proof(
|
||||
env: &Env,
|
||||
password_hash: &str,
|
||||
theme_id: &str,
|
||||
) -> Option<String> {
|
||||
let secret = signing_secret(env, password_hash)?;
|
||||
let expires = Date::now().as_millis() as i64 / 1000 + THEME_PREVIEW_SECONDS;
|
||||
let payload = format!("{theme_id}.{expires}");
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
|
||||
mac.update(format!("nodeflare-theme-preview:{payload}").as_bytes());
|
||||
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
|
||||
Some(format!("{payload}.{signature}"))
|
||||
}
|
||||
|
||||
pub fn verify_theme_preview_proof(value: &str, env: &Env, password_hash: &str) -> Option<String> {
|
||||
let secret = signing_secret(env, password_hash)?;
|
||||
let mut parts = value.split('.');
|
||||
let (Some(theme_id), Some(expires), Some(signature), None) =
|
||||
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if theme_id.is_empty() || theme_id.len() > 80 || theme_id.contains('/') {
|
||||
return None;
|
||||
}
|
||||
let expires = expires.parse::<i64>().ok()?;
|
||||
let current = Date::now().as_millis() as i64 / 1000;
|
||||
if expires < current || expires > current + THEME_PREVIEW_SECONDS + 60 {
|
||||
return None;
|
||||
}
|
||||
let signature = URL_SAFE_NO_PAD.decode(signature).ok()?;
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
|
||||
mac.update(format!("nodeflare-theme-preview:{theme_id}.{expires}").as_bytes());
|
||||
mac.verify_slice(&signature).ok()?;
|
||||
Some(theme_id.to_string())
|
||||
}
|
||||
|
||||
pub fn bearer_token(req: &Request) -> Option<String> {
|
||||
req.headers()
|
||||
.get("Authorization")
|
||||
@@ -208,7 +279,28 @@ pub fn bearer_token(req: &Request) -> Option<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{create_admin_jwt_with_secret, verify_admin_jwt, SESSION_SECONDS};
|
||||
use super::{
|
||||
create_admin_jwt_with_secret, hash_password, verify_admin_jwt, verify_password_hash,
|
||||
SESSION_SECONDS,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn hashes_passwords_with_pbkdf2() {
|
||||
let encoded = hash_password(
|
||||
"correct horse battery staple",
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
);
|
||||
assert!(encoded.starts_with("pbkdf2_sha256_client_v1$10000$"));
|
||||
assert!(verify_password_hash(
|
||||
"correct horse battery staple",
|
||||
&encoded
|
||||
));
|
||||
assert!(!verify_password_hash("wrong", &encoded));
|
||||
assert!(!verify_password_hash(
|
||||
"correct horse battery staple",
|
||||
"sha256$0123456789abcdef0123456789abcdef$invalid"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signs_and_verifies_admin_jwt() {
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
use worker::{wasm_bindgen::JsValue, D1Database, Date, Result};
|
||||
|
||||
use crate::models::{
|
||||
AgentConfigView, AgentReport, AlertRuleInput, AlertRuleView, HistoryPoint, ServerInput,
|
||||
ServerView, SettingsInput, TokenHashRow,
|
||||
ServerView, SettingsInput, ThemeInput, ThemeView, TokenHashRow,
|
||||
};
|
||||
|
||||
const SERVER_SELECT: &str = r#"
|
||||
SELECT
|
||||
s.id, s.name, s.region, s.group_name, s.tags, s.note, s.hidden,
|
||||
s.sort_order, s.expires_at, s.traffic_limit, s.traffic_limit_type,
|
||||
s.expires_at, s.traffic_limit, s.traffic_limit_type,
|
||||
s.price, s.billing_cycle, s.currency, s.auto_renewal, s.public_remark,
|
||||
s.network_interface, s.reset_day, s.report_interval, s.collect_interval,
|
||||
s.rx_correction, s.tx_correction, s.offline_notify_disabled, s.auto_update,
|
||||
s.created_at, s.updated_at,
|
||||
s.created_at,
|
||||
m.timestamp, m.cpu, m.load1, m.load5, m.load15, m.mem_used, m.mem_total,
|
||||
m.swap_used, m.swap_total, m.disk_used, m.disk_total, m.net_in, m.net_out,
|
||||
CASE WHEN m.net_rx_total IS NULL THEN NULL ELSE m.net_rx_total + s.rx_correction END AS net_rx_total,
|
||||
CASE WHEN m.net_tx_total IS NULL THEN NULL ELSE m.net_tx_total + s.tx_correction END AS net_tx_total,
|
||||
m.uptime, m.processes, m.tcp_connections,
|
||||
m.udp_connections, m.cpu_cores, m.cpu_model, m.os, m.kernel, m.arch,
|
||||
m.virtualization, m.ipv4, m.ipv6, m.gpu_usage, m.gpu_model, m.agent_version,
|
||||
m.virtualization, m.gpu_usage, m.gpu_model, m.agent_version,
|
||||
m.disk_read_bps, m.disk_write_bps, m.disk_read_iops, m.disk_write_iops,
|
||||
m.disk_await_ms, m.disk_utilization, m.disk_info, m.gpu_info,
|
||||
m.message
|
||||
@@ -30,12 +30,45 @@ FROM servers s
|
||||
LEFT JOIN latest_metrics m ON m.server_id = s.id
|
||||
"#;
|
||||
|
||||
pub const SECRET_MASK: &str = "********";
|
||||
|
||||
fn secret_for_api(value: &str) -> &str {
|
||||
if value.is_empty() {
|
||||
""
|
||||
} else {
|
||||
SECRET_MASK
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_secret<S>(value: &str, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(secret_for_api(value))
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct SettingRow {
|
||||
key: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ThemeRow {
|
||||
id: String,
|
||||
name: String,
|
||||
description: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AgentConfigRow {
|
||||
report_interval: i64,
|
||||
collect_interval: i64,
|
||||
network_interface: String,
|
||||
auto_update: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SettingsView {
|
||||
pub site_name: String,
|
||||
@@ -46,7 +79,10 @@ pub struct SettingsView {
|
||||
pub public_dashboard: bool,
|
||||
pub offline_threshold_seconds: i64,
|
||||
pub history_retention_days: i64,
|
||||
#[serde(skip_serializing)]
|
||||
pub history_cache_version: i64,
|
||||
pub default_theme: String,
|
||||
pub active_theme_id: String,
|
||||
pub background_url: String,
|
||||
pub theme_options: serde_json::Value,
|
||||
pub show_search: bool,
|
||||
@@ -63,16 +99,21 @@ pub struct SettingsView {
|
||||
pub admin_password_configured: bool,
|
||||
#[serde(skip_serializing)]
|
||||
pub admin_password_hash: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub password_client_salt: String,
|
||||
pub turnstile_enabled: bool,
|
||||
pub turnstile_login_enabled: bool,
|
||||
pub turnstile_site_key: String,
|
||||
#[serde(serialize_with = "serialize_secret")]
|
||||
pub turnstile_secret_key: String,
|
||||
pub notification_enabled: bool,
|
||||
#[serde(serialize_with = "serialize_secret")]
|
||||
pub notification_endpoint: String,
|
||||
pub notification_target: String,
|
||||
pub offline_alert_minutes: i64,
|
||||
pub expiry_alert_days: i64,
|
||||
pub cloudflare_account_id: String,
|
||||
#[serde(serialize_with = "serialize_secret")]
|
||||
pub cloudflare_api_token: String,
|
||||
}
|
||||
|
||||
@@ -104,8 +145,6 @@ struct AlertRuleRow {
|
||||
duration_minutes: i64,
|
||||
aggregation: String,
|
||||
enabled: i64,
|
||||
created_at: i64,
|
||||
updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -118,6 +157,10 @@ pub struct AlertMetricRow {
|
||||
pub server_id: String,
|
||||
pub name: String,
|
||||
pub value: f64,
|
||||
pub sample_count: i64,
|
||||
pub first_timestamp: i64,
|
||||
pub last_timestamp: i64,
|
||||
pub report_interval: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -170,7 +213,7 @@ pub async fn get_token_hash(db: &D1Database, id: &str) -> Result<Option<TokenHas
|
||||
}
|
||||
|
||||
pub async fn agent_config(db: &D1Database, id: &str) -> Result<Option<AgentConfigView>> {
|
||||
let mut config: Option<AgentConfigView> = db
|
||||
let row: Option<AgentConfigRow> = db
|
||||
.prepare(
|
||||
"SELECT report_interval, collect_interval, network_interface, auto_update \
|
||||
FROM servers WHERE id = ?1",
|
||||
@@ -178,11 +221,16 @@ pub async fn agent_config(db: &D1Database, id: &str) -> Result<Option<AgentConfi
|
||||
.bind(&[text(id)])?
|
||||
.first(None)
|
||||
.await?;
|
||||
if let Some(config) = config.as_mut() {
|
||||
config.network_interface = config.network_interface.trim().to_string();
|
||||
config.latency_tasks = crate::latency::tasks_for_server(db, id).await?;
|
||||
}
|
||||
Ok(config)
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(AgentConfigView {
|
||||
report_interval: row.report_interval,
|
||||
collect_interval: row.collect_interval,
|
||||
network_interface: row.network_interface.trim().to_string(),
|
||||
auto_update: row.auto_update,
|
||||
latency_tasks: crate::latency::tasks_for_server(db, id).await?,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn create_server(
|
||||
@@ -329,8 +377,8 @@ pub async fn reorder_servers(db: &D1Database, ids: &[String]) -> Result<()> {
|
||||
pub async fn list_alert_rules(db: &D1Database) -> Result<Vec<AlertRuleView>> {
|
||||
let rows: Vec<AlertRuleRow> = db
|
||||
.prepare(
|
||||
"SELECT id, name, metric, threshold, duration_minutes, aggregation, enabled, \
|
||||
created_at, updated_at FROM alert_rules ORDER BY created_at ASC",
|
||||
"SELECT id, name, metric, threshold, duration_minutes, aggregation, enabled \
|
||||
FROM alert_rules ORDER BY created_at ASC",
|
||||
)
|
||||
.all()
|
||||
.await?
|
||||
@@ -355,8 +403,6 @@ pub async fn list_alert_rules(db: &D1Database) -> Result<Vec<AlertRuleView>> {
|
||||
aggregation: row.aggregation,
|
||||
enabled: row.enabled,
|
||||
server_ids: servers.into_iter().map(|row| row.server_id).collect(),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
});
|
||||
}
|
||||
Ok(rules)
|
||||
@@ -453,20 +499,41 @@ pub async fn alert_metric_values(
|
||||
"AVG"
|
||||
};
|
||||
let since = current_time - rule.duration_minutes.clamp(1, 1440) * 60;
|
||||
let minimum_samples = rule.duration_minutes.clamp(1, 1440);
|
||||
let query = format!(
|
||||
"SELECT s.id AS server_id, s.name, {aggregate}({metric}) AS value \
|
||||
"SELECT s.id AS server_id, s.name, {aggregate}({metric}) AS value, \
|
||||
COUNT(*) AS sample_count, MIN(h.timestamp) AS first_timestamp, \
|
||||
MAX(h.timestamp) AS last_timestamp, s.report_interval \
|
||||
FROM servers s JOIN metric_history h ON h.server_id=s.id \
|
||||
WHERE h.timestamp >= ?1 AND s.hidden=0 AND (\
|
||||
NOT EXISTS(SELECT 1 FROM alert_rule_servers ars WHERE ars.rule_id=?2) OR \
|
||||
EXISTS(SELECT 1 FROM alert_rule_servers ars WHERE ars.rule_id=?2 AND ars.server_id=s.id)\
|
||||
) GROUP BY s.id, s.name HAVING COUNT(*) >= ?3"
|
||||
) GROUP BY s.id, s.name, s.report_interval"
|
||||
);
|
||||
db.prepare(query)
|
||||
.bind(&[number(since), text(&rule.id), number(minimum_samples)])?
|
||||
let rows: Vec<AlertMetricRow> = db
|
||||
.prepare(query)
|
||||
.bind(&[number(since), text(&rule.id)])?
|
||||
.all()
|
||||
.await?
|
||||
.results()
|
||||
.results()?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter(|row| alert_window_covered(row, rule.duration_minutes, current_time))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn alert_window_covered(row: &AlertMetricRow, duration_minutes: i64, current_time: i64) -> bool {
|
||||
let window_seconds = duration_minutes.clamp(1, 1440) * 60;
|
||||
let report_interval = row.report_interval.clamp(15, 3600).max(60);
|
||||
let expected_samples = ((window_seconds + report_interval - 1) / report_interval).max(1);
|
||||
let required_samples = if expected_samples == 1 {
|
||||
1
|
||||
} else {
|
||||
((expected_samples * 3 + 4) / 5).max(2)
|
||||
};
|
||||
let fresh_within = (report_interval * 2).max(120);
|
||||
row.sample_count >= required_samples
|
||||
&& row.first_timestamp <= row.last_timestamp
|
||||
&& current_time.saturating_sub(row.last_timestamp) <= fresh_within
|
||||
}
|
||||
|
||||
pub async fn active_alert_states(db: &D1Database) -> Result<std::collections::HashSet<String>> {
|
||||
@@ -481,16 +548,25 @@ pub async fn active_alert_states(db: &D1Database) -> Result<std::collections::Ha
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn replace_active_alert_states(
|
||||
pub async fn sync_active_alert_states(
|
||||
db: &D1Database,
|
||||
keys: &std::collections::HashSet<String>,
|
||||
previous: &std::collections::HashSet<String>,
|
||||
current: &std::collections::HashSet<String>,
|
||||
) -> Result<()> {
|
||||
let mut statements = vec![db.prepare("DELETE FROM alert_states")];
|
||||
let mut statements = Vec::new();
|
||||
let delete = db.prepare("DELETE FROM alert_states WHERE rule_id=?1 AND server_id=?2");
|
||||
let insert = db.prepare(
|
||||
"INSERT INTO alert_states(rule_id, server_id, active, updated_at) VALUES (?1, ?2, 1, ?3)",
|
||||
"INSERT INTO alert_states(rule_id, server_id, active, updated_at) VALUES (?1, ?2, 1, ?3) \
|
||||
ON CONFLICT(rule_id, server_id) DO UPDATE SET active=1, updated_at=excluded.updated_at",
|
||||
);
|
||||
let timestamp = now();
|
||||
for key in keys {
|
||||
for key in previous.difference(current) {
|
||||
let Some((rule_id, server_id)) = key.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
statements.push(delete.clone().bind(&[text(rule_id), text(server_id)])?);
|
||||
}
|
||||
for key in current.difference(previous) {
|
||||
let Some((rule_id, server_id)) = key.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
@@ -500,7 +576,9 @@ pub async fn replace_active_alert_states(
|
||||
number(timestamp),
|
||||
])?);
|
||||
}
|
||||
db.batch(statements).await?;
|
||||
if !statements.is_empty() {
|
||||
db.batch(statements).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -568,14 +646,13 @@ pub async fn save_reports(db: &D1Database, server_id: &str, reports: &[AgentRepo
|
||||
swap_used, swap_total, disk_used, disk_total, net_in, net_out,
|
||||
net_rx_total, net_tx_total, uptime, processes, tcp_connections,
|
||||
udp_connections, cpu_cores, cpu_model, os, kernel, arch,
|
||||
virtualization, ipv4, ipv6, gpu_usage, gpu_model, agent_version,
|
||||
virtualization, gpu_usage, gpu_model, agent_version,
|
||||
disk_read_bps, disk_write_bps, disk_read_iops, disk_write_iops,
|
||||
disk_await_ms, disk_utilization, disk_info, gpu_info, message
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14,
|
||||
?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26,
|
||||
?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38,
|
||||
?39, ?40
|
||||
?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38
|
||||
) ON CONFLICT(server_id) DO UPDATE SET
|
||||
timestamp=excluded.timestamp, cpu=excluded.cpu, load1=excluded.load1,
|
||||
load5=excluded.load5, load15=excluded.load15, mem_used=excluded.mem_used,
|
||||
@@ -587,8 +664,7 @@ pub async fn save_reports(db: &D1Database, server_id: &str, reports: &[AgentRepo
|
||||
tcp_connections=excluded.tcp_connections, udp_connections=excluded.udp_connections,
|
||||
cpu_cores=excluded.cpu_cores, cpu_model=excluded.cpu_model, os=excluded.os,
|
||||
kernel=excluded.kernel, arch=excluded.arch,
|
||||
virtualization=excluded.virtualization, ipv4=excluded.ipv4, ipv6=excluded.ipv6,
|
||||
gpu_usage=excluded.gpu_usage, gpu_model=excluded.gpu_model,
|
||||
virtualization=excluded.virtualization, gpu_usage=excluded.gpu_usage, gpu_model=excluded.gpu_model,
|
||||
agent_version=excluded.agent_version,
|
||||
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,
|
||||
@@ -676,8 +752,6 @@ fn report_values(server_id: &str, report: &AgentReport, timestamp: i64) -> Vec<J
|
||||
text(&report.kernel),
|
||||
text(&report.arch),
|
||||
text(&report.virtualization),
|
||||
text(&report.ipv4),
|
||||
text(&report.ipv6),
|
||||
number(report.gpu_usage),
|
||||
text(&report.gpu_model),
|
||||
text(&report.agent_version),
|
||||
@@ -778,7 +852,9 @@ pub async fn settings(
|
||||
"history_retention_days",
|
||||
default_retention.clamp(1, 365),
|
||||
),
|
||||
history_cache_version: integer_setting(&values, "history_cache_version", 0),
|
||||
default_theme: string_setting(&values, "default_theme", "system"),
|
||||
active_theme_id: string_setting(&values, "active_theme_id", crate::theme::BUILTIN_THEME_ID),
|
||||
background_url: string_setting(&values, "background_url", ""),
|
||||
theme_options: values
|
||||
.get("theme_options")
|
||||
@@ -800,6 +876,7 @@ pub async fn settings(
|
||||
.get("admin_password_hash")
|
||||
.is_some_and(|value| !value.is_empty()),
|
||||
admin_password_hash: string_setting(&values, "admin_password_hash", ""),
|
||||
password_client_salt: string_setting(&values, "password_client_salt", ""),
|
||||
turnstile_enabled: bool_setting(&values, "turnstile_enabled", false),
|
||||
turnstile_login_enabled: bool_setting(&values, "turnstile_login_enabled", true),
|
||||
turnstile_site_key: string_setting(&values, "turnstile_site_key", ""),
|
||||
@@ -863,6 +940,9 @@ pub async fn update_settings(
|
||||
if let Some(value) = input.default_theme.as_deref() {
|
||||
push_setting!("default_theme", value);
|
||||
}
|
||||
if let Some(value) = input.active_theme_id.as_deref() {
|
||||
push_setting!("active_theme_id", value.trim());
|
||||
}
|
||||
if let Some(value) = input.background_url.as_deref() {
|
||||
push_setting!("background_url", value.trim());
|
||||
}
|
||||
@@ -918,13 +998,21 @@ pub async fn update_settings(
|
||||
if let Some(value) = input.turnstile_site_key.as_deref() {
|
||||
push_setting!("turnstile_site_key", value.trim());
|
||||
}
|
||||
if let Some(value) = input.turnstile_secret_key.as_deref() {
|
||||
if let Some(value) = input
|
||||
.turnstile_secret_key
|
||||
.as_deref()
|
||||
.filter(|value| value.trim() != SECRET_MASK)
|
||||
{
|
||||
push_setting!("turnstile_secret_key", value.trim());
|
||||
}
|
||||
if let Some(value) = input.notification_enabled {
|
||||
push_setting!("notification_enabled", if value { "true" } else { "false" });
|
||||
}
|
||||
if let Some(value) = input.notification_endpoint.as_deref() {
|
||||
if let Some(value) = input
|
||||
.notification_endpoint
|
||||
.as_deref()
|
||||
.filter(|value| value.trim() != SECRET_MASK)
|
||||
{
|
||||
push_setting!("notification_endpoint", value.trim());
|
||||
}
|
||||
if let Some(value) = input.notification_target.as_deref() {
|
||||
@@ -946,13 +1034,104 @@ pub async fn update_settings(
|
||||
};
|
||||
}
|
||||
push_trimmed!(input.cloudflare_account_id, "cloudflare_account_id");
|
||||
push_trimmed!(input.cloudflare_api_token, "cloudflare_api_token");
|
||||
if let Some(value) = input
|
||||
.cloudflare_api_token
|
||||
.as_deref()
|
||||
.filter(|value| value.trim() != SECRET_MASK)
|
||||
{
|
||||
push_setting!("cloudflare_api_token", value.trim());
|
||||
}
|
||||
if !statements.is_empty() {
|
||||
db.batch(statements).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_themes(db: &D1Database, active_id: &str) -> Result<Vec<ThemeView>> {
|
||||
let rows: Vec<ThemeRow> = db
|
||||
.prepare("SELECT id, name, description, url FROM themes ORDER BY created_at DESC")
|
||||
.all()
|
||||
.await?
|
||||
.results()?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let active = row.id == active_id;
|
||||
ThemeView {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
url: row.url,
|
||||
builtin: false,
|
||||
active,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn create_theme(
|
||||
db: &D1Database,
|
||||
id: &str,
|
||||
input: &ThemeInput,
|
||||
source_url: &str,
|
||||
timestamp: i64,
|
||||
) -> Result<()> {
|
||||
db.prepare(
|
||||
"INSERT INTO themes(id, name, description, url, created_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
)
|
||||
.bind(&[
|
||||
text(id),
|
||||
text(input.name.trim()),
|
||||
text(input.description.trim()),
|
||||
text(source_url),
|
||||
number(timestamp),
|
||||
])?
|
||||
.run()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn theme_exists(db: &D1Database, id: &str) -> Result<bool> {
|
||||
let row: Option<ThemeRow> = db
|
||||
.prepare("SELECT id, name, description, url FROM themes WHERE id = ?1")
|
||||
.bind(&[text(id)])?
|
||||
.first(None)
|
||||
.await?;
|
||||
Ok(row.is_some())
|
||||
}
|
||||
|
||||
pub async fn theme_url(db: &D1Database, id: &str) -> Result<Option<String>> {
|
||||
let row: Option<ThemeRow> = db
|
||||
.prepare("SELECT id, name, description, url FROM themes WHERE id = ?1")
|
||||
.bind(&[text(id)])?
|
||||
.first(None)
|
||||
.await?;
|
||||
row.map(|theme| crate::theme::resolve_url(&theme.url).map(|resolved| resolved.resolved_url))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub async fn set_active_theme(db: &D1Database, id: &str) -> Result<bool> {
|
||||
if id != crate::theme::BUILTIN_THEME_ID && !theme_exists(db, id).await? {
|
||||
return Ok(false);
|
||||
}
|
||||
save_setting(db, "active_theme_id", id).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn delete_theme(db: &D1Database, id: &str) -> Result<bool> {
|
||||
let result = db
|
||||
.prepare("DELETE FROM themes WHERE id = ?1")
|
||||
.bind(&[text(id)])?
|
||||
.run()
|
||||
.await?;
|
||||
let deleted = result.meta()?.and_then(|meta| meta.changes).unwrap_or(0) > 0;
|
||||
if deleted && get_setting(db, "active_theme_id").await?.as_deref() == Some(id) {
|
||||
save_setting(db, "active_theme_id", crate::theme::BUILTIN_THEME_ID).await?;
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
pub async fn cleanup_history(db: &D1Database, retention_days: i64) -> Result<()> {
|
||||
let cutoff = now() - retention_days.clamp(1, 365) * 86_400;
|
||||
let recent_cutoff = now() - 7 * 86_400;
|
||||
@@ -1120,3 +1299,47 @@ pub async fn update_expiry(db: &D1Database, id: &str, expires_at: i64) -> Result
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{alert_window_covered, secret_for_api, AlertMetricRow, SECRET_MASK};
|
||||
|
||||
fn row(samples: i64, last_timestamp: i64, report_interval: i64) -> AlertMetricRow {
|
||||
AlertMetricRow {
|
||||
server_id: "server".to_string(),
|
||||
name: "Server".to_string(),
|
||||
value: 90.0,
|
||||
sample_count: samples,
|
||||
first_timestamp: last_timestamp - samples.saturating_sub(1) * 60,
|
||||
last_timestamp,
|
||||
report_interval,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alert_coverage_tracks_report_interval_and_freshness() {
|
||||
let current = 10_000;
|
||||
assert!(alert_window_covered(&row(6, current - 30, 60), 10, current));
|
||||
assert!(!alert_window_covered(
|
||||
&row(5, current - 30, 60),
|
||||
10,
|
||||
current
|
||||
));
|
||||
assert!(alert_window_covered(
|
||||
&row(2, current - 60, 300),
|
||||
10,
|
||||
current
|
||||
));
|
||||
assert!(!alert_window_covered(
|
||||
&row(2, current - 700, 300),
|
||||
10,
|
||||
current
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_stored_secrets_in_api_responses() {
|
||||
assert_eq!(secret_for_api(""), "");
|
||||
assert_eq!(secret_for_api("stored-secret"), SECRET_MASK);
|
||||
}
|
||||
}
|
||||
+4
-13
@@ -13,9 +13,6 @@ struct TaskRow {
|
||||
target: String,
|
||||
interval_seconds: i64,
|
||||
default_enabled: i64,
|
||||
sort_order: i64,
|
||||
created_at: i64,
|
||||
updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -32,10 +29,7 @@ pub struct LatencyTaskView {
|
||||
pub target: String,
|
||||
pub interval_seconds: i64,
|
||||
pub default_enabled: bool,
|
||||
pub sort_order: i64,
|
||||
pub server_ids: Vec<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -61,8 +55,8 @@ fn number(value: impl ToString) -> JsValue {
|
||||
pub async fn list_tasks(db: &D1Database) -> Result<Vec<LatencyTaskView>> {
|
||||
let rows: Vec<TaskRow> = db
|
||||
.prepare(
|
||||
"SELECT id, name, task_type, target, interval_seconds, default_enabled, \
|
||||
sort_order, created_at, updated_at FROM latency_tasks \
|
||||
"SELECT id, name, task_type, target, interval_seconds, default_enabled \
|
||||
FROM latency_tasks \
|
||||
ORDER BY sort_order ASC, created_at ASC",
|
||||
)
|
||||
.all()
|
||||
@@ -93,9 +87,6 @@ pub async fn list_tasks(db: &D1Database) -> Result<Vec<LatencyTaskView>> {
|
||||
target: row.target,
|
||||
interval_seconds: row.interval_seconds,
|
||||
default_enabled: row.default_enabled != 0,
|
||||
sort_order: row.sort_order,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -153,8 +144,8 @@ pub async fn update_task(
|
||||
) -> Result<bool> {
|
||||
let exists: Option<TaskRow> = db
|
||||
.prepare(
|
||||
"SELECT id, name, task_type, target, interval_seconds, default_enabled, \
|
||||
sort_order, created_at, updated_at FROM latency_tasks WHERE id = ?1",
|
||||
"SELECT id, name, task_type, target, interval_seconds, default_enabled \
|
||||
FROM latency_tasks WHERE id = ?1",
|
||||
)
|
||||
.bind(&[text(id)])?
|
||||
.first(None)
|
||||
|
||||
+945
-194
File diff suppressed because it is too large.
Load diff
+5
-5
@@ -7,6 +7,9 @@ pub struct LiveHub {
|
||||
|
||||
impl DurableObject for LiveHub {
|
||||
fn new(state: State, _env: Env) -> Self {
|
||||
if let Ok(pair) = WebSocketRequestResponsePair::new("ping", "pong") {
|
||||
state.set_websocket_auto_response(&pair);
|
||||
}
|
||||
Self { state }
|
||||
}
|
||||
|
||||
@@ -51,12 +54,9 @@ impl DurableObject for LiveHub {
|
||||
|
||||
async fn websocket_message(
|
||||
&self,
|
||||
ws: WebSocket,
|
||||
message: WebSocketIncomingMessage,
|
||||
_ws: WebSocket,
|
||||
_message: WebSocketIncomingMessage,
|
||||
) -> Result<()> {
|
||||
if matches!(message, WebSocketIncomingMessage::String(ref value) if value == "ping") {
|
||||
ws.send_with_str("pong")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+45
-120
@@ -1,102 +1,49 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn default_group() -> String {
|
||||
"默认".to_string()
|
||||
}
|
||||
|
||||
fn default_price() -> f64 {
|
||||
0.0
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_billing_cycle() -> i64 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_currency() -> String {
|
||||
"CNY".to_string()
|
||||
}
|
||||
|
||||
fn default_traffic_limit_type() -> String {
|
||||
"sum".to_string()
|
||||
}
|
||||
|
||||
fn default_reset_day() -> i64 {
|
||||
1
|
||||
}
|
||||
|
||||
fn default_report_interval() -> i64 {
|
||||
60
|
||||
}
|
||||
|
||||
fn default_collect_interval() -> i64 {
|
||||
5
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
pub password_derived: String,
|
||||
pub turnstile_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TurnstileVerifyRequest {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ServerInput {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub region: String,
|
||||
#[serde(default = "default_group")]
|
||||
pub group_name: String,
|
||||
#[serde(default)]
|
||||
pub tags: String,
|
||||
#[serde(default)]
|
||||
pub note: String,
|
||||
#[serde(default)]
|
||||
pub hidden: bool,
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub traffic_limit: i64,
|
||||
#[serde(default = "default_traffic_limit_type")]
|
||||
pub traffic_limit_type: String,
|
||||
#[serde(default = "default_price")]
|
||||
pub price: f64,
|
||||
#[serde(default = "default_billing_cycle")]
|
||||
pub billing_cycle: i64,
|
||||
#[serde(default = "default_currency")]
|
||||
pub currency: String,
|
||||
#[serde(default)]
|
||||
pub auto_renewal: bool,
|
||||
#[serde(default)]
|
||||
pub public_remark: String,
|
||||
#[serde(default)]
|
||||
pub network_interface: String,
|
||||
#[serde(default = "default_reset_day")]
|
||||
pub reset_day: i64,
|
||||
#[serde(default = "default_report_interval")]
|
||||
pub report_interval: i64,
|
||||
#[serde(default = "default_collect_interval")]
|
||||
pub collect_interval: i64,
|
||||
#[serde(default)]
|
||||
pub rx_correction: i64,
|
||||
#[serde(default)]
|
||||
pub tx_correction: i64,
|
||||
#[serde(default)]
|
||||
pub offline_notify_disabled: bool,
|
||||
#[serde(default = "default_enabled")]
|
||||
pub auto_update: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SettingsInput {
|
||||
pub site_name: Option<String>,
|
||||
pub site_description: Option<String>,
|
||||
@@ -107,6 +54,7 @@ pub struct SettingsInput {
|
||||
pub offline_threshold_seconds: Option<i64>,
|
||||
pub history_retention_days: Option<i64>,
|
||||
pub default_theme: Option<String>,
|
||||
pub active_theme_id: Option<String>,
|
||||
pub background_url: Option<String>,
|
||||
pub theme_options: Option<serde_json::Value>,
|
||||
pub show_search: Option<bool>,
|
||||
@@ -121,6 +69,7 @@ pub struct SettingsInput {
|
||||
pub show_uptime: Option<bool>,
|
||||
pub admin_username: Option<String>,
|
||||
pub new_password: Option<String>,
|
||||
pub new_password_derived: Option<String>,
|
||||
pub turnstile_enabled: Option<bool>,
|
||||
pub turnstile_login_enabled: Option<bool>,
|
||||
pub turnstile_site_key: Option<String>,
|
||||
@@ -135,24 +84,43 @@ pub struct SettingsInput {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ThemeInput {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ThemeView {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub url: String,
|
||||
pub builtin: bool,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ServerOrderInput {
|
||||
pub ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ServerBatchInput {
|
||||
pub ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LatencyTaskInput {
|
||||
pub name: String,
|
||||
pub task_type: String,
|
||||
pub target: String,
|
||||
pub interval_seconds: i64,
|
||||
#[serde(default)]
|
||||
pub default_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub server_ids: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -166,6 +134,7 @@ pub struct AgentLatencyTask {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AgentLatencyResult {
|
||||
pub task_id: String,
|
||||
pub timestamp: i64,
|
||||
@@ -173,7 +142,8 @@ pub struct AgentLatencyResult {
|
||||
pub packet_loss: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AgentDiskMetric {
|
||||
pub name: String,
|
||||
pub mount_point: String,
|
||||
@@ -187,7 +157,8 @@ pub struct AgentDiskMetric {
|
||||
pub utilization: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AgentGpuMetric {
|
||||
pub model: String,
|
||||
pub usage: f64,
|
||||
@@ -195,93 +166,53 @@ pub struct AgentGpuMetric {
|
||||
pub memory_total: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AgentReportBatch {
|
||||
pub server_id: String,
|
||||
pub samples: Vec<AgentReport>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AgentReport {
|
||||
#[serde(default)]
|
||||
pub timestamp: i64,
|
||||
#[serde(default)]
|
||||
pub cpu: f64,
|
||||
#[serde(default)]
|
||||
pub load1: f64,
|
||||
#[serde(default)]
|
||||
pub load5: f64,
|
||||
#[serde(default)]
|
||||
pub load15: f64,
|
||||
#[serde(default)]
|
||||
pub mem_used: i64,
|
||||
#[serde(default)]
|
||||
pub mem_total: i64,
|
||||
#[serde(default)]
|
||||
pub swap_used: i64,
|
||||
#[serde(default)]
|
||||
pub swap_total: i64,
|
||||
#[serde(default)]
|
||||
pub disk_used: i64,
|
||||
#[serde(default)]
|
||||
pub disk_total: i64,
|
||||
#[serde(default)]
|
||||
pub net_in: f64,
|
||||
#[serde(default)]
|
||||
pub net_out: f64,
|
||||
#[serde(default)]
|
||||
pub net_rx_total: i64,
|
||||
#[serde(default)]
|
||||
pub net_tx_total: i64,
|
||||
#[serde(default)]
|
||||
pub uptime: i64,
|
||||
#[serde(default)]
|
||||
pub processes: i64,
|
||||
#[serde(default)]
|
||||
pub tcp_connections: i64,
|
||||
#[serde(default)]
|
||||
pub udp_connections: i64,
|
||||
#[serde(default)]
|
||||
pub cpu_cores: i64,
|
||||
#[serde(default)]
|
||||
pub cpu_model: String,
|
||||
#[serde(default)]
|
||||
pub os: String,
|
||||
#[serde(default)]
|
||||
pub kernel: String,
|
||||
#[serde(default)]
|
||||
pub arch: String,
|
||||
#[serde(default)]
|
||||
pub virtualization: String,
|
||||
#[serde(default)]
|
||||
pub ipv4: String,
|
||||
#[serde(default)]
|
||||
pub ipv6: String,
|
||||
#[serde(default)]
|
||||
pub gpu_usage: f64,
|
||||
#[serde(default)]
|
||||
pub gpu_model: String,
|
||||
#[serde(default)]
|
||||
pub agent_version: String,
|
||||
#[serde(default)]
|
||||
pub disk_read_bps: f64,
|
||||
#[serde(default)]
|
||||
pub disk_write_bps: f64,
|
||||
#[serde(default)]
|
||||
pub disk_read_iops: f64,
|
||||
#[serde(default)]
|
||||
pub disk_write_iops: f64,
|
||||
#[serde(default)]
|
||||
pub disk_await_ms: f64,
|
||||
#[serde(default)]
|
||||
pub disk_utilization: f64,
|
||||
#[serde(default)]
|
||||
pub disks: Vec<AgentDiskMetric>,
|
||||
#[serde(default)]
|
||||
pub gpus: Vec<AgentGpuMetric>,
|
||||
#[serde(default)]
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub latency_results: Vec<AgentLatencyResult>,
|
||||
}
|
||||
|
||||
@@ -294,7 +225,6 @@ pub struct ServerView {
|
||||
pub tags: String,
|
||||
pub note: String,
|
||||
pub hidden: i64,
|
||||
pub sort_order: i64,
|
||||
pub expires_at: Option<i64>,
|
||||
pub traffic_limit: i64,
|
||||
pub traffic_limit_type: String,
|
||||
@@ -311,8 +241,8 @@ pub struct ServerView {
|
||||
pub tx_correction: i64,
|
||||
pub offline_notify_disabled: i64,
|
||||
pub auto_update: i64,
|
||||
#[serde(skip_serializing)]
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub timestamp: Option<i64>,
|
||||
pub cpu: Option<f64>,
|
||||
pub load1: Option<f64>,
|
||||
@@ -338,8 +268,6 @@ pub struct ServerView {
|
||||
pub kernel: Option<String>,
|
||||
pub arch: Option<String>,
|
||||
pub virtualization: Option<String>,
|
||||
pub ipv4: Option<String>,
|
||||
pub ipv6: Option<String>,
|
||||
pub gpu_usage: Option<f64>,
|
||||
pub gpu_model: Option<String>,
|
||||
pub agent_version: Option<String>,
|
||||
@@ -349,7 +277,9 @@ pub struct ServerView {
|
||||
pub disk_write_iops: Option<f64>,
|
||||
pub disk_await_ms: Option<f64>,
|
||||
pub disk_utilization: Option<f64>,
|
||||
#[serde(skip_serializing)]
|
||||
pub disk_info: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
pub gpu_info: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
@@ -389,30 +319,28 @@ pub struct TokenHashRow {
|
||||
pub hidden: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AgentConfigView {
|
||||
pub report_interval: i64,
|
||||
pub collect_interval: i64,
|
||||
pub network_interface: String,
|
||||
pub auto_update: i64,
|
||||
#[serde(default)]
|
||||
pub latency_tasks: Vec<AgentLatencyTask>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AlertRuleInput {
|
||||
pub name: String,
|
||||
pub metric: String,
|
||||
pub threshold: f64,
|
||||
pub duration_minutes: i64,
|
||||
pub aggregation: String,
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub server_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AlertRuleView {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
@@ -421,10 +349,7 @@ pub struct AlertRuleView {
|
||||
pub duration_minutes: i64,
|
||||
pub aggregation: String,
|
||||
pub enabled: i64,
|
||||
#[serde(default)]
|
||||
pub server_ids: Vec<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
||||
+29
-6
@@ -5,7 +5,7 @@ use worker::{wasm_bindgen::JsValue, D1Database, Fetch, Method, Request, RequestI
|
||||
|
||||
use crate::db::{self, SettingsView};
|
||||
|
||||
#[derive(Default, Deserialize, Serialize)]
|
||||
#[derive(Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
struct AlertState {
|
||||
offline: HashSet<String>,
|
||||
expiry: HashSet<String>,
|
||||
@@ -63,7 +63,7 @@ pub async fn check_alerts(db_conn: &D1Database, settings: &SettingsView) -> Resu
|
||||
|
||||
let servers = db::list_servers(db_conn, true).await?;
|
||||
let stored = db::get_setting(db_conn, "alert_state").await?;
|
||||
let mut previous: AlertState = stored
|
||||
let previous: AlertState = stored
|
||||
.as_deref()
|
||||
.and_then(|value| serde_json::from_str(value).ok())
|
||||
.unwrap_or_default();
|
||||
@@ -72,7 +72,7 @@ pub async fn check_alerts(db_conn: &D1Database, settings: &SettingsView) -> Resu
|
||||
let offline_after = settings.offline_alert_minutes.clamp(2, 1440) * 60;
|
||||
let mut messages = Vec::new();
|
||||
|
||||
for server in servers {
|
||||
for server in &servers {
|
||||
let server_id = server.id.clone();
|
||||
let is_offline = server
|
||||
.timestamp
|
||||
@@ -119,10 +119,26 @@ pub async fn check_alerts(db_conn: &D1Database, settings: &SettingsView) -> Resu
|
||||
|
||||
let previous_resources = db::active_alert_states(db_conn).await?;
|
||||
let mut current_resources = HashSet::new();
|
||||
let mut evaluated_resources = HashSet::new();
|
||||
let mut eligible_resources = HashSet::new();
|
||||
for rule in db::list_alert_rules(db_conn).await? {
|
||||
if rule.enabled == 0 {
|
||||
continue;
|
||||
}
|
||||
if rule.server_ids.is_empty() {
|
||||
eligible_resources.extend(
|
||||
servers
|
||||
.iter()
|
||||
.filter(|server| server.hidden == 0)
|
||||
.map(|server| format!("{}:{}", rule.id, server.id)),
|
||||
);
|
||||
} else {
|
||||
eligible_resources.extend(
|
||||
rule.server_ids
|
||||
.iter()
|
||||
.map(|server_id| format!("{}:{server_id}", rule.id)),
|
||||
);
|
||||
}
|
||||
let metric_label = match rule.metric.as_str() {
|
||||
"cpu" => "CPU",
|
||||
"memory" => "内存",
|
||||
@@ -138,6 +154,7 @@ pub async fn check_alerts(db_conn: &D1Database, settings: &SettingsView) -> Resu
|
||||
};
|
||||
for value in db::alert_metric_values(db_conn, &rule, current_time).await? {
|
||||
let key = format!("{}:{}", rule.id, value.server_id);
|
||||
evaluated_resources.insert(key.clone());
|
||||
if value.value >= rule.threshold {
|
||||
current_resources.insert(key.clone());
|
||||
if !previous_resources.contains(&key) {
|
||||
@@ -163,12 +180,18 @@ pub async fn check_alerts(db_conn: &D1Database, settings: &SettingsView) -> Resu
|
||||
}
|
||||
}
|
||||
}
|
||||
for key in &previous_resources {
|
||||
if eligible_resources.contains(key) && !evaluated_resources.contains(key) {
|
||||
current_resources.insert(key.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if !messages.is_empty() {
|
||||
let body = format!("NodeFlare 告警\n\n{}", messages.join("\n"));
|
||||
send(settings, &body).await?;
|
||||
}
|
||||
previous = current;
|
||||
db::save_setting(db_conn, "alert_state", &serde_json::to_string(&previous)?).await?;
|
||||
db::replace_active_alert_states(db_conn, ¤t_resources).await
|
||||
if previous != current {
|
||||
db::save_setting(db_conn, "alert_state", &serde_json::to_string(¤t)?).await?;
|
||||
}
|
||||
db::sync_active_alert_states(db_conn, &previous_resources, ¤t_resources).await
|
||||
}
|
||||
+218
-1
@@ -1,4 +1,185 @@
|
||||
use futures_util::TryStreamExt;
|
||||
use serde_json::Value;
|
||||
use worker::{Error, Fetch, Method, Request, Response, Result, Url};
|
||||
|
||||
pub const BUILTIN_THEME_ID: &str = "builtin-komari-glass";
|
||||
pub const BUILTIN_THEME_NAME: &str = "Komari Glass";
|
||||
pub const INDEX_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
pub const ASSET_MAX_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
pub struct ResolvedTheme {
|
||||
pub source_url: String,
|
||||
pub resolved_url: String,
|
||||
}
|
||||
|
||||
pub fn normalize_url(value: &str) -> Option<String> {
|
||||
let raw = value.trim();
|
||||
if !(12..=2048).contains(&raw.len()) || raw.contains('\\') {
|
||||
return None;
|
||||
}
|
||||
let parsed = Url::parse(raw).ok()?;
|
||||
if parsed.scheme() != "https"
|
||||
|| parsed.host_str() != Some("github.com")
|
||||
|| parsed.port().is_some()
|
||||
|| parsed.username() != ""
|
||||
|| parsed.password().is_some()
|
||||
|| parsed.query().is_some()
|
||||
|| parsed.fragment().is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let path = parsed.path().trim_matches('/');
|
||||
let parts: Vec<_> = path.split('/').filter(|part| !part.is_empty()).collect();
|
||||
if parts.len() < 4
|
||||
|| parts[2] != "tree"
|
||||
|| parts.iter().any(|part| {
|
||||
part.is_empty()
|
||||
|| !part
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || ".-_".contains(character))
|
||||
|| matches!(*part, "." | "..")
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(format!("https://github.com/{}", parts.join("/")))
|
||||
}
|
||||
|
||||
pub fn resolve_url(value: &str) -> Result<ResolvedTheme> {
|
||||
let source_url =
|
||||
normalize_url(value).ok_or_else(|| Error::RustError("主题 URL 格式无效".to_string()))?;
|
||||
let parsed = Url::parse(&source_url)?;
|
||||
|
||||
let parts: Vec<_> = parsed
|
||||
.path()
|
||||
.trim_matches('/')
|
||||
.split('/')
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect();
|
||||
let owner = parts[0];
|
||||
let repository = parts[1];
|
||||
let reference = parts[3];
|
||||
let subdirectory = parts[4..].join("/");
|
||||
let suffix = if subdirectory.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("/{subdirectory}")
|
||||
};
|
||||
Ok(ResolvedTheme {
|
||||
source_url,
|
||||
resolved_url: format!(
|
||||
"https://raw.githubusercontent.com/{owner}/{repository}/{reference}{suffix}"
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn remote_url(base: &str, relative: &str) -> String {
|
||||
format!("{}/{}", base.trim_end_matches('/'), relative)
|
||||
}
|
||||
|
||||
fn raw_github_base(base: &str) -> Option<Url> {
|
||||
let parsed = Url::parse(&format!("{}/", base.trim_end_matches('/'))).ok()?;
|
||||
if parsed.scheme() != "https"
|
||||
|| parsed.host_str() != Some("raw.githubusercontent.com")
|
||||
|| parsed.port().is_some()
|
||||
|| parsed.username() != ""
|
||||
|| parsed.password().is_some()
|
||||
|| parsed.query().is_some()
|
||||
|| parsed.fragment().is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let parts = parsed
|
||||
.path()
|
||||
.trim_matches('/')
|
||||
.split('/')
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
(parts.len() >= 3
|
||||
&& parts.iter().all(|part| {
|
||||
!matches!(*part, "." | "..")
|
||||
&& part
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || ".-_".contains(character))
|
||||
}))
|
||||
.then_some(parsed)
|
||||
}
|
||||
|
||||
pub fn asset_url(base: &str, path: &str) -> Option<String> {
|
||||
let relative = path.strip_prefix("/assets/")?;
|
||||
if relative.is_empty()
|
||||
|| relative.len() > 1024
|
||||
|| relative
|
||||
.chars()
|
||||
.any(|character| character.is_control() || matches!(character, '%' | '\\' | '?' | '#'))
|
||||
|| relative
|
||||
.split('/')
|
||||
.any(|part| part.is_empty() || part == "." || part == "..")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let base = raw_github_base(base)?;
|
||||
let remote = base.join(&format!("assets/{relative}")).ok()?;
|
||||
let expected_path = format!("{}assets/{relative}", base.path());
|
||||
if remote.origin() != base.origin() || remote.path() != expected_path {
|
||||
return None;
|
||||
}
|
||||
Some(remote.to_string())
|
||||
}
|
||||
|
||||
pub fn index_url(base: &str) -> Option<String> {
|
||||
raw_github_base(base).map(|base| remote_url(base.as_str(), "index.html"))
|
||||
}
|
||||
|
||||
pub async fn read_response_limited(
|
||||
response: &mut Response,
|
||||
limit: usize,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
let declared = response
|
||||
.headers()
|
||||
.get("Content-Length")?
|
||||
.and_then(|value| value.parse::<usize>().ok());
|
||||
if declared.is_some_and(|length| length == 0 || length > limit) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut body = Vec::with_capacity(declared.unwrap_or(0).min(limit));
|
||||
let mut stream = response.stream()?;
|
||||
while let Some(mut chunk) = stream.try_next().await? {
|
||||
let Some(length) = body.len().checked_add(chunk.len()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if length > limit {
|
||||
return Ok(None);
|
||||
}
|
||||
body.append(&mut chunk);
|
||||
}
|
||||
Ok((!body.is_empty()).then_some(body))
|
||||
}
|
||||
|
||||
pub async fn validate_remote(base: &str) -> Result<()> {
|
||||
let index = index_url(base)
|
||||
.ok_or_else(|| Error::RustError("主题资源地址不是 GitHub Raw 地址".to_string()))?;
|
||||
let request = Request::new(&index, Method::Get)?;
|
||||
let mut response = Fetch::Request(request).send().await?;
|
||||
let status = response.status_code();
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(Error::RustError(format!(
|
||||
"主题 index.html 返回 HTTP {status}"
|
||||
)));
|
||||
}
|
||||
let body = read_response_limited(&mut response, INDEX_MAX_BYTES)
|
||||
.await?
|
||||
.ok_or_else(|| Error::RustError("主题 index.html 内容无效".to_string()))?;
|
||||
let body = String::from_utf8(body)
|
||||
.map_err(|_| Error::RustError("主题 index.html 不是 UTF-8 文本".to_string()))?;
|
||||
if body.trim().is_empty() {
|
||||
return Err(Error::RustError("主题 index.html 内容无效".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn settings_schema() -> Value {
|
||||
let currencies = [
|
||||
@@ -35,7 +216,7 @@ pub fn settings_schema() -> Value {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::settings_schema;
|
||||
use super::{asset_url, normalize_url, resolve_url, settings_schema};
|
||||
|
||||
#[test]
|
||||
fn exposes_builtin_glass_theme_settings() {
|
||||
@@ -45,4 +226,40 @@ mod tests {
|
||||
assert!(settings.iter().any(|field| field["key"] == "enableBlur"));
|
||||
assert_eq!(schema["source"], "builtin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_only_github_tree_urls() {
|
||||
assert_eq!(
|
||||
normalize_url("https://github.com/acme/theme/tree/abc123").as_deref(),
|
||||
Some("https://github.com/acme/theme/tree/abc123")
|
||||
);
|
||||
assert!(normalize_url("https://themes.example.com/nodeflare").is_none());
|
||||
assert!(normalize_url("http://themes.example.com/theme").is_none());
|
||||
assert!(normalize_url("https://user@example.com/theme").is_none());
|
||||
assert!(normalize_url("https://github.com/acme/theme").is_none());
|
||||
assert_eq!(
|
||||
resolve_url("https://github.com/acme/theme/tree/main/dist")
|
||||
.expect("GitHub theme URL")
|
||||
.resolved_url,
|
||||
"https://raw.githubusercontent.com/acme/theme/main/dist"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_proxy_paths_to_theme_assets() {
|
||||
assert_eq!(
|
||||
asset_url(
|
||||
"https://raw.githubusercontent.com/acme/theme/main",
|
||||
"/assets/app.js"
|
||||
)
|
||||
.as_deref(),
|
||||
Some("https://raw.githubusercontent.com/acme/theme/main/assets/app.js")
|
||||
);
|
||||
assert!(asset_url("https://example.com/theme", "/assets/app.js").is_none());
|
||||
assert!(asset_url("https://example.com/theme", "/logo.svg").is_none());
|
||||
assert!(asset_url("https://example.com/theme", "/assets/../secret").is_none());
|
||||
assert!(asset_url("https://example.com/theme", "/assets/%2e%2e/secret").is_none());
|
||||
assert!(asset_url("https://example.com/theme", "/assets/..\\secret").is_none());
|
||||
assert!(asset_url("https://example.com/theme", "/assets/a%2fb.js").is_none());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user