Execute start_print, and never await it on the uplink task
The gateway can now run a cloud-dispatched print: download the job file from the presigned URL in the command, then hand it to the existing `upload_project_file`, which derives the filament mapping from the archive, transfers it over FTPS and starts it. ## The download refuses to be a weapon `src/uplink/download.rs` checks the HTTP status before reading a byte, because an expired presigned URL answers 403 with an XML error document and writing *that* to a printer as a project is the worst outcome available. It rejects a `Content-Length` over 256 MiB before reading, and enforces the cap while streaming as well, so a server that lies or omits the length cannot make a small gateway box buffer until the OOM killer arrives. A byte count that disagrees with `Content-Length` is an error: a `.3mf` is a zip, and a truncated one loses the central directory the printer needs. HTTPS only, no redirects, and the URL is stripped from error messages. `PrinterCommand` no longer derives `Debug` for the same reason - `dispatch` logs every command, and a presigned URL is a bearer credential until it expires. It prints as `<redacted>`. ## Why it is spawned `start_print` is the one command that cannot be answered inline, and the reason is not latency. Everything else here is an MQTT publish returning in milliseconds. This downloads tens of megabytes and then transfers them at a measured 0.2 MiB/s - about four minutes for a 50 MB project - and awaiting that inside the uplink's `select!` loop stops heartbeats and, far worse, stops telemetry for the duration. No telemetry is what makes spawning mandatory rather than tidy. The cloud now derives a job's state from the telemetry that follows a start, so a gateway that goes silent for four minutes during every job start withholds exactly the information the job depends on - while the backend times the command out after ten seconds and concludes the start failed. The job would be requeued onto a printer that was busy printing it. So the `ok` means "accepted, working on it", which is all a start reply was ever trusted to mean. `runs_for_minutes` is an exhaustive match rather than a `matches!`, so a new variant has to make that choice instead of defaulting to the stall. The gap this leaves is honest and recorded in the code: a failure after the ack has no `CommandResult` to travel on, because answering later needs an outbound channel this loop does not have. Until it does, the gateway's log is where the reason lives and the backend reaps the job. reqwest 0.12 with `rustls-tls-native-roots`, not 0.13: 0.13 pulls `webpki-root-certs` under CDLA-Permissive-2.0, which is not on the allow list, and `aws-lc` brings OpenSSL-licensed C. `cargo deny check licenses` passes. 54 tests.
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
//! Fetching a job file from the presigned URL a `start_print` command carries.
|
||||
//!
|
||||
//! The URL is attacker-influenceable in the worst case (a misconfigured or
|
||||
//! compromised cloud, a bad object store) and the body is held in memory
|
||||
//! because `upload_project_file` takes a `Vec<u8>`, so everything here is
|
||||
//! bounded: size, time, redirects and scheme.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// Hard ceiling on the body. A sliced `.3mf` is tens of megabytes (62 MB has
|
||||
/// been seen for a multi-plate project); 256 MiB is roughly four times that,
|
||||
/// so no honest job is refused, while a hostile URL cannot make a gateway on
|
||||
/// small hardware (a Pi-class box is the target) buffer its way into the OOM
|
||||
/// killer. Also well above anything a printer's own storage would take.
|
||||
pub const MAX_JOB_FILE_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// Whole-request budget, connect through last byte. 256 MiB at the slowest
|
||||
/// link worth supporting (~2 MiB/s) is just over two minutes, so 3 minutes
|
||||
/// covers the cap itself; anything slower than that is a stalled transfer, not
|
||||
/// a slow one. Presigned URLs live for minutes, so a longer wait would only
|
||||
/// be spent on a URL about to expire. Note `dispatch` runs inside the uplink
|
||||
/// loop, so this is also the longest a download can hold up heartbeats.
|
||||
pub const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
|
||||
/// Separate and short: an unreachable host should fail in seconds, not use
|
||||
/// up the whole budget above.
|
||||
pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Downloads `url` fully into memory, or says why not.
|
||||
///
|
||||
/// The error is a `String` naming the failure; the caller prefixes the stage.
|
||||
pub async fn fetch_job_file(url: &str) -> Result<Vec<u8>, String> {
|
||||
fetch_job_file_capped(url, MAX_JOB_FILE_BYTES).await
|
||||
}
|
||||
|
||||
/// Same as [`fetch_job_file`] with the cap as a parameter, so tests can
|
||||
/// exercise the limit without serving 256 MiB.
|
||||
pub async fn fetch_job_file_capped(url: &str, max_bytes: u64) -> Result<Vec<u8>, String> {
|
||||
let parsed = reqwest::Url::parse(url).map_err(|err| format!("not a valid URL: {err}"))?;
|
||||
// A presigned URL carries its authority in the query string; sending it
|
||||
// over plain HTTP would expose it to anyone on the path.
|
||||
if parsed.scheme() != "https" {
|
||||
return Err(format!("refusing non-HTTPS URL (scheme '{}')", parsed.scheme()));
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(CONNECT_TIMEOUT)
|
||||
.timeout(DOWNLOAD_TIMEOUT)
|
||||
// A presigned GET answers directly. Following a redirect would carry
|
||||
// us somewhere the signature never vouched for.
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|err| format!("could not build HTTP client: {err}"))?;
|
||||
|
||||
let mut response = client.get(parsed).send().await.map_err(|err| describe(&err))?;
|
||||
|
||||
// Status before body. An expired presigned URL is a 403 with an XML
|
||||
// error document, which must never be mistaken for a project file.
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let hint = if status == reqwest::StatusCode::FORBIDDEN {
|
||||
" (the presigned URL has probably expired)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
return Err(format!("server answered HTTP {status}{hint}"));
|
||||
}
|
||||
|
||||
let expected = response.content_length();
|
||||
if let Some(len) = expected.filter(|len| *len > max_bytes) {
|
||||
return Err(format!("file is {len} bytes, over the {max_bytes} byte limit"));
|
||||
}
|
||||
|
||||
// Stream into a buffer so the cap holds even when the server sends no
|
||||
// Content-Length or lies about it.
|
||||
let mut body = Vec::with_capacity(expected.unwrap_or(0).min(max_bytes) as usize);
|
||||
while let Some(chunk) = response.chunk().await.map_err(|err| describe(&err))? {
|
||||
if body.len() as u64 + chunk.len() as u64 > max_bytes {
|
||||
return Err(format!("file exceeds the {max_bytes} byte limit"));
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
// A `.3mf` is a zip: a truncated one has lost its central directory and
|
||||
// is unreadable, so a short body is an error, not a smaller file.
|
||||
if let Some(len) = expected.filter(|len| *len != body.len() as u64) {
|
||||
return Err(format!("received {} bytes but Content-Length said {len}", body.len()));
|
||||
}
|
||||
if body.is_empty() {
|
||||
return Err("server returned an empty body".into());
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// reqwest's `Display` for a transport error is just "error sending request";
|
||||
/// the useful part (timeout, connect, TLS) is in the flags and source chain.
|
||||
fn describe(err: &reqwest::Error) -> String {
|
||||
let kind = if err.is_timeout() {
|
||||
"timed out"
|
||||
} else if err.is_connect() {
|
||||
"could not connect"
|
||||
} else {
|
||||
"transfer failed"
|
||||
};
|
||||
let mut msg = format!("{kind}: {err}");
|
||||
let mut source = std::error::Error::source(err);
|
||||
while let Some(cause) = source {
|
||||
msg.push_str(&format!(": {cause}"));
|
||||
source = cause.source();
|
||||
}
|
||||
// Never echo the URL back: its query string is the credential.
|
||||
match err.url() {
|
||||
Some(url) => msg.replace(url.as_str(), "<url>"),
|
||||
None => msg,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Checked before any connection is made, so no server is needed.
|
||||
#[tokio::test]
|
||||
async fn a_non_https_url_is_refused_before_any_request() {
|
||||
let err = fetch_job_file("http://example.invalid/plate.3mf?sig=abc").await.unwrap_err();
|
||||
assert!(err.contains("non-HTTPS"), "got: {err}");
|
||||
assert!(fetch_job_file("not a url").await.is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user