@@ -4123,6 +4123,11 @@ pub async fn add_node(req: HttpRequest, state: web::Data<AppState>, body: web::J
41234123 let cluster_secret = crate::auth::load_cluster_secret();
41244124 let default_secret = crate::auth::default_cluster_secret();
41254125 let mut remote_wolfnet_ips: Vec<String> = Vec::new();
4126+ // Also capture the joining node's own identity (its self_id `ws-…` and
4127+ // hostname) so we can reconcile a reimaged node against any stale
4128+ // pre-reimage registry entry once it's added (see reconciliation below).
4129+ let mut remote_self_id = String::new();
4130+ let mut remote_hostname = String::new();
41264131 for url in &status_urls {
41274132 // Try the active cluster secret first, then the default (new node may not have received custom secret yet)
41284133 for secret in [&cluster_secret, &default_secret.to_string()] {
@@ -4133,11 +4138,37 @@ pub async fn add_node(req: HttpRequest, state: web::Data<AppState>, body: web::J
41334138 {
41344139 if resp.status().is_success() {
41354140 if let Ok(data) = resp.json::<serde_json::Value>().await {
4141+ // NOTE: `wolfnet_ips` is read from the TOP level on
4142+ // purpose — `/api/agent/status` actually returns an
4143+ // externally-tagged AgentMessage ({"StatusReport": {…}}),
4144+ // so this lookup has always returned None and the
4145+ // wolfnet-IP conflict check below has been dormant since
4146+ // it was written. We deliberately leave that as-is here:
4147+ // activating it would run BEFORE the reimage
4148+ // reconciliation and could reject a returning node whose
4149+ // static WolfNet IP still matches its own stale entry's
4150+ // cached route — breaking the exact rejoin workflow #3
4151+ // supports. (Flagged separately; needs its own fix that
4152+ // excludes the returning node's own IPs.)
41364153 if let Some(ips) = data.get("wolfnet_ips").and_then(|v| v.as_array()) {
41374154 remote_wolfnet_ips = ips.iter()
41384155 .filter_map(|v| v.as_str().map(|s| s.to_string()))
41394156 .collect();
41404157 }
4158+ // For identity reconciliation we DO need the real
4159+ // values, so reach into the StatusReport envelope (with
4160+ // a raw-object fallback should the format ever change).
4161+ let sr = data.get("StatusReport").unwrap_or(&data);
4162+ if remote_self_id.is_empty()
4163+ && let Some(s) = sr.get("node_id").and_then(|v| v.as_str())
4164+ {
4165+ remote_self_id = s.to_string();
4166+ }
4167+ if remote_hostname.is_empty()
4168+ && let Some(h) = sr.get("hostname").and_then(|v| v.as_str())
4169+ {
4170+ remote_hostname = h.to_string();
4171+ }
41414172 }
41424173 break;
41434174 }
@@ -4185,6 +4216,46 @@ pub async fn add_node(req: HttpRequest, state: web::Data<AppState>, body: web::J
41854216
41864217 let id = state.cluster.add_server(body.address.clone(), port, cluster_name.clone());
41874218
4219+ // ── Join-time identity reconciliation (AstroMando #3, 2026-06-27) ──
4220+ // A reimaged node returns with a NEW self_id (ws-…). The duplicate-block
4221+ // above only rejects a SAME address:port re-add, so an operator re-adding
4222+ // the node at its new address leaves the pre-reimage entry orphaned:
4223+ // offline forever, yet still carrying this node's WolfRun instances.
4224+ // WolfRun would then report those workloads "running" on a node that no
4225+ // longer exists and schedule replacements against a dead id. Collapse any
4226+ // such stale entry into the one we just added.
4227+ //
4228+ // Conservative match — a candidate must be OFFLINE, in the SAME cluster,
4229+ // not the entry we just created, and the same physical node either by
4230+ // self_id (an unchanged identity that merely moved address) OR by hostname
4231+ // (the reimage case, where the self_id changed). A live entry is never
4232+ // touched, so two genuinely-distinct online nodes can't be merged here.
4233+ if !remote_hostname.is_empty() || !remote_self_id.is_empty() {
4234+ let new_cluster = cluster_name.clone()
4235+ .or_else(|| state.cluster.get_node(&id).and_then(|n| n.cluster_name))
4236+ .unwrap_or_else(|| "WolfStack".to_string());
4237+ let stale: Vec<crate::agent::Node> = state.cluster.get_all_nodes()
4238+ .into_iter()
4239+ .filter(|n| n.id != id && !n.is_self && !n.online)
4240+ .filter(|n| node_is_returning_identity(n, &remote_self_id, &remote_hostname, &new_cluster))
4241+ .collect();
4242+ if stale.len() > 1 {
4243+ // More than one stale match is a registry anomaly (e.g. two
4244+ // offline entries sharing this hostname) — collapse them all but
4245+ // make it visible so an operator can investigate a mis-set name.
4246+ tracing::warn!(target: "add_node",
4247+ "Returning node '{}' matched {} stale registry entries — collapsing all into {}",
4248+ remote_hostname, stale.len(), id);
4249+ }
4250+ for old in &stale {
4251+ let moved = state.wolfrun.remap_node_id(&old.id, &id);
4252+ state.cluster.remove_server(&old.id);
4253+ tracing::info!(target: "add_node",
4254+ "Reconciled returning node '{}' ({} → {}): migrated {} WolfRun instance(s), removed stale offline entry",
4255+ remote_hostname, old.id, id, moved);
4256+ }
4257+ }
4258+
41884259 // Push our cluster secret to the new node — UNCONDITIONALLY, even
41894260 // when we're still on the built-in default. The "always push"
41904261 // semantic closes the Stage-2 new-cluster-formation corner case:
@@ -4882,6 +4953,21 @@ fn hostnames_same_node(a: &str, b: &str) -> bool {
48824953 a_short == b_short && (a.contains('.') || b.contains('.'))
48834954}
48844955
4956+ /// Does registry entry `n` look like the same physical node as one that just
4957+ /// (re)joined — for join-time identity reconciliation (AstroMando #3)? Matches
4958+ /// within the same cluster on EITHER an unchanged self_id (the node merely
4959+ /// moved address) OR the hostname (the reimage case, where the self_id is
4960+ /// regenerated). This is purely the identity match; the caller still applies
4961+ /// the offline / not-self / not-the-new-entry guards before collapsing.
4962+ fn node_is_returning_identity(n: &crate::agent::Node, self_id: &str, hostname: &str, cluster: &str) -> bool {
4963+ let same_cluster = n.cluster_name.as_deref().unwrap_or("WolfStack")
4964+ .eq_ignore_ascii_case(cluster);
4965+ if !same_cluster { return false; }
4966+ let sid_match = !self_id.is_empty() && n.self_id.as_deref() == Some(self_id);
4967+ let host_match = !hostname.is_empty() && hostnames_same_node(&n.hostname, hostname);
4968+ sid_match || host_match
4969+ }
4970+
48854971pub async fn wolfnet_sync_cluster(req: HttpRequest, state: web::Data<AppState>, body: web::Json<WolfNetSyncRequest>) -> HttpResponse {
48864972 if let Err(resp) = require_auth(&req, &state) { return resp; }
48874973
@@ -4994,6 +5080,28 @@ pub async fn wolfnet_sync_cluster(req: HttpRequest, state: web::Data<AppState>,
49945080 let mut infos: Vec<NodeWnInfo> = Vec::new();
49955081 let mut errors: Vec<String> = Vec::new();
49965082
5083+ // Same-LAN guard for the bind-all (0.0.0.0) address resolution below.
5084+ // The RFC1918 /24 of every cluster node that already has a real address.
5085+ // A node bound to 0.0.0.0 only substitutes its detected LAN IP for its
5086+ // public IP when that LAN IP shares a /24 with another cluster node —
5087+ // i.e. this genuinely is a single-LAN cluster (AstroMando's topology).
5088+ // On a real multi-DC cluster no peer shares the bind-all node's private
5089+ // /24, so we leave the public-IP value untouched and never trip the
5090+ // behind-NAT endpoint guard for an inter-site link.
5091+ let lan_prefix = |addr: &str| -> Option<String> {
5092+ let ip: std::net::Ipv4Addr = addr.parse().ok()?;
5093+ let o = ip.octets();
5094+ let rfc1918 = o[0] == 10
5095+ || (o[0] == 172 && (16..=31).contains(&o[1]))
5096+ || (o[0] == 192 && o[1] == 168);
5097+ if !rfc1918 { return None; }
5098+ Some(format!("{}.{}.{}", o[0], o[1], o[2]))
5099+ };
5100+ let cluster_lan_prefixes: std::collections::HashSet<String> = node_ids.iter()
5101+ .filter_map(|nid| state.cluster.get_node(nid))
5102+ .filter_map(|n| lan_prefix(&n.address))
5103+ .collect();
5104+
49975105 // Scoped strictly to the cluster passed in `node_ids` — other clusters
49985106 // are never examined or touched.
49995107 for nid in node_ids {
@@ -5018,12 +5126,30 @@ pub async fn wolfnet_sync_cluster(req: HttpRequest, state: web::Data<AppState>,
50185126 errors.push(format!("{}: WolfNet not configured", node.hostname));
50195127 continue;
50205128 }
5021- // If WolfStack is bound to 0.0.0.0 / 127.0.0.1, fall
5022- // back to public_ip then LAN-detected IP so the
5023- // address we record for this node is actually dialable.
5129+ // If WolfStack is bound to 0.0.0.0 / 127.0.0.1 we must
5130+ // resolve a real dialable address. On the common
5131+ // single-NAT topology (every node behind one public IP)
5132+ // the WAN address isn't reachable peer-to-peer, and
5133+ // recording it here poisons effective_site() +
5134+ // pick_wolfnet_endpoint() — the bind-all node's auto-site
5135+ // stops matching its LAN peers' /24, so the mesh falls to
5136+ // the (un-dialable) public path and the sync silently
5137+ // breaks WolfNet to/from that node (AstroMando,
5138+ // 2026-06-27). So prefer the detected LAN IP — but ONLY
5139+ // when it shares a /24 with another cluster node, i.e.
5140+ // this really is a single-LAN cluster. On a multi-DC
5141+ // cluster the detected private IP would differ from the
5142+ // node's public IP and trip the behind-NAT endpoint guard
5143+ // on peers, wiping a working inter-site link — so there we
5144+ // keep the public IP exactly as before. detect_lan_ip()
5145+ // only ever returns a PRIVATE address, so a genuinely
5146+ // public-only node yields None and falls back to
5147+ // public_ip regardless.
50245148 let effective_addr = if node.address == "0.0.0.0" || node.address == "127.0.0.1" {
5025- node.public_ip.clone()
5026- .or_else(|| networking::detect_lan_ip())
5149+ networking::detect_lan_ip()
5150+ .filter(|lan| lan_prefix(lan)
5151+ .is_some_and(|p| cluster_lan_prefixes.contains(&p)))
5152+ .or_else(|| node.public_ip.clone())
50275153 .unwrap_or_else(|| node.address.clone())
50285154 } else {
50295155 node.address.clone()
@@ -39141,6 +39267,59 @@ mod external_url_tests {
3914139267 assert!(!hostnames_same_node("", "immich"));
3914239268 assert!(!hostnames_same_node("immich", ""));
3914339269 }
39270+
39271+ #[test]
39272+ fn returning_identity_matches_reimage_and_address_move_but_not_distinct_nodes() {
39273+ use super::node_is_returning_identity;
39274+ // Build a registry Node from a small JSON object — most fields default.
39275+ fn node(over: serde_json::Value) -> crate::agent::Node {
39276+ let mut base = serde_json::json!({
39277+ "id": "node-old", "hostname": "ws-host", "address": "10.0.0.9",
39278+ "port": 8553, "last_seen": 0, "metrics": null,
39279+ "components": [], "online": false, "is_self": false
39280+ });
39281+ for (k, v) in over.as_object().unwrap() { base[k] = v.clone(); }
39282+ serde_json::from_value(base).unwrap()
39283+ }
39284+
39285+ // Reimage case: self_id regenerated (ws-new), hostname unchanged → match
39286+ // on hostname.
39287+ let stale = node(serde_json::json!({
39288+ "hostname": "ws-host", "self_id": "ws-old", "cluster_name": "WolfStack"
39289+ }));
39290+ assert!(node_is_returning_identity(&stale, "ws-new", "ws-host", "WolfStack"));
39291+
39292+ // Address-move case: same self_id, different hostname spelling → match
39293+ // on self_id.
39294+ let moved = node(serde_json::json!({
39295+ "hostname": "old-name", "self_id": "ws-keep", "cluster_name": "WolfStack"
39296+ }));
39297+ assert!(node_is_returning_identity(&moved, "ws-keep", "totally-different", "WolfStack"));
39298+
39299+ // Distinct node, different hostname AND different self_id → NO match,
39300+ // even in the same cluster (this is the merge we must never make).
39301+ let other = node(serde_json::json!({
39302+ "hostname": "ws-other", "self_id": "ws-other-id", "cluster_name": "WolfStack"
39303+ }));
39304+ assert!(!node_is_returning_identity(&other, "ws-new", "ws-host", "WolfStack"));
39305+
39306+ // Same hostname but a DIFFERENT cluster → never collapsed across clusters.
39307+ let cross = node(serde_json::json!({
39308+ "hostname": "ws-host", "self_id": "ws-x", "cluster_name": "OtherCluster"
39309+ }));
39310+ assert!(!node_is_returning_identity(&cross, "ws-new", "ws-host", "WolfStack"));
39311+
39312+ // Cluster comparison is case-insensitive (matches every other
39313+ // cluster-name comparison in the codebase).
39314+ let cased = node(serde_json::json!({
39315+ "hostname": "ws-host", "self_id": "ws-old", "cluster_name": "wolfstack"
39316+ }));
39317+ assert!(node_is_returning_identity(&cased, "ws-new", "ws-host", "WolfStack"));
39318+
39319+ // Empty inbound identity never matches (a failed status fetch must not
39320+ // collapse anything).
39321+ assert!(!node_is_returning_identity(&stale, "", "", "WolfStack"));
39322+ }
3914439323}
3914539324
3914639325#[cfg(test)]
0 commit comments