Reach catbox.moe over the transport that works
Sending a file to IRC uploads it and sends the link, and that upload was failing for every file - image or video alike, since the file type was never what it turned on. catbox.moe's HTTP/1.1 path is broken. It answers a completed upload with a chunked 500 and then drops the TLS connection without a close_notify, which rustls correctly reports as a truncated stream - so the reply was never read at all and the link, which the server had already sent, was lost with it. The same request over HTTP/2 gets a clean 200. reqwest was built without its http2 feature and so could only speak 1.1; curl negotiates h2, which is why doing it by hand always looked fine. Enabling that feature is compile-time and would have let every reqwest client here negotiate h2 as a side effect, so Discord's and Matrix's are pinned to 1.1 - they work as they stand and changing their transport is not part of fixing an upload. Errors on this path now carry their cause chain. "uploading to catbox" was the entire report of a failure that turned out to be four layers down, and finding it meant rebuilding the daemon to ask. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Generated
+21
@@ -2154,6 +2154,25 @@ dependencies = [
|
||||
"xxhash-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.14.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -2478,6 +2497,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -5010,6 +5030,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ futures = "0.3"
|
||||
|
||||
# Discord backend (QR remote-auth login + gateway client)
|
||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart", "http2"] }
|
||||
# Pulled in transitively by both of the above, but with conflicting default
|
||||
# crypto-provider features (ring vs aws-lc-rs) - direct so main.rs can pin
|
||||
# one explicitly at startup (see rustls::crypto::ring::default_provider()
|
||||
|
||||
@@ -60,6 +60,12 @@ fn http_client() -> &'static reqwest::Client {
|
||||
CLIENT.get_or_init(|| {
|
||||
reqwest::Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
// Pinned to HTTP/1.1. Enabling reqwest's http2 feature (which the
|
||||
// file-upload path needs - see upload::http_client) would otherwise
|
||||
// let every client here negotiate h2 as a side effect, changing the
|
||||
// transport under a backend that works and is tested as it stands.
|
||||
// Nothing here wants h2; if it ever does, that is its own change.
|
||||
.http1_only()
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new())
|
||||
})
|
||||
|
||||
@@ -9,7 +9,14 @@ use serde_json::Value;
|
||||
|
||||
pub fn http_client() -> &'static reqwest::Client {
|
||||
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
|
||||
CLIENT.get_or_init(reqwest::Client::new)
|
||||
CLIENT.get_or_init(|| {
|
||||
// Pinned to HTTP/1.1. Enabling reqwest's http2 feature (which the
|
||||
// file-upload path needs - see upload::http_client) would otherwise
|
||||
// let every client here negotiate h2 as a side effect, changing the
|
||||
// transport under a backend that works and is tested as it stands.
|
||||
// Nothing here wants h2; if it ever does, that is its own change.
|
||||
reqwest::Client::builder().http1_only().build().unwrap_or_else(|_| reqwest::Client::new())
|
||||
})
|
||||
}
|
||||
|
||||
/// The Matrix C-S API's standard error body shape (`{"errcode": "M_...",
|
||||
|
||||
+40
-2
@@ -117,6 +117,25 @@ pub async fn upload(host: Host, path: &str, retention: Option<&str>) -> Result<S
|
||||
}
|
||||
}
|
||||
|
||||
/// The client these hosts are talked to over.
|
||||
///
|
||||
/// HTTP/2 deliberately, and it is not a preference: catbox.moe's HTTP/1.1
|
||||
/// path is broken. Over 1.1 it answers a completed upload with a chunked 500
|
||||
/// and then drops the TLS connection without a close_notify, which rustls
|
||||
/// correctly reports as a truncated stream - so the reply is never read at
|
||||
/// all and every attachment sent to IRC failed. The same request over HTTP/2
|
||||
/// gets a clean 200 with the link in it. Negotiated by ALPN, so a host that
|
||||
/// only speaks 1.1 still works.
|
||||
fn http_client() -> &'static reqwest::Client {
|
||||
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
|
||||
CLIENT.get_or_init(|| {
|
||||
reqwest::Client::builder()
|
||||
.user_agent(concat!("moho/", env!("CARGO_PKG_VERSION"), " (nobilis)"))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new())
|
||||
})
|
||||
}
|
||||
|
||||
/// catbox.moe and its temporary sibling share one API: a multipart form with a
|
||||
/// `reqtype`, and the finished URL as the whole of the response body.
|
||||
async fn catbox_family(host: Host, file_name: String, bytes: Vec<u8>, retention: Option<&str>) -> Result<String> {
|
||||
@@ -130,12 +149,17 @@ async fn catbox_family(host: Host, file_name: String, bytes: Vec<u8>, retention:
|
||||
form = form.text("time", retention.unwrap_or("72h").to_string());
|
||||
}
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
let resp = http_client()
|
||||
.post(url)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("uploading to {}", host.id()))?;
|
||||
// The cause, not just the intent: "uploading to catbox" on its own
|
||||
// leaves somebody staring at a failure that could equally be a dead
|
||||
// network, a proxy, or the host being down. reqwest keeps the actual
|
||||
// reason in the error's source chain rather than its Display, so
|
||||
// without walking it every transport failure reads identically.
|
||||
.map_err(|e| anyhow!("couldn't reach {}: {}", host.id(), with_causes(&e)))?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
link_from_response(host, status, &body)
|
||||
@@ -164,6 +188,20 @@ fn link_from_response(host: Host, status: reqwest::StatusCode, body: &str) -> Re
|
||||
bail!("{} did not return a link: {}", host.id(), snippet(link));
|
||||
}
|
||||
|
||||
/// An error together with everything underneath it.
|
||||
///
|
||||
/// The interesting half of a failed request - the DNS answer, the refused
|
||||
/// connection, the certificate - is never in the top error's own message.
|
||||
fn with_causes(err: &dyn std::error::Error) -> String {
|
||||
let mut out = err.to_string();
|
||||
let mut cause = err.source();
|
||||
while let Some(c) = cause {
|
||||
out.push_str(&format!(": {c}"));
|
||||
cause = c.source();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The first part of a reply, for an error message.
|
||||
///
|
||||
/// Cut on a character boundary rather than a byte one: these are error pages
|
||||
|
||||
Reference in New Issue
Block a user