Fix update looping and nmcli duplicate profiles

This commit is contained in:
Breadway 2026-06-07 10:14:18 +08:00
parent 586bc3a285
commit ef77a02e77
2 changed files with 111 additions and 18 deletions

View file

@ -240,6 +240,40 @@ fn enforce_dns(uuid: &str, iface: &str, dns: &str) {
} }
} }
/// Return the name of the first saved NM connection profile whose name is
/// either exactly `ssid` or `ssid N` (NM's numbered-duplicate convention).
/// Returns `None` if no such profile exists.
fn first_profile_for_ssid(ssid: &str) -> Option<String> {
let o = run(
"nmcli",
&["-t", "-f", "NAME,TYPE", "connection", "show"],
Duration::from_secs(8),
);
if !o.success {
return None;
}
let mut fallback: Option<String> = None;
for line in o.stdout.lines() {
let parts: Vec<&str> = line.splitn(2, ':').collect();
if parts.len() < 2 || !parts[1].contains("wireless") {
continue;
}
let name = unescape(parts[0]);
if name == ssid {
return Some(name);
}
if fallback.is_none() {
if let Some(suffix) = name.strip_prefix(ssid) {
let s = suffix.trim();
if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) {
fallback = Some(name);
}
}
}
}
fallback
}
/// Connect to a network and pin DNS. Returns true only if associated. /// Connect to a network and pin DNS. Returns true only if associated.
pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool { pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool {
connect_verbose(iface, net, wait, dns).is_ok() connect_verbose(iface, net, wait, dns).is_ok()
@ -247,12 +281,62 @@ pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool {
/// Connect to a network and pin DNS. Returns the nmcli error on failure. /// Connect to a network and pin DNS. Returns the nmcli error on failure.
/// ///
/// Uses `nmcli device wifi connect ... password <psk>` so the provided PSK /// Reuses an existing saved profile for the SSID when one exists (updating its
/// always takes effect, even when NM already has a stale saved profile for /// PSK) so that repeated connections do not accumulate numbered duplicates in
/// the same SSID. The PSK is briefly visible in /proc/<pid>/cmdline, which is /// NetworkManager ("NCC", "NCC 1", "NCC 2", …). Falls back to
/// an acceptable trade-off for a personal desktop tool. /// `nmcli device wifi connect` — which creates a new profile — only when no
/// saved profile is found.
pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String> { pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String> {
let wait_s = wait.to_string(); let wait_s = wait.to_string();
if let Some(profile) = first_profile_for_ssid(&net.ssid) {
// Update the saved PSK and, for hidden networks, ensure the flag is set.
if !net.password.is_empty() {
let _ = run(
"nmcli",
&[
"connection",
"modify",
&profile,
"802-11-wireless-security.psk",
net.password.as_str(),
],
Duration::from_secs(6),
);
}
if net.hidden {
let _ = run(
"nmcli",
&[
"connection",
"modify",
&profile,
"802-11-wireless.hidden",
"yes",
],
Duration::from_secs(6),
);
}
let o = run(
"nmcli",
&["--wait", &wait_s, "connection", "up", &profile, "ifname", iface],
Duration::from_secs(wait as u64 + 15),
);
if !o.success {
let detail = o.stderr.trim().to_string();
return Err(if detail.is_empty() {
o.stdout.trim().to_string()
} else {
detail
});
}
if let Some(uuid) = active_uuid(iface) {
enforce_dns(&uuid, iface, dns);
}
return Ok(());
}
// No saved profile — create one via device wifi connect.
let hidden = if net.hidden { "yes" } else { "no" }; let hidden = if net.hidden { "yes" } else { "no" };
let args = [ let args = [
"--wait", "--wait",

View file

@ -67,10 +67,7 @@ fn spawn_nm_monitor(tx: mpsc::Sender<()>) {
let l = line.to_lowercase(); let l = line.to_lowercase();
let interesting = l.contains("disconnect") let interesting = l.contains("disconnect")
|| l.contains("unavailable") || l.contains("unavailable")
|| l.contains("connected") || l.contains("failed");
|| l.contains("connection")
|| l.contains("now")
|| l.contains("state");
if interesting && last.elapsed() > Duration::from_millis(1500) { if interesting && last.elapsed() > Duration::from_millis(1500) {
last = Instant::now(); last = Instant::now();
let _ = tx.send(()); let _ = tx.send(());
@ -126,6 +123,8 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
let mut prev_health: Option<Health> = None; let mut prev_health: Option<Health> = None;
let mut prev_profile = profile.clone(); let mut prev_profile = profile.clone();
let mut fail_streak: u32 = 0; let mut fail_streak: u32 = 0;
let mut last_flow_at: Option<Instant> = None;
const FLOW_COOLDOWN: u64 = 20;
loop { loop {
// Reload config + state so edits and `profile set` take effect live. // Reload config + state so edits and `profile set` take effect live.
@ -146,6 +145,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
); );
prev_profile = profile.clone(); prev_profile = profile.clone();
prev_health = None; // force re-evaluation/recovery for new profile prev_health = None; // force re-evaluation/recovery for new profile
last_flow_at = None; // allow immediate recovery on profile change
} }
let (health, ssid) = classify(&cfg, &profile); let (health, ssid) = classify(&cfg, &profile);
@ -199,17 +199,26 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
Urgency::Normal, Urgency::Normal,
); );
} }
log(&format!( let elapsed = last_flow_at.map(|t| t.elapsed().as_secs()).unwrap_or(u64::MAX);
"watch: down ({:?}) profile={profile} ssid={:?} — running flow", if elapsed >= FLOW_COOLDOWN {
health, ssid log(&format!(
)); "watch: down ({:?}) profile={profile} ssid={:?} — running flow",
let outcome = flow::run(&cfg, &profile); health, ssid
log(&format!("watch: recovery outcome = {:?}", outcome)); ));
fail_streak = if outcome.ok() { let outcome = flow::run(&cfg, &profile);
0 log(&format!("watch: recovery outcome = {:?}", outcome));
last_flow_at = Some(Instant::now());
fail_streak = if outcome.ok() {
0
} else {
fail_streak.saturating_add(1)
};
} else { } else {
fail_streak.saturating_add(1) log(&format!(
}; "watch: down ({:?}) — cooldown ({elapsed}s/{FLOW_COOLDOWN}s), skipping flow",
health
));
}
} }
} }