1pub mod aws;
2pub mod azure;
3pub mod config;
4mod digitalocean;
5pub mod gcp;
6mod hetzner;
7mod i3d;
8pub mod kind;
9mod leaseweb;
10mod linode;
11pub mod oracle;
12pub mod ovh;
13mod proxmox;
14pub mod scaleway;
15pub mod sync;
16mod tailscale;
17mod transip;
18mod upcloud;
19mod vultr;
20
21pub use kind::ProviderKind;
22
23use std::sync::atomic::AtomicBool;
24
25use log::{error, warn};
26use thiserror::Error;
27
28#[derive(Debug, Clone)]
30pub struct ProviderHost {
31 pub server_id: String,
33 pub name: String,
35 pub ip: String,
37 pub tags: Vec<String>,
39 pub metadata: Vec<(String, String)>,
41}
42
43impl ProviderHost {
44 #[allow(dead_code)]
46 pub fn new(server_id: String, name: String, ip: String, tags: Vec<String>) -> Self {
47 Self {
48 server_id,
49 name,
50 ip,
51 tags,
52 metadata: Vec::new(),
53 }
54 }
55}
56
57#[derive(Debug, Error)]
59pub enum ProviderError {
60 #[error("HTTP error: {0}")]
61 Http(String),
62 #[error("Failed to parse response: {0}")]
63 Parse(String),
64 #[error("Authentication failed. Check your API token.")]
65 AuthFailed,
66 #[error("Rate limited. Try again in a moment.")]
67 RateLimited,
68 #[error("{0}")]
69 Execute(String),
70 #[error("Cancelled.")]
71 Cancelled,
72 #[error("Partial result: {failures} of {total} failed")]
75 PartialResult {
76 hosts: Vec<ProviderHost>,
77 failures: usize,
78 total: usize,
79 },
80}
81
82pub trait Provider {
84 fn name(&self) -> &str;
86 fn short_label(&self) -> &str;
88 #[allow(dead_code)]
90 fn fetch_hosts_cancellable(
91 &self,
92 token: &str,
93 cancel: &AtomicBool,
94 ) -> Result<Vec<ProviderHost>, ProviderError>;
95 #[allow(dead_code)]
97 fn fetch_hosts(&self, token: &str) -> Result<Vec<ProviderHost>, ProviderError> {
98 self.fetch_hosts_cancellable(token, &AtomicBool::new(false))
99 }
100 #[allow(dead_code)]
102 fn fetch_hosts_with_progress(
103 &self,
104 token: &str,
105 cancel: &AtomicBool,
106 _progress: &dyn Fn(&str),
107 ) -> Result<Vec<ProviderHost>, ProviderError> {
108 self.fetch_hosts_cancellable(token, cancel)
109 }
110}
111
112fn parse_csv(s: &str) -> Vec<String> {
115 s.split(',')
116 .map(|s| s.trim().to_string())
117 .filter(|s| !s.is_empty())
118 .collect()
119}
120
121type ProviderBuild = fn(Option<&config::ProviderSection>) -> Box<dyn Provider>;
125
126pub struct ProviderDescriptor {
129 pub name: &'static str,
131 pub display: &'static str,
133 pub build: ProviderBuild,
135}
136
137pub const PROVIDERS: &[ProviderDescriptor] = &[
140 ProviderDescriptor {
141 name: "digitalocean",
142 display: "DigitalOcean",
143 build: |_| Box::new(digitalocean::DigitalOcean),
144 },
145 ProviderDescriptor {
146 name: "vultr",
147 display: "Vultr",
148 build: |_| Box::new(vultr::Vultr),
149 },
150 ProviderDescriptor {
151 name: "linode",
152 display: "Linode",
153 build: |_| Box::new(linode::Linode),
154 },
155 ProviderDescriptor {
156 name: "hetzner",
157 display: "Hetzner",
158 build: |_| Box::new(hetzner::Hetzner),
159 },
160 ProviderDescriptor {
161 name: "upcloud",
162 display: "UpCloud",
163 build: |_| Box::new(upcloud::UpCloud),
164 },
165 ProviderDescriptor {
166 name: "proxmox",
167 display: "Proxmox VE",
168 build: |section| {
169 let s = section.cloned().unwrap_or_default();
170 Box::new(proxmox::Proxmox {
171 base_url: s.url,
172 verify_tls: s.verify_tls,
173 })
174 },
175 },
176 ProviderDescriptor {
177 name: "aws",
178 display: "AWS EC2",
179 build: |section| {
180 let s = section.cloned().unwrap_or_default();
181 Box::new(aws::Aws {
182 regions: parse_csv(&s.regions),
183 profile: s.profile,
184 })
185 },
186 },
187 ProviderDescriptor {
188 name: "scaleway",
189 display: "Scaleway",
190 build: |section| {
191 let s = section.cloned().unwrap_or_default();
192 Box::new(scaleway::Scaleway {
193 zones: parse_csv(&s.regions),
194 })
195 },
196 },
197 ProviderDescriptor {
198 name: "gcp",
199 display: "GCP",
200 build: |section| {
201 let s = section.cloned().unwrap_or_default();
202 Box::new(gcp::Gcp {
203 zones: parse_csv(&s.regions),
204 project: s.project,
205 })
206 },
207 },
208 ProviderDescriptor {
209 name: "azure",
210 display: "Azure",
211 build: |section| {
212 let s = section.cloned().unwrap_or_default();
213 Box::new(azure::Azure {
214 subscriptions: parse_csv(&s.regions),
215 })
216 },
217 },
218 ProviderDescriptor {
219 name: "tailscale",
220 display: "Tailscale",
221 build: |_| Box::new(tailscale::Tailscale),
222 },
223 ProviderDescriptor {
224 name: "oracle",
225 display: "Oracle Cloud",
226 build: |section| {
227 let s = section.cloned().unwrap_or_default();
228 Box::new(oracle::Oracle {
229 regions: parse_csv(&s.regions),
230 compartment: s.compartment,
231 })
232 },
233 },
234 ProviderDescriptor {
235 name: "ovh",
236 display: "OVHcloud",
237 build: |section| {
241 let s = section.cloned().unwrap_or_default();
242 Box::new(ovh::Ovh {
243 project: s.project,
244 endpoint: s.regions,
245 })
246 },
247 },
248 ProviderDescriptor {
249 name: "leaseweb",
250 display: "Leaseweb",
251 build: |_| Box::new(leaseweb::Leaseweb),
252 },
253 ProviderDescriptor {
254 name: "i3d",
255 display: "i3D.net",
256 build: |_| Box::new(i3d::I3d),
257 },
258 ProviderDescriptor {
259 name: "transip",
260 display: "TransIP",
261 build: |_| Box::new(transip::TransIp),
262 },
263];
264
265fn descriptor(name: &str) -> Option<&'static ProviderDescriptor> {
267 PROVIDERS.iter().find(|p| p.name == name)
268}
269
270pub const PROVIDER_NAMES: &[&str] = &[
272 "digitalocean",
273 "vultr",
274 "linode",
275 "hetzner",
276 "upcloud",
277 "proxmox",
278 "aws",
279 "scaleway",
280 "gcp",
281 "azure",
282 "tailscale",
283 "oracle",
284 "ovh",
285 "leaseweb",
286 "i3d",
287 "transip",
288];
289
290const _: () = {
292 assert!(
293 PROVIDER_NAMES.len() == PROVIDERS.len(),
294 "PROVIDER_NAMES and PROVIDERS length must match",
295 );
296};
297
298pub fn get_provider(name: &str) -> Option<Box<dyn Provider>> {
300 descriptor(name).map(|d| (d.build)(None))
301}
302
303pub fn get_provider_with_config(
305 name: &str,
306 section: &config::ProviderSection,
307) -> Option<Box<dyn Provider>> {
308 descriptor(name).map(|d| (d.build)(Some(section)))
309}
310
311pub fn provider_display_name(name: &str) -> &str {
313 descriptor(name).map(|d| d.display).unwrap_or(name)
314}
315
316pub(crate) fn http_agent() -> ureq::Agent {
318 ureq::Agent::config_builder()
319 .timeout_global(Some(std::time::Duration::from_secs(30)))
320 .max_redirects(0)
321 .build()
322 .new_agent()
323}
324
325pub(crate) fn http_agent_insecure() -> Result<ureq::Agent, ProviderError> {
327 Ok(ureq::Agent::config_builder()
328 .timeout_global(Some(std::time::Duration::from_secs(30)))
329 .max_redirects(0)
330 .tls_config(
331 ureq::tls::TlsConfig::builder()
332 .provider(ureq::tls::TlsProvider::NativeTls)
333 .disable_verification(true)
334 .build(),
335 )
336 .build()
337 .new_agent())
338}
339
340pub(crate) fn strip_cidr(ip: &str) -> &str {
344 if let Some(pos) = ip.rfind('/') {
346 if ip[pos + 1..].bytes().all(|b| b.is_ascii_digit()) && pos + 1 < ip.len() {
347 return &ip[..pos];
348 }
349 }
350 ip
351}
352
353pub(crate) fn percent_encode(s: &str) -> String {
356 let mut result = String::with_capacity(s.len());
357 for byte in s.bytes() {
358 match byte {
359 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
360 result.push(byte as char);
361 }
362 _ => {
363 result.push_str(&format!("%{:02X}", byte));
364 }
365 }
366 }
367 result
368}
369
370pub(crate) struct EpochDate {
372 pub year: u64,
373 pub month: u64, pub day: u64, pub hours: u64,
376 pub minutes: u64,
377 pub seconds: u64,
378 pub epoch_days: u64,
380}
381
382pub(crate) fn epoch_to_date(epoch_secs: u64) -> EpochDate {
384 let secs_per_day = 86400u64;
385 let epoch_days = epoch_secs / secs_per_day;
386 let mut remaining_days = epoch_days;
387 let day_secs = epoch_secs % secs_per_day;
388
389 let mut year = 1970u64;
390 loop {
391 let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
392 let days_in_year = if leap { 366 } else { 365 };
393 if remaining_days < days_in_year {
394 break;
395 }
396 remaining_days -= days_in_year;
397 year += 1;
398 }
399
400 let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
401 let days_per_month: [u64; 12] = [
402 31,
403 if leap { 29 } else { 28 },
404 31,
405 30,
406 31,
407 30,
408 31,
409 31,
410 30,
411 31,
412 30,
413 31,
414 ];
415 let mut month = 0usize;
416 while month < 12 && remaining_days >= days_per_month[month] {
417 remaining_days -= days_per_month[month];
418 month += 1;
419 }
420
421 EpochDate {
422 year,
423 month: (month + 1) as u64,
424 day: remaining_days + 1,
425 hours: day_secs / 3600,
426 minutes: (day_secs % 3600) / 60,
427 seconds: day_secs % 60,
428 epoch_days,
429 }
430}
431
432fn map_ureq_error(err: ureq::Error) -> ProviderError {
434 match err {
435 ureq::Error::StatusCode(code) => match code {
436 401 | 403 => {
437 error!("[external] HTTP {code}: authentication failed");
438 ProviderError::AuthFailed
439 }
440 429 => {
441 warn!("[external] HTTP 429: rate limited");
442 ProviderError::RateLimited
443 }
444 _ => {
445 error!("[external] HTTP {code}");
446 ProviderError::Http(format!("HTTP {}", code))
447 }
448 },
449 other => {
450 error!("[external] Request failed: {other}");
451 ProviderError::Http(other.to_string())
452 }
453 }
454}
455
456#[cfg(test)]
457#[path = "mod_tests.rs"]
458mod tests;