From 3123c2cf7553ab263c415e546a6fe51ac5f29ced Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:53:14 +0800 Subject: [PATCH 1/2] =?UTF-8?q?chore:=20=E4=BF=AE=E6=AD=A3=E4=B8=8E?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=B8=8D=E7=AC=A6=E7=9A=84=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=EF=BC=8C=E5=88=A0=E6=8E=89=E9=9D=A2=E6=9D=BF=E7=94=A8=E4=B8=8D?= =?UTF-8?q?=E5=88=B0=E7=9A=84=E6=8E=92=E5=BA=8F=E5=86=99=E5=85=A5=E5=92=8C?= =?UTF-8?q?=E6=AD=BB=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 注释改正:请求体上限、分片上限、压缩层对备份的说明、导出走独立连接、迁移所在函数、 Throttle 的两个用途、--site 报错的去向;agent 转发处的 ponytail 不再把已否决的方案写成升级方向 - 回忆式写法改成「否则会怎样」 - NodePatch 去掉 sort:面板从不发送,且它能绕过排序接口「必须列全」的校验 - 面板删掉 token 为空的死分支和重复的节点排序;/api/nodes 直接复用缓存帧,不再复制 - 一条中文日志改为英文,测试里重复的断言合并 --- install-hub.sh | 5 ++-- src/agent_ws.rs | 33 ++++++++++++------------ src/api.rs | 40 +++++++++++++----------------- src/auth.rs | 3 ++- src/db.rs | 38 ++++++++++++++-------------- src/frontend.rs | 4 ++- src/main.rs | 32 +++++++++++------------- web-admin/src/App.tsx | 4 +-- web-admin/src/components/Admin.tsx | 17 ++++++------- web-admin/src/lib/api.ts | 2 +- 10 files changed, 83 insertions(+), 95 deletions(-) diff --git a/install-hub.sh b/install-hub.sh index 6f39b1b2..c2b20c94 100755 --- a/install-hub.sh +++ b/install-hub.sh @@ -501,9 +501,8 @@ done check_port "$PORT" # The same form `api::https_domain` measures --site against on the hub, checked # here because this is where the value is entered. A hub started with a value it -# refuses starts normally and then declines to add or install any node, while the -# message the panel prints names the browser and the reverse proxy, neither of -# which is at fault. +# refuses starts normally and then declines to add or install any node, which +# surfaces only in the journal and the panel, long after the value was typed. SITE="${SITE%/}" if [ -n "$SITE" ]; then case "$SITE" in diff --git a/src/agent_ws.rs b/src/agent_ws.rs index a0ba6e14..208a8d78 100644 --- a/src/agent_ws.rs +++ b/src/agent_ws.rs @@ -66,10 +66,9 @@ impl Arrival { /// interval of a hub restart. /// /// A single map, because "the node is online" and "the node has current figures" -/// are the same fact. Split across two, they required manual synchronisation at -/// every call site and diverged: the connection was recorded at the handshake -/// and the metrics at the first report, so a node that had connected but not yet -/// reported appeared offline for a whole `--interval`. +/// are the same fact. Split across two, they would need synchronising at every +/// call site, and a node connected but not yet reporting would read offline for +/// a whole `--interval`. #[derive(Debug)] pub struct Agent { /// Distinguishes one session on a node from the next; see [`release`]. @@ -606,8 +605,8 @@ pub(crate) const INJECTED: [&str; 4] = ["total_rx", "total_tx", "month_rx", "mon /// `mem_total`, `swap_total` and `disk_total` never reach the `metric` table but /// go straight to the browser, and the default theme blanks a node's entire live /// view when one is absent. Derived from the stored columns instead, this list -/// left those four uncovered, so an agent renaming one blanked every card on the -/// page with nothing in any log to explain it. +/// would leave those four uncovered, and an agent renaming one would blank every +/// card on the page with nothing in any log to explain it. /// /// Hub and agent ship as two binaries from two repositories, and every reader /// here ends in `unwrap_or(0)`: a field the agent renames does not fail, it @@ -1013,10 +1012,10 @@ mod tests { } /// The contract check is what makes a cross-repository rename visible. - /// Derived from the columns the hub stores, it missed four fields that never - /// reach the `metric` table but do reach the browser; the default theme - /// blanks a node's entire live view if one is absent, so the drift surfaced - /// as empty cards and no log output. + /// Derived from the columns the hub stores, it would miss four fields that + /// never reach the `metric` table but do reach the browser; the default theme + /// blanks a node's entire live view if one is absent, so the drift would + /// surface as empty cards and no log output. #[test] fn the_contract_covers_every_field_the_browser_needs_not_just_the_stored_ones() { let fields: Vec<&str> = report_fields().collect(); @@ -1393,11 +1392,12 @@ mod tests { // The rejected results carry task ids of their own: a bare count would be // satisfied by the key collapsing them onto a valid row. send(&app, id, &mut session, 0, &result_json(two, 15)).unwrap(); - send(&app, id, &mut session, 0, &result_json(0, 42)).unwrap(); // no such task - send(&app, id, &mut session, 0, &result_json(-1, 42)).unwrap(); // nor this one - send(&app, id, &mut session, 0, &result_json(99, 42)).unwrap(); // not this node's - // A frame carrying no reading. Defaulting to -1 would file it as a lost - // packet, rendering a malformed frame as an outage. + // Ids naming no probe this node runs. + for task in [0, -1, 99] { + send(&app, id, &mut session, 0, &result_json(task, 42)).unwrap(); + } + // A frame carrying no reading. Defaulting to -1 would file it as a lost + // packet, rendering a malformed frame as an outage. let blind = json!({"jsonrpc": "2.0", "method": "ping.result", "params": {"task_id": one}}); send(&app, id, &mut session, 0, &blind.to_string()).unwrap(); assert!(results(&app, id).is_empty(), "gathered until the minute turns"); @@ -1445,8 +1445,7 @@ mod tests { connect(1); connect(2); assert!(release(&app, id, 1).is_none(), "a stale session must release nothing"); - assert!(live(), "the reconnected agent stays online"); - assert!(app.agents.read().unwrap().contains_key(&id), "and keeps receiving probe pushes"); + assert!(live(), "the reconnected agent stays online and keeps receiving probe pushes"); } #[test] diff --git a/src/api.rs b/src/api.rs index d88606b2..a5d08380 100644 --- a/src/api.rs +++ b/src/api.rs @@ -182,7 +182,7 @@ fn node_view(node: &Node, current: Option<&Agent>, traffic: &Traffic, full: bool // the agent runs -- the agent re-reads its mount table every sample so that // it appears -- by up to a minute. Using the report while a node is connected // keeps every consumer of this view on one number: the card reads the live - // metrics and the detail page reads these, and they previously showed the + // metrics and the detail page reads these, which would otherwise show the // same machine two different capacities. Offline, the stored figure is the // last one reported. No floor is applied: a host whose swap has just been // disabled reports zero and means it. A node connected but not yet reporting @@ -316,7 +316,7 @@ pub async fn nodes(State(app): State, headers: HeaderMap) -> Response { // The same rendered frame the browser streams receive, for the same reason: // otherwise every visitor would rebuild every node's row against the // connection the agents write through. - ([(axum::http::header::CONTENT_TYPE, "application/json")], live_snapshot(&app, full).as_str().to_owned()) + ([(header::CONTENT_TYPE, "application/json")], axum::body::Bytes::from(live_snapshot(&app, full))) .into_response() } @@ -649,10 +649,8 @@ fn provisioning_allowed(app: &App, headers: &HeaderMap) -> Result<(), &'static s } /// Range and sign limits every stored node must satisfy, or the reason it does -/// not. Shared because both writers must enforce them: the create path formerly -/// accepted a whole `Node` unchecked, leaving the values the update path refuses -/// reachable by another route, and an out-of-range reset day remained harmless -/// only because `period_start` clamps what it reads. +/// not. Shared because both writers must enforce them: a value one refuses must +/// not be reachable through the other. fn node_limits(reset_day: Option, price: Option, limit: Option) -> Option<&'static str> { if reset_day.is_some_and(|d| !(1..=31).contains(&d)) { return Some("流量重置日要在 1 到 31 之间"); @@ -1347,8 +1345,8 @@ pub async fn db_stats(_: Admin, State(app): State) -> Response { /// outlives the download. pub async fn db_backup(_: Admin, State(app): State) -> Response { let path = scratch_path(&app, "backup"); - // Off the runtime: this reads the entire database while holding the - // connection the agents write through. + // Off the runtime: this reads the entire database, through a connection of + // its own, so the agents' writes continue meanwhile. let copied = { let (app, path) = (app.clone(), path.clone()); tokio::task::spawn_blocking(move || app.db.backup_into(&path)).await @@ -1953,8 +1951,8 @@ pub async fn settings(_: Admin, State(app): State) -> Json { } // The one readable key with a default that also rejects the empty string: // `setting_error` below refuses "" and `save_settings` writes nothing when any - // key fails, so a hub where this was never set returned "" here and then - // rejected the entire settings form, naming a field that was never edited. + // key fails, so answering "" for a hub where this was never set would have + // the entire settings form rejected, naming a field that was never edited. // `retention_days()` already holds the default `prune` and the data page read, // so it answers here as well. out.insert("retention_days".into(), json!(app.db.retention_days().to_string())); @@ -1981,9 +1979,8 @@ pub async fn settings(_: Admin, State(app): State) -> Json { /// the UI reported as rejected. fn setting_error(app: &App, key: &str, value: &Value) -> Option { // Settings are stored as text. A caller sending the natural JSON type -- - // `{"public_page": false}`, `{"retention_days": 7}` -- was formerly skipped by - // a bare `continue`, so nothing was written while the response reported - // success. + // `{"public_page": false}`, `{"retention_days": 7}` -- is refused rather than + // skipped, which would write nothing while the response reported success. let Some(value) = value.as_str() else { return Some(format!("设置 {key} 的值格式不对")) }; match key { "theme" if !crate::frontend::selectable(app, value) => Some("主题没有安装".into()), @@ -2026,13 +2023,11 @@ pub async fn save_settings( return bad(&message); } } - // Set when the password changed, so the caller receives a fresh session rather - // than being logged out by their own change. + // Set when the password changed: the change signs every session out, and the + // caller receives a replacement rather than being logged out by it. let mut reissued = String::new(); for (key, value) in map { let value = value.as_str().unwrap_or_default(); - // Changing the password logs out every existing session; the caller - // receives a replacement. if key == "admin_password" { match hash_password(value).and_then(|h| { app.db.replace_password(&h)?; @@ -2478,8 +2473,8 @@ mod tests { } /// What a thinned bucket may return. Keeping one row and discarding the rest - /// made the seven-day chart integrate to twice the traffic the minutes hold, - /// and drew a probe losing half its packets as an unbroken line. + /// would integrate the seven-day chart to twice the traffic the minutes hold, + /// and draw a probe losing half its packets as an unbroken line. #[test] fn a_thinned_bucket_answers_with_its_mean_and_says_what_it_lost() { let app = app(); @@ -2967,9 +2962,8 @@ mod tests { ); } - /// Both writers enforce the same limits. The create path formerly accepted a - /// whole `Node` unchecked, leaving everything the update path refuses - /// reachable by another route. + /// Both writers enforce the same limits, so nothing the update path refuses + /// is reachable through the create path, which takes a whole `Node`. #[tokio::test] async fn both_write_paths_refuse_the_same_out_of_range_values() { let app = std::sync::Arc::new(app()); @@ -3262,7 +3256,7 @@ mod tests { /// again in every report -- and the two diverge as soon as a disk is mounted /// on a running machine, which the agent detects by re-reading its mount table /// every sample. Drawn from the stored copy, the card and the detail page - /// showed the same host two different sizes until it reconnected. + /// would show the same host two different sizes until it reconnected. #[test] fn a_capacity_that_changed_since_the_handshake_is_the_reported_one() { let app = app(); diff --git a/src/auth.rs b/src/auth.rs index ff4ee09b..e88b3062 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -72,7 +72,8 @@ fn verify_password(password: &str, stored: &str) -> bool { .unwrap_or(false) } -/// Per-address failure counter for the password endpoint. +/// Per-address failure counter. The hub keeps two: one for the sign-in page, +/// one for agent registration (`App::registrations`). pub struct Throttle { seen: Mutex>, /// How long a failure is remembered. A field rather than the constant so diff --git a/src/db.rs b/src/db.rs index 86422398..f015afe9 100644 --- a/src/db.rs +++ b/src/db.rs @@ -137,7 +137,7 @@ CREATE TABLE IF NOT EXISTS ping_node ( -- Key order follows the only query there is: one node, one time window, -- every probe. With task_id ahead of ts SQLite can seek to the node and no --- further, then scans every record it ever kept -- see the migration in open(). +-- further, then scans every record it ever kept -- see migrate_to_1. CREATE TABLE IF NOT EXISTS ping_record ( node_id INTEGER NOT NULL, task_id INTEGER NOT NULL, ts INTEGER NOT NULL, latency INTEGER NOT NULL, @@ -534,10 +534,11 @@ fn yes() -> bool { } /// Omitted settings stay unchanged. An explicit null clears the expiry date. +/// The position in the list is not among them: it changes only through +/// `reorder_nodes`, which takes the whole order at once. #[derive(Deserialize, Default)] pub struct NodePatch { pub name: Option, - pub sort: Option, pub public: Option, pub price: Option, pub currency: Option, @@ -1022,23 +1023,22 @@ impl Db { let tx = conn.transaction()?; { let mut update = tx.prepare( - "UPDATE node SET name=COALESCE(?2,name), sort=COALESCE(?3,sort), public=COALESCE(?4,public), - price=COALESCE(?5,price), currency=COALESCE(?6,currency), - billing_cycle=COALESCE(?7,billing_cycle), - expires_at=CASE WHEN ?8 THEN ?9 ELSE expires_at END, - remark=COALESCE(?10,remark), traffic_limit=COALESCE(?11,traffic_limit), - traffic_mode=COALESCE(?12,traffic_mode), - traffic_reset_day=COALESCE(?13,traffic_reset_day), - notify=COALESCE(?14,notify), country_pin=COALESCE(?15,country_pin), - ipv4_pin=COALESCE(?16,ipv4_pin), ipv6_pin=COALESCE(?17,ipv6_pin), - group_name=COALESCE(?18,group_name) + "UPDATE node SET name=COALESCE(?2,name), public=COALESCE(?3,public), + price=COALESCE(?4,price), currency=COALESCE(?5,currency), + billing_cycle=COALESCE(?6,billing_cycle), + expires_at=CASE WHEN ?7 THEN ?8 ELSE expires_at END, + remark=COALESCE(?9,remark), traffic_limit=COALESCE(?10,traffic_limit), + traffic_mode=COALESCE(?11,traffic_mode), + traffic_reset_day=COALESCE(?12,traffic_reset_day), + notify=COALESCE(?13,notify), country_pin=COALESCE(?14,country_pin), + ipv4_pin=COALESCE(?15,ipv4_pin), ipv6_pin=COALESCE(?16,ipv6_pin), + group_name=COALESCE(?17,group_name) WHERE id=?1", )?; for id in ids { let found = update.execute(params![ id, n.name, - n.sort, n.public, n.price, n.currency, @@ -2498,8 +2498,8 @@ mod tests { assert_eq!(read("busy_timeout"), 5_000); } - /// A real file, since these three tests exist to exercise what happens to - /// one. Removed by the test that created it. + /// A real file, for the tests that exercise what happens to one. Removed with + /// its companions when dropped. struct Scratch(String); impl Scratch { @@ -3397,10 +3397,10 @@ mod tests { assert_eq!(db.all_traffic()[&id].total_rx, 800); } - /// The rekeying in `open()`: rows must survive it, and the chart's query must - /// emerge able to seek. A migration that leaves every row on the old key fails - /// silently, and stays silent while the query it exists for scans a node's - /// entire history. + /// The rekeying in `migrate_to_1`: rows must survive it, and the chart's + /// query must emerge able to seek. A migration that leaves every row on the + /// old key fails silently, and stays silent while the query it exists for + /// scans a node's entire history. #[test] fn rekeying_ping_record_keeps_the_rows_and_lets_the_chart_query_seek() { let file = std::env::temp_dir().join(format!("monitor-rekey-{}.db", std::process::id())); diff --git a/src/frontend.rs b/src/frontend.rs index 5d3a4f38..c62a0ad6 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -200,7 +200,9 @@ fn manifest(root: &Path, short: &str) -> Option { return None; } let theme: Theme = serde_json::from_slice(&data) - .inspect_err(|e| tracing::warn!("{short}/theme.json 不是有效的 manifest,主题不会出现在列表里:{e}")) + .inspect_err(|e| { + tracing::warn!("{short}/theme.json is not a valid manifest; the theme is left out: {e}") + }) .ok()?; (theme.short == short).then_some(theme) } diff --git a/src/main.rs b/src/main.rs index 435f03f7..3fc17544 100644 --- a/src/main.rs +++ b/src/main.rs @@ -272,14 +272,11 @@ impl futures_core::Stream for Metered { /// /// ponytail: these bytes are relayed unverified, and `install.sh` executes them /// as root on every node. Fetched directly from github.com that is TLS's -/// concern; through the panel's `github_proxy` it rests on the mirror alone. -/// Currently held by the setting accepting https:// only, and by stating so -/// where it is entered. The upgrade path is a pinned digest -- `agent.pin` -/// beside `web-theme.pin`, a fixed release tag, hashed after the fetch and -/// before the relay (4 permits x 1.73 MiB against MemoryMax=256M, so buffering -/// is free). Not a fetched checksum: whoever can replace the binary can replace -/// that too. Deliberately deferred, as it couples agent releases to hub -/// releases. +/// concern; through the panel's `github_proxy` it rests on the mirror alone, +/// which the setting holds to https:// and states where it is entered. A +/// checksum fetched alongside proves nothing, as whoever can replace the binary +/// can replace that too, and a digest pinned into the hub build was evaluated +/// and not adopted: it would tie every agent release to a hub release. async fn agent_binary(State(app): State, Path(arch): Path) -> Response { if !matches!(arch.as_str(), "x86_64" | "aarch64") { return api::answer(StatusCode::NOT_FOUND, "unknown architecture"); @@ -527,14 +524,15 @@ async fn main() -> Result<()> { .route("/api/db/backup", get(api::db_backup)) .route("/api/db/vacuum", post(api::db_vacuum)) .fallback(frontend::serve) - // A report is a few hundred bytes; anything larger is not a report. + // Every body above is a JSON form of a few KiB at most. The ceiling also + // bounds a theme's saved settings, which anonymous callers read back. .layer(tower_http::limit::RequestBodyLimitLayer::new(64 * 1024)) // The two chunked uploads, merged after that layer rather than beneath - // it. They raise the ceiling on a single request -- one 4 MiB piece -- - // not on the file behind it: a 256 MiB backup arrives as 64 such - // requests, so no reverse proxy needs to know the database size. The - // whole-file ceilings live on `total` and are checked before the first - // byte is sent. + // it. They raise the ceiling on a single request to `api::MAX_CHUNK`, + // not on the file behind it: a 256 MiB backup arrives as 64 of the + // panel's 4 MiB pieces, so no reverse proxy needs to know the database + // size. The whole-file ceilings live on `total` and are checked on the + // first request. .merge( Router::new() .route("/api/db/restore", post(api::db_restore)) @@ -554,9 +552,9 @@ async fn main() -> Result<()> { .layer(tower_http::limit::RequestBodyLimitLayer::new(64 * 1024)) .with_state(app.clone()), ) - // Excludes the agent binary and database backups: both are already - // compressed and both are megabytes, so deflating them would consume the - // cores argon2 and the SQLite writer share for no gain. + // Excludes the agent binary, already compressed, and database backups, + // hundreds of megabytes on a large fleet: deflating either would occupy + // the cores argon2 and the SQLite writer share for the whole transfer. .layer( tower_http::compression::CompressionLayer::new().compress_when( tower_http::compression::predicate::DefaultPredicate::new() diff --git a/web-admin/src/App.tsx b/web-admin/src/App.tsx index 7096e87b..c12a21db 100644 --- a/web-admin/src/App.tsx +++ b/web-admin/src/App.tsx @@ -131,8 +131,6 @@ export default function App() { ) } - const sorted = [...(nodes ?? [])].sort((a, b) => a.sort - b.sort || a.id - b.id) - async function signOut() { await api("/auth/logout", { method: "POST" }).catch(() => {}) location.href = "/" @@ -175,7 +173,7 @@ export default function App() {

