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;
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/// A host discovered from a cloud provider API.
29#[derive(Debug, Clone)]
30pub struct ProviderHost {
31    /// Provider-assigned server ID.
32    pub server_id: String,
33    /// Server name/label.
34    pub name: String,
35    /// Public IP address (IPv4 or IPv6).
36    pub ip: String,
37    /// Provider tags/labels.
38    pub tags: Vec<String>,
39    /// Provider metadata (region, plan, etc.) as key-value pairs.
40    pub metadata: Vec<(String, String)>,
41}
42
43impl ProviderHost {
44    /// Create a ProviderHost with no metadata.
45    #[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/// Errors from provider API calls.
58#[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    /// Some hosts were fetched but others failed. The caller should use the
73    /// hosts but suppress destructive operations like --remove.
74    #[error("Partial result: {failures} of {total} failed")]
75    PartialResult {
76        hosts: Vec<ProviderHost>,
77        failures: usize,
78        total: usize,
79    },
80}
81
82/// Trait implemented by each cloud provider.
83pub trait Provider {
84    /// Full provider name (e.g. "digitalocean").
85    fn name(&self) -> &str;
86    /// Short label for aliases (e.g. "do").
87    fn short_label(&self) -> &str;
88    /// Fetch hosts with cancellation support.
89    #[allow(dead_code)]
90    fn fetch_hosts_cancellable(
91        &self,
92        token: &str,
93        cancel: &AtomicBool,
94    ) -> Result<Vec<ProviderHost>, ProviderError>;
95    /// Fetch all servers from the provider API.
96    #[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    /// Fetch hosts with progress reporting. Default delegates to fetch_hosts_cancellable.
101    #[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
112/// Parse a comma-separated provider config field into a list of trimmed,
113/// non-empty entries. Used for regions/zones/subscriptions.
114fn 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
121/// Factory for a provider implementation from an optional config section.
122/// `None` yields a default-constructed instance; `Some(section)` wires the
123/// section's fields into the provider struct.
124type ProviderBuild = fn(Option<&config::ProviderSection>) -> Box<dyn Provider>;
125
126/// Static registry entry describing one provider. Adding a provider means
127/// adding exactly one `ProviderDescriptor` to `PROVIDERS` below.
128pub struct ProviderDescriptor {
129    /// Slug used in config files and aliases.
130    pub name: &'static str,
131    /// Human-readable name shown in the UI.
132    pub display: &'static str,
133    /// Builder. Must not allocate or fail.
134    pub build: ProviderBuild,
135}
136
137/// Single source of truth for the provider registry. Adding a new provider
138/// means one entry here plus the provider module itself.
139pub 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        // OVH overloads `regions` as the API endpoint (e.g. "ovh-eu").
238        // Known quirk flagged in the architecture review; kept as-is to
239        // avoid schema migration in this refactor.
240        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
265/// Look up a descriptor by name.
266fn descriptor(name: &str) -> Option<&'static ProviderDescriptor> {
267    PROVIDERS.iter().find(|p| p.name == name)
268}
269
270/// All known provider names, in registration order.
271pub 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
290// Compile-time guard: PROVIDER_NAMES and PROVIDERS must stay in lockstep.
291const _: () = {
292    assert!(
293        PROVIDER_NAMES.len() == PROVIDERS.len(),
294        "PROVIDER_NAMES and PROVIDERS length must match",
295    );
296};
297
298/// Get a provider implementation by name with default configuration.
299pub fn get_provider(name: &str) -> Option<Box<dyn Provider>> {
300    descriptor(name).map(|d| (d.build)(None))
301}
302
303/// Get a provider implementation configured from a provider section.
304pub 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
311/// Display name for a provider (e.g. "digitalocean" -> "DigitalOcean").
312pub fn provider_display_name(name: &str) -> &str {
313    descriptor(name).map(|d| d.display).unwrap_or(name)
314}
315
316/// Create an HTTP agent with explicit timeouts.
317pub(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
325/// Create an HTTP agent that accepts invalid/self-signed TLS certificates.
326pub(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
340/// Strip CIDR suffix (/64, /128, etc.) from an IP address.
341/// Some provider APIs return IPv6 addresses with prefix length (e.g. "2600:3c00::1/128").
342/// SSH requires bare addresses without CIDR notation.
343pub(crate) fn strip_cidr(ip: &str) -> &str {
344    // Only strip if it looks like a CIDR suffix (slash followed by digits)
345    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
353/// RFC 3986 percent-encoding for URL query parameters.
354/// Encodes all characters except unreserved ones (A-Z, a-z, 0-9, '-', '_', '.', '~').
355pub(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
370/// Date components from a Unix epoch timestamp (no chrono dependency).
371pub(crate) struct EpochDate {
372    pub year: u64,
373    pub month: u64, // 1-based
374    pub day: u64,   // 1-based
375    pub hours: u64,
376    pub minutes: u64,
377    pub seconds: u64,
378    /// Days since epoch (for weekday calculation)
379    pub epoch_days: u64,
380}
381
382/// Convert Unix epoch seconds to date components.
383pub(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
432/// Map a ureq error to a ProviderError.
433fn 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;