Skip to main content

upcloud_api/
wire.rs

1//! **The ONE wire.** Every request to the account and every request to a
2//! `mock-upcloud` is built here, by the same code, from the same
3//! [`crate::body`] spellings — only the base differs, and the base comes from an
4//! [`Endpoint`] the caller could not have forged. That is what makes a run
5//! against the mock a test of the code that runs against the account: the URL,
6//! the body, the headers and the retry policy are not a parallel copy.
7
8use std::path::Path;
9use std::time::Duration;
10
11use serde_json::Value;
12
13use crate::net::{self, Budget};
14use crate::over::{Call, Exchange, Method, Over};
15use crate::{redact_upload_url, Endpoint, Reply, UpCloudApi, ACCOUNT_BASE};
16
17/// How the wire authenticates. The account's clients use a token; monetize's
18/// plugin can also be configured with a sub-account's username and password.
19#[derive(Clone)]
20pub enum Credential {
21    Token(String),
22    Basic { username: String, password: String },
23}
24
25impl std::fmt::Debug for Credential {
26    // Never printed, not even under `{:?}`.
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            Credential::Token(_) => f.write_str("Credential::Token(…)"),
30            Credential::Basic { username, .. } => write!(f, "Credential::Basic({username}, …)"),
31        }
32    }
33}
34
35impl Credential {
36    fn header(&self) -> String {
37        match self {
38            Credential::Token(t) => format!("Bearer {t}"),
39            Credential::Basic { username, password } => format!("Basic {}", base64(format!("{username}:{password}").as_bytes())),
40        }
41    }
42}
43
44/// Timeouts. A single API call and the upload of a multi-hundred-MiB medium
45/// cannot share one budget without one of them being wrong.
46#[derive(Debug, Clone, Copy)]
47pub struct Options {
48    /// One API call, not a procedure.
49    pub call_timeout: Duration,
50    /// One `PUT` of a medium.
51    pub upload_timeout: Duration,
52    /// Retry a TRANSPORT failure within [`net::Budget`] (reads always, writes
53    /// only when nothing left this box). `false` sends every call exactly once
54    /// — for a caller whose own contract is "one attempt, the caller retries",
55    /// which is monetize-cloud-impl's.
56    pub retry_transport: bool,
57}
58
59impl Default for Options {
60    fn default() -> Options {
61        Options { call_timeout: Duration::from_secs(60), upload_timeout: Duration::from_secs(30 * 60), retry_transport: true }
62    }
63}
64
65/// **The only constructor.** The implementation a run holds is a function of
66/// the [`Endpoint`], and an `Endpoint` is only ever built from what the
67/// operator typed.
68pub fn connect(endpoint: &Endpoint, credential: Credential, options: Options) -> Box<dyn UpCloudApi + Send + Sync> {
69    let agent = |t: Duration| {
70        let cfg = ureq::Agent::config_builder()
71            // Every status the API composes — the `412 out_of_stock` included —
72            // arrives as an ordinary response and is judged by the caller.
73            .http_status_as_error(false)
74            .timeout_global(Some(t))
75            .build();
76        ureq::Agent::new_with_config(cfg)
77    };
78    let (base, account) = match endpoint {
79        Endpoint::Account => (ACCOUNT_BASE.to_string(), true),
80        Endpoint::Mock(b) => (b.as_str().to_string(), false),
81    };
82    Box::new(Over(Wire { base, account, credential, retry: options.retry_transport, agent: agent(options.call_timeout), upload: agent(options.upload_timeout) }))
83}
84
85struct Wire {
86    base: String,
87    account: bool,
88    credential: Credential,
89    retry: bool,
90    agent: ureq::Agent,
91    upload: ureq::Agent,
92}
93
94fn read(mut res: ureq::http::Response<ureq::Body>) -> Result<Reply, String> {
95    let status = res.status().as_u16();
96    let text = res.body_mut().read_to_string().map_err(|e| format!("reading the reply body of a {status}: {e}"))?;
97    let body = if text.trim().is_empty() { Value::Null } else { serde_json::from_str(&text).unwrap_or(Value::Null) };
98    Ok(Reply { status, body, text })
99}
100
101impl Wire {
102    fn who(&self) -> &'static str {
103        if self.account {
104            ""
105        } else {
106            " (the fake)"
107        }
108    }
109
110    /// Reads are idempotent and retried on the wire; writes only when the
111    /// failure proves nothing left this box — one order must never become two.
112    fn call(&self, m: Method, path: &str, body: Option<&Value>) -> Result<Reply, String> {
113        let url = format!("{}{path}", self.base);
114        let what = format!("{} {path}", m.as_str());
115        let budget = if !self.retry {
116            Budget::once()
117        } else if m == Method::Get {
118            Budget::api()
119        } else {
120            Budget::write()
121        };
122        let auth = self.credential.header();
123        let res = net::call(&what, budget, || match (m, body) {
124            (Method::Get, _) => self.agent.get(&url).header("Authorization", &auth).header("Accept", "application/json").call(),
125            (Method::Delete, _) => self.agent.delete(&url).header("Authorization", &auth).header("Accept", "application/json").call(),
126            (Method::Post, b) => self
127                .agent
128                .post(&url)
129                .header("Authorization", &auth)
130                .header("Accept", "application/json")
131                .header("Content-Type", "application/json")
132                .send(b.map(|v| v.to_string()).unwrap_or_else(|| "{}".to_string())),
133            (Method::Put, b) => self
134                .agent
135                .put(&url)
136                .header("Authorization", &auth)
137                .header("Accept", "application/json")
138                .header("Content-Type", "application/json")
139                .send(b.map(|v| v.to_string()).unwrap_or_else(|| "{}".to_string())),
140        })
141        .map_err(|e| format!("{what}{}: {e}", self.who()))?;
142        read(res)
143    }
144
145    /// The upload URL must belong to the cloud this wire talks to. A fake that
146    /// answered an import with the account's upload host, or an account whose
147    /// reply named loopback, is a reply this process will not act on.
148    fn upload_url_belongs(&self, url: &str) -> Result<(), String> {
149        let ok = if self.account {
150            url.starts_with("https://") && url.split('/').nth(2).map(|h| h.ends_with(".upcloud.com")).unwrap_or(false)
151        } else {
152            crate::is_loopback(url)
153        };
154        if ok {
155            Ok(())
156        } else {
157            Err(format!(
158                "REFUSED [upload-url-foreign] the import answered an upload URL {} that does not belong to {} — \
159                 nothing is uploaded to a host the run was not aimed at",
160                redact_upload_url(url),
161                if self.account { "the account's upload hosts" } else { "the loopback fake" }
162            ))
163        }
164    }
165}
166
167impl Exchange for Wire {
168    fn describe(&self) -> String {
169        if self.account {
170            format!("THE ACCOUNT — {}", self.base)
171        } else {
172            format!("MOCK_UPCLOUD at {} — a FAKE", self.base)
173        }
174    }
175    fn is_the_account(&self) -> bool {
176        self.account
177    }
178    fn exchange(&self, call: Call<'_>) -> Result<Reply, String> {
179        match call {
180            Call::Api { method, path, body } => self.call(method, &path, body.as_ref()),
181            Call::Upload { url, file } => self.upload(url, file),
182        }
183    }
184}
185
186impl Wire {
187    fn upload(&self, url: &str, file: &Path) -> Result<Reply, String> {
188        self.upload_url_belongs(url)?;
189        let shown = redact_upload_url(url);
190        let len = std::fs::metadata(file).map_err(|e| format!("{}: {e}", file.display()))?.len();
191        let f = std::fs::File::open(file).map_err(|e| format!("open {} for upload: {e}", file.display()))?;
192        // NOT retried: a medium PUT that failed mid-flight may have landed, and
193        // the caller verifies the digest the import reports anyway. NO bearer:
194        // the URL is itself the credential, and the API token has no business
195        // travelling to the upload host. Content-Length is set BY HAND and is
196        // load-bearing — the direct-upload endpoint answers a chunked PUT 411.
197        let res = self
198            .upload
199            .put(url)
200            .header("Content-Type", "application/octet-stream")
201            .header("Content-Length", &len.to_string())
202            .send(ureq::SendBody::from_owned_reader(f))
203            // The redacted form, on the transport-error path too: a hiccup
204            // part-way through the PUT once printed the session secret whole.
205            .map_err(|e| format!("PUT {shown}{}: {e}", self.who()))?;
206        read(res)
207    }
208}
209
210/// RFC 4648 base64 for one Basic header — hand-rolled so this leaf pulls no
211/// crate for twelve lines.
212fn base64(input: &[u8]) -> String {
213    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
214    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
215    for c in input.chunks(3) {
216        let b = [c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0)];
217        let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
218        out.push(T[(n >> 18) as usize & 63] as char);
219        out.push(T[(n >> 12) as usize & 63] as char);
220        out.push(if c.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });
221        out.push(if c.len() > 2 { T[n as usize & 63] as char } else { '=' });
222    }
223    out
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn basic_auth_is_rfc4648() {
232        assert_eq!(base64(b"user:pass"), "dXNlcjpwYXNz");
233        assert_eq!(base64(b"a"), "YQ==");
234        assert_eq!(base64(b"ab"), "YWI=");
235    }
236
237    /// **FAILS-BEFORE, BY NEUTRALISATION**: make `connect` ignore the endpoint
238    /// and hand every run the account's base, and this fails on the second
239    /// assertion — a fake-selected run would describe itself as the account.
240    #[test]
241    fn a_fake_run_never_holds_the_account_and_an_account_run_never_holds_a_fake() {
242        let real = connect(&Endpoint::Account, Credential::Token("t".into()), Options::default());
243        assert!(real.is_the_account() && real.describe().contains("THE ACCOUNT"));
244        let fake = connect(&Endpoint::mock("http://127.0.0.1:8099").unwrap(), Credential::Token("t".into()), Options::default());
245        assert!(!fake.is_the_account(), "a fake that says it is the account is the whole bug");
246        assert!(fake.describe().contains("FAKE") && !fake.describe().contains("api.upcloud.com"));
247    }
248
249    #[test]
250    fn an_upload_url_must_belong_to_the_cloud_the_run_is_aimed_at() {
251        let mk = |e: &Endpoint| Wire {
252            base: String::new(),
253            account: e.is_account(),
254            credential: Credential::Token("t".into()),
255            retry: true,
256            agent: ureq::Agent::new_with_defaults(),
257            upload: ureq::Agent::new_with_defaults(),
258        };
259        let acct = mk(&Endpoint::Account);
260        let fake = mk(&Endpoint::mock("http://127.0.0.1:1").unwrap());
261        let real_url = "https://fi-hel1.img.upcloud.com/uploader/session/x";
262        let mock_url = "http://127.0.0.1:8099/uploader/session/x";
263        assert!(acct.upload_url_belongs(real_url).is_ok());
264        assert!(acct.upload_url_belongs(mock_url).is_err());
265        assert!(fake.upload_url_belongs(mock_url).is_ok());
266        let e = fake.upload_url_belongs(real_url).unwrap_err();
267        assert!(e.contains("upload-url-foreign") && !e.contains("/session/x"), "{e}");
268        assert!(acct.upload_url_belongs("https://evil.example/upcloud.com/x").is_err());
269    }
270}