Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0651e8c615 | ||
|
|
7b69200244 | ||
|
|
2da8e2c698 | ||
|
|
cebbd10127 | ||
|
|
5d05529d11 | ||
|
|
72e480e9f7 | ||
|
|
eb2fc0f37b | ||
|
|
94b5374f66 | ||
|
|
9f65f1e7ae | ||
|
|
74d50719ac | ||
|
|
eb74bc3fe4 | ||
|
|
b13751e6d1 |
@@ -279,6 +279,7 @@ impl RpcHandler {
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.fips_timeout(std::time::Duration::from_secs(8))
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
@@ -364,6 +365,11 @@ impl RpcHandler {
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
// The Cloud page's hottest call: without a fast-fail cap a
|
||||
// cold FIPS path burned ~16.6s before Tor even started,
|
||||
// against the UI's 30s deadline — users saw errors, not
|
||||
// fallback.
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
@@ -1137,6 +1143,7 @@ impl RpcHandler {
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer for preview")?;
|
||||
|
||||
@@ -244,6 +244,8 @@ impl RpcHandler {
|
||||
// OpenWrt / TollGate
|
||||
"openwrt.scan" => self.handle_openwrt_scan(params).await,
|
||||
"openwrt.get-status" => self.handle_openwrt_get_status(params).await,
|
||||
"openwrt.forget" => self.handle_openwrt_forget().await,
|
||||
"openwrt.set-password" => self.handle_openwrt_set_password(params).await,
|
||||
"openwrt.provision-tollgate" => self.handle_openwrt_provision_tollgate(params).await,
|
||||
"openwrt.scan-wifi" => self.handle_openwrt_scan_wifi(params).await,
|
||||
"openwrt.configure-wan" => self.handle_openwrt_configure_wan(params).await,
|
||||
|
||||
@@ -865,7 +865,8 @@ impl RpcHandler {
|
||||
"/rpc/v1",
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::Peers)
|
||||
.timeout(std::time::Duration::from_secs(30));
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6));
|
||||
|
||||
match req.send_json(&body).await {
|
||||
Ok((resp, transport)) if resp.status().is_success() => {
|
||||
|
||||
@@ -820,6 +820,7 @@ impl RpcHandler {
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), &onion_bare, &path)
|
||||
.service(crate::settings::transport::PeerService::MeshFileSharing)
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.fips_timeout(std::time::Duration::from_secs(8))
|
||||
.send_get()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Fetch failed: {}", e))?;
|
||||
|
||||
@@ -165,6 +165,66 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Forget the saved router config — deletes `router_config.json` so the
|
||||
/// app requires a fresh login next time. Takes no params.
|
||||
pub(super) async fn handle_openwrt_forget(&self) -> Result<serde_json::Value> {
|
||||
net_router::forget_router_config(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// Set the router's SSH login password from the app — no manual SSH/
|
||||
/// console session on the router required. Works for a fresh flash
|
||||
/// (root has no password yet — connect with `current_password: ""`)
|
||||
/// or to rotate an existing one. On success the new credentials are
|
||||
/// persisted the same way a normal login does, so the app is fully
|
||||
/// connected afterward with no separate "Connect" step needed.
|
||||
///
|
||||
/// Params: `{ "host": "192.168.1.1", "ssh_user": "root",
|
||||
/// "current_password": "", "new_password": "..." }`
|
||||
pub(super) async fn handle_openwrt_set_password(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let p = params.unwrap_or_default();
|
||||
let host = p
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("host is required"))?
|
||||
.to_string();
|
||||
let ssh_user = p
|
||||
.get("ssh_user")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("root")
|
||||
.to_string();
|
||||
let current_password = p
|
||||
.get("current_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let new_password = p
|
||||
.get("new_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("new_password is required"))?
|
||||
.to_string();
|
||||
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, ¤t_password)?;
|
||||
router.verify_openwrt()?;
|
||||
router.set_password(&ssh_user, &new_password)?;
|
||||
|
||||
net_router::configure_router(
|
||||
&self.config.data_dir,
|
||||
net_router::RouterType::OpenWrt,
|
||||
&host,
|
||||
None,
|
||||
Some(&ssh_user),
|
||||
Some(&new_password),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({ "ok": true, "host": host }))
|
||||
}
|
||||
|
||||
/// Provision TollGate on an OpenWrt router and create the "archipelago" SSID.
|
||||
///
|
||||
/// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "",
|
||||
|
||||
@@ -498,7 +498,8 @@ pub(super) async fn notify_federation_peers_address_change(
|
||||
"/rpc/v1",
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::Peers)
|
||||
.timeout(std::time::Duration::from_secs(30));
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6));
|
||||
match req.send_json(&payload).await {
|
||||
Ok((_, transport)) => {
|
||||
info!(peer_did = %peer.did, transport = %transport, "Notified peer of address change")
|
||||
|
||||
@@ -290,12 +290,12 @@ pub struct ApplyResult {
|
||||
|
||||
/// FIPS UDP transport port (matches `transports.udp.bind_addr` in the generated
|
||||
/// `fips.yaml`). Direct peer links dial this, NOT the HTTP/LAN messaging port.
|
||||
const FIPS_UDP_PORT: u16 = 8668;
|
||||
const FIPS_UDP_PORT: u16 = crate::fips::PUBLISHED_UDP_PORT;
|
||||
|
||||
/// Build transient seed-anchor entries that dial LAN-discovered federation peers
|
||||
/// directly over their FIPS UDP transport. For each peer the registry knows both
|
||||
/// a LAN socket address AND a FIPS npub for, point a `udp` anchor at
|
||||
/// `<lan-ip>:8668`. This lets co-located federation nodes form a DIRECT FIPS link
|
||||
/// `<lan-ip>:<FIPS_UDP_PORT>`. This lets co-located federation nodes form a DIRECT FIPS link
|
||||
/// instead of depending on the global anchor's spanning tree to route between
|
||||
/// them (the cause of every dial falling back to Tor when the anchor link flaps).
|
||||
///
|
||||
@@ -448,4 +448,39 @@ mod tests {
|
||||
assert_eq!(a.transport, "udp");
|
||||
assert_eq!(a.label, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_fips_anchor_port_matches_daemon_bind() {
|
||||
// Drift guard: direct LAN anchors must dial the UDP port the
|
||||
// generated fips.yaml actually binds. These were out of sync for
|
||||
// months (anchors dialed 8668, the daemon bound 2121), making the
|
||||
// whole direct-peering feature dial a dead port.
|
||||
let yaml = crate::fips::config::render_config_yaml();
|
||||
assert!(
|
||||
yaml.contains(&format!("0.0.0.0:{FIPS_UDP_PORT}")),
|
||||
"lan_fips_anchors dials :{FIPS_UDP_PORT} but the daemon config binds elsewhere"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_fips_anchors_builds_direct_entry() {
|
||||
let peer = crate::transport::PeerRecord {
|
||||
did: "did:key:zpeer".to_string(),
|
||||
lan_address: Some("192.168.63.198:5678".to_string()),
|
||||
fips_npub: Some("npub1peer".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let out = lan_fips_anchors(&[peer]);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].address, format!("192.168.63.198:{FIPS_UDP_PORT}"));
|
||||
assert_eq!(out[0].transport, "udp");
|
||||
|
||||
// Peers missing either the LAN address or the npub produce nothing.
|
||||
let no_npub = crate::transport::PeerRecord {
|
||||
did: "did:key:zother".to_string(),
|
||||
lan_address: Some("192.168.63.199:5678".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(lan_fips_anchors(&[no_npub]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,11 +240,21 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
|
||||
// framework-pt). Ship the allowance as a fips.d drop-in on every
|
||||
// install/upgrade so no node ever regresses to a UI-less mesh.
|
||||
sudo_install_dir("/etc/fips/fips.d").await?;
|
||||
let dropin = "# Written by archipelago on every daemon config install.\n\
|
||||
# Allows the web UI + peer API through the fips0\n\
|
||||
# default-deny inbound baseline (fips.nft).\n\
|
||||
tcp dport 80 accept\n\
|
||||
tcp dport 8443 accept\n";
|
||||
// PEER_PORT (5679) carries ALL federation sync, cloud browse/download,
|
||||
// mesh envelopes, DWN and invoices. It was missing from this allowlist
|
||||
// while the comment claimed "web UI + peer API" — so every hardened
|
||||
// node silently dropped peers' FIPS dials at the firewall and the whole
|
||||
// fleet fell back to Tor (root-caused live 2026-07-27: 28k drops on
|
||||
// .198's counter; :5679 answered in 0.35s once the rule was inserted).
|
||||
let dropin = format!(
|
||||
"# Written by archipelago on every daemon config install.\n\
|
||||
# Allows the web UI + peer API through the fips0\n\
|
||||
# default-deny inbound baseline (fips.nft).\n\
|
||||
tcp dport 80 accept\n\
|
||||
tcp dport 8443 accept\n\
|
||||
tcp dport {peer_port} accept\n",
|
||||
peer_port = crate::fips::dial::PEER_PORT
|
||||
);
|
||||
let nft_stage = std::env::temp_dir().join(format!("fips-webui-{}.nft", std::process::id()));
|
||||
tokio::fs::write(&nft_stage, dropin)
|
||||
.await
|
||||
|
||||
@@ -447,14 +447,26 @@ impl<'a> PeerRequest<'a> {
|
||||
}
|
||||
};
|
||||
let url = format!("{}{}", base, self.path);
|
||||
let c = client_with_timeout(self.fips_attempt_timeout());
|
||||
let budget = self.fips_attempt_timeout();
|
||||
// With an explicit fast-fail cap, halve the per-attempt client
|
||||
// timeout so the one-retry path in send_with_retry fits inside the
|
||||
// budget instead of silently doubling it ("fips_timeout(6s)" used
|
||||
// to really mean ~12.6s). Without one (long streaming downloads),
|
||||
// keep the full budget per attempt — the client timeout also
|
||||
// governs body streaming and must not truncate a real transfer.
|
||||
let per_attempt = if self.fips_timeout.is_some() {
|
||||
budget / 2
|
||||
} else {
|
||||
budget
|
||||
};
|
||||
let c = client_with_timeout(per_attempt);
|
||||
let mut rb = c.post(&url).json(body);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
}
|
||||
match send_with_retry(rb).await {
|
||||
Ok(r) => Ok(Some(r)),
|
||||
Err(e) => {
|
||||
match tokio::time::timeout(budget, send_with_retry(rb)).await {
|
||||
Ok(Ok(r)) => Ok(Some(r)),
|
||||
Ok(Err(e)) => {
|
||||
tracing::debug!(
|
||||
"FIPS POST {} failed after retry: {}, falling back to Tor",
|
||||
url,
|
||||
@@ -462,6 +474,14 @@ impl<'a> PeerRequest<'a> {
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::debug!(
|
||||
"FIPS POST {} exceeded attempt budget {:?}, falling back to Tor",
|
||||
url,
|
||||
budget
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,14 +500,22 @@ impl<'a> PeerRequest<'a> {
|
||||
}
|
||||
};
|
||||
let url = format!("{}{}", base, self.path);
|
||||
let c = client_with_timeout(self.fips_attempt_timeout());
|
||||
let budget = self.fips_attempt_timeout();
|
||||
// Same budget discipline as the POST path: halve per attempt only
|
||||
// under an explicit fast-fail cap; hard-cap the retry sequence.
|
||||
let per_attempt = if self.fips_timeout.is_some() {
|
||||
budget / 2
|
||||
} else {
|
||||
budget
|
||||
};
|
||||
let c = client_with_timeout(per_attempt);
|
||||
let mut rb = c.get(&url);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
}
|
||||
match send_with_retry(rb).await {
|
||||
Ok(r) => Ok(Some(r)),
|
||||
Err(e) => {
|
||||
match tokio::time::timeout(budget, send_with_retry(rb)).await {
|
||||
Ok(Ok(r)) => Ok(Some(r)),
|
||||
Ok(Err(e)) => {
|
||||
tracing::debug!(
|
||||
"FIPS GET {} failed after retry: {}, falling back to Tor",
|
||||
url,
|
||||
@@ -495,6 +523,14 @@ impl<'a> PeerRequest<'a> {
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::debug!(
|
||||
"FIPS GET {} exceeded attempt budget {:?}, falling back to Tor",
|
||||
url,
|
||||
budget
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -186,6 +186,7 @@ async fn sync_single_peer(
|
||||
let (health_resp, _) = PeerRequest::new(fips_npub, onion, "/dwn/health")
|
||||
.service(crate::settings::transport::PeerService::Federation)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
.context("Peer DWN unreachable")?;
|
||||
@@ -211,6 +212,7 @@ async fn sync_single_peer(
|
||||
let (pull_res, _) = PeerRequest::new(fips_npub, onion, "/dwn")
|
||||
.service(crate::settings::transport::PeerService::Federation)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_json(&pull_body)
|
||||
.await
|
||||
.context("Failed to query peer DWN")?;
|
||||
@@ -269,6 +271,7 @@ async fn sync_single_peer(
|
||||
match PeerRequest::new(fips_npub, onion, "/dwn")
|
||||
.service(crate::settings::transport::PeerService::Federation)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_json(&push_body)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -366,6 +366,17 @@ pub async fn save_router_config(data_dir: &Path, config: &RouterConfig) -> Resul
|
||||
.context("Writing router config")
|
||||
}
|
||||
|
||||
/// Remove the saved router config (credentials + address) so the app forgets
|
||||
/// this router entirely and the next call requires a fresh login.
|
||||
pub async fn forget_router_config(data_dir: &Path) -> Result<()> {
|
||||
let path = data_dir.join(ROUTER_CONFIG_FILE);
|
||||
match fs::remove_file(&path).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e).context("Removing router config"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that an IP string is a private/LAN address (not public, not localhost).
|
||||
fn is_valid_private_ip(ip_str: &str) -> bool {
|
||||
let ip: std::net::IpAddr = match ip_str.parse() {
|
||||
|
||||
@@ -374,6 +374,7 @@ pub async fn send_to_peer(
|
||||
crate::fips::dial::PeerRequest::new(fips_npub, onion, "/archipelago/node-message")
|
||||
.service(crate::settings::transport::PeerService::Messaging)
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.fips_timeout(std::time::Duration::from_secs(8))
|
||||
.send_json(&body)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -410,6 +411,7 @@ pub async fn check_peer_reachable(onion: &str, fips_npub: Option<&str>) -> Resul
|
||||
// circuit that hasn't answered /health in 12s is "offline" for UI
|
||||
// purposes; the old 30s made the Connected Nodes probes crawl.
|
||||
.timeout(std::time::Duration::from_secs(12))
|
||||
.fips_timeout(std::time::Duration::from_secs(4))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -433,8 +433,18 @@ impl Server {
|
||||
),
|
||||
));
|
||||
|
||||
// LAN transport (mDNS discovery)
|
||||
let mut lan = crate::transport::lan::LanTransport::new(&did, &pubkey_hex, 5678);
|
||||
// LAN transport (mDNS discovery). Advertise our FIPS npub in
|
||||
// the TXT record so co-located peers can form a direct FIPS
|
||||
// link (see `lan_fips_anchors`).
|
||||
let local_fips_npub = crate::identity::fips_npub(&data_dir.join("identity"))
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
let mut lan = crate::transport::lan::LanTransport::new(
|
||||
&did,
|
||||
&pubkey_hex,
|
||||
5678,
|
||||
local_fips_npub,
|
||||
);
|
||||
match lan.start(registry.clone()) {
|
||||
Ok(()) => info!("📡 LAN transport (mDNS) started"),
|
||||
Err(e) => debug!("LAN transport init (non-fatal): {}", e),
|
||||
@@ -753,12 +763,25 @@ impl Server {
|
||||
// (often flaky) global anchor's spanning tree to route to each
|
||||
// other. For every peer the registry knows both a LAN address
|
||||
// AND a FIPS npub for, dial it on its FIPS UDP transport port
|
||||
// (8668) at its LAN IP. This is FIPS's own transport over the
|
||||
// at its LAN IP. This is FIPS's own transport over the
|
||||
// LAN — NOT Tailscale, NOT the HTTP/LAN messaging port. Pure
|
||||
// FIPS. `fipsctl connect` is idempotent, so re-applying every
|
||||
// tick just keeps the direct link warm; unknown/remote peers
|
||||
// (no LAN address) are left to the anchor as before.
|
||||
if let Some(reg) = fips_peer_registry.as_ref() {
|
||||
// Hydrate FIPS npubs into the registry from federation
|
||||
// storage (did-keyed). Peers discovered before the mDNS
|
||||
// TXT `fips` key existed — or running builds that don't
|
||||
// advertise it yet — would otherwise never satisfy the
|
||||
// `fips_npub` requirement in lan_fips_anchors(), leaving
|
||||
// direct LAN peering a no-op.
|
||||
if let Ok(nodes) = crate::federation::load_nodes(&data_dir).await {
|
||||
for n in &nodes {
|
||||
if let Some(npub) = n.fips_npub.as_deref() {
|
||||
reg.set_fips_npub(&n.did, npub).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let direct = crate::fips::anchors::lan_fips_anchors(®.all_peers().await);
|
||||
if !direct.is_empty() {
|
||||
let _ = crate::fips::anchors::apply(&direct).await;
|
||||
@@ -1241,6 +1264,12 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
|
||||
)
|
||||
// Prefix-matched content endpoints (peer file browse + fetch)
|
||||
|| path.starts_with("/content/")
|
||||
// Mesh file sharing — blob fetch by CID, signature-gated in the
|
||||
// handler. Absent from this list it 404'd over FIPS and the feature
|
||||
// was 100% Tor by construction.
|
||||
|| path.starts_with("/blob/")
|
||||
// DWN sync — /dwn/health is step 1 of every sync; same story.
|
||||
|| path.starts_with("/dwn/")
|
||||
}
|
||||
|
||||
async fn accept_loop(
|
||||
@@ -1944,10 +1973,17 @@ mod merge_tests {
|
||||
);
|
||||
assert!(is_peer_allowed_path("/rpc/v1"));
|
||||
assert!(is_peer_allowed_path("/health"));
|
||||
// Mesh blob fetch + DWN sync — both were missing from the allowlist,
|
||||
// which made them deterministically 404 over FIPS and therefore
|
||||
// 100% Tor by construction.
|
||||
assert!(is_peer_allowed_path("/blob/abc123"), "blob fetch by CID");
|
||||
assert!(is_peer_allowed_path("/dwn/health"), "DWN sync step 1");
|
||||
// Not on the allow-list → rejected (no broad surface over the mesh).
|
||||
assert!(!is_peer_allowed_path("/contention"), "must not prefix-leak");
|
||||
assert!(!is_peer_allowed_path("/"));
|
||||
assert!(!is_peer_allowed_path("/rpc/v2"));
|
||||
assert!(!is_peer_allowed_path("/blobber"), "must not prefix-leak");
|
||||
assert!(!is_peer_allowed_path("/dwnx"), "must not prefix-leak");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -24,17 +24,27 @@ pub struct LanTransport {
|
||||
our_did: String,
|
||||
our_pubkey_hex: String,
|
||||
our_port: u16,
|
||||
/// This node's FIPS npub, advertised in the mDNS TXT record so
|
||||
/// co-located peers can form a direct FIPS link (`lan_fips_anchors`)
|
||||
/// without waiting for federation storage to sync.
|
||||
our_fips_npub: Option<String>,
|
||||
daemon: Option<ServiceDaemon>,
|
||||
available: AtomicBool,
|
||||
}
|
||||
|
||||
impl LanTransport {
|
||||
/// Create a new LAN transport. Does not start discovery yet.
|
||||
pub fn new(our_did: &str, our_pubkey_hex: &str, port: u16) -> Self {
|
||||
pub fn new(
|
||||
our_did: &str,
|
||||
our_pubkey_hex: &str,
|
||||
port: u16,
|
||||
our_fips_npub: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
our_did: our_did.to_string(),
|
||||
our_pubkey_hex: our_pubkey_hex.to_string(),
|
||||
our_port: port,
|
||||
our_fips_npub,
|
||||
daemon: None,
|
||||
available: AtomicBool::new(false),
|
||||
}
|
||||
@@ -47,11 +57,14 @@ impl LanTransport {
|
||||
|
||||
// Advertise our service
|
||||
let hostname = format!("archy-{}.local.", &self.our_pubkey_hex[..8]);
|
||||
let properties = vec![
|
||||
let mut properties = vec![
|
||||
("did".to_string(), self.our_did.clone()),
|
||||
("pubkey".to_string(), self.our_pubkey_hex.clone()),
|
||||
("version".to_string(), "0.1.0".to_string()),
|
||||
];
|
||||
if let Some(npub) = &self.our_fips_npub {
|
||||
properties.push(("fips".to_string(), npub.clone()));
|
||||
}
|
||||
|
||||
let service_info = ServiceInfo::new(
|
||||
SERVICE_TYPE,
|
||||
@@ -93,6 +106,11 @@ impl LanTransport {
|
||||
.map(|v| v.val_str().to_string());
|
||||
let addresses = info.get_addresses();
|
||||
|
||||
let fips_npub = info
|
||||
.get_properties()
|
||||
.get("fips")
|
||||
.map(|v| v.val_str().to_string());
|
||||
|
||||
if let (Some(did), Some(pubkey)) = (did, pubkey) {
|
||||
if let Some(scoped_ip) = addresses.iter().next() {
|
||||
let ip: std::net::IpAddr = match scoped_ip.to_string().parse() {
|
||||
@@ -106,6 +124,9 @@ impl LanTransport {
|
||||
.await;
|
||||
registry_clone.set_lan_address(&did, socket_addr).await;
|
||||
registry_clone.set_name(&did, info.get_fullname()).await;
|
||||
if let Some(npub) = fips_npub.as_deref() {
|
||||
registry_clone.set_fips_npub(&did, npub).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ pub enum PeerSource {
|
||||
}
|
||||
|
||||
/// Unified peer record with per-transport capabilities.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PeerRecord {
|
||||
pub did: String,
|
||||
pub pubkey_hex: String,
|
||||
|
||||
@@ -82,6 +82,22 @@ impl Router {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Set `user`'s login password via BusyBox `passwd`, non-interactively.
|
||||
///
|
||||
/// BusyBox's passwd applet reads the new password twice from stdin even
|
||||
/// without a tty (unlike shadow-utils' passwd, which requires one), so
|
||||
/// piping two lines in works. Root can always set its own (or another
|
||||
/// user's) password without supplying the old one first. This is what
|
||||
/// lets a fresh-flash router (root has no password yet) be fully set up
|
||||
/// from the app — no manual SSH/console session required.
|
||||
pub fn set_password(&self, user: &str, new_password: &str) -> Result<()> {
|
||||
let q = crate::uci::shell_quote(new_password);
|
||||
let uq = crate::uci::shell_quote(user);
|
||||
let cmd = format!("printf '%s\\n%s\\n' {q} {q} | passwd {uq} 2>&1");
|
||||
self.run_ok(&cmd)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify the remote device is actually running OpenWrt.
|
||||
pub fn verify_openwrt(&self) -> Result<String> {
|
||||
let release = self
|
||||
|
||||
@@ -60,6 +60,6 @@ impl Router {
|
||||
}
|
||||
|
||||
/// Wrap a value in single quotes, escaping any embedded single quotes.
|
||||
fn shell_quote(s: &str) -> String {
|
||||
pub(crate) fn shell_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
# FIPS near-100% uptime + optimistic UI state — implementation plan
|
||||
|
||||
**Date:** 2026-07-27. **Status:** researched + root-caused live on the fleet; ready to
|
||||
implement for the next release. Two workstreams: (A) make node↔node FIPS transport
|
||||
succeed whenever a FIPS path physically exists, (B) stop the UI reloading everything
|
||||
on every navigation (optimistic/cached cards, stale-while-revalidate) while keeping
|
||||
data fresh.
|
||||
|
||||
**Honesty note on "100%":** if a node's network blackholes every anchor (the .116
|
||||
WiFi case, `docs/HANDOFF-2026-07-20-fips-peer-files.md:117-133`), Tor fallback is
|
||||
*correct*. The achievable target is: **FIPS wins whenever a FIPS path exists, and
|
||||
fallback frequency is measured in-product so regressions are visible.** Today several
|
||||
paths are 0% FIPS *by construction* regardless of network health — that's the bug.
|
||||
|
||||
---
|
||||
|
||||
## Part A — why Cloud/FIPS "commonly falls back to Tor": ranked root causes
|
||||
|
||||
All verified live on 2026-07-27 (.116 local, .198, .228, Framework PT, x250s) plus a
|
||||
full code audit of `core/archipelago/src/{fips,transport,federation,server.rs}`.
|
||||
|
||||
### RC0 — 🔥 The hardening firewall drops the peer-API port on every hardened node (PROVEN)
|
||||
|
||||
The fips0 default-deny baseline (`/etc/fips/fips.nft`) is opened by archipelago's
|
||||
drop-in `80-web-ui.nft` (`fips/config.rs:236-255`) for **80 + 8443 + app ports only**.
|
||||
The peer-API listener — which carries *all* federation sync, cloud browse/download,
|
||||
mesh envelopes, DWN, invoices — is **`PEER_PORT = 5679`** (`fips/dial.rs:35`).
|
||||
**5679 is not in the allowlist.** The drop-in's own comment claims "web UI + peer
|
||||
API" but the peer API port was never added.
|
||||
|
||||
Live proof (2026-07-27):
|
||||
- .116 nft chain: 5,965 dropped packets; .198: **28,670 dropped packets** — that's
|
||||
peers' FIPS dials dying at the firewall.
|
||||
- .198 → .116 `GET :5679/health`: **timeout (6s)** before; **HTTP 200 in 0.35s**
|
||||
after `nft insert rule inet fips inbound iifname fips0 tcp dport 5679 accept`.
|
||||
Same result in reverse direction (200 in 0.64s).
|
||||
- Explains the exact fleet split in `federation/nodes.json`: hardened-baseline nodes
|
||||
(Framework PT, .198, .228, x250-dev, x250-mad2) = `last_transport: tor`;
|
||||
non-hardened nodes (Austin Sapien, X250-Beta, X250-PA) answer :5679 (404 from the
|
||||
path allowlist = listener reachable) = `last_transport: fips`.
|
||||
- Every dial to a hardened peer pays the 8s FIPS connect timeout
|
||||
(`dial.rs:114`) ×2 (retry, `dial.rs:128-140`) → then Tor. That's the "Cloud takes
|
||||
forever / shows Tor" experience.
|
||||
|
||||
**Fix (one line + reload):** add `tcp dport 5679 accept` to the drop-in in
|
||||
`fips/config.rs` (use a constant shared with `dial.rs::PEER_PORT`, not a literal).
|
||||
The drop-in reinstalls on every daemon config install, so it heals fleet-wide on OTA.
|
||||
⚠️ Transient manual rules were inserted on .116 and .198 during diagnosis (2026-07-27)
|
||||
— they vanish on the next `nft -f /etc/fips/fips.nft` reload or reboot; the code fix
|
||||
makes them permanent.
|
||||
|
||||
### RC1 — .228 (Shorty's) runs fips 0.3.0-dev; the 0.4.1 fleet can't reach it
|
||||
|
||||
.228's daemon: `0.3.0-dev (rev 34e00b9f6e)`, both anchor links "connected", but its
|
||||
ULA is 100% unreachable from 0.4.1 nodes (ping loss 100%). FIPS wire format is not
|
||||
stable across revs (`docs/HANDOFF-2026-07-23-companion-apk-deploy.md:78`). Everything
|
||||
to/from .228 rides Tor no matter what else we fix.
|
||||
|
||||
**Fix:** fleet fips-version audit + upgrade to v0.4.1 everywhere (in-product updater
|
||||
exists: `fips/update.rs`; .deb path per `reference_vps2_fips_anchor`). Add a version
|
||||
check to `fips.status` and surface a "peer daemon outdated" warning.
|
||||
|
||||
### RC2 — Direct LAN/endpoint peering is dead code + wrong port + stale seed anchors
|
||||
|
||||
Without direct links, all peer traffic hairpins through the vps2 anchor spanning
|
||||
tree (observed: .116→.198 cold RTT 1.5–3.5s on the same LAN; also the wedged-anchor
|
||||
latency-rot incident, `HANDOFF-2026-07-23:141-160`).
|
||||
|
||||
- **G1 — `lan_fips_anchors()` has never run.** It needs `PeerRecord.fips_npub`, but
|
||||
`PeerRegistry::set_fips_npub` (`transport/mod.rs:302`) has **zero callers** — mDNS
|
||||
TXT records only carry `did`/`pubkey`/`version` (`transport/lan.rs:50-54`). So the
|
||||
"co-located peers form a direct link" feature (`anchors.rs:294-305`,
|
||||
`server.rs:761-766`) is a fleet-wide no-op.
|
||||
- **G2 — wrong UDP port.** `anchors.rs:293` dials `8668`, but the generated
|
||||
fips.yaml binds UDP **2121** (`fips/config.rs:187`, `fips/mod.rs:130`). Even if G1
|
||||
ran, it would dial a dead port. `.116`'s live `seed-anchors.json` still carries
|
||||
`.198@192.168.1.198:8668` — **stale IP (LAN renumbered to 192.168.63.x) AND dead
|
||||
port**; both manual entries are useless today.
|
||||
- No Tailscale/alternate endpoint fallback when LAN is unreachable (the .116↔.198
|
||||
fix of 2026-07-20 was hand-applied per-node config, never productized).
|
||||
|
||||
**Fix:** (a) `FIPS_UDP_PORT` → `crate::fips::PUBLISHED_UDP_PORT` + drift-guard test;
|
||||
(b) hydrate `fips_npub` into the registry from federation storage (did-keyed join) so
|
||||
`lan_fips_anchors` goes live with no wire change; (c) advertise the npub in the mDNS
|
||||
TXT + `set_fips_npub` on resolve as the proper fix; (d) teach the LAN-anchor tick to
|
||||
also try a peer's Tailscale/last-known-good endpoint when LAN fails (reviewed change
|
||||
— this area got handoffs wrong twice, per memory).
|
||||
|
||||
### RC3 — No fast-fail on the hottest call sites; retry silently doubles every budget
|
||||
|
||||
- `content.browse-peer` — **the Cloud page** — has NO `fips_timeout`
|
||||
(`api/rpc/content.rs:363-366`): a cold FIPS path burns up to ~16.6s (8s connect +
|
||||
600ms + 8s retry) before Tor even starts, against a UI deadline of 30s
|
||||
(`Cloud.vue:720`) — and the frontend then retries ×3. Users see errors, not
|
||||
fallback. 12 call sites total lack `fips_timeout` (browse/download/preview-peer,
|
||||
`/blob`, DWN, node_message, rotation notifies).
|
||||
- `dial.rs:128-140` runs 2 full-budget attempts, so `fips_timeout(6s)` really means
|
||||
~12.6s everywhere.
|
||||
|
||||
**Fix:** wrap `send_with_retry` in a single `tokio::time::timeout(fips_attempt_timeout())`
|
||||
(call sites `dial.rs:455`, `dial.rs:488`; halve per-attempt client timeout), then add
|
||||
`.fips_timeout(...)`: `content.rs:366` (6s), `content.rs:281` (8s), `content.rs:1139`
|
||||
(6s), `typed_messages.rs:822` (8s), `dwn_sync.rs:188/213/272` (6s),
|
||||
`node_message.rs:376` (8s), `node_message.rs:412` (4s), `tor/mod.rs:501` (6s),
|
||||
`federation/handlers.rs:869` (6s). **Skip the three 900s streaming downloads**
|
||||
(`content.rs:552/870/1061`, `proxy.rs:236`) — `dial.rs:311-319` documents why; the
|
||||
retry-budget wrap covers their connect phase.
|
||||
|
||||
### RC4 — Two features are 100% Tor by construction (allowlist 404)
|
||||
|
||||
The peer listener path allowlist (`server.rs:1219-1239`) omits `/blob/<cid>` (mesh
|
||||
file sharing, `typed_messages.rs:813-822`) and `/dwn/health` (step 1 of DWN sync,
|
||||
`dwn_sync.rs:186`) → deterministic 404 over FIPS (`dial.rs:44-46` treats 404 as
|
||||
fall-back) → deterministic Tor, after paying the full FIPS cost. Both endpoints are
|
||||
already cryptographically gated, so they meet the allowlist's stated criterion.
|
||||
|
||||
**Fix:** add `|| path.starts_with("/blob/") || path.starts_with("/dwn/")`; extend the
|
||||
existing test block at `server.rs:1935-1945` (assert `/blob/abc` + `/dwn/health`
|
||||
allowed, `/blobber` + `/dwnx` denied).
|
||||
|
||||
### RC5 — Inbound listener can't heal; anchor flap = 5-minute Tor window; probe overhead
|
||||
|
||||
- `peer_late_bind_loop` returns after first successful bind (`server.rs:1203`) and
|
||||
`accept_loop` `continue`s on errors forever (`server.rs:1249-1258`): a fips0
|
||||
teardown/re-key leaves the node inbound-dead until process restart → **every peer**
|
||||
falls back to Tor against it.
|
||||
- Nothing reacts to anchor-link drops: anchors re-apply only on the 300s tick
|
||||
(`server.rs:731`); worst-case 5min Tor-only after a flap (the historic "link dead
|
||||
timeout 30s" flapping made this chronic).
|
||||
- `is_service_active()` spawns up to 2 `systemctl` per FIPS attempt *and* per peer
|
||||
per 25s warm tick (`dial.rs:284-294`); `warm_path` skips peers without
|
||||
`fips_npub` in federation storage (`fips/mod.rs:88-95`); `anchors::apply` is
|
||||
serial with unbounded subprocess waits (`anchors.rs:234-283`).
|
||||
|
||||
**Fix:** rebindable listener; a ~25s connectivity watcher (reuse
|
||||
`service::peer_connectivity_summary`, `fips/service.rs:178-207`) that re-applies
|
||||
anchors immediately on a connected→disconnected edge with bounded backoff; 10s TTL
|
||||
cache for `is_service_active` (mirror `transport/fips.rs:24-107`); warm the union of
|
||||
federation+registry peers; make `apply()` concurrent with per-connect timeouts.
|
||||
|
||||
### RC6 — Zero observability: fallbacks are invisible, so "uptime" is unfalsifiable
|
||||
|
||||
Fallbacks log at `debug!` only (`dial.rs:458,491`); no counters; `last_transport` is
|
||||
written by only 7 of ~20 call sites and **never read** to influence anything
|
||||
(`storage.rs:120-147`). The parallel `TransportRouter` system can't even see FIPS
|
||||
(`FipsTransport` is never constructed — `server.rs:422-442` registers Tor/Mesh/LAN
|
||||
only).
|
||||
|
||||
**Fix:** per-reason fallback counters (F1 no-npub / F2 service-inactive / F3
|
||||
DNS-fail / F4 connect-fail / F5 404 / F6 5xx) surfaced in `fips.status` + `info!`
|
||||
logs with a `reason` field; call `record_peer_transport` from all peer-dial sites;
|
||||
UI: per-peer transport badge on Cloud (the response already carries `transport` —
|
||||
`content.rs:392-400` — Cloud.vue currently throws it away at `:716-721`).
|
||||
|
||||
---
|
||||
|
||||
## Part A — execution phases
|
||||
|
||||
### Phase A0 — fleet triage (no release needed; do first, validates everything)
|
||||
1. Fleet audit: `fipsctl --version` + `nft list table inet fips` + `ss -tlnp | grep 5679`
|
||||
on every node (roster: `reference_test_deploy_roster`).
|
||||
2. Transient `nft insert rule inet fips inbound iifname fips0 tcp dport 5679 accept`
|
||||
on hardened nodes (already done on .116 + .198, 2026-07-27) — instant fleet-wide
|
||||
FIPS recovery while the code fix rides the OTA.
|
||||
3. Upgrade .228 (and any other 0.3.x) fips daemon to v0.4.1.
|
||||
4. Regenerate/clean stale `seed-anchors.json` on .116 (dead 192.168.1.x + :8668 entries).
|
||||
5. Baseline measurement: for each node pair, `content.browse-peer` time + transport.
|
||||
|
||||
### Phase A1 — P0 code (one commit, mechanical, offline-testable)
|
||||
1. **nft drop-in: open 5679** — `fips/config.rs` (share the constant with
|
||||
`dial.rs::PEER_PORT`). ← RC0
|
||||
2. **Allowlist `/blob/`, `/dwn/`** — `server.rs:1219-1239` + tests. ← RC4
|
||||
3. **`FIPS_UDP_PORT` = `PUBLISHED_UDP_PORT` (2121)** — `anchors.rs:293` + drift-guard
|
||||
test against `render_config_yaml()`. ← RC2-G2
|
||||
4. **Un-deaden `lan_fips_anchors`** — hydrate `fips_npub` from federation storage in
|
||||
`server.rs:761-766`; then mDNS TXT `fips` key + `set_fips_npub`
|
||||
(`transport/lan.rs:50-54`, `lan.rs:96-108`, `LanTransport::new` 4th arg via
|
||||
`crate::identity::fips_npub(&data_dir.join("identity"))`). ← RC2-G1
|
||||
5. **Retry-budget wrap + `fips_timeout` on 12 call sites** (list in RC3). ← RC3
|
||||
Verify: `cd core && cargo test -p archipelago` — watch `test_rendered_yaml_exact_snapshot`
|
||||
(`config.rs:419`) + `test_render_is_deterministic` (`config.rs:476`); item 3 must
|
||||
not change rendered output.
|
||||
|
||||
### Phase A2 — telemetry BEFORE tuning (second commit)
|
||||
6. Fallback counters by reason + `fips.status` exposure + `info!` reason logs;
|
||||
`record_peer_transport` from all sites. ← RC6 (gives the baseline that makes A3
|
||||
measurable and "100%" falsifiable)
|
||||
|
||||
### Phase A3 — resilience (third commit, measured against A2 baseline)
|
||||
7. `is_service_active` 10s TTL cache; warm-path union + `warm_path_unchecked`.
|
||||
8. Link-state watcher → immediate anchor re-apply on drop (replaces waiting for the
|
||||
300s tick); concurrent `apply()` with subprocess timeouts.
|
||||
9. Rebindable peer listener (`server.rs:1203`, `1249-1258`).
|
||||
10. (Reviewed, separate PR) endpoint-fallback for direct peering: LAN → Tailscale →
|
||||
last-known-good, npub-keyed. Mesh-routing area — needs careful review per memory.
|
||||
|
||||
### Phase A4 — verification gate (on nodes, before tag)
|
||||
- On .116/.198/framework-pt/.228: `content.browse-peer` to every peer must return
|
||||
`transport: "fips"` with sub-second latency (LAN pairs) / <3s (WAN), 20/20 calls.
|
||||
- Kill the fips daemon on one node → calls fall back to Tor gracefully within the
|
||||
fast-fail budget (<8s), UI shows partial results, no errors.
|
||||
- Restart daemon → FIPS recovers within one watcher tick (~25s), verified in
|
||||
`fips.status` counters.
|
||||
- Flap the anchor link (drop vps2 route) → direct LAN pairs keep FIPS via their
|
||||
direct link (G1 fix proof).
|
||||
- Add these as `tests/multinode/` cases per `docs/multinode-testing-plan.md`; also
|
||||
fix the known `node_rpc()` missing `--max-time` (tracker item).
|
||||
|
||||
---
|
||||
|
||||
## Part B — optimistic loading + state management (frontend)
|
||||
|
||||
Full audit: Pinia exists but pages fetch-on-mount with `loading=true` spinners;
|
||||
`Dashboard.vue:89` keys the router-view by `route.path`, so **every navigation
|
||||
unmounts and refetches everything**; no KeepAlive/onActivated anywhere; no dedup,
|
||||
no abort, no SWR layer. Four hand-rolled cache implementations already exist and
|
||||
prove the pattern (`useFleetData.ts:198-231` sessionStorage hydrate;
|
||||
`homeStatus.ts` sticky-ready loadState; `Home.vue:591-621` wallet localStorage
|
||||
snapshot; `curatedApps.ts:21-77` TTL cache). `SkeletonCard.vue` exists, imported by
|
||||
zero files.
|
||||
|
||||
### B1 — one shared primitive: `useCachedResource` composable + `resources` Pinia store
|
||||
Semantics (generalize `homeStatus.ts` + `useFleetData.ts`):
|
||||
- Keyed resource: `{ data, loadState: idle|loading|ready|error|refreshing, fetchedAt, error }`.
|
||||
- **Hydrate synchronously** from memory (Pinia, survives navigation) → sessionStorage
|
||||
snapshot (survives reload) → then revalidate in background.
|
||||
- Sticky-ready: once `ready`, never regress to `loading`
|
||||
(`loadState = loadState==='ready' ? 'ready' : 'loading'` — the `homeStatus.ts:80` idiom);
|
||||
keep-last-known-value on error with a stale badge (age from `fetchedAt`).
|
||||
- TTL per resource; `revalidateOnFocus` + on WS push (debounced, the
|
||||
`Home.vue:539-542` pattern); explicit `invalidate(key)` for mutations.
|
||||
- Optimistic mutation helper: apply → RPC → rollback on error (generalize
|
||||
`TransportPrefsCard.vue:112-127`).
|
||||
|
||||
### B2 — rpc-client upgrades (`src/api/rpc-client.ts`)
|
||||
- `AbortSignal` in `RPCOptions` (today the AbortController at `:87` is timeout-only)
|
||||
→ abort-on-unmount for fan-outs.
|
||||
- In-flight dedup keyed `method+JSON(params)` — collapses duplicate concurrent calls.
|
||||
- Per-call `maxRetries` override; set `maxRetries: 1` for `content.browse-peer` /
|
||||
`preview-peer` (retry×3 on a 30s timeout is why one slow peer = 90s spinner).
|
||||
|
||||
### B3 — Cloud page conversion (worst offender, the marquee win)
|
||||
- Move `sectionCounts`, `peerNodes`, `myFiles`, `peerFiles`, `paidItems` out of
|
||||
`Cloud.vue` component state (`:403,:476,:582,:689,:427`) into the cached store —
|
||||
instant render on revisit, background refresh.
|
||||
- **Incremental per-peer fan-in**: render each peer's card as its
|
||||
`content.browse-peer` resolves (today `Promise.allSettled` at `:708-747` blocks on
|
||||
the slowest peer). Per-peer states: cached/fresh/loading/unreachable.
|
||||
- **Surface `transport` per peer** (already in the response, discarded at `:716-721`):
|
||||
FIPS/Tor badge + latency — this is also the fleet-wide FIPS-uptime dashboard the
|
||||
user asked for, for free.
|
||||
- Skeleton cards (revive `SkeletonCard.vue`, copy `FileGrid.vue:3-19` shimmer) instead
|
||||
of spinners for counts/folders/peer grids.
|
||||
- Stop `CloudFolder.vue:307-319` calling `cloudStore.reset()` on every folder entry —
|
||||
cache per-path listings, navigate renders cache + revalidates.
|
||||
- `PeerFiles.vue`: persist catalog + preview cache in the store; cap the
|
||||
`preview-peer` fan-out (`:832-841`, currently unbounded) with a small concurrency
|
||||
queue + abort-on-unmount.
|
||||
|
||||
### B4 — roll out to remaining offenders (in audit order)
|
||||
PeerFiles → Web5 wallet/ecash/LND slices → Monitoring → Lightning channels
|
||||
(`LightningChannelsPanel.vue:650`) → Federation (already has `{showLoader:false}` —
|
||||
just adopt the store) → Server → Credentials/OpenWrtGateway/ContainerApps.
|
||||
`Apps.vue`/`Marketplace.vue`/`Fleet.vue` are already good; don't touch.
|
||||
|
||||
### B5 — freshness via the existing push channel
|
||||
`/ws/db` firehose + `sync.ts` JSON-patch already exist. Wire `useCachedResource`
|
||||
revalidation to relevant WS pushes (debounced 800ms), keep the 30s staleness
|
||||
reconciliation as backstop. No new backend needed for v1; a per-topic subscribe can
|
||||
come later.
|
||||
|
||||
### Part B verification (on nodes)
|
||||
- Navigate Cloud → Apps → Cloud: peer files render instantly from cache (0 spinner),
|
||||
refresh indicator while revalidating, updated data lands without layout jump.
|
||||
- One unreachable peer: its card shows stale/unreachable state; other peers render
|
||||
immediately (no 30s all-or-nothing).
|
||||
- Kill backend mid-view: stale data stays visible with age badge; recovery
|
||||
revalidates automatically.
|
||||
- Hard reload: sessionStorage hydrate paints before first RPC completes.
|
||||
|
||||
---
|
||||
|
||||
## Sequencing for the next release
|
||||
|
||||
1. **A0 now** (fleet triage + transient nft rules + .228 daemon upgrade + baseline).
|
||||
2. **A1 + A2** land together (P0 fixes + telemetry) → deploy to .116/.198 →
|
||||
Phase A4 checks on the pair → framework-pt → full fleet.
|
||||
3. **B1 + B2 + B3** (composable + rpc-client + Cloud) in parallel with A-testing —
|
||||
frontend-only, verifiable against .116 dev (`reference_neode_ui_dev_testing`).
|
||||
4. **A3** after telemetry baseline exists; **B4/B5** ride the same or next OTA.
|
||||
5. Gate: Phase A4 checklist green + Part B verification on-device + existing
|
||||
single-node gate stays green → tag/OTA per ship ritual.
|
||||
|
||||
## Success criteria
|
||||
- `content.browse-peer` transport = fips for ≥99% of calls between healthy 0.4.1
|
||||
nodes over 24h (measured by the new counters), Tor reserved for genuinely
|
||||
FIPS-unreachable peers (.116-WiFi-class networks).
|
||||
- Cloud revisit paints in <100ms from cache; fresh data within one revalidate.
|
||||
- Fallback counters visible in `fips.status` so regressions are caught on the
|
||||
dashboard, not by users.
|
||||
Generated
+3
-22
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.115-alpha",
|
||||
"version": "1.7.116-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.115-alpha",
|
||||
"version": "1.7.116-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
@@ -150,7 +150,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1812,7 +1811,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -1836,7 +1834,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3923,7 +3920,6 @@
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
@@ -3973,7 +3969,6 @@
|
||||
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"colorette": "^2.0.20",
|
||||
@@ -4474,7 +4469,6 @@
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -4960,7 +4954,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -5979,7 +5972,6 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -8172,7 +8164,6 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -8222,7 +8213,6 @@
|
||||
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.1.0",
|
||||
"data-urls": "^5.0.0",
|
||||
@@ -8325,8 +8315,7 @@
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
@@ -9082,7 +9071,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -9744,7 +9732,6 @@
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -11000,7 +10987,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -11256,7 +11242,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -11498,7 +11483,6 @@
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -11661,7 +11645,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -11675,7 +11658,6 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -11768,7 +11750,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
|
||||
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.30",
|
||||
"@vue/compiler-sfc": "3.5.30",
|
||||
|
||||
@@ -87,6 +87,15 @@ const detecting = ref(false)
|
||||
const detectError = ref('')
|
||||
const detectedCandidates = ref<string[]>([])
|
||||
|
||||
// Set/change router password — lets a fresh-flash router (root has no
|
||||
// password yet) get fully connected without the user ever opening a
|
||||
// terminal or LuCI themselves.
|
||||
const showSetPassword = ref(false)
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const settingPassword = ref(false)
|
||||
const setPasswordError = ref('')
|
||||
|
||||
const provisioning = ref(false)
|
||||
const provisionError = ref('')
|
||||
const provisionSuccess = ref(false)
|
||||
@@ -148,6 +157,39 @@ async function connect() {
|
||||
}
|
||||
}
|
||||
|
||||
async function setPasswordAndConnect() {
|
||||
if (!host.value.trim() || !newPassword.value) return
|
||||
setPasswordError.value = ''
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
setPasswordError.value = 'Passwords do not match.'
|
||||
return
|
||||
}
|
||||
settingPassword.value = true
|
||||
try {
|
||||
await rpcClient.call({
|
||||
method: 'openwrt.set-password',
|
||||
params: {
|
||||
host: host.value.trim(),
|
||||
ssh_user: sshUser.value,
|
||||
current_password: sshPassword.value,
|
||||
new_password: newPassword.value,
|
||||
},
|
||||
timeout: 20000,
|
||||
})
|
||||
// Router accepted the new password — log in with it right away so the
|
||||
// user never has to separately "Connect" after this.
|
||||
sshPassword.value = newPassword.value
|
||||
showSetPassword.value = false
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
await connect()
|
||||
} catch (e) {
|
||||
setPasswordError.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
settingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
interface WiredInterface { name: string; type: string; state: string; ipv4: string[] }
|
||||
|
||||
async function detectRouter() {
|
||||
@@ -203,6 +245,33 @@ function disconnectRouter() {
|
||||
showConnectForm.value = true
|
||||
}
|
||||
|
||||
const forgetting = ref(false)
|
||||
const forgetError = ref('')
|
||||
|
||||
// Unlike disconnectRouter() (which only resets local state, leaving the saved
|
||||
// router_config.json in place so onMounted reconnects on next visit), this
|
||||
// deletes the saved credentials server-side so the router is truly forgotten.
|
||||
async function forgetRouter() {
|
||||
if (!confirm('Forget this router? You will need to log in again next time.')) return
|
||||
forgetting.value = true
|
||||
forgetError.value = ''
|
||||
try {
|
||||
await rpcClient.call({ method: 'openwrt.forget', timeout: 10000 })
|
||||
status.value = null
|
||||
connectedParams.value = null
|
||||
host.value = ''
|
||||
sshUser.value = 'root'
|
||||
sshPassword.value = ''
|
||||
detectError.value = ''
|
||||
detectedCandidates.value = []
|
||||
showConnectForm.value = true
|
||||
} catch (e) {
|
||||
forgetError.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
forgetting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function provisionTollgate() {
|
||||
provisioning.value = true
|
||||
provisionError.value = ''
|
||||
@@ -437,6 +506,45 @@ onMounted(() => load())
|
||||
>
|
||||
{{ connecting ? 'Connecting…' : 'Connect' }}
|
||||
</button>
|
||||
<button
|
||||
class="w-full text-xs text-white/40 hover:text-white/70 transition-colors text-center"
|
||||
@click="showSetPassword = !showSetPassword"
|
||||
>
|
||||
First login, or don't know the password? Set one →
|
||||
</button>
|
||||
<div v-if="showSetPassword" class="space-y-3 pt-2 border-t border-white/10">
|
||||
<p class="text-xs text-white/40">
|
||||
Sets the router's SSH password directly — no need to SSH in yourself first.
|
||||
Leave "Password" above blank if this is a fresh flash (no password set yet).
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/40 mb-1">New password</label>
|
||||
<input
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/40 mb-1">Confirm password</label>
|
||||
<input
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
:disabled="settingPassword || !host.trim() || !newPassword"
|
||||
class="glass-button w-full text-sm font-medium"
|
||||
:class="settingPassword || !host.trim() || !newPassword ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
@click="setPasswordAndConnect"
|
||||
>
|
||||
{{ settingPassword ? 'Setting password…' : 'Set Password & Connect' }}
|
||||
</button>
|
||||
<p v-if="setPasswordError" class="text-xs text-red-400">{{ setPasswordError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="error" class="mt-3 text-xs text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
@@ -499,7 +607,15 @@ onMounted(() => load())
|
||||
<button class="text-xs text-white/40 hover:text-white/70 transition-colors" @click="disconnectRouter">
|
||||
Switch router →
|
||||
</button>
|
||||
<button
|
||||
class="text-xs text-red-400/70 hover:text-red-400 transition-colors"
|
||||
:disabled="forgetting"
|
||||
@click="forgetRouter"
|
||||
>
|
||||
{{ forgetting ? 'Forgetting…' : 'Forget router' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="forgetError" class="mt-2 text-xs text-red-400">{{ forgetError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- WAN / Uplink -->
|
||||
|
||||
Executable
+278
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env bash
|
||||
# Flash stock OpenWrt on a GL.iNet GL-MT3000 (Beryl AX)
|
||||
#
|
||||
# Usage:
|
||||
# ./flash-mt3000-openwrt.sh <router-password> [router-ip] [--image /path/to/image.bin]
|
||||
#
|
||||
# Defaults to 192.168.8.1.
|
||||
# Requires: curl, sshpass, ssh, sha256sum
|
||||
# Optional: scp (falls back to pipe if unavailable on router)
|
||||
#
|
||||
# What it does:
|
||||
# 1. Verifies tools and connectivity
|
||||
# 2. Downloads the stock OpenWrt sysupgrade image (or uses a pre-downloaded one)
|
||||
# 3. Verifies SHA256 checksum
|
||||
# 4. Uploads image to the router (SCP or pipe)
|
||||
# 5. Flashes via sysupgrade (no settings preserved)
|
||||
# 6. Waits for reboot and verifies new firmware
|
||||
#
|
||||
# After flash, stock OpenWrt is at 192.168.1.1 (root, no password).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[+]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||
err() { echo -e "${RED}[-]${NC} $*" >&2; }
|
||||
|
||||
# --- Parse args ---
|
||||
PASSWORD=""
|
||||
ROUTER="192.168.8.1"
|
||||
LOCAL_IMAGE=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--image)
|
||||
LOCAL_IMAGE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--router)
|
||||
ROUTER="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
if [ -z "$PASSWORD" ]; then
|
||||
PASSWORD="$1"
|
||||
else
|
||||
ROUTER="$1"
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$PASSWORD" ]; then
|
||||
echo "Usage: $0 <router-password> [router-ip] [--image /path/to/sysupgrade.bin]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --image PATH Use a pre-downloaded sysupgrade image instead of downloading"
|
||||
echo " --router IP Router IP (default: 192.168.8.1)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SSH_USER="root"
|
||||
OPENWRT_VERSION="24.10.2"
|
||||
IMAGE_NAME="openwrt-${OPENWRT_VERSION}-mediatek-filogic-glinet_gl-mt3000-squashfs-sysupgrade.bin"
|
||||
IMAGE_URL="https://downloads.openwrt.org/releases/${OPENWRT_VERSION}/targets/mediatek/filogic/${IMAGE_NAME}"
|
||||
CHECKSUM_URL="https://downloads.openwrt.org/releases/${OPENWRT_VERSION}/targets/mediatek/filogic/sha256sums"
|
||||
DOWNLOAD_DIR="/tmp/openwrt-flash"
|
||||
DEFAULT_IMAGE="${DOWNLOAD_DIR}/${IMAGE_NAME}"
|
||||
NEW_IP="192.168.1.1"
|
||||
|
||||
SSH_CMD="sshpass -p ${PASSWORD} ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ${SSH_USER}@${ROUTER}"
|
||||
|
||||
# --- Step 1: Check tools ---
|
||||
log "Checking required tools..."
|
||||
MISSING=()
|
||||
for cmd in curl sshpass ssh sha256sum; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || MISSING+=("$cmd")
|
||||
done
|
||||
if [ ${#MISSING[@]} -gt 0 ]; then
|
||||
err "Missing tools: ${MISSING[*]}"
|
||||
exit 1
|
||||
fi
|
||||
log "All tools found."
|
||||
|
||||
# --- Step 2: Check router connectivity ---
|
||||
log "Checking router connectivity at ${ROUTER}..."
|
||||
if ! $SSH_CMD "echo ok" >/dev/null 2>&1; then
|
||||
err "Cannot SSH into ${ROUTER}. Is the router reachable and is the password correct?"
|
||||
exit 1
|
||||
fi
|
||||
log "Router reachable."
|
||||
|
||||
# Verify it's a GL-MT3000
|
||||
BOARD=$($SSH_CMD "cat /tmp/sysinfo/board_name 2>/dev/null" || true)
|
||||
if [[ "$BOARD" != *"mt3000"* ]]; then
|
||||
err "Router board is '${BOARD}', expected GL-MT3000. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
log "Confirmed GL-MT3000 (board: ${BOARD})."
|
||||
|
||||
# --- Step 3: Get image ---
|
||||
if [ -n "$LOCAL_IMAGE" ]; then
|
||||
# User provided a local image
|
||||
if [ ! -f "$LOCAL_IMAGE" ]; then
|
||||
err "Image file not found: ${LOCAL_IMAGE}"
|
||||
exit 1
|
||||
fi
|
||||
log "Using provided image: ${LOCAL_IMAGE}"
|
||||
else
|
||||
# Download the image
|
||||
log "Downloading OpenWrt ${OPENWRT_VERSION} sysupgrade image..."
|
||||
mkdir -p "$DOWNLOAD_DIR"
|
||||
|
||||
if [ -f "$DEFAULT_IMAGE" ]; then
|
||||
warn "Image already exists locally, skipping download."
|
||||
else
|
||||
# Test if HTTPS is being intercepted (common with GL.iNet routers)
|
||||
CERT_ISSUE=false
|
||||
CERT_INFO=$(curl -v --connect-timeout 5 "https://downloads.openwrt.org" 2>&1 || true)
|
||||
if echo "$CERT_INFO" | grep -qi "GLiNet\|gl-inet\|self-signed"; then
|
||||
CERT_ISSUE=true
|
||||
warn "HTTPS interception detected — the router is MITM-ing TLS connections."
|
||||
warn "This is the same issue that breaks Tailscale."
|
||||
fi
|
||||
|
||||
if [ "$CERT_ISSUE" = true ]; then
|
||||
warn "Attempting download via HTTP..."
|
||||
HTTP_URL="${IMAGE_URL/https:/http:}"
|
||||
if ! curl -fSL --progress-bar --connect-timeout 10 -o "$DEFAULT_IMAGE" "$HTTP_URL" 2>/dev/null; then
|
||||
err ""
|
||||
err "Download failed. The router is intercepting HTTPS and HTTP is also blocked."
|
||||
err ""
|
||||
err "Workaround: download the image on a machine NOT behind this router,"
|
||||
err "then run this script with --image /path/to/${IMAGE_NAME}"
|
||||
err ""
|
||||
err "Direct download URL:"
|
||||
err " ${IMAGE_URL}"
|
||||
err ""
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if ! curl -fSL --progress-bar -o "$DEFAULT_IMAGE" "$IMAGE_URL"; then
|
||||
err "Download failed from ${IMAGE_URL}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
LOCAL_IMAGE="$DEFAULT_IMAGE"
|
||||
fi
|
||||
|
||||
log "Image ready: $(ls -lh "$LOCAL_IMAGE" | awk '{print $5}')"
|
||||
|
||||
# --- Step 4: Verify checksum ---
|
||||
log "Verifying SHA256 checksum..."
|
||||
EXPECTED_HASH=""
|
||||
CHECKSUM_ATTEMPTS=(
|
||||
"https://downloads.openwrt.org/releases/${OPENWRT_VERSION}/targets/mediatek/filogic/sha256sums"
|
||||
"http://downloads.openwrt.org/releases/${OPENWRT_VERSION}/targets/mediatek/filogic/sha256sums"
|
||||
)
|
||||
|
||||
for url in "${CHECKSUM_ATTEMPTS[@]}"; do
|
||||
EXPECTED_HASH=$(curl -fsSL --connect-timeout 5 "$url" 2>/dev/null | grep "${IMAGE_NAME}" | awk '{print $1}' || true)
|
||||
if [ -n "$EXPECTED_HASH" ]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$EXPECTED_HASH" ]; then
|
||||
ACTUAL_HASH=$(sha256sum "$LOCAL_IMAGE" | awk '{print $1}')
|
||||
if [ "$EXPECTED_HASH" != "$ACTUAL_HASH" ]; then
|
||||
err "Checksum mismatch!"
|
||||
err " Expected: ${EXPECTED_HASH}"
|
||||
err " Actual: ${ACTUAL_HASH}"
|
||||
exit 1
|
||||
fi
|
||||
log "Checksum OK: ${ACTUAL_HASH:0:16}..."
|
||||
else
|
||||
warn "Could not fetch expected checksum (network issue?), skipping verification."
|
||||
fi
|
||||
|
||||
# --- Step 5: Pre-flash checks ---
|
||||
log "Running pre-flash checks on router..."
|
||||
|
||||
# Check free space on /tmp
|
||||
FREE_KB=$($SSH_CMD "df /tmp | tail -1 | awk '{print \$4}'")
|
||||
IMAGE_SIZE_KB=$(( $(stat -c%s "$LOCAL_IMAGE") / 1024 ))
|
||||
if [ "$FREE_KB" -lt "$((IMAGE_SIZE_KB + 10240))" ]; then
|
||||
err "Not enough space on /tmp. Need ~$((IMAGE_SIZE_KB/1024))MB, have $((FREE_KB/1024))MB."
|
||||
exit 1
|
||||
fi
|
||||
log "Free space on /tmp: $((FREE_KB/1024))MB, image: $((IMAGE_SIZE_KB/1024))MB — OK."
|
||||
|
||||
# Verify current firmware
|
||||
CURRENT_FW=$($SSH_CMD "cat /etc/openwrt_release 2>/dev/null | grep DISTRIB_DESCRIPTION" || true)
|
||||
log "Current firmware: ${CURRENT_FW}"
|
||||
|
||||
# --- Step 6: Upload image ---
|
||||
log "Uploading image to router..."
|
||||
|
||||
# Try SCP first, fall back to pipe (GL.iNet firmware lacks sftp-server)
|
||||
if command -v scp >/dev/null 2>&1 && \
|
||||
sshpass -p "$PASSWORD" scp -o StrictHostKeyChecking=no -o ConnectTimeout=5 -o BatchMode=yes "$LOCAL_IMAGE" "${SSH_USER}@${ROUTER}:/tmp/openwrt-sysupgrade.bin" 2>/dev/null; then
|
||||
log "Upload complete (via SCP)."
|
||||
else
|
||||
log "SCP unavailable on router, using pipe transfer..."
|
||||
cat "$LOCAL_IMAGE" | $SSH_CMD "cat > /tmp/openwrt-sysupgrade.bin"
|
||||
log "Upload complete (via pipe)."
|
||||
fi
|
||||
|
||||
# Verify uploaded file
|
||||
REMOTE_HASH=$($SSH_CMD "sha256sum /tmp/openwrt-sysupgrade.bin | awk '{print \$1}'")
|
||||
LOCAL_HASH=$(sha256sum "$LOCAL_IMAGE" | awk '{print $1}')
|
||||
if [ "$REMOTE_HASH" != "$LOCAL_HASH" ]; then
|
||||
err "Remote file hash mismatch after upload!"
|
||||
$SSH_CMD "rm -f /tmp/openwrt-sysupgrade.bin"
|
||||
exit 1
|
||||
fi
|
||||
log "Remote file verified."
|
||||
|
||||
# --- Step 7: Flash ---
|
||||
echo ""
|
||||
log "============================================"
|
||||
log "FLASHING STOCK OPENWRT ${OPENWRT_VERSION}"
|
||||
log "The router will reboot. This takes ~3-5 min."
|
||||
log "After flash, OpenWrt will be at ${NEW_IP}"
|
||||
log "============================================"
|
||||
echo ""
|
||||
|
||||
$SSH_CMD "sysupgrade -n /tmp/openwrt-sysupgrade.bin" &
|
||||
SCP_PID=$!
|
||||
|
||||
# sysupgrade disconnects SSH, wait for it
|
||||
wait $SCP_PID 2>/dev/null || true
|
||||
|
||||
log "Flash initiated. Waiting for router to reboot..."
|
||||
sleep 15
|
||||
|
||||
# --- Step 8: Wait for new router ---
|
||||
log "Waiting for stock OpenWrt at ${NEW_IP}..."
|
||||
for i in $(seq 1 90); do
|
||||
if ping -c1 -W2 "$NEW_IP" >/dev/null 2>&1; then
|
||||
log "Router is up at ${NEW_IP}!"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 90 ]; then
|
||||
warn "Router not responding at ${NEW_IP} after 3 minutes."
|
||||
warn "It may still be booting. Try: ssh root@${NEW_IP}"
|
||||
warn "If unreachable, hold reset for U-Boot recovery at 192.168.1.1"
|
||||
exit 1
|
||||
fi
|
||||
printf "\r Waiting... (%d/90)" $i
|
||||
sleep 2
|
||||
done
|
||||
echo ""
|
||||
|
||||
# --- Step 9: Verify new firmware ---
|
||||
log "Verifying new firmware..."
|
||||
for attempt in 1 2 3; do
|
||||
if ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new root@${NEW_IP} "cat /etc/openwrt_release" 2>/dev/null; then
|
||||
echo ""
|
||||
log "============================================"
|
||||
log "FLASH COMPLETE"
|
||||
log "Stock OpenWrt is running at ${NEW_IP}"
|
||||
log "Login: root (no password)"
|
||||
log "Run 'passwd' to set a root password."
|
||||
log "============================================"
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
warn "Could not verify firmware. Router is reachable but SSH may still be starting."
|
||||
warn "Try: ssh root@${NEW_IP}"
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# ./gl-inet-enable-ssh.sh <password> [router-ip]
|
||||
#
|
||||
# Defaults to 192.168.8.1. It does:
|
||||
#
|
||||
# 1. Completes OOBE/init (sets password + enables SSH)
|
||||
# 2. Challenge-response login
|
||||
# 3. Explicitly enables SSH via API
|
||||
# 4. Verifies SSH is working
|
||||
#
|
||||
# Needs curl, python3, openssl, sshpass, and md5sum on the machine running it.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROUTER="${GL_ROUTER:-192.168.8.1}"
|
||||
PASSWORD="${1:?Usage: $0 <password> [router-ip]}"
|
||||
HOST="${2:-$ROUTER}"
|
||||
|
||||
challenge() {
|
||||
curl -sk "http://$HOST/rpc" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"method\":\"challenge\",\"params\":{\"username\":\"root\"},\"id\":1}"
|
||||
}
|
||||
|
||||
login() {
|
||||
local hash="$1"
|
||||
curl -sk "http://$HOST/rpc" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"method\":\"login\",\"params\":{\"username\":\"root\",\"hash\":\"$hash\"},\"id\":2}"
|
||||
}
|
||||
|
||||
rpc_call() {
|
||||
local sid="$1" module="$2" method="$3" params="$4"
|
||||
curl -sk "http://$HOST/rpc" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"method\":\"call\",\"params\":[\"$sid\",\"$module\",\"$method\",$params],\"id\":3}"
|
||||
}
|
||||
|
||||
# Step 1: Complete OOBE / init (enables SSH)
|
||||
echo "Initializing router..."
|
||||
INIT=$(curl -sk "http://$HOST/rpc" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"call\",\"params\":[\"\",\"ui\",\"init\",{\"lang\":\"en\",\"username\":\"root\",\"password\":\"$PASSWORD\",\"security_rule\":0}]}")
|
||||
echo "$INIT"
|
||||
|
||||
if echo "$INIT" | grep -q '"error"'; then
|
||||
echo "Init returned error (may already be initialized), continuing..."
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
|
||||
# Step 2: Challenge-response login
|
||||
echo "Logging in..."
|
||||
CHALLENGE=$(challenge)
|
||||
SALT=$(echo "$CHALLENGE" | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['salt'])")
|
||||
NONCE=$(echo "$CHALLENGE" | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['nonce'])")
|
||||
ALG=$(echo "$CHALLENGE" | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['alg'])")
|
||||
|
||||
CIPHER=$(openssl passwd "-$ALG" -salt "$SALT" "$PASSWORD" 2>/dev/null)
|
||||
HASH=$(printf "root:%s:%s" "$CIPHER" "$NONCE" | md5sum | cut -d' ' -f1)
|
||||
|
||||
LOGIN=$(login "$HASH")
|
||||
SID=$(echo "$LOGIN" | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['sid'])" 2>/dev/null)
|
||||
|
||||
if [ -z "$SID" ]; then
|
||||
echo "Login failed: $LOGIN"
|
||||
echo "SSH should still be enabled from the init call. Try: ssh root@$HOST"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Logged in. SID: $SID"
|
||||
|
||||
# Step 3: Enable SSH explicitly
|
||||
echo "Enabling SSH..."
|
||||
SSH_RESULT=$(rpc_call "$SID" "system" "set_settings" '{"key":"ssh","value":{"enable":true}}')
|
||||
echo "$SSH_RESULT"
|
||||
|
||||
# Step 4: Verify SSH
|
||||
echo "Verifying SSH on $HOST:22..."
|
||||
if sshpass -p "$PASSWORD" ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no root@$HOST "echo SSH OK" 2>/dev/null; then
|
||||
echo "Done. SSH is enabled on root@$HOST"
|
||||
else
|
||||
echo "SSH port not responding yet. May need a moment or SSH may already be enabled."
|
||||
echo "Try: ssh root@$HOST"
|
||||
fi
|
||||
Reference in New Issue
Block a user