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, Ordering};
24
25use log::{debug, 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> {
269 PROVIDERS.iter().find(|p| p.name == name)
270}
271
272fn bare_provider_name(name: &str) -> &str {
277 name.split_once(':').map(|(p, _)| p).unwrap_or(name)
278}
279
280pub const PROVIDER_NAMES: &[&str] = &[
282 "digitalocean",
283 "vultr",
284 "linode",
285 "hetzner",
286 "upcloud",
287 "proxmox",
288 "aws",
289 "scaleway",
290 "gcp",
291 "azure",
292 "tailscale",
293 "oracle",
294 "ovh",
295 "leaseweb",
296 "i3d",
297 "transip",
298];
299
300const _: () = {
302 assert!(
303 PROVIDER_NAMES.len() == PROVIDERS.len(),
304 "PROVIDER_NAMES and PROVIDERS length must match",
305 );
306};
307
308pub fn get_provider(name: &str) -> Option<Box<dyn Provider>> {
311 descriptor(bare_provider_name(name)).map(|d| (d.build)(None))
312}
313
314pub fn get_provider_with_config(section: &config::ProviderSection) -> Option<Box<dyn Provider>> {
320 descriptor(section.provider()).map(|d| (d.build)(Some(section)))
321}
322
323pub fn provider_display_name(name: &str) -> &str {
326 descriptor(bare_provider_name(name))
327 .map(|d| d.display)
328 .unwrap_or(name)
329}
330
331pub(crate) fn http_agent() -> ureq::Agent {
333 ureq::Agent::config_builder()
334 .timeout_global(Some(std::time::Duration::from_secs(30)))
335 .max_redirects(0)
336 .build()
337 .new_agent()
338}
339
340pub(crate) fn http_agent_insecure() -> Result<ureq::Agent, ProviderError> {
342 Ok(ureq::Agent::config_builder()
343 .timeout_global(Some(std::time::Duration::from_secs(30)))
344 .max_redirects(0)
345 .tls_config(
346 ureq::tls::TlsConfig::builder()
347 .provider(ureq::tls::TlsProvider::NativeTls)
348 .disable_verification(true)
349 .build(),
350 )
351 .build()
352 .new_agent())
353}
354
355pub(crate) fn strip_cidr(ip: &str) -> &str {
359 if let Some(pos) = ip.rfind('/') {
361 if ip[pos + 1..].bytes().all(|b| b.is_ascii_digit()) && pos + 1 < ip.len() {
362 return &ip[..pos];
363 }
364 }
365 ip
366}
367
368pub(crate) fn percent_encode(s: &str) -> String {
371 let mut result = String::with_capacity(s.len());
372 for byte in s.bytes() {
373 match byte {
374 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
375 result.push(byte as char);
376 }
377 _ => {
378 result.push_str(&format!("%{:02X}", byte));
379 }
380 }
381 }
382 result
383}
384
385pub(crate) struct EpochDate {
387 pub year: u64,
388 pub month: u64, pub day: u64, pub hours: u64,
391 pub minutes: u64,
392 pub seconds: u64,
393 pub epoch_days: u64,
395}
396
397pub(crate) fn epoch_to_date(epoch_secs: u64) -> EpochDate {
399 let secs_per_day = 86400u64;
400 let epoch_days = epoch_secs / secs_per_day;
401 let mut remaining_days = epoch_days;
402 let day_secs = epoch_secs % secs_per_day;
403
404 let mut year = 1970u64;
405 loop {
406 let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
407 let days_in_year = if leap { 366 } else { 365 };
408 if remaining_days < days_in_year {
409 break;
410 }
411 remaining_days -= days_in_year;
412 year += 1;
413 }
414
415 let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
416 let days_per_month: [u64; 12] = [
417 31,
418 if leap { 29 } else { 28 },
419 31,
420 30,
421 31,
422 30,
423 31,
424 31,
425 30,
426 31,
427 30,
428 31,
429 ];
430 let mut month = 0usize;
431 while month < 12 && remaining_days >= days_per_month[month] {
432 remaining_days -= days_per_month[month];
433 month += 1;
434 }
435
436 EpochDate {
437 year,
438 month: (month + 1) as u64,
439 day: remaining_days + 1,
440 hours: day_secs / 3600,
441 minutes: (day_secs % 3600) / 60,
442 seconds: day_secs % 60,
443 epoch_days,
444 }
445}
446
447fn map_ureq_error(err: ureq::Error) -> ProviderError {
449 match err {
450 ureq::Error::StatusCode(code) => match code {
451 401 | 403 => {
452 error!("[external] HTTP {code}: authentication failed");
453 ProviderError::AuthFailed
454 }
455 429 => {
456 warn!("[external] HTTP 429: rate limited");
457 ProviderError::RateLimited
458 }
459 _ => {
460 error!("[external] HTTP {code}");
461 ProviderError::Http(format!("HTTP {}", code))
462 }
463 },
464 other => {
465 error!("[external] Request failed: {other}");
466 ProviderError::Http(other.to_string())
467 }
468 }
469}
470
471pub(crate) const MAX_PAGES: u64 = 500;
475
476pub(crate) struct PageResult {
478 pub hosts: Vec<ProviderHost>,
480 pub more: bool,
482}
483
484pub(crate) fn paginate<F>(
499 cancel: &AtomicBool,
500 mut fetch_page: F,
501) -> Result<Vec<ProviderHost>, ProviderError>
502where
503 F: FnMut(u64) -> Result<PageResult, ProviderError>,
504{
505 let mut hosts = Vec::new();
506 let mut index = 0u64;
507 loop {
508 if cancel.load(Ordering::Relaxed) {
509 return Err(ProviderError::Cancelled);
510 }
511 match fetch_page(index) {
512 Ok(page) => {
513 hosts.extend(page.hosts);
514 if !page.more {
515 return Ok(hosts);
516 }
517 }
518 Err(
519 e @ (ProviderError::Cancelled
520 | ProviderError::AuthFailed
521 | ProviderError::RateLimited),
522 ) => return Err(e),
523 Err(e) => {
524 if hosts.is_empty() {
525 return Err(e);
526 }
527 debug!(
528 "[external] paginate: page {} failed after {} hosts collected, returning partial result ({e})",
529 index + 1,
530 hosts.len()
531 );
532 return Err(ProviderError::PartialResult {
533 hosts,
534 failures: 1,
535 total: (index + 1) as usize,
536 });
537 }
538 }
539 index += 1;
540 if index >= MAX_PAGES {
541 debug!(
542 "[purple] paginate: reached MAX_PAGES ({MAX_PAGES}) guard, stopping with {} hosts",
543 hosts.len()
544 );
545 return Ok(hosts);
546 }
547 }
548}
549
550#[cfg(test)]
551#[path = "mod_tests.rs"]
552mod tests;