integration: preserve deployed 1.8.0 OTA work
This commit is contained in:
@@ -176,8 +176,7 @@ impl RpcHandler {
|
||||
// launch_port_reachable() below would otherwise upgrade an exited backend
|
||||
// back to "running". The reconcile guard keeps these backends down, so the
|
||||
// marker is authoritative here.
|
||||
let user_stopped =
|
||||
crate::crash_recovery::load_user_stopped(&self.config.data_dir).await;
|
||||
let user_stopped = crate::crash_recovery::load_user_stopped(&self.config.data_dir).await;
|
||||
if data.server_info.status_info.containers_scanned && !data.package_data.is_empty() {
|
||||
let mut containers = Vec::with_capacity(data.package_data.len());
|
||||
for (id, pkg) in &data.package_data {
|
||||
|
||||
@@ -429,11 +429,15 @@ impl RpcHandler {
|
||||
},
|
||||
Some("fedimint") => match mint_fedimint().await {
|
||||
Ok((notes, fed)) => {
|
||||
tracing::info!("paid download: spending {price_sats} sats Fedimint notes from {fed}");
|
||||
tracing::info!(
|
||||
"paid download: spending {price_sats} sats Fedimint notes from {fed}"
|
||||
);
|
||||
(notes, "fedimint")
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("paid download: fedimint spend failed for {price_sats} sats: {e:#}");
|
||||
tracing::warn!(
|
||||
"paid download: fedimint spend failed for {price_sats} sats: {e:#}"
|
||||
);
|
||||
return Ok(serde_json::json!({ "error": format!(
|
||||
"Couldn't pay {price_sats} sats from your Fedimint wallet: {e}. \
|
||||
Fund it, or choose Cashu."
|
||||
@@ -457,7 +461,9 @@ impl RpcHandler {
|
||||
},
|
||||
},
|
||||
};
|
||||
tracing::info!("paid download: paying {price_sats} sats to {onion} via {used_backend} ecash");
|
||||
tracing::info!(
|
||||
"paid download: paying {price_sats} sats to {onion} via {used_backend} ecash"
|
||||
);
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
|
||||
@@ -19,7 +19,10 @@ impl RpcHandler {
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
(svc.assistant_config().await, svc.assistant_denied_askers().await)
|
||||
(
|
||||
svc.assistant_config().await,
|
||||
svc.assistant_denied_askers().await,
|
||||
)
|
||||
};
|
||||
|
||||
let (ollama_detected, models) = detect_ollama().await;
|
||||
|
||||
@@ -257,12 +257,14 @@ mod tests {
|
||||
Some("28.4")
|
||||
);
|
||||
assert_eq!(
|
||||
image_tag("146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260508")
|
||||
.as_deref(),
|
||||
image_tag("146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260508").as_deref(),
|
||||
Some("29.3.knots20260508")
|
||||
);
|
||||
// No tag => None (don't mistake the registry port for a tag).
|
||||
assert_eq!(image_tag("146.59.87.168:3000/lfg2025/bitcoin"), None);
|
||||
assert_eq!(image_tag("docker.io/library/redis:7"), Some("7".to_string()));
|
||||
assert_eq!(
|
||||
image_tag("docker.io/library/redis:7"),
|
||||
Some("7".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,11 @@ impl RpcHandler {
|
||||
// Spendable Fedimint balance too, so callers (e.g. the pay-for-file
|
||||
// pre-check) see funds available across BOTH backends (#3). Best-effort:
|
||||
// if fmcd isn't installed/joined this is just 0, never an error.
|
||||
let fedimint_sats = match fedimint_client::FedimintClient::from_node(&self.config.data_dir)
|
||||
.await
|
||||
{
|
||||
Ok(client) => client.total_balance_sats().await.unwrap_or(0),
|
||||
Err(_) => 0,
|
||||
};
|
||||
let fedimint_sats =
|
||||
match fedimint_client::FedimintClient::from_node(&self.config.data_dir).await {
|
||||
Ok(client) => client.total_balance_sats().await.unwrap_or(0),
|
||||
Err(_) => 0,
|
||||
};
|
||||
Ok(serde_json::json!({
|
||||
// `balance_sats` stays Cashu-only for back-compat; `total_sats` is the
|
||||
// spendable amount across Cashu + Fedimint.
|
||||
|
||||
@@ -220,7 +220,9 @@ pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> {
|
||||
/// `version` field), if covered. Used to decide whether an install-time
|
||||
/// selection should pin (older) or track-latest (default).
|
||||
pub fn catalog_default_version(app_id: &str) -> Option<String> {
|
||||
entry_for(app_id).map(|e| e.version).filter(|v| !v.is_empty())
|
||||
entry_for(app_id)
|
||||
.map(|e| e.version)
|
||||
.filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// Curated, selectable versions for an app per the remote catalog. Empty when
|
||||
@@ -261,8 +263,8 @@ pub fn catalog_image_for_version(
|
||||
format!("{repo}:{version}")
|
||||
}
|
||||
};
|
||||
let same_repo =
|
||||
crate::container::image_versions::image_without_registry_or_tag(&candidate) == manifest_repo;
|
||||
let same_repo = crate::container::image_versions::image_without_registry_or_tag(&candidate)
|
||||
== manifest_repo;
|
||||
if same_repo {
|
||||
Some(candidate)
|
||||
} else {
|
||||
|
||||
@@ -294,7 +294,7 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn wait_for_status_calls(rt: &CountingRuntime, expected: u32) -> u32 {
|
||||
for _ in 0..100 {
|
||||
for _ in 0..1000 {
|
||||
let count = rt.status_call_count();
|
||||
if count >= expected {
|
||||
return count;
|
||||
@@ -341,11 +341,10 @@ mod tests {
|
||||
assert_eq!(wait_for_status_calls(&rt, 1).await, 1);
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
wait_for_status_calls(&rt, 2).await;
|
||||
let count = wait_for_status_calls(&rt, 2).await;
|
||||
|
||||
assert_eq!(
|
||||
rt.status_call_count(),
|
||||
2,
|
||||
assert!(
|
||||
count >= 2,
|
||||
"a second reconcile pass should fire after one interval"
|
||||
);
|
||||
|
||||
@@ -403,9 +402,7 @@ mod tests {
|
||||
assert!(first >= 1, "initial pass should have touched the runtime");
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
tokio::task::yield_now().await;
|
||||
tokio::task::yield_now().await;
|
||||
let second = rt.status_call_count();
|
||||
let second = wait_for_status_calls(&rt, first + 1).await;
|
||||
assert!(
|
||||
second > first,
|
||||
"loop should have fired a second pass after the interval"
|
||||
|
||||
@@ -336,7 +336,10 @@ async fn image_created_unix(image: &str) -> Option<i64> {
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse::<i64>().ok()
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.trim()
|
||||
.parse::<i64>()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Newest modification time (Unix seconds) across all files under `dir`,
|
||||
|
||||
@@ -85,12 +85,7 @@ pub async fn run_post_install(manifest: &AppManifest, container_name: &str, data
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_step(
|
||||
step: &HookStep,
|
||||
container: &str,
|
||||
app_id: &str,
|
||||
data_dir: &Path,
|
||||
) -> Result<()> {
|
||||
async fn run_step(step: &HookStep, container: &str, app_id: &str, data_dir: &Path) -> Result<()> {
|
||||
match step {
|
||||
HookStep::Exec { exec } => {
|
||||
let mut args: Vec<&str> = Vec::with_capacity(exec.len() + 2);
|
||||
|
||||
@@ -302,9 +302,8 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
|
||||
/// journal on every pass. Keyed by Id so a recreated container retries afresh.
|
||||
fn unrepairable_ownership() -> &'static std::sync::Mutex<std::collections::HashSet<(String, String)>>
|
||||
{
|
||||
static SET: std::sync::OnceLock<
|
||||
std::sync::Mutex<std::collections::HashSet<(String, String)>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
static SET: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<(String, String)>>> =
|
||||
std::sync::OnceLock::new();
|
||||
SET.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
|
||||
}
|
||||
|
||||
@@ -1266,9 +1265,13 @@ impl ProdContainerOrchestrator {
|
||||
.get(&app_id)
|
||||
.map(|lm| lm.manifest_dir.clone())
|
||||
.unwrap_or_else(|| root.join(&app_id));
|
||||
state
|
||||
.manifests
|
||||
.insert(app_id.clone(), LoadedManifest { manifest: m, manifest_dir });
|
||||
state.manifests.insert(
|
||||
app_id.clone(),
|
||||
LoadedManifest {
|
||||
manifest: m,
|
||||
manifest_dir,
|
||||
},
|
||||
);
|
||||
overlaid += 1;
|
||||
}
|
||||
}
|
||||
@@ -3409,7 +3412,11 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
{
|
||||
tracing::debug!(container = %name, error = %err, "quadlet stop skipped/failed");
|
||||
}
|
||||
match self.runtime.stop_container_with_grace(&name, grace_secs).await {
|
||||
match self
|
||||
.runtime
|
||||
.stop_container_with_grace(&name, grace_secs)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) => {
|
||||
let stuck_stopping = self
|
||||
@@ -3919,15 +3926,17 @@ app:
|
||||
// ("release", no digit) ship — the app then vanished from the
|
||||
// orchestrator and a stack install half-fell-back to the legacy path.
|
||||
// Fail loudly here instead.
|
||||
let m = AppManifest::from_file(&mf).unwrap_or_else(|e| {
|
||||
panic!("shipped manifest {} must be valid: {e}", mf.display())
|
||||
});
|
||||
let m = AppManifest::from_file(&mf)
|
||||
.unwrap_or_else(|e| panic!("shipped manifest {} must be valid: {e}", mf.display()));
|
||||
let id = m.app.id.clone();
|
||||
let is_build = m.app.container.build.is_some();
|
||||
let value = serde_json::to_value(&m).expect("manifest serializes to JSON");
|
||||
let overlay = catalog_manifest_to_overlay(&id, value);
|
||||
if is_build {
|
||||
assert!(overlay.is_none(), "{id}: build-source app must defer to disk");
|
||||
assert!(
|
||||
overlay.is_none(),
|
||||
"{id}: build-source app must defer to disk"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
overlay.is_some(),
|
||||
@@ -4010,7 +4019,8 @@ app:
|
||||
runtime,
|
||||
PathBuf::from("/nonexistent-for-tests"),
|
||||
);
|
||||
orch.set_data_dir(PathBuf::from("/nonexistent-for-tests"));
|
||||
let data_dir = tempfile::tempdir().unwrap().keep();
|
||||
orch.set_data_dir(data_dir);
|
||||
// Redirect the bitcoin-ui pre-start hook to a test-scoped
|
||||
// tmpdir, seeded with a fake password file. Shared across
|
||||
// every test in this module (OnceLock), so the hook can run
|
||||
@@ -4294,22 +4304,25 @@ app:
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
rt.mark_image_present("archy-bitcoin-ui:local");
|
||||
let orch = orch_with(rt.clone()).await;
|
||||
orch.insert_manifest_for_test(
|
||||
build_manifest(
|
||||
"bitcoin-ui",
|
||||
"/opt/archy/docker/bitcoin-ui",
|
||||
"archy-bitcoin-ui:local",
|
||||
),
|
||||
PathBuf::from("/opt/archy/apps/bitcoin-ui"),
|
||||
)
|
||||
.await;
|
||||
let build_context = tempfile::tempdir().unwrap();
|
||||
std::fs::write(build_context.path().join("Dockerfile"), "FROM scratch\n").unwrap();
|
||||
let build_context = build_context.path().to_string_lossy().into_owned();
|
||||
let manifest = build_manifest("bitcoin-ui", &build_context, "archy-bitcoin-ui:local");
|
||||
let fingerprint = fingerprint_build_context(Path::new(&build_context))
|
||||
.expect("test build context must be fingerprintable");
|
||||
let stamp_path = build_fingerprint_stamp_path(&orch.data_dir, "archy-bitcoin-ui:local");
|
||||
std::fs::create_dir_all(stamp_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&stamp_path, fingerprint).unwrap();
|
||||
orch.insert_manifest_for_test(manifest, PathBuf::from("/opt/archy/apps/bitcoin-ui"))
|
||||
.await;
|
||||
|
||||
orch.install("bitcoin-ui").await.unwrap();
|
||||
let calls = rt.calls();
|
||||
assert!(calls
|
||||
.iter()
|
||||
.any(|c| c == "image_exists:archy-bitcoin-ui:local"));
|
||||
// Build must NOT be invoked because the image is already there.
|
||||
// Build must NOT be invoked because the image is already there and its
|
||||
// recorded build-context fingerprint still matches.
|
||||
assert!(!calls.iter().any(|c| c.starts_with("build_image:")));
|
||||
}
|
||||
|
||||
@@ -4370,7 +4383,7 @@ app:
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let orch = orch_with(rt.clone()).await;
|
||||
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
let data_dir = tempfile::tempdir_in("/var/lib/archipelago").unwrap();
|
||||
let id_u = std::process::Command::new("id").arg("-u").output().unwrap();
|
||||
let id_g = std::process::Command::new("id").arg("-g").output().unwrap();
|
||||
let uid = String::from_utf8_lossy(&id_u.stdout).trim().to_string();
|
||||
@@ -4400,7 +4413,7 @@ app:
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let orch = orch_with(rt.clone()).await;
|
||||
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
let data_dir = tempfile::tempdir_in("/var/lib/archipelago").unwrap();
|
||||
orch.insert_manifest_for_test(
|
||||
pull_manifest_with_generated_file(
|
||||
"exampleapp",
|
||||
@@ -4427,7 +4440,7 @@ app:
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let orch = orch_with(rt.clone()).await;
|
||||
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
let data_dir = tempfile::tempdir_in("/var/lib/archipelago").unwrap();
|
||||
let config_path = data_dir.path().join("config.yaml");
|
||||
std::fs::write(&config_path, "key: operator\n").unwrap();
|
||||
|
||||
@@ -4452,7 +4465,7 @@ app:
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let orch = orch_with(rt.clone()).await;
|
||||
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
let data_dir = tempfile::tempdir_in("/var/lib/archipelago").unwrap();
|
||||
let config_path = data_dir.path().join("config.yaml");
|
||||
std::fs::write(&config_path, "key: old\n").unwrap();
|
||||
|
||||
|
||||
@@ -1300,7 +1300,10 @@ app:
|
||||
let u = QuadletUnit::from_manifest(&m, "x");
|
||||
// tmpfs entry is dropped from bind_mounts; bind entry survives.
|
||||
assert_eq!(u.bind_mounts.len(), 1);
|
||||
assert_eq!(u.bind_mounts[0].host, PathBuf::from("/var/lib/archipelago/x"));
|
||||
assert_eq!(
|
||||
u.bind_mounts[0].host,
|
||||
PathBuf::from("/var/lib/archipelago/x")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -169,7 +169,10 @@ mod tests {
|
||||
let hash = std::fs::read_to_string(dir.path().join("admin")).unwrap();
|
||||
let pw = std::fs::read_to_string(dir.path().join("admin.pw")).unwrap();
|
||||
assert!(hash.starts_with("$2"), "bcrypt hash shape");
|
||||
assert!(bcrypt::verify(pw.trim(), hash.trim()).unwrap(), "pw matches hash");
|
||||
assert!(
|
||||
bcrypt::verify(pw.trim(), hash.trim()).unwrap(),
|
||||
"pw matches hash"
|
||||
);
|
||||
|
||||
for f in ["tok", "admin", "admin.pw"] {
|
||||
let mode = std::fs::metadata(dir.path().join(f))
|
||||
@@ -189,7 +192,10 @@ mod tests {
|
||||
let first = std::fs::read_to_string(dir.path().join("tok")).unwrap();
|
||||
ensure_generated_secrets(dir.path(), &m).unwrap();
|
||||
let second = std::fs::read_to_string(dir.path().join("tok")).unwrap();
|
||||
assert_eq!(first, second, "a present readable secret is never rewritten");
|
||||
assert_eq!(
|
||||
first, second,
|
||||
"a present readable secret is never rewritten"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -172,11 +172,8 @@ mod tests {
|
||||
fn with_tmp_data_dir<F: FnOnce()>(f: F) {
|
||||
let mut counter = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*counter += 1;
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"archy-vc-test-{}-{}",
|
||||
std::process::id(),
|
||||
*counter
|
||||
));
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("archy-vc-test-{}-{}", std::process::id(), *counter));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::env::set_var("ARCHIPELAGO_DATA_DIR", &dir);
|
||||
@@ -244,10 +241,7 @@ mod tests {
|
||||
assert!(!is_downgrade("28.4", "28.4"));
|
||||
// Knots date-suffixed strings compare on major.minor only.
|
||||
assert!(is_downgrade("29.3.knots20260508", "28.1.knots20251010"));
|
||||
assert!(!is_downgrade(
|
||||
"29.3.knots20260101",
|
||||
"29.3.knots20260508"
|
||||
));
|
||||
assert!(!is_downgrade("29.3.knots20260101", "29.3.knots20260508"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -153,7 +153,9 @@ pub async fn read_owned(
|
||||
onion: &str,
|
||||
content_id: &str,
|
||||
) -> Option<(String, Vec<u8>)> {
|
||||
let bytes = fs::read(bytes_path(data_dir, onion, content_id)).await.ok()?;
|
||||
let bytes = fs::read(bytes_path(data_dir, onion, content_id))
|
||||
.await
|
||||
.ok()?;
|
||||
let mime = load_index(data_dir)
|
||||
.await
|
||||
.items
|
||||
|
||||
@@ -296,7 +296,9 @@ pub(crate) async fn notify_join(
|
||||
status = %resp.status(),
|
||||
"peer-joined notification rejected; will retry"
|
||||
),
|
||||
Err(e) => tracing::warn!(attempt, error = %e, "peer-joined notification failed; will retry"),
|
||||
Err(e) => {
|
||||
tracing::warn!(attempt, error = %e, "peer-joined notification failed; will retry")
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(10 * attempt as u64)).await;
|
||||
}
|
||||
|
||||
@@ -1358,6 +1358,14 @@ mod tests {
|
||||
host_port_ready: None,
|
||||
healthy: true,
|
||||
},
|
||||
ContainerHealth {
|
||||
name: "indeedhub-minio".into(),
|
||||
app_id: "indeedhub-minio".into(),
|
||||
state: "running".into(),
|
||||
podman_health: None,
|
||||
host_port_ready: None,
|
||||
healthy: true,
|
||||
},
|
||||
ContainerHealth {
|
||||
name: "indeedhub-api".into(),
|
||||
app_id: "indeedhub-api".into(),
|
||||
|
||||
@@ -181,7 +181,10 @@ async fn is_sender_allowed(
|
||||
match peers.get(&sender_contact_id) {
|
||||
// Match identity on the bound archipelago key (stable, advert/
|
||||
// federation-verified), not the firmware routing key.
|
||||
Some(p) => (p.identity_pubkey_hex().map(|s| s.to_string()), p.did.clone()),
|
||||
Some(p) => (
|
||||
p.identity_pubkey_hex().map(|s| s.to_string()),
|
||||
p.did.clone(),
|
||||
),
|
||||
None => (None, None),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -314,17 +314,66 @@ pub(super) async fn try_chunk_reassemble(
|
||||
|
||||
/// Look up a peer by pubkey hex prefix. Returns (contact_id, display_name).
|
||||
pub(super) async fn resolve_peer(state: &Arc<MeshState>, sender_prefix: &str) -> (u32, String) {
|
||||
let peers = state.peers.read().await;
|
||||
peers
|
||||
.values()
|
||||
.find(|p| {
|
||||
{
|
||||
let peers = state.peers.read().await;
|
||||
if let Some(peer) = peers.values().find(|p| {
|
||||
p.pubkey_hex
|
||||
.as_ref()
|
||||
.map(|k| k.starts_with(sender_prefix))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|p| (p.contact_id, p.advert_name.clone()))
|
||||
.unwrap_or((0, sender_prefix.to_string()))
|
||||
}) {
|
||||
return (peer.contact_id, peer.advert_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((node_num, pubkey_hex, name)) = meshtastic_peer_from_prefix(sender_prefix) {
|
||||
let peer = MeshPeer {
|
||||
contact_id: node_num,
|
||||
advert_name: name.clone(),
|
||||
did: None,
|
||||
pubkey_hex: Some(pubkey_hex),
|
||||
arch_pubkey_hex: None,
|
||||
x25519_pubkey: None,
|
||||
rssi: None,
|
||||
snr: None,
|
||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||
hops: 0xff,
|
||||
last_advert: 0,
|
||||
reachable: true,
|
||||
};
|
||||
let is_new = {
|
||||
let mut peers = state.peers.write().await;
|
||||
peers.insert(node_num, peer.clone()).is_none()
|
||||
};
|
||||
state.update_peer_count().await;
|
||||
let _ = state.event_tx.send(if is_new {
|
||||
MeshEvent::PeerDiscovered(peer)
|
||||
} else {
|
||||
MeshEvent::PeerUpdated(peer)
|
||||
});
|
||||
return (node_num, name);
|
||||
}
|
||||
|
||||
(0, sender_prefix.to_string())
|
||||
}
|
||||
|
||||
fn meshtastic_peer_from_prefix(sender_prefix: &str) -> Option<(u32, String, String)> {
|
||||
if sender_prefix.len() < 12 {
|
||||
return None;
|
||||
}
|
||||
let bytes = hex::decode(&sender_prefix[..12]).ok()?;
|
||||
if bytes.len() != 6 || bytes[4] != b'm' || bytes[5] != b'e' {
|
||||
return None;
|
||||
}
|
||||
let node_num = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
|
||||
if node_num == 0 || node_num == u32::MAX {
|
||||
return None;
|
||||
}
|
||||
let mut full_key = [0u8; 32];
|
||||
full_key[..4].copy_from_slice(&node_num.to_le_bytes());
|
||||
full_key[4..15].copy_from_slice(b"meshtastic:");
|
||||
let name = format!("Meshtastic !{:08x}", node_num);
|
||||
Some((node_num, hex::encode(full_key), name))
|
||||
}
|
||||
|
||||
/// Store a plain-text (non-typed) message and emit an event.
|
||||
@@ -333,6 +382,16 @@ pub(super) async fn store_plain_message(
|
||||
contact_id: u32,
|
||||
peer_name: &str,
|
||||
text: &str,
|
||||
) {
|
||||
store_plain_message_with_encryption(state, contact_id, peer_name, text, false).await;
|
||||
}
|
||||
|
||||
pub(super) async fn store_plain_message_with_encryption(
|
||||
state: &Arc<MeshState>,
|
||||
contact_id: u32,
|
||||
peer_name: &str,
|
||||
text: &str,
|
||||
encrypted: bool,
|
||||
) {
|
||||
let msg_id = state.next_id().await;
|
||||
let msg = MeshMessage {
|
||||
@@ -343,7 +402,7 @@ pub(super) async fn store_plain_message(
|
||||
plaintext: text.to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
delivered: true,
|
||||
encrypted: false,
|
||||
encrypted,
|
||||
message_type: "text".to_string(),
|
||||
typed_payload: None,
|
||||
sender_pubkey: None,
|
||||
|
||||
@@ -4,7 +4,8 @@ use super::super::message_types::TypedEnvelope;
|
||||
use super::super::protocol;
|
||||
use super::decode::{
|
||||
handle_identity_received, is_mc_chunk_frame, resolve_peer, store_plain_message,
|
||||
try_base64_typed, try_chunk_reassemble, try_decrypt_base64, try_decrypt_ratchet_base64,
|
||||
store_plain_message_with_encryption, try_base64_typed, try_chunk_reassemble,
|
||||
try_decrypt_base64, try_decrypt_ratchet_base64,
|
||||
};
|
||||
use super::dispatch::handle_typed_message;
|
||||
use super::MeshState;
|
||||
@@ -62,11 +63,12 @@ pub(super) async fn handle_frame(
|
||||
return true; // Signal caller to sync immediately
|
||||
}
|
||||
|
||||
protocol::RESP_CONTACT_MSG_V3 => {
|
||||
protocol::RESP_CONTACT_MSG_V3 | protocol::RESP_CONTACT_MSG_V3_E2E => {
|
||||
// Direct message received (v3 format) — check for typed envelope first
|
||||
match protocol::parse_contact_msg_v3_raw(&frame.data) {
|
||||
Ok((sender_prefix, payload, _snr)) => {
|
||||
if !payload.is_empty() {
|
||||
let encrypted = frame.code == protocol::RESP_CONTACT_MSG_V3_E2E;
|
||||
let (contact_id, name) = resolve_peer(state, &sender_prefix).await;
|
||||
if TypedEnvelope::is_typed(&payload) {
|
||||
handle_typed_message(&payload, contact_id, &name, state).await;
|
||||
@@ -86,7 +88,10 @@ pub(super) async fn handle_frame(
|
||||
handle_typed_message(&decoded, contact_id, &name, state).await;
|
||||
} else if !payload.starts_with(b"MC") {
|
||||
let text = String::from_utf8_lossy(&payload).to_string();
|
||||
store_plain_message(state, contact_id, &name, &text).await;
|
||||
store_plain_message_with_encryption(
|
||||
state, contact_id, &name, &text, encrypted,
|
||||
)
|
||||
.await;
|
||||
info!(from = %sender_prefix, "Received mesh DM (v3)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,8 +407,13 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
|
||||
// user-controlled feature; until then every firmware contact is
|
||||
// surfaced. `radio_contact_blocklist` is retained but unused.
|
||||
let mut peers = state.peers.write().await;
|
||||
let is_meshtastic = matches!(device.device_type(), DeviceType::Meshtastic);
|
||||
for (idx, contact) in contacts.iter().enumerate() {
|
||||
let contact_id = idx as u32;
|
||||
let contact_id = if is_meshtastic {
|
||||
meshtastic_contact_id(&contact.public_key_hex).unwrap_or(idx as u32)
|
||||
} else {
|
||||
idx as u32
|
||||
};
|
||||
let existing = peers.get(&contact_id);
|
||||
let peer = super::super::types::MeshPeer {
|
||||
contact_id,
|
||||
@@ -482,6 +487,19 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
|
||||
}
|
||||
}
|
||||
|
||||
fn meshtastic_contact_id(public_key_hex: &str) -> Option<u32> {
|
||||
let bytes = hex::decode(public_key_hex).ok()?;
|
||||
if bytes.len() < 15 || &bytes[4..15] != b"meshtastic:" {
|
||||
return None;
|
||||
}
|
||||
let node_num = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
|
||||
if node_num == 0 || node_num == u32::MAX {
|
||||
None
|
||||
} else {
|
||||
Some(node_num)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain any queued messages from the device.
|
||||
/// Returns `true` if a write/communication error occurred (for failure tracking).
|
||||
async fn sync_queued_messages(
|
||||
|
||||
@@ -22,6 +22,7 @@ const START2: u8 = 0xc3;
|
||||
const TO_RADIO_MAX: usize = 512;
|
||||
const BROADCAST_NUM: u32 = 0xffff_ffff;
|
||||
const TEXT_MESSAGE_APP: u32 = 1;
|
||||
const POSITION_APP: u32 = 3;
|
||||
/// Meshtastic PortNum for NodeInfo (identity) packets — used to actively
|
||||
/// advertise ourselves over the air so neighbours discover us, the parity
|
||||
/// equivalent of meshcore's self-advert.
|
||||
@@ -33,6 +34,8 @@ const ADMIN_SET_OWNER_FIELD: u64 = 32;
|
||||
/// Meshtastic firmware caps long_name at ~40 bytes and short_name at 4 bytes.
|
||||
const MESHTASTIC_LONG_NAME_MAX: usize = 39;
|
||||
const MESHTASTIC_SHORT_NAME_MAX: usize = 4;
|
||||
const STALE_RX_SECS: u32 = 24 * 60 * 60;
|
||||
const PLAUSIBLE_RX_EPOCH_SECS: u32 = 1_700_000_000; // 2023-11-14
|
||||
|
||||
const TO_RADIO_PACKET: u64 = 1;
|
||||
const TO_RADIO_WANT_CONFIG_ID: u64 = 3;
|
||||
@@ -45,6 +48,11 @@ const FROM_RADIO_NODE_INFO: u64 = 4;
|
||||
const FROM_RADIO_CONFIG: u64 = 5;
|
||||
const FROM_RADIO_CONFIG_COMPLETE_ID: u64 = 7;
|
||||
const FROM_RADIO_REBOOTED: u64 = 8;
|
||||
/// Upper bound for a single Meshtastic serial API protobuf frame. The serial
|
||||
/// stream can contain firmware log text, so this is also used to reject false
|
||||
/// 0x94c3 markers found inside logs instead of waiting forever for a bogus
|
||||
/// length.
|
||||
const FROM_RADIO_MAX: usize = 4096;
|
||||
|
||||
/// AdminMessage.set_config oneof field number (carries a `Config`). NB: 33 is
|
||||
/// `set_channel` — `set_config` is 34 (verified against meshtastic/protobufs).
|
||||
@@ -53,6 +61,15 @@ const ADMIN_SET_CONFIG_FIELD: u64 = 34;
|
||||
const ADMIN_SET_CHANNEL_FIELD: u64 = 33;
|
||||
/// FromRadio.channel (field 10): a `Channel` streamed during want_config.
|
||||
const FROM_RADIO_CHANNEL: u64 = 10;
|
||||
const FROM_RADIO_QUEUE_STATUS: u64 = 11;
|
||||
const FROM_RADIO_XMODEM_PACKET: u64 = 12;
|
||||
const FROM_RADIO_METADATA: u64 = 13;
|
||||
const FROM_RADIO_MQTT_CLIENT_PROXY_MESSAGE: u64 = 14;
|
||||
const FROM_RADIO_FILE_INFO: u64 = 15;
|
||||
const FROM_RADIO_CLIENT_NOTIFICATION: u64 = 16;
|
||||
const FROM_RADIO_DEVICE_UI_CONFIG: u64 = 17;
|
||||
const FROM_RADIO_LOCKDOWN_STATUS: u64 = 18;
|
||||
const FROM_RADIO_REGION_PRESETS: u64 = 19;
|
||||
/// Channel.role value for the PRIMARY channel (broadcasts ride here).
|
||||
const CHANNEL_ROLE_PRIMARY: u64 = 1;
|
||||
/// Config.lora oneof field number (carries a `LoRaConfig`).
|
||||
@@ -386,7 +403,23 @@ impl MeshtasticDevice {
|
||||
}
|
||||
|
||||
pub async fn send_self_advert(&mut self) -> Result<()> {
|
||||
self.send_to_radio(&encode_heartbeat()).await
|
||||
self.send_to_radio(&encode_heartbeat()).await?;
|
||||
self.send_time_broadcast().await
|
||||
}
|
||||
|
||||
/// Broadcast a minimal Position payload carrying current epoch time. The
|
||||
/// Meshtastic protobuf explicitly documents `Position.time` as the path for
|
||||
/// phone/API clients to set time on mesh devices without GPS/RTC. This keeps
|
||||
/// stock Meshtastic clients from rendering incoming Archipelago-originated
|
||||
/// packets as Jan 1 1970 when their radio clock is unset.
|
||||
pub async fn send_time_broadcast(&mut self) -> Result<()> {
|
||||
let now = now_unix_secs();
|
||||
let mut position = Vec::new();
|
||||
encode_fixed32_field(4, now, &mut position);
|
||||
encode_fixed32_field(7, now, &mut position);
|
||||
let packet = encode_mesh_packet(BROADCAST_NUM, POSITION_APP, &position);
|
||||
self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Build our own `User` protobuf (id/long_name/short_name) for a NodeInfo
|
||||
@@ -595,8 +628,10 @@ impl MeshtasticDevice {
|
||||
// Bound memory if it's a pure-debug flood with no frames:
|
||||
// keep only from the last possible frame-start marker.
|
||||
if self.read_buf.len() > 64 * 1024 {
|
||||
if let Some(pos) =
|
||||
self.read_buf.windows(2).rposition(|w| w == [START1, START2])
|
||||
if let Some(pos) = self
|
||||
.read_buf
|
||||
.windows(2)
|
||||
.rposition(|w| w == [START1, START2])
|
||||
{
|
||||
self.read_buf.drain(..pos);
|
||||
} else {
|
||||
@@ -653,7 +688,17 @@ impl MeshtasticDevice {
|
||||
}
|
||||
None
|
||||
}
|
||||
FROM_RADIO_CONFIG_COMPLETE_ID | FROM_RADIO_REBOOTED => None,
|
||||
FROM_RADIO_CONFIG_COMPLETE_ID
|
||||
| FROM_RADIO_REBOOTED
|
||||
| FROM_RADIO_QUEUE_STATUS
|
||||
| FROM_RADIO_XMODEM_PACKET
|
||||
| FROM_RADIO_METADATA
|
||||
| FROM_RADIO_MQTT_CLIENT_PROXY_MESSAGE
|
||||
| FROM_RADIO_FILE_INFO
|
||||
| FROM_RADIO_CLIENT_NOTIFICATION
|
||||
| FROM_RADIO_DEVICE_UI_CONFIG
|
||||
| FROM_RADIO_LOCKDOWN_STATUS
|
||||
| FROM_RADIO_REGION_PRESETS => None,
|
||||
other => {
|
||||
debug!(
|
||||
field = other,
|
||||
@@ -700,73 +745,135 @@ impl MeshtasticDevice {
|
||||
}
|
||||
|
||||
fn packet_to_inbound_frame(&mut self, data: &[u8]) -> Option<InboundFrame> {
|
||||
let packet = parse_mesh_packet(data)?;
|
||||
if packet.portnum != TEXT_MESSAGE_APP || packet.payload.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let from = packet.from.unwrap_or(0);
|
||||
if Some(from) == self.node_num {
|
||||
return None;
|
||||
}
|
||||
info!(
|
||||
from = format!("!{:08x}", from),
|
||||
len = packet.payload.len(),
|
||||
pki = packet.pki_encrypted,
|
||||
"Meshtastic received text packet over the air"
|
||||
);
|
||||
// Record E2E status: a `pki_encrypted` packet (or one carrying the
|
||||
// sender's `public_key`) proves this DM arrived end-to-end encrypted via
|
||||
// the PKI, not the shared channel PSK. We learn the sender's key here too
|
||||
// — but keep it OUT of the routing `public_key_hex` (synthetic) so the
|
||||
// device interface stays identical to meshcore's and the two remain
|
||||
// hot-swappable behind the mesh listener.
|
||||
if let Some(pk) = packet.public_key.as_ref() {
|
||||
self.peer_pubkeys.entry(from).or_insert_with(|| pk.clone());
|
||||
}
|
||||
if packet.pki_encrypted {
|
||||
debug!(node = from, "Meshtastic DM received end-to-end encrypted (PKI)");
|
||||
}
|
||||
let from_key = synthetic_pubkey(from);
|
||||
self.contacts.entry(from).or_insert_with(|| ParsedContact {
|
||||
public_key_hex: hex::encode(synthetic_pubkey(from)),
|
||||
advert_name: format!("Meshtastic !{:08x}", from),
|
||||
last_advert: 0,
|
||||
contact_type: 1,
|
||||
path_len: 0xff,
|
||||
flags: 0,
|
||||
});
|
||||
|
||||
let mut payload = Vec::with_capacity(15 + packet.payload.len());
|
||||
payload.push(0); // SNR unknown
|
||||
payload.extend_from_slice(&[0, 0]); // reserved
|
||||
payload.extend_from_slice(&from_key[..6]);
|
||||
payload.push(0xff); // unknown/flood path
|
||||
payload.push(0); // text type
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
payload.extend_from_slice(&packet.payload);
|
||||
Some(InboundFrame {
|
||||
code: super::protocol::RESP_CONTACT_MSG_V3,
|
||||
data: payload,
|
||||
bytes_consumed: 0,
|
||||
})
|
||||
packet_to_inbound_frame(
|
||||
data,
|
||||
self.node_num,
|
||||
&mut self.contacts,
|
||||
&mut self.peer_pubkeys,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn packet_to_inbound_frame(
|
||||
data: &[u8],
|
||||
local_node_num: Option<u32>,
|
||||
contacts: &mut HashMap<u32, ParsedContact>,
|
||||
peer_pubkeys: &mut HashMap<u32, Vec<u8>>,
|
||||
) -> Option<InboundFrame> {
|
||||
let packet = parse_mesh_packet(data)?;
|
||||
if packet.portnum != TEXT_MESSAGE_APP || packet.payload.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if packet_is_stale(packet.rx_time) {
|
||||
debug!(
|
||||
from = ?packet.from.map(|n| format!("!{:08x}", n)),
|
||||
rx_time = ?packet.rx_time,
|
||||
"Dropping stale Meshtastic text packet from radio backlog"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let from = packet.from.unwrap_or(0);
|
||||
if Some(from) == local_node_num {
|
||||
return None;
|
||||
}
|
||||
info!(
|
||||
from = format!("!{:08x}", from),
|
||||
len = packet.payload.len(),
|
||||
pki = packet.pki_encrypted,
|
||||
"Meshtastic received text packet over the air"
|
||||
);
|
||||
// Record E2E status without overwriting the synthetic routing key used by
|
||||
// the shared mesh listener.
|
||||
if let Some(pk) = packet.public_key.as_ref() {
|
||||
peer_pubkeys.entry(from).or_insert_with(|| pk.clone());
|
||||
}
|
||||
if packet.pki_encrypted {
|
||||
debug!(
|
||||
node = from,
|
||||
"Meshtastic DM received end-to-end encrypted (PKI)"
|
||||
);
|
||||
}
|
||||
let from_key = synthetic_pubkey(from);
|
||||
contacts.entry(from).or_insert_with(|| ParsedContact {
|
||||
public_key_hex: hex::encode(synthetic_pubkey(from)),
|
||||
advert_name: format!("Meshtastic !{:08x}", from),
|
||||
last_advert: 0,
|
||||
contact_type: 1,
|
||||
path_len: 0xff,
|
||||
flags: 0,
|
||||
});
|
||||
|
||||
let mut payload = Vec::with_capacity(15 + packet.payload.len());
|
||||
payload.push(0); // SNR unknown
|
||||
payload.extend_from_slice(&[0, 0]); // reserved
|
||||
payload.extend_from_slice(&from_key[..6]);
|
||||
payload.push(0xff); // unknown/flood path
|
||||
payload.push(0); // text type
|
||||
payload.extend_from_slice(&packet.rx_time.unwrap_or_else(now_unix_secs).to_le_bytes());
|
||||
payload.extend_from_slice(&packet.payload);
|
||||
Some(InboundFrame {
|
||||
code: if packet.pki_encrypted {
|
||||
super::protocol::RESP_CONTACT_MSG_V3_E2E
|
||||
} else {
|
||||
super::protocol::RESP_CONTACT_MSG_V3
|
||||
},
|
||||
data: payload,
|
||||
bytes_consumed: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn packet_is_stale(rx_time: Option<u32>) -> bool {
|
||||
let Some(rx_time) = rx_time else {
|
||||
return false;
|
||||
};
|
||||
// Radios without GPS/RTC can report tiny nonzero epoch values until their
|
||||
// clock is set. Treat those as unknown, not stale, or live LoRa packets from
|
||||
// stock Meshtastic peers disappear before reaching mesh.messages.
|
||||
if rx_time < PLAUSIBLE_RX_EPOCH_SECS {
|
||||
return false;
|
||||
}
|
||||
let now = now_unix_secs();
|
||||
if now < PLAUSIBLE_RX_EPOCH_SECS || rx_time > now.saturating_add(60) {
|
||||
return false;
|
||||
}
|
||||
rx_time.saturating_add(STALE_RX_SECS) < now
|
||||
}
|
||||
|
||||
fn decode_serial_frame(buf: &mut Vec<u8>) -> Option<Vec<u8>> {
|
||||
let start = buf.windows(2).position(|w| w == [START1, START2])?;
|
||||
if start > 0 {
|
||||
buf.drain(..start);
|
||||
loop {
|
||||
let start = buf.windows(2).position(|w| w == [START1, START2])?;
|
||||
if start > 0 {
|
||||
buf.drain(..start);
|
||||
}
|
||||
if buf.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let len = u16::from_be_bytes([buf[2], buf[3]]) as usize;
|
||||
if len == 0 || len > FROM_RADIO_MAX {
|
||||
debug!(
|
||||
len,
|
||||
head = %hex::encode(&buf[..buf.len().min(16)]),
|
||||
"Discarding invalid Meshtastic serial frame marker"
|
||||
);
|
||||
buf.drain(..1);
|
||||
continue;
|
||||
}
|
||||
if buf.len() < 4 + len {
|
||||
return None;
|
||||
}
|
||||
let payload = buf[4..4 + len].to_vec();
|
||||
if decode_top_level_variant(&payload).is_none() {
|
||||
debug!(
|
||||
len,
|
||||
head = %hex::encode(&buf[..buf.len().min(16)]),
|
||||
"Discarding invalid Meshtastic serial frame payload"
|
||||
);
|
||||
buf.drain(..1);
|
||||
continue;
|
||||
}
|
||||
buf.drain(..4 + len);
|
||||
return Some(payload);
|
||||
}
|
||||
if buf.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let len = u16::from_be_bytes([buf[2], buf[3]]) as usize;
|
||||
if buf.len() < 4 + len {
|
||||
return None;
|
||||
}
|
||||
let payload = buf[4..4 + len].to_vec();
|
||||
buf.drain(..4 + len);
|
||||
Some(payload)
|
||||
}
|
||||
|
||||
fn encode_want_config() -> Vec<u8> {
|
||||
@@ -844,9 +951,7 @@ fn parse_primary_channel(data: &[u8]) -> Option<(String, Vec<u8>)> {
|
||||
j = sn;
|
||||
match (sf, sv) {
|
||||
(2, FieldValue::Bytes(p)) => psk = p.to_vec(),
|
||||
(3, FieldValue::Bytes(n)) => {
|
||||
name = String::from_utf8_lossy(n).to_string()
|
||||
}
|
||||
(3, FieldValue::Bytes(n)) => name = String::from_utf8_lossy(n).to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -918,6 +1023,8 @@ fn encode_mesh_packet(to: u32, portnum: u32, payload: &[u8]) -> Vec<u8> {
|
||||
let mut packet = Vec::new();
|
||||
encode_fixed32_field(2, to, &mut packet);
|
||||
encode_len_field(4, &decoded, &mut packet);
|
||||
encode_fixed32_field(6, next_packet_id(), &mut packet);
|
||||
encode_fixed32_field(7, now_unix_secs(), &mut packet);
|
||||
packet
|
||||
}
|
||||
|
||||
@@ -949,6 +1056,15 @@ fn decode_top_level_variant(buf: &[u8]) -> Option<(u64, &[u8])> {
|
||||
| FROM_RADIO_NODE_INFO
|
||||
| FROM_RADIO_CONFIG
|
||||
| FROM_RADIO_CHANNEL
|
||||
| FROM_RADIO_QUEUE_STATUS
|
||||
| FROM_RADIO_XMODEM_PACKET
|
||||
| FROM_RADIO_METADATA
|
||||
| FROM_RADIO_MQTT_CLIENT_PROXY_MESSAGE
|
||||
| FROM_RADIO_FILE_INFO
|
||||
| FROM_RADIO_CLIENT_NOTIFICATION
|
||||
| FROM_RADIO_DEVICE_UI_CONFIG
|
||||
| FROM_RADIO_LOCKDOWN_STATUS
|
||||
| FROM_RADIO_REGION_PRESETS
|
||||
) {
|
||||
return Some((field, &buf[idx..end]));
|
||||
}
|
||||
@@ -1056,6 +1172,9 @@ struct ParsedPacket {
|
||||
from: Option<u32>,
|
||||
portnum: u32,
|
||||
payload: Vec<u8>,
|
||||
#[allow(dead_code)]
|
||||
id: Option<u32>,
|
||||
rx_time: Option<u32>,
|
||||
/// MeshPacket.pki_encrypted (field 17): the firmware decrypted this packet
|
||||
/// with the PKI (Curve25519) key, i.e. it arrived end-to-end encrypted
|
||||
/// rather than via the shared channel PSK.
|
||||
@@ -1068,6 +1187,8 @@ fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
||||
let mut idx = 0;
|
||||
let mut from = None;
|
||||
let mut decoded = None;
|
||||
let mut id = None;
|
||||
let mut rx_time = None;
|
||||
let mut pki_encrypted = false;
|
||||
let mut public_key = None;
|
||||
while idx < data.len() {
|
||||
@@ -1076,6 +1197,8 @@ fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
||||
match (field, value) {
|
||||
(1, FieldValue::Fixed32(v)) => from = Some(v),
|
||||
(4, FieldValue::Bytes(b)) => decoded = Some(b),
|
||||
(6, FieldValue::Fixed32(v)) => id = Some(v),
|
||||
(7, FieldValue::Fixed32(v)) if v != 0 => rx_time = Some(v),
|
||||
(16, FieldValue::Bytes(b)) if !b.is_empty() => public_key = Some(b.to_vec()),
|
||||
(17, FieldValue::Varint(v)) => pki_encrypted = v != 0,
|
||||
_ => {}
|
||||
@@ -1098,11 +1221,30 @@ fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
||||
from,
|
||||
portnum,
|
||||
payload,
|
||||
id,
|
||||
rx_time,
|
||||
pki_encrypted,
|
||||
public_key,
|
||||
})
|
||||
}
|
||||
|
||||
fn now_unix_secs() -> u32 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as u32)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn next_packet_id() -> u32 {
|
||||
static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(1);
|
||||
let ctr = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos())
|
||||
.unwrap_or(0);
|
||||
nanos ^ ctr.rotate_left(16)
|
||||
}
|
||||
|
||||
enum FieldValue<'a> {
|
||||
Varint(u64),
|
||||
Fixed32(u32),
|
||||
@@ -1204,3 +1346,134 @@ fn synthetic_pubkey(node_num: u32) -> [u8; 32] {
|
||||
out[4..15].copy_from_slice(b"meshtastic:");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mesh::protocol;
|
||||
|
||||
fn serial_frame(payload: &[u8]) -> Vec<u8> {
|
||||
let mut frame = Vec::new();
|
||||
frame.push(START1);
|
||||
frame.push(START2);
|
||||
frame.extend_from_slice(&(payload.len() as u16).to_be_bytes());
|
||||
frame.extend_from_slice(payload);
|
||||
frame
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_serial_frame_skips_false_marker_with_impossible_length() {
|
||||
let valid_payload = encode_varint_field(FROM_RADIO_CONFIG_COMPLETE_ID, 1);
|
||||
let mut buf = vec![b'l', b'o', b'g', START1, START2, 0xff, 0xff, b'x'];
|
||||
buf.extend_from_slice(&serial_frame(&valid_payload));
|
||||
|
||||
let decoded = decode_serial_frame(&mut buf).expect("valid frame after false marker");
|
||||
assert_eq!(decoded, valid_payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_serial_frame_skips_false_marker_with_invalid_payload() {
|
||||
let valid_payload = encode_varint_field(FROM_RADIO_CONFIG_COMPLETE_ID, 1);
|
||||
let mut buf = vec![START1, START2, 0x00, 0x03, b'b', b'a', b'd'];
|
||||
buf.extend_from_slice(&serial_frame(&valid_payload));
|
||||
|
||||
let decoded = decode_serial_frame(&mut buf).expect("valid frame after invalid payload");
|
||||
assert_eq!(decoded, valid_payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_serial_frame_accepts_queue_status_variant() {
|
||||
let mut queue_status = Vec::new();
|
||||
encode_len_field(
|
||||
FROM_RADIO_QUEUE_STATUS,
|
||||
&[0x10, 0x0e, 0x18, 0x10],
|
||||
&mut queue_status,
|
||||
);
|
||||
let mut buf = serial_frame(&queue_status);
|
||||
|
||||
let decoded =
|
||||
decode_serial_frame(&mut buf).expect("queue status is a valid FromRadio frame");
|
||||
assert_eq!(decoded, queue_status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_mesh_packet_sets_nonzero_id_and_time() {
|
||||
let before = now_unix_secs();
|
||||
let packet = encode_mesh_packet(0x1122_3344, TEXT_MESSAGE_APP, b"hello");
|
||||
let after = now_unix_secs();
|
||||
let parsed = parse_mesh_packet(&packet).expect("packet should parse");
|
||||
|
||||
assert_eq!(parsed.portnum, TEXT_MESSAGE_APP);
|
||||
assert_eq!(parsed.payload, b"hello");
|
||||
assert!(parsed.id.unwrap_or(0) != 0);
|
||||
let rx_time = parsed.rx_time.expect("rx_time should be set");
|
||||
assert!(rx_time >= before.saturating_sub(1));
|
||||
assert!(rx_time <= after.saturating_add(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packet_to_inbound_frame_accepts_stock_peer_with_unset_clock() {
|
||||
let from = 0x0000_3ccc;
|
||||
let mut contacts = HashMap::new();
|
||||
let mut peer_pubkeys = HashMap::new();
|
||||
let mut decoded = Vec::new();
|
||||
encode_varint_field_into(1, TEXT_MESSAGE_APP as u64, &mut decoded);
|
||||
encode_len_field(2, b"hello from 3ccc", &mut decoded);
|
||||
|
||||
let mut packet = Vec::new();
|
||||
encode_fixed32_field(1, from, &mut packet);
|
||||
encode_fixed32_field(2, BROADCAST_NUM, &mut packet);
|
||||
encode_len_field(4, &decoded, &mut packet);
|
||||
encode_fixed32_field(7, 12_345, &mut packet);
|
||||
|
||||
let frame =
|
||||
packet_to_inbound_frame(&packet, Some(0x1111_1111), &mut contacts, &mut peer_pubkeys)
|
||||
.expect("live packet with unset radio clock must not be dropped");
|
||||
assert_eq!(frame.code, protocol::RESP_CONTACT_MSG_V3);
|
||||
|
||||
let (sender_prefix, payload, _snr) =
|
||||
protocol::parse_contact_msg_v3_raw(&frame.data).unwrap();
|
||||
assert_eq!(sender_prefix, "cc3c00006d65");
|
||||
assert_eq!(payload, b"hello from 3ccc");
|
||||
assert!(contacts.contains_key(&from));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packet_to_inbound_frame_accepts_recent_meshtastic_backlog() {
|
||||
let from = 0x433e_3ccc;
|
||||
let mut contacts = HashMap::new();
|
||||
let mut peer_pubkeys = HashMap::new();
|
||||
let mut decoded = Vec::new();
|
||||
encode_varint_field_into(1, TEXT_MESSAGE_APP as u64, &mut decoded);
|
||||
encode_len_field(2, b"recent backlog", &mut decoded);
|
||||
|
||||
let mut packet = Vec::new();
|
||||
encode_fixed32_field(1, from, &mut packet);
|
||||
encode_fixed32_field(2, BROADCAST_NUM, &mut packet);
|
||||
encode_len_field(4, &decoded, &mut packet);
|
||||
encode_fixed32_field(7, now_unix_secs().saturating_sub(60 * 60), &mut packet);
|
||||
|
||||
let frame =
|
||||
packet_to_inbound_frame(&packet, Some(0x1111_1111), &mut contacts, &mut peer_pubkeys)
|
||||
.expect("recent radio backlog must surface in mesh.messages");
|
||||
let (sender_prefix, payload, _snr) =
|
||||
protocol::parse_contact_msg_v3_raw(&frame.data).unwrap();
|
||||
assert_eq!(sender_prefix, "cc3c3e436d65");
|
||||
assert_eq!(payload, b"recent backlog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_filter_keeps_packets_from_radios_with_unset_clock() {
|
||||
assert!(!packet_is_stale(None));
|
||||
assert!(!packet_is_stale(Some(0)));
|
||||
assert!(!packet_is_stale(Some(12_345)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_filter_drops_only_plausibly_old_packets() {
|
||||
let old = now_unix_secs().saturating_sub(STALE_RX_SECS + 60);
|
||||
if old >= PLAUSIBLE_RX_EPOCH_SECS {
|
||||
assert!(packet_is_stale(Some(old)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1109,15 +1109,13 @@ impl MeshService {
|
||||
// (FIPS→Tor) instead of handing it to a radio that physically cannot
|
||||
// deliver it. Reachable radio peers stay on the mesh; oversized
|
||||
// envelopes (file shares etc.) always take the federation path.
|
||||
let radio_federated_unreachable = !is_federation_synthetic
|
||||
&& !exceeds_lora
|
||||
&& {
|
||||
let peers = self.state.peers.read().await;
|
||||
peers
|
||||
.get(&contact_id)
|
||||
.map(|p| !p.reachable && p.arch_pubkey_hex.is_some())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let radio_federated_unreachable = !is_federation_synthetic && !exceeds_lora && {
|
||||
let peers = self.state.peers.read().await;
|
||||
peers
|
||||
.get(&contact_id)
|
||||
.map(|p| !p.reachable && p.arch_pubkey_hex.is_some())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
if is_federation_synthetic || exceeds_lora || radio_federated_unreachable {
|
||||
// Resolve the peer's pubkey/did. Prefer the live mesh peer table,
|
||||
// but fall back to federation storage for federation-synthetic ids
|
||||
@@ -1508,9 +1506,12 @@ impl MeshService {
|
||||
// it stays backward compatible. (Federation/Tor sends already sign in
|
||||
// `send_typed_wire_via_federation`.) `with_seq` is applied after signing
|
||||
// — seq is not covered by the signature.
|
||||
let envelope =
|
||||
TypedEnvelope::new_signed(MeshMessageType::Text, text.as_bytes().to_vec(), &self.signing_key)
|
||||
.with_seq(seq);
|
||||
let envelope = TypedEnvelope::new_signed(
|
||||
MeshMessageType::Text,
|
||||
text.as_bytes().to_vec(),
|
||||
&self.signing_key,
|
||||
)
|
||||
.with_seq(seq);
|
||||
let wire = envelope.to_wire()?;
|
||||
self.send_typed_wire(contact_id, wire, "text", text, None, seq)
|
||||
.await
|
||||
@@ -1653,7 +1654,13 @@ impl MeshService {
|
||||
/// Recently-denied `!ai` askers (newest first) so the UI can offer to allow
|
||||
/// them. Cleared implicitly as new denials rotate older ones out.
|
||||
pub async fn assistant_denied_askers(&self) -> Vec<listener::DeniedAsker> {
|
||||
self.state.assist_denied.read().await.iter().cloned().collect()
|
||||
self.state
|
||||
.assist_denied
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Update the mesh-AI assistant settings live (no listener restart) and
|
||||
|
||||
@@ -64,6 +64,11 @@ pub const RESP_CONTACT_MSG_V3: u8 = 0x10;
|
||||
pub const RESP_CHANNEL_MSG_V3: u8 = 0x11;
|
||||
pub const RESP_CHANNEL_INFO: u8 = 0x12;
|
||||
pub const RESP_STATS: u8 = 0x18;
|
||||
/// Archipelago-internal synthetic response code used by the Meshtastic adapter
|
||||
/// for text DMs that the firmware reports as PKI-encrypted. Meshcore firmware
|
||||
/// never emits this code; it lets the shared listener persist the E2E badge
|
||||
/// without changing the on-wire Meshcore frame format.
|
||||
pub const RESP_CONTACT_MSG_V3_E2E: u8 = 0x13;
|
||||
|
||||
// --- Push notification codes (device -> host, async, >= 0x80) ---
|
||||
pub const PUSH_CONTACT_ADVERT: u8 = 0x80;
|
||||
|
||||
@@ -462,8 +462,9 @@ impl Server {
|
||||
let local_onion = snap.server_info.tor_address.clone().unwrap_or_default();
|
||||
let local_pubkey = snap.server_info.pubkey.clone();
|
||||
let local_name = snap.server_info.name.clone();
|
||||
let local_fips_npub =
|
||||
crate::identity::fips_npub(&identity_dir).await.unwrap_or(None);
|
||||
let local_fips_npub = crate::identity::fips_npub(&identity_dir)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
let mut ok = 0usize;
|
||||
let mut healed = 0usize;
|
||||
for node in &nodes {
|
||||
@@ -486,10 +487,8 @@ impl Server {
|
||||
// Without this, a node that joined everyone stays
|
||||
// invisible to the whole fleet until a manual
|
||||
// re-add (the "X250-EXP missing everywhere" case).
|
||||
let they_list_us = state
|
||||
.federated_peers
|
||||
.iter()
|
||||
.any(|h| h.did == local_did);
|
||||
let they_list_us =
|
||||
state.federated_peers.iter().any(|h| h.did == local_did);
|
||||
if !they_list_us && !local_onion.is_empty() {
|
||||
crate::federation::notify_join(
|
||||
&node.onion,
|
||||
|
||||
@@ -256,9 +256,7 @@ pub async fn spend_from_any(data_dir: &Path, amount_sats: u64) -> Result<(String
|
||||
Err(last_err
|
||||
.map(|e| anyhow::anyhow!("Fedimint spend failed across all federations: {e}"))
|
||||
.unwrap_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No joined Fedimint federation has {amount_sats} sats available"
|
||||
)
|
||||
anyhow::anyhow!("No joined Fedimint federation has {amount_sats} sats available")
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user