1pub mod aws;
2pub mod azure;
3pub mod config;
4mod digitalocean;
5pub mod gcp;
6mod hetzner;
7mod i3d;
8mod leaseweb;
9mod linode;
10pub mod oracle;
11pub mod ovh;
12mod proxmox;
13pub mod scaleway;
14pub mod sync;
15mod tailscale;
16mod transip;
17mod upcloud;
18mod vultr;
19
20use std::sync::atomic::AtomicBool;
21
22use log::{error, warn};
23use thiserror::Error;
24
25#[derive(Debug, Clone)]
27pub struct ProviderHost {
28 pub server_id: String,
30 pub name: String,
32 pub ip: String,
34 pub tags: Vec<String>,
36 pub metadata: Vec<(String, String)>,
38}
39
40impl ProviderHost {
41 #[allow(dead_code)]
43 pub fn new(server_id: String, name: String, ip: String, tags: Vec<String>) -> Self {
44 Self {
45 server_id,
46 name,
47 ip,
48 tags,
49 metadata: Vec::new(),
50 }
51 }
52}
53
54#[derive(Debug, Error)]
56pub enum ProviderError {
57 #[error("HTTP error: {0}")]
58 Http(String),
59 #[error("Failed to parse response: {0}")]
60 Parse(String),
61 #[error("Authentication failed. Check your API token.")]
62 AuthFailed,
63 #[error("Rate limited. Try again in a moment.")]
64 RateLimited,
65 #[error("{0}")]
66 Execute(String),
67 #[error("Cancelled.")]
68 Cancelled,
69 #[error("Partial result: {failures} of {total} failed")]
72 PartialResult {
73 hosts: Vec<ProviderHost>,
74 failures: usize,
75 total: usize,
76 },
77}
78
79pub trait Provider {
81 fn name(&self) -> &str;
83 fn short_label(&self) -> &str;
85 #[allow(dead_code)]
87 fn fetch_hosts_cancellable(
88 &self,
89 token: &str,
90 cancel: &AtomicBool,
91 ) -> Result<Vec<ProviderHost>, ProviderError>;
92 #[allow(dead_code)]
94 fn fetch_hosts(&self, token: &str) -> Result<Vec<ProviderHost>, ProviderError> {
95 self.fetch_hosts_cancellable(token, &AtomicBool::new(false))
96 }
97 #[allow(dead_code)]
99 fn fetch_hosts_with_progress(
100 &self,
101 token: &str,
102 cancel: &AtomicBool,
103 _progress: &dyn Fn(&str),
104 ) -> Result<Vec<ProviderHost>, ProviderError> {
105 self.fetch_hosts_cancellable(token, cancel)
106 }
107}
108
109pub const PROVIDER_NAMES: &[&str] = &[
111 "digitalocean",
112 "vultr",
113 "linode",
114 "hetzner",
115 "upcloud",
116 "proxmox",
117 "aws",
118 "scaleway",
119 "gcp",
120 "azure",
121 "tailscale",
122 "oracle",
123 "ovh",
124 "leaseweb",
125 "i3d",
126 "transip",
127];
128
129pub fn get_provider(name: &str) -> Option<Box<dyn Provider>> {
131 match name {
132 "digitalocean" => Some(Box::new(digitalocean::DigitalOcean)),
133 "vultr" => Some(Box::new(vultr::Vultr)),
134 "linode" => Some(Box::new(linode::Linode)),
135 "hetzner" => Some(Box::new(hetzner::Hetzner)),
136 "upcloud" => Some(Box::new(upcloud::UpCloud)),
137 "proxmox" => Some(Box::new(proxmox::Proxmox {
138 base_url: String::new(),
139 verify_tls: true,
140 })),
141 "aws" => Some(Box::new(aws::Aws {
142 regions: Vec::new(),
143 profile: String::new(),
144 })),
145 "scaleway" => Some(Box::new(scaleway::Scaleway { zones: Vec::new() })),
146 "gcp" => Some(Box::new(gcp::Gcp {
147 zones: Vec::new(),
148 project: String::new(),
149 })),
150 "azure" => Some(Box::new(azure::Azure {
151 subscriptions: Vec::new(),
152 })),
153 "tailscale" => Some(Box::new(tailscale::Tailscale)),
154 "oracle" => Some(Box::new(oracle::Oracle {
155 regions: Vec::new(),
156 compartment: String::new(),
157 })),
158 "ovh" => Some(Box::new(ovh::Ovh {
159 project: String::new(),
160 endpoint: String::new(),
161 })),
162 "leaseweb" => Some(Box::new(leaseweb::Leaseweb)),
163 "i3d" => Some(Box::new(i3d::I3d)),
164 "transip" => Some(Box::new(transip::TransIp)),
165 _ => None,
166 }
167}
168
169pub fn get_provider_with_config(
173 name: &str,
174 section: &config::ProviderSection,
175) -> Option<Box<dyn Provider>> {
176 match name {
177 "proxmox" => Some(Box::new(proxmox::Proxmox {
178 base_url: section.url.clone(),
179 verify_tls: section.verify_tls,
180 })),
181 "aws" => Some(Box::new(aws::Aws {
182 regions: section
183 .regions
184 .split(',')
185 .map(|s| s.trim().to_string())
186 .filter(|s| !s.is_empty())
187 .collect(),
188 profile: section.profile.clone(),
189 })),
190 "scaleway" => Some(Box::new(scaleway::Scaleway {
191 zones: section
192 .regions
193 .split(',')
194 .map(|s| s.trim().to_string())
195 .filter(|s| !s.is_empty())
196 .collect(),
197 })),
198 "gcp" => Some(Box::new(gcp::Gcp {
199 zones: section
200 .regions
201 .split(',')
202 .map(|s| s.trim().to_string())
203 .filter(|s| !s.is_empty())
204 .collect(),
205 project: section.project.clone(),
206 })),
207 "azure" => Some(Box::new(azure::Azure {
208 subscriptions: section
209 .regions
210 .split(',')
211 .map(|s| s.trim().to_string())
212 .filter(|s| !s.is_empty())
213 .collect(),
214 })),
215 "oracle" => Some(Box::new(oracle::Oracle {
216 regions: section
217 .regions
218 .split(',')
219 .map(|s| s.trim().to_string())
220 .filter(|s| !s.is_empty())
221 .collect(),
222 compartment: section.compartment.clone(),
223 })),
224 "ovh" => Some(Box::new(ovh::Ovh {
225 project: section.project.clone(),
226 endpoint: section.regions.clone(),
227 })),
228 _ => get_provider(name),
229 }
230}
231
232pub fn provider_display_name(name: &str) -> &str {
234 match name {
235 "digitalocean" => "DigitalOcean",
236 "vultr" => "Vultr",
237 "linode" => "Linode",
238 "hetzner" => "Hetzner",
239 "upcloud" => "UpCloud",
240 "proxmox" => "Proxmox VE",
241 "aws" => "AWS EC2",
242 "scaleway" => "Scaleway",
243 "gcp" => "GCP",
244 "azure" => "Azure",
245 "tailscale" => "Tailscale",
246 "oracle" => "Oracle Cloud",
247 "ovh" => "OVHcloud",
248 "leaseweb" => "Leaseweb",
249 "i3d" => "i3D.net",
250 "transip" => "TransIP",
251 other => other,
252 }
253}
254
255pub(crate) fn http_agent() -> ureq::Agent {
257 ureq::Agent::config_builder()
258 .timeout_global(Some(std::time::Duration::from_secs(30)))
259 .max_redirects(0)
260 .build()
261 .new_agent()
262}
263
264pub(crate) fn http_agent_insecure() -> Result<ureq::Agent, ProviderError> {
266 Ok(ureq::Agent::config_builder()
267 .timeout_global(Some(std::time::Duration::from_secs(30)))
268 .max_redirects(0)
269 .tls_config(
270 ureq::tls::TlsConfig::builder()
271 .provider(ureq::tls::TlsProvider::NativeTls)
272 .disable_verification(true)
273 .build(),
274 )
275 .build()
276 .new_agent())
277}
278
279pub(crate) fn strip_cidr(ip: &str) -> &str {
283 if let Some(pos) = ip.rfind('/') {
285 if ip[pos + 1..].bytes().all(|b| b.is_ascii_digit()) && pos + 1 < ip.len() {
286 return &ip[..pos];
287 }
288 }
289 ip
290}
291
292pub(crate) fn percent_encode(s: &str) -> String {
295 let mut result = String::with_capacity(s.len());
296 for byte in s.bytes() {
297 match byte {
298 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
299 result.push(byte as char);
300 }
301 _ => {
302 result.push_str(&format!("%{:02X}", byte));
303 }
304 }
305 }
306 result
307}
308
309pub(crate) struct EpochDate {
311 pub year: u64,
312 pub month: u64, pub day: u64, pub hours: u64,
315 pub minutes: u64,
316 pub seconds: u64,
317 pub epoch_days: u64,
319}
320
321pub(crate) fn epoch_to_date(epoch_secs: u64) -> EpochDate {
323 let secs_per_day = 86400u64;
324 let epoch_days = epoch_secs / secs_per_day;
325 let mut remaining_days = epoch_days;
326 let day_secs = epoch_secs % secs_per_day;
327
328 let mut year = 1970u64;
329 loop {
330 let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
331 let days_in_year = if leap { 366 } else { 365 };
332 if remaining_days < days_in_year {
333 break;
334 }
335 remaining_days -= days_in_year;
336 year += 1;
337 }
338
339 let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
340 let days_per_month: [u64; 12] = [
341 31,
342 if leap { 29 } else { 28 },
343 31,
344 30,
345 31,
346 30,
347 31,
348 31,
349 30,
350 31,
351 30,
352 31,
353 ];
354 let mut month = 0usize;
355 while month < 12 && remaining_days >= days_per_month[month] {
356 remaining_days -= days_per_month[month];
357 month += 1;
358 }
359
360 EpochDate {
361 year,
362 month: (month + 1) as u64,
363 day: remaining_days + 1,
364 hours: day_secs / 3600,
365 minutes: (day_secs % 3600) / 60,
366 seconds: day_secs % 60,
367 epoch_days,
368 }
369}
370
371fn map_ureq_error(err: ureq::Error) -> ProviderError {
373 match err {
374 ureq::Error::StatusCode(code) => match code {
375 401 | 403 => {
376 error!("[external] HTTP {code}: authentication failed");
377 ProviderError::AuthFailed
378 }
379 429 => {
380 warn!("[external] HTTP 429: rate limited");
381 ProviderError::RateLimited
382 }
383 _ => {
384 error!("[external] HTTP {code}");
385 ProviderError::Http(format!("HTTP {}", code))
386 }
387 },
388 other => {
389 error!("[external] Request failed: {other}");
390 ProviderError::Http(other.to_string())
391 }
392 }
393}
394
395#[cfg(test)]
396#[path = "mod_tests.rs"]
397mod tests;