Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7208e1769 | ||
|
|
77ee39a3df | ||
|
|
02917cc22c | ||
|
|
325b9ea9c9 | ||
|
|
9739bbb3db | ||
|
|
4db1b85fc5 |
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.115-alpha (2026-07-26)
|
||||
|
||||
- The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.
|
||||
- The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.
|
||||
- Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades.
|
||||
|
||||
## v1.7.114-alpha (2026-07-26)
|
||||
|
||||
- Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the "device detected" popup for the port. Radios that could never connect now come up within a minute of being plugged in.
|
||||
|
||||
Generated
+1
-1
@@ -95,7 +95,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.114-alpha"
|
||||
version = "1.7.115-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.114-alpha"
|
||||
version = "1.7.115-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -248,10 +248,17 @@ impl QuadletUnit {
|
||||
} else {
|
||||
proto.as_str()
|
||||
};
|
||||
// Keep the rendered directive byte-identical for unbound
|
||||
// ports so existing units don't read as drifted.
|
||||
// Unbound publishes are pinned to 0.0.0.0: rootlessport's
|
||||
// wildcard bind claims [::] too but BLACK-HOLES inbound v6
|
||||
// (accepts, forwards nothing — "empty response" from the
|
||||
// mesh, confirmed 2026-07-26). Pinning v4 frees the port's
|
||||
// v6 side for the daemon's mesh relay (app_port_v6_relay_loop),
|
||||
// which forwards to the v4 loopback listener that works.
|
||||
// NOTE: this changes the rendered directive for unbound
|
||||
// ports on purpose — the one-time drift/recreate at upgrade
|
||||
// is the deploy vehicle for the fix.
|
||||
if bind.is_empty() {
|
||||
let _ = writeln!(s, "PublishPort={host}:{container}/{p}");
|
||||
let _ = writeln!(s, "PublishPort=0.0.0.0:{host}:{container}/{p}");
|
||||
} else {
|
||||
let _ = writeln!(s, "PublishPort={bind}:{host}:{container}/{p}");
|
||||
}
|
||||
@@ -1018,7 +1025,7 @@ mod tests {
|
||||
u.network = NetworkMode::Bridge("archy-net".into());
|
||||
u.ports = vec![(3000, 3000, "tcp".into(), String::new())];
|
||||
let s = u.render();
|
||||
assert!(s.contains("PublishPort=3000:3000/tcp"));
|
||||
assert!(s.contains("PublishPort=0.0.0.0:3000:3000/tcp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1167,8 +1174,8 @@ mod tests {
|
||||
let s = u.render();
|
||||
assert!(s.contains("PublishPort=127.0.0.1:8332:8332/tcp"));
|
||||
assert!(s.contains("PublishPort=10.89.0.1:8332:8332/tcp"));
|
||||
assert!(s.contains("PublishPort=8333:8333/tcp"));
|
||||
assert!(!s.contains("PublishPort=8332:8332/tcp"));
|
||||
assert!(s.contains("PublishPort=0.0.0.0:8333:8333/tcp"));
|
||||
assert!(!s.contains("PublishPort=0.0.0.0:8332:8332/tcp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1200,8 +1207,8 @@ mod tests {
|
||||
..QuadletUnit::default()
|
||||
};
|
||||
let s = u.render();
|
||||
assert!(s.contains("PublishPort=8332:8332/tcp"));
|
||||
assert!(s.contains("PublishPort=8333:8333/tcp"));
|
||||
assert!(s.contains("PublishPort=0.0.0.0:8332:8332/tcp"));
|
||||
assert!(s.contains("PublishPort=0.0.0.0:8333:8333/tcp"));
|
||||
assert!(s.contains("Environment=BITCOIN_RPC_USER=archipelago"));
|
||||
assert!(s.contains("Environment=BITCOIN_RPC_PASS=secret"));
|
||||
assert!(s.contains("Environment=\"RELAY_NAME=Archipelago Nostr Relay\""));
|
||||
@@ -1317,7 +1324,7 @@ app:
|
||||
let m = AppManifest::parse(yaml).expect("manifest must parse");
|
||||
let s = QuadletUnit::from_manifest(&m, "searxng").render();
|
||||
|
||||
assert!(s.contains("PublishPort=8888:8080/tcp"));
|
||||
assert!(s.contains("PublishPort=0.0.0.0:8888:8080/tcp"));
|
||||
assert!(!s.contains("Network=host"));
|
||||
}
|
||||
|
||||
@@ -1746,7 +1753,7 @@ app:
|
||||
assert!(body.contains("Network=archy-net"));
|
||||
assert!(body.contains("NetworkAlias=lnd"));
|
||||
assert!(body.contains("PodmanArgs=--network-alias=lnd"));
|
||||
assert!(body.contains("PublishPort=10009:10009/tcp"));
|
||||
assert!(body.contains("PublishPort=0.0.0.0:10009:10009/tcp"));
|
||||
assert!(body.contains("Volume=/var/lib/archipelago/lnd:/root/.lnd:Z"));
|
||||
assert!(body.contains("Environment=LND_NETWORK=mainnet"));
|
||||
assert!(body.contains("PodmanArgs=--memory=1024m"));
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Generated by scripts/generate-app-catalog.py. Do not edit manually.
|
||||
//!
|
||||
//! Catalog app launch ports (the web UIs the companion opens by direct
|
||||
//! port). Used to write the fips0 firewall allowance drop-in so app UIs
|
||||
//! are reachable over the mesh; ports of apps that aren't installed have
|
||||
//! no listener, so allowing them is inert.
|
||||
|
||||
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||
2283,
|
||||
2342,
|
||||
3000,
|
||||
3001,
|
||||
3002,
|
||||
4080,
|
||||
5180,
|
||||
7778,
|
||||
8080,
|
||||
8081,
|
||||
8082,
|
||||
8083,
|
||||
8084,
|
||||
8085,
|
||||
8087,
|
||||
8088,
|
||||
8089,
|
||||
8090,
|
||||
8096,
|
||||
8123,
|
||||
8175,
|
||||
8176,
|
||||
8240,
|
||||
8334,
|
||||
8888,
|
||||
8999,
|
||||
9000,
|
||||
9100,
|
||||
10380,
|
||||
11434,
|
||||
18081,
|
||||
18083,
|
||||
23000,
|
||||
32838,
|
||||
50002,
|
||||
];
|
||||
@@ -48,6 +48,30 @@ pub struct FipsConfig {
|
||||
pub struct NodeSection {
|
||||
pub identity: IdentitySection,
|
||||
pub discovery: DiscoverySection,
|
||||
pub retry: RetrySection,
|
||||
pub rate_limit: RateLimitSection,
|
||||
}
|
||||
|
||||
/// Fast-reconnect profile (`node.retry.*`). Upstream defaults (5s base
|
||||
/// doubling to 300s) are tuned for stable always-on links; a node redialing
|
||||
/// a recycled anchor sat off-mesh for ~90s. Field names verified live on
|
||||
/// 2026-07-24: a daemon restarted with these keys in fips.yaml and peered.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct RetrySection {
|
||||
pub base_interval_secs: u64,
|
||||
pub max_backoff_secs: u64,
|
||||
pub max_retries: u32,
|
||||
}
|
||||
|
||||
/// Session-handshake resend pacing (`node.rate_limit.*`). Stock 1s x2.0
|
||||
/// gaps out to 8-16s between resends exactly when a route has just
|
||||
/// appeared; 400ms x1.5 keeps continuous coverage through the connect
|
||||
/// window (phone measured session-after-route: 225ms).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct RateLimitSection {
|
||||
pub handshake_resend_interval_ms: u64,
|
||||
pub handshake_resend_backoff: f64,
|
||||
pub handshake_max_resends: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
@@ -60,6 +84,14 @@ pub struct IdentitySection {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct DiscoverySection {
|
||||
/// Lookup completion timeout. Lookups fired while the tree position is
|
||||
/// still settling are doomed; failing them fast (5s, not 10s) lets the
|
||||
/// 1s-backoff retry find the route the moment it exists.
|
||||
pub timeout_secs: u64,
|
||||
pub backoff_base_secs: u64,
|
||||
pub backoff_max_secs: u64,
|
||||
pub retry_interval_secs: u64,
|
||||
pub max_attempts: u8,
|
||||
pub lan: LanDiscoverySection,
|
||||
}
|
||||
|
||||
@@ -123,8 +155,23 @@ impl Default for FipsConfig {
|
||||
node: NodeSection {
|
||||
identity: IdentitySection { persistent: true },
|
||||
discovery: DiscoverySection {
|
||||
timeout_secs: 5,
|
||||
backoff_base_secs: 1,
|
||||
backoff_max_secs: 30,
|
||||
retry_interval_secs: 2,
|
||||
max_attempts: 3,
|
||||
lan: LanDiscoverySection { enabled: true },
|
||||
},
|
||||
retry: RetrySection {
|
||||
base_interval_secs: 1,
|
||||
max_backoff_secs: 30,
|
||||
max_retries: 30,
|
||||
},
|
||||
rate_limit: RateLimitSection {
|
||||
handshake_resend_interval_ms: 400,
|
||||
handshake_resend_backoff: 1.5,
|
||||
handshake_max_resends: 10,
|
||||
},
|
||||
},
|
||||
tun: TunSection {
|
||||
enabled: true,
|
||||
@@ -186,6 +233,65 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
|
||||
let _ = tokio::fs::remove_file(&stage).await;
|
||||
install_result?;
|
||||
|
||||
// The release-hardening firewall (/etc/fips/fips.nft, provisioned
|
||||
// out-of-band) default-denies inbound on fips0 — without an explicit
|
||||
// allowance the node's web UI is unreachable over the mesh (phones got
|
||||
// RST on :80 with a healthy session; root-caused 2026-07-26 on
|
||||
// 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";
|
||||
let nft_stage = std::env::temp_dir().join(format!("fips-webui-{}.nft", std::process::id()));
|
||||
tokio::fs::write(&nft_stage, dropin)
|
||||
.await
|
||||
.context("Failed to stage web-ui nft drop-in")?;
|
||||
let nft_install = sudo_install_file(&nft_stage, "/etc/fips/fips.d/80-web-ui.nft", "0644").await;
|
||||
let _ = tokio::fs::remove_file(&nft_stage).await;
|
||||
nft_install?;
|
||||
|
||||
// App launch ports: the companion opens catalog apps by direct port
|
||||
// over the mesh. Ports of apps that aren't installed have no listener,
|
||||
// so the allowance is inert until an app exists to answer.
|
||||
let port_list = super::app_ports::APP_LAUNCH_PORTS
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let app_dropin = format!(
|
||||
"# Written by archipelago on every daemon config install.\n\
|
||||
# Catalog app launch ports (web UIs) allowed through the fips0\n\
|
||||
# default-deny inbound baseline. Service/RPC ports stay closed.\n\
|
||||
tcp dport {{ {port_list} }} accept\n"
|
||||
);
|
||||
let app_stage = std::env::temp_dir().join(format!("fips-appports-{}.nft", std::process::id()));
|
||||
tokio::fs::write(&app_stage, app_dropin)
|
||||
.await
|
||||
.context("Failed to stage app-ports nft drop-in")?;
|
||||
let app_install =
|
||||
sudo_install_file(&app_stage, "/etc/fips/fips.d/85-app-ports.nft", "0644").await;
|
||||
let _ = tokio::fs::remove_file(&app_stage).await;
|
||||
app_install?;
|
||||
// Make the allowance live immediately; a no-op error when the
|
||||
// hardening baseline isn't installed on this node yet.
|
||||
if tokio::fs::try_exists("/etc/fips/fips.nft").await.unwrap_or(false) {
|
||||
match Command::new("sudo")
|
||||
.args(["nft", "-f", "/etc/fips/fips.nft"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(out) if !out.status.success() => tracing::warn!(
|
||||
"nft reload after web-ui drop-in failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
),
|
||||
Err(e) => tracing::warn!("nft reload after web-ui drop-in failed: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
sudo_install_file(&src_key, DAEMON_KEY_PATH, "0600").await?;
|
||||
// Heal a legacy fips_key.pub that was written as bech32 npub text
|
||||
// (pre-fix identity::write_fips_key_from_seed did this). Upstream
|
||||
@@ -318,8 +424,21 @@ node:
|
||||
identity:
|
||||
persistent: true
|
||||
discovery:
|
||||
timeout_secs: 5
|
||||
backoff_base_secs: 1
|
||||
backoff_max_secs: 30
|
||||
retry_interval_secs: 2
|
||||
max_attempts: 3
|
||||
lan:
|
||||
enabled: true
|
||||
retry:
|
||||
base_interval_secs: 1
|
||||
max_backoff_secs: 30
|
||||
max_retries: 30
|
||||
rate_limit:
|
||||
handshake_resend_interval_ms: 400
|
||||
handshake_resend_backoff: 1.5
|
||||
handshake_max_resends: 10
|
||||
tun:
|
||||
enabled: true
|
||||
name: fips0
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod anchors;
|
||||
pub mod app_ports;
|
||||
pub mod config;
|
||||
pub mod dial;
|
||||
pub mod iface;
|
||||
|
||||
@@ -988,8 +988,54 @@ impl Server {
|
||||
main_addr,
|
||||
));
|
||||
|
||||
// The mesh is IPv6-only: a phone reaching the node over its fips0
|
||||
// ULA lands on port 80 over v6, where a 0.0.0.0 listener never
|
||||
// answers — the UI was structurally unreachable over the mesh
|
||||
// (RST -> ERR_CONNECTION_ABORTED, confirmed 2026-07-26: v4:80 = 200,
|
||||
// v6:80 = refused). Mirror an IPv4-any main listener with a
|
||||
// V6ONLY [::] socket on the same port — v6-only so it coexists
|
||||
// with the v4 listener regardless of net.ipv6.bindv6only.
|
||||
let v4_any_port = match main_addr {
|
||||
SocketAddr::V4(v4) if v4.ip().is_unspecified() => Some(v4.port()),
|
||||
_ => None,
|
||||
};
|
||||
let v6_task = if let Some(port) = v4_any_port {
|
||||
let v6_addr = SocketAddr::new(
|
||||
std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED),
|
||||
port,
|
||||
);
|
||||
match bind_v6_only(v6_addr) {
|
||||
Ok(listener) => {
|
||||
info!("IPv6 web listener bound {} (mesh ULA reachable)", v6_addr);
|
||||
Some(tokio::spawn(accept_loop(
|
||||
self.api_handler.clone(),
|
||||
listener,
|
||||
active_connections.clone(),
|
||||
false, // same semantics as the main listener
|
||||
tx.subscribe(),
|
||||
v6_addr,
|
||||
)))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("IPv6 web listener bind {} failed: {} — UI stays v4-only", v6_addr, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Peer listener: late-binding so we don't need an archipelago
|
||||
// restart when fips0 comes up after onboarding.
|
||||
// App UIs over the mesh: rootless podman's port forwarder binds
|
||||
// IPv4 only for most apps, so a phone dialing [ULA]:8123 got
|
||||
// nothing even with the firewall open (HA/FileBrowser/Gitea/
|
||||
// Portainer/Pine all v4-only on 2026-07-26; a few bind [::]
|
||||
// themselves). Bridge each catalog launch port to its v4 loopback
|
||||
// listener with a V6ONLY relay — self-selecting: where the app
|
||||
// already answers on v6 the bind fails and the app wins.
|
||||
let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe()));
|
||||
|
||||
let peer_task = tokio::spawn(peer_late_bind_loop(
|
||||
self.api_handler.clone(),
|
||||
active_connections.clone(),
|
||||
@@ -1012,6 +1058,10 @@ impl Server {
|
||||
}
|
||||
|
||||
let _ = main_task.await;
|
||||
if let Some(t) = v6_task {
|
||||
let _ = t.await;
|
||||
}
|
||||
relay_task.abort();
|
||||
let _ = peer_task.await;
|
||||
|
||||
info!("Shutdown complete");
|
||||
@@ -1019,6 +1069,78 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind a V6ONLY `[::]` TCP listener. V6ONLY is set explicitly so the
|
||||
/// socket never claims the IPv4 side (which the main listener owns) —
|
||||
/// without it, Linux hosts with `net.ipv6.bindv6only=0` would fail with
|
||||
/// EADDRINUSE.
|
||||
fn bind_v6_only(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
|
||||
let socket = socket2::Socket::new(
|
||||
socket2::Domain::IPV6,
|
||||
socket2::Type::STREAM,
|
||||
Some(socket2::Protocol::TCP),
|
||||
)?;
|
||||
socket.set_only_v6(true)?;
|
||||
socket.set_reuse_address(true)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
socket.bind(&addr.into())?;
|
||||
socket.listen(1024)?;
|
||||
tokio::net::TcpListener::from_std(socket.into())
|
||||
}
|
||||
|
||||
/// IPv6→IPv4 relay for catalog app launch ports (see the spawn site for
|
||||
/// why). Rescans every 60s so ports of freshly installed apps get bridged
|
||||
/// without a daemon restart. Each relay is a V6ONLY listener forwarding
|
||||
/// raw TCP to the same port on IPv4 loopback.
|
||||
async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
|
||||
use std::collections::HashSet;
|
||||
let mut bridged: HashSet<u16> = HashSet::new();
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
for &port in crate::fips::app_ports::APP_LAUNCH_PORTS {
|
||||
if bridged.contains(&port) {
|
||||
continue;
|
||||
}
|
||||
let addr = SocketAddr::new(
|
||||
std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED),
|
||||
port,
|
||||
);
|
||||
// EADDRINUSE = the app itself (or a prior relay) already
|
||||
// answers on v6 — exactly when we must stay out of the way.
|
||||
let Ok(listener) = bind_v6_only(addr) else { continue };
|
||||
bridged.insert(port);
|
||||
debug!("v6 relay bridging [::]:{port} -> 127.0.0.1:{port}");
|
||||
let mut rx = shutdown_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
accepted = listener.accept() => {
|
||||
let Ok((mut inbound, _)) = accepted else { break };
|
||||
tokio::spawn(async move {
|
||||
let Ok(mut outbound) = tokio::net::TcpStream::connect(
|
||||
("127.0.0.1", port),
|
||||
)
|
||||
.await else { return };
|
||||
let _ = tokio::io::copy_bidirectional(
|
||||
&mut inbound,
|
||||
&mut outbound,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
_ = rx.changed() => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.changed() => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll every 30s for `fips0`'s ULA; when it appears, bind the peer
|
||||
/// listener and run the normal accept loop. If the bind fails (port
|
||||
/// already taken, permissions), log and keep retrying. Returns on
|
||||
|
||||
Generated
+22
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.114-alpha",
|
||||
"version": "1.7.115-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.114-alpha",
|
||||
"version": "1.7.115-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
@@ -150,6 +150,7 @@
|
||||
"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",
|
||||
@@ -1811,6 +1812,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -1834,6 +1836,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3920,6 +3923,7 @@
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
@@ -3969,6 +3973,7 @@
|
||||
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"colorette": "^2.0.20",
|
||||
@@ -4469,6 +4474,7 @@
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -4954,6 +4960,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -5972,6 +5979,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -8164,6 +8172,7 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -8213,6 +8222,7 @@
|
||||
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.1.0",
|
||||
"data-urls": "^5.0.0",
|
||||
@@ -8315,7 +8325,8 @@
|
||||
"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"
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
@@ -9071,6 +9082,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -9732,6 +9744,7 @@
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -10987,6 +11000,7 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -11242,6 +11256,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -11483,6 +11498,7 @@
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -11645,6 +11661,7 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -11658,6 +11675,7 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -11750,6 +11768,7 @@
|
||||
"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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.114-alpha",
|
||||
"version": "1.7.115-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -362,6 +362,18 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.115-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.115-alpha</span>
|
||||
<span class="text-xs text-white/40">July 26, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.</p>
|
||||
<p>The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.</p>
|
||||
<p>Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.114-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
+16
-18
@@ -1,31 +1,29 @@
|
||||
{
|
||||
"changelog": [
|
||||
"Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the \"device detected\" popup for the port. Radios that could never connect now come up within a minute of being plugged in.",
|
||||
"The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing — each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each.",
|
||||
"Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves.",
|
||||
"Type on-chain amounts in whichever unit you think in — a sats/BTC switch on the amount field converts as you type.",
|
||||
"Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs — words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets."
|
||||
"The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.",
|
||||
"The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.",
|
||||
"Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.114-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.114-alpha/archipelago",
|
||||
"current_version": "1.7.115-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.115-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.114-alpha",
|
||||
"sha256": "524039b6517c6e506f2d7933051349310704f3babd490d8125ced9ba24a0b0fc",
|
||||
"size_bytes": 51705848
|
||||
"new_version": "1.7.115-alpha",
|
||||
"sha256": "d4e2d7cb65bf7ac6461eb11642fb8ff9254be636c173a016ad1834d4073a7cee",
|
||||
"size_bytes": 51768592
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.114-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.114-alpha/archipelago-frontend-1.7.114-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.114-alpha.tar.gz",
|
||||
"new_version": "1.7.114-alpha",
|
||||
"sha256": "fc48209cdf18eaf935e589b2779c46f392e925c93c56ccd085437509c13d65fd",
|
||||
"size_bytes": 177972573
|
||||
"current_version": "1.7.115-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.115-alpha/archipelago-frontend-1.7.115-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.115-alpha.tar.gz",
|
||||
"new_version": "1.7.115-alpha",
|
||||
"sha256": "9c131c881016a4e451e2ea3688bc9114302adcb5a2b58e485f3273abcebf07bb",
|
||||
"size_bytes": 178060942
|
||||
}
|
||||
],
|
||||
"release_date": "2026-07-26",
|
||||
"signature": "25ca2cf334da4454cc0ad04165d8fc683a81527a33286bd7253c34a965a791204467fd7e9dba2e6e749469bce699867f7f99fea22d3278d88621ff6aa04ed109",
|
||||
"signature": "80a76615a43e4b715b564143ffc7acf4e3897a921c0b6338c757963becfc6c979c118a7f32554c82ff32de283a4a0c0a566f27c98d4ffd7b6d93747a1f3ea103",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.114-alpha"
|
||||
"version": "1.7.115-alpha"
|
||||
}
|
||||
|
||||
+16
-18
@@ -1,31 +1,29 @@
|
||||
{
|
||||
"changelog": [
|
||||
"Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the \"device detected\" popup for the port. Radios that could never connect now come up within a minute of being plugged in.",
|
||||
"The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing — each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each.",
|
||||
"Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves.",
|
||||
"Type on-chain amounts in whichever unit you think in — a sats/BTC switch on the amount field converts as you type.",
|
||||
"Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs — words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets."
|
||||
"The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.",
|
||||
"The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.",
|
||||
"Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.114-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.114-alpha/archipelago",
|
||||
"current_version": "1.7.115-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.115-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.114-alpha",
|
||||
"sha256": "524039b6517c6e506f2d7933051349310704f3babd490d8125ced9ba24a0b0fc",
|
||||
"size_bytes": 51705848
|
||||
"new_version": "1.7.115-alpha",
|
||||
"sha256": "d4e2d7cb65bf7ac6461eb11642fb8ff9254be636c173a016ad1834d4073a7cee",
|
||||
"size_bytes": 51768592
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.114-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.114-alpha/archipelago-frontend-1.7.114-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.114-alpha.tar.gz",
|
||||
"new_version": "1.7.114-alpha",
|
||||
"sha256": "fc48209cdf18eaf935e589b2779c46f392e925c93c56ccd085437509c13d65fd",
|
||||
"size_bytes": 177972573
|
||||
"current_version": "1.7.115-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.115-alpha/archipelago-frontend-1.7.115-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.115-alpha.tar.gz",
|
||||
"new_version": "1.7.115-alpha",
|
||||
"sha256": "9c131c881016a4e451e2ea3688bc9114302adcb5a2b58e485f3273abcebf07bb",
|
||||
"size_bytes": 178060942
|
||||
}
|
||||
],
|
||||
"release_date": "2026-07-26",
|
||||
"signature": "25ca2cf334da4454cc0ad04165d8fc683a81527a33286bd7253c34a965a791204467fd7e9dba2e6e749469bce699867f7f99fea22d3278d88621ff6aa04ed109",
|
||||
"signature": "80a76615a43e4b715b564143ffc7acf4e3897a921c0b6338c757963becfc6c979c118a7f32554c82ff32de283a4a0c0a566f27c98d4ffd7b6d93747a1f3ea103",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.114-alpha"
|
||||
"version": "1.7.115-alpha"
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
|
||||
# SIGPIPE-safe: use awk to read only the first line and exit,
|
||||
# then terminate the tar pipeline explicitly so `pipefail`+SIGPIPE
|
||||
# don't kill the whole `set -euo pipefail` script.
|
||||
root_mode=$(tar tvzf "$FRONTEND_ARCHIVE" 2>/dev/null | awk 'NR==1{print $1; exit}')
|
||||
root_mode=$({ tar tvzf "$FRONTEND_ARCHIVE" 2>/dev/null || true; } | awk 'NR==1{print $1; exit}')
|
||||
case "$root_mode" in
|
||||
drwxr-xr-x|drwxr-x*x*)
|
||||
echo " Tarball root perms OK: $root_mode"
|
||||
|
||||
@@ -141,6 +141,32 @@ def render_app_session_config(manifests: dict[str, dict[str, Any]]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_rust_ports(ports: dict[str, int], extra_ports: list[int]) -> str:
|
||||
"""Rust constant of catalog launch ports for the fips0 firewall drop-in
|
||||
(core/archipelago/src/fips/app_ports.rs). Extra ports cover the frontend's
|
||||
APP_PORTS overrides (companions/aliases) that have no manifest of their own.
|
||||
"""
|
||||
distinct = sorted(set(list(ports.values()) + extra_ports))
|
||||
lines = [
|
||||
"//! Generated by scripts/generate-app-catalog.py. Do not edit manually.",
|
||||
"//!",
|
||||
"//! Catalog app launch ports (the web UIs the companion opens by direct",
|
||||
"//! port). Used to write the fips0 firewall allowance drop-in so app UIs",
|
||||
"//! are reachable over the mesh; ports of apps that aren\'t installed have",
|
||||
"//! no listener, so allowing them is inert.",
|
||||
"",
|
||||
"pub const APP_LAUNCH_PORTS: &[u16] = &[",
|
||||
]
|
||||
lines.extend(f" {port}," for port in distinct)
|
||||
lines.extend(["];", ""])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# Keep in lockstep with APP_PORTS overrides in
|
||||
# neode-ui/src/views/appSession/appSessionConfig.ts.
|
||||
RUST_EXTRA_PORTS = [8334, 50002, 18083, 11434, 8081, 8240, 8175, 8176, 8080]
|
||||
|
||||
|
||||
def sync_catalog(path: Path, manifests: dict[str, dict[str, Any]]) -> int:
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
catalog = json.load(fh)
|
||||
@@ -178,6 +204,11 @@ def main() -> int:
|
||||
default=[],
|
||||
help="Catalog JSON path to update. May be passed multiple times.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rust-app-ports",
|
||||
default="core/archipelago/src/fips/app_ports.rs",
|
||||
help="Generated Rust launch-port list for the fips0 firewall drop-in. Empty string to skip.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--app-session-config",
|
||||
default="neode-ui/src/views/appSession/generatedAppSessionConfig.ts",
|
||||
@@ -201,6 +232,20 @@ def main() -> int:
|
||||
print(f"{path}: updated")
|
||||
else:
|
||||
print(f"{path}: updated 0 fields")
|
||||
if args.rust_app_ports:
|
||||
ports = {
|
||||
app_id: port
|
||||
for app_id, app in manifests.items()
|
||||
if (port := manifest_launch_port(app))
|
||||
}
|
||||
rust_path = Path(args.rust_app_ports)
|
||||
rust_content = render_rust_ports(ports, RUST_EXTRA_PORTS)
|
||||
rust_old = rust_path.read_text(encoding="utf-8") if rust_path.exists() else ""
|
||||
if rust_old != rust_content:
|
||||
rust_path.write_text(rust_content, encoding="utf-8")
|
||||
print(f"{rust_path}: updated")
|
||||
else:
|
||||
print(f"{rust_path}: updated 0 fields")
|
||||
print(f"total_updated={total}")
|
||||
return 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user