Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcc18b392f | ||
|
|
597489aa3c | ||
|
|
2d19d03e16 | ||
|
|
ffba074e3b | ||
|
|
9123121918 | ||
|
|
70b59e4341 | ||
|
|
2b173bb2e5 | ||
|
|
2428f34614 | ||
|
|
7f0318c68a | ||
|
|
733c4b1d90 | ||
|
|
a242221641 | ||
|
|
384fb7f881 | ||
|
|
f00e2c1033 | ||
|
|
c970ff3bc1 | ||
|
|
019a2fa35c | ||
|
|
ec76512ae7 | ||
|
|
0d16c53ccb | ||
|
|
d281171124 | ||
|
|
dfc4c659a6 | ||
|
|
1102566ce4 | ||
|
|
ef8c2c30dc | ||
|
|
f6e84fdcb4 | ||
|
|
a4df61000c | ||
|
|
2dc6121eea | ||
|
|
658a8922d8 | ||
|
|
23c88da4c4 | ||
|
|
71de1a3c27 | ||
|
|
bbf8dc76c4 | ||
|
|
9d5e73455a | ||
|
|
5f01efda61 | ||
|
|
54f1ec8322 | ||
|
|
fc54525092 | ||
|
|
e52c140069 | ||
|
|
1f24000226 | ||
|
|
b50fc5627c | ||
|
|
a42c24abdf | ||
|
|
5b0b35401b | ||
|
|
e1506a07b2 | ||
|
|
a5d4781ef1 | ||
|
|
8d9f4c9ff0 | ||
|
|
7becb21376 | ||
|
|
711db6b711 | ||
|
|
4b65879b6c | ||
|
|
56cdfcafcc | ||
|
|
3667105b25 | ||
|
|
bb79612772 | ||
|
|
8a7cfffd46 | ||
|
|
02671438d8 | ||
|
|
a5637d9350 | ||
|
|
19540c0578 | ||
|
|
52fb27fd43 | ||
|
|
78e8b1d44a | ||
|
|
ab6601db06 | ||
|
|
ecac80dfcb | ||
|
|
0fb8ee7e5e | ||
|
|
4ca0772db3 | ||
|
|
83feb18063 | ||
|
|
1d6ac1a6c5 | ||
|
|
96b6ff9972 | ||
|
|
424070d215 | ||
|
|
dd909040f8 | ||
|
|
5ba52f1d21 | ||
|
|
8d5410566f | ||
|
|
47f8aff3f9 | ||
|
|
cfde1343a4 | ||
|
|
daa4b55b4b | ||
|
|
846b316d72 | ||
|
|
e2d926f5e0 | ||
|
|
03ee3c0162 | ||
|
|
088eee55b8 | ||
|
|
48c13adaca | ||
|
|
c6f8308e9a | ||
|
|
76141ffd2f | ||
|
|
e3d2f0b827 | ||
|
|
c5cec95108 | ||
|
|
5ca345162d | ||
|
|
963dd03f83 | ||
|
|
cc35b06ce6 | ||
|
|
3ca567c04e | ||
|
|
7fde60126a | ||
|
|
f7377b0ac8 | ||
|
|
9d1970a37d | ||
|
|
2c7ba923cf | ||
|
|
f4cb5e2c74 | ||
|
|
7c23a331ee | ||
|
|
ced5342111 | ||
|
|
d4d6a655e7 | ||
|
|
70cd80c3e8 | ||
|
|
6dcaa41040 | ||
|
|
057ac1c68b | ||
|
|
1291ea1508 | ||
|
|
6b97505a59 | ||
|
|
6d19cf27c3 | ||
|
|
ca47c9fcb9 | ||
|
|
df15a9c890 | ||
|
|
9936bda9ae | ||
|
|
f9bfceb80e | ||
|
|
a6725c1b69 | ||
|
|
5d64991203 | ||
|
|
cb6be9edb5 | ||
|
|
5812f53c7c | ||
|
|
5e6260864e |
@@ -443,15 +443,23 @@ private fun injectTopInset(view: WebView) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
function apply() {
|
function apply() {
|
||||||
|
// IndeeHub's browse hero is an image, not a body background.
|
||||||
|
// Padding the body puts a solid strip above the image; let the
|
||||||
|
// hero start at the display edge while push() keeps its fixed
|
||||||
|
// header below the status icons. Other routes keep the normal
|
||||||
|
// content inset.
|
||||||
|
var imageHero = location.port === '7778' &&
|
||||||
|
(location.pathname === '/' || location.pathname === '/browse') &&
|
||||||
|
!!document.querySelector('section img');
|
||||||
var bodyBg = document.body
|
var bodyBg = document.body
|
||||||
? getComputedStyle(document.body).backgroundColor : '';
|
? getComputedStyle(document.body).backgroundColor : '';
|
||||||
var eff = effectiveTopBg();
|
var eff = effectiveTopBg();
|
||||||
// Repaint body only when its declared background is not what
|
// Repaint body only when its declared background is not what
|
||||||
// the page actually renders at the top (or paints nothing).
|
// the page actually renders at the top (or paints nothing).
|
||||||
var fix = eff && eff !== bodyBg
|
var fix = !imageHero && eff && eff !== bodyBg
|
||||||
? 'background-color:' + eff + ' !important;' : '';
|
? 'background-color:' + eff + ' !important;' : '';
|
||||||
styleEl().textContent =
|
styleEl().textContent =
|
||||||
'body{padding-top:' + SAT + 'px !important;' +
|
'body{padding-top:' + (imageHero ? 0 : SAT) + 'px !important;' +
|
||||||
'box-sizing:border-box !important;' + fix + '}';
|
'box-sizing:border-box !important;' + fix + '}';
|
||||||
}
|
}
|
||||||
function push(el) {
|
function push(el) {
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.9.2-alpha (2026-10-10)
|
||||||
|
|
||||||
|
- Ship the Archipelago companion 0.5.37 (build 57) APK so the downloadable app matches the Android source version and its local playback diagnostics.
|
||||||
|
- Build the installer ISO with a release gate that checks the bundled companion APK version against the Android source version.
|
||||||
|
- Carries all v1.9.1-alpha changes and known limitations unchanged; v1.9.1-alpha shipped the 0.5.36 companion APK and has no installer ISO.
|
||||||
|
|
||||||
|
## v1.9.1-alpha (2026-10-10)
|
||||||
|
|
||||||
|
- Add an owner terminal with xterm rendering, preserved scrollback, focused keyboard handling and Codex skills preinstalled; restrict it to the dashboard origin.
|
||||||
|
- Prevent monitoring alerts from lingering after the condition recovers.
|
||||||
|
- Show free disk capacity ("X free / Y total", TB-aware) from live data on Home, Monitoring and Server instead of a stale cached snapshot.
|
||||||
|
- Allow explicit renewal of expired IndeeHub rentals, and keep the IndeeHub provider injection idempotent when the app already bundles it.
|
||||||
|
- Make IndeeHub native login, registration and media registration reliable on managed stacks; route the IndeeHub catalog and free playback across nodes through FIPS.
|
||||||
|
- Add a private phone setup goal, shared hardware listings, GrapheneOS Pixel artwork and Archipelago router NIP-99 listings.
|
||||||
|
- Open the file-browser goal in the native Cloud view.
|
||||||
|
- Keep isolated backend tests hermetic and add an ARM proposal test lane.
|
||||||
|
|
||||||
|
Known limitations (open acceptance gates at publication):
|
||||||
|
|
||||||
|
- Lightning rental on IndeeHub is not accepted: BTCPay is unconfigured on the IndeeHub API.
|
||||||
|
- Metered ecash playback and the Shorty signed-in operator acceptance are not yet verified.
|
||||||
|
- Remaining items in the post-1.8.22 regression checklist stay open.
|
||||||
|
- Pre-existing historical mirror drift between ngit and Gitea is inventoried, not resolved.
|
||||||
|
|
||||||
## v1.9.0-alpha (2026-10-05)
|
## v1.9.0-alpha (2026-10-05)
|
||||||
|
|
||||||
Unpublished release candidate; qualification is still in progress.
|
Unpublished release candidate; qualification is still in progress.
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.9.0-alpha"
|
version = "1.9.1-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.9.0-alpha"
|
version = "1.9.1-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ use futures_util::{SinkExt, StreamExt};
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
const CDP_HTTP: &str = "http://127.0.0.1:9222";
|
const CDP_HTTP: &str = "http://127.0.0.1:9222";
|
||||||
/// Marker whose presence means this node drives a local kiosk display.
|
/// Marker whose presence means this node drives a local kiosk display.
|
||||||
|
|||||||
@@ -583,15 +583,17 @@ impl ApiHandler {
|
|||||||
paid = true;
|
paid = true;
|
||||||
}
|
}
|
||||||
Ok(false) => {}
|
Ok(false) => {}
|
||||||
Err(_) => return Ok(build_response(
|
Err(_) => {
|
||||||
StatusCode::OK,
|
return Ok(build_response(
|
||||||
"application/json",
|
StatusCode::OK,
|
||||||
hyper::Body::from(serde_json::to_vec(&serde_json::json!({
|
"application/json",
|
||||||
"paid": false,
|
hyper::Body::from(serde_json::to_vec(&serde_json::json!({
|
||||||
"status": "unknown",
|
"paid": false,
|
||||||
"error": "Exact on-chain outputs could not be verified. Keep the original payment address and do not pay again."
|
"status": "unknown",
|
||||||
}))?),
|
"error": "Exact on-chain outputs could not be verified. Keep the original payment address and do not pay again."
|
||||||
)),
|
}))?),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let body = serde_json::json!({ "paid": paid });
|
let body = serde_json::json!({ "paid": paid });
|
||||||
|
|||||||
@@ -193,16 +193,6 @@ impl ApiHandler {
|
|||||||
let data = self.config.data_dir.clone();
|
let data = self.config.data_dir.clone();
|
||||||
let id = binding.content_id.clone();
|
let id = binding.content_id.clone();
|
||||||
let retained = source.clone();
|
let retained = source.clone();
|
||||||
struct CancelCopy(std::sync::Arc<std::sync::atomic::AtomicBool>);
|
|
||||||
impl Drop for CancelCopy {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let cancel_copy = CancelCopy(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
|
|
||||||
false,
|
|
||||||
)));
|
|
||||||
let cancelled = cancel_copy.0.clone();
|
|
||||||
let snapshot = tokio::task::spawn_blocking(move || {
|
let snapshot = tokio::task::spawn_blocking(move || {
|
||||||
crate::content_snapshot::open_matching(&data, &id, &retained.sha256, retained.size)
|
crate::content_snapshot::open_matching(&data, &id, &retained.sha256, retained.size)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use crate::container::{ContainerOrchestrator, DevContainerOrchestrator};
|
|||||||
use crate::monitoring::MetricsStore;
|
use crate::monitoring::MetricsStore;
|
||||||
use crate::session::{self, SessionStore};
|
use crate::session::{self, SessionStore};
|
||||||
use crate::state::StateManager;
|
use crate::state::StateManager;
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use hyper::{Method, Request, Response, StatusCode};
|
use hyper::{Method, Request, Response, StatusCode};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -46,6 +46,43 @@ pub(super) fn build_response(
|
|||||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error")))
|
.unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn zero_sats(value: Option<&serde_json::Value>) -> bool {
|
||||||
|
match value {
|
||||||
|
Some(serde_json::Value::Number(number)) => number.as_u64() == Some(0),
|
||||||
|
Some(serde_json::Value::String(number)) => number.parse::<u64>() == Ok(0),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public playback must be explicitly free in both the mutable project row and
|
||||||
|
/// any signed rental offer. A stale project price must never override signed
|
||||||
|
/// paid terms, and malformed offers fail closed.
|
||||||
|
fn indeehub_listing_is_publicly_free(listing: &serde_json::Value) -> bool {
|
||||||
|
if !zero_sats(listing.get("rentalPrice")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(offer) = listing.get("offerEvent") else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if offer.is_null() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let Some(tags) = offer.get("tags").and_then(serde_json::Value::as_array) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(terms) = tags.iter().find_map(|tag| {
|
||||||
|
let tag = tag.as_array()?;
|
||||||
|
(tag.first()?.as_str()? == "rental")
|
||||||
|
.then(|| tag.get(1)?.as_str())
|
||||||
|
.flatten()
|
||||||
|
}) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
serde_json::from_str::<serde_json::Value>(terms)
|
||||||
|
.ok()
|
||||||
|
.is_some_and(|terms| zero_sats(terms.get("priceSats")))
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ApiHandler {
|
pub struct ApiHandler {
|
||||||
config: Config,
|
config: Config,
|
||||||
rpc_handler: Arc<RpcHandler>,
|
rpc_handler: Arc<RpcHandler>,
|
||||||
@@ -225,6 +262,339 @@ impl ApiHandler {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serve IndeeHub's sanitized public project catalog on the FIPS peer
|
||||||
|
/// listener. The app container remains on loopback; peers never receive
|
||||||
|
/// its session-gated port directly.
|
||||||
|
async fn handle_indeehub_public_catalog(&self) -> Result<Response<hyper::Body>> {
|
||||||
|
let response = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()?
|
||||||
|
.get("http://127.0.0.1:7778/api/projects/public-catalog")
|
||||||
|
.header(reqwest::header::ACCEPT, "application/json")
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
let response = match response {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "IndeeHub public catalog loopback fetch failed");
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"IndeeHub catalog unavailable"}"#),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"IndeeHub catalog unavailable"}"#),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let bytes = response.bytes().await?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
bytes.len() <= 8 * 1024 * 1024,
|
||||||
|
"IndeeHub catalog exceeds limit"
|
||||||
|
);
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_slice(&bytes).context("IndeeHub catalog is not valid JSON")?;
|
||||||
|
anyhow::ensure!(value.is_array(), "IndeeHub catalog must be an array");
|
||||||
|
Ok(build_response(
|
||||||
|
StatusCode::OK,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(bytes),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a free IndeeHub title to its local HLS manifest. IndeeHub's
|
||||||
|
/// optional-auth guard remains the authority: paid content returns 403.
|
||||||
|
async fn handle_indeehub_public_stream(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||||
|
let id = path
|
||||||
|
.strip_prefix("/api/public-indeehub-stream/")
|
||||||
|
.unwrap_or("");
|
||||||
|
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"invalid content id"}"#),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let catalog = reqwest::Client::new()
|
||||||
|
.get("http://127.0.0.1:7778/api/projects/public-catalog")
|
||||||
|
.header(reqwest::header::ACCEPT, "application/json")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
if !catalog.status().is_success() {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"IndeeHub catalog unavailable"}"#),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let listings: serde_json::Value = catalog.json().await?;
|
||||||
|
let listing = listings.as_array().and_then(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.get("id").and_then(|v| v.as_str()) == Some(id))
|
||||||
|
});
|
||||||
|
let Some(listing) = listing else {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"film is not publicly listed"}"#),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if !indeehub_listing_is_publicly_free(listing) {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::PAYMENT_REQUIRED,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"rental required"}"#),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let upstream = reqwest::Client::new()
|
||||||
|
.get(format!("http://127.0.0.1:7778/api/contents/{id}/stream"))
|
||||||
|
.header(reqwest::header::ACCEPT, "application/json")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let status = upstream.status();
|
||||||
|
let mut value: serde_json::Value = upstream
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| serde_json::json!({"error":"stream unavailable"}));
|
||||||
|
if status.is_success() {
|
||||||
|
let content_id = value.get("id").and_then(|v| v.as_str()).unwrap_or(id);
|
||||||
|
if let Some(file) = value.get("file").and_then(|v| v.as_str()) {
|
||||||
|
if let Some(storage_path) = file.strip_prefix("/storage/") {
|
||||||
|
value["file"] = serde_json::json!(format!(
|
||||||
|
"/api/public-indeehub-media/{content_id}/{storage_path}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Cache-Control", "no-store")
|
||||||
|
.header("Access-Control-Allow-Origin", "*")
|
||||||
|
.body(hyper::Body::from(serde_json::to_vec(&value)?))?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Proxy encrypted HLS manifests and segments from IndeeHub's public
|
||||||
|
/// bucket. The manifest key URI is rebound to the guarded free-key route.
|
||||||
|
async fn handle_indeehub_public_media(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||||
|
let tail = path
|
||||||
|
.strip_prefix("/api/public-indeehub-media/")
|
||||||
|
.unwrap_or("");
|
||||||
|
let Some((content_id, object)) = tail.split_once('/') else {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"text/plain",
|
||||||
|
hyper::Body::from("invalid media path"),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let safe_id = !content_id.is_empty()
|
||||||
|
&& content_id
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_hexdigit() || c == '-');
|
||||||
|
let safe_object = !object.is_empty()
|
||||||
|
&& !object.split('/').any(|part| part == "..")
|
||||||
|
&& object
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.'));
|
||||||
|
if !safe_id || !safe_object {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"text/plain",
|
||||||
|
hyper::Body::from("invalid media path"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let upstream = reqwest::Client::new()
|
||||||
|
.get(format!("http://127.0.0.1:7778/storage/{object}"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let status = upstream.status();
|
||||||
|
let content_type = upstream
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("application/octet-stream")
|
||||||
|
.to_owned();
|
||||||
|
let mut bytes = upstream.bytes().await?.to_vec();
|
||||||
|
if object.ends_with(".m3u8") && status.is_success() {
|
||||||
|
let manifest = String::from_utf8(bytes)?;
|
||||||
|
bytes = manifest
|
||||||
|
.replace(
|
||||||
|
&format!("/api/contents/{content_id}/key"),
|
||||||
|
&format!("/api/public-indeehub-key/{content_id}"),
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
}
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header("Content-Type", content_type)
|
||||||
|
.header(
|
||||||
|
"Cache-Control",
|
||||||
|
if object.ends_with(".m3u8") {
|
||||||
|
"no-store"
|
||||||
|
} else {
|
||||||
|
"public, max-age=86400"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.header("Access-Control-Allow-Origin", "*")
|
||||||
|
.body(hyper::Body::from(bytes))?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the HLS key through IndeeHub's access check. With no forwarded
|
||||||
|
/// user credentials, only genuinely free content can succeed here.
|
||||||
|
async fn handle_indeehub_public_key(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||||
|
let id = path.strip_prefix("/api/public-indeehub-key/").unwrap_or("");
|
||||||
|
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"text/plain",
|
||||||
|
hyper::Body::from("invalid content id"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let upstream = reqwest::Client::new()
|
||||||
|
.get(format!("http://127.0.0.1:7778/api/contents/{id}/key"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let status = upstream.status();
|
||||||
|
let bytes = upstream.bytes().await?;
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header("Content-Type", "application/octet-stream")
|
||||||
|
.header("Cache-Control", "no-store")
|
||||||
|
.header("Access-Control-Allow-Origin", "*")
|
||||||
|
.body(hyper::Body::from(bytes))?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_indeehub_peer(&self, onion: &str, path: &str) -> Result<reqwest::Response> {
|
||||||
|
let nodes = crate::federation::load_nodes(&self.config.data_dir)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let node = nodes
|
||||||
|
.iter()
|
||||||
|
.find(|node| node.onion == onion)
|
||||||
|
.context("IndeeHub peer is not connected")?;
|
||||||
|
let (response, _transport) =
|
||||||
|
crate::fips::dial::PeerRequest::new(node.fips_npub.as_deref(), &node.onion, path)
|
||||||
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.fips_timeout(std::time::Duration::from_secs(15))
|
||||||
|
.send_get()
|
||||||
|
.await?;
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same-origin browser bridge for a connected peer's free stream. The
|
||||||
|
/// remote node still performs IndeeHub's free/paid authorization check.
|
||||||
|
async fn handle_indeehub_peer_stream(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||||
|
let tail = path
|
||||||
|
.strip_prefix("/api/peer-indeehub-stream/")
|
||||||
|
.unwrap_or("");
|
||||||
|
let Some((onion, id)) = tail.split_once('/') else {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"invalid peer stream"}"#),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"invalid content id"}"#),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let upstream = self
|
||||||
|
.request_indeehub_peer(onion, &format!("/api/public-indeehub-stream/{id}"))
|
||||||
|
.await?;
|
||||||
|
let status = upstream.status();
|
||||||
|
let mut value: serde_json::Value = upstream
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| serde_json::json!({"error":"stream unavailable"}));
|
||||||
|
if status.is_success() {
|
||||||
|
if let Some(file) = value.get("file").and_then(|v| v.as_str()) {
|
||||||
|
if let Some(remote_path) = file.strip_prefix("/api/public-indeehub-media/") {
|
||||||
|
value["file"] = serde_json::json!(format!(
|
||||||
|
"/api/peer-indeehub-media/{onion}/{remote_path}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Cache-Control", "no-store")
|
||||||
|
.body(hyper::Body::from(serde_json::to_vec(&value)?))?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_indeehub_peer_media(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||||
|
let tail = path.strip_prefix("/api/peer-indeehub-media/").unwrap_or("");
|
||||||
|
let Some((onion, remote_path)) = tail.split_once('/') else {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"text/plain",
|
||||||
|
hyper::Body::from("invalid peer media"),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let upstream = self
|
||||||
|
.request_indeehub_peer(onion, &format!("/api/public-indeehub-media/{remote_path}"))
|
||||||
|
.await?;
|
||||||
|
let status = upstream.status();
|
||||||
|
let content_type = upstream
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or("application/octet-stream")
|
||||||
|
.to_owned();
|
||||||
|
let mut bytes = upstream.bytes().await?.to_vec();
|
||||||
|
if remote_path.ends_with(".m3u8") && status.is_success() {
|
||||||
|
let manifest = String::from_utf8(bytes)?;
|
||||||
|
bytes = manifest
|
||||||
|
.replace(
|
||||||
|
"/api/public-indeehub-key/",
|
||||||
|
&format!("/api/peer-indeehub-key/{onion}/"),
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
}
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header("Content-Type", content_type)
|
||||||
|
.header(
|
||||||
|
"Cache-Control",
|
||||||
|
if remote_path.ends_with(".m3u8") {
|
||||||
|
"no-store"
|
||||||
|
} else {
|
||||||
|
"public, max-age=86400"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.body(hyper::Body::from(bytes))?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_indeehub_peer_key(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||||
|
let tail = path.strip_prefix("/api/peer-indeehub-key/").unwrap_or("");
|
||||||
|
let Some((onion, id)) = tail.split_once('/') else {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"text/plain",
|
||||||
|
hyper::Body::from("invalid peer key"),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let upstream = self
|
||||||
|
.request_indeehub_peer(onion, &format!("/api/public-indeehub-key/{id}"))
|
||||||
|
.await?;
|
||||||
|
let status = upstream.status();
|
||||||
|
let bytes = upstream.bytes().await?;
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header("Content-Type", "application/octet-stream")
|
||||||
|
.header("Cache-Control", "no-store")
|
||||||
|
.body(hyper::Body::from(bytes))?)
|
||||||
|
}
|
||||||
|
|
||||||
/// Serve an encrypted backup archive (`<data_dir>/backups/<id>.bak`) as a
|
/// Serve an encrypted backup archive (`<data_dir>/backups/<id>.bak`) as a
|
||||||
/// browser download. The archive is passphrase-encrypted at rest; the
|
/// browser download. The archive is passphrase-encrypted at rest; the
|
||||||
/// session gate at the route controls who can fetch it.
|
/// session gate at the route controls who can fetch it.
|
||||||
@@ -318,6 +688,40 @@ impl ApiHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A node app on another port shares host-only owner cookies. Never grant
|
||||||
|
// that app access to the owner shell, even though other app APIs accept
|
||||||
|
// same-host cross-port origins. Browser terminal requests must originate
|
||||||
|
// from the exact dashboard authority (Host includes its port).
|
||||||
|
fn has_terminal_origin(headers: &hyper::HeaderMap) -> bool {
|
||||||
|
let Some(origin) = headers
|
||||||
|
.get(hyper::header::ORIGIN)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(host) = headers
|
||||||
|
.get(hyper::header::HOST)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
origin == format!("http://{host}") || origin == format!("https://{host}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browsers do not send Origin on a same-origin GET. Fetch Metadata is a
|
||||||
|
// browser-controlled header and distinguishes the dashboard from an app on
|
||||||
|
// another port, which is only same-site. Mutating requests and WebSockets
|
||||||
|
// still require the exact Origin above.
|
||||||
|
fn has_terminal_read_origin(headers: &hyper::HeaderMap) -> bool {
|
||||||
|
if headers.contains_key(hyper::header::ORIGIN) {
|
||||||
|
return Self::has_terminal_origin(headers);
|
||||||
|
}
|
||||||
|
headers
|
||||||
|
.get("sec-fetch-site")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
== Some("same-origin")
|
||||||
|
}
|
||||||
|
|
||||||
/// Permissive origin check for the share-to-mesh iframe intent: any scheme
|
/// Permissive origin check for the share-to-mesh iframe intent: any scheme
|
||||||
/// http(s):// followed by the configured host_ip, optionally `:port`. Apps
|
/// http(s):// followed by the configured host_ip, optionally `:port`. Apps
|
||||||
/// proxied under other ports (APP_PORTS) call this from within the same
|
/// proxied under other ports (APP_PORTS) call this from within the same
|
||||||
@@ -434,6 +838,13 @@ impl ApiHandler {
|
|||||||
tracing::warn!("401 WebSocket /ws/terminal — session invalid or missing");
|
tracing::warn!("401 WebSocket /ws/terminal — session invalid or missing");
|
||||||
return Ok(Self::unauthorized());
|
return Ok(Self::unauthorized());
|
||||||
}
|
}
|
||||||
|
if !Self::has_terminal_origin(req.headers()) {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"Terminal origin denied"}"#),
|
||||||
|
));
|
||||||
|
}
|
||||||
return Self::handle_terminal_websocket(req).await;
|
return Self::handle_terminal_websocket(req).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,7 +917,25 @@ impl ApiHandler {
|
|||||||
|
|
||||||
match (method, path.as_str()) {
|
match (method, path.as_str()) {
|
||||||
// RPC — auth is handled inside rpc handler per-method
|
// RPC — auth is handled inside rpc handler per-method
|
||||||
(Method::POST, "/rpc/v1") => self.rpc_handler.clone().handle(req_with_bytes).await,
|
(Method::POST, "/rpc/v1") => {
|
||||||
|
let origin = self.app_cors_origin(&headers);
|
||||||
|
let mut response = self.rpc_handler.clone().handle(req_with_bytes).await?;
|
||||||
|
if !origin.is_empty() {
|
||||||
|
response.headers_mut().insert(
|
||||||
|
"access-control-allow-origin",
|
||||||
|
hyper::header::HeaderValue::from_str(&origin)?,
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
"access-control-allow-credentials",
|
||||||
|
hyper::header::HeaderValue::from_static("true"),
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
hyper::header::VARY,
|
||||||
|
hyper::header::HeaderValue::from_static("Origin"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
// AIUI model proxy — session-gated forwarder to Claude/Ollama,
|
// AIUI model proxy — session-gated forwarder to Claude/Ollama,
|
||||||
// replacing the unauthenticated claude-api-proxy.py sidecar and
|
// replacing the unauthenticated claude-api-proxy.py sidecar and
|
||||||
@@ -556,11 +985,29 @@ impl ApiHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
(Method::GET, "/api/terminal/sessions") => {
|
(Method::GET, "/api/terminal/sessions") => {
|
||||||
if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); }
|
if !self.is_authenticated(&headers).await {
|
||||||
|
return Ok(Self::unauthorized());
|
||||||
|
}
|
||||||
|
if !Self::has_terminal_read_origin(&headers) {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"Terminal origin denied"}"#),
|
||||||
|
));
|
||||||
|
}
|
||||||
terminal::list_response().await
|
terminal::list_response().await
|
||||||
}
|
}
|
||||||
(Method::POST, "/api/terminal/sessions") => {
|
(Method::POST, "/api/terminal/sessions") => {
|
||||||
if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); }
|
if !self.is_authenticated(&headers).await {
|
||||||
|
return Ok(Self::unauthorized());
|
||||||
|
}
|
||||||
|
if !Self::has_terminal_origin(&headers) {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"Terminal origin denied"}"#),
|
||||||
|
));
|
||||||
|
}
|
||||||
terminal::create(&body_bytes).await
|
terminal::create(&body_bytes).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -718,6 +1165,39 @@ impl ApiHandler {
|
|||||||
.body(hyper::Body::from(body))?)
|
.body(hyper::Body::from(body))?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public IndeeHub metadata is also exposed on the FIPS peer
|
||||||
|
// listener, whose path filter is the only ingress gate here.
|
||||||
|
(Method::GET, "/api/public-catalog/indeedhub") => {
|
||||||
|
self.handle_indeehub_public_catalog().await
|
||||||
|
}
|
||||||
|
(Method::GET, p) if p.starts_with("/api/public-indeehub-stream/") => {
|
||||||
|
self.handle_indeehub_public_stream(p).await
|
||||||
|
}
|
||||||
|
(Method::GET, p) if p.starts_with("/api/public-indeehub-media/") => {
|
||||||
|
self.handle_indeehub_public_media(p).await
|
||||||
|
}
|
||||||
|
(Method::GET, p) if p.starts_with("/api/public-indeehub-key/") => {
|
||||||
|
self.handle_indeehub_public_key(p).await
|
||||||
|
}
|
||||||
|
(Method::GET, p) if p.starts_with("/api/peer-indeehub-stream/") => {
|
||||||
|
if !self.is_authenticated(&headers).await {
|
||||||
|
return Ok(Self::unauthorized());
|
||||||
|
}
|
||||||
|
self.handle_indeehub_peer_stream(p).await
|
||||||
|
}
|
||||||
|
(Method::GET, p) if p.starts_with("/api/peer-indeehub-media/") => {
|
||||||
|
if !self.is_authenticated(&headers).await {
|
||||||
|
return Ok(Self::unauthorized());
|
||||||
|
}
|
||||||
|
self.handle_indeehub_peer_media(p).await
|
||||||
|
}
|
||||||
|
(Method::GET, p) if p.starts_with("/api/peer-indeehub-key/") => {
|
||||||
|
if !self.is_authenticated(&headers).await {
|
||||||
|
return Ok(Self::unauthorized());
|
||||||
|
}
|
||||||
|
self.handle_indeehub_peer_key(p).await
|
||||||
|
}
|
||||||
|
|
||||||
(Method::GET, "/api/app-catalog") => {
|
(Method::GET, "/api/app-catalog") => {
|
||||||
if !self.is_authenticated(&headers).await {
|
if !self.is_authenticated(&headers).await {
|
||||||
return Ok(Self::unauthorized());
|
return Ok(Self::unauthorized());
|
||||||
@@ -879,3 +1359,89 @@ fn sanitize_html(s: &str) -> String {
|
|||||||
.replace('"', """)
|
.replace('"', """)
|
||||||
.replace('\'', "'")
|
.replace('\'', "'")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod terminal_origin_tests {
|
||||||
|
use super::ApiHandler;
|
||||||
|
use hyper::header::{HeaderMap, HeaderValue, HOST, ORIGIN};
|
||||||
|
|
||||||
|
fn allowed(host: &str, origin: Option<&str>) -> bool {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(HOST, HeaderValue::from_str(host).unwrap());
|
||||||
|
if let Some(origin) = origin {
|
||||||
|
headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
|
||||||
|
}
|
||||||
|
ApiHandler::has_terminal_origin(&headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_allowed(host: &str, origin: Option<&str>, fetch_site: Option<&str>) -> bool {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(HOST, HeaderValue::from_str(host).unwrap());
|
||||||
|
if let Some(origin) = origin {
|
||||||
|
headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
|
||||||
|
}
|
||||||
|
if let Some(fetch_site) = fetch_site {
|
||||||
|
headers.insert("sec-fetch-site", HeaderValue::from_str(fetch_site).unwrap());
|
||||||
|
}
|
||||||
|
ApiHandler::has_terminal_read_origin(&headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_accepts_only_the_exact_dashboard_authority() {
|
||||||
|
assert!(allowed("node.local", Some("https://node.local")));
|
||||||
|
assert!(allowed("192.168.1.2:7778", Some("http://192.168.1.2:7778")));
|
||||||
|
assert!(!allowed("node.local", Some("https://node.local:7778")));
|
||||||
|
assert!(!allowed("node.local:7778", Some("https://node.local")));
|
||||||
|
assert!(!allowed("node.local", Some("https://evil.example")));
|
||||||
|
assert!(!allowed("node.local", Some("null")));
|
||||||
|
assert!(!allowed("node.local", None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_read_accepts_same_origin_fetch_but_not_same_site_apps() {
|
||||||
|
assert!(read_allowed("node.local", None, Some("same-origin")));
|
||||||
|
assert!(!read_allowed("node.local", None, Some("same-site")));
|
||||||
|
assert!(!read_allowed("node.local", None, Some("cross-site")));
|
||||||
|
assert!(!read_allowed("node.local", None, None));
|
||||||
|
assert!(!read_allowed(
|
||||||
|
"node.local",
|
||||||
|
Some("https://evil.example"),
|
||||||
|
Some("same-site")
|
||||||
|
));
|
||||||
|
assert!(!read_allowed(
|
||||||
|
"node.local",
|
||||||
|
Some("https://evil.example"),
|
||||||
|
Some("same-origin")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod indeehub_public_stream_tests {
|
||||||
|
use super::indeehub_listing_is_publicly_free;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_stream_requires_explicitly_free_project_and_signed_terms() {
|
||||||
|
assert!(indeehub_listing_is_publicly_free(&json!({
|
||||||
|
"rentalPrice": 0,
|
||||||
|
"offerEvent": null
|
||||||
|
})));
|
||||||
|
assert!(indeehub_listing_is_publicly_free(&json!({
|
||||||
|
"rentalPrice": "0",
|
||||||
|
"offerEvent": {"tags": [["rental", "{\"priceSats\":0}"]]}
|
||||||
|
})));
|
||||||
|
assert!(!indeehub_listing_is_publicly_free(&json!({
|
||||||
|
"rentalPrice": 15,
|
||||||
|
"offerEvent": null
|
||||||
|
})));
|
||||||
|
assert!(!indeehub_listing_is_publicly_free(&json!({
|
||||||
|
"rentalPrice": 0,
|
||||||
|
"offerEvent": {"tags": [["rental", "{\"priceSats\":15}"]]}
|
||||||
|
})));
|
||||||
|
assert!(!indeehub_listing_is_publicly_free(&json!({
|
||||||
|
"rentalPrice": 0,
|
||||||
|
"offerEvent": {"tags": [["rental", "invalid"]]}
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -178,10 +178,21 @@ pub(crate) async fn create(body: &[u8]) -> Result<Response<hyper::Body>> {
|
|||||||
}
|
}
|
||||||
let id = format!("s-{}", Uuid::new_v4().simple());
|
let id = format!("s-{}", Uuid::new_v4().simple());
|
||||||
let tmux_name = format!("archy-{id}");
|
let tmux_name = format!("archy-{id}");
|
||||||
let output = Command::new("tmux")
|
let output = match Command::new("tmux")
|
||||||
.args(["new-session", "-d", "-s", &tmux_name, "-c", &workspace])
|
.args(["new-session", "-d", "-s", &tmux_name, "-c", &workspace])
|
||||||
.output()
|
.output()
|
||||||
.await?;
|
.await
|
||||||
|
{
|
||||||
|
Ok(output) => output,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"Terminal runtime is not installed on this node"}"#),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
return Ok(build_response(
|
return Ok(build_response(
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
|||||||
@@ -257,6 +257,12 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unit tests deliberately construct a handler without an orchestrator.
|
||||||
|
// Never let that mock cross into a real Podman command on the host.
|
||||||
|
if cfg!(test) && self.orchestrator.is_none() {
|
||||||
|
return Ok(serde_json::json!([]));
|
||||||
|
}
|
||||||
|
|
||||||
let output = tokio::process::Command::new("podman")
|
let output = tokio::process::Command::new("podman")
|
||||||
.args(["ps", "-a", "--format", "json"])
|
.args(["ps", "-a", "--format", "json"])
|
||||||
.output()
|
.output()
|
||||||
|
|||||||
@@ -1013,19 +1013,28 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||||
let path = format!("/content/{}/onchain-status/{}", content_id, address);
|
let path = format!("/content/{}/onchain-status/{}", content_id, address);
|
||||||
let (response, _transport) =
|
let (response, _transport) = match crate::fips::dial::PeerRequest::new(
|
||||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
fips_npub.as_deref(),
|
||||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
onion,
|
||||||
.timeout(std::time::Duration::from_secs(15))
|
&path,
|
||||||
.fips_timeout(std::time::Duration::from_secs(6))
|
)
|
||||||
.send_content_get(&self.config.data_dir)
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
.await
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
{
|
.fips_timeout(std::time::Duration::from_secs(6))
|
||||||
Ok(v) => v,
|
.send_content_get(&self.config.data_dir)
|
||||||
Err(_) => return Ok(serde_json::json!({ "paid": false, "unreachable": true, "status": "unknown", "error": "Payment verification is unavailable. Keep the original address and do not pay again." })),
|
.await
|
||||||
};
|
{
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(
|
||||||
|
serde_json::json!({ "paid": false, "unreachable": true, "status": "unknown", "error": "Payment verification is unavailable. Keep the original address and do not pay again." }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Ok(serde_json::json!({ "paid": false, "status": "unknown", "error": "The seller could not verify this payment. Keep the original address and do not pay again." }));
|
return Ok(
|
||||||
|
serde_json::json!({ "paid": false, "status": "unknown", "error": "The seller could not verify this payment. Keep the original address and do not pay again." }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let body: serde_json::Value = response
|
let body: serde_json::Value = response
|
||||||
.json()
|
.json()
|
||||||
@@ -1409,6 +1418,76 @@ impl RpcHandler {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Browse IndeeHub's public film catalogs through the native peer bridge.
|
||||||
|
/// The bridge selects FIPS first (with the normal safe fallback policy),
|
||||||
|
/// so the browser never needs a peer LAN address or an onion connection.
|
||||||
|
pub(super) async fn handle_content_browse_indeehub_peers(&self) -> Result<serde_json::Value> {
|
||||||
|
let nodes = crate::federation::load_nodes(&self.config.data_dir)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let peers: Vec<_> = nodes
|
||||||
|
.into_iter()
|
||||||
|
.filter(|node| is_valid_v3_onion(&node.onion))
|
||||||
|
.collect();
|
||||||
|
let calls = peers.into_iter().map(|node| async move {
|
||||||
|
let onion = node.onion.clone();
|
||||||
|
let request_onion = onion.clone();
|
||||||
|
let fips = node.fips_npub.clone();
|
||||||
|
let result = tokio::time::timeout(std::time::Duration::from_secs(12), async move {
|
||||||
|
let (response, transport) = crate::fips::dial::PeerRequest::new(
|
||||||
|
fips.as_deref(),
|
||||||
|
&request_onion,
|
||||||
|
"/api/public-catalog/indeedhub",
|
||||||
|
)
|
||||||
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.fips_timeout(std::time::Duration::from_secs(6))
|
||||||
|
.send_get()
|
||||||
|
.await?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
anyhow::bail!("peer returned {}", response.status());
|
||||||
|
}
|
||||||
|
let listings: serde_json::Value = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("invalid IndeeHub peer catalog")?;
|
||||||
|
Ok::<_, anyhow::Error>((listings, transport))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(Result::ok);
|
||||||
|
(onion, result)
|
||||||
|
});
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let mut reached = 0usize;
|
||||||
|
for (onion, result) in futures_util::future::join_all(calls).await {
|
||||||
|
let Some((listings, transport)) = result else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(listings) = listings.as_array() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
reached += 1;
|
||||||
|
for listing in listings {
|
||||||
|
let Some(mut listing) = listing.as_object().cloned() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
listing.insert("peer".to_string(), serde_json::json!(onion));
|
||||||
|
listing.insert(
|
||||||
|
"transport".to_string(),
|
||||||
|
serde_json::json!(transport.to_string()),
|
||||||
|
);
|
||||||
|
items.push(serde_json::Value::Object(listing));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"items": items,
|
||||||
|
"peers_reached": reached,
|
||||||
|
"peers_total": reached,
|
||||||
|
"partial": false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// `content.owned-get` — return a purchased item's bytes (base64) from the
|
/// `content.owned-get` — return a purchased item's bytes (base64) from the
|
||||||
/// local cache for in-app viewing/saving. No network, no re-payment.
|
/// local cache for in-app viewing/saving. No network, no re-payment.
|
||||||
pub(super) async fn handle_content_owned_get(
|
pub(super) async fn handle_content_owned_get(
|
||||||
|
|||||||
@@ -354,6 +354,7 @@ impl RpcHandler {
|
|||||||
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
|
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
|
||||||
"content.indeehub-projects" => self.handle_content_indeehub_projects().await,
|
"content.indeehub-projects" => self.handle_content_indeehub_projects().await,
|
||||||
"content.browse-all-peers" => self.handle_content_browse_all_peers().await,
|
"content.browse-all-peers" => self.handle_content_browse_all_peers().await,
|
||||||
|
"content.browse-indeehub-peers" => self.handle_content_browse_indeehub_peers().await,
|
||||||
"content.playback-handle" => self.handle_playback_handle(params, session_token).await,
|
"content.playback-handle" => self.handle_playback_handle(params, session_token).await,
|
||||||
"content.playback-prepare" => self.handle_playback_prepare(params, session_token).await,
|
"content.playback-prepare" => self.handle_playback_prepare(params, session_token).await,
|
||||||
"content.playback-start" => self.handle_playback_start(params, session_token).await,
|
"content.playback-start" => self.handle_playback_start(params, session_token).await,
|
||||||
|
|||||||
@@ -114,17 +114,34 @@ impl RpcHandler {
|
|||||||
|
|
||||||
/// Explicit owner-key import into a separate native business identity.
|
/// Explicit owner-key import into a separate native business identity.
|
||||||
pub(in crate::api::rpc) async fn handle_identity_import_nostr(
|
pub(in crate::api::rpc) async fn handle_identity_import_nostr(
|
||||||
&self, params: Option<serde_json::Value>,
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.unwrap_or_default();
|
let params = params.unwrap_or_default();
|
||||||
let password = params.get("password").and_then(|v| v.as_str()).unwrap_or("");
|
let password = params
|
||||||
|
.get("password")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
if !self.auth_manager.verify_password(password).await? {
|
if !self.auth_manager.verify_password(password).await? {
|
||||||
anyhow::bail!("Invalid node password");
|
anyhow::bail!("Invalid node password");
|
||||||
}
|
}
|
||||||
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("Just Works");
|
let name = params
|
||||||
anyhow::ensure!(!name.trim().is_empty() && name.len() <= 100, "Invalid identity name");
|
.get("name")
|
||||||
let nsec = params.get("nsec").and_then(|v| v.as_str()).unwrap_or("").trim();
|
.and_then(|v| v.as_str())
|
||||||
let npub = params.get("expected_npub").and_then(|v| v.as_str()).unwrap_or("");
|
.unwrap_or("Just Works");
|
||||||
|
anyhow::ensure!(
|
||||||
|
!name.trim().is_empty() && name.len() <= 100,
|
||||||
|
"Invalid identity name"
|
||||||
|
);
|
||||||
|
let nsec = params
|
||||||
|
.get("nsec")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
let npub = params
|
||||||
|
.get("expected_npub")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||||
let record = manager.import_nostr(name.to_string(), nsec, npub).await?;
|
let record = manager.import_nostr(name.to_string(), nsec, npub).await?;
|
||||||
Ok(serde_json::json!({"id":record.id, "name":record.name,
|
Ok(serde_json::json!({"id":record.id, "name":record.name,
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ fn verified_resolution(input: serde_json::Value, now: u64) -> Result<ResolvePara
|
|||||||
let encoded = serde_json::to_value(event)?;
|
let encoded = serde_json::to_value(event)?;
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
encoded["tags"] == serde_json::json!([["d", RESOLUTION_DOMAIN]])
|
encoded["tags"] == serde_json::json!([["d", RESOLUTION_DOMAIN]])
|
||||||
&& event.created_at.as_u64() >= params.intent.created_at.saturating_sub(30)
|
&& event.created_at.as_secs() >= params.intent.created_at.saturating_sub(30)
|
||||||
&& event.created_at.as_u64() <= now.saturating_add(30),
|
&& event.created_at.as_secs() <= now.saturating_add(30),
|
||||||
"Invalid resolution signature time or scope"
|
"Invalid resolution signature time or scope"
|
||||||
);
|
);
|
||||||
let content: serde_json::Value = serde_json::from_str(&event.content)?;
|
let content: serde_json::Value = serde_json::from_str(&event.content)?;
|
||||||
@@ -103,7 +103,7 @@ fn verified_producer(params: &Params, now: u64) -> Result<String> {
|
|||||||
producer == params.intent.producer,
|
producer == params.intent.producer,
|
||||||
"The signing identity differs from the project producer"
|
"The signing identity differs from the project producer"
|
||||||
);
|
);
|
||||||
let created = event.created_at.as_u64();
|
let created = event.created_at.as_secs();
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
created >= params.intent.created_at.saturating_sub(30)
|
created >= params.intent.created_at.saturating_sub(30)
|
||||||
&& created < params.intent.expires_at
|
&& created < params.intent.expires_at
|
||||||
@@ -201,12 +201,12 @@ impl RpcHandler {
|
|||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
state
|
state
|
||||||
.package_data
|
.package_data
|
||||||
.get("indeedhub-api")
|
.get("indeedhub")
|
||||||
.is_some_and(|entry| matches!(
|
.is_some_and(|entry| matches!(
|
||||||
entry.state,
|
entry.state,
|
||||||
crate::data_model::PackageState::Running
|
crate::data_model::PackageState::Running
|
||||||
)),
|
)),
|
||||||
"The installed IndeeHub API must be running to register its media"
|
"The installed IndeeHub app must be running to register its media"
|
||||||
);
|
);
|
||||||
let identity =
|
let identity =
|
||||||
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
|
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ mod handshake;
|
|||||||
mod identity;
|
mod identity;
|
||||||
mod interfaces;
|
mod interfaces;
|
||||||
mod lightning_purchase;
|
mod lightning_purchase;
|
||||||
mod onchain_purchase;
|
|
||||||
pub(crate) mod lnd;
|
pub(crate) mod lnd;
|
||||||
mod marketplace;
|
mod marketplace;
|
||||||
mod media_registration;
|
mod media_registration;
|
||||||
|
mod onchain_purchase;
|
||||||
mod playback;
|
mod playback;
|
||||||
mod purchase;
|
mod purchase;
|
||||||
// pub(crate): 13-10's `assistant::backends::select_backend` reuses
|
// pub(crate): 13-10's `assistant::backends::select_backend` reuses
|
||||||
@@ -34,12 +34,12 @@ mod monitoring;
|
|||||||
mod music;
|
mod music;
|
||||||
mod names;
|
mod names;
|
||||||
mod network;
|
mod network;
|
||||||
mod publishing;
|
|
||||||
mod node;
|
mod node;
|
||||||
mod nostr;
|
mod nostr;
|
||||||
mod onboarding_gate;
|
mod onboarding_gate;
|
||||||
mod openwrt;
|
mod openwrt;
|
||||||
mod package;
|
mod package;
|
||||||
|
mod publishing;
|
||||||
pub(crate) use package::patch_indeedhub_nostr_provider;
|
pub(crate) use package::patch_indeedhub_nostr_provider;
|
||||||
pub(crate) use package::wyoming_satellite_keeper;
|
pub(crate) use package::wyoming_satellite_keeper;
|
||||||
mod peers;
|
mod peers;
|
||||||
@@ -112,7 +112,6 @@ fn native_consent_origin_allowed(method: &str, headers: &hyper::HeaderMap, dev_m
|
|||||||
| "media.registration.context"
|
| "media.registration.context"
|
||||||
| "media.registration.resolve"
|
| "media.registration.resolve"
|
||||||
| "content.rental-purchase"
|
| "content.rental-purchase"
|
||||||
|
|
||||||
| "content.onchain-cancel"
|
| "content.onchain-cancel"
|
||||||
| "content.onchain-attempt"
|
| "content.onchain-attempt"
|
||||||
| "content.onchain-create"
|
| "content.onchain-create"
|
||||||
|
|||||||
@@ -442,7 +442,7 @@ impl RpcHandler {
|
|||||||
if record.quote.is_none() {
|
if record.quote.is_none() {
|
||||||
engine::mark_address_allocation(&journal, true)?;
|
engine::mark_address_allocation(&journal, true)?;
|
||||||
let status = self.request_onchain_allocation(&record, &fips).await?;
|
let status = self.request_onchain_allocation(&record, &fips).await?;
|
||||||
record = engine::accept_quote(
|
engine::accept_quote(
|
||||||
&journal,
|
&journal,
|
||||||
status.quote()?.context(
|
status.quote()?.context(
|
||||||
"Original seller allocation is unresolved; recover this operation",
|
"Original seller allocation is unresolved; recover this operation",
|
||||||
|
|||||||
@@ -74,10 +74,15 @@ async fn local_podman_image_exists(image: &str) -> Result<bool> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn patched_indeedhub_nginx_config(original: &str) -> String {
|
fn patched_indeedhub_nginx_config(original: &str, inject_provider: bool) -> String {
|
||||||
let mut conf = original
|
let mut conf = original
|
||||||
.lines()
|
.lines()
|
||||||
.filter(|line| !line.contains("X-Frame-Options"))
|
.filter(|line| {
|
||||||
|
!line.contains("X-Frame-Options")
|
||||||
|
&& (inject_provider
|
||||||
|
|| (!line.contains("sub_filter_once")
|
||||||
|
&& !line.contains("sub_filter '</head>'")))
|
||||||
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
conf.push('\n');
|
conf.push('\n');
|
||||||
@@ -91,21 +96,22 @@ fn patched_indeedhub_nginx_config(original: &str) -> String {
|
|||||||
location = /sw.js {",
|
location = /sw.js {",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if conf.contains("try_files") && !conf.contains("sub_filter") {
|
if inject_provider && conf.contains("try_files") && !conf.contains("sub_filter") {
|
||||||
conf = conf.replacen(
|
conf = conf.replacen(
|
||||||
"try_files $uri $uri/ /index.html;",
|
"try_files $uri $uri/ /index.html;",
|
||||||
"try_files $uri $uri/ /index.html;\n\
|
"try_files $uri $uri/ /index.html;\n\
|
||||||
sub_filter_once on;\n\
|
sub_filter_once on;\n\
|
||||||
sub_filter '</head>' '<script src=\"/nostr-provider.js?v=tab-signer-v4\"></script></head>';",
|
sub_filter '</head>' '<script src=\"/nostr-provider.js?v=tab-signer-v5\"></script></head>';",
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
conf = conf.replace(
|
conf = conf.replace(
|
||||||
"src=\"/nostr-provider.js\"",
|
"src=\"/nostr-provider.js\"",
|
||||||
"src=\"/nostr-provider.js?v=tab-signer-v4\"",
|
"src=\"/nostr-provider.js?v=tab-signer-v5\"",
|
||||||
);
|
);
|
||||||
conf = conf.replace("tab-signer-v2", "tab-signer-v4");
|
conf = conf.replace("tab-signer-v2", "tab-signer-v5");
|
||||||
conf = conf.replace("tab-signer-v3", "tab-signer-v4");
|
conf = conf.replace("tab-signer-v3", "tab-signer-v5");
|
||||||
|
conf = conf.replace("tab-signer-v4", "tab-signer-v5");
|
||||||
conf.replace(
|
conf.replace(
|
||||||
"proxy_set_header X-Forwarded-Prefix /api;",
|
"proxy_set_header X-Forwarded-Prefix /api;",
|
||||||
"proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;",
|
"proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;",
|
||||||
@@ -165,6 +171,11 @@ pub(crate) async fn patch_indeedhub_nostr_provider() {
|
|||||||
|
|
||||||
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
|
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_dest = format!("{container_root}/usr/share/nginx/html/nostr-provider.js");
|
||||||
|
let provider_is_bundled =
|
||||||
|
tokio::fs::read_to_string(format!("{container_root}/usr/share/nginx/html/index.html"))
|
||||||
|
.await
|
||||||
|
.map(|html| html.contains("src=\"/nostr-provider.js"))
|
||||||
|
.unwrap_or(false);
|
||||||
let provider_copied = tokio::fs::metadata(provider_src).await.is_ok()
|
let provider_copied = tokio::fs::metadata(provider_src).await.is_ok()
|
||||||
&& tokio::process::Command::new("podman")
|
&& tokio::process::Command::new("podman")
|
||||||
.args([
|
.args([
|
||||||
@@ -189,7 +200,7 @@ pub(crate) async fn patch_indeedhub_nostr_provider() {
|
|||||||
if let Ok(out) = copy_out {
|
if let Ok(out) = copy_out {
|
||||||
if out.status.success() {
|
if out.status.success() {
|
||||||
if let Ok(original) = tokio::fs::read_to_string(&tmp_path).await {
|
if let Ok(original) = tokio::fs::read_to_string(&tmp_path).await {
|
||||||
let conf = patched_indeedhub_nginx_config(&original);
|
let conf = patched_indeedhub_nginx_config(&original, !provider_is_bundled);
|
||||||
if conf != original && tokio::fs::write(&tmp_path, &conf).await.is_ok() {
|
if conf != original && tokio::fs::write(&tmp_path, &conf).await.is_ok() {
|
||||||
config_copied = tokio::process::Command::new("podman")
|
config_copied = tokio::process::Command::new("podman")
|
||||||
.args([
|
.args([
|
||||||
@@ -219,7 +230,8 @@ pub(crate) async fn patch_indeedhub_nostr_provider() {
|
|||||||
}
|
}
|
||||||
} else if conf == original
|
} else if conf == original
|
||||||
&& conf.contains("location = /nostr-provider.js {")
|
&& conf.contains("location = /nostr-provider.js {")
|
||||||
&& conf.contains("src=\"/nostr-provider.js?v=tab-signer-v4\"")
|
&& (provider_is_bundled
|
||||||
|
|| conf.contains("src=\"/nostr-provider.js?v=tab-signer-v5\""))
|
||||||
{
|
{
|
||||||
config_copied = true;
|
config_copied = true;
|
||||||
}
|
}
|
||||||
@@ -2618,19 +2630,23 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"#;
|
"#;
|
||||||
let patched = patched_indeedhub_nginx_config(original);
|
let patched = patched_indeedhub_nginx_config(original, true);
|
||||||
assert!(!patched.contains("X-Frame-Options"));
|
assert!(!patched.contains("X-Frame-Options"));
|
||||||
assert!(patched.contains("location = /nostr-provider.js {"));
|
assert!(patched.contains("location = /nostr-provider.js {"));
|
||||||
assert!(patched.contains("Cache-Control \"no-cache, no-store, must-revalidate\""));
|
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("src=\"/nostr-provider.js?v=tab-signer-v5\""));
|
||||||
assert!(patched.contains("X-Forwarded-Prefix $http_x_forwarded_prefix/api"));
|
assert!(patched.contains("X-Forwarded-Prefix $http_x_forwarded_prefix/api"));
|
||||||
assert_eq!(patched_indeedhub_nginx_config(&patched), patched);
|
assert_eq!(patched_indeedhub_nginx_config(&patched, true), patched);
|
||||||
|
|
||||||
let previous_broker = patched.replace("tab-signer-v4", "tab-signer-v3");
|
let previous_broker = patched.replace("tab-signer-v5", "tab-signer-v3");
|
||||||
let migrated = patched_indeedhub_nginx_config(&previous_broker);
|
let migrated = patched_indeedhub_nginx_config(&previous_broker, true);
|
||||||
assert!(migrated.contains("tab-signer-v4"));
|
assert!(migrated.contains("tab-signer-v5"));
|
||||||
assert!(!migrated.contains("tab-signer-v3"));
|
assert!(!migrated.contains("tab-signer-v3"));
|
||||||
assert_eq!(patched_indeedhub_nginx_config(&migrated), migrated);
|
assert_eq!(patched_indeedhub_nginx_config(&migrated, true), migrated);
|
||||||
|
|
||||||
|
let bundled = patched_indeedhub_nginx_config(&patched, false);
|
||||||
|
assert!(!bundled.contains("sub_filter"));
|
||||||
|
assert_eq!(patched_indeedhub_nginx_config(&bundled, false), bundled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -883,7 +883,8 @@ async fn do_orchestrator_package_start(
|
|||||||
if i > 0 {
|
if i > 0 {
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
}
|
}
|
||||||
let managed = crate::container::supervised_update::installed_unit(data_dir, name)?.is_some();
|
let managed =
|
||||||
|
crate::container::supervised_update::installed_unit(data_dir, name)?.is_some();
|
||||||
if !managed {
|
if !managed {
|
||||||
repair_before_package_start(name).await;
|
repair_before_package_start(name).await;
|
||||||
wait_before_package_start(name).await;
|
wait_before_package_start(name).await;
|
||||||
@@ -1145,7 +1146,8 @@ async fn do_orchestrator_package_stop(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
for name in containers {
|
for name in containers {
|
||||||
let managed = crate::container::supervised_update::installed_unit(data_dir, name)?.is_some();
|
let managed =
|
||||||
|
crate::container::supervised_update::installed_unit(data_dir, name)?.is_some();
|
||||||
match orchestrator.stop(name).await {
|
match orchestrator.stop(name).await {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(e) if !managed && is_unknown_app_id_error(&e) => {
|
Err(e) if !managed && is_unknown_app_id_error(&e) => {
|
||||||
@@ -1977,9 +1979,9 @@ pub(super) fn manifest_apps_dirs() -> Vec<std::path::PathBuf> {
|
|||||||
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
|
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
|
||||||
dirs.push(root.into());
|
dirs.push(root.into());
|
||||||
}
|
}
|
||||||
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
// Cargo exposes this at compile time, not when an already-built isolated
|
||||||
dirs.push(Path::new(&manifest_dir).join("../../apps"));
|
// test executable is launched from a different working directory.
|
||||||
}
|
dirs.push(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps"));
|
||||||
dirs.extend([
|
dirs.extend([
|
||||||
Path::new("apps").to_path_buf(),
|
Path::new("apps").to_path_buf(),
|
||||||
Path::new("/opt/archipelago/apps").to_path_buf(),
|
Path::new("/opt/archipelago/apps").to_path_buf(),
|
||||||
|
|||||||
@@ -146,7 +146,11 @@ impl RpcHandler {
|
|||||||
.require_fips()
|
.require_fips()
|
||||||
.single_delivery()
|
.single_delivery()
|
||||||
.timeout(std::time::Duration::from_secs(20))
|
.timeout(std::time::Duration::from_secs(20))
|
||||||
.send_content_json(&self.config.data_dir, &binding.contract.seller_did, &body)
|
.send_rental_control_json(
|
||||||
|
&self.config.data_dir,
|
||||||
|
&binding.contract.seller_did,
|
||||||
|
&body,
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
response.status().is_success(),
|
response.status().is_success(),
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ impl RpcHandler {
|
|||||||
struct RentalParams {
|
struct RentalParams {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
retry_preparation: bool,
|
retry_preparation: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
renew_expired: bool,
|
||||||
seller_did: String,
|
seller_did: String,
|
||||||
content_id: String,
|
content_id: String,
|
||||||
expected_sha256: String,
|
expected_sha256: String,
|
||||||
@@ -209,6 +211,46 @@ impl RpcHandler {
|
|||||||
price_sats: params.expected_price_sats,
|
price_sats: params.expected_price_sats,
|
||||||
viewing_seconds: params.expected_viewing_seconds,
|
viewing_seconds: params.expected_viewing_seconds,
|
||||||
};
|
};
|
||||||
|
if params.renew_expired {
|
||||||
|
let journal = Journal::open(&self.config.data_dir).await?;
|
||||||
|
let matching = journal
|
||||||
|
.find_buyers(&buyer, transport.seller_did(), ¶ms.content_id)
|
||||||
|
.await?;
|
||||||
|
let settled: Vec<_> = matching
|
||||||
|
.into_iter()
|
||||||
|
.filter(|record| record.phase == crate::content_purchase::BuyerPhase::ReceiptSaved)
|
||||||
|
.collect();
|
||||||
|
anyhow::ensure!(
|
||||||
|
settled.len() <= 1,
|
||||||
|
"Multiple original rentals require recovery"
|
||||||
|
);
|
||||||
|
if let Some(record) = settled.into_iter().next() {
|
||||||
|
let capability = record
|
||||||
|
.receipt()
|
||||||
|
.context("Original rental receipt is missing")?
|
||||||
|
.capability
|
||||||
|
.clone();
|
||||||
|
let original_duration = journal
|
||||||
|
.protocol_envelope("buyer", &record.contract.id)
|
||||||
|
.await?
|
||||||
|
.context("Original rental terms are missing")?
|
||||||
|
.offer
|
||||||
|
.viewing_seconds
|
||||||
|
.context("Original purchase is not a timed rental")?;
|
||||||
|
let (started, expires) = transport
|
||||||
|
.expired_rental_window(&record.contract, &capability, original_duration)
|
||||||
|
.await?;
|
||||||
|
journal
|
||||||
|
.record_rental_expiry(
|
||||||
|
&record.contract,
|
||||||
|
started,
|
||||||
|
expires,
|
||||||
|
original_duration,
|
||||||
|
u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
match caller::purchase_bound(
|
match caller::purchase_bound(
|
||||||
&self.config.data_dir,
|
&self.config.data_dir,
|
||||||
&buyer,
|
&buyer,
|
||||||
|
|||||||
@@ -119,9 +119,9 @@ fn apps_dirs() -> Vec<PathBuf> {
|
|||||||
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
|
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
|
||||||
dirs.push(root.into());
|
dirs.push(root.into());
|
||||||
}
|
}
|
||||||
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
// Preserve source discovery after the prebuilt test binary moves into its
|
||||||
dirs.push(PathBuf::from(manifest_dir).join("../../apps"));
|
// networkless execution container.
|
||||||
}
|
dirs.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../apps"));
|
||||||
dirs.extend([
|
dirs.extend([
|
||||||
PathBuf::from("apps"),
|
PathBuf::from("apps"),
|
||||||
PathBuf::from("/opt/archipelago/apps"),
|
PathBuf::from("/opt/archipelago/apps"),
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use rand::RngCore;
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
@@ -227,7 +226,9 @@ pub fn global() -> Arc<ConfirmGate> {
|
|||||||
/// nonce for the same action never matches today's pending entry).
|
/// nonce for the same action never matches today's pending entry).
|
||||||
pub fn mint_nonce(tool_name: &str, validated_args: &str) -> String {
|
pub fn mint_nonce(tool_name: &str, validated_args: &str) -> String {
|
||||||
let mut salt = [0u8; 16];
|
let mut salt = [0u8; 16];
|
||||||
rand::thread_rng().fill_bytes(&mut salt);
|
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut salt).unwrap_or_else(|e| {
|
||||||
|
panic!("refusing to mint a confirmation nonce from degenerate entropy: {e} (KEY-05)")
|
||||||
|
});
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(salt);
|
hasher.update(salt);
|
||||||
hasher.update(tool_name.as_bytes());
|
hasher.update(tool_name.as_bytes());
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ pub const TOKEN_LEN: usize = 8;
|
|||||||
/// wrapped. Called exactly once per [`UntrustedBlock::new`] /
|
/// wrapped. Called exactly once per [`UntrustedBlock::new`] /
|
||||||
/// [`wrap_untrusted`] invocation.
|
/// [`wrap_untrusted`] invocation.
|
||||||
fn fresh_token() -> String {
|
fn fresh_token() -> String {
|
||||||
rand::thread_rng()
|
rand::rngs::OsRng
|
||||||
.sample_iter(Alphanumeric)
|
.sample_iter(Alphanumeric)
|
||||||
.take(TOKEN_LEN)
|
.take(TOKEN_LEN)
|
||||||
.map(char::from)
|
.map(char::from)
|
||||||
|
|||||||
@@ -553,6 +553,24 @@ mod lifecycle_regression_tests {
|
|||||||
assert_eq!(entry.static_files.license, "");
|
assert_eq!(entry.static_files.license, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_presentation_reports_the_declared_author() {
|
||||||
|
let mut entry = installing_fixture();
|
||||||
|
entry.manifest.author = Some("Archipelago".to_owned());
|
||||||
|
for (metadata, expected) in [
|
||||||
|
(serde_json::json!({}), "Archipelago"),
|
||||||
|
(serde_json::json!({"author":" "}), "Archipelago"),
|
||||||
|
(serde_json::json!({"author":3}), "Archipelago"),
|
||||||
|
(serde_json::json!({"author":" ForgeSworn "}), "ForgeSworn"),
|
||||||
|
] {
|
||||||
|
apply_manifest_value(
|
||||||
|
&serde_json::json!({"app":{"metadata":metadata}}),
|
||||||
|
&mut entry,
|
||||||
|
);
|
||||||
|
assert_eq!(entry.manifest.author.as_deref(), Some(expected));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn installed_manifest_entry_path_survives_scans_without_changing_runtime_origin() {
|
fn installed_manifest_entry_path_survives_scans_without_changing_runtime_origin() {
|
||||||
let mut entry = installing_fixture();
|
let mut entry = installing_fixture();
|
||||||
@@ -882,6 +900,10 @@ fn apply_manifest_value(value: &serde_json::Value, entry: &mut PackageDataEntry)
|
|||||||
if let Some(icon) = text(metadata.get("icon")) {
|
if let Some(icon) = text(metadata.get("icon")) {
|
||||||
entry.static_files.icon = icon;
|
entry.static_files.icon = icon;
|
||||||
}
|
}
|
||||||
|
// The scanner's default author is the node OS; a declared author wins.
|
||||||
|
if let Some(author) = text(metadata.get("author")) {
|
||||||
|
entry.manifest.author = Some(author);
|
||||||
|
}
|
||||||
if let Some(tier) = text(metadata.get("tier")) {
|
if let Some(tier) = text(metadata.get("tier")) {
|
||||||
entry.manifest.tier = Some(tier);
|
entry.manifest.tier = Some(tier);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2402,7 +2402,10 @@ impl ProdContainerOrchestrator {
|
|||||||
return Ok(ReconcileAction::Left("user-uninstalled".into()));
|
return Ok(ReconcileAction::Left("user-uninstalled".into()));
|
||||||
}
|
}
|
||||||
self.sync_quadlet_unit(lm, &managed_name).await?;
|
self.sync_quadlet_unit(lm, &managed_name).await?;
|
||||||
let status = self.runtime.get_container_status(&managed_name).await
|
let status = self
|
||||||
|
.runtime
|
||||||
|
.get_container_status(&managed_name)
|
||||||
|
.await
|
||||||
.context("Reviewed managed runtime is missing; explicit recovery required")?;
|
.context("Reviewed managed runtime is missing; explicit recovery required")?;
|
||||||
anyhow::ensure!(matches!(status.state, ContainerState::Running),
|
anyhow::ensure!(matches!(status.state, ContainerState::Running),
|
||||||
"Reviewed managed runtime is not running; recover its saved systemd unit explicitly instead of recreating from the catalog");
|
"Reviewed managed runtime is not running; recover its saved systemd unit explicitly instead of recreating from the catalog");
|
||||||
@@ -4019,36 +4022,38 @@ impl ProdContainerOrchestrator {
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "bitcoin-knots".to_string());
|
.unwrap_or_else(|| "bitcoin-knots".to_string());
|
||||||
}
|
}
|
||||||
#[allow(unreachable_code)]
|
#[cfg(not(test))]
|
||||||
// The known Bitcoin node containers, preferred in order. Any archy
|
{
|
||||||
// Bitcoin distribution runs as a container named `bitcoin-<distro>`
|
// The known Bitcoin node containers, preferred in order. Any archy
|
||||||
// (or bare `bitcoin`), all reachable on archy-net by name.
|
// Bitcoin distribution runs as a container named `bitcoin-<distro>`
|
||||||
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
|
// (or bare `bitcoin`), all reachable on archy-net by name.
|
||||||
let names = tokio::process::Command::new("podman")
|
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
|
||||||
.args(["ps", "--format", "{{.Names}}"])
|
let names = tokio::process::Command::new("podman")
|
||||||
.output()
|
.args(["ps", "--format", "{{.Names}}"])
|
||||||
.await
|
.output()
|
||||||
.ok()
|
.await
|
||||||
.filter(|o| o.status.success())
|
.ok()
|
||||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
.filter(|o| o.status.success())
|
||||||
.unwrap_or_default();
|
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||||
let running: Vec<&str> = names.lines().map(|l| l.trim()).collect();
|
.unwrap_or_default();
|
||||||
// Prefer a known name in priority order…
|
let running: Vec<&str> = names.lines().map(|l| l.trim()).collect();
|
||||||
if let Some(hit) = BITCOIN_NAMES.iter().find(|n| running.contains(n)) {
|
// Prefer a known name in priority order…
|
||||||
return hit.to_string();
|
if let Some(hit) = BITCOIN_NAMES.iter().find(|n| running.contains(n)) {
|
||||||
|
return hit.to_string();
|
||||||
|
}
|
||||||
|
// …else accept ANY running `bitcoin-*` / `bitcoin` container, so a
|
||||||
|
// future Bitcoin distribution archy ships works without editing this
|
||||||
|
// list (user req 2026-07-22). Excludes companions/sidecars like
|
||||||
|
// `bitcoin-ui` and `archy-*`.
|
||||||
|
if let Some(other) = running.iter().find(|n| {
|
||||||
|
(**n == "bitcoin" || n.starts_with("bitcoin-"))
|
||||||
|
&& !n.ends_with("-ui")
|
||||||
|
&& !n.starts_with("archy-")
|
||||||
|
}) {
|
||||||
|
return other.to_string();
|
||||||
|
}
|
||||||
|
"bitcoin-knots".to_string()
|
||||||
}
|
}
|
||||||
// …else accept ANY running `bitcoin-*` / `bitcoin` container, so a
|
|
||||||
// future Bitcoin distribution archy ships works without editing this
|
|
||||||
// list (user req 2026-07-22). Excludes companions/sidecars like
|
|
||||||
// `bitcoin-ui` and `archy-*`.
|
|
||||||
if let Some(other) = running.iter().find(|n| {
|
|
||||||
(**n == "bitcoin" || n.starts_with("bitcoin-"))
|
|
||||||
&& !n.ends_with("-ui")
|
|
||||||
&& !n.starts_with("archy-")
|
|
||||||
}) {
|
|
||||||
return other.to_string();
|
|
||||||
}
|
|
||||||
"bitcoin-knots".to_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -5227,8 +5232,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
|||||||
};
|
};
|
||||||
let name = compute_container_name(&lm.manifest);
|
let name = compute_container_name(&lm.manifest);
|
||||||
if super::supervised_update::installed_unit(&self.data_dir, &name)?.is_some() {
|
if super::supervised_update::installed_unit(&self.data_dir, &name)?.is_some() {
|
||||||
anyhow::ensure!(!super::update_transaction::is_held(&self.data_dir, &name)?,
|
anyhow::ensure!(
|
||||||
"Reviewed managed runtime is held for update recovery");
|
!super::update_transaction::is_held(&self.data_dir, &name)?,
|
||||||
|
"Reviewed managed runtime is held for update recovery"
|
||||||
|
);
|
||||||
self.sync_quadlet_unit(&lm, &name).await?;
|
self.sync_quadlet_unit(&lm, &name).await?;
|
||||||
self.ensure_resolved_source_available(&lm).await?;
|
self.ensure_resolved_source_available(&lm).await?;
|
||||||
}
|
}
|
||||||
@@ -5328,13 +5335,18 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
|||||||
let _guard = lock.lock().await;
|
let _guard = lock.lock().await;
|
||||||
let name = compute_container_name(&lm.manifest);
|
let name = compute_container_name(&lm.manifest);
|
||||||
if super::supervised_update::installed_unit(&self.data_dir, &name)?.is_some() {
|
if super::supervised_update::installed_unit(&self.data_dir, &name)?.is_some() {
|
||||||
anyhow::ensure!(!super::update_transaction::is_held(&self.data_dir, &name)?,
|
anyhow::ensure!(
|
||||||
"Reviewed managed runtime is held for update recovery");
|
!super::update_transaction::is_held(&self.data_dir, &name)?,
|
||||||
|
"Reviewed managed runtime is held for update recovery"
|
||||||
|
);
|
||||||
self.sync_quadlet_unit(&lm, &name).await?;
|
self.sync_quadlet_unit(&lm, &name).await?;
|
||||||
quadlet::stop_service(&format!("{name}.service")).await?;
|
quadlet::stop_service(&format!("{name}.service")).await?;
|
||||||
if let Ok(status) = self.runtime.get_container_status(&name).await {
|
if let Ok(status) = self.runtime.get_container_status(&name).await {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
matches!(status.state, ContainerState::Stopped | ContainerState::Exited | ContainerState::Created),
|
matches!(
|
||||||
|
status.state,
|
||||||
|
ContainerState::Stopped | ContainerState::Exited | ContainerState::Created
|
||||||
|
),
|
||||||
"Reviewed managed runtime is still active after systemd stop"
|
"Reviewed managed runtime is still active after systemd stop"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5391,7 +5403,12 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
|||||||
|
|
||||||
async fn restart(&self, app_id: &str) -> Result<()> {
|
async fn restart(&self, app_id: &str) -> Result<()> {
|
||||||
if let Ok(lm) = self.loaded(app_id).await {
|
if let Ok(lm) = self.loaded(app_id).await {
|
||||||
if super::supervised_update::installed_unit(&self.data_dir, &compute_container_name(&lm.manifest))?.is_some() {
|
if super::supervised_update::installed_unit(
|
||||||
|
&self.data_dir,
|
||||||
|
&compute_container_name(&lm.manifest),
|
||||||
|
)?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
self.validate_start(app_id).await?;
|
self.validate_start(app_id).await?;
|
||||||
self.stop(app_id).await?;
|
self.stop(app_id).await?;
|
||||||
return self.start(app_id).await;
|
return self.start(app_id).await;
|
||||||
@@ -7966,27 +7983,59 @@ app:
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn reviewed_runtime_survives_catalog_drift_and_refuses_repairs_before_mutation() {
|
async fn reviewed_runtime_survives_catalog_drift_and_refuses_repairs_before_mutation() {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
for case in ["running", "stopped", "missing", "changed-unit", "missing-unit", "user-stopped", "user-uninstalled"] {
|
for case in [
|
||||||
|
"running",
|
||||||
|
"stopped",
|
||||||
|
"missing",
|
||||||
|
"changed-unit",
|
||||||
|
"missing-unit",
|
||||||
|
"user-stopped",
|
||||||
|
"user-uninstalled",
|
||||||
|
] {
|
||||||
let rt = Arc::new(MockRuntime::default());
|
let rt = Arc::new(MockRuntime::default());
|
||||||
let orch = orch_with(rt.clone()).await;
|
let orch = orch_with(rt.clone()).await;
|
||||||
let name = format!("managed-{}", uuid::Uuid::new_v4().simple());
|
let name = format!("managed-{}", uuid::Uuid::new_v4().simple());
|
||||||
let mut manifest = pull_manifest(&name, "catalog:new");
|
let mut manifest = pull_manifest(&name, "catalog:new");
|
||||||
manifest.app.environment = vec!["NEW_CATALOG_ENV=changed".into()];
|
manifest.app.environment = vec!["NEW_CATALOG_ENV=changed".into()];
|
||||||
orch.insert_manifest_for_test(manifest, PathBuf::from("/tmp/catalog-drift")).await;
|
orch.insert_manifest_for_test(manifest, PathBuf::from("/tmp/catalog-drift"))
|
||||||
|
.await;
|
||||||
let body = "[Container]\nImage=original:retained\nEnvironment=OLD_ENV=preserved\nPublishPort=127.0.0.1:1234:80\n";
|
let body = "[Container]\nImage=original:retained\nEnvironment=OLD_ENV=preserved\nPublishPort=127.0.0.1:1234:80\n";
|
||||||
let records = orch.data_dir.join("update-transactions/installed-units");
|
let records = orch.data_dir.join("update-transactions/installed-units");
|
||||||
std::fs::create_dir_all(&records).unwrap();
|
std::fs::create_dir_all(&records).unwrap();
|
||||||
std::fs::write(records.join(format!("{name}.json")), serde_json::to_vec(&serde_json::json!({
|
std::fs::write(
|
||||||
"schema": 1, "operation": uuid::Uuid::new_v4().to_string(),
|
records.join(format!("{name}.json")),
|
||||||
"name": name, "body": body, "mode": 0o600
|
serde_json::to_vec(&serde_json::json!({
|
||||||
})).unwrap()).unwrap();
|
"schema": 1, "operation": uuid::Uuid::new_v4().to_string(),
|
||||||
let unit = quadlet::unit_dir().await.unwrap().join(format!("{name}.container"));
|
"name": name, "body": body, "mode": 0o600
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let unit = quadlet::unit_dir()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.join(format!("{name}.container"));
|
||||||
if case != "missing-unit" {
|
if case != "missing-unit" {
|
||||||
std::fs::write(&unit, if case == "changed-unit" { "operator changed" } else { body }).unwrap();
|
std::fs::write(
|
||||||
|
&unit,
|
||||||
|
if case == "changed-unit" {
|
||||||
|
"operator changed"
|
||||||
|
} else {
|
||||||
|
body
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
std::fs::set_permissions(&unit, std::fs::Permissions::from_mode(0o600)).unwrap();
|
std::fs::set_permissions(&unit, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||||
}
|
}
|
||||||
if case != "missing" {
|
if case != "missing" {
|
||||||
rt.set_state(&name, if case == "stopped" { ContainerState::Stopped } else { ContainerState::Running });
|
rt.set_state(
|
||||||
|
&name,
|
||||||
|
if case == "stopped" {
|
||||||
|
ContainerState::Stopped
|
||||||
|
} else {
|
||||||
|
ContainerState::Running
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if case == "user-stopped" {
|
if case == "user-stopped" {
|
||||||
crate::crash_recovery::mark_user_stopped(&orch.data_dir, &name).await;
|
crate::crash_recovery::mark_user_stopped(&orch.data_dir, &name).await;
|
||||||
@@ -8010,7 +8059,13 @@ app:
|
|||||||
assert!(result.is_err(), "{case} must refuse before mutation");
|
assert!(result.is_err(), "{case} must refuse before mutation");
|
||||||
}
|
}
|
||||||
assert_eq!(*rt.containers.lock().unwrap(), before, "{case}");
|
assert_eq!(*rt.containers.lock().unwrap(), before, "{case}");
|
||||||
assert!(rt.calls().iter().all(|call| call.starts_with("get_container_status:")), "{case}: {:?}", rt.calls());
|
assert!(
|
||||||
|
rt.calls()
|
||||||
|
.iter()
|
||||||
|
.all(|call| call.starts_with("get_container_status:")),
|
||||||
|
"{case}: {:?}",
|
||||||
|
rt.calls()
|
||||||
|
);
|
||||||
if case == "running" {
|
if case == "running" {
|
||||||
assert_eq!(std::fs::read_to_string(&unit).unwrap(), body);
|
assert_eq!(std::fs::read_to_string(&unit).unwrap(), body);
|
||||||
}
|
}
|
||||||
@@ -8026,8 +8081,14 @@ app:
|
|||||||
orch.validate_start(&name).await.unwrap();
|
orch.validate_start(&name).await.unwrap();
|
||||||
orch.start(&name).await.unwrap();
|
orch.start(&name).await.unwrap();
|
||||||
assert_eq!(std::fs::read_to_string(&unit).unwrap(), body);
|
assert_eq!(std::fs::read_to_string(&unit).unwrap(), body);
|
||||||
assert!(!crate::crash_recovery::load_user_stopped(&orch.data_dir).await.contains(&name));
|
assert!(!crate::crash_recovery::load_user_stopped(&orch.data_dir)
|
||||||
assert!(!crate::crash_recovery::load_user_uninstalled(&orch.data_dir).await.contains(&name));
|
.await
|
||||||
|
.contains(&name));
|
||||||
|
assert!(
|
||||||
|
!crate::crash_recovery::load_user_uninstalled(&orch.data_dir)
|
||||||
|
.await
|
||||||
|
.contains(&name)
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Missing images/units and modified units refuse an explicit
|
// Missing images/units and modified units refuse an explicit
|
||||||
// start before service mutation; no catalog pull is attempted.
|
// start before service mutation; no catalog pull is attempted.
|
||||||
@@ -8037,7 +8098,14 @@ app:
|
|||||||
assert!(orch.stop(&name).await.is_err());
|
assert!(orch.stop(&name).await.is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert!(rt.calls().iter().all(|call| call.starts_with("get_container_status:") || call.starts_with("image_exists:")), "{case}: {:?}", rt.calls());
|
assert!(
|
||||||
|
rt.calls()
|
||||||
|
.iter()
|
||||||
|
.all(|call| call.starts_with("get_container_status:")
|
||||||
|
|| call.starts_with("image_exists:")),
|
||||||
|
"{case}: {:?}",
|
||||||
|
rt.calls()
|
||||||
|
);
|
||||||
assert_eq!(*rt.containers.lock().unwrap(), before, "{case}");
|
assert_eq!(*rt.containers.lock().unwrap(), before, "{case}");
|
||||||
let _ = std::fs::remove_file(unit);
|
let _ = std::fs::remove_file(unit);
|
||||||
}
|
}
|
||||||
@@ -8664,9 +8732,11 @@ app:
|
|||||||
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
|
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
|
||||||
crate::crash_recovery::save_container_snapshot_for_test(&orch.data_dir, &refs).await;
|
crate::crash_recovery::save_container_snapshot_for_test(&orch.data_dir, &refs).await;
|
||||||
// Repeated passes must leave lifecycle ownership with companion.rs.
|
// Repeated passes must leave lifecycle ownership with companion.rs.
|
||||||
for _ in 0..3 {
|
for pass in 0..3 {
|
||||||
let report = orch.reconcile_existing().await;
|
let report = orch.reconcile_existing().await;
|
||||||
assert_eq!(report.actions.len(), companions.len());
|
if pass == 0 {
|
||||||
|
assert_eq!(report.actions.len(), companions.len());
|
||||||
|
}
|
||||||
assert!(report
|
assert!(report
|
||||||
.actions
|
.actions
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -742,14 +742,17 @@ pub async fn unit_dir() -> Result<PathBuf> {
|
|||||||
.get_or_init(|| tempfile::tempdir().unwrap().keep())
|
.get_or_init(|| tempfile::tempdir().unwrap().keep())
|
||||||
.clone());
|
.clone());
|
||||||
}
|
}
|
||||||
let home = std::env::var_os("HOME")
|
#[cfg(not(test))]
|
||||||
.map(PathBuf::from)
|
{
|
||||||
.ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?;
|
let home = std::env::var_os("HOME")
|
||||||
let dir = home.join(DEFAULT_REL_UNIT_DIR);
|
.map(PathBuf::from)
|
||||||
fs::create_dir_all(&dir)
|
.ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?;
|
||||||
.await
|
let dir = home.join(DEFAULT_REL_UNIT_DIR);
|
||||||
.with_context(|| format!("create_dir_all {}", dir.display()))?;
|
fs::create_dir_all(&dir)
|
||||||
Ok(dir)
|
.await
|
||||||
|
.with_context(|| format!("create_dir_all {}", dir.display()))?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The early same-node Portainer repair used a managed Quadlet drop-in. Once
|
/// The early same-node Portainer repair used a managed Quadlet drop-in. Once
|
||||||
@@ -944,21 +947,25 @@ async fn systemctl_user_status(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
{
|
{
|
||||||
use std::os::unix::process::ExitStatusExt;
|
use std::os::unix::process::ExitStatusExt;
|
||||||
|
let _ = (args, timeout);
|
||||||
return Ok(std::process::ExitStatus::from_raw(0));
|
return Ok(std::process::ExitStatus::from_raw(0));
|
||||||
}
|
}
|
||||||
let mut cmd = Command::new("systemctl");
|
#[cfg(not(test))]
|
||||||
cmd.arg("--user").args(args);
|
{
|
||||||
cmd.kill_on_drop(true);
|
let mut cmd = Command::new("systemctl");
|
||||||
tokio::time::timeout(timeout, cmd.status())
|
cmd.arg("--user").args(args);
|
||||||
.await
|
cmd.kill_on_drop(true);
|
||||||
.with_context(|| {
|
tokio::time::timeout(timeout, cmd.status())
|
||||||
format!(
|
.await
|
||||||
"systemctl --user {} timed out after {}s",
|
.with_context(|| {
|
||||||
args.join(" "),
|
format!(
|
||||||
timeout.as_secs()
|
"systemctl --user {} timed out after {}s",
|
||||||
)
|
args.join(" "),
|
||||||
})?
|
timeout.as_secs()
|
||||||
.with_context(|| format!("spawn systemctl --user {}", args.join(" ")))
|
)
|
||||||
|
})?
|
||||||
|
.with_context(|| format!("spawn systemctl --user {}", args.join(" ")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn kill_and_reset_service(service: &str) -> Result<()> {
|
async fn kill_and_reset_service(service: &str) -> Result<()> {
|
||||||
@@ -994,21 +1001,25 @@ async fn wait_not_deactivating(service: &str, timeout: Duration) -> bool {
|
|||||||
async fn systemctl_user_output(args: &[&str], timeout: Duration) -> Result<std::process::Output> {
|
async fn systemctl_user_output(args: &[&str], timeout: Duration) -> Result<std::process::Output> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
{
|
{
|
||||||
|
let _ = (args, timeout);
|
||||||
anyhow::bail!("Unit tests have no real user service manager");
|
anyhow::bail!("Unit tests have no real user service manager");
|
||||||
}
|
}
|
||||||
let mut cmd = Command::new("systemctl");
|
#[cfg(not(test))]
|
||||||
cmd.arg("--user").args(args);
|
{
|
||||||
cmd.kill_on_drop(true);
|
let mut cmd = Command::new("systemctl");
|
||||||
tokio::time::timeout(timeout, cmd.output())
|
cmd.arg("--user").args(args);
|
||||||
.await
|
cmd.kill_on_drop(true);
|
||||||
.with_context(|| {
|
tokio::time::timeout(timeout, cmd.output())
|
||||||
format!(
|
.await
|
||||||
"systemctl --user {} timed out after {}s",
|
.with_context(|| {
|
||||||
args.join(" "),
|
format!(
|
||||||
timeout.as_secs()
|
"systemctl --user {} timed out after {}s",
|
||||||
)
|
args.join(" "),
|
||||||
})?
|
timeout.as_secs()
|
||||||
.with_context(|| format!("spawn systemctl --user {}", args.join(" ")))
|
)
|
||||||
|
})?
|
||||||
|
.with_context(|| format!("spawn systemctl --user {}", args.join(" ")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn contains_stale_health_gate(unit_body: &str) -> bool {
|
pub fn contains_stale_health_gate(unit_body: &str) -> bool {
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ pub(crate) fn installed_context(
|
|||||||
state: &crate::data_model::DataModel,
|
state: &crate::data_model::DataModel,
|
||||||
) -> Result<InstalledAppContext> {
|
) -> Result<InstalledAppContext> {
|
||||||
for id in ["indeedhub", "indeedhub-api"] {
|
for id in ["indeedhub", "indeedhub-api"] {
|
||||||
|
// Stack components are not separate package records on managed installs.
|
||||||
|
// Keep validating legacy standalone API records when they exist; the
|
||||||
|
// installed parent and its existing API identity pin are mandatory.
|
||||||
|
if id == "indeedhub-api" && !state.package_data.contains_key(id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let entry = state
|
let entry = state
|
||||||
.package_data
|
.package_data
|
||||||
.get(id)
|
.get(id)
|
||||||
@@ -613,6 +619,12 @@ mod tests {
|
|||||||
assert!(installed_context(root.path(), &identity, &state).is_err());
|
assert!(installed_context(root.path(), &identity, &state).is_err());
|
||||||
let pin = ensure_for_installation(root.path(), "indeedhub-api", &identity).unwrap();
|
let pin = ensure_for_installation(root.path(), "indeedhub-api", &identity).unwrap();
|
||||||
let first = installed_context(root.path(), &identity, &state).unwrap();
|
let first = installed_context(root.path(), &identity, &state).unwrap();
|
||||||
|
let api_entry = state.package_data.remove("indeedhub-api").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
installed_context(root.path(), &identity, &state).unwrap(),
|
||||||
|
first
|
||||||
|
);
|
||||||
|
state.package_data.insert("indeedhub-api".into(), api_entry);
|
||||||
assert_eq!(first.app_audience, pin.app_audience);
|
assert_eq!(first.app_audience, pin.app_audience);
|
||||||
assert_eq!(first.app_origins, vec!["https://localhost:7778"]);
|
assert_eq!(first.app_origins, vec!["https://localhost:7778"]);
|
||||||
state.package_data.get_mut("indeedhub-api").unwrap().state =
|
state.package_data.get_mut("indeedhub-api").unwrap().state =
|
||||||
|
|||||||
@@ -1167,6 +1167,40 @@ impl Journal {
|
|||||||
}
|
}
|
||||||
Ok(record)
|
Ok(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retire a settled timed-rental receipt only after the authenticated
|
||||||
|
/// seller has reported its immutable viewing window as expired. This is a
|
||||||
|
/// lifecycle transition, not evidence that a permanent copy was cached:
|
||||||
|
/// it merely permits a later, explicitly approved rental operation for the
|
||||||
|
/// same registered title. Incomplete and still-active purchases must remain
|
||||||
|
/// recoverable under their original UUID.
|
||||||
|
pub async fn record_rental_expiry(
|
||||||
|
&self,
|
||||||
|
contract: &Contract,
|
||||||
|
started_at: u64,
|
||||||
|
expires_at: u64,
|
||||||
|
viewing_seconds: u64,
|
||||||
|
now: u64,
|
||||||
|
) -> Result<BuyerRecord> {
|
||||||
|
let mut record = self.bound_buyer(contract).await?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
contract.content_id.starts_with("registered_")
|
||||||
|
&& record.phase == BuyerPhase::ReceiptSaved
|
||||||
|
&& record.receipt.is_some(),
|
||||||
|
"Only a settled timed rental can be retired"
|
||||||
|
);
|
||||||
|
anyhow::ensure!(
|
||||||
|
viewing_seconds > 0
|
||||||
|
&& started_at > 0
|
||||||
|
&& started_at.checked_add(viewing_seconds) == Some(expires_at)
|
||||||
|
&& now >= expires_at,
|
||||||
|
"Original rental viewing period has not ended"
|
||||||
|
);
|
||||||
|
record.phase = BuyerPhase::Delivered;
|
||||||
|
record.validate()?;
|
||||||
|
self.write("buyer", &contract.id, &record).await?;
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1365,6 +1399,49 @@ mod tests {
|
|||||||
assert!(journal.buyer(&expired.id).await.unwrap().is_none());
|
assert!(journal.buyer(&expired.id).await.unwrap().is_none());
|
||||||
}
|
}
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
async fn only_an_expired_settled_rental_can_be_retired_for_another_period() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let mut rental = contract();
|
||||||
|
rental.content_id = "registered_film-1".into();
|
||||||
|
let journal = Journal::open(root.path()).await.unwrap();
|
||||||
|
journal.prepare_buyer(&rental, 1100).await.unwrap();
|
||||||
|
let seller = journal.prepare_seller(&rental, 1100).await.unwrap();
|
||||||
|
journal
|
||||||
|
.record_acceptance(&rental, &seller.acceptance().unwrap(), &rental.seller_did)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let encoded = token(&rental, "rental-expiry");
|
||||||
|
journal.record_token(&rental, &encoded).await.unwrap();
|
||||||
|
journal
|
||||||
|
.record_incoming_token(&rental, &encoded)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
journal
|
||||||
|
.record_settlement(&rental, rental.minimum_net_sats)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let receipt = journal.issue_receipt(&rental).await.unwrap();
|
||||||
|
journal.record_receipt(&rental, &receipt).await.unwrap();
|
||||||
|
|
||||||
|
assert!(journal
|
||||||
|
.record_rental_expiry(&rental, 2000, 5600, 3600, 5599)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(
|
||||||
|
journal.buyer(&rental.id).await.unwrap().unwrap().phase,
|
||||||
|
BuyerPhase::ReceiptSaved
|
||||||
|
);
|
||||||
|
let retired = journal
|
||||||
|
.record_rental_expiry(&rental, 2000, 5600, 3600, 5600)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(retired.phase, BuyerPhase::Delivered);
|
||||||
|
|
||||||
|
let mut next = rental.clone();
|
||||||
|
next.id = uuid::Uuid::new_v4().to_string();
|
||||||
|
journal.prepare_buyer(&next, 1500).await.unwrap();
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
async fn damaged_records_and_nonregular_targets_are_preserved() {
|
async fn damaged_records_and_nonregular_targets_are_preserved() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
let contract = contract();
|
let contract = contract();
|
||||||
|
|||||||
@@ -206,7 +206,10 @@ pub(crate) async fn purchase_bound(
|
|||||||
.await?;
|
.await?;
|
||||||
let unresolved: Vec<_> = matching
|
let unresolved: Vec<_> = matching
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|record| record.phase != BuyerPhase::Cancelled)
|
.filter(|record| {
|
||||||
|
record.phase != BuyerPhase::Cancelled
|
||||||
|
&& !(expected.is_some() && record.phase == BuyerPhase::Delivered)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
unresolved.len() <= 1,
|
unresolved.len() <= 1,
|
||||||
|
|||||||
@@ -39,6 +39,60 @@ impl FipsPurchaseTransport {
|
|||||||
self.retry_preparation = retry;
|
self.retry_preparation = retry;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask the authenticated seller for the original rental window without
|
||||||
|
/// starting or extending it. The capability never crosses into the app.
|
||||||
|
pub async fn expired_rental_window(
|
||||||
|
&self,
|
||||||
|
contract: &crate::content_purchase::Contract,
|
||||||
|
capability: &str,
|
||||||
|
viewing_seconds: u64,
|
||||||
|
) -> Result<(u64, u64)> {
|
||||||
|
let route = format!(
|
||||||
|
"/content/{}/rental/{}/prepare",
|
||||||
|
contract.content_id, contract.id
|
||||||
|
);
|
||||||
|
let body = serde_json::json!({"capability":capability,"ready_id":null,"retry":false});
|
||||||
|
let (mut response, _) =
|
||||||
|
crate::fips::dial::PeerRequest::new(Some(&self.fips_npub), &self.onion, &route)
|
||||||
|
.require_fips()
|
||||||
|
.single_delivery()
|
||||||
|
.timeout(Duration::from_secs(20))
|
||||||
|
.send_rental_control_json(&self.data_dir, &self.seller_did, &body)
|
||||||
|
.await?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
response.status().is_success(),
|
||||||
|
"Original rental status is unavailable"
|
||||||
|
);
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
while let Some(chunk) = response.chunk().await? {
|
||||||
|
anyhow::ensure!(
|
||||||
|
bytes
|
||||||
|
.len()
|
||||||
|
.checked_add(chunk.len())
|
||||||
|
.is_some_and(|n| n <= 16 * 1024),
|
||||||
|
"Rental status response is too large"
|
||||||
|
);
|
||||||
|
bytes.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
let value: serde_json::Value = serde_json::from_slice(&bytes)?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
value["state"] == "expired",
|
||||||
|
"Original rental viewing period is still available"
|
||||||
|
);
|
||||||
|
let started = value["started_at"]
|
||||||
|
.as_u64()
|
||||||
|
.context("Missing rental start")?;
|
||||||
|
let expires = value["expires_at"]
|
||||||
|
.as_u64()
|
||||||
|
.context("Missing rental expiry")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
value["viewing_seconds"].as_u64() == Some(viewing_seconds)
|
||||||
|
&& started.checked_add(viewing_seconds) == Some(expires),
|
||||||
|
"Seller changed the original rental window"
|
||||||
|
);
|
||||||
|
Ok((started, expires))
|
||||||
|
}
|
||||||
async fn post<B: Serialize + Sync, R: DeserializeOwned>(
|
async fn post<B: Serialize + Sync, R: DeserializeOwned>(
|
||||||
&self,
|
&self,
|
||||||
route: &str,
|
route: &str,
|
||||||
|
|||||||
@@ -171,10 +171,7 @@ pub(crate) fn prepare(
|
|||||||
Err(error)
|
Err(error)
|
||||||
if error
|
if error
|
||||||
.downcast_ref::<std::io::Error>()
|
.downcast_ref::<std::io::Error>()
|
||||||
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) =>
|
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => {}
|
||||||
{
|
|
||||||
()
|
|
||||||
}
|
|
||||||
Err(error) => return Err(error),
|
Err(error) => return Err(error),
|
||||||
}
|
}
|
||||||
let reservation = crate::snapshot_budget::reserve(
|
let reservation = crate::snapshot_budget::reserve(
|
||||||
|
|||||||
@@ -35,6 +35,23 @@ use tokio::net::UdpSocket;
|
|||||||
/// path filter can restrict the exposed surface.
|
/// path filter can restrict the exposed surface.
|
||||||
pub const PEER_PORT: u16 = 5679;
|
pub const PEER_PORT: u16 = 5679;
|
||||||
|
|
||||||
|
fn is_rental_control_route(path: &str) -> bool {
|
||||||
|
let parts: Vec<_> = path.split('/').collect();
|
||||||
|
let valid_id = |value: &str| {
|
||||||
|
!value.is_empty()
|
||||||
|
&& value
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
|
||||||
|
};
|
||||||
|
parts.len() == 6
|
||||||
|
&& parts[0].is_empty()
|
||||||
|
&& parts[1] == "content"
|
||||||
|
&& valid_id(parts[2])
|
||||||
|
&& parts[3] == "rental"
|
||||||
|
&& valid_id(parts[4])
|
||||||
|
&& matches!(parts[5], "prepare" | "start")
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a FIPS-side HTTP status should trigger a fall-back to Tor in
|
/// Whether a FIPS-side HTTP status should trigger a fall-back to Tor in
|
||||||
/// `Auto` mode. A `404` over FIPS often means the peer's mesh listener
|
/// `Auto` mode. A `404` over FIPS often means the peer's mesh listener
|
||||||
/// doesn't expose that path (e.g. a peer on an older build with a stricter
|
/// doesn't expose that path (e.g. a peer on an older build with a stricter
|
||||||
@@ -527,8 +544,27 @@ impl<'a> PeerRequest<'a> {
|
|||||||
request.send_encoded_json(&encoded).await
|
request.send_encoded_json(&encoded).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rental preparation/start carries an already-settled capability, never a
|
||||||
|
/// payment token. Authenticate the exact control route without relaxing the
|
||||||
|
/// purchase-only guard above or replaying any purchase on a retry.
|
||||||
|
pub(crate) async fn send_rental_control_json<B: serde::Serialize>(
|
||||||
|
self,
|
||||||
|
data_dir: &std::path::Path,
|
||||||
|
expected_seller: &str,
|
||||||
|
body: &B,
|
||||||
|
) -> Result<(reqwest::Response, crate::transport::TransportKind)> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
is_rental_control_route(self.path),
|
||||||
|
"Not a rental control route"
|
||||||
|
);
|
||||||
|
let (request, encoded) = self
|
||||||
|
.prepare_authenticated_content_json(data_dir, expected_seller, body)
|
||||||
|
.await?;
|
||||||
|
request.send_encoded_json(&encoded).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn prepare_content_json<B: serde::Serialize>(
|
async fn prepare_content_json<B: serde::Serialize>(
|
||||||
mut self,
|
self,
|
||||||
data_dir: &std::path::Path,
|
data_dir: &std::path::Path,
|
||||||
expected_seller: &str,
|
expected_seller: &str,
|
||||||
body: &B,
|
body: &B,
|
||||||
@@ -537,6 +573,16 @@ impl<'a> PeerRequest<'a> {
|
|||||||
self.path.starts_with("/content/purchase/"),
|
self.path.starts_with("/content/purchase/"),
|
||||||
"Not a purchase route"
|
"Not a purchase route"
|
||||||
);
|
);
|
||||||
|
self.prepare_authenticated_content_json(data_dir, expected_seller, body)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prepare_authenticated_content_json<B: serde::Serialize>(
|
||||||
|
mut self,
|
||||||
|
data_dir: &std::path::Path,
|
||||||
|
expected_seller: &str,
|
||||||
|
body: &B,
|
||||||
|
) -> Result<(Self, Vec<u8>)> {
|
||||||
let peer = crate::federation::load_unique_payment_peer(data_dir, self.onion_host).await?;
|
let peer = crate::federation::load_unique_payment_peer(data_dir, self.onion_host).await?;
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
peer.did == expected_seller,
|
peer.did == expected_seller,
|
||||||
@@ -887,6 +933,28 @@ impl<'a> PeerRequest<'a> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rental_control_routes_are_exact_and_cannot_be_purchase_routes() {
|
||||||
|
assert!(is_rental_control_route(
|
||||||
|
"/content/registered_abc/rental/1234-5678/prepare"
|
||||||
|
));
|
||||||
|
assert!(is_rental_control_route(
|
||||||
|
"/content/registered_abc/rental/1234-5678/start"
|
||||||
|
));
|
||||||
|
for path in [
|
||||||
|
"/content/purchase/abc",
|
||||||
|
"/content/registered_abc/rental/1234-5678/settle",
|
||||||
|
"/content/registered_abc/rental/1234-5678/prepare/extra",
|
||||||
|
"/content/../rental/1234-5678/prepare",
|
||||||
|
"/content/registered_abc/rental/1234-5678/prepare?token=x",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!is_rental_control_route(path),
|
||||||
|
"unexpected rental control route: {path}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn purchase_post_serializes_once_and_binds_the_actual_bytes_to_the_peer() {
|
async fn purchase_post_serializes_once_and_binds_the_actual_bytes_to_the_peer() {
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ pub const FIPS_IFACE: &str = "fips0";
|
|||||||
/// - Link-local (`fe80::/10`) and non-ULA addresses are ignored — we
|
/// - Link-local (`fe80::/10`) and non-ULA addresses are ignored — we
|
||||||
/// only want the mesh-routable ULA that `<npub>.fips` DNS resolves to.
|
/// only want the mesh-routable ULA that `<npub>.fips` DNS resolves to.
|
||||||
pub fn fips0_ula() -> Option<Ipv6Addr> {
|
pub fn fips0_ula() -> Option<Ipv6Addr> {
|
||||||
addresses_on(FIPS_IFACE).into_iter().find(|a| is_ula(a))
|
addresses_on(FIPS_IFACE).into_iter().find(is_ula)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List every IPv6 address bound to a given interface from
|
/// List every IPv6 address bound to a given interface from
|
||||||
|
|||||||
@@ -946,7 +946,10 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
|||||||
}
|
}
|
||||||
if matches!(
|
if matches!(
|
||||||
pkg.state,
|
pkg.state,
|
||||||
PackageState::Starting | PackageState::Stopping | PackageState::Restarting | PackageState::Updating
|
PackageState::Starting
|
||||||
|
| PackageState::Stopping
|
||||||
|
| PackageState::Restarting
|
||||||
|
| PackageState::Updating
|
||||||
) {
|
) {
|
||||||
debug!(
|
debug!(
|
||||||
"Skipping container during package lifecycle transition: {} ({:?})",
|
"Skipping container during package lifecycle transition: {} ({:?})",
|
||||||
@@ -1069,7 +1072,8 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
|||||||
app_id: Some(container.app_id.clone()),
|
app_id: Some(container.app_id.clone()),
|
||||||
});
|
});
|
||||||
if data.notifications.len() > 20 {
|
if data.notifications.len() > 20 {
|
||||||
data.notifications = data.notifications.split_off(data.notifications.len() - 20);
|
data.notifications =
|
||||||
|
data.notifications.split_off(data.notifications.len() - 20);
|
||||||
}
|
}
|
||||||
state_changed = true;
|
state_changed = true;
|
||||||
}
|
}
|
||||||
@@ -1164,7 +1168,8 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
|||||||
// the restart resyncs cleanly instead of crash-looping.
|
// the restart resyncs cleanly instead of crash-looping.
|
||||||
maybe_recover_corrupt_electrumx(&container.name, attempt).await;
|
maybe_recover_corrupt_electrumx(&container.name, attempt).await;
|
||||||
|
|
||||||
let restarted = restart_container(&container.name, &container.state, &data_dir).await;
|
let restarted =
|
||||||
|
restart_container(&container.name, &container.state, &data_dir).await;
|
||||||
|
|
||||||
if !restarted || attempt >= MAX_RESTART_ATTEMPTS {
|
if !restarted || attempt >= MAX_RESTART_ATTEMPTS {
|
||||||
let notification = Notification {
|
let notification = Notification {
|
||||||
@@ -1249,10 +1254,15 @@ mod tests {
|
|||||||
let installed = root.path().join("update-transactions/installed-units");
|
let installed = root.path().join("update-transactions/installed-units");
|
||||||
std::fs::create_dir_all(&installed).unwrap();
|
std::fs::create_dir_all(&installed).unwrap();
|
||||||
let record = installed.join(format!("{name}.json"));
|
let record = installed.join(format!("{name}.json"));
|
||||||
std::fs::write(&record, serde_json::to_vec(&serde_json::json!({
|
std::fs::write(
|
||||||
"schema": 1, "operation": uuid::Uuid::new_v4().to_string(),
|
&record,
|
||||||
"name": name, "body": "[Container]\nImage=original:retained\n", "mode": 0o600
|
serde_json::to_vec(&serde_json::json!({
|
||||||
})).unwrap()).unwrap();
|
"schema": 1, "operation": uuid::Uuid::new_v4().to_string(),
|
||||||
|
"name": name, "body": "[Container]\nImage=original:retained\n", "mode": 0o600
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(!automatic_recovery_allowed(root.path(), name));
|
assert!(!automatic_recovery_allowed(root.path(), name));
|
||||||
assert!(!restart_container(name, "running", root.path()).await);
|
assert!(!restart_container(name, "running", root.path()).await);
|
||||||
std::fs::write(&record, b"damaged").unwrap();
|
std::fs::write(&record, b"damaged").unwrap();
|
||||||
|
|||||||
@@ -205,14 +205,22 @@ impl IdentityManager {
|
|||||||
nsec: &str,
|
nsec: &str,
|
||||||
expected_npub: &str,
|
expected_npub: &str,
|
||||||
) -> Result<IdentityRecord> {
|
) -> Result<IdentityRecord> {
|
||||||
anyhow::ensure!(!name.trim().is_empty() && name.len() <= 100, "Invalid identity name");
|
anyhow::ensure!(
|
||||||
anyhow::ensure!(nsec.starts_with("nsec1") && nsec.len() == 63, "Enter a plain nsec owner key");
|
!name.trim().is_empty() && name.len() <= 100,
|
||||||
let secret = nostr_sdk::SecretKey::parse(nsec)
|
"Invalid identity name"
|
||||||
.map_err(|_| anyhow::anyhow!("Invalid owner key"))?;
|
);
|
||||||
|
anyhow::ensure!(
|
||||||
|
nsec.starts_with("nsec1") && nsec.len() == 63,
|
||||||
|
"Enter a plain nsec owner key"
|
||||||
|
);
|
||||||
|
let secret =
|
||||||
|
nostr_sdk::SecretKey::parse(nsec).map_err(|_| anyhow::anyhow!("Invalid owner key"))?;
|
||||||
let keys = nostr_sdk::Keys::new(secret);
|
let keys = nostr_sdk::Keys::new(secret);
|
||||||
let nostr_pubkey = keys.public_key().to_hex();
|
let nostr_pubkey = keys.public_key().to_hex();
|
||||||
anyhow::ensure!(keys.public_key().to_bech32()? == expected_npub,
|
anyhow::ensure!(
|
||||||
"Owner key does not match this website");
|
keys.public_key().to_bech32()? == expected_npub,
|
||||||
|
"Owner key does not match this website"
|
||||||
|
);
|
||||||
|
|
||||||
// Serializes imports only; mature creation/signing paths are untouched.
|
// Serializes imports only; mature creation/signing paths are untouched.
|
||||||
static IMPORT_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
static IMPORT_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||||
@@ -260,7 +268,8 @@ impl IdentityManager {
|
|||||||
// Atomic publication, and unlike rename this cannot replace a file.
|
// Atomic publication, and unlike rename this cannot replace a file.
|
||||||
fs::hard_link(&staging, &destination).await?;
|
fs::hard_link(&staging, &destination).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}.await;
|
}
|
||||||
|
.await;
|
||||||
let _ = fs::remove_file(&staging).await;
|
let _ = fs::remove_file(&staging).await;
|
||||||
write_result.context("Could not save imported identity")?;
|
write_result.context("Could not save imported identity")?;
|
||||||
self.get(&id).await
|
self.get(&id).await
|
||||||
@@ -974,23 +983,53 @@ mod tests {
|
|||||||
async fn import_nostr_preserves_identities_and_rejects_mismatches() {
|
async fn import_nostr_preserves_identities_and_rejects_mismatches() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let manager = IdentityManager::new(dir.path()).await.unwrap();
|
let manager = IdentityManager::new(dir.path()).await.unwrap();
|
||||||
let original = manager.create("Personal".into(), IdentityPurpose::Personal).await.unwrap();
|
let original = manager
|
||||||
|
.create("Personal".into(), IdentityPurpose::Personal)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let keys = nostr_sdk::Keys::generate();
|
let keys = nostr_sdk::Keys::generate();
|
||||||
let nsec = keys.secret_key().to_bech32().unwrap();
|
let nsec = keys.secret_key().to_bech32().unwrap();
|
||||||
let npub = keys.public_key().to_bech32().unwrap();
|
let npub = keys.public_key().to_bech32().unwrap();
|
||||||
assert!(manager.import_nostr("Wrong".into(), &nsec, "npub1wrong").await.is_err());
|
assert!(manager
|
||||||
|
.import_nostr("Wrong".into(), &nsec, "npub1wrong")
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
assert_eq!(manager.list().await.unwrap().0.len(), 1);
|
assert_eq!(manager.list().await.unwrap().0.len(), 1);
|
||||||
let imported = manager.import_nostr("Website".into(), &nsec, &npub).await.unwrap();
|
let imported = manager
|
||||||
|
.import_nostr("Website".into(), &nsec, &npub)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(imported.nostr_npub.as_deref(), Some(npub.as_str()));
|
assert_eq!(imported.nostr_npub.as_deref(), Some(npub.as_str()));
|
||||||
assert_eq!(manager.import_nostr("Again".into(), &nsec, &npub).await.unwrap().id, imported.id);
|
assert_eq!(
|
||||||
|
manager
|
||||||
|
.import_nostr("Again".into(), &nsec, &npub)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.id,
|
||||||
|
imported.id
|
||||||
|
);
|
||||||
let (records, default) = manager.list().await.unwrap();
|
let (records, default) = manager.list().await.unwrap();
|
||||||
assert_eq!(records.len(), 2);
|
assert_eq!(records.len(), 2);
|
||||||
assert_eq!(default.as_deref(), Some(original.id.as_str()));
|
assert_eq!(default.as_deref(), Some(original.id.as_str()));
|
||||||
assert_eq!(manager.get(&original.id).await.unwrap().nostr_pubkey, original.nostr_pubkey);
|
assert_eq!(
|
||||||
assert_eq!(manager.export_keys(&imported.id).await.unwrap()["nostr_nsec"], nsec);
|
manager.get(&original.id).await.unwrap().nostr_pubkey,
|
||||||
#[cfg(unix)] {
|
original.nostr_pubkey
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manager.export_keys(&imported.id).await.unwrap()["nostr_nsec"],
|
||||||
|
nsec
|
||||||
|
);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let mode = std::fs::metadata(dir.path().join("identities").join(format!("{}.json", imported.id))).unwrap().permissions().mode();
|
let mode = std::fs::metadata(
|
||||||
|
dir.path()
|
||||||
|
.join("identities")
|
||||||
|
.join(format!("{}.json", imported.id)),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode();
|
||||||
assert_eq!(mode & 0o777, 0o600);
|
assert_eq!(mode & 0o777, 0o600);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1012,14 +1051,24 @@ mod tests {
|
|||||||
assert_eq!(records.len(), 1);
|
assert_eq!(records.len(), 1);
|
||||||
assert!(default.is_none());
|
assert!(default.is_none());
|
||||||
let hash = [7u8; 32];
|
let hash = [7u8; 32];
|
||||||
let signature = manager.nostr_sign(&first.id, &hex::encode(hash)).await.unwrap();
|
let signature = manager
|
||||||
|
.nostr_sign(&first.id, &hex::encode(hash))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let signature: nostr_sdk::secp256k1::schnorr::Signature = signature.parse().unwrap();
|
let signature: nostr_sdk::secp256k1::schnorr::Signature = signature.parse().unwrap();
|
||||||
let pubkey: nostr_sdk::secp256k1::XOnlyPublicKey = keys.public_key().to_hex().parse().unwrap();
|
let pubkey: nostr_sdk::secp256k1::XOnlyPublicKey =
|
||||||
nostr_sdk::secp256k1::Secp256k1::verification_only().verify_schnorr(
|
keys.public_key().to_hex().parse().unwrap();
|
||||||
&signature, &nostr_sdk::secp256k1::Message::from_digest(hash), &pubkey,
|
nostr_sdk::secp256k1::Secp256k1::verification_only()
|
||||||
).unwrap();
|
.verify_schnorr(
|
||||||
|
&signature,
|
||||||
|
&nostr_sdk::secp256k1::Message::from_digest(hash),
|
||||||
|
&pubkey,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let entries = std::fs::read_dir(dir.path().join("identities")).unwrap();
|
let entries = std::fs::read_dir(dir.path().join("identities")).unwrap();
|
||||||
assert!(entries.map(|entry| entry.unwrap().file_name()).all(|name| !name.to_string_lossy().ends_with(".tmp")));
|
assert!(entries
|
||||||
|
.map(|entry| entry.unwrap().file_name())
|
||||||
|
.all(|name| !name.to_string_lossy().ends_with(".tmp")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ mod monitoring;
|
|||||||
mod music;
|
mod music;
|
||||||
mod names;
|
mod names;
|
||||||
mod network;
|
mod network;
|
||||||
mod publishing;
|
|
||||||
mod node_message;
|
mod node_message;
|
||||||
mod nostr_discovery;
|
mod nostr_discovery;
|
||||||
mod nostr_handshake;
|
mod nostr_handshake;
|
||||||
@@ -87,6 +86,7 @@ mod nostr_security_tests;
|
|||||||
mod peers;
|
mod peers;
|
||||||
mod port_allocator;
|
mod port_allocator;
|
||||||
mod prepared_media;
|
mod prepared_media;
|
||||||
|
mod publishing;
|
||||||
mod rate_limit;
|
mod rate_limit;
|
||||||
mod registered_media;
|
mod registered_media;
|
||||||
mod rental_chunk_index;
|
mod rental_chunk_index;
|
||||||
|
|||||||
@@ -683,10 +683,7 @@ pub fn resolve(
|
|||||||
Err(error)
|
Err(error)
|
||||||
if error
|
if error
|
||||||
.downcast_ref::<std::io::Error>()
|
.downcast_ref::<std::io::Error>()
|
||||||
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) =>
|
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => {}
|
||||||
{
|
|
||||||
()
|
|
||||||
}
|
|
||||||
Err(error) => return Err(error),
|
Err(error) => return Err(error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,22 +113,19 @@ const PORT_FREE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10
|
|||||||
/// resource is gone yet).
|
/// resource is gone yet).
|
||||||
async fn wait_for_port_free(path: &str) -> Result<()> {
|
async fn wait_for_port_free(path: &str) -> Result<()> {
|
||||||
let deadline = tokio::time::Instant::now() + PORT_FREE_TIMEOUT;
|
let deadline = tokio::time::Instant::now() + PORT_FREE_TIMEOUT;
|
||||||
let mut last_err = None;
|
|
||||||
loop {
|
loop {
|
||||||
match serial2_tokio::SerialPort::open(path, 115200) {
|
match serial2_tokio::SerialPort::open(path, 115200) {
|
||||||
Ok(_) => return Ok(()),
|
Ok(_) => return Ok(()),
|
||||||
Err(e) => last_err = Some(e),
|
Err(error) if tokio::time::Instant::now() >= deadline => {
|
||||||
}
|
return Err(anyhow::anyhow!(
|
||||||
if tokio::time::Instant::now() >= deadline {
|
"{path} is still held open by something else after {}s (last error: {error}) — refusing to start the flasher against a contended port",
|
||||||
break;
|
PORT_FREE_TIMEOUT.as_secs(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(_) => {}
|
||||||
}
|
}
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
}
|
}
|
||||||
Err(anyhow::anyhow!(
|
|
||||||
"{path} is still held open by something else after {}s (last error: {}) — refusing to start the flasher against a contended port",
|
|
||||||
PORT_FREE_TIMEOUT.as_secs(),
|
|
||||||
last_err.map(|e| e.to_string()).unwrap_or_default()
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Live state for the one flash job that can run at a time. A single global
|
/// Live state for the one flash job that can run at a time. A single global
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ mod frames;
|
|||||||
mod node_cmd;
|
mod node_cmd;
|
||||||
mod session;
|
mod session;
|
||||||
|
|
||||||
pub(crate) use session::{probe_device, DeviceProbe};
|
pub(crate) use session::probe_device;
|
||||||
|
|
||||||
use super::types::*;
|
use super::types::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|||||||
@@ -551,17 +551,19 @@ pub fn parse_contact_msg_v3(data: &[u8]) -> Result<(String, String, i8)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse RESP_CHANNEL_MSG_V3 (0x11) - channel message.
|
/// Parse RESP_CHANNEL_MSG_V3 (0x11) - channel message.
|
||||||
/// Format: channel_idx(1B) + path_len(1B) + txt_type(1B) + timestamp(4B) + text
|
/// Format after the response code has been removed:
|
||||||
|
/// SNR(1B) + reserved(2B) + channel_idx(1B) + path_len(1B) +
|
||||||
|
/// txt_type(1B) + timestamp(4B) + text.
|
||||||
/// Returns (channel_idx, text).
|
/// Returns (channel_idx, text).
|
||||||
pub fn parse_channel_msg_v3(data: &[u8]) -> Result<(u8, String)> {
|
pub fn parse_channel_msg_v3(data: &[u8]) -> Result<(u8, String)> {
|
||||||
if data.len() < 7 {
|
if data.len() < 10 {
|
||||||
anyhow::bail!("Channel message too short: {} bytes", data.len());
|
anyhow::bail!("Channel message too short: {} bytes", data.len());
|
||||||
}
|
}
|
||||||
let channel_idx = data[0];
|
let channel_idx = data[3];
|
||||||
// data[1] = path_len, data[2] = txt_type
|
// data[0] = SNR, data[1..3] = reserved, data[4] = path_len,
|
||||||
// data[3..7] = timestamp
|
// data[5] = txt_type, data[6..10] = timestamp.
|
||||||
let text = if data.len() > 7 {
|
let text = if data.len() > 10 {
|
||||||
String::from_utf8_lossy(&data[7..])
|
String::from_utf8_lossy(&data[10..])
|
||||||
.trim_end_matches('\0')
|
.trim_end_matches('\0')
|
||||||
.to_string()
|
.to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -649,12 +651,12 @@ pub fn parse_contact_msg_v1_raw(data: &[u8]) -> Result<(String, Vec<u8>)> {
|
|||||||
/// Parse RESP_CHANNEL_MSG_V3 returning raw payload bytes.
|
/// Parse RESP_CHANNEL_MSG_V3 returning raw payload bytes.
|
||||||
/// Returns (channel_idx, raw_payload_bytes).
|
/// Returns (channel_idx, raw_payload_bytes).
|
||||||
pub fn parse_channel_msg_v3_raw(data: &[u8]) -> Result<(u8, Vec<u8>)> {
|
pub fn parse_channel_msg_v3_raw(data: &[u8]) -> Result<(u8, Vec<u8>)> {
|
||||||
if data.len() < 7 {
|
if data.len() < 10 {
|
||||||
anyhow::bail!("Channel message too short: {} bytes", data.len());
|
anyhow::bail!("Channel message too short: {} bytes", data.len());
|
||||||
}
|
}
|
||||||
let channel_idx = data[0];
|
let channel_idx = data[3];
|
||||||
let payload = if data.len() > 7 {
|
let payload = if data.len() > 10 {
|
||||||
let mut p = data[7..].to_vec();
|
let mut p = data[10..].to_vec();
|
||||||
// Strip trailing NUL bytes
|
// Strip trailing NUL bytes
|
||||||
while p.last() == Some(&0) {
|
while p.last() == Some(&0) {
|
||||||
p.pop();
|
p.pop();
|
||||||
@@ -779,6 +781,23 @@ pub fn parse_identity_broadcast(msg: &str) -> Option<(String, String, String)> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_v3_channel_after_snr_and_reserved_prefix() -> Result<()> {
|
||||||
|
// decode_frame removes the 0x11 response code before this parser sees
|
||||||
|
// the payload. A real V3 frame then begins with SNR + two reserved
|
||||||
|
// bytes; treating SNR as the channel index filed public messages under
|
||||||
|
// random channels such as 47 or 208.
|
||||||
|
let data = [
|
||||||
|
0xd0, 0x00, 0x00, // SNR and reserved
|
||||||
|
0x00, 0xff, 0x00, // public channel, path, text type
|
||||||
|
0x78, 0x56, 0x34, 0x12, // timestamp
|
||||||
|
b'h', b'e', b'l', b'l', b'o', 0x00,
|
||||||
|
];
|
||||||
|
assert_eq!(parse_channel_msg_v3(&data)?, (0, "hello".to_string()));
|
||||||
|
assert_eq!(parse_channel_msg_v3_raw(&data)?, (0, b"hello".to_vec()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_encode_frame() {
|
fn test_encode_frame() {
|
||||||
let frame = encode_frame(&[CMD_DEVICE_QUERY, PROTOCOL_VERSION]);
|
let frame = encode_frame(&[CMD_DEVICE_QUERY, PROTOCOL_VERSION]);
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ async fn read_disk_usage() -> Result<(u64, u64)> {
|
|||||||
};
|
};
|
||||||
let mut command = tokio::process::Command::new("df");
|
let mut command = tokio::process::Command::new("df");
|
||||||
command.args(["--block-size=1", "--output=used,size", target]);
|
command.args(["--block-size=1", "--output=used,size", target]);
|
||||||
let output = bounded_output(command, std::time::Duration::from_secs(3)).await
|
let output = bounded_output(command, std::time::Duration::from_secs(3))
|
||||||
|
.await
|
||||||
.context("Failed to run df")?;
|
.context("Failed to run df")?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
@@ -222,7 +223,8 @@ async fn bounded_output(
|
|||||||
) -> Result<std::process::Output> {
|
) -> Result<std::process::Output> {
|
||||||
command.kill_on_drop(true);
|
command.kill_on_drop(true);
|
||||||
tokio::time::timeout(timeout, command.output())
|
tokio::time::timeout(timeout, command.output())
|
||||||
.await.context("Metrics subprocess timed out")?
|
.await
|
||||||
|
.context("Metrics subprocess timed out")?
|
||||||
.context("Metrics subprocess failed")
|
.context("Metrics subprocess failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +232,8 @@ async fn bounded_output(
|
|||||||
async fn read_container_stats() -> Result<Vec<ContainerMetrics>> {
|
async fn read_container_stats() -> Result<Vec<ContainerMetrics>> {
|
||||||
let mut command = tokio::process::Command::new("podman");
|
let mut command = tokio::process::Command::new("podman");
|
||||||
command.args(["stats", "--no-stream", "--format", "json"]);
|
command.args(["stats", "--no-stream", "--format", "json"]);
|
||||||
let output = bounded_output(command, std::time::Duration::from_secs(8)).await
|
let output = bounded_output(command, std::time::Duration::from_secs(8))
|
||||||
|
.await
|
||||||
.context("Failed to run podman stats")?;
|
.context("Failed to run podman stats")?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
@@ -411,14 +414,22 @@ mod subprocess_deadline_tests {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let pid_file = dir.path().join("pid");
|
let pid_file = dir.path().join("pid");
|
||||||
let mut command = tokio::process::Command::new("sh");
|
let mut command = tokio::process::Command::new("sh");
|
||||||
command.arg("-c").arg("echo $$ > \"$1\"; exec sleep 30").arg("metrics-test").arg(&pid_file);
|
command
|
||||||
|
.arg("-c")
|
||||||
|
.arg("echo $$ > \"$1\"; exec sleep 30")
|
||||||
|
.arg("metrics-test")
|
||||||
|
.arg(&pid_file);
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
let error = bounded_output(command, std::time::Duration::from_millis(500)).await.unwrap_err();
|
let error = bounded_output(command, std::time::Duration::from_millis(500))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
assert!(error.to_string().contains("timed out"));
|
assert!(error.to_string().contains("timed out"));
|
||||||
assert!(start.elapsed() < std::time::Duration::from_secs(3));
|
assert!(start.elapsed() < std::time::Duration::from_secs(3));
|
||||||
let pid = tokio::fs::read_to_string(pid_file).await.unwrap();
|
let pid = tokio::fs::read_to_string(pid_file).await.unwrap();
|
||||||
for _ in 0..40 {
|
for _ in 0..40 {
|
||||||
if !std::path::Path::new(&format!("/proc/{}", pid.trim())).exists() { return; }
|
if !std::path::Path::new(&format!("/proc/{}", pid.trim())).exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||||
}
|
}
|
||||||
panic!("Timed-out metrics subprocess was not reaped");
|
panic!("Timed-out metrics subprocess was not reaped");
|
||||||
@@ -428,7 +439,9 @@ mod subprocess_deadline_tests {
|
|||||||
async fn successful_metrics_output_is_preserved() {
|
async fn successful_metrics_output_is_preserved() {
|
||||||
let mut command = tokio::process::Command::new("printf");
|
let mut command = tokio::process::Command::new("printf");
|
||||||
command.arg("metrics-ok");
|
command.arg("metrics-ok");
|
||||||
let output = bounded_output(command, std::time::Duration::from_secs(1)).await.unwrap();
|
let output = bounded_output(command, std::time::Duration::from_secs(1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert!(output.status.success());
|
assert!(output.status.success());
|
||||||
assert_eq!(output.stdout, b"metrics-ok");
|
assert_eq!(output.stdout, b"metrics-ok");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,10 +40,12 @@ pub fn spawn_metrics_collector(
|
|||||||
store.push(snapshot).await;
|
store.push(snapshot).await;
|
||||||
debug!("Metrics snapshot collected");
|
debug!("Metrics snapshot collected");
|
||||||
|
|
||||||
|
// Reconcile even when all thresholds have recovered, so
|
||||||
|
// the previous disk/RAM/CPU warning disappears promptly.
|
||||||
|
if let Some(ref state_mgr) = state {
|
||||||
|
notifications::push_alert_notifications(state_mgr, &alerts).await;
|
||||||
|
}
|
||||||
if !alerts.is_empty() {
|
if !alerts.is_empty() {
|
||||||
if let Some(ref state_mgr) = state {
|
|
||||||
notifications::push_alert_notifications(state_mgr, &alerts).await;
|
|
||||||
}
|
|
||||||
if let Some(ref dir) = data_dir {
|
if let Some(ref dir) = data_dir {
|
||||||
notifications::deliver_alert_webhooks(dir, &alerts).await;
|
notifications::deliver_alert_webhooks(dir, &alerts).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ pub(crate) async fn push_alert_notifications(
|
|||||||
alerts: &[FiredAlert],
|
alerts: &[FiredAlert],
|
||||||
) {
|
) {
|
||||||
let (mut data, _rev) = state_mgr.get_snapshot().await;
|
let (mut data, _rev) = state_mgr.get_snapshot().await;
|
||||||
|
let previous_count = data.notifications.len();
|
||||||
prune_stale_alert_notifications(&mut data.notifications, alerts);
|
prune_stale_alert_notifications(&mut data.notifications, alerts);
|
||||||
|
if alerts.is_empty() && data.notifications.len() == previous_count {
|
||||||
|
return;
|
||||||
|
}
|
||||||
for alert in alerts {
|
for alert in alerts {
|
||||||
let level = match alert.kind {
|
let level = match alert.kind {
|
||||||
AlertRuleKind::DiskUsage | AlertRuleKind::RamUsage => {
|
AlertRuleKind::DiskUsage | AlertRuleKind::RamUsage => {
|
||||||
@@ -42,7 +46,17 @@ pub(crate) async fn push_alert_notifications(
|
|||||||
data.notifications.remove(0);
|
data.notifications.remove(0);
|
||||||
}
|
}
|
||||||
state_mgr.update_data(data).await;
|
state_mgr.update_data(data).await;
|
||||||
info!("Fired {} alert(s)", alerts.len());
|
if !alerts.is_empty() {
|
||||||
|
info!("Fired {} alert(s)", alerts.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_metrics_alert_id(id: &str) -> bool {
|
||||||
|
id.split_once('-').is_some_and(|(kind, timestamp)| {
|
||||||
|
matches!(kind, "disk" | "ram" | "cpu" | "latency")
|
||||||
|
&& !timestamp.is_empty()
|
||||||
|
&& timestamp.bytes().all(|byte| byte.is_ascii_digit())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prune_stale_alert_notifications(
|
fn prune_stale_alert_notifications(
|
||||||
@@ -55,6 +69,11 @@ fn prune_stale_alert_notifications(
|
|||||||
if active_ids.contains(notification.id.as_str()) {
|
if active_ids.contains(notification.id.as_str()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// A fresh timestamp is not evidence that an earlier threshold is
|
||||||
|
// still exceeded. Remove resolved monitoring alerts immediately.
|
||||||
|
if is_metrics_alert_id(¬ification.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if notification.app_id.is_some() || notification.id.starts_with("health-") {
|
if notification.app_id.is_some() || notification.id.starts_with("health-") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -127,6 +146,8 @@ mod tests {
|
|||||||
notification("alert-active", fresh_timestamp.clone(), None),
|
notification("alert-active", fresh_timestamp.clone(), None),
|
||||||
notification("alert-old", old_timestamp, None),
|
notification("alert-old", old_timestamp, None),
|
||||||
notification("alert-fresh", fresh_timestamp.clone(), None),
|
notification("alert-fresh", fresh_timestamp.clone(), None),
|
||||||
|
notification("disk-123", fresh_timestamp.clone(), None),
|
||||||
|
notification("ram-123", fresh_timestamp.clone(), None),
|
||||||
notification("health-indeedhub-1", fresh_timestamp, Some("indeedhub")),
|
notification("health-indeedhub-1", fresh_timestamp, Some("indeedhub")),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -135,4 +156,22 @@ mod tests {
|
|||||||
let ids: Vec<&str> = notifications.iter().map(|n| n.id.as_str()).collect();
|
let ids: Vec<&str> = notifications.iter().map(|n| n.id.as_str()).collect();
|
||||||
assert_eq!(ids, vec!["alert-fresh", "health-indeedhub-1"]);
|
assert_eq!(ids, vec!["alert-fresh", "health-indeedhub-1"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clears_resolved_metrics_alerts_without_touching_other_notifications() {
|
||||||
|
let fresh = Utc::now().to_rfc3339();
|
||||||
|
let mut notifications = vec![
|
||||||
|
notification("disk-123", fresh.clone(), None),
|
||||||
|
notification("latency-456", fresh.clone(), None),
|
||||||
|
notification("disk-not-a-timestamp", fresh.clone(), None),
|
||||||
|
notification("health-indeedhub-1", fresh.clone(), Some("indeedhub")),
|
||||||
|
notification("app-notice", fresh, Some("indeedhub")),
|
||||||
|
];
|
||||||
|
prune_stale_alert_notifications(&mut notifications, &[]);
|
||||||
|
let ids: Vec<&str> = notifications.iter().map(|n| n.id.as_str()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
ids,
|
||||||
|
vec!["disk-not-a-timestamp", "health-indeedhub-1", "app-notice"]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ impl MetricsStore {
|
|||||||
// Use saturating semantics to avoid underflow
|
// Use saturating semantics to avoid underflow
|
||||||
let _ = self
|
let _ = self
|
||||||
.ws_connections
|
.ws_connections
|
||||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
|
.try_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
|
||||||
if v > 0 {
|
if v > 0 {
|
||||||
Some(v - 1)
|
Some(v - 1)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ pub async fn publish_presence(
|
|||||||
// NIP-40 expiration: relays that honour it garbage-collect the event if
|
// NIP-40 expiration: relays that honour it garbage-collect the event if
|
||||||
// this node stops heartbeating (reinstall, decommission, long outage).
|
// this node stops heartbeating (reinstall, decommission, long outage).
|
||||||
// `discover` enforces the same window client-side for relays that don't.
|
// `discover` enforces the same window client-side for relays that don't.
|
||||||
let expires = Timestamp::from(Timestamp::now().as_u64() + PRESENCE_TTL_SECS);
|
let expires = Timestamp::from(Timestamp::now().as_secs() + PRESENCE_TTL_SECS);
|
||||||
let builder = EventBuilder::new(Kind::Custom(30078), content)
|
let builder = EventBuilder::new(Kind::Custom(30078), content)
|
||||||
.tag(Tag::identifier("archipelago-node"))
|
.tag(Tag::identifier("archipelago-node"))
|
||||||
.tag(Tag::expiration(expires));
|
.tag(Tag::expiration(expires));
|
||||||
@@ -268,7 +268,7 @@ pub async fn publish_tombstone(
|
|||||||
}
|
}
|
||||||
// Tombstone also expires: after TTL the relay may drop it entirely,
|
// Tombstone also expires: after TTL the relay may drop it entirely,
|
||||||
// which is the desired end state (nothing left to list).
|
// which is the desired end state (nothing left to list).
|
||||||
let expires = Timestamp::from(Timestamp::now().as_u64() + PRESENCE_TTL_SECS);
|
let expires = Timestamp::from(Timestamp::now().as_secs() + PRESENCE_TTL_SECS);
|
||||||
let builder = EventBuilder::new(Kind::Custom(30078), "{}")
|
let builder = EventBuilder::new(Kind::Custom(30078), "{}")
|
||||||
.tag(Tag::identifier("archipelago-node"))
|
.tag(Tag::identifier("archipelago-node"))
|
||||||
.tag(Tag::expiration(expires));
|
.tag(Tag::expiration(expires));
|
||||||
@@ -326,7 +326,8 @@ pub async fn discover_nodes(
|
|||||||
client.disconnect().await;
|
client.disconnect().await;
|
||||||
|
|
||||||
let mut nodes = Vec::new();
|
let mut nodes = Vec::new();
|
||||||
let stale_cutoff = Timestamp::from(Timestamp::now().as_u64().saturating_sub(PRESENCE_TTL_SECS));
|
let stale_cutoff =
|
||||||
|
Timestamp::from(Timestamp::now().as_secs().saturating_sub(PRESENCE_TTL_SECS));
|
||||||
for event in events {
|
for event in events {
|
||||||
// Client-side staleness enforcement: pre-TTL events (and events from
|
// Client-side staleness enforcement: pre-TTL events (and events from
|
||||||
// relays that ignore NIP-40) would otherwise list dead installs
|
// relays that ignore NIP-40) would otherwise list dead installs
|
||||||
|
|||||||
@@ -1194,11 +1194,13 @@ impl Server {
|
|||||||
// Podman needs and can restart-loop apps that publish those ports.
|
// Podman needs and can restart-loop apps that publish those ports.
|
||||||
let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe()));
|
let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe()));
|
||||||
let publishing_task = tokio::spawn(crate::publishing::serving::run(
|
let publishing_task = tokio::spawn(crate::publishing::serving::run(
|
||||||
self._config.data_dir.clone(), tx.subscribe(),
|
self._config.data_dir.clone(),
|
||||||
|
tx.subscribe(),
|
||||||
));
|
));
|
||||||
|
|
||||||
let publishing_tor_task = tokio::spawn(crate::publishing::tor::run(
|
let publishing_tor_task = tokio::spawn(crate::publishing::tor::run(
|
||||||
self._config.data_dir.clone(), tx.subscribe(),
|
self._config.data_dir.clone(),
|
||||||
|
tx.subscribe(),
|
||||||
));
|
));
|
||||||
|
|
||||||
// The app gate: authentication in front of every app port, on every
|
// The app gate: authentication in front of every app port, on every
|
||||||
@@ -1466,6 +1468,9 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
|
|||||||
| "/archipelago/mesh-typed"
|
| "/archipelago/mesh-typed"
|
||||||
| "/dwn"
|
| "/dwn"
|
||||||
| "/transport/inbox"
|
| "/transport/inbox"
|
||||||
|
// IndeeHub public metadata is loopback-fetched by the native
|
||||||
|
// handler; the app port itself is never exposed on FIPS.
|
||||||
|
| "/api/public-catalog/indeedhub"
|
||||||
// Content *catalog* — the peer-browse entry point. This is the
|
// Content *catalog* — the peer-browse entry point. This is the
|
||||||
// exact path `/content` (no trailing slash); the prefix match
|
// exact path `/content` (no trailing slash); the prefix match
|
||||||
// below only covers `/content/<id>` item fetches, so without
|
// below only covers `/content/<id>` item fetches, so without
|
||||||
@@ -1482,6 +1487,11 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
|
|||||||
|| path.starts_with("/blob/")
|
|| path.starts_with("/blob/")
|
||||||
// DWN sync — /dwn/health is step 1 of every sync; same story.
|
// DWN sync — /dwn/health is step 1 of every sync; same story.
|
||||||
|| path.starts_with("/dwn/")
|
|| path.starts_with("/dwn/")
|
||||||
|
// Free IndeeHub playback. Stream and key authorization is delegated
|
||||||
|
// to IndeeHub; encrypted public segments are safe to proxy verbatim.
|
||||||
|
|| path.starts_with("/api/public-indeehub-stream/")
|
||||||
|
|| path.starts_with("/api/public-indeehub-media/")
|
||||||
|
|| path.starts_with("/api/public-indeehub-key/")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The ordinary API listener is a management surface even when contacted
|
/// The ordinary API listener is a management surface even when contacted
|
||||||
@@ -2417,12 +2427,20 @@ mod merge_tests {
|
|||||||
// 100% Tor by construction.
|
// 100% Tor by construction.
|
||||||
assert!(is_peer_allowed_path("/blob/abc123"), "blob fetch by CID");
|
assert!(is_peer_allowed_path("/blob/abc123"), "blob fetch by CID");
|
||||||
assert!(is_peer_allowed_path("/dwn/health"), "DWN sync step 1");
|
assert!(is_peer_allowed_path("/dwn/health"), "DWN sync step 1");
|
||||||
|
assert!(is_peer_allowed_path("/api/public-indeehub-stream/1234",));
|
||||||
|
assert!(is_peer_allowed_path(
|
||||||
|
"/api/public-indeehub-media/1234/projects/film/transcoded/seg_000.ts",
|
||||||
|
));
|
||||||
|
assert!(is_peer_allowed_path("/api/public-indeehub-key/1234"));
|
||||||
// Not on the allow-list → rejected (no broad surface over the mesh).
|
// 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("/contention"), "must not prefix-leak");
|
||||||
assert!(!is_peer_allowed_path("/"));
|
assert!(!is_peer_allowed_path("/"));
|
||||||
assert!(!is_peer_allowed_path("/rpc/v2"));
|
assert!(!is_peer_allowed_path("/rpc/v2"));
|
||||||
assert!(!is_peer_allowed_path("/blobber"), "must not prefix-leak");
|
assert!(!is_peer_allowed_path("/blobber"), "must not prefix-leak");
|
||||||
assert!(!is_peer_allowed_path("/dwnx"), "must not prefix-leak");
|
assert!(!is_peer_allowed_path("/dwnx"), "must not prefix-leak");
|
||||||
|
assert!(!is_peer_allowed_path("/api/public-indeehub-stream"));
|
||||||
|
assert!(!is_peer_allowed_path("/api/public-indeehub-media"));
|
||||||
|
assert!(!is_peer_allowed_path("/api/public-indeehub-key"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -145,10 +145,7 @@ pub(crate) fn reserve_until(
|
|||||||
Err(error)
|
Err(error)
|
||||||
if error
|
if error
|
||||||
.downcast_ref::<std::io::Error>()
|
.downcast_ref::<std::io::Error>()
|
||||||
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) =>
|
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => {}
|
||||||
{
|
|
||||||
()
|
|
||||||
}
|
|
||||||
Err(error) => return Err(error),
|
Err(error) => return Err(error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1496,25 +1496,28 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
|
|||||||
.context("isolated test command failed");
|
.context("isolated test command failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut full: Vec<&str> = vec![
|
#[cfg(not(test))]
|
||||||
"systemd-run",
|
{
|
||||||
"--wait",
|
let mut full: Vec<&str> = vec![
|
||||||
"--quiet",
|
"systemd-run",
|
||||||
"--collect",
|
"--wait",
|
||||||
"--pipe",
|
"--quiet",
|
||||||
// Shell snippets passed as one argument must reach the child intact.
|
"--collect",
|
||||||
// systemd-run otherwise expands $VAR/${VAR} against the manager's
|
"--pipe",
|
||||||
// environment before `sh -lc` can see them (and usually replaces them
|
// Shell snippets passed as one argument must reach the child intact.
|
||||||
// with empty strings).
|
// systemd-run otherwise expands $VAR/${VAR} against the manager's
|
||||||
"--expand-environment=no",
|
// environment before `sh -lc` can see them (and usually replaces them
|
||||||
"--",
|
// with empty strings).
|
||||||
];
|
"--expand-environment=no",
|
||||||
full.extend_from_slice(args);
|
"--",
|
||||||
tokio::process::Command::new("sudo")
|
];
|
||||||
.args(&full)
|
full.extend_from_slice(args);
|
||||||
.status()
|
tokio::process::Command::new("sudo")
|
||||||
.await
|
.args(&full)
|
||||||
.context("sudo systemd-run spawn failed")
|
.status()
|
||||||
|
.await
|
||||||
|
.context("sudo systemd-run spawn failed")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Same mechanism as `host_sudo` but captures stdout — for read-only probes
|
/// Same mechanism as `host_sudo` but captures stdout — for read-only probes
|
||||||
@@ -1535,21 +1538,24 @@ pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Outp
|
|||||||
.context("isolated test command failed");
|
.context("isolated test command failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut full: Vec<&str> = vec![
|
#[cfg(not(test))]
|
||||||
"systemd-run",
|
{
|
||||||
"--wait",
|
let mut full: Vec<&str> = vec![
|
||||||
"--quiet",
|
"systemd-run",
|
||||||
"--collect",
|
"--wait",
|
||||||
"--pipe",
|
"--quiet",
|
||||||
"--expand-environment=no",
|
"--collect",
|
||||||
"--",
|
"--pipe",
|
||||||
];
|
"--expand-environment=no",
|
||||||
full.extend_from_slice(args);
|
"--",
|
||||||
tokio::process::Command::new("sudo")
|
];
|
||||||
.args(&full)
|
full.extend_from_slice(args);
|
||||||
.output()
|
tokio::process::Command::new("sudo")
|
||||||
.await
|
.args(&full)
|
||||||
.context("sudo systemd-run output spawn failed")
|
.output()
|
||||||
|
.await
|
||||||
|
.context("sudo systemd-run output spawn failed")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
|
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
|
||||||
|
|||||||
@@ -474,7 +474,7 @@ async fn ensure_token(
|
|||||||
|
|
||||||
/// Draw a fresh readable wallet name, Minibits-style: adjective + noun + number.
|
/// Draw a fresh readable wallet name, Minibits-style: adjective + noun + number.
|
||||||
fn generate_wallet_id() -> String {
|
fn generate_wallet_id() -> String {
|
||||||
let mut rng = rand::thread_rng();
|
let mut rng = rand::rngs::OsRng;
|
||||||
let adj = ADJECTIVES.choose(&mut rng).copied().unwrap_or("quiet");
|
let adj = ADJECTIVES.choose(&mut rng).copied().unwrap_or("quiet");
|
||||||
let noun = NOUNS.choose(&mut rng).copied().unwrap_or("harbor");
|
let noun = NOUNS.choose(&mut rng).copied().unwrap_or("harbor");
|
||||||
let num = rand::Rng::gen_range(&mut rng, 1..=999);
|
let num = rand::Rng::gen_range(&mut rng, 1..=999);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use super::nut13::RecoverySource;
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use bitcoin::secp256k1;
|
use bitcoin::secp256k1;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{debug, warn};
|
use tracing::debug;
|
||||||
|
|
||||||
/// Default timeout for mint API calls.
|
/// Default timeout for mint API calls.
|
||||||
const MINT_TIMEOUT_SECS: u64 = 10;
|
const MINT_TIMEOUT_SECS: u64 = 10;
|
||||||
@@ -83,13 +83,25 @@ impl std::fmt::Debug for PreparedSwap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PreparedSwap {
|
impl PreparedSwap {
|
||||||
// Add inside impl PreparedSwap; no mutability or proof/output secrets exposed.
|
// Add inside impl PreparedSwap; no mutability or proof/output secrets exposed.
|
||||||
pub(super) fn payment_keyset_id(&self) -> &str { &self.keyset.id }
|
pub(super) fn payment_keyset_id(&self) -> &str {
|
||||||
pub(super) fn input_fee_sats(&self) -> Result<u64> {
|
&self.keyset.id
|
||||||
let inputs = self.inputs.iter().try_fold(0u64, |sum, proof| sum.checked_add(proof.amount)).context("Prepared input sum overflow")?;
|
}
|
||||||
let outputs = self.outputs.iter().try_fold(0u64, |sum, output| sum.checked_add(output.amount)).context("Prepared output sum overflow")?;
|
pub(super) fn input_fee_sats(&self) -> Result<u64> {
|
||||||
inputs.checked_sub(outputs).context("Prepared outputs exceed input value")
|
let inputs = self
|
||||||
}
|
.inputs
|
||||||
|
.iter()
|
||||||
|
.try_fold(0u64, |sum, proof| sum.checked_add(proof.amount))
|
||||||
|
.context("Prepared input sum overflow")?;
|
||||||
|
let outputs = self
|
||||||
|
.outputs
|
||||||
|
.iter()
|
||||||
|
.try_fold(0u64, |sum, output| sum.checked_add(output.amount))
|
||||||
|
.context("Prepared output sum overflow")?;
|
||||||
|
inputs
|
||||||
|
.checked_sub(outputs)
|
||||||
|
.context("Prepared outputs exceed input value")
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn inputs(&self) -> &[Proof] {
|
pub(super) fn inputs(&self) -> &[Proof] {
|
||||||
&self.inputs
|
&self.inputs
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ pub mod mint_client;
|
|||||||
pub(crate) mod mutation;
|
pub(crate) mod mutation;
|
||||||
pub mod nut13;
|
pub mod nut13;
|
||||||
pub mod profits;
|
pub mod profits;
|
||||||
mod send_journal;
|
|
||||||
mod receive_journal;
|
mod receive_journal;
|
||||||
|
mod send_journal;
|
||||||
|
|
||||||
pub(crate) mod purchase_fee_plan;
|
pub(crate) mod purchase_fee_plan;
|
||||||
|
|
||||||
|
|||||||
@@ -2682,7 +2682,7 @@ async fn rental_catalog_term_mismatch_never_plans_or_creates_buyer_intent() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn unconfirmed_quote_can_cancel_and_requote_without_exposing_wallet_funds() {
|
async fn unconfirmed_quote_can_cancel_and_requote_without_exposing_wallet_funds() {
|
||||||
use crate::content_purchase_caller::{purchase, ReadyPurchase};
|
use crate::content_purchase_caller::{purchase, ReadyPurchase};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::AtomicBool;
|
||||||
let mint = Mint::start(0, None).await;
|
let mint = Mint::start(0, None).await;
|
||||||
let buyer = tempfile::tempdir().unwrap();
|
let buyer = tempfile::tempdir().unwrap();
|
||||||
let seller = mint.wallet().await;
|
let seller = mint.wallet().await;
|
||||||
|
|||||||
@@ -1795,8 +1795,10 @@ app:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
exempt.sort();
|
exempt.sort();
|
||||||
// Reviewed 2026-09-30: lightning-stack's three retired endpoints
|
// Reviewed 2026-10-09: lightning-stack's three retired endpoints
|
||||||
// disappeared; Cuprate restricted RPC moved from none to gate-open.
|
// disappeared; Cuprate restricted RPC moved from none to gate-open.
|
||||||
|
// DATUM's Stratum port is a raw public mining protocol; its separate
|
||||||
|
// administration interface remains gated and loopback-bound.
|
||||||
// Compare exact endpoints, not just a count that can hide substitutions.
|
// Compare exact endpoints, not just a count that can hide substitutions.
|
||||||
let expected = [
|
let expected = [
|
||||||
("bitcoin-core", 8333),
|
("bitcoin-core", 8333),
|
||||||
@@ -1804,6 +1806,7 @@ app:
|
|||||||
("core-lightning", 9736),
|
("core-lightning", 9736),
|
||||||
("core-lightning", 9835),
|
("core-lightning", 9835),
|
||||||
("cuprate", 18183),
|
("cuprate", 18183),
|
||||||
|
("datum", 23334),
|
||||||
("electrumx", 50001),
|
("electrumx", 50001),
|
||||||
("fedimint", 8173),
|
("fedimint", 8173),
|
||||||
("fedimint", 8174),
|
("fedimint", 8174),
|
||||||
@@ -1870,6 +1873,8 @@ app:
|
|||||||
// Angor's indexer exposes public chain data/transaction broadcast;
|
// Angor's indexer exposes public chain data/transaction broadcast;
|
||||||
// its optional standalone relay accepts signed public Nostr events.
|
// its optional standalone relay accepts signed public Nostr events.
|
||||||
// Neither mounts credentials or the node's internal relay database.
|
// Neither mounts credentials or the node's internal relay database.
|
||||||
|
// Gashboard performs its own NIP-98/access-list authentication on
|
||||||
|
// every data route before issuing or accepting a session.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
open,
|
open,
|
||||||
vec![
|
vec![
|
||||||
@@ -1877,6 +1882,7 @@ app:
|
|||||||
("angor-relay".to_string(), 8091u16),
|
("angor-relay".to_string(), 8091u16),
|
||||||
("btcpay-server".to_string(), 23000u16),
|
("btcpay-server".to_string(), 23000u16),
|
||||||
("cuprate".to_string(), 18090u16),
|
("cuprate".to_string(), 18090u16),
|
||||||
|
("gashboard".to_string(), 1337u16),
|
||||||
("gitea".to_string(), 3001u16),
|
("gitea".to_string(), 3001u16),
|
||||||
("nginx-proxy-manager".to_string(), 8081u16),
|
("nginx-proxy-manager".to_string(), 8081u16),
|
||||||
("tailscale".to_string(), 8240u16),
|
("tailscale".to_string(), 8240u16),
|
||||||
|
|||||||
@@ -1002,7 +1002,10 @@ fn manifest_container_name(manifest: &AppManifest) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn manifest_apps_dirs() -> Vec<PathBuf> {
|
fn manifest_apps_dirs() -> Vec<PathBuf> {
|
||||||
let mut dirs = Vec::new();
|
// Keep source-tree discovery independent of the caller's working
|
||||||
|
// directory. Isolated test runners deliberately start in `core/`, while
|
||||||
|
// production uses one of the installed paths below.
|
||||||
|
let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps")];
|
||||||
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
||||||
dirs.push(Path::new(&manifest_dir).join("../../apps"));
|
dirs.push(Path::new(&manifest_dir).join("../../apps"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ pub fn stop_grace_secs_for(container_name: &str) -> u64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rust 1.99 treats the boxed Future generated by async-trait as must-use and
|
||||||
|
// also sees the macro's own must-use marker. Keep the compatibility allowance
|
||||||
|
// scoped to this generated trait surface; callers still cannot ignore Result.
|
||||||
|
#[allow(clippy::double_must_use)]
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ContainerRuntime: Send + Sync {
|
pub trait ContainerRuntime: Send + Sync {
|
||||||
/// CLI used for offline app provisioning in this runtime's storage scope.
|
/// CLI used for offline app provisioning in this runtime's storage scope.
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Interactive hardware illustrations
|
||||||
|
|
||||||
|
The radio detection modal and unconfigured OpenWrt gateway use original
|
||||||
|
procedural Three.js models. The renderer follows the studio lighting,
|
||||||
|
rounded geometry, material finishes and instancing approach used for the
|
||||||
|
computer illustrations in `archipelago-website-deck`.
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
`neode-ui/src/graphics/deviceModels.ts` contains 25 radio illustrations,
|
||||||
|
including the generic fallback and three optional enclosure variants, plus
|
||||||
|
the Beryl AX (GL-MT3000) and a black Pixel 9 Pro. All existing `meshDeviceImages.ts` detection results
|
||||||
|
resolve to a procedural model key. USB chip identification remains a
|
||||||
|
best-effort visual match; it does not select firmware or claim exact hardware.
|
||||||
|
|
||||||
|
The radios include modeled rear faces, enclosure panels or populated boards,
|
||||||
|
and appropriate battery holders, connectors and antennas. These are stylized
|
||||||
|
recognition illustrations, not CAD models. Fine component placement and rear
|
||||||
|
details are representative; not every board revision or enclosure has been
|
||||||
|
verified against a rear photograph. The Beryl AX is the example router from
|
||||||
|
`docs/openwrt-gateway-setup.md`, not an assertion about the connected router.
|
||||||
|
|
||||||
|
Reference material:
|
||||||
|
|
||||||
|
- Beryl AX official front/rear/side hardware drawing:
|
||||||
|
https://static.gl-inet.com/docs/router/en/4/user_guide/gl-mt3000/hardware_info/mt3000_interface.jpg
|
||||||
|
- Beryl AX guide:
|
||||||
|
https://docs.gl-inet.com/router/en/4/user_guide/gl-mt3000/
|
||||||
|
- T-Echo manufacturer board and enclosure reference:
|
||||||
|
https://github.com/Xinyuan-LilyGO/T-Echo
|
||||||
|
https://raw.githubusercontent.com/Xinyuan-LilyGO/T-Echo/main/image/T-ECHO.jpg
|
||||||
|
- T-Deck manufacturer documentation:
|
||||||
|
https://wiki.lilygo.cc/products/t-deck-series/t-deck/
|
||||||
|
|
||||||
|
Manufacturer photographs are references only; none are copied into the app.
|
||||||
|
Geometry, grain and device labels are generated locally without CDN assets.
|
||||||
|
The black Pixel 9 Pro illustration uses an unmodified, locally bundled GrapheneOS
|
||||||
|
boot logo mask from its framework source. Its rounded outline, glass panels,
|
||||||
|
camera island, layered lenses, side buttons and ports are procedural geometry.
|
||||||
|
The display shows a static phase of the OS boot animation, distinct from Google's
|
||||||
|
bootloader notice. Source and attribution: `neode-ui/src/graphics/assets/ATTRIBUTION.md`.
|
||||||
|
|
||||||
|
## Interaction and lifecycle
|
||||||
|
|
||||||
|
`DeviceModel.vue` loads the renderer on intersection. It turns once per minute,
|
||||||
|
capped at 30 rendered frames per second. Drag horizontally to rotate; a mouse
|
||||||
|
can also tilt vertically. Touch preserves vertical page scrolling. Arrow keys
|
||||||
|
rotate/tilt, Home shows the front and End shows the back. Keyboard interaction
|
||||||
|
pauses automatic turning. The Pause/Rotate button controls autoplay.
|
||||||
|
|
||||||
|
Reduced motion disables autoplay and hides its control while preserving manual
|
||||||
|
rotation. Hidden tabs and offscreen models stop scheduling frames. Model changes
|
||||||
|
reuse the renderer/environment and dispose the previous geometry, textures and
|
||||||
|
materials. Unmount releases the renderer and context. Unsupported browser APIs,
|
||||||
|
unavailable WebGL, import failures or lost contexts leave a static fallback.
|
||||||
|
|
||||||
|
The router figure uses the remaining viewport height below the connection form
|
||||||
|
on desktop (minimum 260px); mobile layouts get a fixed 290px illustration. Small desktop
|
||||||
|
windows can scroll normally rather than collapsing the model to zero height.
|
||||||
|
|
||||||
|
## Hardware discovery
|
||||||
|
|
||||||
|
The router gateway and private-phone Setup goal share `HardwareMarketplace.vue`.
|
||||||
|
Buy router / Buy phone replaces the model with a horizontal product carousel,
|
||||||
|
preserving the outer container's height. Discovery queries enabled relays from
|
||||||
|
`nostr.list-relays` for NIP-99 kind-30402 events. Phone discovery uses the `pixel`
|
||||||
|
keyword. Development builds show clearly
|
||||||
|
labeled sample listings when no matching live listings are found. Checkout and
|
||||||
|
the product View/Buy actions remain unimplemented.
|
||||||
|
|
||||||
|
Browser scenarios and reports stay outside this repository, under
|
||||||
|
`/home/yaya/.local/share/browser-check/projects/archy-devices/`.
|
||||||
|
|
||||||
|
## Validation (2026-10-09)
|
||||||
|
|
||||||
|
- Production typecheck/build passes; the lazy renderer chunk is 136 KB gzipped.
|
||||||
|
- Existing gateway/cache tests (10) and radio setup tests (2) pass.
|
||||||
|
- Chromium 153, Firefox 155 and WebKit 26.6 pass at desktop 1440×900 and
|
||||||
|
phone 390×844: all 26 models, front/back rotation, drag, autoplay/pause,
|
||||||
|
reduced motion, zero offscreen/idle draws, model replacement, unmount/remount
|
||||||
|
and context-loss fallback. Reports: `/tmp/archy-models-final/`.
|
||||||
|
- Actual gateway and detection-modal components pass the same six-case matrix
|
||||||
|
with mocked RPC data. Reports: `/tmp/archy-device-pages-matrix/`.
|
||||||
|
- Delayed renderer loading, storage denial, reload and unavailable WebGL pass
|
||||||
|
the same matrix. Reports: `/tmp/archy-device-fallbacks/`.
|
||||||
|
- These are Linux browser tests with phone viewport emulation, not physical
|
||||||
|
iPhone/Android GPU or touch-scroll certification. No live router was changed.
|
||||||
|
|
||||||
|
Branch: `feat/3d-mesh-router`, based on ngit main
|
||||||
|
`5812f53c7c05027848a1007f818e9d33117b62e0`. The ngit helper could read relay
|
||||||
|
state but failed Git server authentication; main's published kind-30618 ref
|
||||||
|
was checked on relay.ngit.dev and that exact commit fetched from the existing
|
||||||
|
Git mirror. No remote proposal, merge or deployment was performed.
|
||||||
@@ -42,6 +42,254 @@ acceptance; this is a new paid-file incident.
|
|||||||
|
|
||||||
## Current tasks
|
## Current tasks
|
||||||
|
|
||||||
|
- 2026-10-10 three-node IndeeHub/terminal UAT checkpoint: Yaya, Framework and
|
||||||
|
Shorty serve byte-identical IndeeHub entry pages
|
||||||
|
(`0a2ef3393aacd96d834ad4666d4aa4c64c5fe0cc28f973351cfbc73a78156e7a`),
|
||||||
|
dashboard entry pages
|
||||||
|
(`b41a8d3c87ba923c9a4e5f7d0a61dafac593a3438510482d9e6b85a53bb63c9f`)
|
||||||
|
and management binaries
|
||||||
|
(`daf39c71b7029c0b51f8b74cc146fb3dd9af287744856e0e6a63bb63b40829ab`).
|
||||||
|
All manager health endpoints report `ok` with crash recovery complete.
|
||||||
|
Bitcoin/LND container IDs remained exact across the manager restarts. The
|
||||||
|
canonical runtime Nginx source is identical on all three nodes at
|
||||||
|
`524fd418ce71c6399f2772f7a105a968254e9a6414c96e45376422c414c18658`;
|
||||||
|
this matters because manager bootstrap otherwise restored an older runtime
|
||||||
|
template and removed terminal/playback routes on restart.
|
||||||
|
|
||||||
|
Terminal creation and authenticated listing pass on Yaya and Framework.
|
||||||
|
The live failure was missing `tmux`, after routing and origin failures had
|
||||||
|
separately been corrected. `tmux` is installed on all three UAT nodes and is
|
||||||
|
now required by both ISO installation paths. Shorty's proxy/runtime is
|
||||||
|
installed and Nginx-valid, but authenticated creation was not accepted because
|
||||||
|
its dashboard password differs; keep that as unverified rather than a pass.
|
||||||
|
Test-created Yaya/Framework session records were moved recoverably under the
|
||||||
|
support directory after acceptance and no longer appear in the session list.
|
||||||
|
The UI now includes actual response details for create failures instead of the
|
||||||
|
generic message, and its empty state occupies and centers within the full
|
||||||
|
terminal viewport. That dashboard build is byte-identical on all three nodes.
|
||||||
|
|
||||||
|
Cross-node public film discovery now works through FIPS in both directions:
|
||||||
|
Yaya receives Framework's *Arch x Indie* and Framework receives Yaya's
|
||||||
|
*Web5 Explained*. Framework's explicitly free film returned a JSON stream,
|
||||||
|
HLS manifest, 16-byte AES key and 1,736,192-byte MPEG-TS segment through the
|
||||||
|
Yaya same-origin bridge. A negative test found Yaya's project row said zero
|
||||||
|
while its signed offer required 15 sats; the first bridge build would have
|
||||||
|
treated that mismatch as free. The deployed guard now requires both project
|
||||||
|
data and signed terms to be explicitly zero. Framework receives HTTP 402
|
||||||
|
`rental required` and no stream URL for that title. Never weaken this guard to
|
||||||
|
make a paid playback test pass. Paid Cashu/Lightning rental, metered ecash,
|
||||||
|
entitlement recovery without another payment, and the intended native-identity
|
||||||
|
replacement for the old `0494` offer remain open end-to-end acceptance gates.
|
||||||
|
|
||||||
|
MeshCore V3 parsing now reads the channel after SNR/reserved bytes. The UI
|
||||||
|
recovers already-persisted impossible MeshCore channel IDs into Public while
|
||||||
|
preserving valid channels and other transports. Connected Nodes uses `Map`,
|
||||||
|
with full-width My connections/Find Nodes controls on mobile. Focused frontend
|
||||||
|
tests (8), TypeScript, production build, terminal packaging regression and
|
||||||
|
formatting pass. Three targeted backend regressions pass through the isolated
|
||||||
|
runner: guarded public streaming, MeshCore V3 channel parsing, and the peer
|
||||||
|
content path filter. The reviewed source was submitted as ngit proposal
|
||||||
|
`e970261bdf1578094cc05180853b850fafdf0950bb3f250d374672144d576bfc`
|
||||||
|
and merged once as `23c88da4c426fa8094e2e7c931586d27d028f4eb`.
|
||||||
|
Exact main mirroring and a complete mirror audit remain required before
|
||||||
|
OTA/ISO. No OTA or ISO was published by this UAT deployment.
|
||||||
|
|
||||||
|
- 2026-10-09 dual-node UAT correction: Archy `main` at `5b0b35401` was already
|
||||||
|
serving identical backend and dashboard artifacts on Yaya and Framework;
|
||||||
|
browser cache hid the new device-model screens. Yaya's terminal failed
|
||||||
|
independently because `/api/terminal/sessions` fell through to HTML. Its
|
||||||
|
HTTP and HTTPS proxy now return backend JSON `401` when unsigned; Framework
|
||||||
|
already did. Source correction ngit proposal `dd22151e` was reviewed and
|
||||||
|
merged as `b50fc5627ca4b108b85c40f72366a07a8cbcd5a9`, then pushed to
|
||||||
|
ngit and Gitea with local main-ref parity passing. Authenticated browser
|
||||||
|
terminal acceptance remains open; the UAT Nginx edit is live on Yaya.
|
||||||
|
|
||||||
|
IndeeHub frontend UAT on both nodes now includes the explicit local `/relay`
|
||||||
|
Archipelago source and publication panel flags after an omitted build setting
|
||||||
|
hid them. This is still a mounted UAT frontend, not a pinned app image. Yaya's
|
||||||
|
local relay has one protocol-qualified offer signed by the unwanted `0494`
|
||||||
|
identity; Framework's has none. Framework still runs its April `1.0.0` API
|
||||||
|
without the Archipelago module or registration pin, while Yaya has an October
|
||||||
|
API with three Archipelago migrations. Ordinary films exist on both nodes but
|
||||||
|
are not automatically signed Archipelago catalog offers. Preserve those films
|
||||||
|
and native identities; do not republish or delete the old signed event without
|
||||||
|
producer authorization. Backend image parity, node-specific pin preparation,
|
||||||
|
actual producer publication, cross-node discovery and paid playback remain
|
||||||
|
separate open acceptance gates.
|
||||||
|
|
||||||
|
- 2026-10-09 later dual-node UAT checkpoint: both nodes now run the same
|
||||||
|
source-built IndeeHub API image `f007b818d8ad` from `518de96`, with their
|
||||||
|
own existing native registration bindings and no runtime code mounts. The
|
||||||
|
three missing Framework migrations were applied after a validated database
|
||||||
|
backup; Yaya's database and prior overrides were also backed up, and the
|
||||||
|
overrides retired recoverably. Both nodes run the same rebuilt frontend and
|
||||||
|
asynchronous transcode worker image. Local public catalogs now return Yaya's
|
||||||
|
completed *Web5 Explained* and Framework's completed *Arch x Indie*;
|
||||||
|
Framework's stalled transcode was requeued once and completed. Posters and
|
||||||
|
encrypted HLS manifests return 200 on both nodes; raw source files return
|
||||||
|
403, unauthenticated key requests return 401, and MinIO policy reconciliation
|
||||||
|
succeeds twice on both nodes. Focused API/worker tests and production builds
|
||||||
|
passed, but authenticated purchase/playback and a new post-fix long transcode
|
||||||
|
remain unverified. Framework and Yaya still have separate local project
|
||||||
|
catalogs; neither local publication proves cross-node discovery. The only
|
||||||
|
signed Archipelago offer found was Yaya's earlier event from the unwanted
|
||||||
|
`0494` identity; do not sign or republish it as a different producer without
|
||||||
|
the operator's explicit identity choice. The IndeeHub source branch is not
|
||||||
|
merged upstream because its configured remote is currently inaccessible.
|
||||||
|
The Archy terminal proxy now returns JSON 401 on both nodes instead of HTML
|
||||||
|
for an unauthenticated API request; authenticated browser terminal acceptance
|
||||||
|
is still open. No catalog, OTA or ISO was published.
|
||||||
|
|
||||||
|
- 2026-10-09 recurrence: the operator again receives the profile-identity refusal.
|
||||||
|
Live reproduction shows the signed session still matches the selected native
|
||||||
|
identity, but the API's original DTO is back and omits `nostrPubkey`. Do not
|
||||||
|
weaken the frontend identity check. The earlier direct `podman restart` was
|
||||||
|
incorrect for this systemd-managed app: the attached service exits, then its
|
||||||
|
`Restart=always` cleanup/recreation returns to the original image. On this
|
||||||
|
repeat, the API disappears briefly and systemd recreates it at 16:30:53 UTC;
|
||||||
|
journal evidence and changed container ID confirm that lifecycle. The brief
|
||||||
|
earlier successful login was therefore insufficient deployment acceptance.
|
||||||
|
|
||||||
|
A per-node UAT correction now uses Quadlet drop-in
|
||||||
|
`~/.config/containers/systemd/indeedhub-api.container.d/90-profile-uat.conf`
|
||||||
|
to mount `/var/lib/archipelago/uat-overrides/indeehub-profile-08586e3/user.dto.js`
|
||||||
|
read-only at `/app/dist/users/dto/response/user.dto.js`. Artifact SHA256:
|
||||||
|
`4450f45def6c68396d9f4fa597a24ca74cec447d1cb3c8772af29e406d929e6d`.
|
||||||
|
The original image, source Quadlet, credentials, databases and identities are
|
||||||
|
unchanged. Restart via `systemctl --user restart indeedhub-api`, never direct
|
||||||
|
Podman. The module checksum survives two managed recreations. Real native
|
||||||
|
login after the first passes. The immediate second-start login obtained no
|
||||||
|
session; after explicitly verifying API health 200, the repeated real native
|
||||||
|
login passes with matching node selection, JWT subject and profile public key.
|
||||||
|
The service remains active/running with zero automatic restarts. This is a persistent **local UAT
|
||||||
|
override**, not a new signed image/catalog. Native upgrades remain held until
|
||||||
|
the reviewed image includes the fix and this exact override is retired; do not
|
||||||
|
bypass external-override/installation ownership checks. Recovery is to move
|
||||||
|
this specific drop-in into the existing private repair backup, daemon-reload
|
||||||
|
and restart the managed API; that restores the old image's known login defect.
|
||||||
|
No node identity or personal media was replaced. The previously pending
|
||||||
|
isolated registration regression has now passed: one test, zero failures.
|
||||||
|
|
||||||
|
- 2026-10-09 live repair checkpoint (supersedes the pending deployment notes
|
||||||
|
immediately below): Yaya now runs management backend SHA256
|
||||||
|
`938a90c7d9ecbdb179b1cf2886b5ed03e4d78c0d454f2cec42c0d9de3d447eb9`,
|
||||||
|
built from the registration correction in `424070d2`. Optimized build passed.
|
||||||
|
Management health, unchanged running container IDs, unchanged node identity key
|
||||||
|
and unchanged registration pin/marker checks passed. The old binary is retained
|
||||||
|
under `/var/lib/archipelago/support/indee-registration-424070d2/`.
|
||||||
|
The first context probe during inventory startup still failed; after inventory
|
||||||
|
recovery the authenticated `media.registration.context` probe succeeds and
|
||||||
|
returns all installation bindings. No catalog or app-data mutation was used.
|
||||||
|
The probe initially treated JSON-RPC `error: null` as failure; corrected it and
|
||||||
|
repeated the successful live request. Isolated backend regression compilation
|
||||||
|
remains pending after severe build-host swapping; do not claim its test passed.
|
||||||
|
|
||||||
|
A real native Home Serve login then exposed another defect: `UserDTO` omitted
|
||||||
|
`nostrPubkey`, so the strict frontend profile check correctly refused the
|
||||||
|
otherwise matching signed session. IndeeHub `08586e3` adds this public field;
|
||||||
|
two DTO regressions and backend production build pass. Deployed only the built
|
||||||
|
DTO module with its previous copy saved under the private
|
||||||
|
`indee-profile-08586e3` artifact directory, then restarted only `indeedhub-api`.
|
||||||
|
API health passes. A clean real browser selected native **Home Serve**, approved
|
||||||
|
only its kind-27235 authentication request, and verified that the native choice,
|
||||||
|
session subject and API profile key match. The signer closes normally. No
|
||||||
|
identity was created/replaced and no payment or publication was submitted.
|
||||||
|
Frontend `7b18912` and API DTO remain running-container patches; durable image/
|
||||||
|
managed deployment, complete video upload/publication and cached-account-switch
|
||||||
|
acceptance remain open. Preserve the operator's native identities throughout.
|
||||||
|
|
||||||
|
- 2026-10-09 UI checkpoint: IndeeHub commit `40b3798` is served on Yaya
|
||||||
|
(index SHA256 `d921a88448f89674809dbe623ec045849012b1fb0330e3abf1d2d6836e9b3c96`).
|
||||||
|
Publishing now appears only in its own editor tab; Assets remains mounted
|
||||||
|
across tab switches. Copy distinguishes computer upload from Cloud-backed
|
||||||
|
catalog publication; saved-registration recovery is under a details control.
|
||||||
|
Production build and 26 focused frontend tests pass. Browser checks verify
|
||||||
|
native signer availability, no automatic login, removal of local account
|
||||||
|
controls and clearing of legacy app-local accounts. Real selected-identity
|
||||||
|
sign-in and video upload/publication remain unverified. This is a running
|
||||||
|
container frontend patch, not a durable pinned-image/catalog deployment.
|
||||||
|
Backend correction `424070d2` has ngit proposal
|
||||||
|
`9d6de783f5022b3b859e0b1f3e881e389da4482dd5dd15ff29a40b4e25dafcb8`;
|
||||||
|
isolated regression and optimized build are still running, not passed or
|
||||||
|
deployed. Preserve this distinction when resuming the backend work.
|
||||||
|
|
||||||
|
- 2026-10-09 follow-up: operator reports missing autosign and the same generic
|
||||||
|
registration error. `40b3798` disabled provider auto-authentication without
|
||||||
|
replacing it with app authentication on native selection; this was a regression.
|
||||||
|
IndeeHub `7b18912` adds a single shared automatic/manual login path driven by
|
||||||
|
the trusted provider's `onIdentitySelected`. It rejects mismatching signer keys
|
||||||
|
and changed selections, checks the backend profile, and never falls back to a
|
||||||
|
cached app identity. Twenty-one focused tests and the production build pass.
|
||||||
|
Yaya serves index SHA256
|
||||||
|
`1c7fee64430b0d993288408dbe493d0f4f55a49fe2422a3507c7204c12343632`;
|
||||||
|
the native provider bytes are unchanged. A disposable served-app browser test
|
||||||
|
verifies selection triggers exactly one kind-27235 signature request without
|
||||||
|
clicking login; it intentionally stops before signing with a stub. This is not
|
||||||
|
real-identity login acceptance. The first browser run raced deployment and
|
||||||
|
failed against the previous build; the post-deployment rerun passes.
|
||||||
|
Authenticated read-only registration RPC still reproduces the generic failure,
|
||||||
|
with server cause `IndeeHub is not installed`; backend build/test remain pending.
|
||||||
|
|
||||||
|
- [ ] **2026-10-09 IndeeHub UAT remains failed:** operator still reports wrong
|
||||||
|
Nostr identity, upload failure and confusing always-visible publishing UI.
|
||||||
|
Preserve native identities; remove only app-local generated/imported accounts.
|
||||||
|
Require explicit Archipelago selection and matching API profile. On Yaya,
|
||||||
|
`media.registration.context` logs `IndeeHub is not installed` while all seven
|
||||||
|
containers run. Authenticated state includes only the parent `indeedhub`
|
||||||
|
package; the registration code incorrectly requires a separate
|
||||||
|
`indeedhub-api` package record. Candidate correction retains parent running/
|
||||||
|
installed checks and the existing API installation pin; it checks a legacy
|
||||||
|
API package when present. Source tests/build and live registration acceptance
|
||||||
|
must complete before closing. Move publishing to its own editor tab, preserve
|
||||||
|
computer-upload state and existing pending registration requests.
|
||||||
|
|
||||||
|
Retained operator UAT follow-up (2026-10-09; none of these are closed by an
|
||||||
|
isolated build or a running-container patch):
|
||||||
|
|
||||||
|
- [ ] Keep only the selected native Archipelago signer identity; reject any
|
||||||
|
cached or mismatching IndeeHub identity, and verify Home Serve and
|
||||||
|
Archipelago sign-in with real selected identities after reload.
|
||||||
|
- [ ] Verify computer uploads for every asset field, byte-level progress,
|
||||||
|
project Save/Publish persistence, and reloaded poster/video/trailer URLs.
|
||||||
|
The poster URL currently omits the slash after `/storage` on Yaya.
|
||||||
|
- [ ] Restore the authenticated, mobile-friendly Cloud file modal, with an
|
||||||
|
actual attach action beside computer upload for every required asset
|
||||||
|
field. The current Cloud iframe is not an attachment workflow.
|
||||||
|
- [ ] Verify that any producer can register and publish Cloud video, including
|
||||||
|
exact-project/video/terms signing, resumable registration, and playback.
|
||||||
|
- [ ] Deliver and UAT both metered ecash playback and fixed-period rental,
|
||||||
|
retaining Lightning payment/rental support where promised. A price in
|
||||||
|
sats plus one-time Cashu rental is not metered ecash streaming.
|
||||||
|
- [ ] Deploy pinned, durable IndeeHub frontend/API images and test their
|
||||||
|
recreation without losing native identities, uploaded assets, projects,
|
||||||
|
payments, or publication state. Running-container file patches do not
|
||||||
|
satisfy this gate.
|
||||||
|
|
||||||
|
Dual-node UAT checkpoint, 2026-10-09: Yaya and Framework serve the same
|
||||||
|
IndeeHub frontend build through restart-surviving UAT Quadlet overrides;
|
||||||
|
their existing native-provider bytes were preserved. Both app endpoints
|
||||||
|
return HTTP 200 and protected API endpoints reject unauthenticated requests.
|
||||||
|
Narrow API overrides add the profile field required by the strict signer
|
||||||
|
check and correct `/storage/projects/...` poster URLs. Yaya's saved project
|
||||||
|
still has a poster and video key; its poster now returns HTTP 200 while the
|
||||||
|
private video returns HTTP 403 without authorization. A poster-only anonymous
|
||||||
|
`GetObject` rule was added to the public bucket; no private bucket read rule
|
||||||
|
was added. Framework's two missing IndeeHub buckets were created without
|
||||||
|
replacing other storage. Source backend tests/build, frontend build, and 12
|
||||||
|
focused identity/upload tests pass. Real selected-identity sign-in, upload
|
||||||
|
completion and Save/reload on both physical nodes still require operator UAT.
|
||||||
|
The Cloud browser is view-only and is **not** an attachment picker. These
|
||||||
|
overrides are not pinned managed images or release acceptance.
|
||||||
|
|
||||||
|
- [ ] **Yaya disk threshold notification recovery:** after safely reclaiming
|
||||||
|
only BuildKit cache, actual data-volume use is 62% (127,440,146,432 of
|
||||||
|
219,181,039,616 bytes on 2026-10-09). The previous 164 GiB / 194 GiB
|
||||||
|
display reflected real usage before cleanup, not merely stale UI.
|
||||||
|
Source monitoring pushes notifications only when current alerts are
|
||||||
|
nonempty, so a previously fired threshold notice may remain after usage
|
||||||
|
recovers. Fix notification clearing and verify live recovery after app
|
||||||
|
removal/cache cleanup without deleting wallets or persistent app data.
|
||||||
|
|
||||||
- [x] Yaya App Store component/alias regression (reported 2026-10-02): one
|
- [x] Yaya App Store component/alias regression (reported 2026-10-02): one
|
||||||
Cuprate entry (hide Cuprate UI companion), one BTCPay Server in Commerce
|
Cuprate entry (hide Cuprate UI companion), one BTCPay Server in Commerce
|
||||||
with its icon (merge legacy btcpay pins), one NetBird entry (hide server
|
with its icon (merge legacy btcpay pins), one NetBird entry (hide server
|
||||||
@@ -1598,3 +1846,28 @@ isolated runner. The full isolated suite, production backend build, another
|
|||||||
explicit native update, post-update runtime/data proofs, and paired browser
|
explicit native update, post-update runtime/data proofs, and paired browser
|
||||||
acceptance remain pending. No wallet, payment, personal-media or profile changes
|
acceptance remain pending. No wallet, payment, personal-media or profile changes
|
||||||
are part of this readiness correction.
|
are part of this readiness correction.
|
||||||
|
|
||||||
|
## 2026-10-09: two-node IndeeHub UAT — open acceptance items
|
||||||
|
|
||||||
|
Yaya and Framework now have matching UAT frontend bundles, but this is **not**
|
||||||
|
feature acceptance or release publication. The backstage `indeehub.com/watch/`
|
||||||
|
slug control has been removed from the active bundle. The local My List button
|
||||||
|
now calls the library API instead of only toggling its icon; signed-in browser
|
||||||
|
acceptance is pending. Cross-node saves remain open because the library API is
|
||||||
|
local-project-only. Cross-node comments, reactions and zaps remain open: social
|
||||||
|
events use node-origin-specific content IDs and each `/relay` requires that
|
||||||
|
node's login. Do not treat cross-node public film listing as social federation.
|
||||||
|
|
||||||
|
Framework logged `content.playback-prepare: Not a purchase route` after a Cashu
|
||||||
|
rental attempt. The buyer sent a rental-control path through a purchase-only
|
||||||
|
transport guard. A narrow authenticated rental-control route is implemented;
|
||||||
|
the release binary compiled, its route unit test passed through the isolated
|
||||||
|
runner, and the identical binary is deployed on both UAT nodes with healthy
|
||||||
|
RPC and session services. An existing-purchase retry without another payment
|
||||||
|
remains pending. The earlier
|
||||||
|
Framework LND startup fix does not close this paid-file recovery incident.
|
||||||
|
Lightning purchase/zap still lacks configured BTCPay credentials on both UAT
|
||||||
|
nodes; the API now reports service unavailable instead of an opaque Invalid URL,
|
||||||
|
but payment is not accepted. Framework's listed film has no verified media
|
||||||
|
registration or signed stream offer; listing is not proof of playable media.
|
||||||
|
No OTA, ISO or public release follows from these UAT changes.
|
||||||
|
|||||||
@@ -1,7 +1,165 @@
|
|||||||
# Archipelago 1.9.0-alpha release acceptance
|
# Archipelago 1.9.0-alpha release acceptance
|
||||||
|
|
||||||
|
## Three-node expired-rental renewal UAT — 10 October 2026
|
||||||
|
|
||||||
|
- ngit proposal `267f63692f16af34771b7268ffd71502f9a1b087661044730bb891478c82212d`
|
||||||
|
was merged once as `f6e84fdcb46eb2ac998f4ad53cd9c34ccfb03ac4`; the exact `main`
|
||||||
|
commit is on ngit and Gitea and the local/main mirror check passes.
|
||||||
|
- Explicit renewal is permitted only after the authenticated seller reports the
|
||||||
|
original immutable timed-rental window as expired. Active and incomplete
|
||||||
|
purchases continue to recover under their existing operation and are not paid
|
||||||
|
again. The isolated backend regression passed 1/1; the dashboard bridge passed
|
||||||
|
29/29 tests; the IndeeHub player passed 10/10 tests. A real expired Framework
|
||||||
|
rental was recovered before this change without another payment and confirmed
|
||||||
|
the missing transition; an actual renewed payment remains operator UAT, not a
|
||||||
|
source-test pass.
|
||||||
|
- Optimized backend SHA256 `97f4ffcc6e5f47eaeb88bfcd72d858aa1b15baa2ae775d02718ed7074151f4fd`,
|
||||||
|
dashboard index SHA256 `d6ffe81f2e7449b12ebb3affed63802520a75634d753ed81d77605d17c5e8e7c`,
|
||||||
|
and IndeeHub index SHA256 `ee9192ab758871df04ee519c69a3dcb8693001fcc69c2b547cc7ca94236eba54`
|
||||||
|
are deployed on Yaya, Framework and Shorty with private rollback copies.
|
||||||
|
All three management health probes pass. Bitcoin/LND and IndeeHub database,
|
||||||
|
Redis, MinIO, relay, API and ffmpeg container IDs remained unchanged.
|
||||||
|
- Framework's obsolete nginx `sub_filter` was injecting a duplicate native
|
||||||
|
signer provider into the self-contained frontend. It was removed with its
|
||||||
|
previous configuration retained privately; all three nodes now serve the same
|
||||||
|
IndeeHub HTML. The mobile Connected Nodes footer now gives Map and Refresh
|
||||||
|
equal width and uses Refresh text rather than an oversized mobile icon.
|
||||||
|
- This is UAT deployment evidence, not OTA/ISO acceptance. Lightning rental,
|
||||||
|
metered ecash, Shorty authenticated operator acceptance, the remaining active
|
||||||
|
regression checklist and full historical mirror parity remain release gates.
|
||||||
|
|
||||||
|
## Yaya alpha UAT continuation — 9 October 2026
|
||||||
|
|
||||||
|
Operator renewed deployment authorization for `yaya-server` for combined UAT,
|
||||||
|
including IndeeHub, JustWorks and the merged external-access, Gashboard and
|
||||||
|
DATUM work. OTA/ISO publication still requires subsequent operator acceptance.
|
||||||
|
|
||||||
|
Fresh read-only inspection found the prior operation `36ac4803` Restored with
|
||||||
|
cleanup complete, no lifecycle holds, and all 28 current containers running.
|
||||||
|
The API is already the shutdown-hook-aware version (main.js SHA256
|
||||||
|
`6ff3d4fa5d6a17c530a8e69b2b8b65ec8214c03e71f9a8cf3a27dd53702f1120`).
|
||||||
|
Its one project, one content item and one shareholder caused the legacy
|
||||||
|
empty-business gate to refuse. All 11 checked payment/rental/publication history
|
||||||
|
and intent tables are empty; pending shareholder revenue is zero, and BTCPay/
|
||||||
|
Strike credentials and endpoints are absent. These observations do not qualify
|
||||||
|
updates of nodes with monetary history or configured payment providers.
|
||||||
|
|
||||||
|
Correction `1e11c93e` adds a bounded money-free UAT path: exact known API entry
|
||||||
|
point, disabled registration/publication, absent providers and HTTP connections,
|
||||||
|
closed ingress, stopped frontend/worker, paused empty queue, zero monetary state,
|
||||||
|
and exact database commitments before and after the existing child SIGTERM path.
|
||||||
|
Changed data refuses forward cutover and retains live data for native recovery.
|
||||||
|
All 63 maintenance-controller tests passed. A matching optimized backend build
|
||||||
|
and live deployment/verification remain pending at this checkpoint.
|
||||||
|
|
||||||
|
The README server-install instructions and verified published 1.9.0-alpha ISO
|
||||||
|
link are commit `31ea755c`, including alpha/funds-at-own-risk and planned
|
||||||
|
pre-production hardening wording. Both commits were reviewed and merged through
|
||||||
|
ngit proposal `5bd850d3` (status applied), merge `ecc8cc80`. Main and the existing
|
||||||
|
`v1.9.0-alpha` tag passed local/ngit/Gitea parity. The ISO asset returned HTTP 200
|
||||||
|
and its published SHA-256 is
|
||||||
|
`aad5f0350428976969043079a63b0e7a8264dcee2574526bd34719c798c750af`.
|
||||||
|
This documentation update does not publish a new installer.
|
||||||
|
|
||||||
|
### IndeeHub sign-out UAT deployment — PASS with retained limitation
|
||||||
|
|
||||||
|
The dashboard/provider sign-out change was reviewed as ngit proposal
|
||||||
|
`8648b7362c3ddb2195a7f5466cab9c8ad224ca289996fec303f4a7f8091e7c42`
|
||||||
|
and merged at `2cb1bae5`. A first supervised Yaya attempt
|
||||||
|
`74ef081b-0e7a-49c0-8fd8-959ebcf4b2ec` correctly refused before target startup
|
||||||
|
because the maintenance controller recognized only coherently disabled
|
||||||
|
registration/publication flags, while this money-free UAT installation has both
|
||||||
|
flags enabled. Native recovery completed `Restored`, cleanup complete and
|
||||||
|
maintenance released; PostgreSQL, Redis, MinIO, relay and API identities were
|
||||||
|
preserved, and only the frontend/worker recovery containers were recreated. The
|
||||||
|
failed attempt and its private receipts remain retained; it was not retried or
|
||||||
|
reused.
|
||||||
|
|
||||||
|
The narrow controller correction accepts either coherently enabled or coherently
|
||||||
|
disabled UAT flags while continuing to reject missing, ambiguous or mixed flags
|
||||||
|
and preserving the exact API binary, zero monetary/business/outbox/entitlement/
|
||||||
|
revenue, absent-provider and absent-connection requirements. Sixty-three focused
|
||||||
|
controller tests passed. The required isolated backend suite passed 2,066 tests,
|
||||||
|
with five explicit opt-in ignores and zero failures. It was reviewed as ngit
|
||||||
|
proposal `ecac4574fdefcdb17b913b720d1f7b446db6cd9a6f6063f78a22d6b7af6d320c`,
|
||||||
|
merged at `5812f53c`, mirrored exactly to ngit and Gitea, and passed the local
|
||||||
|
main-ref parity check.
|
||||||
|
|
||||||
|
Yaya now runs optimized backend SHA256
|
||||||
|
`6a1389c8945f95183ea918b653f813e3125e4254ec3078e8daa1773d896573df`
|
||||||
|
with helper SHA256
|
||||||
|
`4e086c7b8ef985944f1a370d932d9107ec87d2164b004f130522d25a81184c23`.
|
||||||
|
The signed 69-app combined catalog remains SHA256
|
||||||
|
`88888f6a541e2b13b70e2ab57ae6c355420313d61fd47c152b4341b2cda3ab1a`.
|
||||||
|
After native recovery, a fresh seven-unit plan was bound to the actual recovered
|
||||||
|
frontend/worker recipes; its SHA256 is
|
||||||
|
`a6d26db482c86ff8dda6b2c2816473f2cdcbd5def98c9cf640b97e42d53206bd`.
|
||||||
|
Offline preparation preserved the exact seven originals without changing any
|
||||||
|
runtime. Dashboard index and provider SHA256 values are respectively
|
||||||
|
`f3cff3f8a27ae94318c34ef61638e22a73bb06fc076634ebe981309ee3c5008e`
|
||||||
|
and `ad4c93b3b25545dca1b391c5add75e5bc618c865290805f7ac5d1ad0fd18b469`.
|
||||||
|
|
||||||
|
Exactly one new authenticated `package.update` was submitted. Operation
|
||||||
|
`309f3d74-74d4-478a-a02e-cf077bab547d` completed Prepared → Editing → Starting →
|
||||||
|
**Committed**, cleanup complete; maintenance is **Released/committed**, and both
|
||||||
|
fresh database and four-volume restore proofs pass. Post-commit verification
|
||||||
|
passed all seven exact target units/images and healthy new runtimes, exact
|
||||||
|
database commitments, all 110 migration rows including the three reviewed
|
||||||
|
additions, 21 unchanged unrelated runtimes, 16 unchanged identity/operator
|
||||||
|
files, 18 unchanged unrelated units, cleared holds/fence, coherent enabled UAT
|
||||||
|
flags, nginx configuration, and matching host/container provider bytes.
|
||||||
|
|
||||||
|
A disposable live Chromium test injected valid-looking stale login hints without
|
||||||
|
using a real identity. The production API did not grant authentication; the
|
||||||
|
primary login credentials and active-account selection cleared, and the app
|
||||||
|
remained signed out after both normal and cache-bypassing hard reloads. Three
|
||||||
|
secondary API-client hints (`indeehub_api_token`, `indeehub_api_refresh`, and
|
||||||
|
`indeehub_api_expires`) remained in that synthetic profile but did not restore
|
||||||
|
authentication. Treat complete removal of those non-authorizing hints as retained
|
||||||
|
hardening rather than claiming every cache key was erased. The IndeeHub frontend
|
||||||
|
commits `0d6434e` and `71cf546a` remain local because that repository has no ngit
|
||||||
|
coordinate; do not publish them Gitea-only under the mirror policy.
|
||||||
|
|
||||||
|
### IndeeHub selected-identity regression — UAT reopened 9 October 2026
|
||||||
|
|
||||||
|
Operator UAT disproved the earlier sign-out acceptance: choosing the homeserver
|
||||||
|
identity could continue with a previously active saved account and display a
|
||||||
|
different npub. `loginWithExtension` caught selection/provider failures without
|
||||||
|
rethrowing, after which the modal read `accountManager.active`; that pointer could
|
||||||
|
still name the old account. IndeeHub commits `a4d3946` and `86256ee` now clear the
|
||||||
|
active pointer before an explicit native switch, propagate failure, bind the
|
||||||
|
authentication call to the exact returned signer account, and retain the
|
||||||
|
Archipelago provider hook in the source index. A regression starts with a stale
|
||||||
|
saved account and proves a failed selection cannot fall back to it while the
|
||||||
|
saved key itself remains preserved.
|
||||||
|
|
||||||
|
All 155 frontend tests, type checking and the production build pass. A live Yaya
|
||||||
|
frontend-only correction is installed with rollback under the operator's private
|
||||||
|
artifact directory. The served index references the provider, provider SHA256 is
|
||||||
|
still `ad4c93b3b25545dca1b391c5add75e5bc618c865290805f7ac5d1ad0fd18b469`,
|
||||||
|
the service worker was regenerated to invalidate the brief provider-less cache,
|
||||||
|
and `/health` passes. Automated Chromium confirms the provider loads and no stale
|
||||||
|
active account is restored. Real homeserver selection and displayed-npub matching
|
||||||
|
remain pending operator UAT; the current live filesystem correction is not yet a
|
||||||
|
new signed catalog/image activation and must not be described as durable managed
|
||||||
|
deployment acceptance.
|
||||||
|
|
||||||
## Combined Yaya UAT candidate — 9 October 2026
|
## Combined Yaya UAT candidate — 9 October 2026
|
||||||
|
|
||||||
|
Follow-up operator request: retain all node-native identities; remove IndeeHub's
|
||||||
|
Create Account/private-key import and reset only its old browser accounts.
|
||||||
|
IndeeHub commit `7fbdc74` removes generation/import handlers and screens, performs
|
||||||
|
a one-time removal of `indeedhub-accounts`, `indeedhub-active-account` and app
|
||||||
|
session credentials, and corrects the default API base from browser localhost
|
||||||
|
to `/api`. The previous live hotfix was built without explicit API settings,
|
||||||
|
explaining its network error; its earlier success claims were premature.
|
||||||
|
Twenty-one focused authentication/reset tests pass and the production build
|
||||||
|
passes with `/api`, `/storage`, `/relay`, mock off and publication enabled.
|
||||||
|
This updated static bundle is deployed to Yaya's existing frontend container;
|
||||||
|
its index SHA256 is `a14834acab5fc9b42f55a92c540b9b765334ac7d2d2628ddc82bc1c5725d53ad`.
|
||||||
|
Node identity storage was not modified. Signed-image recreation and real native
|
||||||
|
identity sign-in acceptance remain outstanding.
|
||||||
|
|
||||||
The combined source is published on ngit and mirrored byte-for-byte to Gitea at
|
The combined source is published on ngit and mirrored byte-for-byte to Gitea at
|
||||||
main `bb933d409187a8181e284a5a91a81b4e036b3036`, based on accepted main
|
main `bb933d409187a8181e284a5a91a81b4e036b3036`, based on accepted main
|
||||||
`aff408cc`. Its integration commits retain each proposal head as a parent:
|
`aff408cc`. Its integration commits retain each proposal head as a parent:
|
||||||
@@ -1197,3 +1355,46 @@ Public assets: [1.9.0-alpha release](https://source.archipelago-foundation.org/l
|
|||||||
The separate follow-up branch now passes1,683 backend tests (four existing ignored)
|
The separate follow-up branch now passes1,683 backend tests (four existing ignored)
|
||||||
and all1,222 frontend tests after correcting the storage classifier. These are
|
and all1,222 frontend tests after correcting the storage classifier. These are
|
||||||
source test results, not inclusion in the published artifacts or live acceptance.
|
source test results, not inclusion in the published artifacts or live acceptance.
|
||||||
|
|
||||||
|
## IndeeHub native login and registration repair — 2026-10-09 follow-up
|
||||||
|
|
||||||
|
ngit proposal `9d6de783f5022b3b859e0b1f3e881e389da4482dd5dd15ff29a40b4e25dafcb8`
|
||||||
|
was reviewed and merged once as
|
||||||
|
`83feb1806336a1204ea0d6232de5220d4f6656ad`. It retains installed/running
|
||||||
|
parent, existing node identity/pin, installed origins and legacy API component
|
||||||
|
checks while accepting managed stacks without a separate API package record.
|
||||||
|
See the latest checkpoint in `post-1.8.22-regressions-20261001.md` for exact
|
||||||
|
deployed hashes, preserved-state checks, backups and the failed earlier probes.
|
||||||
|
|
||||||
|
Yaya's authenticated registration-context RPC now passes on the optimized
|
||||||
|
candidate backend. Real native **Home Serve** authentication passes after
|
||||||
|
IndeeHub frontend `7b18912` and API profile correction `08586e3`: chosen node
|
||||||
|
key, signed session subject and API profile key match, with native consent and
|
||||||
|
normal signer dismissal. Frontend 21 focused tests, API two DTO tests, and both
|
||||||
|
production builds pass. The isolated Rust regression build is still running;
|
||||||
|
no unit-test pass is inferred from the live check.
|
||||||
|
|
||||||
|
The frontend/API module changes are running-container UAT patches, not a new
|
||||||
|
durable pinned app image. Full upload/publication and cached-identity-switch
|
||||||
|
acceptance remain open. No payment, public listing, identity replacement, fleet
|
||||||
|
catalog, OTA or ISO publication was performed by this repair.
|
||||||
|
|
||||||
|
## ngit UAT contribution disposition — 2026-10-09
|
||||||
|
|
||||||
|
The overlapping hardware proposals `a9165a5e`, `5a210008`, `a11eb1da`,
|
||||||
|
and `39c7c335` were reviewed as one contribution and merged once at
|
||||||
|
`a5637d9350aa05fa52136e736a64a5bccd1539c6`. ngit marked all four
|
||||||
|
applied after main publication; no duplicate Gitea merge was created.
|
||||||
|
The hardware frontend production build and six focused UI tests passed.
|
||||||
|
|
||||||
|
Terminal proposal `c1156d20` merged at `02671438d`; its dashboard-origin
|
||||||
|
security correction was separately reviewed as ngit proposal `2e6577db`
|
||||||
|
and merged at `bb7961277`. The terminal frontend build, two shell suites,
|
||||||
|
and isolated backend `terminal_origin_tests` passed. Declared-author proposal
|
||||||
|
`924f78e1` merged at `3667105b2`; its isolated backend regression passed.
|
||||||
|
The IndeeHub open-UAT checklist proposal `f9914a73` merged at `4b65879b6`.
|
||||||
|
After publishing main at `4b65879b6`, ngit showed no open/draft proposals.
|
||||||
|
|
||||||
|
`python3 scripts/check-git-mirrors.py --local` passed for local/ngit/Gitea
|
||||||
|
main at `4b65879b6`. This is a main-ref check only, not an `--all` historical
|
||||||
|
branch/tag audit, live-node acceptance, or permission to publish OTA/ISO.
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Release OTA and ISO from latest `main`
|
||||||
|
|
||||||
|
This is the operator handoff for producing a release from whatever commit is
|
||||||
|
the reviewed tip of `main` at the time. A release is not ready merely because
|
||||||
|
the artifacts build. Every active regression, live-node acceptance, mirror and
|
||||||
|
publication gate below must pass.
|
||||||
|
|
||||||
|
## 1. Freeze an exact reviewed source revision
|
||||||
|
|
||||||
|
Use a fresh clone or clean worktree. Do not build from an UAT node or a dirty
|
||||||
|
tree.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git switch main
|
||||||
|
git pull --ff-only ngit main
|
||||||
|
git status --short
|
||||||
|
git rev-parse HEAD
|
||||||
|
python3 scripts/check-git-mirrors.py --local --all
|
||||||
|
```
|
||||||
|
|
||||||
|
`git status --short` must be empty. The full mirror audit must show matching
|
||||||
|
advertised branches/tags on ngit and Gitea. Inventory and resolve any unrelated
|
||||||
|
drift; never force-push, delete refs, or rewrite published history merely to
|
||||||
|
make the check green. Record the accepted ngit proposal and merge commit in the
|
||||||
|
release acceptance ledger.
|
||||||
|
|
||||||
|
Read and retain every unchecked item in:
|
||||||
|
|
||||||
|
- `docs/post-1.8.22-regressions-20261001.md`
|
||||||
|
- the current release acceptance ledger and UAT checklist
|
||||||
|
|
||||||
|
Run the source gates and the documented live-node matrix. On a node with
|
||||||
|
installed apps, backend tests must run only through the isolation wrapper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/test-backend-isolated.sh
|
||||||
|
tests/release/run.sh
|
||||||
|
python3 scripts/check-app-catalog-drift.py --release --strict
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep source/unit results separate from actual-node acceptance. In particular,
|
||||||
|
prove that paid-file recovery never makes a second payment and that app cleanup
|
||||||
|
preserves wallets, persistent data, and uninstall decisions.
|
||||||
|
|
||||||
|
## 2. Prepare and sign the OTA release
|
||||||
|
|
||||||
|
Choose a new SemVer that has never been published. Do not move or replace an
|
||||||
|
existing release tag. Curate the new top entry in `CHANGELOG.md`, then preview:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
release_version=X.Y.Z-alpha
|
||||||
|
scripts/create-release.sh "$release_version" --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
When all gates and review are complete, run the real preparation from clean
|
||||||
|
`main` in an interactive terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/create-release.sh "$release_version"
|
||||||
|
```
|
||||||
|
|
||||||
|
The script builds the backend, frontend, AIUI and radio tools, creates a signed
|
||||||
|
pending manifest and staged OTA artifacts, commits the release preparation, and
|
||||||
|
creates annotated tag `v$release_version`. During the signing prompt, paste the
|
||||||
|
24-word release master mnemonic **once**, press Enter, then Ctrl-D on the next
|
||||||
|
line. Never put the mnemonic in a command, file, chat, log, or Git. Do not use
|
||||||
|
`RELEASE_MASTER_MNEMONIC` for the normal operator ceremony.
|
||||||
|
|
||||||
|
If an already-prepared pending manifest needs signing on the offline/operator
|
||||||
|
terminal, first ensure the release binary was built from the exact frozen
|
||||||
|
commit, then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/sign-manifest.sh \
|
||||||
|
"releases/pending/v${release_version}/manifest.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
The script cryptographically verifies the result against the public release
|
||||||
|
root pinned in the binary. A failed verification is a hard stop.
|
||||||
|
|
||||||
|
Review the preparation commit, tag target, staged artifacts and signature. Push
|
||||||
|
the exact reviewed commit/tag to both publication mirrors according to the
|
||||||
|
ngit-first workflow; do not create independent merges on each platform.
|
||||||
|
|
||||||
|
## 3. Build and sign the installer ISO
|
||||||
|
|
||||||
|
The ISO builder requires clean `main`, matching versions, the annotated tag and
|
||||||
|
the signed live manifest. After OTA publication has safely promoted the live
|
||||||
|
manifest (next section), return to the exact tagged clean tree and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/build-iso-release.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
For a normal release, do **not** pass `--skip-gates` or `--no-qemu`. The command
|
||||||
|
runs the release harness, strict catalog check, artifact checks, ISO build,
|
||||||
|
mount-level smoke test and headless QEMU boot. Keep its final PASS summary as
|
||||||
|
release evidence.
|
||||||
|
|
||||||
|
Sign the generated ISO checksum document in the operator ceremony:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
iso_path=/absolute/path/to/archipelago-${release_version}.iso
|
||||||
|
bash scripts/sign-iso-checksums.sh "$iso_path"
|
||||||
|
core/target/release/archipelago ceremony verify \
|
||||||
|
"$iso_path.sha256.json"
|
||||||
|
sha256sum -c "$iso_path.sha256"
|
||||||
|
```
|
||||||
|
|
||||||
|
The signing script again expects one mnemonic paste, Enter, then Ctrl-D. It
|
||||||
|
does not place the mnemonic on disk.
|
||||||
|
|
||||||
|
## 4. Publish without exposing incomplete updates
|
||||||
|
|
||||||
|
Configure the Gitea remote with a public HTTPS URL and keep its credentials in
|
||||||
|
Git's credential helper, never in the remote URL. Then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/publish-release-assets.sh "$release_version" gitea-vps2
|
||||||
|
```
|
||||||
|
|
||||||
|
This script deliberately pushes the tag first, creates the release, uploads and
|
||||||
|
byte-verifies the OTA assets, and pushes the manifest-bearing `main` last. When
|
||||||
|
the signed ISO files exist, the same command attaches and verifies them. Never
|
||||||
|
manually publish `releases/manifest.json` before its referenced assets are
|
||||||
|
downloadable and verified.
|
||||||
|
|
||||||
|
After publishing, mirror the exact accepted `main` commit and annotated tag to
|
||||||
|
ngit and Gitea, then verify both:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/check-git-mirrors.py --local \
|
||||||
|
--ref "refs/tags/v${release_version}"
|
||||||
|
python3 scripts/check-git-mirrors.py --local --all
|
||||||
|
scripts/check-release-assets.sh releases/manifest.json
|
||||||
|
```
|
||||||
|
|
||||||
|
A failed push, missing ref, tag-object mismatch, unavailable mirror, incomplete
|
||||||
|
asset, or mismatched byte hash blocks publication. A main-only check is not full
|
||||||
|
historical mirror parity.
|
||||||
|
|
||||||
|
## 5. Canary and final acceptance
|
||||||
|
|
||||||
|
Before announcing the release:
|
||||||
|
|
||||||
|
1. Apply OTA to one non-critical canary from the signed manifest.
|
||||||
|
2. Reboot it and verify management health, signer/native identity, installed and
|
||||||
|
deliberately removed apps, wallet state, persistent app data, terminal,
|
||||||
|
networking/FIPS, and the active regression checklist.
|
||||||
|
3. Exercise rollback/recovery without changing wallets or app data.
|
||||||
|
4. Boot the ISO in QEMU and on representative physical hardware; perform a clean
|
||||||
|
install and an upgrade-path check, then repeat the same acceptance matrix.
|
||||||
|
5. Verify public release downloads and both Git mirrors once more. Record exact
|
||||||
|
commit, annotated tag object, artifact hashes, signer verification, node
|
||||||
|
evidence, ngit proposal/merge, known deferrals and operator acceptance in the
|
||||||
|
release ledger.
|
||||||
|
|
||||||
|
Do not call the release complete if any required live acceptance, signature,
|
||||||
|
mirror, asset, OTA reboot/rollback, or ISO boot/install result is missing.
|
||||||
@@ -386,6 +386,7 @@ DOCKERFILE_HEAD
|
|||||||
python3 \
|
python3 \
|
||||||
curl \
|
curl \
|
||||||
git \
|
git \
|
||||||
|
tmux \
|
||||||
vim-tiny \
|
vim-tiny \
|
||||||
nano \
|
nano \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ chroot /mnt/archipelago apt-get install -y \
|
|||||||
curl \
|
curl \
|
||||||
wget \
|
wget \
|
||||||
htop \
|
htop \
|
||||||
|
tmux \
|
||||||
vim-tiny \
|
vim-tiny \
|
||||||
nano \
|
nano \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
|||||||
@@ -37,6 +37,21 @@ map "$archy_management_private_source:$archy_management_private_client:$archy_ma
|
|||||||
}
|
}
|
||||||
# END ARCHIPELAGO MANAGEMENT SOURCE GUARD
|
# END ARCHIPELAGO MANAGEMENT SOURCE GUARD
|
||||||
|
|
||||||
|
# Only IndeeHub on this same node may read the owner's FileBrowser API for
|
||||||
|
# its in-app Cloud picker. Never reflect arbitrary cross-port origins.
|
||||||
|
map "$host|$http_origin" $archy_cloud_picker_origin {
|
||||||
|
default "";
|
||||||
|
"~^([^|]+)\|http://\1:7778$" $http_origin;
|
||||||
|
}
|
||||||
|
map "$request_method|$http_sec_fetch_site|$http_origin" $archy_terminal_origin {
|
||||||
|
default $http_origin;
|
||||||
|
~^GET\|same-origin\| "$scheme://$http_host";
|
||||||
|
# Older WebViews can omit both Origin and Fetch Metadata on same-origin
|
||||||
|
# GETs. Only synthesize in that header-free case; a cross-origin request
|
||||||
|
# carrying Origin still reaches the backend unchanged and is rejected.
|
||||||
|
"GET||" "$scheme://$http_host";
|
||||||
|
}
|
||||||
|
|
||||||
# Rate limit zones
|
# Rate limit zones
|
||||||
limit_req_zone $binary_remote_addr zone=rpc:10m rate=20r/s;
|
limit_req_zone $binary_remote_addr zone=rpc:10m rate=20r/s;
|
||||||
limit_req_zone $binary_remote_addr zone=auth:10m rate=3r/s;
|
limit_req_zone $binary_remote_addr zone=auth:10m rate=3r/s;
|
||||||
@@ -279,6 +294,30 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Serve static files (Vue.js SPA)
|
# Serve static files (Vue.js SPA)
|
||||||
|
# The developer terminal is an authenticated JSON API. Keep it ahead of
|
||||||
|
# the SPA fallback so a missing session returns JSON 401, not index.html.
|
||||||
|
location ^~ /api/terminal/ {
|
||||||
|
proxy_pass http://127.0.0.1:5678;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header Cookie $http_cookie;
|
||||||
|
proxy_set_header Origin $archy_terminal_origin;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Browser-facing bridge for IndeeHub streams hosted by connected nodes.
|
||||||
|
# The backend enforces the owner session on peer routes and free-content
|
||||||
|
# policy on public routes; keep these ahead of the dashboard SPA fallback.
|
||||||
|
location ~ ^/api/(peer|public)-indeehub-(stream|media|key)/ {
|
||||||
|
proxy_pass http://127.0.0.1:5678;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header Cookie $http_cookie;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
add_header Cache-Control "no-cache, must-revalidate";
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
@@ -315,6 +354,12 @@ server {
|
|||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# Browser-mediated Cloud selection uses the existing node session.
|
||||||
|
# Only this node's own IndeeHub origin is admitted by the map above.
|
||||||
|
proxy_hide_header Access-Control-Allow-Origin;
|
||||||
|
proxy_hide_header Access-Control-Allow-Credentials;
|
||||||
|
add_header Access-Control-Allow-Origin $archy_cloud_picker_origin always;
|
||||||
|
add_header Access-Control-Allow-Credentials "true" always;
|
||||||
|
|
||||||
# Limit request body to 1MB for RPC calls
|
# Limit request body to 1MB for RPC calls
|
||||||
client_max_body_size 1m;
|
client_max_body_size 1m;
|
||||||
@@ -379,6 +424,24 @@ server {
|
|||||||
error_page 504 = @backend_timeout;
|
error_page 504 = @backend_timeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Public, sanitized IndeeHub listings only. The upstream is the direct
|
||||||
|
# loopback app port; video, keys and payments remain behind the app gate.
|
||||||
|
location = /api/public-catalog/indeedhub {
|
||||||
|
limit_except GET { deny all; }
|
||||||
|
proxy_pass http://127.0.0.1:7778/api/projects/public-catalog;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Cookie "";
|
||||||
|
proxy_set_header Authorization "";
|
||||||
|
proxy_hide_header Access-Control-Allow-Origin;
|
||||||
|
proxy_hide_header Access-Control-Allow-Credentials;
|
||||||
|
proxy_hide_header Cache-Control;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
proxy_read_timeout 10s;
|
||||||
|
add_header Access-Control-Allow-Origin "*" always;
|
||||||
|
add_header Cache-Control "public, max-age=30" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
}
|
||||||
|
|
||||||
# Pine node status — live node facts for the Pine launcher page and the
|
# Pine node status — live node facts for the Pine launcher page and the
|
||||||
# seeded Home Assistant sensors. Sensitive fields are token-gated at the
|
# seeded Home Assistant sensors. Sensitive fields are token-gated at the
|
||||||
# backend; nginx only forwards.
|
# backend; nginx only forwards.
|
||||||
@@ -603,6 +666,8 @@ server {
|
|||||||
proxy_hide_header X-Frame-Options;
|
proxy_hide_header X-Frame-Options;
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
proxy_hide_header Content-Security-Policy;
|
proxy_hide_header Content-Security-Policy;
|
||||||
|
add_header Access-Control-Allow-Origin $archy_cloud_picker_origin always;
|
||||||
|
add_header Access-Control-Allow-Credentials "true" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
proxy_request_buffering off;
|
proxy_request_buffering off;
|
||||||
proxy_set_header Accept-Encoding "";
|
proxy_set_header Accept-Encoding "";
|
||||||
@@ -1257,6 +1322,25 @@ server {
|
|||||||
return 404;
|
return 404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ^~ /api/terminal/ {
|
||||||
|
proxy_pass http://127.0.0.1:5678;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header Cookie $http_cookie;
|
||||||
|
proxy_set_header Origin $archy_terminal_origin;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/api/(peer|public)-indeehub-(stream|media|key)/ {
|
||||||
|
proxy_pass http://127.0.0.1:5678;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header Cookie $http_cookie;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
@@ -1306,6 +1390,24 @@ server {
|
|||||||
error_page 504 = @backend_timeout;
|
error_page 504 = @backend_timeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Public, sanitized IndeeHub listings only. The upstream is the direct
|
||||||
|
# loopback app port; video, keys and payments remain behind the app gate.
|
||||||
|
location = /api/public-catalog/indeedhub {
|
||||||
|
limit_except GET { deny all; }
|
||||||
|
proxy_pass http://127.0.0.1:7778/api/projects/public-catalog;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Cookie "";
|
||||||
|
proxy_set_header Authorization "";
|
||||||
|
proxy_hide_header Access-Control-Allow-Origin;
|
||||||
|
proxy_hide_header Access-Control-Allow-Credentials;
|
||||||
|
proxy_hide_header Cache-Control;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
proxy_read_timeout 10s;
|
||||||
|
add_header Access-Control-Allow-Origin "*" always;
|
||||||
|
add_header Cache-Control "public, max-age=30" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
}
|
||||||
|
|
||||||
# Pine node status — live node facts for the Pine launcher page and the
|
# Pine node status — live node facts for the Pine launcher page and the
|
||||||
# seeded Home Assistant sensors. Sensitive fields are token-gated at the
|
# seeded Home Assistant sensors. Sensitive fields are token-gated at the
|
||||||
# backend; nginx only forwards.
|
# backend; nginx only forwards.
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ planned (see docs/LICENSE-COMPLIANCE-AUDIT.md).
|
|||||||
|
|
||||||
## Vendored
|
## Vendored
|
||||||
|
|
||||||
|
- `src/graphics/assets/grapheneos-boot-mask.png` — GrapheneOS boot artwork,
|
||||||
|
Apache-2.0; see the adjacent ATTRIBUTION.md for the pinned source and credits.
|
||||||
- `public/assets/icon/` — see ATTRIBUTION.md in that directory
|
- `public/assets/icon/` — see ATTRIBUTION.md in that directory
|
||||||
(game-icons.net CC BY 3.0; pixelarticons MIT).
|
(game-icons.net CC BY 3.0; pixelarticons MIT).
|
||||||
- `public/assets/img/mesh-devices/` — Meshtastic project artwork, GPL-3.0;
|
- `public/assets/img/mesh-devices/` — Meshtastic project artwork, GPL-3.0;
|
||||||
|
|||||||
@@ -4470,6 +4470,12 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
|
|
||||||
// ── OpenWRT / TollGate gateway (demo: a thoroughly-used gateway) ──
|
// ── OpenWRT / TollGate gateway (demo: a thoroughly-used gateway) ──
|
||||||
case 'openwrt.get-status': {
|
case 'openwrt.get-status': {
|
||||||
|
// Keep the local preview in the unconnected state so the router model
|
||||||
|
// is immediately visible. Set MOCK_OPENWRT_CONNECTED=1 to preview the
|
||||||
|
// connected dashboard instead.
|
||||||
|
if (!process.env.MOCK_OPENWRT_CONNECTED) {
|
||||||
|
return res.json({ error: { code: -32004, message: 'No router configured' } })
|
||||||
|
}
|
||||||
return res.json({
|
return res.json({
|
||||||
result: {
|
result: {
|
||||||
host: '192.168.8.1',
|
host: '192.168.8.1',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.9.0-alpha",
|
"version": "1.9.1-alpha",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "./start-dev.sh",
|
"start": "./start-dev.sh",
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"qrloop": "^1.4.1",
|
"qrloop": "^1.4.1",
|
||||||
|
"three": "^0.185.0",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-i18n": "^11.3.0",
|
"vue-i18n": "^11.3.0",
|
||||||
"vue-router": "^4.6.3"
|
"vue-router": "^4.6.3"
|
||||||
@@ -51,16 +52,17 @@
|
|||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@types/three": "^0.185.0",
|
||||||
"@vite-pwa/assets-generator": "^1.0.2",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^5.0.3",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"autoprefixer": "^10.4.22",
|
"autoprefixer": "^10.4.22",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dockerode": "^4.0.9",
|
"dockerode": "^5.0.1",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
@@ -68,7 +70,7 @@
|
|||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.2.2",
|
"vite": "^7.2.2",
|
||||||
"vite-plugin-pwa": "^1.2.0",
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
"vitest": "^3.1.1",
|
"vitest": "^5.0.3",
|
||||||
"vue-tsc": "^3.1.3",
|
"vue-tsc": "^3.1.3",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
# Device Artwork Attribution
|
|
||||||
|
|
||||||
The device illustrations in this directory are from the
|
|
||||||
**Meshtastic® project** — https://meshtastic.org
|
|
||||||
(https://github.com/meshtastic), © Meshtastic contributors,
|
|
||||||
licensed under GPL-3.0.
|
|
||||||
|
|
||||||
Meshtastic® is a registered trademark of Meshtastic LLC, used here solely to
|
|
||||||
identify compatible hardware. No endorsement by the Meshtastic project is
|
|
||||||
implied.
|
|
||||||
|
|
||||||
Any modifications to these images made for Archipelago are likewise available
|
|
||||||
under GPL-3.0 as part of this public repository.
|
|
||||||
|
Before Width: | Height: | Size: 96 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="795.27 277.13 409.46 1319.35"><defs><style>.cls-1{fill:#353535;}.cls-2{fill:#1e1e1d;}.cls-3{fill:#b1a368;}.cls-10,.cls-11,.cls-4,.cls-6,.cls-8,.cls-9{fill:none;}.cls-4,.cls-6{stroke:#050606;}.cls-10,.cls-11,.cls-4,.cls-6,.cls-8{stroke-miterlimit:10;}.cls-4{stroke-width:2.41px;}.cls-5{fill:#30c2db;}.cls-6{stroke-width:3.91px;}.cls-7{fill:#dcf0f2;}.cls-10,.cls-11,.cls-8{stroke:#dcf0f2;}.cls-8{stroke-width:1.81px;}.cls-9{stroke:#17afbf;stroke-linecap:round;stroke-linejoin:round;stroke-width:7.23px;}.cls-10{stroke-width:1.78px;}.cls-11{stroke-width:1.81px;}</style></defs><g id="Layer_7" data-name="Layer 7"><path class="cls-1" fill="#353535" d="M915.62,278.34h22.61a35,35,0,0,1,35,35V715.74a0,0,0,0,1,0,0H880.6a0,0,0,0,1,0,0V313.36A35,35,0,0,1,915.62,278.34Z"></path><rect class="cls-2" fill="#1e1e1d" x="880.6" y="340.15" width="92.65" height="7.54"></rect><rect class="cls-2" fill="#1e1e1d" x="880.6" y="356.68" width="92.65" height="7.54"></rect><rect class="cls-3" fill="#b1a368" x="885.8" y="844.3" width="84.14" height="19.02"></rect><rect class="cls-3" fill="#b1a368" x="880.6" y="819.07" width="92.65" height="25.23"></rect><rect class="cls-3" fill="#b1a368" x="885.8" y="790.65" width="84.14" height="28.41"></rect><rect class="cls-3" fill="#b1a368" x="880.6" y="723.02" width="92.65" height="67.63"></rect><rect class="cls-3" fill="#b1a368" x="885.8" y="715.74" width="84.14" height="7.28"></rect><rect class="cls-4" stroke-width="2.41px" x="885.8" y="844.3" width="84.14" height="19.02"></rect><rect class="cls-4" stroke-width="2.41px" x="880.6" y="819.07" width="92.65" height="25.23"></rect><rect class="cls-4" stroke-width="2.41px" x="885.8" y="790.65" width="84.14" height="28.41"></rect><rect class="cls-4" stroke-width="2.41px" x="880.6" y="723.02" width="92.65" height="67.63"></rect><rect class="cls-4" stroke-width="2.41px" x="885.8" y="715.74" width="84.14" height="7.28"></rect><path class="cls-4" stroke-width="2.41px" d="M915.62,278.34h22.61a35,35,0,0,1,35,35V715.74a0,0,0,0,1,0,0H880.6a0,0,0,0,1,0,0V313.36A35,35,0,0,1,915.62,278.34Z"></path><rect class="cls-4" stroke-width="2.41px" x="880.6" y="340.15" width="92.65" height="7.54"></rect><rect class="cls-4" stroke-width="2.41px" x="880.6" y="356.68" width="92.65" height="7.54"></rect><rect class="cls-5" fill="#30c2db" x="796.48" y="856.3" width="407.05" height="738.98" rx="47.74"></rect><rect class="cls-1" fill="#353535" x="900.05" y="973.19" width="202.03" height="354.65" rx="16.4"></rect><rect class="cls-6" stroke-width="3.91px" x="900.05" y="973.19" width="202.03" height="354.65" rx="16.4"></rect><rect class="cls-7" fill="#dcf0f2" x="871.51" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><rect class="cls-7" fill="#dcf0f2" x="1070.16" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><rect class="cls-4" stroke-width="2.41px" x="871.51" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><rect class="cls-4" stroke-width="2.41px" x="1070.16" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><circle class="cls-8" stroke-width="1.81px" cx="841.7" cy="1537.01" r="16.25"></circle><circle class="cls-8" stroke-width="1.81px" cx="841.7" cy="913.26" r="16.25"></circle><circle class="cls-8" stroke-width="1.81px" cx="1157.32" cy="913.26" r="16.25"></circle><circle class="cls-8" stroke-width="1.81px" cx="1157.32" cy="1504.51" r="16.25"></circle><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="942.51" y1="1592.42" x2="942.51" y2="1381.55"></line><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="966.52" y1="1592.42" x2="966.52" y2="1381.55"></line><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="990.57" y1="1592.42" x2="990.57" y2="1381.55"></line><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="1014.59" y1="1592.42" x2="1014.59" y2="1381.55"></line><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="1038.63" y1="1592.42" x2="1038.63" y2="1381.55"></line><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="1062.65" y1="1592.42" x2="1062.65" y2="1381.55"></line><rect class="cls-4" stroke-width="2.41px" x="796.48" y="856.3" width="407.05" height="738.98" rx="47.74"></rect><path class="cls-10" stroke-width="1.78px" d="M1040.1,947.74H960.65A13.93,13.93,0,0,1,947,936.64l-10.23-49.2a13.93,13.93,0,0,1,13.64-16.77h97.72a13.93,13.93,0,0,1,13.75,16.18l-8,49.2A13.94,13.94,0,0,1,1040.1,947.74Z"></path><rect class="cls-11" stroke-width="1.81px" x="816.35" y="870.67" width="365.51" height="703.12" rx="32.37"></rect><rect class="cls-11" stroke-width="1.81px" x="888.77" y="963.84" width="223.2" height="374.66" rx="25.21"></rect></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 10 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="404.68 390.65 1217.15 959.26"><defs><style>.cls-1{fill:#dfeaf7;}.cls-2{fill:#17907f;}.cls-3{fill:#2b2b2b;}.cls-4,.cls-5{fill:none;stroke:#050606;stroke-miterlimit:10;}.cls-4{stroke-width:2.25px;}.cls-5{stroke-width:4px;}.cls-6{fill:#050606;}</style></defs><g id="Layer_5" data-name="Layer 5"><path class="cls-1" fill="#dfeaf7" d="M1517.73,392.65h0a102.1,102.1,0,0,0-102.1,102.1V770.37A40.62,40.62,0,0,1,1375,811H455.16a48.49,48.49,0,0,0-48.48,48.48v126a11.85,11.85,0,0,0,3.46,8.37l15.34,15.34a11.81,11.81,0,0,1,3.47,8.37v137.16a11.81,11.81,0,0,1-3.47,8.37l-15.34,15.34a11.85,11.85,0,0,0-3.46,8.37v112.67a48.49,48.49,0,0,0,48.48,48.49H1571.34a48.51,48.51,0,0,0,48.49-48.5V494.75A102.1,102.1,0,0,0,1517.73,392.65Zm-110.61,815V954a33.14,33.14,0,0,1,66.27,0v253.65a33.14,33.14,0,0,1-66.27,0Z"></path><path class="cls-2" fill="#17907f" d="M1516,439.16c-30.23.91-53.92,26.54-53.92,56.79V770.37A87.11,87.11,0,0,1,1375,857.48H732.31A27.51,27.51,0,0,0,704.8,885v388.93a27.51,27.51,0,0,0,27.51,27.51h828.38a12.7,12.7,0,0,0,12.65-12.65v-794A55.69,55.69,0,0,0,1516,439.16Zm-108.9,768.47V954a33.14,33.14,0,0,1,66.27,0v253.65a33.14,33.14,0,0,1-66.27,0Z"></path><rect class="cls-3" fill="#2b2b2b" x="787.14" y="943.38" width="429.45" height="224.42"></rect><path class="cls-1" fill="#dfeaf7" d="M1478.6,915.35A54.23,54.23,0,0,0,1386,953.69v254.23a54.23,54.23,0,1,0,108.45,0V953.69A54,54,0,0,0,1478.6,915.35Zm-5.21,292.28a33.14,33.14,0,0,1-66.27,0V954a33.14,33.14,0,0,1,66.27,0Z"></path></g><g id="Layer_2" data-name="Layer 2"><path class="cls-4" stroke-width="2.25px" d="M1573.34,494.75v794a12.68,12.68,0,0,1-12.65,12.65H732.31a27.51,27.51,0,0,1-27.51-27.51V885a27.51,27.51,0,0,1,27.51-27.51H1375a87.11,87.11,0,0,0,87.11-87.11V496c0-30.25,23.69-55.88,53.92-56.79A55.69,55.69,0,0,1,1573.34,494.75Z"></path><path class="cls-5" stroke-width="4px" d="M410.14,1178.39,425.49,1163a11.78,11.78,0,0,0,3.46-8.35V1017.5a11.8,11.8,0,0,0-3.46-8.35l-15.35-15.36a11.77,11.77,0,0,1-3.46-8.35v-126A48.47,48.47,0,0,1,455.16,811H1375a40.63,40.63,0,0,0,40.63-40.63V494.75a102.1,102.1,0,0,1,102.1-102.1h0a102.1,102.1,0,0,1,102.1,102.1v804.66a48.51,48.51,0,0,1-48.49,48.5H455.16a48.48,48.48,0,0,1-48.48-48.49V1186.74A11.78,11.78,0,0,1,410.14,1178.39Z"></path><rect class="cls-4" stroke-width="2.25px" x="1407.12" y="920.85" width="66.26" height="319.9" rx="33.13"></rect><rect class="cls-4" stroke-width="2.25px" x="1386.03" y="899.46" width="108.46" height="362.69" rx="54.23"></rect><path class="cls-6" fill="#050606" d="M639.76,1070.55a2.91,2.91,0,0,1-2.91-2.91v-30.53a5.42,5.42,0,0,0-1.6-3.86l-32.44-32.44a11.86,11.86,0,0,1-3.5-8.44V901a12.52,12.52,0,0,0-12.51-12.51H483.92a12.7,12.7,0,0,0-12.68,12.69v76.78a24.13,24.13,0,0,0,7.11,17.18l14.33,14.33a24.13,24.13,0,0,0,17.18,7.11h50.75a11.86,11.86,0,0,1,8.44,3.5l24.26,24.26a12.47,12.47,0,0,1,3.68,8.88v14.46a2.91,2.91,0,1,1-5.81,0v-14.46a6.72,6.72,0,0,0-2-4.77l-24.26-24.26a6.09,6.09,0,0,0-4.33-1.8H509.86a29.91,29.91,0,0,1-21.29-8.81l-14.33-14.33a29.87,29.87,0,0,1-8.81-21.29V901.14a18.51,18.51,0,0,1,18.49-18.5H586.8A18.34,18.34,0,0,1,605.12,901v91.41a6.09,6.09,0,0,0,1.8,4.33l32.44,32.44a11.19,11.19,0,0,1,3.3,8v30.53A2.9,2.9,0,0,1,639.76,1070.55Z"></path><path class="cls-6" fill="#050606" d="M586.8,1289.46H483.92a18.51,18.51,0,0,1-18.49-18.5v-76.78a29.87,29.87,0,0,1,8.81-21.29l14.33-14.34a29.91,29.91,0,0,1,21.29-8.81h50.75a6.09,6.09,0,0,0,4.33-1.8l24.26-24.25a6.74,6.74,0,0,0,2-4.78v-14.46a2.91,2.91,0,0,1,5.81,0v14.46a12.51,12.51,0,0,1-3.68,8.89l-24.26,24.25a11.86,11.86,0,0,1-8.44,3.5H509.86a24.17,24.17,0,0,0-17.18,7.11L478.35,1177a24.09,24.09,0,0,0-7.11,17.18V1271a12.71,12.71,0,0,0,12.68,12.69H586.8a12.53,12.53,0,0,0,12.51-12.52v-91.4a11.83,11.83,0,0,1,3.5-8.44l32.44-32.44a5.46,5.46,0,0,0,1.6-3.87v-30.53a2.91,2.91,0,0,1,5.81,0V1135a11.19,11.19,0,0,1-3.3,8l-32.44,32.45a6.06,6.06,0,0,0-1.8,4.33v91.4A18.35,18.35,0,0,1,586.8,1289.46Z"></path><rect class="cls-4" stroke-width="2.25px" x="787.14" y="943.38" width="429.45" height="224.42"></rect></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
@@ -1,502 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 293.56501 470.885"
|
|
||||||
version="1.1"
|
|
||||||
id="svg207"
|
|
||||||
sodipodi:docname="buyer benmeshtastic 2-03.svg"
|
|
||||||
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
|
|
||||||
width="293.565"
|
|
||||||
height="470.88501"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg">
|
|
||||||
<sodipodi:namedview
|
|
||||||
id="namedview207"
|
|
||||||
pagecolor="#ffffff"
|
|
||||||
bordercolor="#000000"
|
|
||||||
borderopacity="0.25"
|
|
||||||
inkscape:showpageshadow="2"
|
|
||||||
inkscape:pageopacity="0.0"
|
|
||||||
inkscape:pagecheckerboard="0"
|
|
||||||
inkscape:deskcolor="#d1d1d1"
|
|
||||||
inkscape:zoom="0.96807266"
|
|
||||||
inkscape:cx="186.96944"
|
|
||||||
inkscape:cy="370.32344"
|
|
||||||
inkscape:window-width="1728"
|
|
||||||
inkscape:window-height="1056"
|
|
||||||
inkscape:window-x="0"
|
|
||||||
inkscape:window-y="33"
|
|
||||||
inkscape:window-maximized="1"
|
|
||||||
inkscape:current-layer="Layer_2" />
|
|
||||||
<defs
|
|
||||||
id="defs1">
|
|
||||||
<style
|
|
||||||
id="style1">.cls-1{fill:#474746;}.cls-2{fill:#3a3a3a;}.cls-3{fill:#202121;}.cls-4{fill:#dbdcdd;}.cls-5{fill:#cbcbcc;}.cls-11,.cls-16,.cls-18,.cls-6{fill:none;}.cls-11,.cls-16,.cls-6{stroke:#1d1d1b;stroke-linecap:round;stroke-linejoin:round;}.cls-6{stroke-width:0.95px;}.cls-7{fill:#e5e2e5;}.cls-8{fill:#444e85;}.cls-21,.cls-22,.cls-9{fill:#1d1d1b;}.cls-10{fill:#d1b066;}.cls-11{stroke-width:1.3px;}.cls-12{fill:#d8b258;}.cls-13{fill:#b28e45;}.cls-14{fill:#a6a9af;}.cls-15{fill:#d1c854;}.cls-16{stroke-width:1.01px;}.cls-17{fill:#fff;}.cls-18{stroke:#fff;stroke-miterlimit:10;}.cls-19{fill:#ba9c61;}.cls-20{fill:#715c34;}.cls-21{font-size:13.29px;}.cls-21,.cls-22{font-family:LucidaConsole, Lucida Console;}.cls-22{font-size:8.44px;}</style>
|
|
||||||
</defs>
|
|
||||||
<g
|
|
||||||
id="Layer_2"
|
|
||||||
data-name="Layer 2"
|
|
||||||
transform="translate(-106.20269,-157.045)">
|
|
||||||
<path
|
|
||||||
class="cls-1" fill="#474746"
|
|
||||||
d="m 396.14,293.93 v 311.36 a 22,22 0 0 1 -22,22 H 128.65 a 22,22 0 0 1 -22,-22 v -225.7 a 22,22 0 0 1 22,-22 h 69.14 a 22,22 0 0 0 22,-22 v -41.6 a 22,22 0 0 1 22,-22 h 132.32 a 22,22 0 0 1 22.03,21.94 z"
|
|
||||||
id="path1" />
|
|
||||||
<path
|
|
||||||
class="cls-2" fill="#3a3a3a"
|
|
||||||
d="m 358.92,384 a 23.67,23.67 0 0 1 23.64,23.64 v 176.6 a 23.67,23.67 0 0 1 -23.64,23.64 H 147.31 A 23.67,23.67 0 0 1 123.67,584.24 V 407.61 A 23.67,23.67 0 0 1 147.31,384 h 211.61 m 0,-5 H 147.31 a 28.64,28.64 0 0 0 -28.64,28.64 v 176.6 a 28.65,28.65 0 0 0 28.64,28.64 h 211.61 a 28.64,28.64 0 0 0 28.64,-28.64 V 407.61 A 28.64,28.64 0 0 0 358.92,379 Z"
|
|
||||||
id="path2" />
|
|
||||||
<rect
|
|
||||||
class="cls-2" fill="#3a3a3a"
|
|
||||||
x="237.61"
|
|
||||||
y="284.09"
|
|
||||||
width="139.52"
|
|
||||||
height="79.300003"
|
|
||||||
rx="8.3900003"
|
|
||||||
id="rect2" />
|
|
||||||
<rect
|
|
||||||
class="cls-3" fill="#202121"
|
|
||||||
x="244.67999"
|
|
||||||
y="289.66"
|
|
||||||
width="125.32"
|
|
||||||
height="67.370003"
|
|
||||||
rx="2"
|
|
||||||
id="rect3" />
|
|
||||||
<path
|
|
||||||
class="cls-2" fill="#3a3a3a"
|
|
||||||
d="M 151.55,352.34 H 194 a 3.83,3.83 0 0 1 3.83,3.83 v 1.39 h -50.11 v -1.39 a 3.83,3.83 0 0 1 3.83,-3.83 z"
|
|
||||||
id="path3" />
|
|
||||||
<path
|
|
||||||
class="cls-1" fill="#474746"
|
|
||||||
d="m 240,272 a 3,3 0 0 1 3,-2.69 h 24.48 a 3,3 0 0 1 3,2.61 L 240.25,272"
|
|
||||||
id="path4" />
|
|
||||||
<path
|
|
||||||
class="cls-4" fill="#dbdcdd"
|
|
||||||
d="m 353.11,172.61 a 54.23,54.23 0 0 0 -32.38,-10.67 h -49 a 54.49,54.49 0 0 0 -54.49,54.48 V 282 h -5.21 v -70 a 54.49,54.49 0 0 1 54.49,-54.48 h 49 a 54.24,54.24 0 0 1 37.59,15.09 z"
|
|
||||||
id="path5" />
|
|
||||||
<path
|
|
||||||
class="cls-5" fill="#cbcbcc"
|
|
||||||
d="m 370,212 v 51.84 h -21.49 l -9.24,-46.17 V 209 A 19.5,19.5 0 0 0 319.79,189.52 H 260.87 A 19.48,19.48 0 0 0 241.39,209 v 60.74 c -1.23,0.56 -0.86,2.13 -0.86,2.13 l -6.91,1.75 c -5.66,1.43 -7.63,4.78 -9.44,7 -1.81,2.22 -2,1.41 -2,1.41 h -4.89 v -65.61 a 54.49,54.49 0 0 1 54.49,-54.48 h 49 a 54.23,54.23 0 0 1 32.38,10.67 v 0 A 54.3,54.3 0 0 1 370,212 Z"
|
|
||||||
id="path6" />
|
|
||||||
<path
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
d="m 396.42,323.74 h 1.21 a 1.46,1.46 0 0 1 1.46,1.46 v 2.17 a 1.46,1.46 0 0 1 -1.46,1.46 h -1.21 z"
|
|
||||||
id="path7" />
|
|
||||||
<path
|
|
||||||
class="cls-2" fill="#3a3a3a"
|
|
||||||
d="M 329.33,586.79 H 198 A 24.51,24.51 0 0 1 173.52,562.31 V 429 A 24.5,24.5 0 0 1 198,404.51 H 329.33 A 24.49,24.49 0 0 1 353.8,429 v 133.31 a 24.5,24.5 0 0 1 -24.47,24.48 z m -128.81,-27 H 326.8 V 431.51 H 200.52 Z"
|
|
||||||
id="path8" />
|
|
||||||
<rect
|
|
||||||
class="cls-7" fill="#e5e2e5"
|
|
||||||
x="187.02"
|
|
||||||
y="418.01001"
|
|
||||||
width="153.28"
|
|
||||||
height="155.27"
|
|
||||||
rx="10.97"
|
|
||||||
id="rect8" />
|
|
||||||
<path
|
|
||||||
class="cls-3" fill="#202121"
|
|
||||||
d="m 166.62,386.56 a 12.85,12.85 0 1 0 12.86,12.85 12.85,12.85 0 0 0 -12.86,-12.85 z m 0,21 a 8.15,8.15 0 1 1 8.15,-8.15 8.14,8.14 0 0 1 -8.15,8.15 z"
|
|
||||||
id="path9" />
|
|
||||||
<path
|
|
||||||
class="cls-2" fill="#3a3a3a"
|
|
||||||
d="m 174.77,399.41 a 8.15,8.15 0 1 1 -8.15,-8.15 8.15,8.15 0 0 1 8.15,8.15 z"
|
|
||||||
id="path10" />
|
|
||||||
<path
|
|
||||||
class="cls-3" fill="#202121"
|
|
||||||
d="m 359.9,386.56 a 12.85,12.85 0 1 0 12.86,12.85 12.85,12.85 0 0 0 -12.86,-12.85 z m 0,21 a 8.15,8.15 0 1 1 8.15,-8.15 8.14,8.14 0 0 1 -8.15,8.15 z"
|
|
||||||
id="path11" />
|
|
||||||
<path
|
|
||||||
class="cls-2" fill="#3a3a3a"
|
|
||||||
d="m 368.05,399.41 a 8.15,8.15 0 1 1 -8.15,-8.15 8.15,8.15 0 0 1 8.15,8.15 z"
|
|
||||||
id="path12" />
|
|
||||||
<path
|
|
||||||
class="cls-3" fill="#202121"
|
|
||||||
d="m 359.9,579.56 a 12.86,12.86 0 1 0 12.86,12.85 12.85,12.85 0 0 0 -12.86,-12.85 z m 0,21 a 8.15,8.15 0 1 1 8.15,-8.15 8.14,8.14 0 0 1 -8.15,8.15 z"
|
|
||||||
id="path13" />
|
|
||||||
<path
|
|
||||||
class="cls-2" fill="#3a3a3a"
|
|
||||||
d="m 368.05,592.41 a 8.15,8.15 0 1 1 -8.15,-8.14 8.15,8.15 0 0 1 8.15,8.14 z"
|
|
||||||
id="path14" />
|
|
||||||
<path
|
|
||||||
class="cls-3" fill="#202121"
|
|
||||||
d="m 166.62,579.56 a 12.86,12.86 0 1 0 12.86,12.85 12.86,12.86 0 0 0 -12.86,-12.85 z m 0,21 a 8.15,8.15 0 1 1 8.15,-8.15 8.14,8.14 0 0 1 -8.15,8.15 z"
|
|
||||||
id="path15" />
|
|
||||||
<path
|
|
||||||
class="cls-2" fill="#3a3a3a"
|
|
||||||
d="m 174.77,592.41 a 8.15,8.15 0 1 1 -8.15,-8.14 8.15,8.15 0 0 1 8.15,8.14 z"
|
|
||||||
id="path16" />
|
|
||||||
<path
|
|
||||||
class="cls-8" fill="#444e85"
|
|
||||||
d="m 253.12,315.37 h 5.46 v 15.27 h 8.51 v 5 h -14 z"
|
|
||||||
id="path17" />
|
|
||||||
<path
|
|
||||||
class="cls-8" fill="#444e85"
|
|
||||||
d="m 269.79,315.37 h 5.48 v 20.25 h -5.48 z"
|
|
||||||
id="path18" />
|
|
||||||
<path
|
|
||||||
class="cls-8" fill="#444e85"
|
|
||||||
d="m 279.18,315.37 h 5.46 v 15.27 h 8.52 v 5 h -14 z"
|
|
||||||
id="path19" />
|
|
||||||
<path
|
|
||||||
class="cls-8" fill="#444e85"
|
|
||||||
d="m 292,315.37 h 6 l 3.56,6.84 3.56,-6.84 h 6 l -6.87,11.77 v 8.48 h -5.47 v -8.48 z"
|
|
||||||
id="path20" />
|
|
||||||
<path
|
|
||||||
class="cls-8" fill="#444e85"
|
|
||||||
d="m 320.33,328.29 v -4.21 h 8.44 v 8.63 a 16.91,16.91 0 0 1 -4.28,2.58 13,13 0 0 1 -4.42,0.68 9.6,9.6 0 0 1 -5.13,-1.23 8,8 0 0 1 -3.07,-3.66 13.45,13.45 0 0 1 -1.09,-5.58 13.15,13.15 0 0 1 1.19,-5.77 8.3,8.3 0 0 1 3.5,-3.72 10,10 0 0 1 4.83,-1 11.86,11.86 0 0 1 4.38,0.61 5.69,5.69 0 0 1 2.41,1.88 8.8,8.8 0 0 1 1.44,3.24 l -5.27,1.08 a 3.24,3.24 0 0 0 -1.1,-1.76 3.15,3.15 0 0 0 -2,-0.6 3.36,3.36 0 0 0 -2.86,1.43 7.54,7.54 0 0 0 -1.07,4.52 c 0,2.19 0.36,3.76 1.08,4.7 a 3.57,3.57 0 0 0 3,1.41 5.11,5.11 0 0 0 1.75,-0.31 9.16,9.16 0 0 0 1.91,-1 v -1.91 z"
|
|
||||||
id="path21" />
|
|
||||||
<path
|
|
||||||
class="cls-8" fill="#444e85"
|
|
||||||
d="m 331.32,325.51 c 0,-3.3 0.81,-5.88 2.41,-7.72 a 8.46,8.46 0 0 1 6.72,-2.76 8.55,8.55 0 0 1 6.79,2.71 q 2.38,2.72 2.39,7.61 a 14.13,14.13 0 0 1 -1,5.82 8.19,8.19 0 0 1 -3,3.54 8.93,8.93 0 0 1 -4.91,1.26 10,10 0 0 1 -4.94,-1.09 8,8 0 0 1 -3.18,-3.45 12.89,12.89 0 0 1 -1.28,-5.92 z m 5.46,0 a 7.49,7.49 0 0 0 1,4.41 3.44,3.44 0 0 0 5.43,0 q 1,-1.31 1,-4.71 a 6.89,6.89 0 0 0 -1,-4.17 3.25,3.25 0 0 0 -2.73,-1.32 3.13,3.13 0 0 0 -2.65,1.34 7.6,7.6 0 0 0 -1.05,4.48 z"
|
|
||||||
id="path22" />
|
|
||||||
<path
|
|
||||||
class="cls-8" fill="#444e85"
|
|
||||||
d="m 356,322.64 v -4.46 h 1.89 a 3.22,3.22 0 0 1 1,0.12 1,1 0 0 1 0.52,0.43 1.3,1.3 0 0 1 0.2,0.7 1.18,1.18 0 0 1 -0.3,0.83 1.44,1.44 0 0 1 -0.88,0.42 2,2 0 0 1 0.48,0.37 5,5 0 0 1 0.51,0.72 l 0.55,0.87 h -1.08 l -0.65,-1 a 8,8 0 0 0 -0.47,-0.65 0.82,0.82 0 0 0 -0.28,-0.19 1.54,1.54 0 0 0 -0.45,-0.05 h -0.18 v 1.86 z m 0.9,-2.57 h 0.66 a 3.91,3.91 0 0 0 0.81,-0.05 0.51,0.51 0 0 0 0.26,-0.19 0.59,0.59 0 0 0 0.09,-0.34 0.52,0.52 0 0 0 -0.12,-0.36 0.62,0.62 0 0 0 -0.34,-0.18 h -1.36 z"
|
|
||||||
id="path23" />
|
|
||||||
<path
|
|
||||||
class="cls-8" fill="#444e85"
|
|
||||||
d="m 357.66,326.28 a 5.71,5.71 0 1 1 5.71,-5.71 5.71,5.71 0 0 1 -5.71,5.71 z m 0,-10.12 a 4.42,4.42 0 1 0 4.41,4.41 4.41,4.41 0 0 0 -4.41,-4.41 z"
|
|
||||||
id="path24" />
|
|
||||||
<path
|
|
||||||
class="cls-9" fill="#1d1d1b"
|
|
||||||
d="m 135.34,431.15 h 25.89 v 5.73 h -21.49 v 14.25 h -4.4 z"
|
|
||||||
id="path25" />
|
|
||||||
<path
|
|
||||||
class="cls-9" fill="#1d1d1b"
|
|
||||||
d="m 135.34,455 h 26.1 v 5.73 h -26.1 z"
|
|
||||||
id="path26" />
|
|
||||||
<path
|
|
||||||
class="cls-9" fill="#1d1d1b"
|
|
||||||
d="m 135.34,466.39 h 25.89 v 5.73 h -21.49 v 14.25 h -4.4 z"
|
|
||||||
id="path27" />
|
|
||||||
<path
|
|
||||||
class="cls-9" fill="#1d1d1b"
|
|
||||||
d="m 135.34,494.27 h 11 l 15.11,-10.4 v 6.72 l -10.32,6.68 10.32,6.55 v 6.6 L 146.29,500 h -10.95 z"
|
|
||||||
id="path28" />
|
|
||||||
<path
|
|
||||||
class="cls-9" fill="#1d1d1b"
|
|
||||||
d="m 144.94,526.48 h 4.4 v 12.35 h -10.4 a 15.78,15.78 0 0 1 -2.82,-5.22 20.31,20.31 0 0 1 -1.22,-6.92 16.61,16.61 0 0 1 1.72,-7.77 11.34,11.34 0 0 1 4.91,-5 14.83,14.83 0 0 1 6.95,-1.67 14,14 0 0 1 7.25,1.86 12.12,12.12 0 0 1 4.86,5.44 15.74,15.74 0 0 1 1.3,6.8 q 0,5.28 -2,8.26 a 9.16,9.16 0 0 1 -5.64,3.82 l -1,-5.69 a 5.51,5.51 0 0 0 3,-2.26 7.25,7.25 0 0 0 1.11,-4.13 8.14,8.14 0 0 0 -2.19,-6 q -2.19,-2.21 -6.49,-2.21 c -3.1,0 -5.43,0.75 -7,2.24 a 7.82,7.82 0 0 0 -2.33,5.89 10.72,10.72 0 0 0 0.65,3.61 12.93,12.93 0 0 0 1.58,3.11 h 3.31 z"
|
|
||||||
id="path29" />
|
|
||||||
<path
|
|
||||||
class="cls-9" fill="#1d1d1b"
|
|
||||||
d="m 148.23,543 a 15.54,15.54 0 0 1 6.7,1.3 12.53,12.53 0 0 1 3.58,2.64 11.41,11.41 0 0 1 2.35,3.67 17,17 0 0 1 1,6.12 q 0,6.27 -3.58,10 -3.58,3.73 -9.95,3.77 -6.33,0 -9.89,-3.74 -3.56,-3.74 -3.57,-10 0,-6.33 3.55,-10.07 3.55,-3.74 9.81,-3.69 z m 0.18,5.91 c -2.95,0 -5.2,0.74 -6.72,2.23 a 8.12,8.12 0 0 0 0,11.28 q 2.28,2.19 6.81,2.19 4.53,0 6.7,-2.14 a 8.43,8.43 0 0 0 0,-11.39 q -2.28,-2.22 -6.79,-2.22 z"
|
|
||||||
id="path30" />
|
|
||||||
<path
|
|
||||||
class="cls-1" fill="#474746"
|
|
||||||
d="m 384.27,274.39 v -3.8 a 6.7,6.7 0 0 0 -6.71,-6.71 h -73.09 a 6.7,6.7 0 0 0 -6.71,6.71 v 1.31 c 0,0 74.61,0 76.35,0 0.32,0 6.89,-0.2 10.16,2.49 z"
|
|
||||||
id="path67" />
|
|
||||||
<path
|
|
||||||
class="cls-15" fill="#d1c854"
|
|
||||||
d="m 223.32,282 a 21.84,21.84 0 0 0 -3.5,11.91 v 41.6 a 21.88,21.88 0 0 1 -3,11 L 204.7,339.3 a 8,8 0 0 1 -3.9,-6.86 V 290 a 8,8 0 0 1 8,-8 z"
|
|
||||||
id="path68" />
|
|
||||||
<path
|
|
||||||
class="cls-2" fill="#3a3a3a"
|
|
||||||
d="m 374.11,275.9 a 18,18 0 0 1 18,18 v 311.39 a 18.05,18.05 0 0 1 -18,18 H 128.65 a 18.05,18.05 0 0 1 -18,-18 v -225.7 a 18,18 0 0 1 18,-18 h 69.14 a 26.05,26.05 0 0 0 26,-26 v -41.6 a 18,18 0 0 1 18,-18 h 132.32 m 0,-4 H 241.85 a 22,22 0 0 0 -22,22 v 41.6 a 22,22 0 0 1 -22,22 h -69.2 a 22,22 0 0 0 -22,22 v 225.7 a 22,22 0 0 0 22,22 h 245.46 a 22,22 0 0 0 22,-22 V 293.93 a 22,22 0 0 0 -22,-22 z"
|
|
||||||
id="path69" />
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
id="Layer_7"
|
|
||||||
data-name="Layer 7"
|
|
||||||
transform="translate(-106,-157.045)">
|
|
||||||
<path
|
|
||||||
class="cls-11" stroke-width="1.3px"
|
|
||||||
d="m 396.14,293.93 v 311.35 a 22,22 0 0 1 -22,22 H 128.65 a 22,22 0 0 1 -22,-22 V 379.59 a 22,22 0 0 1 22,-22 h 69.14 a 22,22 0 0 0 22,-22 v -41.66 a 22,22 0 0 1 22,-22 h 132.32 a 22,22 0 0 1 22.03,22 z"
|
|
||||||
id="path70" />
|
|
||||||
<rect
|
|
||||||
class="cls-11" stroke-width="1.3px"
|
|
||||||
x="118.67"
|
|
||||||
y="378.97"
|
|
||||||
width="268.88"
|
|
||||||
height="233.91"
|
|
||||||
rx="28.639999"
|
|
||||||
id="rect70" />
|
|
||||||
<rect
|
|
||||||
class="cls-11" stroke-width="1.3px"
|
|
||||||
x="237.61"
|
|
||||||
y="284.09"
|
|
||||||
width="139.52"
|
|
||||||
height="79.300003"
|
|
||||||
rx="8.3900003"
|
|
||||||
id="rect71" />
|
|
||||||
<rect
|
|
||||||
class="cls-11" stroke-width="1.3px"
|
|
||||||
x="244.67999"
|
|
||||||
y="289.66"
|
|
||||||
width="125.32"
|
|
||||||
height="67.370003"
|
|
||||||
rx="2"
|
|
||||||
id="rect72" />
|
|
||||||
<path
|
|
||||||
class="cls-11" stroke-width="1.3px"
|
|
||||||
d="M 151.55,352.34 H 194 a 3.83,3.83 0 0 1 3.83,3.83 v 1.39 h -50.11 v -1.39 a 3.83,3.83 0 0 1 3.83,-3.83 z"
|
|
||||||
id="path72" />
|
|
||||||
<path
|
|
||||||
class="cls-16" stroke-width="1.01px"
|
|
||||||
d="m 240,272 a 3,3 0 0 1 3,-2.69 h 24.48 a 3,3 0 0 1 3,2.61"
|
|
||||||
id="path73" />
|
|
||||||
<rect
|
|
||||||
class="cls-10" fill="#d1b066"
|
|
||||||
x="322.39001"
|
|
||||||
y="253.42999"
|
|
||||||
width="37.77"
|
|
||||||
height="10.45"
|
|
||||||
id="rect73" />
|
|
||||||
<path
|
|
||||||
class="cls-10" fill="#d1b066"
|
|
||||||
d="m 328.2,250.81 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
|
|
||||||
id="path74" />
|
|
||||||
<path
|
|
||||||
class="cls-10" fill="#d1b066"
|
|
||||||
d="m 328.2,248.28 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
|
|
||||||
id="path75" />
|
|
||||||
<path
|
|
||||||
class="cls-10" fill="#d1b066"
|
|
||||||
d="m 328.2,245.75 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
|
|
||||||
id="path76" />
|
|
||||||
<path
|
|
||||||
class="cls-10" fill="#d1b066"
|
|
||||||
d="m 328.2,243.22 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
|
|
||||||
id="path77" />
|
|
||||||
<path
|
|
||||||
class="cls-10" fill="#d1b066"
|
|
||||||
d="m 328.2,240.69 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
|
|
||||||
id="path78" />
|
|
||||||
<path
|
|
||||||
class="cls-10" fill="#d1b066"
|
|
||||||
d="m 328.2,238.17 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.09 1,-0.63 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
|
|
||||||
id="path79" />
|
|
||||||
<path
|
|
||||||
class="cls-10" fill="#d1b066"
|
|
||||||
d="m 328.2,235.64 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
|
|
||||||
id="path80" />
|
|
||||||
<path
|
|
||||||
class="cls-10" fill="#d1b066"
|
|
||||||
d="m 328.2,233.11 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
|
|
||||||
id="path81" />
|
|
||||||
<path
|
|
||||||
class="cls-10" fill="#d1b066"
|
|
||||||
d="m 328.2,230.58 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
|
|
||||||
id="path82" />
|
|
||||||
<path
|
|
||||||
class="cls-10" fill="#d1b066"
|
|
||||||
d="m 328.2,228.05 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
|
|
||||||
id="path83" />
|
|
||||||
<path
|
|
||||||
class="cls-10" fill="#d1b066"
|
|
||||||
d="m 328.2,225.52 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
|
|
||||||
id="path84" />
|
|
||||||
<path
|
|
||||||
class="cls-10" fill="#d1b066"
|
|
||||||
d="m 328.2,223 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.09 1,-0.63 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
|
|
||||||
id="path85" />
|
|
||||||
<rect
|
|
||||||
class="cls-10" fill="#d1b066"
|
|
||||||
x="328.20001"
|
|
||||||
y="217.71001"
|
|
||||||
width="26.27"
|
|
||||||
height="4.9099998"
|
|
||||||
id="rect85" />
|
|
||||||
<rect
|
|
||||||
class="cls-11" stroke-width="1.3px"
|
|
||||||
x="322.63"
|
|
||||||
y="253.42999"
|
|
||||||
width="18.77"
|
|
||||||
height="10.45"
|
|
||||||
id="rect86" />
|
|
||||||
<rect
|
|
||||||
class="cls-11" stroke-width="1.3px"
|
|
||||||
x="341.39999"
|
|
||||||
y="253.42999"
|
|
||||||
width="18.77"
|
|
||||||
height="10.45"
|
|
||||||
id="rect87" />
|
|
||||||
<rect
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
x="328.20001"
|
|
||||||
y="217.71001"
|
|
||||||
width="26.27"
|
|
||||||
height="5.29"
|
|
||||||
id="rect88" />
|
|
||||||
<path
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
d="m 328.2,250.81 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
|
|
||||||
id="path88" />
|
|
||||||
<path
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
d="m 328.2,248.28 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
|
|
||||||
id="path89" />
|
|
||||||
<path
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
d="m 328.2,245.75 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
|
|
||||||
id="path90" />
|
|
||||||
<path
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
d="m 328.2,243.22 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
|
|
||||||
id="path91" />
|
|
||||||
<path
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
d="m 328.2,240.69 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
|
|
||||||
id="path92" />
|
|
||||||
<path
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
d="m 328.2,238.17 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.09 1,-0.63 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
|
|
||||||
id="path93" />
|
|
||||||
<path
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
d="m 328.2,235.64 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
|
|
||||||
id="path94" />
|
|
||||||
<path
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
d="m 328.2,233.11 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
|
|
||||||
id="path95" />
|
|
||||||
<path
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
d="m 328.2,230.58 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
|
|
||||||
id="path96" />
|
|
||||||
<path
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
d="m 328.2,228.05 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
|
|
||||||
id="path97" />
|
|
||||||
<path
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
d="m 328.2,225.52 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
|
|
||||||
id="path98" />
|
|
||||||
<path
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
d="m 328.2,223 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.09 1,-0.63 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
|
|
||||||
id="path99" />
|
|
||||||
<path
|
|
||||||
class="cls-11" stroke-width="1.3px"
|
|
||||||
d="m 384.27,274.39 v 0 -3.79 a 6.7,6.7 0 0 0 -6.71,-6.71 h -73.09 a 6.7,6.7 0 0 0 -6.71,6.71 v 1.31"
|
|
||||||
id="path100" />
|
|
||||||
<path
|
|
||||||
class="cls-11" stroke-width="1.3px"
|
|
||||||
d="m 223.32,282 h -14.53 a 8,8 0 0 0 -8,8 v 42.43 a 8,8 0 0 0 3.9,6.86 l 12.16,7.25"
|
|
||||||
id="path101" />
|
|
||||||
<path
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
d="m 212.06,282 v -70 a 54.49,54.49 0 0 1 54.49,-54.48 h 49 A 54.48,54.48 0 0 1 370,212 v 51.84"
|
|
||||||
id="path102" />
|
|
||||||
<path
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
d="M 241.39,269.77 V 209 a 19.48,19.48 0 0 1 19.48,-19.48 h 58.92 A 19.48,19.48 0 0 1 339.27,209 v 8.68"
|
|
||||||
id="path103" />
|
|
||||||
<path
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
d="m 396.42,323.74 h 1.21 a 1.46,1.46 0 0 1 1.46,1.46 v 2.17 a 1.46,1.46 0 0 1 -1.46,1.46 h -1.21 z"
|
|
||||||
id="path104" />
|
|
||||||
<rect
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
x="187.02"
|
|
||||||
y="418.01001"
|
|
||||||
width="153.28"
|
|
||||||
height="155.27"
|
|
||||||
rx="10.97"
|
|
||||||
id="rect104" />
|
|
||||||
<circle
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
cx="166.62"
|
|
||||||
cy="399.41"
|
|
||||||
r="8.1499996"
|
|
||||||
id="circle140" />
|
|
||||||
<circle
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
cx="166.62"
|
|
||||||
cy="399.41"
|
|
||||||
r="12.85"
|
|
||||||
id="circle141" />
|
|
||||||
<circle
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
cx="359.89999"
|
|
||||||
cy="399.41"
|
|
||||||
r="8.1499996"
|
|
||||||
id="circle142" />
|
|
||||||
<circle
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
cx="359.89999"
|
|
||||||
cy="399.41"
|
|
||||||
r="12.85"
|
|
||||||
id="circle143" />
|
|
||||||
<circle
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
cx="359.89999"
|
|
||||||
cy="592.40997"
|
|
||||||
r="8.1499996"
|
|
||||||
id="circle144" />
|
|
||||||
<circle
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
cx="359.89999"
|
|
||||||
cy="592.40997"
|
|
||||||
r="12.85"
|
|
||||||
id="circle145" />
|
|
||||||
<circle
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
cx="166.62"
|
|
||||||
cy="592.40997"
|
|
||||||
r="8.1499996"
|
|
||||||
id="circle146" />
|
|
||||||
<circle
|
|
||||||
class="cls-6" stroke-width="0.95px"
|
|
||||||
cx="166.62"
|
|
||||||
cy="592.40997"
|
|
||||||
r="12.85"
|
|
||||||
id="circle147" />
|
|
||||||
<path
|
|
||||||
class="cls-8" fill="#444e85"
|
|
||||||
d="m 253.12,315.37 h 5.46 v 15.27 h 8.51 v 5 h -14 z"
|
|
||||||
id="path147" />
|
|
||||||
<path
|
|
||||||
class="cls-8" fill="#444e85"
|
|
||||||
d="m 269.79,315.37 h 5.48 v 20.25 h -5.48 z"
|
|
||||||
id="path148" />
|
|
||||||
<path
|
|
||||||
class="cls-8" fill="#444e85"
|
|
||||||
d="m 279.18,315.37 h 5.46 v 15.27 h 8.52 v 5 h -14 z"
|
|
||||||
id="path149" />
|
|
||||||
<path
|
|
||||||
class="cls-8" fill="#444e85"
|
|
||||||
d="m 292,315.37 h 6 l 3.56,6.84 3.56,-6.84 h 6 l -6.87,11.77 v 8.48 h -5.47 v -8.48 z"
|
|
||||||
id="path150" />
|
|
||||||
<path
|
|
||||||
class="cls-8" fill="#444e85"
|
|
||||||
d="m 320.33,328.29 v -4.21 h 8.44 v 8.63 a 16.91,16.91 0 0 1 -4.28,2.58 13,13 0 0 1 -4.42,0.68 9.6,9.6 0 0 1 -5.13,-1.23 8,8 0 0 1 -3.07,-3.66 13.45,13.45 0 0 1 -1.09,-5.58 13.15,13.15 0 0 1 1.19,-5.77 8.3,8.3 0 0 1 3.5,-3.72 10,10 0 0 1 4.83,-1 11.86,11.86 0 0 1 4.38,0.61 5.69,5.69 0 0 1 2.41,1.88 8.8,8.8 0 0 1 1.44,3.24 l -5.27,1.08 a 3.24,3.24 0 0 0 -1.1,-1.76 3.15,3.15 0 0 0 -2,-0.6 3.36,3.36 0 0 0 -2.86,1.43 7.54,7.54 0 0 0 -1.07,4.52 c 0,2.19 0.36,3.76 1.08,4.7 a 3.57,3.57 0 0 0 3,1.41 5.11,5.11 0 0 0 1.75,-0.31 9.16,9.16 0 0 0 1.91,-1 v -1.91 z"
|
|
||||||
id="path151" />
|
|
||||||
<path
|
|
||||||
class="cls-8" fill="#444e85"
|
|
||||||
d="m 331.32,325.51 c 0,-3.3 0.81,-5.88 2.41,-7.72 a 8.46,8.46 0 0 1 6.72,-2.76 8.55,8.55 0 0 1 6.79,2.71 q 2.38,2.72 2.39,7.61 a 14.13,14.13 0 0 1 -1,5.82 8.19,8.19 0 0 1 -3,3.54 8.93,8.93 0 0 1 -4.91,1.26 10,10 0 0 1 -4.94,-1.09 8,8 0 0 1 -3.18,-3.45 12.89,12.89 0 0 1 -1.28,-5.92 z m 5.46,0 a 7.49,7.49 0 0 0 1,4.41 3.44,3.44 0 0 0 5.43,0 q 1,-1.31 1,-4.71 a 6.89,6.89 0 0 0 -1,-4.17 3.25,3.25 0 0 0 -2.73,-1.32 3.13,3.13 0 0 0 -2.65,1.34 7.6,7.6 0 0 0 -1.05,4.48 z"
|
|
||||||
id="path152" />
|
|
||||||
<path
|
|
||||||
class="cls-8" fill="#444e85"
|
|
||||||
d="m 356,322.64 v -4.46 h 1.89 a 3.22,3.22 0 0 1 1,0.12 1,1 0 0 1 0.52,0.43 1.3,1.3 0 0 1 0.2,0.7 1.18,1.18 0 0 1 -0.3,0.83 1.44,1.44 0 0 1 -0.88,0.42 2,2 0 0 1 0.48,0.37 5,5 0 0 1 0.51,0.72 l 0.55,0.87 h -1.08 l -0.65,-1 a 8,8 0 0 0 -0.47,-0.65 0.82,0.82 0 0 0 -0.28,-0.19 1.54,1.54 0 0 0 -0.45,-0.05 h -0.18 v 1.86 z m 0.9,-2.57 h 0.66 a 3.91,3.91 0 0 0 0.81,-0.05 0.51,0.51 0 0 0 0.26,-0.19 0.59,0.59 0 0 0 0.09,-0.34 0.52,0.52 0 0 0 -0.12,-0.36 0.62,0.62 0 0 0 -0.34,-0.18 h -1.36 z"
|
|
||||||
id="path153" />
|
|
||||||
<path
|
|
||||||
class="cls-8" fill="#444e85"
|
|
||||||
d="m 357.66,326.28 a 5.71,5.71 0 1 1 5.71,-5.71 5.71,5.71 0 0 1 -5.71,5.71 z m 0,-10.12 a 4.42,4.42 0 1 0 4.41,4.41 4.41,4.41 0 0 0 -4.41,-4.41 z"
|
|
||||||
id="path154" />
|
|
||||||
<path
|
|
||||||
class="cls-3" fill="#202121"
|
|
||||||
d="m 135.34,431.15 h 25.89 v 5.73 h -21.49 v 14.25 h -4.4 z"
|
|
||||||
id="path155" />
|
|
||||||
<path
|
|
||||||
class="cls-3" fill="#202121"
|
|
||||||
d="m 135.34,455 h 26.1 v 5.73 h -26.1 z"
|
|
||||||
id="path156" />
|
|
||||||
<path
|
|
||||||
class="cls-3" fill="#202121"
|
|
||||||
d="m 135.34,466.39 h 25.89 v 5.73 h -21.49 v 14.25 h -4.4 z"
|
|
||||||
id="path157" />
|
|
||||||
<path
|
|
||||||
class="cls-3" fill="#202121"
|
|
||||||
d="m 135.34,494.27 h 11 l 15.11,-10.4 v 6.72 l -10.32,6.68 10.32,6.55 v 6.6 L 146.29,500 h -10.95 z"
|
|
||||||
id="path158" />
|
|
||||||
<path
|
|
||||||
class="cls-3" fill="#202121"
|
|
||||||
d="m 144.94,526.48 h 4.4 v 12.35 h -10.4 a 15.78,15.78 0 0 1 -2.82,-5.22 20.31,20.31 0 0 1 -1.22,-6.92 16.61,16.61 0 0 1 1.72,-7.77 11.34,11.34 0 0 1 4.91,-5 14.83,14.83 0 0 1 6.95,-1.67 14,14 0 0 1 7.25,1.86 12.12,12.12 0 0 1 4.86,5.44 15.74,15.74 0 0 1 1.3,6.8 q 0,5.28 -2,8.26 a 9.16,9.16 0 0 1 -5.64,3.82 l -1,-5.69 a 5.51,5.51 0 0 0 3,-2.26 7.25,7.25 0 0 0 1.11,-4.13 8.14,8.14 0 0 0 -2.19,-6 q -2.19,-2.21 -6.49,-2.21 c -3.1,0 -5.43,0.75 -7,2.24 a 7.82,7.82 0 0 0 -2.33,5.89 10.72,10.72 0 0 0 0.65,3.61 12.93,12.93 0 0 0 1.58,3.11 h 3.31 z"
|
|
||||||
id="path159" />
|
|
||||||
<path
|
|
||||||
class="cls-3" fill="#202121"
|
|
||||||
d="m 148.23,543 a 15.54,15.54 0 0 1 6.7,1.3 12.53,12.53 0 0 1 3.58,2.64 11.41,11.41 0 0 1 2.35,3.67 17,17 0 0 1 1,6.12 q 0,6.27 -3.58,10 -3.58,3.73 -9.95,3.77 -6.33,0 -9.89,-3.74 -3.56,-3.74 -3.57,-10 0,-6.33 3.55,-10.07 3.55,-3.74 9.81,-3.69 z m 0.18,5.91 c -2.95,0 -5.2,0.74 -6.72,2.23 a 8.12,8.12 0 0 0 0,11.28 q 2.28,2.19 6.81,2.19 4.53,0 6.7,-2.14 a 8.43,8.43 0 0 0 0,-11.39 q -2.28,-2.22 -6.79,-2.22 z"
|
|
||||||
id="path160" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 123 KiB |
@@ -1,109 +0,0 @@
|
|||||||
<svg xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" id="svg105" sodipodi:docname="thinknode_m1.svg" inkscape:version="1.4 (e7c3feb1, 2024-10-09)" viewBox="397.31 77.24 361 863.17">
|
|
||||||
<sodipodi:namedview id="namedview105" pagecolor="#ffffff" bordercolor="#000000" borderopacity="0.25" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:zoom="4.066786" inkscape:cx="564.94244" inkscape:cy="741.49463" inkscape:window-width="1472" inkscape:window-height="890" inkscape:window-x="0" inkscape:window-y="38" inkscape:window-maximized="1" inkscape:current-layer="Layer_3"/>
|
|
||||||
<defs id="defs1">
|
|
||||||
<style id="style1">.cls-1{fill:#353535;}.cls-2{fill:#262626;}.cls-3{fill:#cccccb;}.cls-4{fill:#2b2b2b;}.cls-5{fill:#f05043;}.cls-6{fill:#3d3d3d;}.cls-7{fill:#231f20;}.cls-8{fill:none;stroke:#000;stroke-miterlimit:10;}</style>
|
|
||||||
</defs>
|
|
||||||
<g id="Layer_3" data-name="Layer 3">
|
|
||||||
<path class="cls-1" fill="#353535" d="M720.82,449.91h11.45a19.68,19.68,0,0,1,19.67,19.68V905.12a28.48,28.48,0,0,1-28.47,28.48H425.72A27.77,27.77,0,0,1,397.81,906V470.82a21,21,0,0,1,21.13-20.91h23.74" id="path1"/>
|
|
||||||
<rect class="cls-2" fill="#262626" x="447.12" y="523.83" width="266.09" height="266.09" rx="22.7" id="rect1"/>
|
|
||||||
<rect class="cls-1" fill="#353535" x="465.51" y="542.22" width="229.3" height="229.3" rx="12.91" id="rect2"/>
|
|
||||||
<rect class="cls-3" fill="#cccccb" x="476.07" y="552.78" width="208.17" height="208.17" rx="7.83" id="rect3"/>
|
|
||||||
<path class="cls-1" fill="#353535" d="M507.38,77.74H472.16a7,7,0,0,0-7,7V359.93L452.2,396.26v39.91H561V396.26l-13.3-36V84.15a6.41,6.41,0,0,0-6.41-6.41Z" id="path3"/>
|
|
||||||
<rect class="cls-2" fill="#262626" x="454.25" y="436.17" width="104.38" height="3.65" id="rect4"/>
|
|
||||||
<polygon class="cls-1" fill="#353535" points="442.68 449.91 448.16 440.69 562.98 440.69 570.51 449.91 442.68 449.91" id="polygon4"/>
|
|
||||||
<rect class="cls-1" fill="#353535" x="604.37" y="355.96" width="105.26" height="60.65" rx="4.8" id="rect5"/>
|
|
||||||
<path class="cls-2" fill="#262626" d="M611.2,356v-5.48a3.13,3.13,0,0,1,3.13-3.13h86.35a3.13,3.13,0,0,1,3.13,3.13V356Z" id="path5"/>
|
|
||||||
<rect class="cls-2" fill="#262626" x="611.07" y="416.61" width="92.74" height="23.22" id="rect6"/>
|
|
||||||
<polygon class="cls-1" fill="#353535" points="592.99 449.91 598.47 440.69 713.42 440.69 720.82 449.91 592.99 449.91" id="polygon6"/>
|
|
||||||
<rect class="cls-2" fill="#262626" x="751.94" y="555.13" width="5.87" height="47.48" id="rect7"/>
|
|
||||||
<path class="cls-2" fill="#262626" d="M751.94,683.87h2.72a3.15,3.15,0,0,1,3.15,3.15v49.17a3.15,3.15,0,0,1-3.15,3.15h-2.72a0,0,0,0,1,0,0V683.87A0,0,0,0,1,751.94,683.87Z" id="path7"/>
|
|
||||||
<path class="cls-2" fill="#262626" d="M751.94,781.43h2.72a3.15,3.15,0,0,1,3.15,3.15v49.17a3.15,3.15,0,0,1-3.15,3.15h-2.72a0,0,0,0,1,0,0V781.43A0,0,0,0,1,751.94,781.43Z" id="path8"/>
|
|
||||||
<path class="cls-4" fill="#2b2b2b" d="M425.72,933.6l17.46-41.05a15.2,15.2,0,0,1,14-9.25H702.88A15.19,15.19,0,0,1,717,892.9l15.52,39.22" id="path9"/>
|
|
||||||
<rect class="cls-2" fill="#262626" x="505.03" y="841.57" width="147.52" height="24.65" rx="12.33" id="rect9"/>
|
|
||||||
<circle class="cls-5" fill="#f05043" cx="518.72" cy="853.89" r="5.48" id="circle9"/>
|
|
||||||
<circle class="cls-1" fill="#353535" cx="640.14" cy="853.89" r="5.48" id="circle10"/>
|
|
||||||
<circle class="cls-1" fill="#353535" cx="541.83" cy="853.89" r="5.48" id="circle11"/>
|
|
||||||
<circle class="cls-1" fill="#353535" cx="567.67" cy="853.89" r="5.48" id="circle12"/>
|
|
||||||
<circle class="cls-1" fill="#353535" cx="593.51" cy="853.89" r="5.48" id="circle13"/>
|
|
||||||
<circle class="cls-1" fill="#353535" cx="616.82" cy="853.89" r="5.48" id="circle14"/>
|
|
||||||
<path class="cls-4" fill="#2b2b2b" d="M428.2,933.6v4.1a2.21,2.21,0,0,0,2.22,2.21h11.22a2.21,2.21,0,0,0,2.21-2.21v-4.1" id="path14"/>
|
|
||||||
<path class="cls-4" fill="#2b2b2b" d="M713.2,933.6v4.1a2.21,2.21,0,0,0,2.22,2.21h11.22a2.21,2.21,0,0,0,2.21-2.21v-4.1" id="path15"/>
|
|
||||||
<path class="cls-6" fill="#3d3d3d" d="M494.46,449.91v5.59a12.22,12.22,0,0,0,1.05,4.95l8.6,19.42a9.43,9.43,0,0,0,8.62,5.61h3.61a9.43,9.43,0,0,0,9.43-9.43V449.91" id="path16"/>
|
|
||||||
<path class="cls-6" fill="#3d3d3d" d="M672.56,449.91v5.59a12.22,12.22,0,0,1-1,4.95l-8.6,19.42a9.43,9.43,0,0,1-8.62,5.61h-3.61a9.43,9.43,0,0,1-9.43-9.43V449.91" id="path17"/>
|
|
||||||
<path class="cls-6" fill="#3d3d3d" d="M532.42,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,532.42,449.91Z" id="path18"/>
|
|
||||||
<path class="cls-6" fill="#3d3d3d" d="M559.81,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,559.81,449.91Z" id="path19"/>
|
|
||||||
<path class="cls-6" fill="#3d3d3d" d="M587.2,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,587.2,449.91Z" id="path20"/>
|
|
||||||
<path class="cls-6" fill="#3d3d3d" d="M613.81,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,613.81,449.91Z" id="path21"/>
|
|
||||||
<path class="cls-1" fill="#353535" d="M477,924.32h-3V903.09h-7.46v-2.65h17.9v2.65H477Z" id="path51"/>
|
|
||||||
<path class="cls-1" fill="#353535" d="M490.59,906.36c0,.43,0,.86,0,1.31s-.07.85-.12,1.2h.2a5.17,5.17,0,0,1,1.44-1.54,7.08,7.08,0,0,1,1.94-.92,7.81,7.81,0,0,1,2.21-.31,8.61,8.61,0,0,1,3.63.68,4.62,4.62,0,0,1,2.19,2.13,8.22,8.22,0,0,1,.73,3.74v11.67h-2.9V912.85a4.72,4.72,0,0,0-1-3.24,3.92,3.92,0,0,0-3.05-1.07,5.65,5.65,0,0,0-3.14.75,4.07,4.07,0,0,0-1.62,2.21,11.17,11.17,0,0,0-.49,3.56v9.26h-2.94V898.91h2.94Z" id="path52"/>
|
|
||||||
<path class="cls-1" fill="#353535" d="M509.82,899.67a1.73,1.73,0,0,1,1.19.46,2.17,2.17,0,0,1,0,2.82,1.74,1.74,0,0,1-1.19.47,1.77,1.77,0,0,1-1.24-.47,2.24,2.24,0,0,1,0-2.82A1.76,1.76,0,0,1,509.82,899.67Zm1.44,6.73v17.92h-2.94V906.4Z" id="path53"/>
|
|
||||||
<path class="cls-1" fill="#353535" d="M525.58,906.06a6.8,6.8,0,0,1,4.85,1.56c1.09,1,1.63,2.71,1.63,5v11.67h-2.91V912.85a4.67,4.67,0,0,0-1-3.24,3.88,3.88,0,0,0-3-1.07c-2,0-3.36.56-4.11,1.67a8.54,8.54,0,0,0-1.14,4.82v9.29H517V906.4h2.37l.44,2.44h.16a5.68,5.68,0,0,1,1.49-1.56,6.41,6.41,0,0,1,2-.92A8.13,8.13,0,0,1,525.58,906.06Z" id="path54"/>
|
|
||||||
<path class="cls-1" fill="#353535" d="M540.53,912.18c0,.36,0,.83,0,1.41s-.07,1.08-.09,1.5h.14l.6-.77.82-1c.28-.34.52-.63.72-.85l5.72-6.05h3.44l-7.26,7.66,7.76,10.26h-3.54L542.57,916l-2,1.78v6.58h-2.91V898.91h2.91Z" id="path55"/>
|
|
||||||
<path class="cls-1" fill="#353535" d="M574.81,924.32H571.3l-12.78-19.83h-.13c0,.51.08,1.12.11,1.82s.07,1.45.1,2.24,0,1.61,0,2.43v13.34h-2.77V900.44h3.48l12.74,19.77h.13c0-.36-.05-.89-.08-1.6s-.07-1.5-.1-2.35,0-1.62,0-2.34V900.44h2.81Z" id="path56"/>
|
|
||||||
<path class="cls-1" fill="#353535" d="M596.48,915.33a12.32,12.32,0,0,1-.58,4,8.32,8.32,0,0,1-1.67,2.93,7,7,0,0,1-2.65,1.82,9.31,9.31,0,0,1-3.46.62,8.63,8.63,0,0,1-3.28-.62,7.29,7.29,0,0,1-2.61-1.82,8.57,8.57,0,0,1-1.72-2.93,11.76,11.76,0,0,1-.62-4,11.43,11.43,0,0,1,1-5,7.12,7.12,0,0,1,2.87-3.14,8.76,8.76,0,0,1,4.45-1.09,8.4,8.4,0,0,1,4.3,1.09,7.45,7.45,0,0,1,2.91,3.14A11,11,0,0,1,596.48,915.33Zm-13.54,0a10.93,10.93,0,0,0,.55,3.66,4.82,4.82,0,0,0,1.72,2.39,5.69,5.69,0,0,0,5.95,0,4.78,4.78,0,0,0,1.73-2.39,10.93,10.93,0,0,0,.55-3.66,10.36,10.36,0,0,0-.57-3.65,4.84,4.84,0,0,0-1.72-2.32,5,5,0,0,0-3-.82,4.5,4.5,0,0,0-4,1.8A8.79,8.79,0,0,0,582.94,915.33Z" id="path57"/>
|
|
||||||
<path class="cls-1" fill="#353535" d="M607.49,924.66a6.67,6.67,0,0,1-5.35-2.33c-1.34-1.54-2-3.86-2-6.94s.67-5.4,2-7a6.74,6.74,0,0,1,5.37-2.36,7.71,7.71,0,0,1,2.44.35,6.37,6.37,0,0,1,1.81,1,6.58,6.58,0,0,1,1.3,1.34h.2c0-.29-.06-.72-.11-1.29s-.09-1-.09-1.36v-7.15H616v25.41h-2.38l-.43-2.4h-.14a6.51,6.51,0,0,1-1.3,1.38,5.9,5.9,0,0,1-1.82,1A7.4,7.4,0,0,1,607.49,924.66Zm.46-2.44c1.9,0,3.23-.52,4-1.56a7.84,7.84,0,0,0,1.16-4.7v-.53a9.84,9.84,0,0,0-1.11-5.14c-.73-1.19-2.09-1.79-4.08-1.79a4,4,0,0,0-3.56,1.89,9.46,9.46,0,0,0-1.19,5.07,9,9,0,0,0,1.19,5A4,4,0,0,0,608,922.22Z" id="path58"/>
|
|
||||||
<path class="cls-1" fill="#353535" d="M628.62,906.06a7.53,7.53,0,0,1,4,1,6.62,6.62,0,0,1,2.54,2.82,9.69,9.69,0,0,1,.89,4.27v1.77H623.74a6.75,6.75,0,0,0,1.56,4.63,5.42,5.42,0,0,0,4.16,1.59,12.58,12.58,0,0,0,3-.32,16.78,16.78,0,0,0,2.72-.92v2.58a13.84,13.84,0,0,1-2.71.89,16.15,16.15,0,0,1-3.17.28,9.46,9.46,0,0,1-4.5-1,7.15,7.15,0,0,1-3-3.09,10.61,10.61,0,0,1-1.09-5,12,12,0,0,1,1-5,7.33,7.33,0,0,1,6.94-4.38Zm0,2.41a4.26,4.26,0,0,0-3.33,1.36,6.34,6.34,0,0,0-1.45,3.76h9.13a7.05,7.05,0,0,0-.47-2.68,3.94,3.94,0,0,0-1.42-1.79A4.33,4.33,0,0,0,628.59,908.47Z" id="path59"/>
|
|
||||||
<path class="cls-1" fill="#353535" d="M639.36,913h8.1v2.74h-8.1Z" id="path60"/>
|
|
||||||
<path class="cls-1" fill="#353535" d="M662.87,924.32,655,903.39h-.13c0,.44.08,1,.12,1.7s.06,1.45.08,2.26,0,1.65,0,2.49v14.48h-2.77V900.44h4.45L664.15,920h.13l7.49-19.57h4.42v23.88h-3V909.64c0-.78,0-1.55,0-2.32s.06-1.5.1-2.18.08-1.25.1-1.72h-.13l-8,20.9Z" id="path61"/>
|
|
||||||
<path class="cls-1" fill="#353535" d="M688.16,924.32V909.41c0-.63,0-1.3,0-2s0-1.42.07-2.1,0-1.27,0-1.76c-.35.38-.66.69-.92.92s-.6.54-1,.92l-2.41,2-1.57-2,6.26-4.89H691v23.88Z" id="path62"/>
|
|
||||||
<path class="cls-1" fill="#353535" d="M502.55,894.19l-2.22-2.37a14.1,14.1,0,0,1,18.94-.33l-2.13,2.44a10.9,10.9,0,0,0-7.16-2.69A10.78,10.78,0,0,0,502.55,894.19Z" id="path63"/>
|
|
||||||
<path class="cls-1" fill="#353535" d="M506.38,897.85l-2.4-2.18a8.08,8.08,0,0,1,11.61-.39l-2.24,2.33a4.85,4.85,0,0,0-7,.24Z" id="path64"/>
|
|
||||||
</g>
|
|
||||||
<g id="Layer_2" data-name="Layer 2">
|
|
||||||
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M472.55,77.74h68.09a7.43,7.43,0,0,1,7.43,7.43V360.26a0,0,0,0,1,0,0h-83a0,0,0,0,1,0,0V85.17A7.43,7.43,0,0,1,472.55,77.74Z" id="path65"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="465.12" y1="123.91" x2="548.07" y2="123.91" id="line65"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="465.12" y1="149.74" x2="548.07" y2="149.74" id="line66"/>
|
|
||||||
<polyline class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" points="465.12 360.26 452.2 396.26 452.2 436.17 560.99 436.17 560.99 396.26 548.07 360.26" id="polyline66"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="452.2" y1="396.26" x2="560.98" y2="396.26" id="line67"/>
|
|
||||||
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M449.69,440.17H562a3.26,3.26,0,0,1,2.56,1.55l5.93,8.19H442.68l4-7.49A3.65,3.65,0,0,1,449.69,440.17Z" id="path67"/>
|
|
||||||
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M600,440.17H712.33a3.24,3.24,0,0,1,2.56,1.55l5.93,8.19H593l4-7.49A3.65,3.65,0,0,1,600,440.17Z" id="path68"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="454.45" y1="436.17" x2="454.45" y2="439.83" id="line68"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="558.64" y1="436.17" x2="558.64" y2="439.83" id="line69"/>
|
|
||||||
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="604.37" y="355.96" width="105.26" height="60.65" rx="4.87" id="rect69"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="611.07" y1="416.61" x2="611.07" y2="439.83" id="line70"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="703.81" y1="416.61" x2="703.81" y2="439.83" id="line71"/>
|
|
||||||
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M614.2,347.35h86.48a3.13,3.13,0,0,1,3.13,3.13V356a0,0,0,0,1,0,0H611.07a0,0,0,0,1,0,0v-5.48A3.13,3.13,0,0,1,614.2,347.35Z" id="path71"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="570.51" y1="449.91" x2="592.99" y2="449.91" id="line72"/>
|
|
||||||
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M720.82,449.91h11.45a19.68,19.68,0,0,1,19.67,19.68V905.12a28.48,28.48,0,0,1-28.47,28.48H425.72A27.77,27.77,0,0,1,397.81,906V470.82a21,21,0,0,1,21.13-20.91h23.74" id="path72"/>
|
|
||||||
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="447.12" y="523.83" width="266.09" height="266.09" rx="22.7" id="rect72"/>
|
|
||||||
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="465.51" y="542.22" width="229.3" height="229.3" rx="12.91" id="rect73"/>
|
|
||||||
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="476.07" y="552.78" width="208.17" height="208.17" rx="7.83" id="rect74"/>
|
|
||||||
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M494.46,449.91v5.59a12.22,12.22,0,0,0,1.05,4.95l8.6,19.42a9.43,9.43,0,0,0,8.62,5.61h3.61a9.43,9.43,0,0,0,9.43-9.43V449.91" id="path74"/>
|
|
||||||
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M672.56,449.91v5.59a12.22,12.22,0,0,1-1,4.95l-8.6,19.42a9.43,9.43,0,0,1-8.62,5.61h-3.61a9.43,9.43,0,0,1-9.43-9.43V449.91" id="path75"/>
|
|
||||||
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M532.42,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,532.42,449.91Z" id="path76"/>
|
|
||||||
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M559.81,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,559.81,449.91Z" id="path77"/>
|
|
||||||
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M587.2,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,587.2,449.91Z" id="path78"/>
|
|
||||||
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M613.81,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,613.81,449.91Z" id="path79"/>
|
|
||||||
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="751.94" y="555.13" width="5.87" height="47.48" id="rect79"/>
|
|
||||||
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M751.94,683.87h2.72a3.15,3.15,0,0,1,3.15,3.15v49.17a3.15,3.15,0,0,1-3.15,3.15h-2.72a0,0,0,0,1,0,0V683.87A0,0,0,0,1,751.94,683.87Z" id="path80"/>
|
|
||||||
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M751.94,781.43h2.72a3.15,3.15,0,0,1,3.15,3.15v49.17a3.15,3.15,0,0,1-3.15,3.15h-2.72a0,0,0,0,1,0,0V781.43A0,0,0,0,1,751.94,781.43Z" id="path81"/>
|
|
||||||
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M425.72,933.6l17.46-41.05a15.2,15.2,0,0,1,14-9.25H702.88A15.19,15.19,0,0,1,717,892.9l15.52,39.22" id="path82"/>
|
|
||||||
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="505.03" y="841.57" width="147.52" height="24.65" rx="12.33" id="rect82"/>
|
|
||||||
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="518.72" cy="853.89" r="5.48" id="circle82"/>
|
|
||||||
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="640.14" cy="853.89" r="5.48" id="circle83"/>
|
|
||||||
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="541.83" cy="853.89" r="5.48" id="circle84"/>
|
|
||||||
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="567.67" cy="853.89" r="5.48" id="circle85"/>
|
|
||||||
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="593.51" cy="853.89" r="5.48" id="circle86"/>
|
|
||||||
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="616.82" cy="853.89" r="5.48" id="circle87"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="430.68" y1="572.74" x2="430.68" y2="602.61" id="line87"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="424.42" y1="595.43" x2="424.42" y2="578.11" id="line88"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="438.21" y1="595.43" x2="438.21" y2="578.11" id="line89"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="430.68" y1="644.74" x2="430.68" y2="674.61" id="line90"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="424.42" y1="667.43" x2="424.42" y2="650.11" id="line91"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="438.21" y1="667.43" x2="438.21" y2="650.11" id="line92"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="430.68" y1="716.74" x2="430.68" y2="746.61" id="line93"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="424.42" y1="739.43" x2="424.42" y2="722.11" id="line94"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="438.21" y1="739.43" x2="438.21" y2="722.11" id="line95"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="730.03" y1="572.74" x2="730.03" y2="602.61" id="line96"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="723.77" y1="595.43" x2="723.77" y2="578.11" id="line97"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="737.56" y1="595.43" x2="737.56" y2="578.11" id="line98"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="730.03" y1="644.74" x2="730.03" y2="674.61" id="line99"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="723.77" y1="667.43" x2="723.77" y2="650.11" id="line100"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="737.56" y1="667.43" x2="737.56" y2="650.11" id="line101"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="730.03" y1="716.74" x2="730.03" y2="746.61" id="line102"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="723.77" y1="739.43" x2="723.77" y2="722.11" id="line103"/>
|
|
||||||
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="737.56" y1="739.43" x2="737.56" y2="722.11" id="line104"/>
|
|
||||||
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M428.2,933.6v4.1a2.21,2.21,0,0,0,2.22,2.21h11.22a2.21,2.21,0,0,0,2.21-2.21v-4.1" id="path104"/>
|
|
||||||
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M713.2,933.6v4.1a2.21,2.21,0,0,0,2.22,2.21h11.22a2.21,2.21,0,0,0,2.21-2.21v-4.1" id="path105"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
@@ -1,264 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 233.57587 210"
|
|
||||||
fill="none"
|
|
||||||
version="1.1"
|
|
||||||
id="svg22"
|
|
||||||
sodipodi:docname="unknown-new.svg"
|
|
||||||
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
|
|
||||||
width="233.57587"
|
|
||||||
height="210"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg">
|
|
||||||
<sodipodi:namedview
|
|
||||||
id="namedview22"
|
|
||||||
pagecolor="#ffffff"
|
|
||||||
bordercolor="#000000"
|
|
||||||
borderopacity="0.25"
|
|
||||||
inkscape:showpageshadow="2"
|
|
||||||
inkscape:pageopacity="0.0"
|
|
||||||
inkscape:pagecheckerboard="0"
|
|
||||||
inkscape:deskcolor="#d1d1d1"
|
|
||||||
inkscape:zoom="2.6448247"
|
|
||||||
inkscape:cx="116.45384"
|
|
||||||
inkscape:cy="98.116143"
|
|
||||||
inkscape:window-width="1728"
|
|
||||||
inkscape:window-height="1056"
|
|
||||||
inkscape:window-x="0"
|
|
||||||
inkscape:window-y="33"
|
|
||||||
inkscape:window-maximized="1"
|
|
||||||
inkscape:current-layer="svg22" />
|
|
||||||
<!-- Antenna -->
|
|
||||||
<!-- Radio body - main housing with gradient -->
|
|
||||||
<defs
|
|
||||||
id="defs6">
|
|
||||||
<linearGradient
|
|
||||||
id="bodyGrad"
|
|
||||||
x1="29.580399"
|
|
||||||
y1="34.651325"
|
|
||||||
x2="207.06279"
|
|
||||||
y2="212.13371"
|
|
||||||
gradientTransform="matrix(0.84515425,0,0,1.183216,16.289544,-41)"
|
|
||||||
gradientUnits="userSpaceOnUse">
|
|
||||||
<stop
|
|
||||||
offset="0%"
|
|
||||||
style="stop-color:#22222a"
|
|
||||||
id="stop2" />
|
|
||||||
<stop
|
|
||||||
offset="100%"
|
|
||||||
style="stop-color:#16161b"
|
|
||||||
id="stop3" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient
|
|
||||||
id="screenGrad"
|
|
||||||
x1="36.829224"
|
|
||||||
y1="105.71089"
|
|
||||||
x2="36.829224"
|
|
||||||
y2="201.38222"
|
|
||||||
gradientTransform="matrix(1.1811273,0,0,0.84664878,16.289544,-41)"
|
|
||||||
gradientUnits="userSpaceOnUse">
|
|
||||||
<stop
|
|
||||||
offset="0%"
|
|
||||||
style="stop-color:#0c1a12"
|
|
||||||
id="stop4" />
|
|
||||||
<stop
|
|
||||||
offset="100%"
|
|
||||||
style="stop-color:#061008"
|
|
||||||
id="stop5" />
|
|
||||||
</linearGradient>
|
|
||||||
<filter
|
|
||||||
id="glow"
|
|
||||||
x="-0.8"
|
|
||||||
y="-0.8"
|
|
||||||
width="2.6"
|
|
||||||
height="2.6">
|
|
||||||
<feGaussianBlur
|
|
||||||
stdDeviation="2"
|
|
||||||
result="coloredBlur"
|
|
||||||
id="feGaussianBlur5" />
|
|
||||||
<feMerge
|
|
||||||
id="feMerge6">
|
|
||||||
<feMergeNode
|
|
||||||
in="coloredBlur"
|
|
||||||
id="feMergeNode5" />
|
|
||||||
<feMergeNode
|
|
||||||
in="SourceGraphic"
|
|
||||||
id="feMergeNode6" />
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
<rect
|
|
||||||
x="46.289543"
|
|
||||||
y="5"
|
|
||||||
width="140"
|
|
||||||
height="200"
|
|
||||||
rx="14"
|
|
||||||
fill="url(#bodyGrad)"
|
|
||||||
stroke="#67ea94"
|
|
||||||
stroke-width="10"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
id="rect6"
|
|
||||||
style="fill:url(#bodyGrad)" />
|
|
||||||
<!-- Inner bezel -->
|
|
||||||
<rect
|
|
||||||
x="54.289543"
|
|
||||||
y="13"
|
|
||||||
width="124"
|
|
||||||
height="184"
|
|
||||||
rx="10"
|
|
||||||
fill="none"
|
|
||||||
stroke="#2a2a32"
|
|
||||||
stroke-width="1"
|
|
||||||
id="rect7" />
|
|
||||||
<!-- Top speaker grille -->
|
|
||||||
<rect
|
|
||||||
x="71.289543"
|
|
||||||
y="21"
|
|
||||||
width="90"
|
|
||||||
height="20"
|
|
||||||
rx="3"
|
|
||||||
fill="#0a0a0f"
|
|
||||||
id="rect8" />
|
|
||||||
<g
|
|
||||||
stroke="#333340"
|
|
||||||
stroke-width="1"
|
|
||||||
id="g10"
|
|
||||||
transform="translate(16.289544,-41)">
|
|
||||||
<line
|
|
||||||
x1="62"
|
|
||||||
y1="68"
|
|
||||||
x2="138"
|
|
||||||
y2="68"
|
|
||||||
id="line8" />
|
|
||||||
<line
|
|
||||||
x1="62"
|
|
||||||
y1="72"
|
|
||||||
x2="138"
|
|
||||||
y2="72"
|
|
||||||
id="line9" />
|
|
||||||
<line
|
|
||||||
x1="62"
|
|
||||||
y1="76"
|
|
||||||
x2="138"
|
|
||||||
y2="76"
|
|
||||||
id="line10" />
|
|
||||||
</g>
|
|
||||||
<!-- Screen area with glow effect -->
|
|
||||||
<rect
|
|
||||||
x="60.289543"
|
|
||||||
y="49"
|
|
||||||
width="112"
|
|
||||||
height="80"
|
|
||||||
rx="6"
|
|
||||||
fill="url(#screenGrad)"
|
|
||||||
stroke="#67ea94"
|
|
||||||
stroke-width="1"
|
|
||||||
opacity="0.8"
|
|
||||||
id="rect10"
|
|
||||||
style="fill:url(#screenGrad)" />
|
|
||||||
<!-- Meshtastic logo centered in screen - using actual logo paths -->
|
|
||||||
<g
|
|
||||||
transform="matrix(0.95,0,0,0.95,69.289544,64)"
|
|
||||||
filter="url(#glow)"
|
|
||||||
id="g14">
|
|
||||||
<g
|
|
||||||
transform="matrix(0.802386,0,0,0.460028,-421.748,-122.127)"
|
|
||||||
id="g13">
|
|
||||||
<g
|
|
||||||
transform="matrix(0.579082,0,0,1.01004,460.975,-39.6867)"
|
|
||||||
id="g11">
|
|
||||||
<path
|
|
||||||
d="m 250.908,330.267 -57.782,84.738 -12.188,-8.311 63.864,-93.657 c 1.372,-2.013 3.651,-3.218 6.087,-3.221 2.437,-0.002 4.717,1.199 6.093,3.21 l 64.012,93.51 -12.173,8.333 z"
|
|
||||||
fill="#67ea94"
|
|
||||||
id="path10" />
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
transform="matrix(0.582378,0,0,1.01579,485.019,-211.182)"
|
|
||||||
id="g12">
|
|
||||||
<path
|
|
||||||
d="m 87.642,581.398 67.115,-98.421 -12.119,-8.264 -67.115,98.421 z"
|
|
||||||
fill="#67ea94"
|
|
||||||
id="path11" />
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<!-- Question mark with glow -->
|
|
||||||
<!-- Control area background -->
|
|
||||||
<rect
|
|
||||||
x="60.289543"
|
|
||||||
y="137"
|
|
||||||
width="112"
|
|
||||||
height="60"
|
|
||||||
rx="6"
|
|
||||||
fill="#0f0f14"
|
|
||||||
id="rect14" />
|
|
||||||
<!-- D-pad style navigation -->
|
|
||||||
<g
|
|
||||||
transform="translate(88.289544,167)"
|
|
||||||
id="g17">
|
|
||||||
<circle
|
|
||||||
cx="0"
|
|
||||||
cy="0"
|
|
||||||
r="22"
|
|
||||||
fill="#1a1a20"
|
|
||||||
stroke="#2a2a32"
|
|
||||||
stroke-width="1"
|
|
||||||
id="circle14" />
|
|
||||||
<path
|
|
||||||
d="M -6,-16 H 6 V -6 H -6 Z"
|
|
||||||
fill="#2a2a32"
|
|
||||||
rx="1"
|
|
||||||
id="path14" />
|
|
||||||
<path
|
|
||||||
d="M -6,6 H 6 V 16 H -6 Z"
|
|
||||||
fill="#2a2a32"
|
|
||||||
rx="1"
|
|
||||||
id="path15" />
|
|
||||||
<path
|
|
||||||
d="M -16,-6 H -6 V 6 h -10 z"
|
|
||||||
fill="#2a2a32"
|
|
||||||
rx="1"
|
|
||||||
id="path16" />
|
|
||||||
<path
|
|
||||||
d="M 6,-6 H 16 V 6 H 6 Z"
|
|
||||||
fill="#2a2a32"
|
|
||||||
rx="1"
|
|
||||||
id="path17" />
|
|
||||||
<circle
|
|
||||||
cx="0"
|
|
||||||
cy="0"
|
|
||||||
r="5"
|
|
||||||
fill="#3a3a42"
|
|
||||||
id="circle17" />
|
|
||||||
</g>
|
|
||||||
<!-- Action button (glowing green) -->
|
|
||||||
<circle
|
|
||||||
cx="144.28955"
|
|
||||||
cy="167"
|
|
||||||
r="14"
|
|
||||||
fill="#1a2a1f"
|
|
||||||
stroke="#67ea94"
|
|
||||||
stroke-width="2"
|
|
||||||
id="circle18" />
|
|
||||||
<circle
|
|
||||||
cx="144.28955"
|
|
||||||
cy="167"
|
|
||||||
r="8"
|
|
||||||
fill="#67ea94"
|
|
||||||
filter="url(#glow)"
|
|
||||||
id="circle19" />
|
|
||||||
<!-- Status LED -->
|
|
||||||
<circle
|
|
||||||
cx="170.26186"
|
|
||||||
cy="25.547974"
|
|
||||||
r="3"
|
|
||||||
fill="#67ea94"
|
|
||||||
opacity="0.8"
|
|
||||||
filter="url(#glow)"
|
|
||||||
id="circle20" />
|
|
||||||
<!-- Side buttons -->
|
|
||||||
<!-- USB port indication at bottom -->
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -439,7 +439,8 @@
|
|||||||
start: function (handle, readyId) { return rentalRequest('start', { handle: handle, ready_id: readyId }, 35000); },
|
start: function (handle, readyId) { return rentalRequest('start', { handle: handle, ready_id: readyId }, 35000); },
|
||||||
request: function (offer, options) {
|
request: function (offer, options) {
|
||||||
if (!options || options.playbackProtocol !== 2) return Promise.reject(new Error('Playback protocol 2 is required before requesting a rental.'));
|
if (!options || options.playbackProtocol !== 2) return Promise.reject(new Error('Playback protocol 2 is required before requesting a rental.'));
|
||||||
return rentalRequest('request', { offer: offer }, 600000, options.signal);
|
if (options.renewExpired !== undefined && typeof options.renewExpired !== 'boolean') return Promise.reject(new Error('Invalid rental renewal request.'));
|
||||||
|
return rentalRequest('request', { offer: offer, renewExpired: options.renewExpired === true }, 600000, options.signal);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Object.defineProperty(window.archipelagoRental, 'playbackProtocol', { value: 2, writable: false, configurable: false, enumerable: true });
|
Object.defineProperty(window.archipelagoRental, 'playbackProtocol', { value: 2, writable: false, configurable: false, enumerable: true });
|
||||||
|
|||||||