feat(mesh): show federated Archipelago nodes on the Mesh Map
Peers that opt in via a new "Share Location" toggle in Settings (server.set-location RPC) get plotted on other trusted peers' Mesh Map with a distinct Archy-logo marker, separate from raw LoRa radio peers. Location is persisted locally, carried in NodeStateSnapshot, and propagated through federation sync/delta like other node state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e3baaa5de3
commit
177b8a4338
@@ -419,6 +419,7 @@ impl RpcHandler {
|
||||
|
||||
// Server settings
|
||||
"server.set-name" => self.handle_server_set_name(params).await,
|
||||
"server.set-location" => self.handle_server_set_location(params).await,
|
||||
|
||||
// System monitoring
|
||||
"system.get-hostname" => self.handle_system_get_hostname().await,
|
||||
|
||||
@@ -454,6 +454,12 @@ impl RpcHandler {
|
||||
.flatten(),
|
||||
};
|
||||
|
||||
let shared_location = if data.server_info.share_location {
|
||||
data.server_info.lat.zip(data.server_info.lon)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let state = federation::build_local_state(
|
||||
apps,
|
||||
0.0,
|
||||
@@ -467,6 +473,7 @@ impl RpcHandler {
|
||||
nostr_npub,
|
||||
own_fips_npub,
|
||||
&federated_peers,
|
||||
shared_location,
|
||||
);
|
||||
|
||||
Ok(serde_json::to_value(&state)?)
|
||||
|
||||
@@ -77,6 +77,55 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// server.set-location — Set this node's own lat/lon + whether to share
|
||||
/// it with trusted federation peers (for the Mesh Map). `lat`/`lon` are
|
||||
/// optional so a caller can flip `share` off without clearing the saved
|
||||
/// position, or clear the position by passing nulls.
|
||||
pub(in crate::api::rpc) async fn handle_server_set_location(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let lat = params.get("lat").and_then(|v| v.as_f64());
|
||||
let lon = params.get("lon").and_then(|v| v.as_f64());
|
||||
let share_location = params
|
||||
.get("share")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: share"))?;
|
||||
|
||||
if let (Some(lat), Some(lon)) = (lat, lon) {
|
||||
if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) {
|
||||
anyhow::bail!("Invalid lat/lon");
|
||||
}
|
||||
}
|
||||
|
||||
let location_file = self.config.data_dir.join("server-location.json");
|
||||
let payload = serde_json::json!({ "lat": lat, "lon": lon, "share_location": share_location });
|
||||
tokio::fs::write(&location_file, serde_json::to_vec(&payload)?)
|
||||
.await
|
||||
.context("Failed to write server location")?;
|
||||
|
||||
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||
data.server_info.lat = lat;
|
||||
data.server_info.lon = lon;
|
||||
data.server_info.share_location = share_location;
|
||||
self.state_manager.update_data(data).await;
|
||||
|
||||
info!(share_location, "Server location updated");
|
||||
|
||||
// Push the new location to federation peers in background, same as
|
||||
// a rename — trusted peers' next state sync picks it up.
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let state_manager = self.state_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = push_name_to_peers(&data_dir, &state_manager).await {
|
||||
debug!("Federation location push (non-fatal): {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({ "lat": lat, "lon": lon, "share_location": share_location }))
|
||||
}
|
||||
|
||||
/// system.get-hostname — Current OS hostname + the mDNS `.local` name it
|
||||
/// resolves to on the LAN (avahi-daemon advertises `<hostname>.local`).
|
||||
/// Lets Settings show users where to reach this node over HTTPS for
|
||||
|
||||
@@ -61,6 +61,18 @@ pub struct ServerInfo {
|
||||
/// True if this node's keys are derived from a BIP-39 seed.
|
||||
#[serde(rename = "seed-backed", default)]
|
||||
pub seed_backed: bool,
|
||||
/// This node's own physical location, for the Mesh Map — opt-in only
|
||||
/// (see `share_location`), set via `server.set-location`. `None` until
|
||||
/// the user sets one, regardless of `share_location`.
|
||||
#[serde(default)]
|
||||
pub lat: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub lon: Option<f64>,
|
||||
/// Whether `lat`/`lon` should be included in the state snapshot we send
|
||||
/// to trusted federation peers (so they can plot us on their Mesh Map).
|
||||
/// Defaults to false — never shared unless explicitly turned on.
|
||||
#[serde(rename = "share-location", default)]
|
||||
pub share_location: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -347,6 +359,9 @@ impl DataModel {
|
||||
wifi_ssids: vec![],
|
||||
zram_enabled: false,
|
||||
seed_backed: false,
|
||||
lat: None,
|
||||
lon: None,
|
||||
share_location: false,
|
||||
},
|
||||
package_data: HashMap::new(),
|
||||
peer_health: HashMap::new(),
|
||||
|
||||
@@ -506,6 +506,8 @@ mod tests {
|
||||
nostr_npub: None,
|
||||
own_fips_npub: None,
|
||||
federated_peers: Vec::new(),
|
||||
lat: None,
|
||||
lon: None,
|
||||
};
|
||||
|
||||
update_node_state(dir.path(), "did:key:z1", state)
|
||||
|
||||
@@ -208,6 +208,7 @@ async fn merge_transitive_peers(
|
||||
/// and route directly over FIPS from now on). Only peers we trust are
|
||||
/// shared — an Untrusted/Observer node should not be re-exported
|
||||
/// through us to the network.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_local_state(
|
||||
apps: Vec<AppStatus>,
|
||||
cpu: f64,
|
||||
@@ -221,6 +222,9 @@ pub fn build_local_state(
|
||||
nostr_npub: Option<String>,
|
||||
own_fips_npub: Option<String>,
|
||||
federated_peers: &[FederatedNode],
|
||||
// Only Some when the node has opted in via server.set-location's
|
||||
// `share` flag — see NodeStateSnapshot::lat/lon's doc comment.
|
||||
shared_location: Option<(f64, f64)>,
|
||||
) -> NodeStateSnapshot {
|
||||
let hints = federated_peers
|
||||
.iter()
|
||||
@@ -248,6 +252,8 @@ pub fn build_local_state(
|
||||
nostr_npub,
|
||||
own_fips_npub,
|
||||
federated_peers: hints,
|
||||
lat: shared_location.map(|(lat, _)| lat),
|
||||
lon: shared_location.map(|(_, lon)| lon),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,12 +347,14 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert_eq!(state.apps.len(), 1);
|
||||
assert_eq!(state.cpu_usage_percent, Some(25.5));
|
||||
assert_eq!(state.tor_active, Some(true));
|
||||
assert_eq!(state.node_name, Some("Test Node".to_string()));
|
||||
assert!(state.federated_peers.is_empty());
|
||||
assert_eq!(state.lat, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -392,7 +400,7 @@ mod tests {
|
||||
last_transport_at: None,
|
||||
},
|
||||
];
|
||||
let state = build_local_state(vec![], 0.0, 0, 0, 0, 0, 0, true, None, None, None, &peers);
|
||||
let state = build_local_state(vec![], 0.0, 0, 0, 0, 0, 0, true, None, None, None, &peers, None);
|
||||
assert_eq!(state.federated_peers.len(), 1);
|
||||
assert_eq!(state.federated_peers[0].did, "did:key:zTrusted");
|
||||
assert_eq!(
|
||||
|
||||
@@ -93,6 +93,14 @@ pub struct NodeStateSnapshot {
|
||||
/// re-export them in her own state snapshots).
|
||||
#[serde(default)]
|
||||
pub federated_peers: Vec<FederationPeerHint>,
|
||||
/// This node's own location, for the Mesh Map — only present when the
|
||||
/// sender has opted in via `server.set-location`'s `share` flag. Absent
|
||||
/// (not just null) for nodes that haven't opted in, so older receivers
|
||||
/// and the map's "no location shared" state both fall out naturally.
|
||||
#[serde(default)]
|
||||
pub lat: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub lon: Option<f64>,
|
||||
}
|
||||
|
||||
/// Minimal peer summary shared via `NodeStateSnapshot.federated_peers`.
|
||||
|
||||
@@ -83,6 +83,16 @@ impl Server {
|
||||
data.server_info.name = Some(name);
|
||||
}
|
||||
}
|
||||
// Load persisted node location (Mesh Map opt-in sharing)
|
||||
let location_file = config.data_dir.join("server-location.json");
|
||||
if let Ok(bytes) = tokio::fs::read(&location_file).await {
|
||||
if let Ok(loc) = serde_json::from_slice::<serde_json::Value>(&bytes) {
|
||||
data.server_info.lat = loc.get("lat").and_then(|v| v.as_f64());
|
||||
data.server_info.lon = loc.get("lon").and_then(|v| v.as_f64());
|
||||
data.server_info.share_location =
|
||||
loc.get("share_location").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
}
|
||||
}
|
||||
data.server_info.tor_address = docker_packages::read_tor_address("archipelago").await;
|
||||
if let Some(ref tor) = data.server_info.tor_address {
|
||||
data.server_info.node_address = Some(identity.node_address(tor));
|
||||
|
||||
@@ -48,6 +48,12 @@ pub struct StateDelta {
|
||||
/// Tor active flag (only if changed).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tor: Option<bool>,
|
||||
/// Shared location (only if changed) — same "None means unchanged"
|
||||
/// convention as the other scalar fields here.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lat: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lon: Option<f64>,
|
||||
}
|
||||
|
||||
/// Compute the delta between two state snapshots.
|
||||
@@ -114,6 +120,12 @@ pub fn compute_delta(prev: &NodeStateSnapshot, curr: &NodeStateSnapshot) -> Stat
|
||||
if curr.tor_active != prev.tor_active {
|
||||
delta.tor = curr.tor_active;
|
||||
}
|
||||
if curr.lat != prev.lat {
|
||||
delta.lat = curr.lat;
|
||||
}
|
||||
if curr.lon != prev.lon {
|
||||
delta.lon = curr.lon;
|
||||
}
|
||||
|
||||
delta
|
||||
}
|
||||
@@ -162,6 +174,12 @@ pub fn apply_delta(base: &NodeStateSnapshot, delta: &StateDelta) -> NodeStateSna
|
||||
if let Some(tor) = delta.tor {
|
||||
result.tor_active = Some(tor);
|
||||
}
|
||||
if let Some(lat) = delta.lat {
|
||||
result.lat = Some(lat);
|
||||
}
|
||||
if let Some(lon) = delta.lon {
|
||||
result.lon = Some(lon);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
@@ -225,6 +243,8 @@ mod tests {
|
||||
nostr_npub: None,
|
||||
own_fips_npub: None,
|
||||
federated_peers: Vec::new(),
|
||||
lat: None,
|
||||
lon: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +279,8 @@ mod tests {
|
||||
nostr_npub: None,
|
||||
own_fips_npub: None,
|
||||
federated_peers: Vec::new(),
|
||||
lat: None,
|
||||
lon: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user