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 bare provider name. Internal helper; public wrappers
266/// below strip any `:label` suffix before calling this, so callers cannot
267/// accidentally pass a labeled id (`proxmox:server1`) and silently miss.
268fn descriptor(name: &str) -> Option<&'static ProviderDescriptor> {
269    PROVIDERS.iter().find(|p| p.name == name)
270}
271
272/// Return the bare provider name, stripping an optional `:label` suffix.
273/// `ProviderConfigId` is the canonical home for this split; this helper keeps
274/// `&str`-only public APIs label-tolerant without forcing every caller to
275/// parse first.
276fn bare_provider_name(name: &str) -> &str {
277    name.split_once(':').map(|(p, _)| p).unwrap_or(name)
278}
279
280/// All known provider names, in registration order.
281pub 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
300// Compile-time guard: PROVIDER_NAMES and PROVIDERS must stay in lockstep.
301const _: () = {
302    assert!(
303        PROVIDER_NAMES.len() == PROVIDERS.len(),
304        "PROVIDER_NAMES and PROVIDERS length must match",
305    );
306};
307
308/// Get a provider implementation by name with default configuration. Accepts
309/// either a bare provider name (`"proxmox"`) or a labeled id (`"proxmox:server1"`).
310pub fn get_provider(name: &str) -> Option<Box<dyn Provider>> {
311    descriptor(bare_provider_name(name)).map(|d| (d.build)(None))
312}
313
314/// Get a provider implementation configured from a provider section. The bare
315/// provider name comes from `section.id.provider`, so labeled configs resolve
316/// the right descriptor by construction; passing a separate `name` was
317/// historically a foot-gun (issue #51) where callers handed in the labeled id
318/// string and the lookup missed the registry.
319pub fn get_provider_with_config(section: &config::ProviderSection) -> Option<Box<dyn Provider>> {
320    descriptor(section.provider()).map(|d| (d.build)(Some(section)))
321}
322
323/// Display name for a provider (e.g. "digitalocean" -> "DigitalOcean"). Accepts
324/// either a bare name or a labeled id; unknown names fall back to the input.
325pub fn provider_display_name(name: &str) -> &str {
326    descriptor(bare_provider_name(name))
327        .map(|d| d.display)
328        .unwrap_or(name)
329}
330
331/// Create an HTTP agent with explicit timeouts.
332pub(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
340/// Create an HTTP agent that accepts invalid/self-signed TLS certificates.
341pub(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
355/// Strip CIDR suffix (/64, /128, etc.) from an IP address.
356/// Some provider APIs return IPv6 addresses with prefix length (e.g. "2600:3c00::1/128").
357/// SSH requires bare addresses without CIDR notation.
358pub(crate) fn strip_cidr(ip: &str) -> &str {
359    // Only strip if it looks like a CIDR suffix (slash followed by digits)
360    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
368/// RFC 3986 percent-encoding for URL query parameters.
369/// Encodes all characters except unreserved ones (A-Z, a-z, 0-9, '-', '_', '.', '~').
370pub(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
385/// Date components from a Unix epoch timestamp (no chrono dependency).
386pub(crate) struct EpochDate {
387    pub year: u64,
388    pub month: u64, // 1-based
389    pub day: u64,   // 1-based
390    pub hours: u64,
391    pub minutes: u64,
392    pub seconds: u64,
393    /// Days since epoch (for weekday calculation)
394    pub epoch_days: u64,
395}
396
397/// Convert Unix epoch seconds to date components.
398pub(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
447/// Map a ureq error to a ProviderError.
448fn 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
471#[cfg(test)]
472#[path = "mod_tests.rs"]
473mod tests;