Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions install-hub.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 16 additions & 17 deletions src/agent_ws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`].
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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]
Expand Down
40 changes: 17 additions & 23 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -316,7 +316,7 @@ pub async fn nodes(State(app): State<Shared>, 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()
}

Expand Down Expand Up @@ -658,10 +658,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<u32>, price: Option<f64>, limit: Option<i64>) -> Option<&'static str> {
if reset_day.is_some_and(|d| !(1..=31).contains(&d)) {
return Some("流量重置日要在 1 到 31 之间");
Expand Down Expand Up @@ -1356,8 +1354,8 @@ pub async fn db_stats(_: Admin, State(app): State<Shared>) -> Response {
/// outlives the download.
pub async fn db_backup(_: Admin, State(app): State<Shared>) -> 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
Expand Down Expand Up @@ -1962,8 +1960,8 @@ pub async fn settings(_: Admin, State(app): State<Shared>) -> Json<Value> {
}
// 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()));
Expand All @@ -1990,9 +1988,8 @@ pub async fn settings(_: Admin, State(app): State<Shared>) -> Json<Value> {
/// the UI reported as rejected.
fn setting_error(app: &App, key: &str, value: &Value) -> Option<String> {
// 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()),
Expand Down Expand Up @@ -2035,13 +2032,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)?;
Expand Down Expand Up @@ -2490,8 +2485,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();
Expand Down Expand Up @@ -2979,9 +2974,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());
Expand Down Expand Up @@ -3274,7 +3268,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();
Expand Down
3 changes: 2 additions & 1 deletion src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HashMap<IpAddr, (u32, Instant)>>,
/// How long a failure is remembered. A field rather than the constant so
Expand Down
38 changes: 19 additions & 19 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,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,
Expand Down Expand Up @@ -550,10 +550,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<String>,
pub sort: Option<i64>,
pub public: Option<bool>,
pub price: Option<f64>,
pub currency: Option<String>,
Expand Down Expand Up @@ -1085,23 +1086,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,
Expand Down Expand Up @@ -2564,8 +2564,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 {
Expand Down Expand Up @@ -3557,10 +3557,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()));
Expand Down
4 changes: 3 additions & 1 deletion src/frontend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,9 @@ fn manifest(root: &Path, short: &str) -> Option<Theme> {
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)
}
Expand Down
Loading
Loading