fix(openwrt): harden TollGate PR integration
This commit is contained in:
@@ -270,6 +270,10 @@ impl RpcHandler {
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(str::to_string);
|
.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 {
|
let config = TollGateConfig {
|
||||||
ssid: "archipelago".to_string(),
|
ssid: "archipelago".to_string(),
|
||||||
|
|||||||
@@ -136,11 +136,44 @@ pub fn apply_payout_identity(router: &Router, payout_address: Option<&str>) -> R
|
|||||||
let Some(address) = payout_address else {
|
let Some(address) = payout_address else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
validate_payout_address(address)?;
|
||||||
|
|
||||||
let existing = router.run_ok("cat /etc/tollgate/identities.json 2>/dev/null || echo '{}'")?;
|
let existing = router.run_ok("cat /etc/tollgate/identities.json 2>/dev/null || echo '{}'")?;
|
||||||
let mut doc: serde_json::Value =
|
let mut doc = parse_identities(&existing)?;
|
||||||
serde_json::from_str(existing.trim()).unwrap_or_else(|_| serde_json::json!({}));
|
|
||||||
|
|
||||||
|
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::Value> {
|
||||||
|
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
|
let identities = doc
|
||||||
.as_object_mut()
|
.as_object_mut()
|
||||||
.context("identities.json root is not a JSON object")?
|
.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(())
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()) {
|
for section in sections.lines().map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
let network_key = format!("{}.network", section);
|
let network_key = format!("{}.network", section);
|
||||||
let current = router.uci_get(&network_key).unwrap_or_default();
|
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!(
|
info!(
|
||||||
"[{}] Re-gating upstream default AP {} (was network=lan) onto the tollgate network",
|
"[{}] Re-gating upstream default AP {} ({}) onto the tollgate network",
|
||||||
router.host, section
|
router.host, section, ssid
|
||||||
);
|
);
|
||||||
router.uci_set(&network_key, "tollgate")?;
|
router.uci_set(&network_key, "tollgate")?;
|
||||||
}
|
}
|
||||||
@@ -161,6 +169,10 @@ pub fn regate_upstream_default_aps(router: &Router) -> Result<()> {
|
|||||||
Ok(())
|
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").
|
/// Return the first available wireless radio device name (e.g. "radio0").
|
||||||
fn detect_radio(router: &Router) -> Result<String> {
|
fn detect_radio(router: &Router) -> Result<String> {
|
||||||
let out =
|
let out =
|
||||||
@@ -169,3 +181,19 @@ fn detect_radio(router: &Router) -> Result<String> {
|
|||||||
let radio = out.trim().split('.').nth(1).unwrap_or("radio0").to_string();
|
let radio = out.trim().split('.').nth(1).unwrap_or("radio0").to_string();
|
||||||
Ok(radio)
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user