删除冗余代码
This commit is contained in:
1 parent
41d9e343a1
commit
a01b54a58f
12 files changed
+210
-166
No files matched your search
@@ -39,6 +39,39 @@ jobs:
|
||||
version="${RAW_VERSION#v}"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
quality:
|
||||
name: Quality checks
|
||||
needs: metadata
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Check Rust formatting
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Check Agent formatting
|
||||
run: cargo fmt --manifest-path agent/Cargo.toml -- --check
|
||||
- name: Lint Worker
|
||||
run: cargo clippy --locked --all-targets -- -D warnings
|
||||
- name: Lint Agent
|
||||
run: cargo clippy --locked --manifest-path agent/Cargo.toml --all-targets -- -D warnings
|
||||
- name: Test Worker
|
||||
run: cargo test --locked --lib
|
||||
- name: Test Agent
|
||||
run: cargo test --locked --manifest-path agent/Cargo.toml
|
||||
- name: Build frontend and Worker
|
||||
run: bun run build
|
||||
|
||||
agent:
|
||||
name: Agent (${{ matrix.platform }})
|
||||
needs: metadata
|
||||
@@ -187,7 +220,7 @@ jobs:
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
if: github.event_name == 'push'
|
||||
needs: [metadata, agent, frontend]
|
||||
needs: [metadata, quality, agent, frontend]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
|
||||
+36
-9
@@ -63,6 +63,7 @@ struct RuntimeConfig {
|
||||
report_interval: u64,
|
||||
collect_interval: u64,
|
||||
network_interface: String,
|
||||
agent_mirror: String,
|
||||
auto_update: bool,
|
||||
latency_tasks: Vec<LatencyTask>,
|
||||
}
|
||||
@@ -179,6 +180,7 @@ struct RemoteConfig {
|
||||
report_interval: u64,
|
||||
collect_interval: u64,
|
||||
network_interface: String,
|
||||
agent_mirror: String,
|
||||
auto_update: bool,
|
||||
latency_tasks: Vec<LatencyTask>,
|
||||
}
|
||||
@@ -1258,6 +1260,7 @@ fn runtime_config(options: &CliOptions) -> Result<RuntimeConfig> {
|
||||
report_interval: interval,
|
||||
collect_interval: 5,
|
||||
network_interface: String::new(),
|
||||
agent_mirror: String::new(),
|
||||
auto_update: true,
|
||||
latency_tasks: Vec::new(),
|
||||
})
|
||||
@@ -1561,6 +1564,10 @@ fn apply_remote(config: &mut RuntimeConfig, remote: &RemoteConfig) -> bool {
|
||||
config
|
||||
.network_interface
|
||||
.clone_from(&remote.network_interface);
|
||||
let mirror = remote.agent_mirror.trim().trim_end_matches('/');
|
||||
if mirror.is_empty() || valid_endpoint(mirror) {
|
||||
config.agent_mirror = mirror.to_string();
|
||||
}
|
||||
config.auto_update = remote.auto_update;
|
||||
let tasks = sanitize_latency_tasks(&remote.latency_tasks);
|
||||
let changed = config.latency_tasks != tasks;
|
||||
@@ -1761,7 +1768,16 @@ fn download_agent(
|
||||
Ok(valid)
|
||||
}
|
||||
|
||||
fn update(agent: &ureq::Agent) -> Result<bool> {
|
||||
fn mirrored_download_url(mirror: &str, url: &str) -> String {
|
||||
let mirror = mirror.trim().trim_end_matches('/');
|
||||
if mirror.is_empty() {
|
||||
url.to_string()
|
||||
} else {
|
||||
format!("{mirror}/{url}")
|
||||
}
|
||||
}
|
||||
|
||||
fn update(agent: &ureq::Agent, mirror: &str) -> Result<bool> {
|
||||
let mut response = agent
|
||||
.get(LATEST_RELEASE_API)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
@@ -1800,7 +1816,7 @@ fn update(agent: &ureq::Agent) -> Result<bool> {
|
||||
};
|
||||
if !download_agent(
|
||||
agent,
|
||||
&release_asset.browser_download_url,
|
||||
&mirrored_download_url(mirror, &release_asset.browser_download_url),
|
||||
&temporary,
|
||||
&release.tag_name,
|
||||
&expected_sha256,
|
||||
@@ -1943,7 +1959,7 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> {
|
||||
}
|
||||
}
|
||||
if !once && config.auto_update && Instant::now() >= next_update_check {
|
||||
match update(&agent) {
|
||||
match update(&agent, &config.agent_mirror) {
|
||||
Ok(true) => return Ok(()),
|
||||
Ok(false) => {
|
||||
next_update_check = Instant::now() + UPDATE_CHECK_INTERVAL;
|
||||
@@ -2005,12 +2021,12 @@ mod tests {
|
||||
use super::{
|
||||
ack_interval, agent_artifact_name, clock_offset_from_http_date, corrected_timestamp,
|
||||
executable_format_valid, gpu_name_from_uevent, is_public_probe_ip, live_endpoint,
|
||||
live_update_payload, median, normalized_version, parse_lspci_gpu_names, parse_probe_target,
|
||||
parse_system_profiler_gpu_names, ping_latency, prune_report_samples, release_asset_sha256,
|
||||
report_retry_delay, sanitize_latency_tasks, selected_interface, tcp_latency_probe_address,
|
||||
valid_endpoint, version_triplet, CliOptions, ClockCalibration, GithubReleaseAsset,
|
||||
LatencyResult, LatencyTask, LiveAck, Report, CLOCK_CALIBRATION_MAX_AGE, MAX_LATENCY_TASKS,
|
||||
MAX_PENDING_LATENCY_RESULTS, PROBE_ATTEMPTS,
|
||||
live_update_payload, median, mirrored_download_url, normalized_version,
|
||||
parse_lspci_gpu_names, parse_probe_target, parse_system_profiler_gpu_names, ping_latency,
|
||||
prune_report_samples, release_asset_sha256, report_retry_delay, sanitize_latency_tasks,
|
||||
selected_interface, tcp_latency_probe_address, valid_endpoint, version_triplet, CliOptions,
|
||||
ClockCalibration, GithubReleaseAsset, LatencyResult, LatencyTask, LiveAck, Report,
|
||||
CLOCK_CALIBRATION_MAX_AGE, MAX_LATENCY_TASKS, MAX_PENDING_LATENCY_RESULTS, PROBE_ATTEMPTS,
|
||||
};
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
@@ -2103,6 +2119,17 @@ mod tests {
|
||||
assert!(!valid_endpoint("https://bad host.example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_mirrored_download_urls() {
|
||||
let release =
|
||||
"https://github.com/imengying/NodeFlare/releases/download/v1.2.3/agent-linux-x86_64";
|
||||
assert_eq!(mirrored_download_url("", release), release);
|
||||
assert_eq!(
|
||||
mirrored_download_url("https://mirror.example.com/", release),
|
||||
format!("https://mirror.example.com/{release}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_live_websocket_endpoints() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -28,6 +28,7 @@ CREATE TABLE servers (
|
||||
collect_interval INTEGER NOT NULL DEFAULT 5,
|
||||
rx_correction INTEGER NOT NULL DEFAULT 0,
|
||||
tx_correction INTEGER NOT NULL DEFAULT 0,
|
||||
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,
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Agent 下载加速前缀(拼接在 GitHub Release 下载地址之前),空字符串表示直连。
|
||||
ALTER TABLE servers ADD COLUMN agent_mirror TEXT NOT NULL DEFAULT '';
|
||||
@@ -67,7 +67,7 @@ request -H "Authorization: Bearer $admin_token" \
|
||||
"$MONITOR_BASE_URL/api/admin/themes" | \
|
||||
jq -e '.themes | any(.builtin == true and .id == "builtin-nodeflare-glass" and .name == "NodeFlare Glass" and .active == true)' >/dev/null
|
||||
|
||||
server_input='{"name":"Smoke Test Node","region":"JP","group_name":"Test","tags":"smoke","hidden":false,"expires_at":1893456000,"traffic_limit":107374182400,"traffic_limit_type":"max","price":9.9,"billing_cycle":30,"currency":"USD","auto_renewal":true,"network_interface":"","reset_day":1,"report_interval":60,"collect_interval":5,"rx_correction":0,"tx_correction":0,"offline_notify_disabled":false,"auto_update":true}'
|
||||
server_input='{"name":"Smoke Test Node","region":"JP","group_name":"Test","tags":"smoke","hidden":false,"expires_at":1893456000,"traffic_limit":107374182400,"traffic_limit_type":"max","price":9.9,"billing_cycle":30,"currency":"USD","auto_renewal":true,"network_interface":"","reset_day":1,"report_interval":60,"collect_interval":5,"rx_correction":0,"tx_correction":0,"agent_mirror":"https://mirror.example.com","offline_notify_disabled":false,"auto_update":true}'
|
||||
|
||||
server_id=
|
||||
latency_task_id=
|
||||
@@ -130,7 +130,7 @@ report_response=$(request -H "Authorization: Bearer $agent_token" -H 'Content-Ty
|
||||
--data "$report" "$MONITOR_BASE_URL/api/agent/report")
|
||||
printf '%s' "$report_response" | jq -e \
|
||||
--arg task_id "$latency_task_id" \
|
||||
'.collect_interval == 5 and .auto_update == true and (.latency_tasks | any(.id == $task_id and .task_type == "tcp" and .target == "example.com" and .port == 443))' >/dev/null
|
||||
'.collect_interval == 5 and .agent_mirror == "https://mirror.example.com" and .auto_update == true and (.latency_tasks | any(.id == $task_id and .task_type == "tcp" and .target == "example.com" and .port == 443))' >/dev/null
|
||||
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/admin/servers" | \
|
||||
jq -e --arg id "$server_id" '.servers | any(.id == $id and .last_ip == "8.8.8.8")' >/dev/null
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
use worker::{wasm_bindgen::JsValue, D1Database, Date, Result};
|
||||
@@ -68,6 +67,7 @@ struct AgentConfigRow {
|
||||
report_interval: i64,
|
||||
collect_interval: i64,
|
||||
network_interface: String,
|
||||
agent_mirror: String,
|
||||
auto_update: i64,
|
||||
}
|
||||
|
||||
@@ -325,7 +325,7 @@ pub async fn update_last_ip(db: &D1Database, id: &str, ip: &str) -> Result<()> {
|
||||
pub async fn agent_config(db: &D1Database, id: &str) -> Result<Option<AgentConfigView>> {
|
||||
let row: Option<AgentConfigRow> = db
|
||||
.prepare(
|
||||
"SELECT report_interval, collect_interval, network_interface, auto_update \
|
||||
"SELECT report_interval, collect_interval, network_interface, agent_mirror, auto_update \
|
||||
FROM servers WHERE id = ?1",
|
||||
)
|
||||
.bind(&[text(id)])?
|
||||
@@ -338,6 +338,7 @@ pub async fn agent_config(db: &D1Database, id: &str) -> Result<Option<AgentConfi
|
||||
report_interval: row.report_interval,
|
||||
collect_interval: row.collect_interval,
|
||||
network_interface: row.network_interface.trim().to_string(),
|
||||
agent_mirror: row.agent_mirror.trim().trim_end_matches('/').to_string(),
|
||||
auto_update: row.auto_update != 0,
|
||||
latency_tasks: crate::latency::tasks_for_server(db, id).await?,
|
||||
}))
|
||||
@@ -383,7 +384,7 @@ pub async fn create_server(
|
||||
number(input.collect_interval),
|
||||
number(input.rx_correction),
|
||||
number(input.tx_correction),
|
||||
text(input.agent_mirror.trim()),
|
||||
text(input.agent_mirror.trim().trim_end_matches('/')),
|
||||
JsValue::from_bool(input.offline_notify_disabled),
|
||||
JsValue::from_bool(input.auto_update),
|
||||
text(token),
|
||||
@@ -427,7 +428,7 @@ pub async fn update_server(db: &D1Database, id: &str, input: &ServerInput) -> Re
|
||||
number(input.collect_interval),
|
||||
number(input.rx_correction),
|
||||
number(input.tx_correction),
|
||||
text(input.agent_mirror.trim()),
|
||||
text(input.agent_mirror.trim().trim_end_matches('/')),
|
||||
JsValue::from_bool(input.offline_notify_disabled),
|
||||
JsValue::from_bool(input.auto_update),
|
||||
number(now()),
|
||||
@@ -1071,48 +1072,6 @@ pub async fn settings(
|
||||
})
|
||||
}
|
||||
|
||||
const SETTINGS_CACHE_TTL_SECONDS: i64 = 30;
|
||||
|
||||
static SETTINGS_CACHE: Mutex<Option<(i64, SettingsView)>> = Mutex::new(None);
|
||||
|
||||
/// Settings gate every request (including the static-asset fallback), so keep an
|
||||
/// isolate-local copy to avoid one D1 read per request. Writes invalidate it;
|
||||
/// other isolates observe changes after at most the TTL.
|
||||
pub async fn cached_settings(
|
||||
db: &D1Database,
|
||||
default_name: &str,
|
||||
default_threshold: i64,
|
||||
default_retention: i64,
|
||||
default_username: &str,
|
||||
) -> Result<SettingsView> {
|
||||
let current = now();
|
||||
if let Ok(slot) = SETTINGS_CACHE.lock() {
|
||||
if let Some((loaded_at, cached)) = slot.as_ref() {
|
||||
if current - *loaded_at < SETTINGS_CACHE_TTL_SECONDS {
|
||||
return Ok(cached.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let settings = settings(
|
||||
db,
|
||||
default_name,
|
||||
default_threshold,
|
||||
default_retention,
|
||||
default_username,
|
||||
)
|
||||
.await?;
|
||||
if let Ok(mut slot) = SETTINGS_CACHE.lock() {
|
||||
*slot = Some((current, settings.clone()));
|
||||
}
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
pub fn invalidate_settings_cache() {
|
||||
if let Ok(mut slot) = SETTINGS_CACHE.lock() {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_settings(
|
||||
db: &D1Database,
|
||||
input: &SettingsInput,
|
||||
@@ -1268,7 +1227,6 @@ pub async fn update_settings(
|
||||
}
|
||||
if !statements.is_empty() {
|
||||
db.batch(statements).await?;
|
||||
invalidate_settings_cache();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1520,7 +1478,6 @@ pub async fn save_setting(db: &D1Database, key: &str, value: &str) -> Result<()>
|
||||
.bind(&[text(key), text(value), number(now())])?
|
||||
.run()
|
||||
.await?;
|
||||
invalidate_settings_cache();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1534,7 +1491,6 @@ pub async fn increment_setting(db: &D1Database, key: &str) -> Result<()> {
|
||||
.bind(&[text(key), number(now())])?
|
||||
.run()
|
||||
.await?;
|
||||
invalidate_settings_cache();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+79
-25
@@ -1,5 +1,4 @@
|
||||
mod auth;
|
||||
mod routes;
|
||||
mod cloudflare;
|
||||
mod db;
|
||||
mod exchange;
|
||||
@@ -7,6 +6,7 @@ mod latency;
|
||||
mod live;
|
||||
mod models;
|
||||
mod notify;
|
||||
mod routes;
|
||||
mod theme;
|
||||
mod turnstile;
|
||||
|
||||
@@ -113,7 +113,10 @@ fn env_secret_text(env: &Env, key: &str) -> String {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn settings_for_admin_response(mut settings: db::SettingsView, env: &Env) -> db::SettingsView {
|
||||
pub(crate) fn settings_for_admin_response(
|
||||
mut settings: db::SettingsView,
|
||||
env: &Env,
|
||||
) -> db::SettingsView {
|
||||
for (field, variable) in [
|
||||
(&mut settings.turnstile_site_key, "TURNSTILE_SITE_KEY"),
|
||||
(&mut settings.turnstile_secret_key, "TURNSTILE_SECRET_KEY"),
|
||||
@@ -626,27 +629,46 @@ pub(crate) fn validate_server(input: &ServerInput) -> Option<&'static str> {
|
||||
if input.rx_correction < 0 || input.tx_correction < 0 {
|
||||
return Some("流量修正值不能为负数");
|
||||
}
|
||||
let mirror = input.agent_mirror.trim();
|
||||
if !mirror.is_empty() {
|
||||
if mirror.len() > 2048
|
||||
|| !mirror
|
||||
.chars()
|
||||
.all(|value| value.is_ascii_alphanumeric() || "_./:@-".contains(value))
|
||||
{
|
||||
return Some("Agent 下载加速地址格式无效");
|
||||
}
|
||||
let local_http = mirror.starts_with("http://localhost")
|
||||
|| mirror.starts_with("http://127.0.0.1");
|
||||
if !mirror.starts_with("https://") && !local_http {
|
||||
return Some("Agent 下载加速地址必须使用 HTTPS");
|
||||
}
|
||||
if mirror.contains('@') {
|
||||
return Some("Agent 下载加速地址不能包含用户信息");
|
||||
}
|
||||
if !valid_agent_mirror(&input.agent_mirror) {
|
||||
return Some("Agent 下载加速地址必须是无凭据、查询参数和片段的 HTTPS URL");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn valid_agent_mirror(value: &str) -> bool {
|
||||
let value = value.trim().trim_end_matches('/');
|
||||
if value.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if value.len() > 2048
|
||||
|| value.chars().any(char::is_whitespace)
|
||||
|| !value
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || "_./:@-".contains(character))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Ok(url) = Url::parse(value) else {
|
||||
return false;
|
||||
};
|
||||
if url.host_str().is_none()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if url.scheme() == "https" {
|
||||
return true;
|
||||
}
|
||||
url.scheme() == "http"
|
||||
&& matches!(
|
||||
url.host_str(),
|
||||
Some(host) if host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_report(report: &AgentReport) -> Option<&'static str> {
|
||||
let floats = [
|
||||
report.cpu,
|
||||
@@ -747,7 +769,10 @@ pub(crate) fn validate_report(report: &AgentReport) -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn public_server(server: ServerView, latency: Vec<latency::LatencySample>) -> PublicServer {
|
||||
pub(crate) fn public_server(
|
||||
server: ServerView,
|
||||
latency: Vec<latency::LatencySample>,
|
||||
) -> PublicServer {
|
||||
let disks = server
|
||||
.disk_info
|
||||
.as_deref()
|
||||
@@ -985,7 +1010,7 @@ async fn handle(req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
let default_threshold = env_number(&env, "OFFLINE_THRESHOLD_SECONDS", 180).clamp(30, 3600);
|
||||
let default_retention = env_number(&env, "HISTORY_RETENTION_DAYS", 30).clamp(1, 365);
|
||||
let default_username = env_text(&env, "ADMIN_USERNAME", "");
|
||||
let settings = db::cached_settings(
|
||||
let settings = db::settings(
|
||||
&database,
|
||||
&default_name,
|
||||
default_threshold,
|
||||
@@ -1051,7 +1076,7 @@ async fn handle(req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
routes::RouteOutcome::Unmatched(req) => req,
|
||||
};
|
||||
let response = env.assets("ASSETS")?.fetch_request(req).await?;
|
||||
Ok(secure_public_response(mutable_response(response)?)?)
|
||||
secure_public_response(mutable_response(response)?)
|
||||
}
|
||||
#[event(fetch, respond_with_errors)]
|
||||
async fn fetch(req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
@@ -1147,9 +1172,9 @@ async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
|
||||
mod tests {
|
||||
use super::{
|
||||
allow_on_rate_limit_failure, history_cache_key, rate_limit_binding, same_origin,
|
||||
submitted_secret, valid_cloudflare_account_id, valid_cloudflare_api_token,
|
||||
valid_password_derived, valid_ping_target, valid_server_price, validate_latency_task,
|
||||
ADMIN_HTML, ADMIN_SCRIPT, ADMIN_STYLE,
|
||||
submitted_secret, valid_agent_mirror, valid_cloudflare_account_id,
|
||||
valid_cloudflare_api_token, valid_password_derived, valid_ping_target, valid_server_price,
|
||||
validate_latency_task, ADMIN_HTML, ADMIN_SCRIPT, ADMIN_STYLE,
|
||||
};
|
||||
use crate::{db::SECRET_MASK, models::LatencyTaskInput};
|
||||
use worker::{Method, Url};
|
||||
@@ -1198,6 +1223,35 @@ mod tests {
|
||||
assert!(!valid_server_price(f64::NAN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_agent_mirrors() {
|
||||
for mirror in [
|
||||
"",
|
||||
"https://mirror.example.com",
|
||||
"https://mirror.example.com/github",
|
||||
"http://127.0.0.1:8080",
|
||||
"http://localhost:8080",
|
||||
] {
|
||||
assert!(
|
||||
valid_agent_mirror(mirror),
|
||||
"expected valid mirror: {mirror}"
|
||||
);
|
||||
}
|
||||
for mirror in [
|
||||
"https://",
|
||||
"http://mirror.example.com",
|
||||
"https://user@mirror.example.com",
|
||||
"https://mirror.example.com/?token=secret",
|
||||
"https://mirror.example.com/#fragment",
|
||||
"https://bad host.example.com",
|
||||
] {
|
||||
assert!(
|
||||
!valid_agent_mirror(mirror),
|
||||
"expected invalid mirror: {mirror}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_latency_targets() {
|
||||
for target in ["gd-ct-dualstack.ip.zstaticcdn.com", "1.1.1.1"] {
|
||||
|
||||
+1
-1
@@ -43,7 +43,6 @@ pub struct ServerInput {
|
||||
pub collect_interval: i64,
|
||||
pub rx_correction: i64,
|
||||
pub tx_correction: i64,
|
||||
#[serde(default)]
|
||||
pub agent_mirror: String,
|
||||
pub offline_notify_disabled: bool,
|
||||
pub auto_update: bool,
|
||||
@@ -334,6 +333,7 @@ pub struct AgentConfigView {
|
||||
pub report_interval: i64,
|
||||
pub collect_interval: i64,
|
||||
pub network_interface: String,
|
||||
pub agent_mirror: String,
|
||||
pub auto_update: bool,
|
||||
pub latency_tasks: Vec<AgentLatencyTask>,
|
||||
}
|
||||
|
||||
+17
-37
@@ -11,29 +11,10 @@ use crate::models::{
|
||||
};
|
||||
use crate::routes::{RouteContext, RouteOutcome};
|
||||
use crate::{
|
||||
cloudflare,
|
||||
db,
|
||||
error,
|
||||
exchange,
|
||||
json,
|
||||
latency,
|
||||
live,
|
||||
no_content,
|
||||
now,
|
||||
notify,
|
||||
request_json,
|
||||
server_id,
|
||||
settings_for_admin_response,
|
||||
theme,
|
||||
validate_alert_rule,
|
||||
validate_latency_task,
|
||||
validate_server,
|
||||
validate_theme,
|
||||
valid_cloudflare_account_id,
|
||||
valid_cloudflare_api_token,
|
||||
valid_password_derived,
|
||||
submitted_secret,
|
||||
API_JSON_MAX_BYTES,
|
||||
cloudflare, db, error, exchange, json, latency, live, no_content, notify, now, request_json,
|
||||
server_id, settings_for_admin_response, submitted_secret, theme, valid_cloudflare_account_id,
|
||||
valid_cloudflare_api_token, valid_password_derived, validate_alert_rule, validate_latency_task,
|
||||
validate_server, validate_theme, API_JSON_MAX_BYTES,
|
||||
};
|
||||
|
||||
pub(crate) async fn route(mut req: Request, ctx: &RouteContext) -> Result<RouteOutcome> {
|
||||
@@ -122,7 +103,9 @@ pub(crate) async fn route(mut req: Request, ctx: &RouteContext) -> Result<RouteO
|
||||
if method == Method::Post && path == "/api/admin/themes" {
|
||||
return handled(themes_post(&mut req, ctx).await);
|
||||
}
|
||||
if method == Method::Post && path.starts_with("/api/admin/themes/") && path.ends_with("/preview")
|
||||
if method == Method::Post
|
||||
&& path.starts_with("/api/admin/themes/")
|
||||
&& path.ends_with("/preview")
|
||||
{
|
||||
return handled(theme_preview_post(ctx, &path).await);
|
||||
}
|
||||
@@ -336,8 +319,7 @@ async fn servers_order_patch(req: &mut Request, ctx: &RouteContext) -> Result<Re
|
||||
}
|
||||
let current = db::list_servers(&ctx.database, true).await?;
|
||||
let current_ids: HashSet<_> = current.iter().map(|server| &server.id).collect();
|
||||
if input.ids.len() != current_ids.len()
|
||||
|| input.ids.iter().any(|id| !current_ids.contains(id))
|
||||
if input.ids.len() != current_ids.len() || input.ids.iter().any(|id| !current_ids.contains(id))
|
||||
{
|
||||
return error("排序列表必须包含全部节点", 400);
|
||||
}
|
||||
@@ -354,11 +336,7 @@ async fn servers_post(req: &mut Request, ctx: &RouteContext) -> Result<Response>
|
||||
return error(message, 400);
|
||||
}
|
||||
let id = short_durable_id(ctx)?;
|
||||
let token = ctx
|
||||
.env
|
||||
.durable_object("LIVE_HUB")?
|
||||
.unique_id()?
|
||||
.to_string();
|
||||
let token = ctx.env.durable_object("LIVE_HUB")?.unique_id()?.to_string();
|
||||
db::create_server(&ctx.database, &id, &token, &input).await?;
|
||||
latency::assign_defaults(&ctx.database, &id).await?;
|
||||
json(&serde_json::json!({ "id": id, "agent_token": token }), 201)
|
||||
@@ -587,7 +565,8 @@ async fn notifications_test(ctx: &RouteContext) -> Result<Response> {
|
||||
if ctx.settings.notification_endpoint.trim().is_empty() {
|
||||
return error("请先填写 Telegram Bot Token 和 Chat ID", 400);
|
||||
}
|
||||
if let Err(err) = notify::send(&ctx.settings, "NodeFlare 测试通知:通知渠道配置成功。").await {
|
||||
if let Err(err) = notify::send(&ctx.settings, "NodeFlare 测试通知:通知渠道配置成功。").await
|
||||
{
|
||||
console_error!("test notification failed: {err}");
|
||||
return error("测试通知发送失败,请检查 Bot Token 和 Chat ID", 502);
|
||||
}
|
||||
@@ -705,8 +684,7 @@ async fn settings_patch(req: &mut Request, ctx: &RouteContext) -> Result<Respons
|
||||
} else {
|
||||
configured_secret_key
|
||||
};
|
||||
let protection_activated = input.turnstile_enabled == Some(true)
|
||||
&& !settings.turnstile_enabled
|
||||
let protection_activated = input.turnstile_enabled == Some(true) && !settings.turnstile_enabled
|
||||
|| input.turnstile_login_enabled == Some(true) && !settings.turnstile_login_enabled;
|
||||
if protection_activated && (site_key.is_empty() || secret_key.is_empty()) {
|
||||
return error("启用 Turnstile 前必须填写站点密钥和私钥", 400);
|
||||
@@ -735,9 +713,11 @@ async fn settings_patch(req: &mut Request, ctx: &RouteContext) -> Result<Respons
|
||||
{
|
||||
return error("Cloudflare Account ID 应为 32 位十六进制字符", 400);
|
||||
}
|
||||
if input.cloudflare_api_token.as_deref().is_some_and(|value| {
|
||||
value.trim() != db::SECRET_MASK && !valid_cloudflare_api_token(value)
|
||||
}) {
|
||||
if input
|
||||
.cloudflare_api_token
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.trim() != db::SECRET_MASK && !valid_cloudflare_api_token(value))
|
||||
{
|
||||
return error("Cloudflare API Token 格式无效", 400);
|
||||
}
|
||||
if input
|
||||
|
||||
+4
-4
@@ -2,16 +2,16 @@ use std::net::IpAddr;
|
||||
|
||||
use worker::{Context, D1Database, Env, Request, Response, Result};
|
||||
|
||||
use crate::auth::bearer_token;
|
||||
use crate::db;
|
||||
use crate::live;
|
||||
use crate::latency;
|
||||
use crate::live;
|
||||
use crate::models::AgentReportBatch;
|
||||
use crate::sha256_hex;
|
||||
use crate::{
|
||||
client_ip, error, no_content, now, public_server, request_json, validate_report,
|
||||
AGENT_JSON_MAX_BYTES, MAX_AGENT_LATENCY_RESULTS, MAX_AGENT_SAMPLES,
|
||||
};
|
||||
use crate::auth::bearer_token;
|
||||
use crate::models::AgentReportBatch;
|
||||
use crate::sha256_hex;
|
||||
|
||||
pub(crate) async fn report(
|
||||
mut req: Request,
|
||||
|
||||
+15
-23
@@ -2,31 +2,18 @@ use std::collections::HashMap;
|
||||
|
||||
use worker::{Method, Request, Response, Result};
|
||||
|
||||
use crate::routes::{RouteContext, RouteOutcome};
|
||||
use crate::{
|
||||
db,
|
||||
error,
|
||||
json,
|
||||
latency,
|
||||
live,
|
||||
no_content,
|
||||
now,
|
||||
public_server,
|
||||
requested_hours,
|
||||
server_id,
|
||||
cached_history_response,
|
||||
history_cache_key,
|
||||
request_json,
|
||||
set_admin_session_cookie,
|
||||
store_history_response,
|
||||
API_JSON_MAX_BYTES,
|
||||
};
|
||||
use crate::auth::{
|
||||
create_admin_jwt, create_turnstile_proof, hash_password, random_salt, verify_credentials,
|
||||
};
|
||||
use crate::models::LoginRequest;
|
||||
use crate::turnstile;
|
||||
use crate::client_ip;
|
||||
use crate::models::LoginRequest;
|
||||
use crate::routes::{RouteContext, RouteOutcome};
|
||||
use crate::turnstile;
|
||||
use crate::{
|
||||
cached_history_response, db, error, history_cache_key, json, latency, live, no_content, now,
|
||||
public_server, request_json, requested_hours, server_id, set_admin_session_cookie,
|
||||
store_history_response, API_JSON_MAX_BYTES,
|
||||
};
|
||||
|
||||
pub(crate) async fn route(mut req: Request, ctx: &RouteContext) -> Result<RouteOutcome> {
|
||||
let method = req.method();
|
||||
@@ -39,14 +26,19 @@ pub(crate) async fn route(mut req: Request, ctx: &RouteContext) -> Result<RouteO
|
||||
return Ok(RouteOutcome::Handled(admin_login(&mut req, ctx).await?));
|
||||
}
|
||||
if method == Method::Post && path == "/api/turnstile/verify" {
|
||||
return Ok(RouteOutcome::Handled(turnstile_verify(&mut req, ctx).await?));
|
||||
return Ok(RouteOutcome::Handled(
|
||||
turnstile_verify(&mut req, ctx).await?,
|
||||
));
|
||||
}
|
||||
if method == Method::Get && path == "/api/exchange-rates" {
|
||||
return Ok(RouteOutcome::Handled(exchange_rates(ctx).await?));
|
||||
}
|
||||
if method == Method::Get && path == "/api/ws" {
|
||||
if !ctx.turnstile_verified {
|
||||
return Ok(RouteOutcome::Handled(error("请先完成 Cloudflare 人机验证", 403)?));
|
||||
return Ok(RouteOutcome::Handled(error(
|
||||
"请先完成 Cloudflare 人机验证",
|
||||
403,
|
||||
)?));
|
||||
}
|
||||
if !ctx.settings.public_dashboard && !ctx.admin {
|
||||
return Ok(RouteOutcome::Handled(error("此仪表盘需要登录后访问", 401)?));
|
||||
|
||||
+16
-13
@@ -2,7 +2,10 @@ use worker::{Method, Request, Response, Result};
|
||||
|
||||
use crate::auth::verify_theme_preview_proof;
|
||||
use crate::routes::{RouteContext, RouteOutcome};
|
||||
use crate::{db, remote_theme_response, remote_theme_preview_response, secure_public_response, theme, ADMIN_HTML, ADMIN_SCRIPT, ADMIN_STYLE};
|
||||
use crate::{
|
||||
db, remote_theme_preview_response, remote_theme_response, secure_public_response, theme,
|
||||
ADMIN_HTML, ADMIN_SCRIPT, ADMIN_STYLE,
|
||||
};
|
||||
|
||||
/// 管理端内嵌资源(编译进 wasm,不受主题影响)。
|
||||
pub(crate) fn embedded_asset(path: &str) -> Result<Option<Response>> {
|
||||
@@ -10,11 +13,10 @@ pub(crate) fn embedded_asset(path: &str) -> Result<Option<Response>> {
|
||||
"/admin" | "/admin/" | "/admin/index.html" => {
|
||||
crate::embedded_admin_response(ADMIN_HTML, "text/html; charset=utf-8").map(Some)
|
||||
}
|
||||
"/admin-assets/admin.js" => crate::embedded_admin_response(
|
||||
ADMIN_SCRIPT,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.map(Some),
|
||||
"/admin-assets/admin.js" => {
|
||||
crate::embedded_admin_response(ADMIN_SCRIPT, "application/javascript; charset=utf-8")
|
||||
.map(Some)
|
||||
}
|
||||
"/admin-assets/admin.css" => {
|
||||
crate::embedded_admin_response(ADMIN_STYLE, "text/css; charset=utf-8").map(Some)
|
||||
}
|
||||
@@ -31,16 +33,17 @@ pub(crate) async fn route(req: Request, ctx: &RouteContext) -> Result<RouteOutco
|
||||
|
||||
if let Some(preview_path) = path.strip_prefix("/__theme-preview/") {
|
||||
let (proof, relative) = preview_path.split_once('/').unwrap_or((preview_path, ""));
|
||||
let Some(theme_id) = verify_theme_preview_proof(proof, &ctx.env, &ctx.settings.admin_password_hash)
|
||||
let Some(theme_id) =
|
||||
verify_theme_preview_proof(proof, &ctx.env, &ctx.settings.admin_password_hash)
|
||||
else {
|
||||
return Ok(RouteOutcome::Handled(
|
||||
secure_public_response(Response::error("主题预览链接已过期", 403)?)?,
|
||||
));
|
||||
return Ok(RouteOutcome::Handled(secure_public_response(
|
||||
Response::error("主题预览链接已过期", 403)?,
|
||||
)?));
|
||||
};
|
||||
let Some(url) = db::theme_url(&ctx.database, &theme_id).await? else {
|
||||
return Ok(RouteOutcome::Handled(
|
||||
secure_public_response(Response::error("主题不存在", 404)?)?,
|
||||
));
|
||||
return Ok(RouteOutcome::Handled(secure_public_response(
|
||||
Response::error("主题不存在", 404)?,
|
||||
)?));
|
||||
};
|
||||
let prefix = format!("/__theme-preview/{proof}");
|
||||
return Ok(RouteOutcome::Handled(
|
||||
|
||||
Reference in new issue
Block a user