feat(release): stage GitWorkshop and next node updates

This commit is contained in:
archipelago
2026-09-09 18:15:21 -04:00
parent 973356df16
commit f5c0ba85cd
97 changed files with 5716 additions and 1327 deletions
+12 -1
View File
@@ -64,6 +64,11 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"must be",
"cannot",
"Password",
// auth.changePassword verifies the existing node password before it
// writes either the web hash or the optional Linux/SSH password. This
// is safe, actionable validation text; masking it as an internal
// failure sent operators to the server logs for a simple typo.
"Current password is incorrect",
// OTA apply/download errors are all operator-actionable ("download it
// again", "download first") — sanitizing them to "Operation failed"
// left users stuck with no idea what to do, and hid the "already
@@ -242,6 +247,12 @@ mod sanitize_tests {
assert_eq!(sanitize_error_message(msg), msg);
}
#[test]
fn change_password_rejection_reaches_the_operator() {
let msg = "Current password is incorrect";
assert_eq!(sanitize_error_message(msg), msg);
}
#[test]
fn tor_unavailable_precondition_passes_through() {
let msg = "Tor address not available. Tor may not be running.";
@@ -306,7 +317,7 @@ mod sanitize_tests {
/// Deterministic: same session token always produces the same CSRF token.
/// Survives backend restarts because it depends only on the session token
/// and the on-disk remember secret (not ephemeral state).
pub(super) async fn derive_csrf_token(session_token: &str) -> String {
pub(crate) async fn derive_csrf_token(session_token: &str) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
+118 -19
View File
@@ -34,6 +34,7 @@ mod nostr;
mod onboarding_gate;
mod openwrt;
mod package;
pub(crate) use package::patch_indeedhub_nostr_provider;
pub(crate) use package::wyoming_satellite_keeper;
mod peers;
mod pine_status;
@@ -71,12 +72,53 @@ pub use middleware::PeerAddr;
// never added to it — the Phase-10 hard constraint this crate must hold.
// The list's *contents* are unchanged; only its read-visibility widens from
// "this module" to "this crate".
pub(crate) use middleware::UNAUTHENTICATED_METHODS;
use middleware::{
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message, CACHEABLE_METHODS,
};
pub(crate) use middleware::{derive_csrf_token, UNAUTHENTICATED_METHODS};
use middleware::{extract_client_ip, extract_cookie, sanitize_error_message, CACHEABLE_METHODS};
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};
/// Browser apps run on dedicated high ports and can share the authenticated
/// node cookie. Nostr signing must therefore be callable by the dashboard
/// bridge (ports 80/443), not directly by an iframe that could bypass its
/// consent dialog. Requests without Origin remain available to authenticated
/// local CLI/integration clients. Development permits loopback origins.
fn nostr_signing_origin_allowed(headers: &hyper::HeaderMap, dev_mode: bool) -> bool {
let Some(origin) = headers.get("origin").and_then(|value| value.to_str().ok()) else {
return true;
};
let Ok(url) = reqwest::Url::parse(origin) else {
return false;
};
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
return false;
}
if dev_mode && matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1")) {
return true;
}
matches!(url.port_or_known_default(), Some(80 | 443))
}
/// Read-only authenticated methods may skip CSRF, but they must still exist in
/// the dispatcher. The tab signer uses `system.get-hostname` as its lightweight
/// session probe, so keeping the policy in one testable function protects that
/// cross-origin app-gate bootstrap contract.
fn csrf_exempt_method(method: &str) -> bool {
matches!(
method,
"node-messages-received"
| "server.echo"
| "server.get-state"
| "system.stats"
| "tor.status"
| "tor.onion-addresses"
| "bitcoin.relay-status"
| "federation.list-nodes"
| "system.get-settings"
| "system.get-node-key"
| "system.get-metrics"
| "system.get-hostname"
)
}
/// Default dev password when no user is set up (matches mock-backend).
/// Dev builds only — the pre-setup login bypass that reads this is
/// cfg-gated out of release binaries.
@@ -291,6 +333,18 @@ impl RpcHandler {
debug!("RPC method: {}", rpc_req.method);
if matches!(
rpc_req.method.as_str(),
"node.nostr-sign" | "identity.nostr-sign"
) && !nostr_signing_origin_allowed(&parts.headers, self.config.dev_mode)
{
return Ok(self.error_response(
403,
"Nostr signing from app origins requires the dashboard consent bridge",
StatusCode::FORBIDDEN,
));
}
// Enforce authentication for non-allowlisted methods
let is_unauthenticated = UNAUTHENTICATED_METHODS.contains(&rpc_req.method.as_str());
let mut new_session_cookies: Option<(String, String)> = None;
@@ -340,21 +394,7 @@ impl RpcHandler {
// CSRF protection: validate X-CSRF-Token header via HMAC derivation from session token.
// Skip CSRF for read-only methods (polling, status) — CSRF prevents state-changing forgery.
// Skip when session was just auto-restored from remember-me (browser has stale CSRF cookie).
let csrf_exempt = matches!(
rpc_req.method.as_str(),
"node-messages-received"
| "server.echo"
| "server.get-state"
| "system.stats"
| "tor.status"
| "tor.onion-addresses"
| "bitcoin.relay-status"
| "federation.list-nodes"
| "system.get-settings"
| "system.get-node-key"
| "system.get-metrics"
| "system.get-version"
);
let csrf_exempt = csrf_exempt_method(&rpc_req.method);
if !is_unauthenticated && new_session_cookies.is_none() && !csrf_exempt {
let csrf_header = parts
.headers
@@ -735,3 +775,62 @@ impl RpcHandler {
);
}
}
#[cfg(test)]
mod nostr_signing_origin_tests {
use super::*;
use hyper::header::{HeaderMap, HeaderValue, ORIGIN};
fn headers(origin: Option<&str>) -> HeaderMap {
let mut headers = HeaderMap::new();
if let Some(origin) = origin {
headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
}
headers
}
#[test]
fn signing_accepts_dashboard_and_authenticated_non_browser_clients() {
assert!(nostr_signing_origin_allowed(&headers(None), false));
assert!(nostr_signing_origin_allowed(
&headers(Some("https://node.local")),
false
));
assert!(nostr_signing_origin_allowed(
&headers(Some("http://192.0.2.10")),
false
));
}
#[test]
fn signing_rejects_app_ports_but_allows_loopback_dev_server() {
assert!(!nostr_signing_origin_allowed(
&headers(Some("https://node.local:8337")),
false
));
assert!(!nostr_signing_origin_allowed(
&headers(Some("https://node.local:7778")),
false
));
assert!(nostr_signing_origin_allowed(
&headers(Some("http://localhost:5173")),
true
));
}
}
#[cfg(test)]
mod session_probe_contract_tests {
use super::*;
#[test]
fn signer_session_probe_is_implemented_authenticated_and_read_only() {
const PROBE: &str = "system.get-hostname";
const DISPATCHER: &str = include_str!("dispatcher.rs");
assert!(csrf_exempt_method(PROBE));
assert!(!UNAUTHENTICATED_METHODS.contains(&PROBE));
assert!(DISPATCHER.contains("\"system.get-hostname\" =>"));
assert!(!DISPATCHER.contains("\"system.get-version\" =>"));
}
}
+181 -195
View File
@@ -74,110 +74,178 @@ async fn local_podman_image_exists(image: &str) -> Result<bool> {
}
}
pub(super) async fn patch_indeedhub_nostr_provider() {
fn patched_indeedhub_nginx_config(original: &str) -> String {
let mut conf = original
.lines()
.filter(|line| !line.contains("X-Frame-Options"))
.collect::<Vec<_>>()
.join("\n");
conf.push('\n');
if !conf.contains("location = /nostr-provider.js {") {
conf = conf.replace(
"location = /sw.js {",
"location = /nostr-provider.js {\n\
add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\
expires off;\n\
}\n\n\
location = /sw.js {",
);
}
if conf.contains("try_files") && !conf.contains("sub_filter") {
conf = conf.replacen(
"try_files $uri $uri/ /index.html;",
"try_files $uri $uri/ /index.html;\n\
sub_filter_once on;\n\
sub_filter '</head>' '<script src=\"/nostr-provider.js?v=tab-signer-v4\"></script></head>';",
1,
);
}
conf = conf.replace(
"src=\"/nostr-provider.js\"",
"src=\"/nostr-provider.js?v=tab-signer-v4\"",
);
conf = conf.replace("tab-signer-v2", "tab-signer-v4");
conf = conf.replace("tab-signer-v3", "tab-signer-v4");
conf.replace(
"proxy_set_header X-Forwarded-Prefix /api;",
"proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;",
)
}
pub(crate) async fn patch_indeedhub_nostr_provider() {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"sed",
"-i",
"/X-Frame-Options/d",
"/etc/nginx/conf.d/default.conf",
])
// Frontend assets can change during a dashboard-only OTA while the
// IndeedHub container keeps running. Reconcile the injected provider on
// daemon startup as well as app install/start, but stay quiet when the app
// is not installed or is intentionally stopped.
let running = tokio::process::Command::new("podman")
.args(["inspect", "-f", "{{.State.Running}}", "indeedhub"])
.output()
.await;
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
if tokio::fs::metadata(provider_src).await.is_ok() {
let _ = tokio::process::Command::new("podman")
.args([
"cp",
provider_src,
"indeedhub:/usr/share/nginx/html/nostr-provider.js",
])
.output()
.await;
.await
.map(|out| out.status.success() && String::from_utf8_lossy(&out.stdout).trim() == "true")
.unwrap_or(false);
if !running {
return;
}
let check = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"grep",
"-q",
"nostr-provider",
"/etc/nginx/conf.d/default.conf",
])
// `podman exec` cannot always join a rootless container's delegated cgroup
// from the system service, while Podman 5's copier refuses to overwrite an
// existing regular file. Mount the rootless storage namespace instead;
// this replaces both files without entering the container's cgroup.
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let tmp_dir = format!("/tmp/indeedhub-nginx-patch-{}-{unique}", std::process::id());
let tmp_path = format!("{tmp_dir}/default.conf");
if tokio::fs::create_dir(&tmp_dir).await.is_err() {
tracing::warn!("IndeeHub signer reconciliation could not create its temporary directory");
return;
}
let mount_out = tokio::process::Command::new("podman")
.args(["unshare", "podman", "mount", "indeedhub"])
.output()
.await;
let already_patched = check.map(|o| o.status.success()).unwrap_or(false);
let container_root = mount_out
.ok()
.filter(|out| out.status.success())
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
.filter(|path| {
std::path::Path::new(path).is_absolute()
&& path.contains("/containers/storage/overlay/")
&& path.ends_with("/merged")
});
let Some(container_root) = container_root else {
let _ = tokio::fs::remove_dir(&tmp_dir).await;
tracing::warn!("IndeeHub signer reconciliation could not mount rootless storage");
return;
};
if !already_patched {
let cat_out = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "cat", "/etc/nginx/conf.d/default.conf"])
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
let provider_dest = format!("{container_root}/usr/share/nginx/html/nostr-provider.js");
let provider_copied = tokio::fs::metadata(provider_src).await.is_ok()
&& tokio::process::Command::new("podman")
.args([
"unshare",
"install",
"-m",
"644",
provider_src,
&provider_dest,
])
.output()
.await;
.await
.map(|out| out.status.success())
.unwrap_or(false);
if let Ok(out) = cat_out {
if out.status.success() {
let conf = String::from_utf8_lossy(&out.stdout).to_string();
let conf = conf.replace(
"location = /sw.js {",
"location = /nostr-provider.js {\n\
add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\
expires off;\n\
}\n\n\
location = /sw.js {",
);
let conf = if conf.contains("try_files") && !conf.contains("sub_filter") {
conf.replacen(
"try_files $uri $uri/ /index.html;",
"try_files $uri $uri/ /index.html;\n\
sub_filter_once on;\n\
sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';",
1,
)
} else {
conf
};
let copy_out = tokio::process::Command::new("podman")
.args(["cp", "indeedhub:/etc/nginx/conf.d/default.conf", &tmp_path])
.output()
.await;
let tmp_path = "/tmp/indeedhub-nginx-patch.conf";
if tokio::fs::write(tmp_path, &conf).await.is_ok() {
let _ = tokio::process::Command::new("podman")
.args(["cp", tmp_path, "indeedhub:/etc/nginx/conf.d/default.conf"])
let mut config_copied = false;
if let Ok(out) = copy_out {
if out.status.success() {
if let Ok(original) = tokio::fs::read_to_string(&tmp_path).await {
let conf = patched_indeedhub_nginx_config(&original);
if conf != original && tokio::fs::write(&tmp_path, &conf).await.is_ok() {
config_copied = tokio::process::Command::new("podman")
.args([
"unshare",
"install",
"-m",
"644",
&tmp_path,
&format!("{container_root}/etc/nginx/conf.d/default.conf"),
])
.output()
.await;
let _ = tokio::fs::remove_file(tmp_path).await;
.await
.map(|out| out.status.success())
.unwrap_or(false);
if config_copied {
let _ = tokio::fs::remove_file(&tmp_path).await;
config_copied = tokio::process::Command::new("podman")
.args(["cp", "indeedhub:/etc/nginx/conf.d/default.conf", &tmp_path])
.output()
.await
.map(|out| out.status.success())
.unwrap_or(false)
&& tokio::fs::read_to_string(&tmp_path)
.await
.map(|actual| actual == conf)
.unwrap_or(false);
}
} else if conf == original
&& conf.contains("location = /nostr-provider.js {")
&& conf.contains("src=\"/nostr-provider.js?v=tab-signer-v4\"")
{
config_copied = true;
}
}
}
}
let _ = tokio::fs::remove_file(&tmp_path).await;
let _ = tokio::fs::remove_dir(&tmp_dir).await;
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"sed",
"-i",
"s|proxy_set_header X-Forwarded-Prefix /api;|proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;|",
"/etc/nginx/conf.d/default.conf",
])
.args(["unshare", "podman", "unmount", "indeedhub"])
.output()
.await;
let reload = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "nginx", "-s", "reload"])
.args(["kill", "--signal", "HUP", "indeedhub"])
.output()
.await;
match reload {
Ok(o) if o.status.success() => {
Ok(o) if o.status.success() && provider_copied && config_copied => {
info!("IndeeHub: NIP-07 provider injected, nginx patched and reloaded");
}
Ok(o) => {
tracing::warn!(
"IndeeHub nginx reload failed: {}",
"IndeeHub signer reconciliation incomplete (provider_copied={}, config_copied={}): {}",
provider_copied,
config_copied,
String::from_utf8_lossy(&o.stderr)
);
}
@@ -1620,124 +1688,10 @@ autopilot.active=false\n",
}
}
// IndeeHub: inject nostr-provider.js and patch container nginx for NIP-07 signing
// IndeeHub: inject the current consent-gated provider and make it work
// in both the dashboard frame and a direct browser tab.
if package_id == "indeedhub" {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
// 1. Remove X-Frame-Options so iframe embedding works
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"sed",
"-i",
"/X-Frame-Options/d",
"/etc/nginx/conf.d/default.conf",
])
.output()
.await;
// 2. Copy nostr-provider.js into container
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
if tokio::fs::metadata(provider_src).await.is_ok() {
let _ = tokio::process::Command::new("podman")
.args([
"cp",
provider_src,
"indeedhub:/usr/share/nginx/html/nostr-provider.js",
])
.output()
.await;
}
// 3. Add nostr-provider.js location block + sub_filter injection
let check = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"grep",
"-q",
"nostr-provider",
"/etc/nginx/conf.d/default.conf",
])
.output()
.await;
let already_patched = check.map(|o| o.status.success()).unwrap_or(false);
if !already_patched {
// Read current nginx config from container
let cat_out = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "cat", "/etc/nginx/conf.d/default.conf"])
.output()
.await;
if let Ok(out) = cat_out {
if out.status.success() {
let conf = String::from_utf8_lossy(&out.stdout).to_string();
// Insert provider location block before the sw.js location
let conf = conf.replace(
"location = /sw.js {",
"location = /nostr-provider.js {\n\
\x20 add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\
\x20 expires off;\n\
\x20 }\n\n\
\x20 location = /sw.js {"
);
// Inject script tag into HTML via sub_filter
let conf = if conf.contains("try_files") && !conf.contains("sub_filter") {
conf.replacen(
"try_files $uri $uri/ /index.html;",
"try_files $uri $uri/ /index.html;\n\
\x20 sub_filter_once on;\n\
\x20 sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';",
1,
)
} else {
conf
};
// Write patched config back into container
let tmp_path = "/tmp/indeedhub-nginx-patch.conf";
if tokio::fs::write(tmp_path, &conf).await.is_ok() {
let _ = tokio::process::Command::new("podman")
.args(["cp", tmp_path, "indeedhub:/etc/nginx/conf.d/default.conf"])
.output()
.await;
let _ = tokio::fs::remove_file(tmp_path).await;
}
}
}
}
// 4. Fix X-Forwarded-Prefix for NIP-98 URL reconstruction in iframe context
let _ = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "sed", "-i",
"s|proxy_set_header X-Forwarded-Prefix /api;|proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;|",
"/etc/nginx/conf.d/default.conf"])
.output()
.await;
// 5. Reload nginx to apply changes
let reload = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "nginx", "-s", "reload"])
.output()
.await;
match reload {
Ok(o) if o.status.success() => {
info!("IndeeHub: NIP-07 provider injected, nginx patched and reloaded");
}
Ok(o) => {
tracing::warn!(
"IndeeHub nginx reload failed: {}",
String::from_utf8_lossy(&o.stderr)
);
}
Err(e) => {
tracing::warn!("IndeeHub nginx reload error: {}", e);
}
}
patch_indeedhub_nostr_provider().await;
}
// Gitea: keep it on its native host port (3001). The UI opens Gitea
@@ -2800,11 +2754,43 @@ fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
#[cfg(test)]
mod tests {
use super::{
orchestrator_install_app_id, parse_setup_token, should_try_orchestrator_install,
uses_orchestrator_install_flow,
orchestrator_install_app_id, parse_setup_token, patched_indeedhub_nginx_config,
should_try_orchestrator_install, uses_orchestrator_install_flow,
};
use crate::api::rpc::package::runtime::orchestrator_uninstall_app_ids;
#[test]
fn indeedhub_nginx_patch_is_complete_and_idempotent() {
let original = r#"server {
add_header X-Frame-Options SAMEORIGIN;
location = /sw.js {
expires off;
}
location /api/ {
proxy_set_header X-Forwarded-Prefix /api;
}
location / {
try_files $uri $uri/ /index.html;
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
}
"#;
let patched = patched_indeedhub_nginx_config(original);
assert!(!patched.contains("X-Frame-Options"));
assert!(patched.contains("location = /nostr-provider.js {"));
assert!(patched.contains("Cache-Control \"no-cache, no-store, must-revalidate\""));
assert!(patched.contains("src=\"/nostr-provider.js?v=tab-signer-v4\""));
assert!(patched.contains("X-Forwarded-Prefix $http_x_forwarded_prefix/api"));
assert_eq!(patched_indeedhub_nginx_config(&patched), patched);
let previous_broker = patched.replace("tab-signer-v4", "tab-signer-v3");
let migrated = patched_indeedhub_nginx_config(&previous_broker);
assert!(migrated.contains("tab-signer-v4"));
assert!(!migrated.contains("tab-signer-v3"));
assert_eq!(patched_indeedhub_nginx_config(&migrated), migrated);
}
#[test]
fn orchestrator_install_allowlist_includes_ported_backends() {
for app in [
@@ -4,6 +4,7 @@ mod dependencies;
mod install;
mod lifecycle;
mod pine_ha;
pub(crate) use install::patch_indeedhub_nostr_provider;
pub(crate) use pine_ha::wyoming_satellite_keeper;
mod progress;
mod runtime;
+3
View File
@@ -442,6 +442,9 @@ impl RpcHandler {
"claimed_count": outcome.claimed_count,
"received_sats": outcome.received_sats,
"failed_count": outcome.failed_count,
"receipt_id": outcome.receipt_id,
"receipt_sats": outcome.receipt_sats,
"receipt_at": outcome.receipt_at,
}))
}