Compare commits

...
Author SHA1 Message Date
ssmithx 0651e8c615 Merge branch 'openwrt-enhancements' of http://146.59.87.168:3000/lfg2025/archy into openwrt-enhancements 2026-07-28 13:39:51 -04:00
ssmithx 7b69200244 adding some buttons 2026-07-28 13:39:00 -04:00
ssmithxandClaude Sonnet 5 2da8e2c698 feat(openwrt): add "Forget router" to actually clear saved config
"Switch router" only reset local Vue state, leaving router_config.json
on disk — a reload silently reconnected with the old saved SSH
password. Add openwrt.forget RPC + forget_router_config() to delete
the saved config, and a "Forget router" button in OpenWrtGateway.vue.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 18:14:54 -04:00
ssmithxandssmithx cebbd10127 updated flashing script 2026-07-27 18:11:13 -04:00
ssmithxandssmithx 5d05529d11 adding flashing script - needs work 2026-07-27 18:11:12 -04:00
ssmithxandssmithx 72e480e9f7 first of openwrt-tollgate-UI enhancements 2026-07-27 18:11:12 -04:00
archipelagoandClaude Fable 5 eb2fc0f37b fix(fips): P0 uptime fixes — open peer port 5679, allow /blob+/dwn, fix LAN anchor port, un-deaden direct peering, fast-fail budgets
Phase A1 of docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md — the five changes that
made FIPS fall back to Tor even when a FIPS path existed:

- RC0: the fips.d drop-in now opens PEER_PORT 5679 (was 80+8443 only, so
  every hardened node firewalled peers' FIPS dials; 28k drops on .198)
- RC4: /blob/ and /dwn/ added to the peer-path allowlist — mesh file
  sharing and DWN sync were 404 → 100% Tor by construction
- RC2-G2: lan_fips_anchors dials PUBLISHED_UDP_PORT (2121) instead of the
  dead 8668, with a drift-guard test against the rendered daemon config
- RC2-G1: direct LAN peering actually runs now — mDNS TXT advertises the
  FIPS npub, discovery calls set_fips_npub, and the anchor tick hydrates
  npubs from federation storage for peers on older builds
- RC3: FIPS attempt budget is a hard cap (retry no longer doubles it) and
  the 12 hot call sites get explicit fips_timeout fast-fail so Tor keeps
  its full budget (browse-peer, preview, /blob, DWN, node-message,
  rotation notifies)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:42:44 -04:00
archipelagoandClaude Fable 5 94b5374f66 Merge public-prelaunch: open-source launch prep + FIPS unit-fallback coverage + companion safe-area fix
Demo images / Build & push demo images (push) Successful in 3m10s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:16:27 -04:00
archipelagoandClaude Fable 5 9f65f1e7ae docs: FIPS near-100% uptime + optimistic UI state plan — live-proven root causes (nft 5679 drop, .228 daemon skew, dead LAN peering, no fast-fail) + phased execution
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:14:23 -04:00
ssmithx 74d50719ac updated flashing script 2026-07-26 23:24:01 +00:00
ssmithx eb74bc3fe4 adding flashing script - needs work 2026-07-26 23:15:26 +00:00
ssmithx b13751e6d1 first of openwrt-tollgate-UI enhancements 2026-07-26 23:07:17 +00:00
22 changed files with 1048 additions and 46 deletions
+7
View File
@@ -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))?;
+60
View File
@@ -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, &current_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": "",
+2 -1
View File
@@ -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")
+37 -2
View File
@@ -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());
}
}
+15 -5
View File
@@ -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
+44 -8
View File
@@ -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)
}
}
}
+3
View File
@@ -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
{
+11
View File
@@ -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() {
+2
View File
@@ -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
{
+39 -3
View File
@@ -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(&reg.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]
+23 -2
View File
@@ -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;
}
}
}
}
+1 -1
View File
@@ -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,
+16
View File
@@ -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
+1 -1
View File
@@ -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"'\''"))
}
+300
View File
@@ -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.53.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.
+3 -22
View File
@@ -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 -->
+278
View File
@@ -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}"
+85
View File
@@ -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