Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c4169867c | ||
|
|
acf544500f | ||
|
|
7d767c8cb0 | ||
|
|
eb3ccfa00b | ||
|
|
eda28c4cd6 | ||
|
|
d69e845216 | ||
|
|
b02ba4100d | ||
|
|
3daea6623b | ||
|
|
0677924a64 | ||
|
|
971d477795 | ||
|
|
33477f284b | ||
|
|
03e38d1ca3 | ||
|
|
e5fc99d66c | ||
|
|
8b74803290 |
+15
-8
@@ -15,6 +15,9 @@ app:
|
||||
image: source.archipelago-foundation.org/lfg2025/gitea:1.27.3
|
||||
pull_policy: if-not-present
|
||||
|
||||
# Preserve repositories, database, keys and configuration during runtime repairs.
|
||||
backup_before_runtime_change: true
|
||||
|
||||
dependencies:
|
||||
# Source history, LFS objects, release artifacts and OCI layers all share
|
||||
# this persistent store. 500Mi was only suitable for an empty demo node.
|
||||
@@ -25,7 +28,7 @@ app:
|
||||
disk_limit: 50Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE, SYS_CHROOT]
|
||||
readonly_root: false
|
||||
no_new_privileges: false
|
||||
network_policy: bridge
|
||||
@@ -62,6 +65,17 @@ app:
|
||||
target: /etc/gitea
|
||||
options: [rw]
|
||||
|
||||
# Seed a fresh installation with the same origin advertised by the app gate.
|
||||
# Existing app.ini (including custom HTTPS/domain settings) is never replaced.
|
||||
files:
|
||||
- path: /var/lib/archipelago/gitea/data/gitea/conf/app.ini
|
||||
overwrite: false
|
||||
content: |
|
||||
[server]
|
||||
DOMAIN = {{HOST_IP}}
|
||||
SSH_DOMAIN = {{HOST_IP}}
|
||||
ROOT_URL = http://{{HOST_IP}}:3001/
|
||||
|
||||
environment:
|
||||
- GITEA__database__DB_TYPE=sqlite3
|
||||
- GITEA__server__SSH_PORT=2222
|
||||
@@ -106,10 +120,3 @@ app:
|
||||
- Issue tracking and pull requests
|
||||
- CI/CD via Gitea Actions
|
||||
- Lightweight SQLite deployment
|
||||
|
||||
nginx_proxy:
|
||||
listen: 3000
|
||||
proxy_pass: http://127.0.0.1:3001
|
||||
extra_headers:
|
||||
- proxy_hide_header X-Frame-Options
|
||||
- proxy_hide_header Content-Security-Policy
|
||||
|
||||
@@ -14,8 +14,16 @@ app:
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/portainer:2.45.0
|
||||
pull_policy: if-not-present
|
||||
# Portainer fetches Git sources and images from services on this same node.
|
||||
# Rootless pasta copies the host LAN address into its namespace, so a LAN
|
||||
# URL points back at Portainer itself. Give it a private address with the
|
||||
# supported rootless slirp backend; public app URLs still traverse the gate.
|
||||
network: slirp4netns
|
||||
data_uid: "1000:1000"
|
||||
|
||||
# Snapshot state before an upgrade recreates this app with new networking.
|
||||
backup_before_runtime_change: true
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
|
||||
@@ -162,11 +162,33 @@ impl ApiHandler {
|
||||
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
|
||||
),
|
||||
)),
|
||||
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
|
||||
Ok(content_server::ServeResult::Unavailable) => Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"The seller's node can't read this file right now. This request did not redeem an ecash payment."}"#,
|
||||
),
|
||||
)),
|
||||
Ok(content_server::ServeResult::RangeNotSatisfiable(total)) => Ok(Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header("Content-Range", format!("bytes */{total}"))
|
||||
.body(hyper::Body::empty())
|
||||
.unwrap()),
|
||||
Ok(content_server::ServeResult::NotFound) => Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
)),
|
||||
// Not a 404: a paid request may already have been charged by the
|
||||
// time this fails, and "not found" hid the real error entirely.
|
||||
Err(e) => {
|
||||
tracing::error!("Serving content {content_id} failed: {e:#}");
|
||||
Ok(build_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"text/plain",
|
||||
hyper::Body::from("Failed to serve content"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,25 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &
|
||||
}
|
||||
}
|
||||
|
||||
/// Only pass through the peer's bounded, printable explanation; refund status
|
||||
/// is always determined locally and must never come from the peer's wording.
|
||||
fn seller_error_message(status: reqwest::StatusCode, body: &str) -> String {
|
||||
let reason = serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_owned));
|
||||
match reason {
|
||||
Some(reason) if !reason.trim().is_empty() => {
|
||||
let clean: String = reason
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.take(240)
|
||||
.collect();
|
||||
format!("Seller response ({status}): {clean}")
|
||||
}
|
||||
_ => format!("Peer returned an error ({status})."),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep first purchases and cached repeats compatible with both existing clients.
|
||||
fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json::Value {
|
||||
use base64::Engine;
|
||||
@@ -54,13 +73,9 @@ fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json
|
||||
})
|
||||
}
|
||||
|
||||
/// FileBrowser owns its files through a rootless UID mapping. Use its authenticated
|
||||
/// API rather than writing host paths with the backend's unrelated UID. Its
|
||||
/// override=false upload atomically refuses existing names, including races.
|
||||
/// File purchases through an atomic no-clobber write in Files' own namespace.
|
||||
async fn file_purchase_in_files(
|
||||
client: &reqwest::Client,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
data_dir: &std::path::Path,
|
||||
filename: &str,
|
||||
mime: &str,
|
||||
bytes: &[u8],
|
||||
@@ -72,59 +87,24 @@ async fn file_purchase_in_files(
|
||||
} else {
|
||||
"Documents"
|
||||
};
|
||||
let mut folder_url = reqwest::Url::parse(base_url)?;
|
||||
folder_url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| anyhow::anyhow!("Invalid Files URL"))?
|
||||
.extend(["api", "resources", folder, ""]);
|
||||
let response = client
|
||||
.get(folder_url.clone())
|
||||
.header("X-Auth", token)
|
||||
.send()
|
||||
.await?;
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
let response = client
|
||||
.post(folder_url.clone())
|
||||
.header("X-Auth", token)
|
||||
.send()
|
||||
.await?;
|
||||
if response.status() != reqwest::StatusCode::CONFLICT {
|
||||
response.error_for_status()?;
|
||||
}
|
||||
} else {
|
||||
response.error_for_status()?;
|
||||
}
|
||||
let base = std::path::Path::new(filename)
|
||||
let root = data_dir.join("filebrowser");
|
||||
anyhow::ensure!(
|
||||
tokio::fs::metadata(&root).await?.is_dir(),
|
||||
"Files storage is unavailable"
|
||||
);
|
||||
let name = std::path::Path::new(filename)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or("download");
|
||||
let (stem, extension) = match base.rsplit_once('.') {
|
||||
Some((stem, ext)) if !stem.is_empty() => (stem, format!(".{ext}")),
|
||||
_ => (base, String::new()),
|
||||
};
|
||||
for attempt in 1..=100 {
|
||||
let name = if attempt == 1 {
|
||||
base.to_string()
|
||||
} else {
|
||||
format!("{stem} ({attempt}){extension}")
|
||||
};
|
||||
let mut url = folder_url.clone();
|
||||
url.path_segments_mut().unwrap().pop_if_empty().push(&name);
|
||||
url.query_pairs_mut().append_pair("override", "false");
|
||||
let response = client
|
||||
.post(url)
|
||||
.header("X-Auth", token)
|
||||
.body(bytes.to_vec())
|
||||
.send()
|
||||
.await?;
|
||||
if response.status() == reqwest::StatusCode::CONFLICT {
|
||||
continue;
|
||||
}
|
||||
response.error_for_status()?;
|
||||
return Ok(format!("{folder}/{name}"));
|
||||
}
|
||||
anyhow::bail!("Too many existing copies; purchased file remains in the purchase cache")
|
||||
let path =
|
||||
crate::container::filebrowser::save_new_file(&root.join(folder), name, bytes).await?;
|
||||
Ok(format!(
|
||||
"{folder}/{}",
|
||||
path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.context("Invalid Files name")?
|
||||
))
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
@@ -623,13 +603,14 @@ impl RpcHandler {
|
||||
|
||||
let path = format!("/content/{}", content_id);
|
||||
// Surface a real reason instead of the generic sanitized error (#30):
|
||||
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
|
||||
// here means the peer is genuinely unreachable on both transports.
|
||||
// A bearer token must not be replayed after an ambiguous delivery.
|
||||
// A transport error can mean the seller received it without replying.
|
||||
let (response, transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Payment-Token", token_str.clone())
|
||||
.single_delivery()
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.send_get()
|
||||
.await
|
||||
@@ -642,7 +623,7 @@ impl RpcHandler {
|
||||
let refund =
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Could not reach the peer over mesh or Tor. {refund}")
|
||||
"error": format!("The purchase could not be completed. {refund}")
|
||||
}));
|
||||
}
|
||||
};
|
||||
@@ -679,7 +660,7 @@ impl RpcHandler {
|
||||
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
||||
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Peer returned an error ({status}). {refund}")
|
||||
"error": format!("{} {refund}", seller_error_message(status, &body))
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -693,10 +674,17 @@ impl RpcHandler {
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read response body")?;
|
||||
let bytes = match response.bytes().await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
tracing::warn!("paid download: response body failed: {error}");
|
||||
let refund =
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("The file transfer was interrupted after payment was sent. {refund}")
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Persist the purchase so it "stays unlocked" for this buyer: cache the
|
||||
// bytes + metadata keyed by (onion, content_id). The gallery then renders
|
||||
@@ -728,28 +716,8 @@ impl RpcHandler {
|
||||
|
||||
// The durable purchased-content cache above is primary. A Files copy
|
||||
// remains optional: a stopped FileBrowser must not undo a paid download.
|
||||
let filed = async {
|
||||
let auth = self.handle_filebrowser_token().await?;
|
||||
let token = auth
|
||||
.get("token")
|
||||
.and_then(|v| v.as_str())
|
||||
.context("FileBrowser omitted its authentication token")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()?;
|
||||
file_purchase_in_files(
|
||||
&client,
|
||||
"http://127.0.0.1:8083",
|
||||
token,
|
||||
&filename,
|
||||
&mime_type,
|
||||
&bytes,
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
let filed =
|
||||
file_purchase_in_files(&self.config.data_dir, &filename, &mime_type, &bytes).await;
|
||||
match filed {
|
||||
Ok(path) => tracing::info!("paid download: filed into Files/{path}"),
|
||||
Err(error) => tracing::warn!(
|
||||
|
||||
@@ -1,69 +1,4 @@
|
||||
use super::*;
|
||||
use hyper::{
|
||||
service::{make_service_fn, service_fn},
|
||||
Body, Response, Server,
|
||||
};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
convert::Infallible,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
struct FilesApi {
|
||||
url: String,
|
||||
seen: Arc<Mutex<Vec<(String, String, Vec<u8>)>>>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
impl Drop for FilesApi {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
fn files_api(statuses: Vec<u16>) -> FilesApi {
|
||||
let statuses = Arc::new(Mutex::new(VecDeque::from(statuses)));
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let history = seen.clone();
|
||||
let server = Server::bind(&([127, 0, 0, 1], 0).into());
|
||||
let address = server.local_addr();
|
||||
let service = make_service_fn(move |_| {
|
||||
let statuses = statuses.clone();
|
||||
let seen = history.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(service_fn(move |request: hyper::Request<Body>| {
|
||||
let statuses = statuses.clone();
|
||||
let seen = seen.clone();
|
||||
async move {
|
||||
assert_eq!(request.headers().get("X-Auth").unwrap(), "test-session");
|
||||
let method = request.method().to_string();
|
||||
let uri = request.uri().to_string();
|
||||
let body = hyper::body::to_bytes(request.into_body())
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec();
|
||||
seen.lock().unwrap().push((method, uri, body));
|
||||
let status = statuses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("unexpected extra Files request");
|
||||
Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
FilesApi {
|
||||
url: format!("http://{address}"),
|
||||
seen,
|
||||
task: tokio::spawn(async move {
|
||||
server.serve(service).await.unwrap();
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
|
||||
@@ -85,80 +20,54 @@ fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_copy_uses_authenticated_api_and_preserves_existing_names() {
|
||||
let api = files_api(vec![200, 409, 200]);
|
||||
let client = reqwest::Client::new();
|
||||
let path = file_purchase_in_files(
|
||||
&client,
|
||||
&api.url,
|
||||
"test-session",
|
||||
"../my #file?.txt",
|
||||
"text/plain",
|
||||
b"paid bytes",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(path, "Documents/my #file? (2).txt");
|
||||
let seen = api.seen.lock().unwrap();
|
||||
assert_eq!(seen[0].0, "GET");
|
||||
assert_eq!(seen[0].1, "/api/resources/Documents/");
|
||||
assert_eq!(seen.len(), 3);
|
||||
for (_, uri, body) in &seen[1..] {
|
||||
assert!(uri.contains("override=false"));
|
||||
assert!(uri.contains("%23file%3F"));
|
||||
assert!(!uri.contains("../"));
|
||||
assert_eq!(body, b"paid bytes");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_copy_creates_missing_media_folder() {
|
||||
async fn files_copy_routes_media_and_sanitizes_the_filename() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
tokio::fs::create_dir(dir.path().join("filebrowser"))
|
||||
.await
|
||||
.unwrap();
|
||||
for (mime, folder) in [
|
||||
("image/png", "Photos"),
|
||||
("video/mp4", "Photos"),
|
||||
("audio/ogg", "Music"),
|
||||
("audio/mpeg", "Music"),
|
||||
("text/plain", "Documents"),
|
||||
] {
|
||||
let api = files_api(vec![404, 200, 200]);
|
||||
let path = file_purchase_in_files(
|
||||
&reqwest::Client::new(),
|
||||
&api.url,
|
||||
"test-session",
|
||||
"file",
|
||||
mime,
|
||||
b"bytes",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(path, format!("{folder}/file"));
|
||||
let seen = api.seen.lock().unwrap();
|
||||
assert_eq!(seen[1].0, "POST");
|
||||
assert!(seen[1].1.ends_with('/'));
|
||||
assert!(seen[1].2.is_empty());
|
||||
assert_eq!(seen[2].2, b"bytes");
|
||||
let relative = file_purchase_in_files(dir.path(), "../name #?.bin", mime, b"paid")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(relative.starts_with(&format!("{folder}/name #?")));
|
||||
assert_eq!(
|
||||
tokio::fs::read(dir.path().join("filebrowser").join(relative))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"paid"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_copy_fails_without_overwriting_or_claiming_success_on_errors() {
|
||||
for statuses in [
|
||||
vec![401],
|
||||
vec![503],
|
||||
vec![404, 500],
|
||||
vec![200, 507],
|
||||
vec![200, 403],
|
||||
] {
|
||||
let expected = statuses.len();
|
||||
let api = files_api(statuses);
|
||||
assert!(file_purchase_in_files(
|
||||
&reqwest::Client::new(),
|
||||
&api.url,
|
||||
"test-session",
|
||||
"file.txt",
|
||||
"text/plain",
|
||||
b"bytes"
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(api.seen.lock().unwrap().len(), expected);
|
||||
async fn unavailable_files_storage_is_reported_without_creating_a_fake_installation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(
|
||||
file_purchase_in_files(dir.path(), "name", "text/plain", b"bytes")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(!dir.path().join("filebrowser").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seller_errors_are_bounded_printable_and_identified_as_peer_text() {
|
||||
let status = reqwest::StatusCode::SERVICE_UNAVAILABLE;
|
||||
let message = seller_error_message(status, r#"{"error":"Cannot read file\n\u0000"}"#);
|
||||
assert!(message.starts_with("Seller response (503"));
|
||||
assert!(message.ends_with("Cannot read file"));
|
||||
assert!(!message.contains('\n') && !message.contains('\0'));
|
||||
let body = serde_json::json!({"error": "é".repeat(1000)}).to_string();
|
||||
assert!(seller_error_message(status, &body).chars().count() < 300);
|
||||
for body in ["not JSON", r#"{"error": 7}"#, r#"{"error":" "}"#] {
|
||||
assert_eq!(
|
||||
seller_error_message(status, body),
|
||||
"Peer returned an error (503 Service Unavailable)."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1699,32 +1699,10 @@ autopilot.active=false\n",
|
||||
patch_indeedhub_nostr_provider().await;
|
||||
}
|
||||
|
||||
// Gitea: keep it on its native host port (3001). The UI opens Gitea
|
||||
// in a new tab on that direct port so absolute asset URLs must be
|
||||
// rooted at the host port rather than Archipelago's /app/gitea/ path.
|
||||
if package_id == "gitea" {
|
||||
let _ = tokio::fs::remove_file("/etc/nginx/conf.d/gitea-iframe.conf").await;
|
||||
|
||||
// Set ROOT_URL to the direct launch route so links/assets stay
|
||||
// anchored under the same origin Gitea is launched from.
|
||||
let host_ip = &self.config.host_ip;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["exec", "gitea", "sh", "-c",
|
||||
&format!("grep -q ROOT_URL /data/gitea/conf/app.ini && sed -i 's|ROOT_URL.*|ROOT_URL = http://{}:3001/|' /data/gitea/conf/app.ini || true", host_ip)])
|
||||
.output()
|
||||
.await;
|
||||
// Also ensure X_FRAME_OPTIONS is empty so Gitea doesn't send the header
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["exec", "gitea", "sh", "-c",
|
||||
"grep -q X_FRAME_OPTIONS /data/gitea/conf/app.ini && sed -i 's|X_FRAME_OPTIONS.*|X_FRAME_OPTIONS =|' /data/gitea/conf/app.ini || sed -i '/^\\[security\\]/a X_FRAME_OPTIONS =' /data/gitea/conf/app.ini"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
info!(
|
||||
"Gitea: ROOT_URL set to http://{}:3001/, X_FRAME_OPTIONS cleared",
|
||||
host_ip
|
||||
);
|
||||
}
|
||||
// Gitea owns its public URL and security settings in app.ini, including
|
||||
// values chosen in its first-run setup. Do not rewrite operator values
|
||||
// or claim success from best-effort grep/sed commands. The app gate
|
||||
// fronts its declared HTTP port and handles frame headers separately.
|
||||
|
||||
if package_id == "nextcloud" {
|
||||
let host_ip = &self.config.host_ip;
|
||||
|
||||
@@ -102,6 +102,28 @@ pub struct AppCatalogEntry {
|
||||
/// `docs/registry-manifest-design.md`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub manifest: Option<serde_json::Value>,
|
||||
/// Backward-compatible catalog rollout: old daemons ignore these and keep
|
||||
/// the base manifest. New daemons choose only variants they can safely apply.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub manifest_variants: Vec<CatalogManifestVariant>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CatalogManifestVariant {
|
||||
pub requires: Vec<String>,
|
||||
pub manifest: serde_json::Value,
|
||||
}
|
||||
|
||||
fn selected_manifest(entry: AppCatalogEntry) -> Option<serde_json::Value> {
|
||||
// Never let an unknown future requirement become an unsafe partial match.
|
||||
for variant in entry.manifest_variants.into_iter().rev() {
|
||||
if !variant.requires.is_empty() && variant.requires.iter().all(|capability| {
|
||||
capability == "runtime-migration-backup-v1"
|
||||
}) {
|
||||
return Some(variant.manifest);
|
||||
}
|
||||
}
|
||||
entry.manifest
|
||||
}
|
||||
|
||||
/// One selectable version in an app's `versions[]` list. The catalog carries a
|
||||
@@ -234,7 +256,7 @@ pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> {
|
||||
load_catalog()
|
||||
.apps
|
||||
.into_iter()
|
||||
.filter_map(|(id, e)| e.manifest.map(|m| (id, m)))
|
||||
.filter_map(|(id, e)| selected_manifest(e).map(|m| (id, m)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -557,6 +579,27 @@ fn write_cache(data_dir: &Path, body: &str) -> anyhow::Result<bool> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn catalog_migration_variant_is_compatible_with_old_and_future_daemons() {
|
||||
let raw = serde_json::json!({
|
||||
"version": "2.45.0", "manifest": {"app": {"id": "portainer", "container": {}}},
|
||||
"manifest_variants": [{"requires": ["runtime-migration-backup-v1"],
|
||||
"manifest": {"app": {"id": "portainer", "container": {"network": "slirp4netns"}, "backup_before_runtime_change": true}}}]
|
||||
});
|
||||
#[derive(Deserialize)]
|
||||
struct OldEntry { manifest: serde_json::Value }
|
||||
let old: OldEntry = serde_json::from_value(raw.clone()).unwrap();
|
||||
assert!(old.manifest["app"]["container"].get("network").is_none());
|
||||
let current: AppCatalogEntry = serde_json::from_value(raw.clone()).unwrap();
|
||||
let chosen = selected_manifest(current).unwrap();
|
||||
assert_eq!(chosen["app"]["container"]["network"], "slirp4netns");
|
||||
assert_eq!(chosen["app"]["backup_before_runtime_change"], true);
|
||||
let mut future = raw;
|
||||
future["manifest_variants"][0]["requires"].as_array_mut().unwrap().push(serde_json::json!("unknown-next-capability"));
|
||||
let chosen = selected_manifest(serde_json::from_value(future).unwrap()).unwrap();
|
||||
assert!(chosen["app"]["container"].get("network").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_and_ignores_unknown_fields() {
|
||||
let json = r#"{
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! starting the container with `--config /data/.filebrowser.json`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
|
||||
use crate::update::host_sudo;
|
||||
@@ -117,6 +117,197 @@ fn shell_quote(s: &str) -> String {
|
||||
s.replace('\'', "'\\''")
|
||||
}
|
||||
|
||||
/// Save a complete purchase without overwriting any existing directory entry.
|
||||
/// Both host and rootless-namespace paths publish with a no-clobber hard link.
|
||||
pub async fn save_new_file(dir: &Path, name: &str, bytes: &[u8]) -> Result<PathBuf> {
|
||||
save_new_file_with(dir, name, bytes, write_via_userns).await
|
||||
}
|
||||
|
||||
fn validate_filename(name: &str) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
!name.is_empty()
|
||||
&& name != "."
|
||||
&& name != ".."
|
||||
&& !name.contains(['/', '\\', '\0'])
|
||||
&& name.len() <= 255,
|
||||
"Invalid purchased filename"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_new_file_with<F, Fut>(
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
fallback: F,
|
||||
) -> Result<PathBuf>
|
||||
where
|
||||
F: FnOnce(PathBuf, String, Vec<u8>) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<PathBuf>>,
|
||||
{
|
||||
validate_filename(name)?;
|
||||
// Never follow a user-created destination directory symlink.
|
||||
match fs::symlink_metadata(dir).await {
|
||||
Ok(meta) => anyhow::ensure!(meta.is_dir(), "Files destination is not a directory"),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
save_after_direct_result(
|
||||
write_direct(dir, name, bytes).await,
|
||||
dir,
|
||||
name,
|
||||
bytes,
|
||||
fallback,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn save_after_direct_result<F, Fut>(
|
||||
result: std::io::Result<PathBuf>,
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
fallback: F,
|
||||
) -> Result<PathBuf>
|
||||
where
|
||||
F: FnOnce(PathBuf, String, Vec<u8>) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<PathBuf>>,
|
||||
{
|
||||
match result {
|
||||
Ok(path) => Ok(path),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
fallback(dir.to_owned(), name.to_owned(), bytes.to_vec())
|
||||
.await
|
||||
.context("Saving purchase in Files user namespace")
|
||||
}
|
||||
Err(error) => Err(error).context("Saving purchase in Files"),
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered_name(name: &str, attempt: usize) -> String {
|
||||
if attempt == 1 {
|
||||
return name.to_owned();
|
||||
}
|
||||
match name.rsplit_once('.') {
|
||||
Some((stem, extension)) if !stem.is_empty() => format!("{stem} ({attempt}).{extension}"),
|
||||
_ => format!("{name} ({attempt})"),
|
||||
}
|
||||
}
|
||||
|
||||
struct PendingFile(PathBuf);
|
||||
impl Drop for PendingFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_direct(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
fs::create_dir_all(dir).await?;
|
||||
let temp_path = dir.join(format!(".archy-saving-{}", uuid::Uuid::new_v4()));
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&temp_path)
|
||||
.await?;
|
||||
let temp = PendingFile(temp_path);
|
||||
file.write_all(bytes).await?;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o644))
|
||||
.await?;
|
||||
file.sync_all().await?;
|
||||
for attempt in 1..=100 {
|
||||
let target = dir.join(numbered_name(name, attempt));
|
||||
match fs::hard_link(&temp.0, &target).await {
|
||||
Ok(()) => return Ok(target),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
"Too many existing copies; purchase cache retained",
|
||||
))
|
||||
}
|
||||
|
||||
// Positional arguments carry all user-controlled text. mktemp prevents temp-name
|
||||
// collisions; ln -T refuses files, symlinks and directories, including races.
|
||||
const WRITE_VIA_USERNS: &str = r#"set -eu
|
||||
dir=$1
|
||||
name=$2
|
||||
expected=$3
|
||||
[ ! -L "$dir" ] || exit 1
|
||||
if [ ! -d "$dir" ]; then
|
||||
mkdir -p -- "$dir"
|
||||
chown --reference="$(dirname -- "$dir")" -- "$dir"
|
||||
fi
|
||||
tmp=$(mktemp "$dir/.archy-saving.XXXXXXXXXX")
|
||||
trap 'rm -f -- "$tmp"' EXIT HUP INT TERM
|
||||
cat > "$tmp"
|
||||
[ "$(wc -c < "$tmp")" -eq "$expected" ] || exit 1
|
||||
chown --reference="$dir" -- "$tmp"
|
||||
chmod 0644 -- "$tmp"
|
||||
sync -f -- "$tmp"
|
||||
stem=$name
|
||||
ext=
|
||||
case "$name" in
|
||||
*.*) prefix=${name%.*}; if [ -n "$prefix" ]; then stem=$prefix; ext=.${name##*.}; fi ;;
|
||||
esac
|
||||
n=1
|
||||
while [ "$n" -le 100 ]; do
|
||||
candidate=$name
|
||||
if [ "$n" -gt 1 ]; then candidate="$stem ($n)$ext"; fi
|
||||
dst="$dir/$candidate"
|
||||
if ln -T -- "$tmp" "$dst" 2>/dev/null; then
|
||||
printf '%s' "$candidate"
|
||||
exit 0
|
||||
fi
|
||||
# A conflict may be a dangling symlink; never follow it or overwrite it.
|
||||
if [ ! -e "$dst" ] && [ ! -L "$dst" ]; then exit 1; fi
|
||||
n=$((n + 1))
|
||||
done
|
||||
exit 1
|
||||
"#;
|
||||
|
||||
async fn write_via_userns(dir: PathBuf, name: String, bytes: Vec<u8>) -> Result<PathBuf> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut child = tokio::process::Command::new("podman")
|
||||
.args(["unshare", "sh", "-c", WRITE_VIA_USERNS, "sh"])
|
||||
.arg(&dir)
|
||||
.arg(&name)
|
||||
.arg(bytes.len().to_string())
|
||||
.kill_on_drop(true)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.context("Starting Files namespace writer")?;
|
||||
let mut stdin = child.stdin.take().context("Files writer stdin missing")?;
|
||||
let operation = async {
|
||||
let fed = stdin.write_all(&bytes).await;
|
||||
drop(stdin);
|
||||
let output = child.wait_with_output().await?;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"Files namespace writer failed: {}",
|
||||
output.status
|
||||
);
|
||||
fed.context("Sending purchase bytes to Files")?;
|
||||
let chosen =
|
||||
String::from_utf8(output.stdout).context("Files writer returned an invalid name")?;
|
||||
validate_filename(&chosen)?;
|
||||
anyhow::ensure!(
|
||||
(1..=100).any(|n| numbered_name(&name, n) == chosen),
|
||||
"Files writer returned an unexpected name"
|
||||
);
|
||||
Ok(dir.join(chosen))
|
||||
};
|
||||
tokio::time::timeout(std::time::Duration::from_secs(120), operation)
|
||||
.await
|
||||
.context("Files namespace writer timed out")?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -152,3 +343,231 @@ mod tests {
|
||||
assert_eq!(second, EnsureOutcome::Unchanged);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod purchase_write_tests {
|
||||
use super::*;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
os::unix::fs::{symlink, PermissionsExt},
|
||||
};
|
||||
|
||||
fn no_temps(dir: &Path) {
|
||||
assert!(std::fs::read_dir(dir).unwrap().all(|e| !e
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(".archy-saving")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_write_uses_complete_bytes_and_preserves_originals() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join("song.mp3"), b"original")
|
||||
.await
|
||||
.unwrap();
|
||||
let target = save_new_file(dir.path(), "song.mp3", b"new").await.unwrap();
|
||||
assert_eq!(target.file_name().unwrap(), "song (2).mp3");
|
||||
assert_eq!(fs::read(target).await.unwrap(), b"new");
|
||||
assert_eq!(
|
||||
fs::read(dir.path().join("song.mp3")).await.unwrap(),
|
||||
b"original"
|
||||
);
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn simultaneous_saves_publish_unique_complete_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut tasks = Vec::new();
|
||||
for n in 0..24u8 {
|
||||
let dir = dir.path().to_owned();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let bytes = vec![n; 32768];
|
||||
let path = save_new_file(&dir, "same.bin", &bytes).await.unwrap();
|
||||
assert_eq!(fs::read(&path).await.unwrap(), bytes);
|
||||
path
|
||||
}));
|
||||
}
|
||||
let mut paths = HashSet::new();
|
||||
for task in tasks {
|
||||
assert!(paths.insert(task.await.unwrap()));
|
||||
}
|
||||
assert_eq!(paths.len(), 24);
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_directories_and_dangling_symlinks_are_conflicts() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(dir.path().join("name")).await.unwrap();
|
||||
symlink("missing", dir.path().join("name (2)")).unwrap();
|
||||
let path = save_new_file(dir.path(), "name", b"new").await.unwrap();
|
||||
assert_eq!(path.file_name().unwrap(), "name (3)");
|
||||
assert!(dir.path().join("name").is_dir());
|
||||
assert!(fs::symlink_metadata(dir.path().join("name (2)"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_symlink());
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_names_and_symlink_destination_are_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for name in [
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"../escape",
|
||||
"/absolute",
|
||||
"a/b",
|
||||
"a\\b",
|
||||
"a\0b",
|
||||
] {
|
||||
assert!(save_new_file(dir.path(), name, b"bytes").await.is_err());
|
||||
}
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
symlink(outside.path(), dir.path().join("Music")).unwrap();
|
||||
assert!(save_new_file(&dir.path().join("Music"), "song", b"bytes")
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collision_limit_preserves_all_files_and_cleans_temporary_data() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for n in 1..=100 {
|
||||
fs::write(dir.path().join(numbered_name("a.txt", n)), b"keep")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert!(save_new_file(dir.path(), "a.txt", b"new").await.is_err());
|
||||
for n in 1..=100 {
|
||||
assert_eq!(
|
||||
fs::read(dir.path().join(numbered_name("a.txt", n)))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"keep"
|
||||
);
|
||||
}
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn permission_fallback_is_exercised_without_skipping_as_root() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = save_after_direct_result(
|
||||
Err(std::io::ErrorKind::PermissionDenied.into()),
|
||||
dir.path(),
|
||||
"a",
|
||||
b"abc",
|
||||
|dir, name, bytes| async move {
|
||||
assert_eq!(bytes, b"abc");
|
||||
Ok(dir.join(name))
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, dir.path().join("a"));
|
||||
assert!(save_after_direct_result(
|
||||
Err(std::io::ErrorKind::PermissionDenied.into()),
|
||||
dir.path(),
|
||||
"a",
|
||||
b"abc",
|
||||
|_, _, _| async { anyhow::bail!("namespace unavailable") }
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("namespace"));
|
||||
assert!(save_after_direct_result(
|
||||
Err(std::io::ErrorKind::StorageFull.into()),
|
||||
dir.path(),
|
||||
"a",
|
||||
b"abc",
|
||||
|_, _, _| async { panic!("disk full must not trigger permission fallback") }
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
|
||||
async fn run_script(
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
expected: usize,
|
||||
) -> std::process::Output {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut child = tokio::process::Command::new("sh")
|
||||
.args(["-c", WRITE_VIA_USERNS, "sh"])
|
||||
.arg(dir)
|
||||
.arg(name)
|
||||
.arg(expected.to_string())
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
input.write_all(bytes).await.unwrap();
|
||||
drop(input);
|
||||
child.wait_with_output().await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn namespace_script_preserves_names_bytes_modes_and_existing_entries() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let folder = dir.path().join("Music");
|
||||
let name = "song ' $() ; #.mp3";
|
||||
for n in 1..=2 {
|
||||
let output = run_script(&folder, name, b"abc", 3).await;
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let chosen = String::from_utf8(output.stdout).unwrap();
|
||||
assert_eq!(chosen, numbered_name(name, n));
|
||||
let path = folder.join(chosen);
|
||||
assert_eq!(fs::read(&path).await.unwrap(), b"abc");
|
||||
assert_eq!(
|
||||
fs::metadata(path).await.unwrap().permissions().mode() & 0o777,
|
||||
0o644
|
||||
);
|
||||
}
|
||||
no_temps(&folder);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn namespace_script_refuses_truncated_input_and_cleans_up() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = run_script(dir.path(), "never.bin", b"partial", 100).await;
|
||||
assert!(!output.status.success());
|
||||
assert!(!dir.path().join("never.bin").exists());
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn namespace_script_does_not_link_inside_existing_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(dir.path().join("name")).await.unwrap();
|
||||
symlink("missing", dir.path().join("name (2)")).unwrap();
|
||||
let output = run_script(dir.path(), "name", b"abc", 3).await;
|
||||
assert!(output.status.success());
|
||||
assert_eq!(output.stdout, b"name (3)");
|
||||
assert_eq!(
|
||||
std::fs::read_dir(dir.path().join("name")).unwrap().count(),
|
||||
0
|
||||
);
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_keep_extensions_and_dotfiles() {
|
||||
assert_eq!(numbered_name("a.tar.gz", 2), "a.tar (2).gz");
|
||||
assert_eq!(numbered_name(".hidden", 2), ".hidden (2)");
|
||||
assert_eq!(numbered_name("README", 2), "README (2)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Consistent, private snapshots for declaratively opted-in runtime migrations.
|
||||
use anyhow::{bail, Context, Result};
|
||||
use archipelago_container::AppManifest;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn enabled(manifest: &AppManifest) -> Result<bool> {
|
||||
match manifest.app.extensions.get("backup_before_runtime_change") {
|
||||
None => Ok(false),
|
||||
Some(value) => value
|
||||
.as_bool()
|
||||
.context("backup_before_runtime_change must be boolean"),
|
||||
}
|
||||
}
|
||||
|
||||
fn relative_sources(manifest: &AppManifest, data_dir: &Path) -> Result<Vec<PathBuf>> {
|
||||
let mut sources = Vec::new();
|
||||
for volume in &manifest.app.volumes {
|
||||
if volume.options.iter().any(|v| v == "ro") || volume.volume_type == "tmpfs" {
|
||||
continue;
|
||||
}
|
||||
// A runtime socket is a connection, not application state.
|
||||
if volume.source == "/run/user/1000/podman/podman.sock" {
|
||||
continue;
|
||||
}
|
||||
if volume.volume_type != "bind" {
|
||||
bail!("runtime migration backup requires bind-mounted persistent state");
|
||||
}
|
||||
let path = Path::new(&volume.source);
|
||||
let relative = path
|
||||
.strip_prefix(data_dir)
|
||||
.context("runtime migration state must be inside the node data directory")?;
|
||||
if relative.starts_with("migration-backups") {
|
||||
bail!("migration backup cannot include its own archive directory");
|
||||
}
|
||||
if relative.as_os_str().is_empty()
|
||||
|| relative
|
||||
.components()
|
||||
.any(|c| !matches!(c, std::path::Component::Normal(_)))
|
||||
{
|
||||
bail!("invalid runtime migration state path");
|
||||
}
|
||||
sources.push(relative.to_path_buf());
|
||||
}
|
||||
sources.sort();
|
||||
sources.dedup();
|
||||
let mut roots: Vec<PathBuf> = Vec::new();
|
||||
for source in sources {
|
||||
if !roots.iter().any(|root| source.starts_with(root)) {
|
||||
roots.push(source);
|
||||
}
|
||||
}
|
||||
if roots.is_empty() {
|
||||
bail!("runtime migration backup has no persistent state mounts");
|
||||
}
|
||||
Ok(roots)
|
||||
}
|
||||
|
||||
/// Caller must gracefully stop the app before this function, and resume the old
|
||||
/// service if it fails. No source files are changed or deleted by this operation.
|
||||
pub async fn snapshot(
|
||||
manifest: &AppManifest,
|
||||
data_dir: &Path,
|
||||
previous_unit: Option<&[u8]>,
|
||||
) -> Result<PathBuf> {
|
||||
let mut command = tokio::process::Command::new("podman");
|
||||
command.args(["unshare", "tar"]);
|
||||
snapshot_with_command(manifest, data_dir, previous_unit, command).await
|
||||
}
|
||||
|
||||
async fn snapshot_with_command(
|
||||
manifest: &AppManifest,
|
||||
data_dir: &Path,
|
||||
previous_unit: Option<&[u8]>,
|
||||
mut command: tokio::process::Command,
|
||||
) -> Result<PathBuf> {
|
||||
let sources = relative_sources(manifest, data_dir)?;
|
||||
let canonical_root = tokio::fs::canonicalize(data_dir).await?;
|
||||
for source in &sources {
|
||||
let path = data_dir.join(source);
|
||||
if tokio::fs::symlink_metadata(&path)
|
||||
.await?
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
{
|
||||
bail!("runtime migration state mount is a symlink; explicit backup required");
|
||||
}
|
||||
let canonical = tokio::fs::canonicalize(&path).await?;
|
||||
if !canonical.starts_with(&canonical_root) {
|
||||
bail!("runtime migration state path resolves outside node data directory");
|
||||
}
|
||||
}
|
||||
let root = data_dir.join("migration-backups");
|
||||
tokio::fs::create_dir_all(&root).await?;
|
||||
tokio::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).await?;
|
||||
let dir = root.join(uuid::Uuid::new_v4().to_string());
|
||||
tokio::fs::create_dir(&dir).await?;
|
||||
tokio::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).await?;
|
||||
if let Some(unit) = previous_unit {
|
||||
let path = dir.join("previous.container");
|
||||
tokio::fs::write(&path, unit).await?;
|
||||
tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).await?;
|
||||
tokio::fs::File::open(&path).await?.sync_all().await?;
|
||||
}
|
||||
let partial = dir.join("state.tar.partial");
|
||||
let archive = dir.join("state.tar");
|
||||
let output = command
|
||||
.args([
|
||||
"--create",
|
||||
"--numeric-owner",
|
||||
"--acls",
|
||||
"--xattrs",
|
||||
"--file",
|
||||
])
|
||||
.arg(&partial)
|
||||
.arg("--directory")
|
||||
.arg(data_dir)
|
||||
.arg("--")
|
||||
.args(&sources)
|
||||
.output()
|
||||
.await
|
||||
.context("start rootless migration snapshot")?;
|
||||
if !output.status.success() {
|
||||
// No tar stderr in public logs: it can contain private filenames.
|
||||
let _ = tokio::fs::remove_file(&partial).await;
|
||||
bail!("persistent-state snapshot failed; original state was left intact");
|
||||
}
|
||||
tokio::fs::set_permissions(&partial, std::fs::Permissions::from_mode(0o600)).await?;
|
||||
tokio::fs::File::open(&partial).await?.sync_all().await?;
|
||||
tokio::fs::rename(&partial, &archive).await?;
|
||||
let metadata = serde_json::json!({"app": manifest.app.id, "version": manifest.app.version,
|
||||
"network": manifest.app.container.network, "capabilities": manifest.app.security.capabilities, "sources": sources});
|
||||
tokio::fs::write(
|
||||
dir.join("metadata.json"),
|
||||
serde_json::to_vec_pretty(&metadata)?,
|
||||
)
|
||||
.await?;
|
||||
tokio::fs::File::open(&dir).await?.sync_all().await?;
|
||||
Ok(archive)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn portainer() -> AppManifest {
|
||||
AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")).unwrap()
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn stopped_state_archive_round_trips_database_compose_and_old_unit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = dir.path().join("portainer");
|
||||
tokio::fs::create_dir_all(state.join("compose"))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(state.join("portainer.db"), b"fixture database")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(state.join("compose/stack.yml"), b"services: {}\n")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut m = portainer();
|
||||
m.app.volumes[0].source = state.display().to_string();
|
||||
m.app.volumes[1].source = state.join("compose").display().to_string();
|
||||
let archive = snapshot_with_command(
|
||||
&m,
|
||||
dir.path(),
|
||||
Some(b"old unit"),
|
||||
tokio::process::Command::new("tar"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
std::fs::metadata(&archive).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read(archive.parent().unwrap().join("previous.container"))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"old unit"
|
||||
);
|
||||
let restored = tempfile::tempdir().unwrap();
|
||||
assert!(tokio::process::Command::new("tar")
|
||||
.arg("-xf")
|
||||
.arg(archive)
|
||||
.arg("-C")
|
||||
.arg(restored.path())
|
||||
.status()
|
||||
.await
|
||||
.unwrap()
|
||||
.success());
|
||||
assert_eq!(
|
||||
tokio::fs::read(restored.path().join("portainer/portainer.db"))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"fixture database"
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read(restored.path().join("portainer/compose/stack.yml"))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"services: {}\n"
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read(state.join("portainer.db")).await.unwrap(),
|
||||
b"fixture database"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_snapshot_never_publishes_archive_or_changes_original_state() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = dir.path().join("portainer");
|
||||
tokio::fs::create_dir_all(state.join("compose"))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(state.join("portainer.db"), b"unchanged")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut m = portainer();
|
||||
m.app.volumes[0].source = state.display().to_string();
|
||||
m.app.volumes[1].source = state.join("compose").display().to_string();
|
||||
assert!(
|
||||
snapshot_with_command(&m, dir.path(), None, tokio::process::Command::new("false"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read(state.join("portainer.db")).await.unwrap(),
|
||||
b"unchanged"
|
||||
);
|
||||
for entry in std::fs::read_dir(dir.path().join("migration-backups")).unwrap() {
|
||||
assert!(!entry.unwrap().path().join("state.tar").exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_covers_all_portainer_state_once_and_excludes_runtime_socket() {
|
||||
let m = portainer();
|
||||
assert!(enabled(&m).unwrap());
|
||||
assert_eq!(
|
||||
relative_sources(&m, Path::new("/var/lib/archipelago")).unwrap(),
|
||||
vec![PathBuf::from("portainer")]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn backup_refuses_unknown_state_locations_instead_of_silently_omitting_them() {
|
||||
let mut m = portainer();
|
||||
m.app.volumes[0].source = "/other/operator/state".into();
|
||||
assert!(relative_sources(&m, Path::new("/var/lib/archipelago")).is_err());
|
||||
m.app.volumes[0].source = "/var/lib/archipelago/../secret".into();
|
||||
assert!(relative_sources(&m, Path::new("/var/lib/archipelago")).is_err());
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod hooks;
|
||||
pub mod image_policy;
|
||||
pub mod image_versions;
|
||||
pub mod lnd;
|
||||
pub mod migration_backup;
|
||||
pub mod prod_orchestrator;
|
||||
pub mod quadlet;
|
||||
pub mod registry;
|
||||
|
||||
@@ -91,6 +91,21 @@ fn is_builtin_network_mode(network: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
// Only an explicitly selected rootless mode establishes drift. An omitted
|
||||
// network delegates to Podman and must not recreate unrelated installed apps.
|
||||
fn rootless_network_mode_drifted(expected: Option<&str>, actual: &str) -> bool {
|
||||
matches!(expected, Some("slirp4netns" | "pasta"))
|
||||
&& !actual.trim().is_empty()
|
||||
&& actual.trim().split(':').next() != expected
|
||||
}
|
||||
|
||||
fn missing_declared_capability(expected: &[String], actual: &[String]) -> bool {
|
||||
expected.iter().any(|required| {
|
||||
let required = required.strip_prefix("CAP_").unwrap_or(required);
|
||||
!actual.iter().any(|cap| cap.strip_prefix("CAP_").unwrap_or(cap) == required)
|
||||
})
|
||||
}
|
||||
|
||||
fn uses_pasta_network(manifest: &AppManifest) -> bool {
|
||||
manifest.app.container.network.as_deref() == Some("pasta")
|
||||
}
|
||||
@@ -2464,6 +2479,7 @@ impl ProdContainerOrchestrator {
|
||||
.await
|
||||
{
|
||||
tracing::info!(app_id = %app_id, container = %name, "container published-port drift detected — recreating");
|
||||
self.backup_runtime_change(&name, &resolved_manifest).await?;
|
||||
let _ = self.runtime.stop_container(&name).await;
|
||||
let _ = self.runtime.remove_container(&name).await;
|
||||
self.install_fresh(lm).await?;
|
||||
@@ -2499,6 +2515,7 @@ impl ProdContainerOrchestrator {
|
||||
return Ok(ReconcileAction::NoOp);
|
||||
}
|
||||
tracing::info!(app_id = %app_id, container = %name, "container env drift detected — recreating");
|
||||
self.backup_runtime_change(&name, &resolved_manifest).await?;
|
||||
let _ = self.runtime.stop_container(&name).await;
|
||||
let _ = self.runtime.remove_container(&name).await;
|
||||
self.install_fresh(lm).await?;
|
||||
@@ -2555,6 +2572,7 @@ impl ProdContainerOrchestrator {
|
||||
.await
|
||||
{
|
||||
tracing::info!(app_id = %app_id, container = %name, "stopped container env/port drift detected — recreating");
|
||||
self.backup_runtime_change(&name, &resolved_manifest).await?;
|
||||
let _ = self.runtime.remove_container(&name).await;
|
||||
self.install_fresh(lm).await?;
|
||||
return Ok(ReconcileAction::Installed);
|
||||
@@ -2611,6 +2629,7 @@ impl ProdContainerOrchestrator {
|
||||
self.prepare_for_start(&resolved_manifest).await?;
|
||||
if self.container_env_drifted(&name, &resolved_manifest).await {
|
||||
tracing::info!(app_id = %app_id, container = %name, "created container env drift detected — recreating");
|
||||
self.backup_runtime_change(&name, &resolved_manifest).await?;
|
||||
let _ = self.runtime.remove_container(&name).await;
|
||||
self.install_fresh(lm).await?;
|
||||
return Ok(ReconcileAction::Installed);
|
||||
@@ -3080,13 +3099,9 @@ impl ProdContainerOrchestrator {
|
||||
/// app is a companion (companion.rs owns those units), or when no
|
||||
/// unit file exists yet (install_via_quadlet handles first-write).
|
||||
///
|
||||
/// We DON'T restart the .service when content changes — running
|
||||
/// containers keep their current config until an operator-initiated
|
||||
/// restart picks up the new file. That's the right tradeoff: file
|
||||
/// updates are cheap and non-destructive; service restarts are
|
||||
/// destructive (the SIGKILL cascade we're trying to eliminate).
|
||||
/// systemctl --user daemon-reload runs only when content actually
|
||||
/// changed, so steady-state reconcile ticks pay just one fs read.
|
||||
/// Ordinary metadata changes wait for an operator restart. Runtime-affecting
|
||||
/// changes restart the service and retain a durable pending marker until
|
||||
/// that succeeds, including across daemon restarts and failed reloads.
|
||||
async fn sync_quadlet_unit(&self, lm: &LoadedManifest, name: &str) -> Result<()> {
|
||||
// Companions: same reasoning as migrate_to_quadlet_if_needed —
|
||||
// companion.rs renders these units with a different shape, syncing
|
||||
@@ -3106,7 +3121,7 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
let old_body = tokio::fs::read_to_string(&unit_path)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
.with_context(|| format!("read existing quadlet for {name}"))?;
|
||||
let restart_required = quadlet::contains_stale_health_gate(&old_body);
|
||||
|
||||
let mut resolved = lm.manifest.clone();
|
||||
@@ -3122,49 +3137,49 @@ impl ProdContainerOrchestrator {
|
||||
quadlet::network_aliases_changed(&old_body, &new_body);
|
||||
let restart_for_exec_change = quadlet::exec_changed(&old_body, &new_body);
|
||||
let restart_for_health_change = quadlet::health_cmd_changed(&old_body, &new_body);
|
||||
let restart_for_security_change = quadlet::security_changed(&old_body, &new_body);
|
||||
let needs_restart = restart_required
|
||||
|| restart_for_port_change
|
||||
|| restart_for_network_alias_change
|
||||
|| restart_for_exec_change
|
||||
|| restart_for_health_change
|
||||
|| restart_for_security_change;
|
||||
// Record the obligation BEFORE replacing the unit. A failed reload or
|
||||
// restart must not become a no-op on the next tick just because the
|
||||
// generated file already matches the manifest.
|
||||
let pending = quadlet::RestartObligation::prepare(&unit_path, needs_restart).await?;
|
||||
if pending.is_pending() {
|
||||
self.ensure_resolved_source_available(lm).await?;
|
||||
}
|
||||
if needs_restart {
|
||||
self.backup_runtime_change(name, &resolved).await?;
|
||||
}
|
||||
let changed = quadlet::write_if_changed(&unit, &unit_dir)
|
||||
.await
|
||||
.with_context(|| format!("drift-sync quadlet unit for {name}"))?;
|
||||
if changed {
|
||||
if changed || pending.is_pending() {
|
||||
quadlet::daemon_reload_user()
|
||||
.await
|
||||
.context("systemctl --user daemon-reload after drift-syncing quadlet unit")?;
|
||||
tracing::info!(
|
||||
app_id = %lm.manifest.app.id,
|
||||
container = %name,
|
||||
"Quadlet unit drift-synced — file rewritten, .service NOT restarted (operator restart picks up new config)"
|
||||
);
|
||||
}
|
||||
if changed
|
||||
&& (restart_required
|
||||
|| restart_for_port_change
|
||||
|| restart_for_network_alias_change
|
||||
|| restart_for_exec_change
|
||||
|| restart_for_health_change)
|
||||
{
|
||||
self.ensure_resolved_source_available(lm).await?;
|
||||
if pending.is_pending() {
|
||||
let service = unit.service_name();
|
||||
let reason = if restart_required {
|
||||
"stale health gate"
|
||||
} else if restart_for_port_change {
|
||||
"port binding drift"
|
||||
} else if restart_for_network_alias_change {
|
||||
"network alias drift"
|
||||
} else if restart_for_health_change {
|
||||
"health command drift"
|
||||
} else {
|
||||
"exec drift"
|
||||
};
|
||||
tracing::info!(
|
||||
app_id = %lm.manifest.app.id,
|
||||
container = %name,
|
||||
service = %service,
|
||||
reason = reason,
|
||||
"Quadlet unit rewrite requires service restart"
|
||||
"Applying pending Quadlet runtime change"
|
||||
);
|
||||
quadlet::restart_service(&service)
|
||||
.await
|
||||
.with_context(|| format!("restart drifted quadlet service {service}"))?;
|
||||
pending.complete().await?;
|
||||
} else if changed {
|
||||
tracing::info!(
|
||||
app_id = %lm.manifest.app.id,
|
||||
container = %name,
|
||||
"Quadlet metadata updated; operator restart will apply it"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -3866,6 +3881,64 @@ impl ProdContainerOrchestrator {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn backup_runtime_change(&self, name: &str, manifest: &AppManifest) -> Result<()> {
|
||||
if !crate::container::migration_backup::enabled(manifest)? {
|
||||
return Ok(());
|
||||
}
|
||||
// A persistent disk/permission failure must not repeatedly stop a
|
||||
// working old service. Reuse the reconciler's bounded repair budget.
|
||||
if !self.should_attempt_repair(name).await {
|
||||
anyhow::bail!("runtime migration retry budget exhausted; original service retained, inspect backup failure before retrying");
|
||||
}
|
||||
// Called only before a known runtime change. No app-specific commands;
|
||||
// opted-in manifests identify their persistent state through bind mounts.
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["inspect", name, "--format", "{{.HostConfig.NetworkMode}}"])
|
||||
.output().await.context("inspect network before migration backup")?;
|
||||
let present = if output.status.success() {
|
||||
true
|
||||
} else {
|
||||
// A crash after gracefully stopping a --rm Quadlet container can
|
||||
// leave only its data and old unit. Prove absence before snapshotting
|
||||
// stopped state; an inspect/Podman failure is not proof of absence.
|
||||
let exists = tokio::process::Command::new("podman")
|
||||
.args(["container", "exists", name]).status().await?;
|
||||
if exists.code() != Some(1) {
|
||||
anyhow::bail!("cannot verify existing container before network migration backup");
|
||||
}
|
||||
false
|
||||
};
|
||||
let service = format!("{name}.service");
|
||||
let managed = quadlet::unit_exists(name).await;
|
||||
let previous_unit = if managed {
|
||||
Some(tokio::fs::read(quadlet::unit_dir().await?.join(format!("{name}.container"))).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if managed {
|
||||
quadlet::stop_service(&service).await?;
|
||||
} else if present {
|
||||
self.runtime.stop_container(name).await?;
|
||||
}
|
||||
match crate::container::migration_backup::snapshot(manifest, &self.data_dir, previous_unit.as_deref()).await {
|
||||
Ok(archive) => {
|
||||
tracing::info!(container = %name, backup = %archive.display(), "Persistent state saved before network migration");
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
// The unit has not been rewritten yet. Restore its previous
|
||||
// service on backup failure and report the migration failure.
|
||||
let restored = if managed {
|
||||
quadlet::enable_now(&service).await
|
||||
} else {
|
||||
self.runtime.start_container(name).await
|
||||
};
|
||||
restored.context("restore original app after failed migration snapshot")?;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn container_env_drifted(&self, name: &str, manifest: &AppManifest) -> bool {
|
||||
if cfg!(test) {
|
||||
return false;
|
||||
@@ -3875,6 +3948,43 @@ impl ProdContainerOrchestrator {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Generated-unit drift handles managed services; preserve deliberate
|
||||
// systemd drop-in overrides instead of recreating them every tick.
|
||||
let unmanaged = !quadlet::unit_exists(name).await;
|
||||
// Podman's effective bounding set, not Docker-compatible CapAdd (which
|
||||
// can be empty even when Quadlet supplied capabilities).
|
||||
if unmanaged && !manifest.app.security.capabilities.is_empty() {
|
||||
if let Ok(output) = tokio::process::Command::new("podman")
|
||||
.args(["inspect", name, "--format", "{{json .BoundingCaps}}"])
|
||||
.output().await
|
||||
{
|
||||
if output.status.success() {
|
||||
if let Ok(actual) = serde_json::from_slice::<Vec<String>>(&output.stdout) {
|
||||
if missing_declared_capability(&manifest.app.security.capabilities, &actual) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quadlet handles declarative Network= drift above. Legacy rootless
|
||||
// Podman containers need the same convergence when no unit owns them.
|
||||
if unmanaged && matches!(manifest.app.container.network.as_deref(), Some("slirp4netns" | "pasta")) {
|
||||
if let Ok(output) = tokio::process::Command::new("podman")
|
||||
.args(["inspect", name, "--format", "{{.HostConfig.NetworkMode}}"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
if output.status.success() && rootless_network_mode_drifted(
|
||||
manifest.app.container.network.as_deref(),
|
||||
&String::from_utf8_lossy(&output.stdout),
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let inspect = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"inspect",
|
||||
@@ -4917,6 +5027,44 @@ mod tests {
|
||||
/// recovered when its siblings have live containers (the stack is
|
||||
/// installed), and left alone when the whole stack is gone or the app
|
||||
/// is not a stack member at all.
|
||||
#[tokio::test]
|
||||
async fn gitea_fresh_url_seed_preserves_operator_config_and_reports_write_failure() {
|
||||
let manifest = AppManifest::parse(include_str!("../../../../apps/gitea/manifest.yml")).unwrap();
|
||||
let seed = &manifest.app.files[0];
|
||||
assert!(!seed.overwrite);
|
||||
let content = seed.content.replace("{{HOST_IP}}", "192.0.2.1");
|
||||
assert!(content.contains("ROOT_URL = http://192.0.2.1:3001/"));
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("fresh/app.ini");
|
||||
assert_eq!(ensure_rendered_file(path.to_str().unwrap(), &content, seed.overwrite).await.unwrap(), HookOutcome::Rewritten);
|
||||
assert!(tokio::fs::read_to_string(&path).await.unwrap().contains("ROOT_URL"));
|
||||
let custom = "[server]\nROOT_URL = https://git.example.test/\n[database]\nDB_TYPE = postgres\n";
|
||||
tokio::fs::write(&path, custom).await.unwrap();
|
||||
assert_eq!(ensure_rendered_file(path.to_str().unwrap(), &content, seed.overwrite).await.unwrap(), HookOutcome::Unchanged);
|
||||
assert_eq!(tokio::fs::read_to_string(&path).await.unwrap(), custom);
|
||||
let impossible = path.join("app.ini");
|
||||
assert!(ensure_rendered_file(impossible.to_str().unwrap(), &content, seed.overwrite).await.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_sandbox_capability_repair_uses_bounding_set_and_preserves_extra_overrides() {
|
||||
let required = vec!["CHOWN".into(), "SYS_CHROOT".into()];
|
||||
assert!(missing_declared_capability(&required, &["CAP_CHOWN".into()]));
|
||||
assert!(!missing_declared_capability(&required, &["CAP_CHOWN".into(), "CAP_SYS_CHROOT".into()]));
|
||||
assert!(!missing_declared_capability(&required, &["CHOWN".into(), "SYS_CHROOT".into(), "CAP_KILL".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_rootless_network_change_converges_without_guessing_defaults() {
|
||||
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "pasta"));
|
||||
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "bridge"));
|
||||
assert!(!rootless_network_mode_drifted(Some("slirp4netns"), "slirp4netns"));
|
||||
assert!(!rootless_network_mode_drifted(Some("slirp4netns"), "slirp4netns:allow_host_loopback=true"));
|
||||
assert!(!rootless_network_mode_drifted(None, "pasta"));
|
||||
assert!(!rootless_network_mode_drifted(Some("slirp4netns"), ""));
|
||||
assert!(!rootless_network_mode_drifted(Some("archy-net"), "bridge"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_stack_member_recovery_requires_a_live_sibling() {
|
||||
let present: HashSet<String> = ["indeedhub-redis", "indeedhub-relay", "indeedhub"]
|
||||
|
||||
@@ -938,12 +938,69 @@ pub fn health_cmd_changed(old_body: &str, new_body: &str) -> bool {
|
||||
!= directive_values(new_body, "HealthRetries=")
|
||||
}
|
||||
|
||||
/// A unit rewrite and a successful systemd restart are separate operations.
|
||||
/// Keep the restart obligation across errors or a management-daemon restart.
|
||||
pub struct RestartObligation {
|
||||
marker: PathBuf,
|
||||
pending: bool,
|
||||
}
|
||||
|
||||
impl RestartObligation {
|
||||
pub async fn prepare(unit_path: &Path, newly_required: bool) -> Result<Self> {
|
||||
let marker = unit_path.with_extension("restart-pending");
|
||||
if newly_required {
|
||||
// Contents contain no manifest environment or credentials. sync_all
|
||||
// makes the obligation durable before the subsequent unit rename.
|
||||
let file = tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(&marker)
|
||||
.await
|
||||
.context("record pending Quadlet restart")?;
|
||||
file.sync_all().await?;
|
||||
if let Some(parent) = marker.parent() {
|
||||
tokio::fs::File::open(parent).await?.sync_all().await?;
|
||||
}
|
||||
}
|
||||
let pending = tokio::fs::try_exists(&marker).await?;
|
||||
Ok(Self { marker, pending })
|
||||
}
|
||||
|
||||
pub fn is_pending(&self) -> bool {
|
||||
self.pending
|
||||
}
|
||||
|
||||
/// Call only after systemd accepted the replacement service successfully.
|
||||
pub async fn complete(self) -> Result<()> {
|
||||
if self.pending {
|
||||
tokio::fs::remove_file(&self.marker)
|
||||
.await
|
||||
.context("clear completed Quadlet restart")?;
|
||||
if let Some(parent) = self.marker.parent() {
|
||||
tokio::fs::File::open(parent).await?.sync_all().await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn publish_ports_changed(old_body: &str, new_body: &str) -> bool {
|
||||
let old_ports = directive_values(old_body, "PublishPort=");
|
||||
let new_ports = directive_values(new_body, "PublishPort=");
|
||||
old_ports != new_ports
|
||||
}
|
||||
|
||||
pub fn security_changed(old_body: &str, new_body: &str) -> bool {
|
||||
["AddCapability=", "DropCapability=", "NoNewPrivileges=", "ReadOnly=", "User="]
|
||||
.iter().any(|directive| {
|
||||
let mut old = directive_values(old_body, directive);
|
||||
let mut new = directive_values(new_body, directive);
|
||||
old.sort(); new.sort();
|
||||
old != new
|
||||
})
|
||||
}
|
||||
|
||||
pub fn network_aliases_changed(old_body: &str, new_body: &str) -> bool {
|
||||
let old_network = directive_values(old_body, "Network=");
|
||||
let new_network = directive_values(new_body, "Network=");
|
||||
@@ -1541,6 +1598,28 @@ app:
|
||||
assert!(!s.contains("Network=host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portainer_catalog_network_repairs_same_node_routing_without_exposing_backend() {
|
||||
let manifest = AppManifest::parse(include_str!(
|
||||
"../../../../apps/portainer/manifest.yml"
|
||||
))
|
||||
.expect("shipped Portainer manifest must parse");
|
||||
let new = QuadletUnit::from_manifest(&manifest, "portainer").render();
|
||||
assert!(new.contains("Network=slirp4netns\n"));
|
||||
assert!(!new.contains("NetworkAlias="));
|
||||
assert!(new.contains("PublishPort=127.0.0.1:9000:9000/tcp"));
|
||||
assert!(!new.contains("PublishPort=0.0.0.0"));
|
||||
// The upgrade changes networking only: retain both state mounts and the
|
||||
// existing rootless socket, without an app.ini or repository rewrite.
|
||||
assert!(new.contains("Volume=/var/lib/archipelago/portainer:/data"));
|
||||
assert!(new.contains("Volume=/var/lib/archipelago/portainer/compose:/data/compose"));
|
||||
assert!(new.contains("Volume=/run/user/1000/podman/podman.sock:/var/run/docker.sock"));
|
||||
let old = new.replace("Network=slirp4netns\n", "");
|
||||
assert!(network_aliases_changed(&old, &new));
|
||||
assert!(!network_aliases_changed(&new, &new));
|
||||
assert!(!publish_ports_changed(&old, &new));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_manifest_slirp4netns_omits_network_alias() {
|
||||
let yaml = r#"
|
||||
@@ -1891,6 +1970,47 @@ app:
|
||||
assert!(!network_aliases_changed(new, new));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_runtime_change_remains_pending_when_unit_already_matches() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let unit = dir.path().join("portainer.container");
|
||||
tokio::fs::write(&unit, "[Container]\n").await.unwrap();
|
||||
let pending = RestartObligation::prepare(&unit, true).await.unwrap();
|
||||
assert!(pending.is_pending());
|
||||
tokio::fs::write(&unit, "[Container]\nNetwork=slirp4netns\n")
|
||||
.await
|
||||
.unwrap();
|
||||
// Simulate systemctl failure or daemon interruption after unit rewrite.
|
||||
drop(pending);
|
||||
let retry = RestartObligation::prepare(&unit, false).await.unwrap();
|
||||
assert!(retry.is_pending(), "matching unit must not discard failed restart");
|
||||
retry.complete().await.unwrap();
|
||||
assert!(!RestartObligation::prepare(&unit, false).await.unwrap().is_pending());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_runtime_change_errors_are_not_reported_as_success() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing = dir.path().join("missing/app.container");
|
||||
assert!(RestartObligation::prepare(&missing, true).await.is_err());
|
||||
let unit = dir.path().join("app.container");
|
||||
let pending = RestartObligation::prepare(&unit, true).await.unwrap();
|
||||
tokio::fs::remove_file(unit.with_extension("restart-pending")).await.unwrap();
|
||||
assert!(pending.complete().await.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitea_ssh_sandbox_capability_is_applied_as_a_runtime_change() {
|
||||
let manifest = AppManifest::parse(include_str!("../../../../apps/gitea/manifest.yml")).unwrap();
|
||||
manifest.validate().unwrap();
|
||||
let new = QuadletUnit::from_manifest(&manifest, "gitea").render();
|
||||
assert!(new.contains("AddCapability=SYS_CHROOT\n"));
|
||||
let old = new.replace("AddCapability=SYS_CHROOT\n", "");
|
||||
assert!(security_changed(&old, &new));
|
||||
assert!(!security_changed(&new, &new));
|
||||
assert!(!security_changed("AddCapability=CHOWN\nAddCapability=SETUID\n", "AddCapability=SETUID\nAddCapability=CHOWN\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_aliases_changed_detects_network_mode_drift() {
|
||||
let old = "[Container]\nNetwork=slirp4netns\n";
|
||||
|
||||
@@ -238,6 +238,11 @@ pub enum ServeResult {
|
||||
Forbidden,
|
||||
/// Content not found.
|
||||
NotFound,
|
||||
/// The catalog entry and file exist but this node can't read the file.
|
||||
/// Returned before any payment is taken.
|
||||
Unavailable,
|
||||
/// Requested byte range cannot be served; no payment was taken.
|
||||
RangeNotSatisfiable(u64),
|
||||
}
|
||||
|
||||
/// Serve a content item by ID with access control and optional range request.
|
||||
@@ -252,6 +257,39 @@ pub async fn serve_content(
|
||||
range: Option<ByteRange>,
|
||||
owner_session: bool,
|
||||
) -> Result<ServeResult> {
|
||||
serve_content_with(
|
||||
data_dir,
|
||||
id,
|
||||
payment_token,
|
||||
invoice_hash,
|
||||
peer_did,
|
||||
range,
|
||||
owner_session,
|
||||
|path, range, mime| prepare_content(data_dir, path, range, mime),
|
||||
|token, amount| async move { verify_payment_token(data_dir, &token, amount).await },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// Inject only the read and payment boundaries, so tests can prove ordering
|
||||
// without mint access, file-permission assumptions or privileged commands.
|
||||
async fn serve_content_with<R, RF, V, VF>(
|
||||
data_dir: &Path,
|
||||
id: &str,
|
||||
payment_token: Option<&str>,
|
||||
invoice_hash: Option<&str>,
|
||||
peer_did: Option<&str>,
|
||||
range: Option<ByteRange>,
|
||||
owner_session: bool,
|
||||
read: R,
|
||||
verify: V,
|
||||
) -> Result<ServeResult>
|
||||
where
|
||||
R: FnOnce(PathBuf, Option<ByteRange>, String) -> RF,
|
||||
RF: std::future::Future<Output = Result<ServeResult>>,
|
||||
V: FnOnce(String, u64) -> VF,
|
||||
VF: std::future::Future<Output = bool>,
|
||||
{
|
||||
let catalog = load_catalog(data_dir).await?;
|
||||
let item = match catalog.items.iter().find(|i| i.id == id) {
|
||||
Some(i) => i,
|
||||
@@ -314,6 +352,29 @@ pub async fn serve_content(
|
||||
return Ok(ServeResult::NotFound);
|
||||
}
|
||||
|
||||
// Refuse unauthorized viewers before opening or reading any bytes.
|
||||
if !owner_session && matches!(item.access, AccessControl::PeersOnly) && !is_known_peer {
|
||||
return Ok(ServeResult::Forbidden);
|
||||
}
|
||||
if !owner_session {
|
||||
if let AccessControl::Paid { price_sats, .. } = &item.access {
|
||||
if payment_token.is_none() && invoice_hash.is_none() {
|
||||
return Ok(ServeResult::PaymentRequired(*price_sats));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finish all file I/O before consuming bearer payment. Merely opening then
|
||||
// reopening after charging still lost payments on read errors or deletion.
|
||||
let prepared = match read(file_path, range, item.mime_type.clone()).await {
|
||||
Ok(result @ (ServeResult::Ok(..) | ServeResult::Partial { .. })) => result,
|
||||
Ok(other) => return Ok(other),
|
||||
Err(error) => {
|
||||
warn!(content_id = %id, "Cannot prepare shared content: {error:#}");
|
||||
return Ok(ServeResult::Unavailable);
|
||||
}
|
||||
};
|
||||
|
||||
// Check access control
|
||||
if !owner_session {
|
||||
match &item.access {
|
||||
@@ -331,7 +392,7 @@ pub async fn serve_content(
|
||||
"fedimint"
|
||||
};
|
||||
if method_accepted(&item.access, method)
|
||||
&& verify_payment_token(data_dir, token, *price_sats).await
|
||||
&& verify(token.to_owned(), *price_sats).await
|
||||
{
|
||||
authorized = true;
|
||||
}
|
||||
@@ -358,55 +419,127 @@ pub async fn serve_content(
|
||||
}
|
||||
}
|
||||
|
||||
let metadata = fs::metadata(&file_path)
|
||||
Ok(prepared)
|
||||
}
|
||||
|
||||
async fn prepare_content(
|
||||
data_dir: &Path,
|
||||
path: PathBuf,
|
||||
range: Option<ByteRange>,
|
||||
mime: String,
|
||||
) -> Result<ServeResult> {
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
let mut file = match fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(&path)
|
||||
.await
|
||||
.context("Failed to read file metadata")?;
|
||||
let total_size = metadata.len();
|
||||
|
||||
// Handle range request for streaming
|
||||
if let Some(range) = range {
|
||||
let start = range.start.min(total_size.saturating_sub(1));
|
||||
let end = range
|
||||
.end
|
||||
.map(|e| e.min(total_size - 1))
|
||||
.unwrap_or(total_size - 1);
|
||||
|
||||
if start > end || start >= total_size {
|
||||
return Ok(ServeResult::NotFound);
|
||||
{
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
let bytes = read_filebrowser_via_userns(data_dir, &path).await?;
|
||||
return slice_prepared_content(bytes, range, mime);
|
||||
}
|
||||
|
||||
let len = (end - start + 1) as usize;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
let mut file = tokio::fs::File::open(&file_path)
|
||||
Err(error) => return Err(error).context("Opening shared content"),
|
||||
};
|
||||
let metadata = file.metadata().await?;
|
||||
anyhow::ensure!(metadata.is_file(), "Shared content is not a regular file");
|
||||
let total = metadata.len();
|
||||
if let Some(range) = range {
|
||||
let Some((start, end)) = checked_range(&range, total) else {
|
||||
return Ok(ServeResult::RangeNotSatisfiable(total));
|
||||
};
|
||||
file.seek(std::io::SeekFrom::Start(start)).await?;
|
||||
let len = usize::try_from(end - start + 1).context("Content range is too large")?;
|
||||
let mut bytes = vec![0; len];
|
||||
file.read_exact(&mut bytes)
|
||||
.await
|
||||
.context("Failed to open content file")?;
|
||||
file.seek(std::io::SeekFrom::Start(start))
|
||||
.await
|
||||
.context("Failed to seek")?;
|
||||
let mut buf = vec![0u8; len];
|
||||
file.read_exact(&mut buf)
|
||||
.await
|
||||
.context("Failed to read range")?;
|
||||
|
||||
debug!(
|
||||
"Serving content '{}' range {}-{}/{} ({} bytes)",
|
||||
id, start, end, total_size, len
|
||||
);
|
||||
.context("Reading shared content range")?;
|
||||
return Ok(ServeResult::Partial {
|
||||
bytes: buf,
|
||||
mime_type: item.mime_type.clone(),
|
||||
bytes,
|
||||
mime_type: mime,
|
||||
start,
|
||||
end,
|
||||
total: total_size,
|
||||
total,
|
||||
});
|
||||
}
|
||||
|
||||
let bytes = fs::read(&file_path)
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)
|
||||
.await
|
||||
.context("Failed to read content file")?;
|
||||
.context("Reading shared content")?;
|
||||
Ok(ServeResult::Ok(bytes, mime))
|
||||
}
|
||||
|
||||
debug!("Serving content '{}' ({} bytes)", id, bytes.len());
|
||||
Ok(ServeResult::Ok(bytes, item.mime_type.clone()))
|
||||
fn checked_range(range: &ByteRange, total: u64) -> Option<(u64, u64)> {
|
||||
let last = total.checked_sub(1)?;
|
||||
let end = range.end.unwrap_or(last).min(last);
|
||||
(range.start <= end && range.start < total).then_some((range.start, end))
|
||||
}
|
||||
|
||||
fn slice_prepared_content(
|
||||
bytes: Vec<u8>,
|
||||
range: Option<ByteRange>,
|
||||
mime: String,
|
||||
) -> Result<ServeResult> {
|
||||
let total = bytes.len() as u64;
|
||||
match range {
|
||||
None => Ok(ServeResult::Ok(bytes, mime)),
|
||||
Some(range) => match checked_range(&range, total) {
|
||||
Some((start, end)) => Ok(ServeResult::Partial {
|
||||
bytes: bytes[start as usize..=end as usize].to_vec(),
|
||||
mime_type: mime,
|
||||
start,
|
||||
end,
|
||||
total,
|
||||
}),
|
||||
None => Ok(ServeResult::RangeNotSatisfiable(total)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Read only an explicitly shared, regular file within FileBrowser storage.
|
||||
/// Do not change its mode or grant world-readable access to paid/private data.
|
||||
async fn filebrowser_read_path(data_dir: &Path, path: &Path) -> Result<PathBuf> {
|
||||
let root = fs::canonicalize(data_dir.join("filebrowser")).await?;
|
||||
let target = fs::canonicalize(path).await?;
|
||||
anyhow::ensure!(
|
||||
target.starts_with(&root) && target != root,
|
||||
"Shared file is outside Files storage"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
fs::metadata(&target).await?.is_file(),
|
||||
"Shared content is not a regular file"
|
||||
);
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
async fn read_filebrowser_via_userns(data_dir: &Path, path: &Path) -> Result<Vec<u8>> {
|
||||
let path = filebrowser_read_path(data_dir, path).await?;
|
||||
// Tests exercise the boundary explicitly; they never launch the host Podman.
|
||||
#[cfg(test)]
|
||||
{
|
||||
let _ = path;
|
||||
anyhow::bail!("Files namespace read disabled in unit tests")
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
let output = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(900),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["unshare", "cat", "--"])
|
||||
.arg(path)
|
||||
.kill_on_drop(true)
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
.context("Files namespace read timed out")??;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"Files namespace read failed: {}",
|
||||
output.status
|
||||
);
|
||||
Ok(output.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of attempting to serve a preview.
|
||||
@@ -729,3 +862,301 @@ mod prune_missing_content_tests {
|
||||
assert_eq!(reloaded.items[0].id, "present-item");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod paid_read_order_tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
async fn fixture(bytes: &[u8]) -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join("content/files"))
|
||||
.await
|
||||
.unwrap();
|
||||
fs::write(dir.path().join("content/files/test.bin"), bytes)
|
||||
.await
|
||||
.unwrap();
|
||||
save_catalog(
|
||||
dir.path(),
|
||||
&ContentCatalog {
|
||||
items: vec![ContentItem {
|
||||
id: "paid".into(),
|
||||
filename: "test.bin".into(),
|
||||
mime_type: "application/octet-stream".into(),
|
||||
size_bytes: bytes.len() as u64,
|
||||
description: String::new(),
|
||||
access: AccessControl::Paid {
|
||||
price_sats: 10,
|
||||
accepted: vec!["ecash".into()],
|
||||
},
|
||||
availability: Availability::AllPeers,
|
||||
added_at: "2026-09-30".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn all_read_failures_precede_redemption_even_as_root() {
|
||||
for kind in [
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
std::io::ErrorKind::NotFound,
|
||||
std::io::ErrorKind::Other,
|
||||
] {
|
||||
let dir = fixture(b"abc").await;
|
||||
let charged = AtomicUsize::new(0);
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
Some("cashuBtest"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
|_, _, _| async move { Err(std::io::Error::from(kind).into()) },
|
||||
|_, _| async {
|
||||
charged.fetch_add(1, Ordering::SeqCst);
|
||||
true
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::Unavailable));
|
||||
assert_eq!(charged.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(load_catalog(dir.path()).await.unwrap().items.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deletion_during_payment_cannot_lose_prepared_bytes() {
|
||||
let dir = fixture(b"original").await;
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
Some("cashuBtest"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||
|_, amount| {
|
||||
assert_eq!(amount, 10);
|
||||
async {
|
||||
fs::remove_file(dir.path().join("content/files/test.bin"))
|
||||
.await
|
||||
.unwrap();
|
||||
true
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::Ok(bytes, _) if bytes == b"original"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_out_of_bounds_and_reversed_ranges_never_charge() {
|
||||
for (bytes, start, end) in [
|
||||
(b"".as_slice(), 0, None),
|
||||
(b"abc".as_slice(), 3, None),
|
||||
(b"abc".as_slice(), 2, Some(1)),
|
||||
] {
|
||||
let dir = fixture(bytes).await;
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
Some("cashuBtest"),
|
||||
None,
|
||||
None,
|
||||
Some(ByteRange { start, end }),
|
||||
false,
|
||||
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||
|_, _| async { panic!("invalid range reached payment") },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(result, ServeResult::RangeNotSatisfiable(n) if n == bytes.len() as u64)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepared_range_survives_file_change_while_payment_is_verified() {
|
||||
let dir = fixture(b"abcdef").await;
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
Some("cashuBtest"),
|
||||
None,
|
||||
None,
|
||||
Some(ByteRange {
|
||||
start: 2,
|
||||
end: Some(999),
|
||||
}),
|
||||
false,
|
||||
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||
|_, _| async {
|
||||
fs::write(dir.path().join("content/files/test.bin"), b"x")
|
||||
.await
|
||||
.unwrap();
|
||||
true
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(result, ServeResult::Partial { bytes, start: 2, end: 5, total: 6, .. } if bytes == b"cdef")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn payment_denial_never_returns_prepared_content() {
|
||||
let dir = fixture(b"secret").await;
|
||||
let charged = AtomicUsize::new(0);
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
Some("cashuBtest"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||
|_, _| async {
|
||||
charged.fetch_add(1, Ordering::SeqCst);
|
||||
false
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::PaymentRequired(10)));
|
||||
assert_eq!(charged.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_payment_and_peer_restrictions_precede_file_reads() {
|
||||
let dir = fixture(b"secret").await;
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
|_, _, _| async { panic!("unauthorized file read") },
|
||||
|_, _| async { panic!("unexpected payment") },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::PaymentRequired(10)));
|
||||
let mut catalog = load_catalog(dir.path()).await.unwrap();
|
||||
catalog.items[0].access = AccessControl::PeersOnly;
|
||||
save_catalog(dir.path(), &catalog).await.unwrap();
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
|_, _, _| async { panic!("unauthorized file read") },
|
||||
|_, _| async { panic!("unexpected payment") },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::Forbidden));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_reads_paid_content_without_redemption() {
|
||||
let dir = fixture(b"own file").await;
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||
|_, _| async { panic!("owner charged") },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::Ok(bytes, _) if bytes == b"own file"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn directory_in_place_of_file_does_not_charge() {
|
||||
let dir = fixture(b"abc").await;
|
||||
let path = dir.path().join("content/files/test.bin");
|
||||
fs::remove_file(&path).await.unwrap();
|
||||
fs::create_dir(&path).await.unwrap();
|
||||
let result = serve_content_with(
|
||||
dir.path(),
|
||||
"paid",
|
||||
Some("cashuBtest"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|
||||
|_, _| async { panic!("directory charged") },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::Unavailable));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_namespace_read_is_scoped_to_regular_files_and_keeps_mode() {
|
||||
use std::os::unix::fs::{symlink, PermissionsExt};
|
||||
let dir = fixture(b"outside").await;
|
||||
let root = dir.path().join("filebrowser");
|
||||
fs::create_dir(&root).await.unwrap();
|
||||
let inside = root.join("song");
|
||||
fs::write(&inside, b"song").await.unwrap();
|
||||
fs::set_permissions(&inside, std::fs::Permissions::from_mode(0o640))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
filebrowser_read_path(dir.path(), &inside).await.unwrap(),
|
||||
inside
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&inside).await.unwrap().permissions().mode() & 0o777,
|
||||
0o640
|
||||
);
|
||||
let outside = dir.path().join("content/files/test.bin");
|
||||
symlink(&outside, root.join("escape")).unwrap();
|
||||
for path in [outside, root.join("escape"), root.clone()] {
|
||||
assert!(filebrowser_read_path(dir.path(), &path).await.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_namespace_bytes_use_the_same_range_rules() {
|
||||
assert!(matches!(
|
||||
slice_prepared_content(
|
||||
vec![],
|
||||
Some(ByteRange {
|
||||
start: 0,
|
||||
end: None
|
||||
}),
|
||||
"x".into()
|
||||
)
|
||||
.unwrap(),
|
||||
ServeResult::RangeNotSatisfiable(0)
|
||||
));
|
||||
assert!(
|
||||
matches!(slice_prepared_content(b"abc".to_vec(), Some(ByteRange { start: 1, end: None }), "x".into()).unwrap(), ServeResult::Partial { bytes, start: 1, end: 2, total: 3, .. } if bytes == b"bc")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,25 @@ fn fips_should_fall_back(status: reqwest::StatusCode) -> bool {
|
||||
status == reqwest::StatusCode::NOT_FOUND || status.is_server_error()
|
||||
}
|
||||
|
||||
/// Is this FIPS answer the final one, or should the request go again over
|
||||
/// Tor? A single-delivery request already reached the peer, so any answer
|
||||
/// is final: a Tor replay would carry the same (possibly spent) payload.
|
||||
fn fips_answer_is_final(
|
||||
pref: crate::settings::transport::TransportPref,
|
||||
single_delivery: bool,
|
||||
status: reqwest::StatusCode,
|
||||
) -> bool {
|
||||
pref == crate::settings::transport::TransportPref::Fips
|
||||
|| single_delivery
|
||||
|| !fips_should_fall_back(status)
|
||||
}
|
||||
|
||||
/// May a failed FIPS attempt be sent again? Only a failed connect proves the
|
||||
/// peer never saw it; a timeout can land after the request was delivered.
|
||||
fn fips_retryable(single_delivery: bool, e: &reqwest::Error) -> bool {
|
||||
e.is_connect() || (!single_delivery && e.is_timeout())
|
||||
}
|
||||
|
||||
/// DNS suffix appended to a peer's bech32 npub.
|
||||
pub const FIPS_DNS_SUFFIX: &str = "fips";
|
||||
|
||||
@@ -113,7 +132,21 @@ pub fn client() -> reqwest::Client {
|
||||
/// before the Tor fallback ever gets a chance. The generous `connect_timeout`
|
||||
/// is preserved so a cold hole-punched path still gets time to establish.
|
||||
pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
|
||||
client_with_delivery_policy(timeout, false)
|
||||
}
|
||||
|
||||
fn delivery_redirect_policy(single: bool) -> reqwest::redirect::Policy {
|
||||
if single {
|
||||
reqwest::redirect::Policy::none()
|
||||
} else {
|
||||
reqwest::redirect::Policy::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn client_with_delivery_policy(timeout: Duration, single: bool) -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.redirect(delivery_redirect_policy(single))
|
||||
.timeout(timeout)
|
||||
.connect_timeout(Duration::from_secs(8))
|
||||
.user_agent("archipelago-fips/1")
|
||||
@@ -130,10 +163,18 @@ pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
|
||||
/// robust". Only connect/timeout errors are retried (a real HTTP response,
|
||||
/// including 4xx/5xx, is returned as-is for the caller to interpret).
|
||||
async fn send_with_retry(rb: reqwest::RequestBuilder) -> Result<reqwest::Response, reqwest::Error> {
|
||||
send_with_retry_if(rb, |e| e.is_connect() || e.is_timeout()).await
|
||||
}
|
||||
|
||||
/// [`send_with_retry`], retrying only on errors `retryable` accepts.
|
||||
async fn send_with_retry_if(
|
||||
rb: reqwest::RequestBuilder,
|
||||
retryable: impl Fn(&reqwest::Error) -> bool,
|
||||
) -> Result<reqwest::Response, reqwest::Error> {
|
||||
let retry = rb.try_clone();
|
||||
match rb.send().await {
|
||||
Ok(resp) => Ok(resp),
|
||||
Err(e) if (e.is_connect() || e.is_timeout()) && retry.is_some() => {
|
||||
Err(e) if retryable(&e) && retry.is_some() => {
|
||||
// Brief pause so the hole-punch packets from the first attempt can
|
||||
// traverse before we re-dial onto the warmed path.
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
@@ -350,6 +391,9 @@ pub struct PeerRequest<'a> {
|
||||
/// the per-peer FIPS/Tor badge reflects reality. Opt-in because not
|
||||
/// every caller has a data dir in scope.
|
||||
pub record_data_dir: Option<std::path::PathBuf>,
|
||||
/// The request carries something that must reach the peer at most once
|
||||
/// (a bearer ecash token). See [`PeerRequest::single_delivery`].
|
||||
pub single_delivery: bool,
|
||||
}
|
||||
|
||||
impl<'a> PeerRequest<'a> {
|
||||
@@ -363,9 +407,25 @@ impl<'a> PeerRequest<'a> {
|
||||
fips_timeout: None,
|
||||
service: None,
|
||||
record_data_dir: None,
|
||||
single_delivery: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Never send this request twice. A paid download carries a bearer ecash
|
||||
/// token that the seller redeems on first sight; replaying it over Tor
|
||||
/// after FIPS already delivered it hands the seller a spent token, so the
|
||||
/// buyer is charged and gets a 402 instead of the file (2026-09-29: FIPS
|
||||
/// answered 404 after the seller redeemed, the Tor retry got 402).
|
||||
///
|
||||
/// With this set, whatever FIPS answers is final, the FIPS retry fires
|
||||
/// only when the first attempt never connected, and Tor is used only when
|
||||
/// FIPS could not have delivered the request. An attempt that may have
|
||||
/// been delivered but timed out is an error, not a fallback.
|
||||
pub fn single_delivery(mut self) -> Self {
|
||||
self.single_delivery = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Record the transport that serves this request into federation storage
|
||||
/// (matched by this request's onion host). Best-effort, off the hot path.
|
||||
pub fn record_transport(mut self, data_dir: impl Into<std::path::PathBuf>) -> Self {
|
||||
@@ -442,7 +502,7 @@ impl<'a> PeerRequest<'a> {
|
||||
// Use the FIPS reply unless it's one a Tor retry could
|
||||
// fix (404 path-not-served / 5xx) and we're allowed to
|
||||
// fall back. FIPS-only never falls back.
|
||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
||||
if fips_answer_is_final(pref, self.single_delivery, resp.status()) {
|
||||
telemetry::record_fips_ok();
|
||||
self.spawn_record(crate::transport::TransportKind::Fips);
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
@@ -481,7 +541,7 @@ impl<'a> PeerRequest<'a> {
|
||||
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
|
||||
match self.try_fips_get().await? {
|
||||
Some(resp) => {
|
||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
||||
if fips_answer_is_final(pref, self.single_delivery, resp.status()) {
|
||||
telemetry::record_fips_ok();
|
||||
self.spawn_record(crate::transport::TransportKind::Fips);
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
@@ -551,13 +611,21 @@ impl<'a> PeerRequest<'a> {
|
||||
} else {
|
||||
budget
|
||||
};
|
||||
let c = client_with_timeout(per_attempt);
|
||||
let c = client_with_delivery_policy(per_attempt, self.single_delivery);
|
||||
let mut rb = c.post(&url).json(body);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
}
|
||||
match tokio::time::timeout(budget, send_with_retry(rb)).await {
|
||||
let single = self.single_delivery;
|
||||
let attempt = send_with_retry_if(rb, |e| fips_retryable(single, e));
|
||||
match tokio::time::timeout(budget, attempt).await {
|
||||
Ok(Ok(r)) => Ok(Some(r)),
|
||||
Ok(Err(e)) if single && !e.is_connect() => Err(anyhow::anyhow!(
|
||||
"FIPS POST failed after possible delivery; not replaying: {e}"
|
||||
)),
|
||||
Err(_) if single => Err(anyhow::anyhow!(
|
||||
"FIPS POST exceeded its budget after possible delivery; not replaying"
|
||||
)),
|
||||
Ok(Err(e)) => {
|
||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||
tracing::info!(
|
||||
@@ -612,13 +680,28 @@ impl<'a> PeerRequest<'a> {
|
||||
} else {
|
||||
budget
|
||||
};
|
||||
let c = client_with_timeout(per_attempt);
|
||||
let c = client_with_delivery_policy(per_attempt, self.single_delivery);
|
||||
let mut rb = c.get(&url);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
}
|
||||
match tokio::time::timeout(budget, send_with_retry(rb)).await {
|
||||
let single = self.single_delivery;
|
||||
let attempt = send_with_retry_if(rb, |e| fips_retryable(single, e));
|
||||
match tokio::time::timeout(budget, attempt).await {
|
||||
Ok(Ok(r)) => Ok(Some(r)),
|
||||
// Anything but a failed connect may have reached the peer.
|
||||
Ok(Err(e)) if single && !e.is_connect() => Err(anyhow::anyhow!(
|
||||
"FIPS GET {} failed after the request may have been delivered \
|
||||
(not retrying over Tor): {}",
|
||||
self.path,
|
||||
e
|
||||
)),
|
||||
Err(_) if single => Err(anyhow::anyhow!(
|
||||
"FIPS GET {} exceeded its {:?} budget after the request may have \
|
||||
been delivered (not retrying over Tor)",
|
||||
self.path,
|
||||
budget
|
||||
)),
|
||||
Ok(Err(e)) => {
|
||||
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||
tracing::info!(
|
||||
@@ -676,6 +759,7 @@ impl<'a> PeerRequest<'a> {
|
||||
.context("Invalid Tor SOCKS proxy URL")?;
|
||||
reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.redirect(delivery_redirect_policy(self.single_delivery))
|
||||
.timeout(self.timeout)
|
||||
.build()
|
||||
.context("Build Tor HTTP client")
|
||||
@@ -759,4 +843,181 @@ mod tests {
|
||||
let err = decode_response(0xAABB, &r, "x").unwrap_err();
|
||||
assert!(err.to_string().contains("no AAAA"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_delivery_answer_is_final_whatever_its_status() {
|
||||
use crate::settings::transport::TransportPref;
|
||||
use reqwest::StatusCode;
|
||||
// Regression (2026-09-29): the seller redeemed a paid download's
|
||||
// token, answered 404, and the Tor fallback replayed the spent token.
|
||||
for status in [
|
||||
StatusCode::NOT_FOUND,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
StatusCode::OK,
|
||||
] {
|
||||
assert!(fips_answer_is_final(TransportPref::Auto, true, status));
|
||||
}
|
||||
// Everything else keeps the existing fallback rules.
|
||||
assert!(!fips_answer_is_final(
|
||||
TransportPref::Auto,
|
||||
false,
|
||||
StatusCode::NOT_FOUND
|
||||
));
|
||||
assert!(!fips_answer_is_final(
|
||||
TransportPref::Auto,
|
||||
false,
|
||||
StatusCode::BAD_GATEWAY
|
||||
));
|
||||
assert!(fips_answer_is_final(
|
||||
TransportPref::Auto,
|
||||
false,
|
||||
StatusCode::PAYMENT_REQUIRED
|
||||
));
|
||||
assert!(fips_answer_is_final(
|
||||
TransportPref::Fips,
|
||||
false,
|
||||
StatusCode::NOT_FOUND
|
||||
));
|
||||
}
|
||||
|
||||
/// A listener that accepts connections and never answers, counting them.
|
||||
async fn silent_peer() -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let counter = seen.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut held = Vec::new();
|
||||
while let Ok((stream, _)) = listener.accept().await {
|
||||
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
held.push(stream); // keep it open, never reply
|
||||
}
|
||||
});
|
||||
(format!("http://{addr}/content/x"), seen)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_single_delivery_request_is_not_resent_after_a_timeout() {
|
||||
let (url, seen) = silent_peer().await;
|
||||
let c = client_with_timeout(Duration::from_millis(300));
|
||||
let err = send_with_retry_if(c.get(&url), |e| fips_retryable(true, e))
|
||||
.await
|
||||
.expect_err("peer never answers");
|
||||
assert!(err.is_timeout());
|
||||
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_ordinary_request_is_still_retried_once_after_a_timeout() {
|
||||
let (url, seen) = silent_peer().await;
|
||||
let c = client_with_timeout(Duration::from_millis(300));
|
||||
let _ = send_with_retry_if(c.get(&url), |e| fips_retryable(false, e)).await;
|
||||
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_single_delivery_request_still_retries_a_refused_connect() {
|
||||
// Nothing listening: the peer provably never saw the request.
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
drop(listener);
|
||||
let c = client_with_timeout(Duration::from_millis(500));
|
||||
let err = send_with_retry_if(c.get(format!("http://{addr}/")), |e| {
|
||||
fips_retryable(true, e)
|
||||
})
|
||||
.await
|
||||
.expect_err("nothing listening");
|
||||
assert!(err.is_connect());
|
||||
assert!(fips_retryable(true, &err));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod delivery_redirect_tests {
|
||||
use super::*;
|
||||
use hyper::{
|
||||
service::{make_service_fn, service_fn},
|
||||
Body, Response, Server,
|
||||
};
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn paid_bearer_request_does_not_follow_redirects_but_normal_get_does() {
|
||||
let seen = Arc::new(AtomicUsize::new(0));
|
||||
let counter = seen.clone();
|
||||
let server = Server::bind(&([127, 0, 0, 1], 0).into());
|
||||
let address = server.local_addr();
|
||||
let service = make_service_fn(move |_| {
|
||||
let counter = counter.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(service_fn(move |request: hyper::Request<Body>| {
|
||||
let counter = counter.clone();
|
||||
async move {
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
let response = if request.uri().path() == "/first" {
|
||||
Response::builder()
|
||||
.status(302)
|
||||
.header("Location", "/replay")
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
} else {
|
||||
Response::new(Body::from("replayed"))
|
||||
};
|
||||
Ok::<_, Infallible>(response)
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
let task = tokio::spawn(server.serve(service));
|
||||
let url = format!("http://{address}/first");
|
||||
let response = client_with_delivery_policy(Duration::from_secs(2), true)
|
||||
.get(&url)
|
||||
.header("X-Payment-Token", "dummy-test-token")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::FOUND);
|
||||
assert_eq!(seen.load(Ordering::SeqCst), 1);
|
||||
let response = client_with_delivery_policy(Duration::from_secs(2), false)
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(seen.load(Ordering::SeqCst), 3);
|
||||
task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paid_request_is_not_resent_when_peer_disconnects_after_reading_it() {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let seen = Arc::new(AtomicUsize::new(0));
|
||||
let counter = seen.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
while let Ok((mut stream, _)) = listener.accept().await {
|
||||
let mut buf = [0; 4096];
|
||||
let _ = stream.read(&mut buf).await;
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
drop(stream);
|
||||
}
|
||||
});
|
||||
let c = client_with_delivery_policy(Duration::from_secs(2), true);
|
||||
let error = send_with_retry_if(c.get(format!("http://{address}/")), |e| {
|
||||
fips_retryable(true, e)
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(!error.is_connect());
|
||||
assert_eq!(seen.load(Ordering::SeqCst), 1);
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1074,6 +1074,12 @@ impl AppManifest {
|
||||
// `..` copy sources). See docs/manifest-hooks-design.md.
|
||||
self.app.hooks.validate()?;
|
||||
|
||||
if let Some(value) = self.app.extensions.get("backup_before_runtime_change") {
|
||||
if value.as_bool().is_none() {
|
||||
return Err(ManifestError::Invalid("backup_before_runtime_change must be boolean".into()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1111,6 +1117,7 @@ fn validate_security(policy: &SecurityPolicy) -> Result<(), ManifestError> {
|
||||
"SETGID",
|
||||
"SETUID",
|
||||
"SYS_ADMIN",
|
||||
"SYS_CHROOT",
|
||||
];
|
||||
let mut seen = HashSet::new();
|
||||
for cap in &policy.capabilities {
|
||||
@@ -1746,40 +1753,28 @@ app:
|
||||
}
|
||||
}
|
||||
exempt.sort();
|
||||
// 28 as of 2026-08-23: the 26 below plus cuprate's two exemptions —
|
||||
// 18183 (Monero p2p gossip, same reasoning as bitcoin's 8333) and
|
||||
// 18090 (host mapping for Monero's canonical 18089 restricted RPC,
|
||||
// upstream's own safe-for-public
|
||||
// subset that wallets connect to directly as a "remote node" over
|
||||
// plain HTTP JSON-RPC — same reasoning as electrumx's 50001).
|
||||
// cuprate's unrestricted RPC (full node control) stays loopback-only
|
||||
// (auth: local), not in this set.
|
||||
//
|
||||
// 26 as of 2026-08-16: the 25 below plus phoenixd 9740, a
|
||||
// loopback-only JSON API whose own generated http password
|
||||
// authenticates every request (added with the phoenixd onboarding,
|
||||
// which did not update this count — exactly the drift this test
|
||||
// exists to catch).
|
||||
//
|
||||
// 25 as of the v1.7.123 port-policy round: bitcoin p2p (8333 ×2),
|
||||
// core-lightning 9736/9835, electrumx 50001, fedimint 8173/8174,
|
||||
// fedimint-gateway 8176/9737, gitea ssh 2222, lightning-stack
|
||||
// 8091/9738/10010, lnd 9735/10009/18080, netbird 3478/8086/8087,
|
||||
// pine TLS 10381 + the three voice ports (10200/10300/10400 — the
|
||||
// disclosed known gap), router SSDP/mDNS 1900/5353. Every one is a
|
||||
// deliberate, rationale-carrying exemption; the release-gate test
|
||||
// stage timed out that cycle, so the count here lagged at 17.
|
||||
assert_eq!(
|
||||
exempt.len(),
|
||||
28,
|
||||
"unauthenticated port set changed — review before updating this count: {exempt:?}"
|
||||
);
|
||||
// Reviewed 2026-09-30: lightning-stack's three retired endpoints
|
||||
// disappeared; Cuprate restricted RPC moved from none to gate-open.
|
||||
// Compare exact endpoints, not just a count that can hide substitutions.
|
||||
let expected = [
|
||||
("bitcoin-core", 8333), ("bitcoin-knots", 8333),
|
||||
("core-lightning", 9736), ("core-lightning", 9835),
|
||||
("cuprate", 18183), ("electrumx", 50001),
|
||||
("fedimint", 8173), ("fedimint", 8174),
|
||||
("fedimint-gateway", 8176), ("fedimint-gateway", 9737),
|
||||
("gitea", 2222), ("lnd", 9735), ("lnd", 10009), ("lnd", 18080),
|
||||
("netbird", 8087), ("netbird-server", 3478), ("netbird-server", 8086),
|
||||
("phoenixd", 9740), ("pine", 10381), ("pine-openwakeword", 10400),
|
||||
("pine-piper", 10200), ("pine-whisper", 10300),
|
||||
("router", 1900), ("router", 5353),
|
||||
].into_iter().map(|(id, port)| (id.to_owned(), port)).collect::<Vec<_>>();
|
||||
assert_eq!(exempt, expected, "unauthenticated endpoint set changed; review each exemption");
|
||||
}
|
||||
|
||||
/// `auth: open` ports are served by the gate WITHOUT its login challenge,
|
||||
/// so they are the second unauthenticated-by-the-gate surface and get the
|
||||
/// same review guard as `auth: none`. Each one must be an app that
|
||||
/// enforces a real login of its own.
|
||||
/// same review guard as `auth: none`. Each must enforce its own login or
|
||||
/// have an explicitly reviewed public protocol purpose.
|
||||
#[test]
|
||||
fn gate_open_ports_are_all_accounted_for() {
|
||||
let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps");
|
||||
@@ -1801,6 +1796,8 @@ app:
|
||||
}
|
||||
}
|
||||
open.sort();
|
||||
// Cuprate 18090 is its deliberately public restricted RPC subset;
|
||||
// unrestricted node-control RPC remains container-loopback-only.
|
||||
// Gitea 3001 (git clients speak basic-auth, not browser cookies),
|
||||
// BTCPay 23000 (checkout/invoice/webhook endpoints must be reachable
|
||||
// by anonymous payers), and — since the v1.8.7 platform round — the
|
||||
@@ -1812,11 +1809,12 @@ app:
|
||||
open,
|
||||
vec![
|
||||
("btcpay-server".to_string(), 23000u16),
|
||||
("cuprate".to_string(), 18090u16),
|
||||
("gitea".to_string(), 3001u16),
|
||||
("nginx-proxy-manager".to_string(), 8081u16),
|
||||
("tailscale".to_string(), 8240u16),
|
||||
],
|
||||
"gate-open port set changed — every entry must be an app with its own login"
|
||||
"gate-open port set changed — review login or intentional public protocol purpose"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -310,59 +310,7 @@ impl PodmanClient {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Honour the manifest's protocol (default tcp). netbird's STUN port
|
||||
// is 3478/udp; forcing tcp here would publish the wrong protocol and
|
||||
// silently break relay discovery.
|
||||
let protocol = match port.protocol.to_ascii_lowercase().as_str() {
|
||||
"udp" => "udp",
|
||||
"sctp" => "sctp",
|
||||
_ => "tcp",
|
||||
};
|
||||
// Effective bind. A gated port with no declared bind would
|
||||
// publish 0.0.0.0 — the app would own every host address, which
|
||||
// is both the exposure itself and the reason the daemon's app
|
||||
// gate cannot bind those addresses to authenticate them. Pin it
|
||||
// to loopback so the gate can take the external addresses.
|
||||
//
|
||||
// Doing it HERE, at container creation, is the point: the pin and
|
||||
// the gate's takeover then both come from the daemon and cannot
|
||||
// disagree. The earlier attempt put this decision in manifest
|
||||
// data instead, and a node whose manifests lagged the binary
|
||||
// published Bitcoin's loopback-only RPC across the LAN
|
||||
// (test node, 2026-08-03).
|
||||
//
|
||||
// A port that already declares a bind is never overridden — that
|
||||
// is exactly what keeps `bind: 127.0.0.1` ports host-local and
|
||||
// leaves `auth: none` protocol ports (LND gRPC/REST, electrum)
|
||||
// published as they are, so remote wallets keep working.
|
||||
// NOTE: the daemon deliberately does NOT rewrite this. Pinning a
|
||||
// published port to loopback is how an app hands its external
|
||||
// addresses to the gate, but it belongs in the manifest, not in
|
||||
// daemon-side inference:
|
||||
//
|
||||
// * `bind` is already honoured by every publish path (here and
|
||||
// in package::install), so a manifest edit needs no code.
|
||||
// * inference here would cover only THIS path — proven on
|
||||
// a test node, where a recreate went through another one and
|
||||
// the pin never applied.
|
||||
// * and inferring from an ABSENT field is what republished
|
||||
// Bitcoin's loopback RPC across the LAN, and came within one
|
||||
// container-recreate of pinning LND's gRPC/REST and breaking
|
||||
// every remote wallet.
|
||||
//
|
||||
// So the migration ships as `bind: 127.0.0.1` in the signed
|
||||
// catalog. Verified 2026-08-03 that a disk-only manifest edit is
|
||||
// overridden by the catalog, which is precisely why the catalog is
|
||||
// the right and only place to carry it.
|
||||
let mut mapping = serde_json::json!({
|
||||
"container_port": port.container,
|
||||
"host_port": port.host,
|
||||
"protocol": protocol,
|
||||
});
|
||||
if !port.bind.is_empty() {
|
||||
mapping["host_ip"] = serde_json::json!(port.bind);
|
||||
}
|
||||
port_mappings.push(mapping);
|
||||
port_mappings.push(podman_publish_mapping(port));
|
||||
}
|
||||
|
||||
let mut mounts = Vec::new();
|
||||
@@ -751,6 +699,25 @@ pub fn image_uses_insecure_registry(image: &str) -> bool {
|
||||
.is_some_and(|host| INSECURE_REGISTRY_HOSTS.contains(&host))
|
||||
}
|
||||
|
||||
// Keep the explicitly declared bind and transport identical to Quadlet. The
|
||||
// app gate owns external listeners; container publication must not bypass it.
|
||||
fn podman_publish_mapping(port: &crate::manifest::PortMapping) -> serde_json::Value {
|
||||
let protocol = match port.protocol.to_ascii_lowercase().as_str() {
|
||||
"udp" => "udp",
|
||||
"sctp" => "sctp",
|
||||
_ => "tcp",
|
||||
};
|
||||
let mut mapping = serde_json::json!({
|
||||
"container_port": port.container,
|
||||
"host_port": port.host,
|
||||
"protocol": protocol,
|
||||
});
|
||||
if !port.bind.is_empty() {
|
||||
mapping["host_ip"] = serde_json::json!(port.bind);
|
||||
}
|
||||
mapping
|
||||
}
|
||||
|
||||
fn podman_network_settings(
|
||||
network: Option<&str>,
|
||||
network_policy: &str,
|
||||
@@ -1110,6 +1077,15 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portainer_manifest_keeps_private_network_and_loopback_api_publication() {
|
||||
let m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
|
||||
assert_eq!(podman_network_settings(m.app.container.network.as_deref(), &m.app.security.network_policy), ("slirp4netns", None));
|
||||
assert_eq!(podman_publish_mapping(&m.app.ports[0]), serde_json::json!({
|
||||
"container_port": 9000, "host_port": 9000, "protocol": "tcp", "host_ip": "127.0.0.1"
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn podman_network_settings_uses_networks_map_for_custom_networks() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -618,6 +618,28 @@ impl DockerRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
// Docker is a development fallback. Refuse Podman-only network modes instead
|
||||
// of silently installing a different topology; still honor binds for other apps.
|
||||
fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result<Vec<String>> {
|
||||
let network = manifest.app.container.network.as_deref()
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or(&manifest.app.security.network_policy);
|
||||
if matches!(network, "slirp4netns" | "pasta") {
|
||||
anyhow::bail!("this app requires rootless Podman networking ({network})");
|
||||
}
|
||||
let mut args = Vec::new();
|
||||
if !network.is_empty() && network != "isolated" {
|
||||
args.extend(["--network".to_owned(), network.to_owned()]);
|
||||
}
|
||||
for port in &manifest.app.ports {
|
||||
let host = port.host.checked_add(offset).context("published port offset overflow")?;
|
||||
let bind = if port.bind.is_empty() { String::new() } else { format!("{}:", port.bind) };
|
||||
let protocol = if port.protocol.is_empty() { "tcp" } else { &port.protocol };
|
||||
args.extend(["-p".to_owned(), format!("{bind}{host}:{}/{protocol}", port.container)]);
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ContainerRuntime for DockerRuntime {
|
||||
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> {
|
||||
@@ -657,25 +679,7 @@ impl ContainerRuntime for DockerRuntime {
|
||||
cmd.arg("--read-only");
|
||||
}
|
||||
|
||||
match manifest.app.security.network_policy.as_str() {
|
||||
"host" => {
|
||||
cmd.arg("--network").arg("host");
|
||||
}
|
||||
"isolated" => {
|
||||
// Docker uses bridge network by default
|
||||
}
|
||||
_ => {
|
||||
cmd.arg("--network")
|
||||
.arg(&manifest.app.security.network_policy);
|
||||
}
|
||||
}
|
||||
|
||||
// Port mappings with offset
|
||||
for port in &manifest.app.ports {
|
||||
let host_port = port.host + port_offset;
|
||||
cmd.arg("-p")
|
||||
.arg(format!("{}:{}", host_port, port.container));
|
||||
}
|
||||
cmd.args(docker_network_and_ports(manifest, port_offset)?);
|
||||
|
||||
// Volumes
|
||||
for volume in &manifest.app.volumes {
|
||||
@@ -1035,6 +1039,17 @@ mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn docker_fallback_rejects_rootless_only_topology_and_preserves_bind_protocol() {
|
||||
let mut m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
|
||||
assert!(docker_network_and_ports(&m, 0).is_err());
|
||||
m.app.container.network = Some("bridge".into());
|
||||
m.app.ports[0].protocol = "udp".into();
|
||||
let args = docker_network_and_ports(&m, 1).unwrap();
|
||||
assert_eq!(args, vec!["--network", "bridge", "-p", "127.0.0.1:9001:9000/udp"]);
|
||||
assert!(docker_network_and_ports(&m, u16::MAX).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_container_classifier_covers_podman5_phrasings() {
|
||||
// podman 5.x `inspect` phrasing for a missing container.
|
||||
|
||||
+5
-2
@@ -14,6 +14,8 @@ doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
|
||||
|
||||
## Next release after 1.8.21 — reported 2026-09-30
|
||||
|
||||
Release status and acceptance gates: [execution checklist](next-release-20260930.md).
|
||||
|
||||
- [ ] **Release blocker: Gitea → Portainer repository integration.** Diagnose
|
||||
smart-HTTP reachability from Portainer's actual request namespace, then provide
|
||||
one declarative topology and idempotent migration for fresh installs and
|
||||
@@ -36,8 +38,9 @@ doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
|
||||
documentation establishes a UI. Verify Bitcoin/Mempool requirements, decide
|
||||
whether an existing first-class relay meets Angor's requirements or a relay
|
||||
must be packaged with the indexer, and use the Angor logo from angor.io for its
|
||||
service icon. The mentioned setup-documentation link was not included; asked
|
||||
the operator for it. Include this service in the next-release scope.
|
||||
service icon. Official current deployment documentation located and reviewed: stock Mempool
|
||||
plus an optional strfry relay. Reuse of existing indexing services is the
|
||||
proposed approach; implementation and acceptance remain pending. Include this service in the next-release scope.
|
||||
|
||||
- [ ] **App lifecycle: keep installed apps visible through restart and hard
|
||||
refresh; gate embedded/browser launches on actual web and listener readiness.**
|
||||
|
||||
@@ -290,3 +290,23 @@ app:
|
||||
Validate with `scripts/validate-app-manifest.sh` and regenerate the catalog
|
||||
with `scripts/generate-app-catalog.py` (drift-checked in CI by
|
||||
`scripts/check-app-catalog-drift.py`).
|
||||
|
||||
### Persistent-state backup for runtime repairs
|
||||
|
||||
`app.backup_before_runtime_change: true` opts an app into a stopped-state snapshot
|
||||
before reconciliation changes a service’s network, ports, security settings,
|
||||
command or health configuration. Image-upgrade backup policy remains separate. The orchestrator
|
||||
archives writable persistent bind mounts under the node data directory, collapses
|
||||
nested mounts, excludes the runtime Podman socket, and preserves the previous
|
||||
Quadlet definition for rollback. Named volumes, outside-data-root state and
|
||||
symlinked mount roots fail closed rather than silently producing an incomplete
|
||||
backup. A failed snapshot resumes the original service and leaves migration
|
||||
pending. Private archives are retained under `migration-backups/`; fresh installs
|
||||
and unchanged runtime configurations do not create migration snapshots.
|
||||
|
||||
Catalog generation preserves the previously published base manifest for older
|
||||
daemons and puts opted-in network changes in a signed `manifest_variants` entry
|
||||
requiring `runtime-migration-backup-v1`. New runtimes select only variants whose
|
||||
complete requirement list they support. Supply `BASE_CATALOG` when generating
|
||||
against a different reviewed pre-migration catalog. This keeps catalog refresh
|
||||
from applying a migration before the matching OTA code is installed.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# Same-node Gitea sources in Portainer
|
||||
|
||||
Status: root cause reproduced and network repair verified in disposable and actual
|
||||
production Portainer instances; final migration integration and release acceptance remain in progress.
|
||||
This change belongs to the next signed catalog, OTA and ISO. It does not modify
|
||||
published 1.8.21 artifacts.
|
||||
|
||||
## Confirmed cause
|
||||
|
||||
On the affected X250, Gitea 1.27.3 and Portainer 2.45.0 run in rootless Podman
|
||||
5.4.2, managed by user Quadlet services. Gitea publishes HTTP on loopback and the
|
||||
Archipelago app gate serves its public port. Gitea's public ROOT_URL already
|
||||
matches that gate URL.
|
||||
|
||||
Portainer had no explicit network selection and Podman selected pasta. Its
|
||||
network namespace contained the host's LAN address. A Git request to that same
|
||||
LAN address therefore reached Portainer's namespace rather than the host gate:
|
||||
connection refused before authentication. The exact smart-HTTP request from the
|
||||
host returned 200 with `application/x-git-upload-pack-advertisement`. From
|
||||
Portainer's actual namespace the LAN request was refused, while its host mapping
|
||||
returned a Git advertisement and the expected branch tip. Direct container-IP
|
||||
requests timed out. Container health and host-only HTTP checks missed the defect.
|
||||
|
||||
A disposable Portainer using `slirp4netns` successfully created a Source through
|
||||
Portainer's own API, using the original LAN clone URL. Returning that fixture to
|
||||
pasta reproduced the refusal; recreating with slirp repaired it while preserving
|
||||
its account and saved Source. Restart also passed. The requested branch tip and
|
||||
Compose file were read from that actual Portainer network namespace. No user
|
||||
stack was deployed. Deployment addresses and repository details are kept outside
|
||||
this public record.
|
||||
|
||||
## Source changes
|
||||
|
||||
- Declare Portainer's rootless `slirp4netns` mode in its manifest. No shared static
|
||||
container IP, host networking, all-interface backend publication or auth bypass.
|
||||
- Keep Gitea's loopback HTTP backend and gate port; machine Git uses Gitea's
|
||||
authentication. Remove obsolete port-3000 nginx metadata/template and the old
|
||||
best-effort installer commands which silently rewrote app.ini and falsely
|
||||
claimed success. Gitea owns first-run setup and operator configuration.
|
||||
- Gitea SSH also failed before authentication: OpenSSH logged a denied
|
||||
`chroot("/var/empty")` because the manifest dropped `SYS_CHROOT`. Add that
|
||||
specific sandbox capability and reconcile security-directive changes. A
|
||||
disposable fixture then passed SSH clone/push with host-key checking enabled.
|
||||
- Existing Quadlet reconciliation applies Network= drift. Record a durable
|
||||
pending restart before updating the unit and clear it only after a successful
|
||||
restart, so failed reloads/restarts and management interruptions retry.
|
||||
- Detect explicit rootless network-mode drift in the older Podman runtime too.
|
||||
Unspecified networks do not trigger inferred changes to unrelated apps.
|
||||
- Portainer and Gitea opt into `backup_before_runtime_change`. Before recreation, gracefully
|
||||
stop the app and archive its writable persistent bind mounts, including nested
|
||||
Compose state, once each. Runtime sockets are excluded. Save the previous
|
||||
Quadlet definition, where present. Archives live under the node data directory's
|
||||
private `migration-backups/<id>/` directory; state is never deleted. Backup
|
||||
failures resume the original service and fail the migration visibly.
|
||||
- Keep Podman API and Quadlet bind/network behavior covered by actual-manifest
|
||||
tests. Docker remains a development fallback: it now preserves bind/protocol
|
||||
declarations and rejects Podman-only networking instead of silently changing it.
|
||||
|
||||
## Operator use and diagnostics
|
||||
|
||||
Use Gitea's advertised HTTP(S) clone URL in Portainer Sources, with the Gitea
|
||||
username and token in the credential fields. On first-run Gitea setup, the public
|
||||
base URL must match the origin opened through Archipelago (including its port).
|
||||
Keep a deliberately configured HTTPS/domain origin when one exists. Do not use a
|
||||
container IP or put a token into the URL. A private repository requires repository
|
||||
read permission. A successful Source check fetches Git refs; it does not deploy
|
||||
a stack or establish that a Compose build uses a desired application revision.
|
||||
|
||||
`scripts/check-portainer-git-source.py` calls Portainer's own read-only Source
|
||||
connection test. Supply a private mode-600 JSON credential file containing
|
||||
`api_key` or `jwt`, and optionally `git: {username, password}`. Pass
|
||||
`--portainer-url`, `--repository-url` and `--credentials-file`. It does not create
|
||||
Sources or stacks and prints no credentials or raw server errors. It distinguishes
|
||||
Portainer login/API failures from Git connection refusal, timeout, DNS/TLS
|
||||
failure, HTML/login interception and repository authentication failure. TLS
|
||||
verification stays enabled and API redirects are refused.
|
||||
|
||||
## Upgrade and rollback
|
||||
|
||||
The signed catalog embeds manifests and overrides installed disk copies.
|
||||
Capability-gated manifest variants keep the previous Portainer manifest as the
|
||||
base for older daemons; only daemons supporting `runtime-migration-backup-v1`
|
||||
select the network repair. This prevents catalog refresh from triggering an
|
||||
unbacked recreation before the OTA is installed. A disk
|
||||
edit alone cannot deliver this fix. Publish the matching catalog with the tested
|
||||
runtime, then verify the generated unit, actual network mode and Source API.
|
||||
Expect a Portainer interruption while the snapshot and recreation run; duration
|
||||
depends on its saved state size.
|
||||
The Portainer routing repair does not require a Gitea configuration change.
|
||||
The separate SSH capability repair does recreate Gitea, preserving and snapshotting
|
||||
both data/config mounts first. Supported systemd drop-in overrides remain intact.
|
||||
|
||||
Keep the previous trusted catalog/runtime for rollback. Restore that catalog
|
||||
before restoring the saved `previous.container`, reloading user systemd and
|
||||
starting Portainer; otherwise reconciliation will correctly reapply the new
|
||||
manifest. The archive is a stopped-state emergency backup, not an instruction to
|
||||
roll back a live database automatically. Restore it only with Portainer stopped
|
||||
and after preserving any newer state. Do not replace Gitea data/config, keys,
|
||||
repositories or the production Portainer database with disposable test data.
|
||||
|
||||
## Validation and remaining gates
|
||||
|
||||
- Disposable X250 Portainer Source API: old mode refuses; repaired mode succeeds;
|
||||
saved account/Source survive recreation; restart succeeds.
|
||||
- Invalid Git credentials produce a repository-authentication error, distinct
|
||||
from TCP refusal. Requested branch and Compose file read from Portainer context.
|
||||
- Combined backend suite including the reviewed paid-download PRs and catalog
|
||||
rollout guard: 1,605 passed, zero failed, four existing ignored
|
||||
tests, including stopped-state archive round trips and failure preservation. Container runtime suite: 78 passed.
|
||||
Five diagnostic regression tests passed; catalog regeneration is idempotent
|
||||
and the generated catalog has zero manifest metadata drift.
|
||||
- Fresh managed Gitea and Portainer fixtures: authenticated private Source
|
||||
creation, invalid-token rejection, workstation clone/push and exact branch
|
||||
lookup from Portainer namespace passed. LFS batch/upload/download and OCI
|
||||
registry authentication/blob/manifest round trips passed. Desktop and mobile
|
||||
login/private-repository/assets/hard-refresh checks passed.
|
||||
- Still required before release: live automatic migration with the new runtime,
|
||||
snapshot/rollback verification and reversed install-order acceptance,
|
||||
lifecycle/reboot convergence, and signed-catalog delivery to the existing app.
|
||||
Record LFS/registry/SSH/browser checks and actual hardware/runtime coverage.
|
||||
|
||||
### Affected X250: production routing repair verified
|
||||
|
||||
Applied the tested rootless network setting to the actual installed Portainer
|
||||
through a persistent Quadlet drop-in, after gracefully stopping it and creating a
|
||||
private archive of its database and Compose directory. Compared the archive
|
||||
against the stopped original before changing configuration; retained the original
|
||||
unit and a rollback path. A verification helper initially compared mount list
|
||||
order rather than mount identity and safely rolled back; the corrected check
|
||||
compares sorted source/destination/write-mode tuples and passed.
|
||||
|
||||
The actual production Portainer namespace reproduced connection refusal before
|
||||
repair. After repair it received a Git smart-HTTP advertisement, fetched the
|
||||
requested branch at its current tip and read its Compose file. Repeating these
|
||||
checks after restarting the managed Portainer service passed. All original data
|
||||
and socket mounts and the loopback-only HTTP binding are retained. Gitea,
|
||||
Bitcoin and the wallet container IDs and start times were unchanged. No stack
|
||||
was deployed and no repository credential was changed.
|
||||
|
||||
This establishes the routing repair on the affected hardware. A logged-in
|
||||
production Portainer Source UI/API acceptance has not yet been recorded; the
|
||||
corresponding API checks passed on disposable instances as documented above.
|
||||
The installed-node drop-in persists through service restart/reboot but is not the
|
||||
fleet delivery mechanism. Automatic migration and signed catalog/OTA/ISO release
|
||||
validation remain pending; the source manifest declares the same network mode.
|
||||
Private deployment addresses, branch details and state archives are not committed.
|
||||
|
||||
### Managed automatic migration and archive restore
|
||||
|
||||
The new runtime candidate migrated an existing managed fixture from pasta to
|
||||
slirp without a manual unit edit. It preserved the account, saved Source and
|
||||
mount set, saved a private stopped-state archive plus the previous unit, restored
|
||||
Source API access, and cleared the pending restart marker. A management-service
|
||||
restart preserved the new container identity/start time and did not create
|
||||
another archive. The archive extracted into an isolated scratch directory and
|
||||
compared cleanly, including the database and Compose directory. Rootless archive
|
||||
ownership required scratch cleanup inside `podman unshare`; no production data
|
||||
was overwritten. Native Bitcoin and LND IDs/start times remained unchanged.
|
||||
|
||||
This optimized candidate predates the final bounded backup-retry guard; that
|
||||
latest source passed the isolated 1,605-test suite and must also be exercised in
|
||||
the final release build. A fixture-only systemd start failure was then injected during a security
|
||||
directive migration. The failure retained the durable restart marker. After
|
||||
removing the injected failure, the reconciler restarted the service without a
|
||||
manual container start, restored Source API access and cleared the marker.
|
||||
Reverse install order, final-build retry-budget coverage, full reboot and
|
||||
signed delivery remain open.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Next OTA and raw ISO after 1.8.21
|
||||
|
||||
**Status: implementation and acceptance in progress; NOT ready to release.**
|
||||
|
||||
This is the consolidated execution checklist for the operator's chat requests.
|
||||
A targeted node repair is not completion of the release. Finish the remaining
|
||||
acceptance gates, preserve live wallets and app data, and publish both artifacts
|
||||
through git and ngit. No universal absence of future failures is claimed.
|
||||
|
||||
## Changes already shipped in 1.8.21 or earlier
|
||||
|
||||
Keep these fixes in the next build and include relevant regressions:
|
||||
|
||||
- Mempool image/catalog version agreement and update-button behavior.
|
||||
- Minibits integration; Framework automatic LND startup and safe unavailable
|
||||
balances. Framework incident closed with operator acceptance.
|
||||
- Shorter, single-column ecash backup messaging.
|
||||
- AIUI transparent background on desktop/mobile.
|
||||
- Cashu paid-file keyset/mint/error/refund corrections, with live purchases.
|
||||
- mempool.space explorer fallback, preserving local/custom explorer settings.
|
||||
- Bitcoin install pruning choice and matching automatic-pruning behavior.
|
||||
- Friendly Bitcoin warmup and LND install/start/sync waiting states.
|
||||
- Raw ISO publishing and upload support.
|
||||
|
||||
The Primal automatic LNURL comment problem was traced to sender behavior and
|
||||
Minibits metadata. The user accepted clearing the sender's automatic comment;
|
||||
no unsupported local metadata rewrite or wallet-identity replacement is planned.
|
||||
See the Framework incident and 1.8.21 execution records for evidence/limits.
|
||||
|
||||
## New release scope and gates
|
||||
|
||||
| Task | Implemented/verified | Remaining before release |
|
||||
| --- | --- | --- |
|
||||
| X250 Bitcoin picker | Inline choices; actual Chromium kiosk selection, readability and pruning layout passed | Include in final UI/build checks |
|
||||
| App disappearance/readiness | Durable inventory and safe lifecycle repair; delayed HTTP and desktop/mobile hard-refresh checks passed | Final lifecycle/reboot gate on candidate |
|
||||
| X250 GitWorkshop/Nginx | Missing build contexts restored, dependency/build checks and live UI passed; Nginx slow pull diagnosed; truthful progress label | Verify both artifact payloads contain all build contexts |
|
||||
| PRs 161/162 | Reviewed, repaired, merged/closed normally; combined regression suite passed | Candidate funded Tor-only purchase, change and Files acceptance |
|
||||
| Gitea/Portainer | Root cause confirmed; source network/backup/retry/catalog changes; real X250 routing repair and restart verified; private Git, SSH, LFS, registry and browser fixture checks passed | Automatic migration, scratch restore and failure retry passed; still need reverse install order, reboot convergence, production Source API/UI, signed delivery |
|
||||
| Angor headless store service | Current official guide reviewed: standard Mempool with optional strfry relay | Implement using app-development docs; safe dependency/relay integration; official logo; API and lifecycle acceptance |
|
||||
|
||||
Durable payment receipts after a lost seller response remain a separately
|
||||
recorded design follow-up. Preserve the truthful unconfirmed-refund warning and
|
||||
prevent duplicate automatic payment; do not describe an unconfirmed refund as
|
||||
completed. See PR review for the accepted scope and coverage limits.
|
||||
|
||||
## Final release checklist
|
||||
|
||||
- [ ] Finish all new-scope implementation and specific acceptance above.
|
||||
- [ ] Remove disposable fixtures and temporary test overrides; verify native
|
||||
Bitcoin/LND identity and start-state baselines remain protected.
|
||||
- [ ] Commit and push completed source changes to git and ngit.
|
||||
- [ ] Run final backend/UI/regression/release gates on the final source; inspect
|
||||
skipped tests and report actual hardware/runtime coverage.
|
||||
- [ ] Prepare compatible signed app catalog; old runtimes must not apply a
|
||||
migration before they have backup/recovery support.
|
||||
- [ ] Version/changelog and OTA payload prepared, validated and signed by user.
|
||||
- [ ] Raw ISO built; payload hashes/content verified; installer boot tested.
|
||||
- [ ] User signs ISO checksums; publish OTA and ISO plus verification files on
|
||||
git and ngit; independently read back hashes and update discovery.
|
||||
- [ ] Provide LAN scp command for the new raw ISO.
|
||||
|
||||
Latest backend source verification: 1,605 passed, zero failed, four existing
|
||||
ignored tests. This is one layer of evidence, not a substitute for live gates.
|
||||
@@ -6,8 +6,8 @@ Reviewed both open PRs from the repository pull-request list: [#161](https://sou
|
||||
and [#162](https://source.archipelago-foundation.org/lfg2025/archy/pulls/162).
|
||||
Both branches were updated from main, repaired and tested independently and
|
||||
together. Their existing remote branches were advanced without rewriting the
|
||||
contributors' history. They remain open for integration into the release after
|
||||
1.8.21; no reviewed code was merged into main or deployed to a live wallet.
|
||||
contributors' history. Both were subsequently merged and closed and are now
|
||||
integrated on main. Candidate live-wallet acceptance remains pending.
|
||||
The signed 1.8.21 artifacts are unchanged.
|
||||
|
||||
| Candidate | Tested commit | Isolated backend result |
|
||||
@@ -95,8 +95,8 @@ Logs on the development box:
|
||||
|
||||
## Next-release acceptance and limits
|
||||
|
||||
- Integrate the reviewed branches and repeat the release gates against the
|
||||
final release commit if additional code changes land.
|
||||
- Both reviewed branches are integrated on main alongside the lifecycle fixes.
|
||||
Repeat release gates against the final release commit after remaining changes.
|
||||
- Perform funded peer-to-peer acceptance on the candidate build, including a
|
||||
Tor-only purchase and a purchase requiring change, before the next release.
|
||||
The new review branches were not deployed to funded live wallets here.
|
||||
@@ -109,8 +109,8 @@ Logs on the development box:
|
||||
ordinary write failures and truncated input are tested to clean up. The final
|
||||
filename is published only after complete input, and existing files remain
|
||||
protected.
|
||||
- The separately reported X250 kiosk version-selector rendering issue remains
|
||||
open in `TODO.md` and requires validation on the actual kiosk.
|
||||
- The separately reported X250 kiosk selector is fixed and verified on the
|
||||
actual kiosk; see the lifecycle evidence and consolidated release checklist.
|
||||
|
||||
## Authorized merge — 2026-09-30
|
||||
|
||||
@@ -122,6 +122,5 @@ Gitea normal merges completed and read-back confirmed `merged=true`, `state=clos
|
||||
- #161: `3daea6623be3e2c7222101b8e6ac411423c7e16c`.
|
||||
- #162: `b02ba4100d922dd1b75c6a78121ef446c2159a54`.
|
||||
|
||||
Local next-release lifecycle work will be integrated with this main before the
|
||||
next release. Funded release acceptance and the documented delivery-receipt
|
||||
Local next-release lifecycle work was integrated with main at `d69e8452`. Funded release acceptance and the documented delivery-receipt
|
||||
limitation remain as recorded above; merging does not claim a new release.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Repair and release execution — 2026-09-29
|
||||
|
||||
**Status: IN PROGRESS. Do not publish an OTA or ISO until the release gates pass.**
|
||||
**Status: 1.8.21 PUBLISHED — see the completion record at the end.**
|
||||
|
||||
The next release is tracked in [the current execution checklist](next-release-20260930.md).
|
||||
The dated entries below preserve the investigation history.
|
||||
|
||||
User requires all tasks completed and tested on the development box before the
|
||||
next OTA and raw ISO. Passing unit tests alone does not establish live correctness.
|
||||
@@ -52,8 +55,8 @@ next OTA and raw ISO. Passing unit tests alone does not establish live correctne
|
||||
- [x] Live waiting/UI verified on dev; recovery covered by deterministic tests.
|
||||
- [x] Framework operator acceptance and authorization to release recorded.
|
||||
- [x] Release version/changelog, catalog/image implications, signing prepared.
|
||||
- [ ] Signed OTA built, tested, published to git and ngit.
|
||||
- [ ] Raw ISO built, boot-tested, signed and published; download command supplied.
|
||||
- [x] Signed OTA built, tested, published to git and ngit.
|
||||
- [x] Raw ISO built, boot-tested, signed and published; download command supplied.
|
||||
|
||||
Tests must not wipe/recreate wallets, prune the operator's existing full chain,
|
||||
or claim that arbitrary failures can never happen. Record material gaps before
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Gitea iframe proxy — strips X-Frame-Options so Gitea works in Archipelago iframe.
|
||||
# Gitea container binds to port 3001, this proxy listens on port 3000 (the public port).
|
||||
# Deployed to /etc/nginx/conf.d/gitea-iframe.conf
|
||||
server {
|
||||
listen 3000;
|
||||
server_name _;
|
||||
client_max_body_size 1G;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,11 @@ def load_catalog(path: Path) -> dict[str, dict[str, Any]]:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
manifest = entry.get("manifest")
|
||||
for variant in reversed(entry.get("manifest_variants", [])):
|
||||
requires = variant.get("requires", [])
|
||||
if requires and all(cap == "runtime-migration-backup-v1" for cap in requires):
|
||||
manifest = variant.get("manifest")
|
||||
break
|
||||
if isinstance(manifest, dict) and isinstance(manifest.get("app"), dict):
|
||||
# Embedded manifest: compare against the same fields the disk
|
||||
# manifests expose, plus the entry's own version.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test Git from Portainer's server context without creating a Source or stack."""
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import socket
|
||||
import stat
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
def classify(error):
|
||||
text = error.lower()
|
||||
for category, patterns in (
|
||||
('connection-refused', ('connection refused',)),
|
||||
('dns-failure', ('no such host', 'name resolution', 'server misbehaving')),
|
||||
('timeout', ('timeout', 'timed out', 'deadline exceeded')),
|
||||
('tls-failure', ('x509:', 'certificate', 'tls handshake')),
|
||||
('proxy-or-login-interception', ('text/html', '<html', '<!doctype', 'unexpected content-type', 'invalid pkt-len')),
|
||||
('repository-authentication', ('authentication required', 'authentication failed', 'authorization failed', '401', '403')),
|
||||
('repository-not-found-or-private', ('repository not found', '404')),
|
||||
):
|
||||
if any(pattern in text for pattern in patterns):
|
||||
return category
|
||||
return 'git-error'
|
||||
|
||||
|
||||
def safe_url(value):
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
if parsed.scheme not in ('http', 'https') or not parsed.hostname:
|
||||
raise ValueError('Use an HTTP(S) URL')
|
||||
if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment:
|
||||
raise ValueError('URLs must not contain credentials, query parameters or fragments')
|
||||
return value.rstrip('/')
|
||||
|
||||
|
||||
def private_json(path):
|
||||
path = pathlib.Path(path)
|
||||
if stat.S_IMODE(path.stat().st_mode) & 0o077:
|
||||
raise ValueError('Credential file must be private (chmod 600)')
|
||||
return json.loads(path.read_text())
|
||||
|
||||
|
||||
def check(base, repository, credentials, opener=None):
|
||||
base, repository = safe_url(base), safe_url(repository)
|
||||
# JWT/API keys and Git credentials travel in headers/body, never URLs or logs.
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
if credentials.get('api_key'):
|
||||
headers['X-API-Key'] = credentials['api_key']
|
||||
elif credentials.get('jwt'):
|
||||
headers['Authorization'] = 'Bearer ' + credentials['jwt']
|
||||
else:
|
||||
raise ValueError('Credential file needs api_key or jwt')
|
||||
payload = {'url': repository, 'tlsSkipVerify': False, 'interval': '5m'}
|
||||
if credentials.get('git'):
|
||||
payload['authentication'] = credentials['git']
|
||||
request = urllib.request.Request(base + '/api/gitops/sources/test',
|
||||
data=json.dumps(payload).encode(), headers=headers)
|
||||
opener = opener or urllib.request.build_opener(NoRedirect())
|
||||
try:
|
||||
with opener.open(request, timeout=45) as response:
|
||||
result = json.load(response)
|
||||
except urllib.error.HTTPError as error:
|
||||
return {'success': False, 'category': 'portainer-authentication' if error.code in (401, 403) else 'portainer-api-error', 'http_status': error.code}
|
||||
except (urllib.error.URLError, TimeoutError, socket.timeout) as error:
|
||||
return {'success': False, 'category': 'portainer-api-' + classify(str(error))}
|
||||
except (ValueError, UnicodeError):
|
||||
return {'success': False, 'category': 'portainer-api-invalid-response'}
|
||||
if not isinstance(result, dict) or not isinstance(result.get('success'), bool):
|
||||
return {'success': False, 'category': 'portainer-api-invalid-response'}
|
||||
return {'success': result['success'], 'category': 'git-refs-readable' if result['success'] else classify(str(result.get('error', '')))}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--portainer-url', required=True, help='Reachable Portainer origin, without /api')
|
||||
parser.add_argument('--repository-url', required=True, help='The same clone URL entered in Portainer')
|
||||
parser.add_argument('--credentials-file', required=True, help='Mode 600 JSON: api_key or jwt; optional git: {username,password}')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
result = check(args.portainer_url, args.repository_url, private_json(args.credentials_file))
|
||||
except (OSError, ValueError):
|
||||
parser.exit(2, 'Invalid URL or private credential file; no credentials were printed.\n')
|
||||
print(json.dumps(result))
|
||||
return 0 if result['success'] else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -36,11 +36,15 @@ source "$ROOT/scripts/image-versions.sh"
|
||||
set +a
|
||||
|
||||
UPDATED="$(date -u +%Y-%m-%d)" OUT="$OUT" APPS_DIR="$ROOT/apps" \
|
||||
BASE_CATALOG="${BASE_CATALOG:-$ROOT/releases/app-catalog.json}" \
|
||||
PUBLIC_CATALOG="$ROOT/app-catalog/catalog.json" \
|
||||
EMBED_MANIFESTS="${EMBED_MANIFESTS:-1}" python3 - <<'PY'
|
||||
import glob
|
||||
import json, os
|
||||
|
||||
with open(os.environ["BASE_CATALOG"], encoding="utf-8") as baseline_file:
|
||||
baseline_entries = json.load(baseline_file).get("apps", {})
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
@@ -182,7 +186,17 @@ if os.environ.get("EMBED_MANIFESTS") and apps_dir:
|
||||
continue
|
||||
entry = apps.setdefault(str(app_id), {})
|
||||
entry.setdefault("version", str(app.get("version", "")) or "0")
|
||||
entry["manifest"] = _retarget_registry(data)
|
||||
rendered = _retarget_registry(data)
|
||||
if data["app"].get("backup_before_runtime_change"):
|
||||
baseline = baseline_entries.get(app_id, {}).get("manifest")
|
||||
if not baseline or baseline.get("app", {}).get("backup_before_runtime_change"):
|
||||
raise SystemExit(f"{app_id}: a pre-migration BASE_CATALOG manifest is required for old-node compatibility")
|
||||
entry["manifest"] = baseline
|
||||
entry["manifest_variants"] = [{
|
||||
"requires": ["runtime-migration-backup-v1"], "manifest": rendered,
|
||||
}]
|
||||
else:
|
||||
entry["manifest"] = rendered
|
||||
embedded += 1
|
||||
|
||||
# Multi-version support (docs/bitcoin-multi-version-design.md §3 Phase 1):
|
||||
|
||||
@@ -9,7 +9,12 @@ command -v setpriv >/dev/null
|
||||
sudo -n true || { echo 'Isolated backend tests require noninteractive sudo for systemd namespaces.' >&2; exit 1; }
|
||||
metadata=$(mktemp)
|
||||
trap 'rm -f "$metadata"' EXIT
|
||||
if ! cargo test --manifest-path "$REPO/core/Cargo.toml" -p archipelago --bin archipelago \
|
||||
case "${ARCHY_TEST_PACKAGE:-archipelago}" in
|
||||
archipelago) test_target=(-p archipelago --bin archipelago) ;;
|
||||
archipelago-container) test_target=(-p archipelago-container --lib) ;;
|
||||
*) echo 'Unsupported isolated test package' >&2; exit 2 ;;
|
||||
esac
|
||||
if ! cargo test --manifest-path "$REPO/core/Cargo.toml" "${test_target[@]}" \
|
||||
--locked --no-run --message-format=json --config 'profile.test.package.archipelago.opt-level=0' > "$metadata"; then
|
||||
python3 - "$metadata" <<'PYDIAG'
|
||||
import json,sys
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import pathlib
|
||||
import unittest
|
||||
import urllib.error
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
spec = importlib.util.spec_from_file_location('diagnostic', ROOT / 'scripts/check-portainer-git-source.py')
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
|
||||
|
||||
class Response(io.BytesIO):
|
||||
pass
|
||||
|
||||
|
||||
class FakeAPI:
|
||||
def __init__(self, result=None, error=None):
|
||||
self.result, self.error, self.request = result, error, None
|
||||
def open(self, request, timeout):
|
||||
self.request = request
|
||||
if self.error:
|
||||
raise self.error
|
||||
return Response(json.dumps(self.result).encode())
|
||||
|
||||
|
||||
class Diagnostics(unittest.TestCase):
|
||||
def test_server_context_credentials_not_in_url_and_tls_stays_enabled(self):
|
||||
api = FakeAPI({'success': True})
|
||||
result = m.check('http://localhost:9000', 'http://node:3001/user/repo',
|
||||
{'jwt': 'test-jwt', 'git': {'username': 'test-user', 'password': 'test-secret'}}, api)
|
||||
self.assertTrue(result['success'])
|
||||
self.assertEqual(api.request.full_url, 'http://localhost:9000/api/gitops/sources/test')
|
||||
payload = json.loads(api.request.data)
|
||||
self.assertFalse(payload['tlsSkipVerify'])
|
||||
self.assertEqual(payload['authentication']['password'], 'test-secret')
|
||||
self.assertNotIn('test-secret', json.dumps(result))
|
||||
|
||||
def test_failure_categories_from_source_api(self):
|
||||
cases = [('dial tcp: connection refused', 'connection-refused'),
|
||||
('lookup node: no such host', 'dns-failure'),
|
||||
('context deadline exceeded', 'timeout'),
|
||||
('unexpected content-type text/html', 'proxy-or-login-interception'),
|
||||
('authentication required', 'repository-authentication'),
|
||||
('x509: certificate signed by unknown authority', 'tls-failure'),
|
||||
('repository not found', 'repository-not-found-or-private')]
|
||||
for error, expected in cases:
|
||||
with self.subTest(error=error):
|
||||
result = m.check('http://localhost:9000', 'http://node/repo', {'jwt': 'test'}, FakeAPI({'success': False, 'error': error}))
|
||||
self.assertEqual(result['category'], expected)
|
||||
self.assertFalse(result['success'])
|
||||
|
||||
def test_portainer_auth_is_distinct_from_repository_auth(self):
|
||||
api = FakeAPI(error=urllib.error.HTTPError('http://localhost', 401, 'Unauthorized', {}, None))
|
||||
self.assertEqual(m.check('http://localhost', 'http://node/repo', {'jwt': 'bad'}, api)['category'], 'portainer-authentication')
|
||||
|
||||
def test_html_or_malformed_api_response_never_proves_git_success(self):
|
||||
for value in ({'status': 1}, {'success': 'true'}, [], '<html>login</html>'):
|
||||
self.assertFalse(m.check('http://localhost', 'http://node/repo', {'jwt': 'test'}, FakeAPI(value))['success'])
|
||||
|
||||
def test_credential_urls_rejected_before_request(self):
|
||||
for value in ('http://user:secret@node/repo', 'http://node/repo?token=secret', 'file:///data/repo'):
|
||||
with self.assertRaises(ValueError):
|
||||
m.check('http://localhost', value, {'jwt': 'test'}, FakeAPI())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user