1use 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#[derive(Clone)]
19pub enum Credential {
20 Token(String),
21 Basic { username: String, password: String },
22}
23
24impl std::fmt::Debug for Credential {
25 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#[derive(Debug, Clone, Copy)]
46pub struct Options {
47 pub call_timeout: Duration,
49 pub upload_timeout: Duration,
51 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
64pub 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 .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 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 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 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 .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
304fn 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 #[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}