Skip to main content

purple_ssh/providers/
mod.rs

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/// A host discovered from a cloud provider API.
26#[derive(Debug, Clone)]
27pub struct ProviderHost {
28    /// Provider-assigned server ID.
29    pub server_id: String,
30    /// Server name/label.
31    pub name: String,
32    /// Public IP address (IPv4 or IPv6).
33    pub ip: String,
34    /// Provider tags/labels.
35    pub tags: Vec<String>,
36    /// Provider metadata (region, plan, etc.) as key-value pairs.
37    pub metadata: Vec<(String, String)>,
38}
39
40impl ProviderHost {
41    /// Create a ProviderHost with no metadata.
42    #[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/// Errors from provider API calls.
55#[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    /// Some hosts were fetched but others failed. The caller should use the
70    /// hosts but suppress destructive operations like --remove.
71    #[error("Partial result: {failures} of {total} failed")]
72    PartialResult {
73        hosts: Vec<ProviderHost>,
74        failures: usize,
75        total: usize,
76    },
77}
78
79/// Trait implemented by each cloud provider.
80pub trait Provider {
81    /// Full provider name (e.g. "digitalocean").
82    fn name(&self) -> &str;
83    /// Short label for aliases (e.g. "do").
84    fn short_label(&self) -> &str;
85    /// Fetch hosts with cancellation support.
86    #[allow(dead_code)]
87    fn fetch_hosts_cancellable(
88        &self,
89        token: &str,
90        cancel: &AtomicBool,
91    ) -> Result<Vec<ProviderHost>, ProviderError>;
92    /// Fetch all servers from the provider API.
93    #[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    /// Fetch hosts with progress reporting. Default delegates to fetch_hosts_cancellable.
98    #[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
109/// All known provider names.
110pub 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
129/// Get a provider implementation by name.
130pub 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
169/// Get a provider implementation configured from a provider section.
170/// For providers that need extra config (e.g. Proxmox base URL), this
171/// creates a properly configured instance.
172pub 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
232/// Display name for a provider (e.g. "digitalocean" -> "DigitalOcean").
233pub 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
255/// Create an HTTP agent with explicit timeouts.
256pub(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
264/// Create an HTTP agent that accepts invalid/self-signed TLS certificates.
265pub(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
279/// Strip CIDR suffix (/64, /128, etc.) from an IP address.
280/// Some provider APIs return IPv6 addresses with prefix length (e.g. "2600:3c00::1/128").
281/// SSH requires bare addresses without CIDR notation.
282pub(crate) fn strip_cidr(ip: &str) -> &str {
283    // Only strip if it looks like a CIDR suffix (slash followed by digits)
284    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
292/// RFC 3986 percent-encoding for URL query parameters.
293/// Encodes all characters except unreserved ones (A-Z, a-z, 0-9, '-', '_', '.', '~').
294pub(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
309/// Date components from a Unix epoch timestamp (no chrono dependency).
310pub(crate) struct EpochDate {
311    pub year: u64,
312    pub month: u64, // 1-based
313    pub day: u64,   // 1-based
314    pub hours: u64,
315    pub minutes: u64,
316    pub seconds: u64,
317    /// Days since epoch (for weekday calculation)
318    pub epoch_days: u64,
319}
320
321/// Convert Unix epoch seconds to date components.
322pub(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
371/// Map a ureq error to a ProviderError.
372fn 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;