安装命令

- {/* A node added before the hub kept tokens has nothing to show - until one is reissued. */} - {command || (token ? "网卡名有误,改正后显示命令" : "旧版本创建的凭证不可读取,换发后显示")} + {command || "网卡名有误,改正后显示命令"}
@@ -2419,9 +2417,8 @@ function SettingsTab({ onSaved }: { onSaved: () => void }) { onClick={() => save({ site_name: String(s.site_name ?? ""), - // `||` rather than `??`: the hub returns "" for an unset key - // rather than null, and "" is the one value this key's write path - // refuses. + // `||` rather than `??`: an emptied box saves the default, "" + // being the one value this key's write path refuses. retention_days: String(s.retention_days || "30"), github_proxy: String(s.github_proxy ?? ""), public_page: s.public_page === "off" ? "off" : "on", @@ -2686,7 +2683,7 @@ function Notify({ nodes, refresh }: { nodes: Node[]; refresh: () => void }) {

事件

- set("notify_grace", e.target.value)} /> + set("notify_grace", e.target.value)} /> set("notify_traffic", e.target.value)} /> @@ -2719,8 +2716,6 @@ function Notify({ nodes, refresh }: { nodes: Node[]; refresh: () => void }) { ) } -// The two ways into this panel, on their own page: the GitHub identity it trusts -// and the password that works when GitHub does not. type Session = { id: string; current: boolean; created_at: number } function useSessions() { @@ -2786,6 +2781,8 @@ function Sessions({ rows, reload }: { rows: Session[]; reload: () => void }) { ) } +// The ways into this panel, on their own page: the sessions signed in, the +// GitHub identity it trusts and the password that works when GitHub does not. function Security({ site }: { site: string }) { const { s, set, save } = useSettings() const sessions = useSessions() diff --git a/web-admin/src/lib/api.ts b/web-admin/src/lib/api.ts index 669f3541..9bddf5b7 100644 --- a/web-admin/src/lib/api.ts +++ b/web-admin/src/lib/api.ts @@ -79,7 +79,7 @@ export type Node = { /** Panel only. The agent's reporting interval in seconds, read from its reports; null until two have arrived. */ interval?: number | null remark?: string - /** Panel only. Empty for nodes created before the hub retained a copy. */ + /** Panel only. */ token?: string /** Panel only. Whether going offline and returning are announced. */ notify?: boolean From 5f06dd8e1ce9a9c48b2a1008ea5f3f09111290e7 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:43:12 +0800 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20=E9=9D=A2=E6=9D=BF=E7=9A=84=20Node?= =?UTF-8?q?=20=E7=B1=BB=E5=9E=8B=E5=8E=BB=E6=8E=89=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E7=9A=84=20sort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-admin/src/lib/api.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/web-admin/src/lib/api.ts b/web-admin/src/lib/api.ts index 9bddf5b7..627f4ddd 100644 --- a/web-admin/src/lib/api.ts +++ b/web-admin/src/lib/api.ts @@ -26,7 +26,6 @@ export type Metrics = { export type Node = { id: number name: string - sort: number public: boolean online: boolean last_seen: number