feat(release): stage GitWorkshop and next node updates
This commit is contained in:
@@ -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>;
|
||||
|
||||
@@ -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\" =>"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user