diff --git a/core/archipelago/src/api/rpc/openwrt.rs b/core/archipelago/src/api/rpc/openwrt.rs index 19a4e49d..f92f9cd6 100644 --- a/core/archipelago/src/api/rpc/openwrt.rs +++ b/core/archipelago/src/api/rpc/openwrt.rs @@ -270,6 +270,10 @@ impl RpcHandler { .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_string); + if let Some(address) = payout_address.as_deref() { + tollgate::config::validate_payout_address(address) + .context("invalid TollGate payout address")?; + } let config = TollGateConfig { ssid: "archipelago".to_string(), diff --git a/core/openwrt/src/tollgate/config.rs b/core/openwrt/src/tollgate/config.rs index 050771ef..e5938757 100644 --- a/core/openwrt/src/tollgate/config.rs +++ b/core/openwrt/src/tollgate/config.rs @@ -136,11 +136,44 @@ pub fn apply_payout_identity(router: &Router, payout_address: Option<&str>) -> R let Some(address) = payout_address else { return Ok(()); }; + validate_payout_address(address)?; let existing = router.run_ok("cat /etc/tollgate/identities.json 2>/dev/null || echo '{}'")?; - let mut doc: serde_json::Value = - serde_json::from_str(existing.trim()).unwrap_or_else(|_| serde_json::json!({})); + let mut doc = parse_identities(&existing)?; + merge_payout_identity(&mut doc, address)?; + + let json_str = serde_json::to_string_pretty(&doc).context("serialize identities.json")?; + router + .upload_file("/etc/tollgate/identities.json", json_str.as_bytes()) + .context("upload /etc/tollgate/identities.json")?; + Ok(()) +} + +fn parse_identities(existing: &str) -> Result { + serde_json::from_str(existing.trim()).context( + "parse existing /etc/tollgate/identities.json; refusing to overwrite malformed identity data", + ) +} + +/// Reject malformed values before provisioning changes anything on the +/// router. A payout typo otherwise remains dormant until the threshold is +/// reached, when the operator discovers that settlement cannot resolve. +pub fn validate_payout_address(address: &str) -> Result<()> { + let (name, domain) = address + .split_once('@') + .context("Lightning address must look like name@example.com")?; + if name.is_empty() + || domain.is_empty() + || domain.contains('@') + || address.chars().any(char::is_whitespace) + { + anyhow::bail!("Lightning address must look like name@example.com"); + } + Ok(()) +} + +fn merge_payout_identity(doc: &mut serde_json::Value, address: &str) -> Result<()> { let identities = doc .as_object_mut() .context("identities.json root is not a JSON object")? @@ -166,9 +199,70 @@ pub fn apply_payout_identity(router: &Router, payout_address: Option<&str>) -> R } } - let json_str = serde_json::to_string_pretty(&doc).context("serialize identities.json")?; - router - .upload_file("/etc/tollgate/identities.json", json_str.as_bytes()) - .context("upload /etc/tollgate/identities.json")?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::{merge_payout_identity, parse_identities, validate_payout_address}; + + #[test] + fn payout_merge_changes_only_owner_address() { + let mut doc = serde_json::json!({ + "config_version": "v0.0.1", + "owned_identities": [{ "name": "merchant", "privatekey": "keep-secret" }], + "public_identities": [ + { "name": "owner", "pubkey": "not currently used", "lightning_address": "old@example.com" }, + { "name": "upstream", "lightning_address": "keep@example.com" } + ] + }); + let before_owned = doc["owned_identities"].clone(); + let before_other = doc["public_identities"][1].clone(); + + merge_payout_identity(&mut doc, "operator@example.com").unwrap(); + + assert_eq!(doc["owned_identities"], before_owned); + assert_eq!(doc["public_identities"][1], before_other); + assert_eq!( + doc["public_identities"][0]["lightning_address"], + "operator@example.com" + ); + } + + #[test] + fn payout_merge_can_create_missing_owner() { + let mut doc = serde_json::json!({ "public_identities": [] }); + merge_payout_identity(&mut doc, "operator@example.com").unwrap(); + assert_eq!(doc["public_identities"][0]["name"], "owner"); + assert_eq!( + doc["public_identities"][0]["lightning_address"], + "operator@example.com" + ); + } + + #[test] + fn payout_address_validation_rejects_typographical_failures() { + assert!(validate_payout_address("operator@example.com").is_ok()); + for invalid in [ + "", + "operator", + "@example.com", + "operator@", + "a@b@c", + "a b@example.com", + ] { + assert!( + validate_payout_address(invalid).is_err(), + "accepted {invalid:?}" + ); + } + } + + #[test] + fn malformed_identity_data_is_never_replaced() { + let err = parse_identities("{ truncated").unwrap_err(); + assert!(err + .to_string() + .contains("refusing to overwrite malformed identity data")); + } +} diff --git a/core/openwrt/src/tollgate/wifi.rs b/core/openwrt/src/tollgate/wifi.rs index bd88e338..626bde6a 100644 --- a/core/openwrt/src/tollgate/wifi.rs +++ b/core/openwrt/src/tollgate/wifi.rs @@ -149,10 +149,18 @@ pub fn regate_upstream_default_aps(router: &Router) -> Result<()> { for section in sections.lines().map(str::trim).filter(|s| !s.is_empty()) { let network_key = format!("{}.network", section); let current = router.uci_get(&network_key).unwrap_or_default(); - if current == "lan" { + let ssid = router + .uci_get(&format!("{}.ssid", section)) + .unwrap_or_default(); + // A failed/changed upstream first-boot script can leave a stock + // default_radioN section in place. Moving that interface merely + // because it is on LAN can seize the router's existing management AP. + // Only the public APs the TollGate installer demonstrably rebranded + // belong on the paid network. + if should_regate_upstream_ap(¤t, &ssid) { info!( - "[{}] Re-gating upstream default AP {} (was network=lan) onto the tollgate network", - router.host, section + "[{}] Re-gating upstream default AP {} ({}) onto the tollgate network", + router.host, section, ssid ); router.uci_set(&network_key, "tollgate")?; } @@ -161,6 +169,10 @@ pub fn regate_upstream_default_aps(router: &Router) -> Result<()> { Ok(()) } +fn should_regate_upstream_ap(network: &str, ssid: &str) -> bool { + network.trim() == "lan" && ssid.trim().starts_with("TollGate-") +} + /// Return the first available wireless radio device name (e.g. "radio0"). fn detect_radio(router: &Router) -> Result { let out = @@ -169,3 +181,19 @@ fn detect_radio(router: &Router) -> Result { let radio = out.trim().split('.').nth(1).unwrap_or("radio0").to_string(); Ok(radio) } + +#[cfg(test)] +mod tests { + use super::should_regate_upstream_ap; + + #[test] + fn regates_only_confirmed_upstream_tollgate_aps() { + assert!(should_regate_upstream_ap("lan", "TollGate-3458")); + assert!(should_regate_upstream_ap(" lan\n", " TollGate-A1B2 ")); + + assert!(!should_regate_upstream_ap("lan", "OpenWrt")); + assert!(!should_regate_upstream_ap("lan", "Archipelago Admin")); + assert!(!should_regate_upstream_ap("tollgate", "TollGate-3458")); + assert!(!should_regate_upstream_ap("lan", "tollgate-3458")); + } +}