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::{body, redact_upload_url, Backups, BootOrder, Console, DeviceKind, Endpoint, Label, NewStorage, Reply, Stop, UpCloudApi, WithStorages, ACCOUNT_BASE};
15
16/// How the wire authenticates. The account's clients use a token; monetize's
17/// plugin can also be configured with a sub-account's username and password.
18#[derive(Clone)]
19pub enum Credential {
20    Token(String),
21    Basic { username: String, password: String },
22}
23
24impl std::fmt::Debug for Credential {
25    // Never printed, not even under `{:?}`.
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match self {
28            Credential::Token(_) => f.write_str("Credential::Token(…)"),
29            Credential::Basic { username, .. } => write!(f, "Credential::Basic({username}, …)"),
30        }
31    }
32}
33
34impl Credential {
35    fn header(&self) -> String {
36        match self {
37            Credential::Token(t) => format!("Bearer {t}"),
38            Credential::Basic { username, password } => format!("Basic {}", base64(format!("{username}:{password}").as_bytes())),
39        }
40    }
41}
42
43/// Timeouts. A single API call and the upload of a multi-hundred-MiB medium
44/// cannot share one budget without one of them being wrong.
45#[derive(Debug, Clone, Copy)]
46pub struct Options {
47    /// One API call, not a procedure.
48    pub call_timeout: Duration,
49    /// One `PUT` of a medium.
50    pub upload_timeout: Duration,
51    /// Retry a TRANSPORT failure within [`net::Budget`] (reads always, writes
52    /// only when nothing left this box). `false` sends every call exactly once
53    /// — for a caller whose own contract is "one attempt, the caller retries",
54    /// which is monetize-cloud-impl's.
55    pub retry_transport: bool,
56}
57
58impl Default for Options {
59    fn default() -> Options {
60        Options { call_timeout: Duration::from_secs(60), upload_timeout: Duration::from_secs(30 * 60), retry_transport: true }
61    }
62}
63
64/// **The only constructor.** The implementation a run holds is a function of
65/// the [`Endpoint`], and an `Endpoint` is only ever built from what the
66/// operator typed.
67pub fn connect(endpoint: &Endpoint, credential: Credential, options: Options) -> Box<dyn UpCloudApi + Send + Sync> {
68    let agent = |t: Duration| {
69        let cfg = ureq::Agent::config_builder()
70            // Every status the API composes — the `412 out_of_stock` included —
71            // arrives as an ordinary response and is judged by the caller.
72            .http_status_as_error(false)
73            .timeout_global(Some(t))
74            .build();
75        ureq::Agent::new_with_config(cfg)
76    };
77    let (base, account) = match endpoint {
78        Endpoint::Account => (ACCOUNT_BASE.to_string(), true),
79        Endpoint::Mock(b) => (b.as_str().to_string(), false),
80    };
81    Box::new(Wire { base, account, credential, retry: options.retry_transport, agent: agent(options.call_timeout), upload: agent(options.upload_timeout) })
82}
83
84struct Wire {
85    base: String,
86    account: bool,
87    credential: Credential,
88    retry: bool,
89    agent: ureq::Agent,
90    upload: ureq::Agent,
91}
92
93#[derive(Clone, Copy, PartialEq, Eq)]
94enum M {
95    Get,
96    Post,
97    Put,
98    Delete,
99}
100
101impl M {
102    fn as_str(self) -> &'static str {
103        match self {
104            M::Get => "GET",
105            M::Post => "POST",
106            M::Put => "PUT",
107            M::Delete => "DELETE",
108        }
109    }
110}
111
112fn read(mut res: ureq::http::Response<ureq::Body>) -> Result<Reply, String> {
113    let status = res.status().as_u16();
114    let text = res.body_mut().read_to_string().map_err(|e| format!("reading the reply body of a {status}: {e}"))?;
115    let body = if text.trim().is_empty() { Value::Null } else { serde_json::from_str(&text).unwrap_or(Value::Null) };
116    Ok(Reply { status, body, text })
117}
118
119impl Wire {
120    fn who(&self) -> &'static str {
121        if self.account {
122            ""
123        } else {
124            " (the fake)"
125        }
126    }
127
128    /// Reads are idempotent and retried on the wire; writes only when the
129    /// failure proves nothing left this box — one order must never become two.
130    fn call(&self, m: M, path: &str, body: Option<&Value>) -> Result<Reply, String> {
131        let url = format!("{}{path}", self.base);
132        let what = format!("{} {path}", m.as_str());
133        let budget = if !self.retry {
134            Budget::once()
135        } else if m == M::Get {
136            Budget::api()
137        } else {
138            Budget::write()
139        };
140        let auth = self.credential.header();
141        let res = net::call(&what, budget, || match (m, body) {
142            (M::Get, _) => self.agent.get(&url).header("Authorization", &auth).header("Accept", "application/json").call(),
143            (M::Delete, _) => self.agent.delete(&url).header("Authorization", &auth).header("Accept", "application/json").call(),
144            (M::Post, b) => self
145                .agent
146                .post(&url)
147                .header("Authorization", &auth)
148                .header("Accept", "application/json")
149                .header("Content-Type", "application/json")
150                .send(b.map(|v| v.to_string()).unwrap_or_else(|| "{}".to_string())),
151            (M::Put, b) => self
152                .agent
153                .put(&url)
154                .header("Authorization", &auth)
155                .header("Accept", "application/json")
156                .header("Content-Type", "application/json")
157                .send(b.map(|v| v.to_string()).unwrap_or_else(|| "{}".to_string())),
158        })
159        .map_err(|e| format!("{what}{}: {e}", self.who()))?;
160        read(res)
161    }
162
163    fn get(&self, path: &str) -> Result<Reply, String> {
164        self.call(M::Get, path, None)
165    }
166
167    /// The upload URL must belong to the cloud this wire talks to. A fake that
168    /// answered an import with the account's upload host, or an account whose
169    /// reply named loopback, is a reply this process will not act on.
170    fn upload_url_belongs(&self, url: &str) -> Result<(), String> {
171        let ok = if self.account {
172            url.starts_with("https://") && url.split('/').nth(2).map(|h| h.ends_with(".upcloud.com")).unwrap_or(false)
173        } else {
174            crate::is_loopback(url)
175        };
176        if ok {
177            Ok(())
178        } else {
179            Err(format!(
180                "REFUSED [upload-url-foreign] the import answered an upload URL {} that does not belong to {} — \
181                 nothing is uploaded to a host the run was not aimed at",
182                redact_upload_url(url),
183                if self.account { "the account's upload hosts" } else { "the loopback fake" }
184            ))
185        }
186    }
187}
188
189impl UpCloudApi for Wire {
190    fn describe(&self) -> String {
191        if self.account {
192            format!("THE ACCOUNT — {}", self.base)
193        } else {
194            format!("MOCK_UPCLOUD at {} — a FAKE", self.base)
195        }
196    }
197    fn is_the_account(&self) -> bool {
198        self.account
199    }
200    fn account(&self) -> Result<Reply, String> {
201        self.get("/account")
202    }
203    fn servers(&self) -> Result<Reply, String> {
204        self.get("/server")
205    }
206    fn server(&self, uuid: &str) -> Result<Reply, String> {
207        self.get(&format!("/server/{uuid}"))
208    }
209    fn firewall_rules(&self, uuid: &str) -> Result<Reply, String> {
210        self.get(&format!("/server/{uuid}/firewall_rule"))
211    }
212    fn storages_private(&self) -> Result<Reply, String> {
213        self.get("/storage/private")
214    }
215    fn storage(&self, uuid: &str) -> Result<Reply, String> {
216        self.get(&format!("/storage/{uuid}"))
217    }
218    fn zones(&self) -> Result<Reply, String> {
219        self.get("/zone")
220    }
221    fn plans(&self) -> Result<Reply, String> {
222        self.get("/plan")
223    }
224    fn price(&self) -> Result<Reply, String> {
225        self.get("/price")
226    }
227    fn servers_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> {
228        self.get(&format!("/server{}", crate::label_query(labels)))
229    }
230    fn storages_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> {
231        self.get(&format!("/storage{}", crate::label_query(labels)))
232    }
233    fn create_server(&self, document: &Value) -> Result<Reply, String> {
234        self.call(M::Post, "/server", Some(document))
235    }
236    fn stop_server(&self, uuid: &str, stop: Stop) -> Result<Reply, String> {
237        self.call(M::Post, &format!("/server/{uuid}/stop"), Some(&body::stop(stop)))
238    }
239    fn start_server(&self, uuid: &str) -> Result<Reply, String> {
240        self.call(M::Post, &format!("/server/{uuid}/start"), None)
241    }
242    fn modify_server_plan(&self, uuid: &str, plan: &str) -> Result<Reply, String> {
243        self.call(M::Put, &format!("/server/{uuid}"), Some(&body::server_plan(plan)))
244    }
245    fn set_boot_order(&self, uuid: &str, order: BootOrder) -> Result<Reply, String> {
246        self.call(M::Put, &format!("/server/{uuid}"), Some(&body::boot_order(order)))
247    }
248    fn set_console(&self, uuid: &str, console: Console<'_>) -> Result<Reply, String> {
249        self.call(M::Put, &format!("/server/{uuid}"), Some(&body::console(&console)))
250    }
251    fn attach_storage(&self, server: &str, kind: DeviceKind, storage: &str, at: Option<&str>) -> Result<Reply, String> {
252        self.call(M::Post, &format!("/server/{server}/storage/attach"), Some(&body::attach(kind, storage, at)))
253    }
254    fn detach_storage(&self, server: &str, address: &str) -> Result<Reply, String> {
255        self.call(M::Post, &format!("/server/{server}/storage/detach"), Some(&body::detach(address)))
256    }
257    fn eject_cdrom(&self, server: &str) -> Result<Reply, String> {
258        self.call(M::Post, &format!("/server/{server}/cdrom/eject"), None)
259    }
260    fn delete_server(&self, uuid: &str, with: WithStorages) -> Result<Reply, String> {
261        self.call(M::Delete, &format!("/server/{uuid}{}", crate::delete_server_query(with)), None)
262    }
263    fn create_storage(&self, new: &NewStorage<'_>) -> Result<Reply, String> {
264        self.call(M::Post, "/storage", Some(&body::create_storage(new)))
265    }
266    fn clone_storage(&self, uuid: &str, title: &str, zone: &str, tier: &str) -> Result<Reply, String> {
267        self.call(M::Post, &format!("/storage/{uuid}/clone"), Some(&body::clone_storage(title, zone, tier)))
268    }
269    fn import_direct_upload(&self, uuid: &str) -> Result<Reply, String> {
270        self.call(M::Post, &format!("/storage/{uuid}/import"), Some(&body::direct_upload()))
271    }
272    fn upload_direct(&self, url: &str, file: &Path) -> Result<Reply, String> {
273        self.upload_url_belongs(url)?;
274        let shown = redact_upload_url(url);
275        let len = std::fs::metadata(file).map_err(|e| format!("{}: {e}", file.display()))?.len();
276        let f = std::fs::File::open(file).map_err(|e| format!("open {} for upload: {e}", file.display()))?;
277        // NOT retried: a medium PUT that failed mid-flight may have landed, and
278        // the caller verifies the digest the import reports anyway. NO bearer:
279        // the URL is itself the credential, and the API token has no business
280        // travelling to the upload host. Content-Length is set BY HAND and is
281        // load-bearing — the direct-upload endpoint answers a chunked PUT 411.
282        let res = self
283            .upload
284            .put(url)
285            .header("Content-Type", "application/octet-stream")
286            .header("Content-Length", &len.to_string())
287            .send(ureq::SendBody::from_owned_reader(f))
288            // The redacted form, on the transport-error path too: a hiccup
289            // part-way through the PUT once printed the session secret whole.
290            .map_err(|e| format!("PUT {shown}{}: {e}", self.who()))?;
291        read(res)
292    }
293    fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String> {
294        self.call(M::Put, &format!("/storage/{uuid}"), Some(&body::storage_size(gb)))
295    }
296    fn resize_filesystem(&self, uuid: &str) -> Result<Reply, String> {
297        self.call(M::Post, &format!("/storage/{uuid}/resize"), None)
298    }
299    fn delete_storage(&self, uuid: &str, backups: Backups) -> Result<Reply, String> {
300        self.call(M::Delete, &format!("/storage/{uuid}{}", crate::delete_storage_query(backups)), None)
301    }
302}
303
304/// RFC 4648 base64 for one Basic header — hand-rolled so this leaf pulls no
305/// crate for twelve lines.
306fn base64(input: &[u8]) -> String {
307    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
308    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
309    for c in input.chunks(3) {
310        let b = [c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0)];
311        let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
312        out.push(T[(n >> 18) as usize & 63] as char);
313        out.push(T[(n >> 12) as usize & 63] as char);
314        out.push(if c.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });
315        out.push(if c.len() > 2 { T[n as usize & 63] as char } else { '=' });
316    }
317    out
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn basic_auth_is_rfc4648() {
326        assert_eq!(base64(b"user:pass"), "dXNlcjpwYXNz");
327        assert_eq!(base64(b"a"), "YQ==");
328        assert_eq!(base64(b"ab"), "YWI=");
329    }
330
331    /// **FAILS-BEFORE, BY NEUTRALISATION**: make `connect` ignore the endpoint
332    /// and hand every run the account's base, and this fails on the second
333    /// assertion — a fake-selected run would describe itself as the account.
334    #[test]
335    fn a_fake_run_never_holds_the_account_and_an_account_run_never_holds_a_fake() {
336        let real = connect(&Endpoint::Account, Credential::Token("t".into()), Options::default());
337        assert!(real.is_the_account() && real.describe().contains("THE ACCOUNT"));
338        let fake = connect(&Endpoint::mock("http://127.0.0.1:8099").unwrap(), Credential::Token("t".into()), Options::default());
339        assert!(!fake.is_the_account(), "a fake that says it is the account is the whole bug");
340        assert!(fake.describe().contains("FAKE") && !fake.describe().contains("api.upcloud.com"));
341    }
342
343    #[test]
344    fn an_upload_url_must_belong_to_the_cloud_the_run_is_aimed_at() {
345        let mk = |e: &Endpoint| Wire {
346            base: String::new(),
347            account: e.is_account(),
348            credential: Credential::Token("t".into()),
349            retry: true,
350            agent: ureq::Agent::new_with_defaults(),
351            upload: ureq::Agent::new_with_defaults(),
352        };
353        let acct = mk(&Endpoint::Account);
354        let fake = mk(&Endpoint::mock("http://127.0.0.1:1").unwrap());
355        let real_url = "https://fi-hel1.img.upcloud.com/uploader/session/x";
356        let mock_url = "http://127.0.0.1:8099/uploader/session/x";
357        assert!(acct.upload_url_belongs(real_url).is_ok());
358        assert!(acct.upload_url_belongs(mock_url).is_err());
359        assert!(fake.upload_url_belongs(mock_url).is_ok());
360        let e = fake.upload_url_belongs(real_url).unwrap_err();
361        assert!(e.contains("upload-url-foreign") && !e.contains("/session/x"), "{e}");
362        assert!(acct.upload_url_belongs("https://evil.example/upcloud.com/x").is_err());
363    }
364}