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;
11mod netbox;
12pub mod oracle;
13pub mod ovh;
14mod proxmox;
15pub mod scaleway;
16pub mod sync;
17mod tailscale;
18mod teleport;
19mod transip;
20mod upcloud;
21mod vultr;
22
23pub use kind::ProviderKind;
24
25use std::sync::atomic::{AtomicBool, Ordering};
26
27use log::{debug, error, warn};
28use thiserror::Error;
29
30/// A host discovered from a cloud provider API.
31#[derive(Debug, Clone, Default)]
32pub struct ProviderHost {
33    /// Provider-assigned server ID.
34    pub server_id: String,
35    /// Server name/label.
36    pub name: String,
37    /// Public IP address (IPv4 or IPv6) or a hostname the transport resolves.
38    pub ip: String,
39    /// Provider tags/labels.
40    pub tags: Vec<String>,
41    /// Provider metadata (region, plan, etc.) as key-value pairs.
42    pub metadata: Vec<(String, String)>,
43    /// SSH port when the provider knows it differs from 22.
44    pub port: Option<u16>,
45    /// Extra ssh_config directives the provider manages on this host, such as
46    /// a `ProxyCommand`. Written on add and kept current on later syncs.
47    pub directives: Vec<(String, String)>,
48}
49
50impl ProviderHost {
51    /// Create a ProviderHost with no metadata.
52    #[allow(dead_code)]
53    pub fn new(server_id: String, name: String, ip: String, tags: Vec<String>) -> Self {
54        Self {
55            server_id,
56            name,
57            ip,
58            tags,
59            ..Default::default()
60        }
61    }
62}
63
64/// Errors from provider API calls.
65#[derive(Debug, Error)]
66pub enum ProviderError {
67    #[error("HTTP error: {0}")]
68    Http(String),
69    #[error("Failed to parse response: {0}")]
70    Parse(String),
71    #[error("Authentication failed. Check your API token.")]
72    AuthFailed,
73    #[error("Rate limited. Try again in a moment.")]
74    RateLimited,
75    #[error("{0}")]
76    Execute(String),
77    #[error("Cancelled.")]
78    Cancelled,
79    /// Some hosts were fetched but others failed. The caller should use the
80    /// hosts but suppress destructive operations like --remove.
81    #[error("Partial result: {failures} of {total} failed")]
82    PartialResult {
83        hosts: Vec<ProviderHost>,
84        failures: usize,
85        total: usize,
86    },
87}
88
89/// Trait implemented by each cloud provider.
90///
91/// The trait deliberately stays narrow: every provider implements
92/// `fetch_hosts_cancellable` itself rather than overriding a uniform
93/// fetch loop. Variation is too wide for a single shape. Linode and
94/// DigitalOcean use `?page=N&per_page=N`; GCP uses `?maxResults=N`
95/// plus a `pageToken` cursor across an aggregated multi-zone listing;
96/// Azure paginates across multiple subscriptions; AWS uses SigV4 plus
97/// the EC2 query API; Tailscale mixes Basic and Bearer auth depending
98/// on key prefix; Oracle signs each request with a per-tenancy RSA
99/// key.
100///
101/// What is shared lives in this module as free helpers so every
102/// provider opts in to the parts that apply:
103///   - `bearer_auth`: `Authorization: Bearer <token>` formatter.
104///   - `ProviderMetadata`: typed builder for `ProviderHost::metadata`.
105///   - `paginate`: cancellable page-walk with `MAX_PAGES` guard.
106///   - `http_agent` / `http_agent_insecure`: ureq agent setup.
107///   - `map_ureq_error`: HTTP error to `ProviderError` mapping
108///     (401 to `AuthFailed`, 429 to `RateLimited`, etc).
109///   - `strip_cidr`, `percent_encode`, `epoch_to_date`: string and
110///     URL helpers.
111///
112/// Adding a new provider: implement `fetch_hosts_cancellable` and
113/// reach for the helpers above instead of reimplementing them. Do
114/// not invent provider-specific copies of these primitives; that is
115/// where past parity bugs have hidden.
116pub trait Provider {
117    /// Full provider name (e.g. "digitalocean").
118    fn name(&self) -> &str;
119    /// Short label for aliases (e.g. "do").
120    fn short_label(&self) -> &str;
121    /// Fetch hosts with cancellation support. `env` carries the resolved
122    /// process environment (home directory, credential env vars) so the few
123    /// providers that read AWS credentials or expand `~` in key paths take
124    /// them from the injected snapshot instead of ambient `std::env` /
125    /// `dirs::home_dir`. Most providers ignore it.
126    #[allow(dead_code)]
127    fn fetch_hosts_cancellable(
128        &self,
129        token: &str,
130        cancel: &AtomicBool,
131        env: &crate::runtime::env::Env,
132    ) -> Result<Vec<ProviderHost>, ProviderError>;
133    /// Fetch all servers from the provider API.
134    #[allow(dead_code)]
135    fn fetch_hosts(
136        &self,
137        token: &str,
138        env: &crate::runtime::env::Env,
139    ) -> Result<Vec<ProviderHost>, ProviderError> {
140        self.fetch_hosts_cancellable(token, &AtomicBool::new(false), env)
141    }
142    /// Fetch hosts with progress reporting. Default delegates to fetch_hosts_cancellable.
143    #[allow(dead_code)]
144    fn fetch_hosts_with_progress(
145        &self,
146        token: &str,
147        cancel: &AtomicBool,
148        env: &crate::runtime::env::Env,
149        _progress: &dyn Fn(&str),
150    ) -> Result<Vec<ProviderHost>, ProviderError> {
151        self.fetch_hosts_cancellable(token, cancel, env)
152    }
153}
154
155/// Parse a comma-separated provider config field into a list of trimmed,
156/// non-empty entries. Used for regions/zones/subscriptions.
157fn parse_csv(s: &str) -> Vec<String> {
158    s.split(',')
159        .map(|s| s.trim().to_string())
160        .filter(|s| !s.is_empty())
161        .collect()
162}
163
164/// Format an `Authorization: Bearer <token>` header value. Centralised so
165/// the literal lives in one place across the 9-plus providers that use
166/// bearer auth; a typo in any of them would silently break sync.
167pub(crate) fn bearer_auth(token: &str) -> String {
168    format!("Bearer {token}")
169}
170
171/// Builder for `ProviderHost::metadata`. Replaces the
172/// `metadata.push(("region".to_string(), value.clone()))` boilerplate
173/// that repeated across every provider with a typed builder so callers
174/// only spell the value once and never clone strings by hand.
175///
176/// Conventions: keys are spelled as `&'static str` literals on each
177/// call so a typo is a compile error rather than a silent runtime
178/// mismatch downstream. The canonical key set (`region`, `status`,
179/// `plan`, `image`, `os`, `location`, `type`, `zone`, `size`, `instance`,
180/// `project`, `compartment`, `tenancy`) lives in the trait docs.
181#[derive(Debug, Default)]
182pub(crate) struct ProviderMetadata(Vec<(String, String)>);
183
184impl ProviderMetadata {
185    pub(crate) fn new() -> Self {
186        Self(Vec::new())
187    }
188
189    /// Append `(key, value)` and return the builder for chaining.
190    /// `value: impl Into<String>` accepts both `String` and `&str`, so
191    /// callers do not have to `.to_string()` their fields by hand.
192    pub(crate) fn push(&mut self, key: &'static str, value: impl Into<String>) -> &mut Self {
193        self.0.push((key.to_string(), value.into()));
194        self
195    }
196
197    /// Append `(key, value)` only when `value` is `Some`. Saves the
198    /// repeated `if let Some(v) = ... { metadata.push(...) }` wrapper.
199    pub(crate) fn push_opt<S: Into<String>>(
200        &mut self,
201        key: &'static str,
202        value: Option<S>,
203    ) -> &mut Self {
204        if let Some(v) = value {
205            self.0.push((key.to_string(), v.into()));
206        }
207        self
208    }
209
210    /// Consume the builder, yielding the canonical `Vec<(String,
211    /// String)>` shape that `ProviderHost::metadata` expects.
212    pub(crate) fn finish(self) -> Vec<(String, String)> {
213        self.0
214    }
215}
216
217/// Factory for a provider implementation from an optional config section.
218/// `None` yields a default-constructed instance; `Some(section)` wires the
219/// section's fields into the provider struct.
220type ProviderBuild = fn(Option<&config::ProviderSection>) -> Box<dyn Provider>;
221
222/// Static registry entry describing one provider. Adding a provider means
223/// adding exactly one `ProviderDescriptor` to `PROVIDERS` below.
224pub struct ProviderDescriptor {
225    /// Slug used in config files and aliases.
226    pub name: &'static str,
227    /// Human-readable name shown in the UI.
228    pub display: &'static str,
229    /// Builder. Must not allocate or fail.
230    pub build: ProviderBuild,
231}
232
233/// Single source of truth for the provider registry. Adding a new provider
234/// means one entry here plus the provider module itself.
235pub const PROVIDERS: &[ProviderDescriptor] = &[
236    ProviderDescriptor {
237        name: "digitalocean",
238        display: "DigitalOcean",
239        build: |_| Box::new(digitalocean::DigitalOcean),
240    },
241    ProviderDescriptor {
242        name: "vultr",
243        display: "Vultr",
244        build: |_| Box::new(vultr::Vultr),
245    },
246    ProviderDescriptor {
247        name: "linode",
248        display: "Linode",
249        build: |_| Box::new(linode::Linode),
250    },
251    ProviderDescriptor {
252        name: "hetzner",
253        display: "Hetzner",
254        build: |_| Box::new(hetzner::Hetzner),
255    },
256    ProviderDescriptor {
257        name: "upcloud",
258        display: "UpCloud",
259        build: |_| Box::new(upcloud::UpCloud),
260    },
261    ProviderDescriptor {
262        name: "proxmox",
263        display: "Proxmox VE",
264        build: |section| {
265            let s = section.cloned().unwrap_or_default();
266            Box::new(proxmox::Proxmox {
267                base_url: s.url,
268                verify_tls: s.verify_tls,
269            })
270        },
271    },
272    ProviderDescriptor {
273        name: "aws",
274        display: "AWS EC2",
275        build: |section| {
276            let s = section.cloned().unwrap_or_default();
277            Box::new(aws::Aws {
278                regions: parse_csv(&s.regions),
279                profile: s.profile,
280            })
281        },
282    },
283    ProviderDescriptor {
284        name: "scaleway",
285        display: "Scaleway",
286        build: |section| {
287            let s = section.cloned().unwrap_or_default();
288            Box::new(scaleway::Scaleway {
289                zones: parse_csv(&s.regions),
290            })
291        },
292    },
293    ProviderDescriptor {
294        name: "gcp",
295        display: "GCP",
296        build: |section| {
297            let s = section.cloned().unwrap_or_default();
298            Box::new(gcp::Gcp {
299                zones: parse_csv(&s.regions),
300                project: s.project,
301            })
302        },
303    },
304    ProviderDescriptor {
305        name: "azure",
306        display: "Azure",
307        build: |section| {
308            let s = section.cloned().unwrap_or_default();
309            Box::new(azure::Azure {
310                subscriptions: parse_csv(&s.regions),
311            })
312        },
313    },
314    ProviderDescriptor {
315        name: "tailscale",
316        display: "Tailscale",
317        build: |_| Box::new(tailscale::Tailscale),
318    },
319    ProviderDescriptor {
320        name: "oracle",
321        display: "Oracle Cloud",
322        build: |section| {
323            let s = section.cloned().unwrap_or_default();
324            Box::new(oracle::Oracle {
325                regions: parse_csv(&s.regions),
326                compartment: s.compartment,
327            })
328        },
329    },
330    ProviderDescriptor {
331        name: "ovh",
332        display: "OVHcloud",
333        // OVH overloads `regions` as the API endpoint (e.g. "ovh-eu").
334        // Known quirk flagged in the architecture review; kept as-is to
335        // avoid schema migration in this refactor.
336        build: |section| {
337            let s = section.cloned().unwrap_or_default();
338            Box::new(ovh::Ovh {
339                project: s.project,
340                endpoint: s.regions,
341            })
342        },
343    },
344    ProviderDescriptor {
345        name: "leaseweb",
346        display: "Leaseweb",
347        build: |_| Box::new(leaseweb::Leaseweb),
348    },
349    ProviderDescriptor {
350        name: "i3d",
351        display: "i3D.net",
352        build: |_| Box::new(i3d::I3d),
353    },
354    ProviderDescriptor {
355        name: "transip",
356        display: "TransIP",
357        build: |_| Box::new(transip::TransIp),
358    },
359    ProviderDescriptor {
360        name: "teleport",
361        display: "Teleport",
362        build: |section| {
363            let s = section.cloned().unwrap_or_default();
364            Box::new(teleport::Teleport {
365                identity_file: s.identity_file,
366            })
367        },
368    },
369    ProviderDescriptor {
370        name: "netbox",
371        display: "NetBox",
372        build: |section| {
373            let s = section.cloned().unwrap_or_default();
374            Box::new(netbox::NetBox {
375                base_url: s.url,
376                verify_tls: s.verify_tls,
377                filter: s.filter,
378            })
379        },
380    },
381];
382
383/// Look up a descriptor by bare provider name. Internal helper; public wrappers
384/// below strip any `:label` suffix before calling this, so callers cannot
385/// accidentally pass a labeled id (`proxmox:server1`) and silently miss.
386fn descriptor(name: &str) -> Option<&'static ProviderDescriptor> {
387    PROVIDERS.iter().find(|p| p.name == name)
388}
389
390/// Return the bare provider name, stripping an optional `:label` suffix.
391/// `ProviderConfigId` is the canonical home for this split; this helper keeps
392/// `&str`-only public APIs label-tolerant without forcing every caller to
393/// parse first.
394fn bare_provider_name(name: &str) -> &str {
395    name.split_once(':').map(|(p, _)| p).unwrap_or(name)
396}
397
398/// All known provider names, in registration order.
399pub const PROVIDER_NAMES: &[&str] = &[
400    "digitalocean",
401    "vultr",
402    "linode",
403    "hetzner",
404    "upcloud",
405    "proxmox",
406    "aws",
407    "scaleway",
408    "gcp",
409    "azure",
410    "tailscale",
411    "oracle",
412    "ovh",
413    "leaseweb",
414    "i3d",
415    "transip",
416    "teleport",
417    "netbox",
418];
419
420// Compile-time guard: PROVIDER_NAMES and PROVIDERS must stay in lockstep.
421const _: () = {
422    assert!(
423        PROVIDER_NAMES.len() == PROVIDERS.len(),
424        "PROVIDER_NAMES and PROVIDERS length must match",
425    );
426};
427
428/// Get a provider implementation by name with default configuration. Accepts
429/// either a bare provider name (`"proxmox"`) or a labeled id (`"proxmox:server1"`).
430pub fn get_provider(name: &str) -> Option<Box<dyn Provider>> {
431    descriptor(bare_provider_name(name)).map(|d| (d.build)(None))
432}
433
434/// Get a provider implementation configured from a provider section. The bare
435/// provider name comes from `section.id.provider`, so labeled configs resolve
436/// the right descriptor by construction; passing a separate `name` was
437/// historically a foot-gun (issue #51) where callers handed in the labeled id
438/// string and the lookup missed the registry.
439pub fn get_provider_with_config(section: &config::ProviderSection) -> Option<Box<dyn Provider>> {
440    descriptor(section.provider()).map(|d| (d.build)(Some(section)))
441}
442
443/// Display name for a provider (e.g. "digitalocean" -> "DigitalOcean"). Accepts
444/// either a bare name or a labeled id; unknown names fall back to the input.
445pub fn provider_display_name(name: &str) -> &str {
446    descriptor(bare_provider_name(name))
447        .map(|d| d.display)
448        .unwrap_or(name)
449}
450
451/// Create an HTTP agent with explicit timeouts.
452pub(crate) fn http_agent() -> ureq::Agent {
453    ureq::Agent::config_builder()
454        .timeout_global(Some(std::time::Duration::from_secs(30)))
455        .max_redirects(0)
456        .build()
457        .new_agent()
458}
459
460/// Create an HTTP agent that accepts invalid/self-signed TLS certificates.
461pub(crate) fn http_agent_insecure() -> Result<ureq::Agent, ProviderError> {
462    Ok(ureq::Agent::config_builder()
463        .timeout_global(Some(std::time::Duration::from_secs(30)))
464        .max_redirects(0)
465        .tls_config(
466            ureq::tls::TlsConfig::builder()
467                .provider(ureq::tls::TlsProvider::NativeTls)
468                .disable_verification(true)
469                .build(),
470        )
471        .build()
472        .new_agent())
473}
474
475/// Strip CIDR suffix (/64, /128, etc.) from an IP address.
476/// Some provider APIs return IPv6 addresses with prefix length (e.g. "2600:3c00::1/128").
477/// SSH requires bare addresses without CIDR notation.
478pub(crate) fn strip_cidr(ip: &str) -> &str {
479    // Only strip if it looks like a CIDR suffix (slash followed by digits)
480    if let Some(pos) = ip.rfind('/') {
481        if ip[pos + 1..].bytes().all(|b| b.is_ascii_digit()) && pos + 1 < ip.len() {
482            return &ip[..pos];
483        }
484    }
485    ip
486}
487
488/// RFC 3986 percent-encoding for URL query parameters.
489/// Encodes all characters except unreserved ones (A-Z, a-z, 0-9, '-', '_', '.', '~').
490pub(crate) fn percent_encode(s: &str) -> String {
491    let mut result = String::with_capacity(s.len());
492    for byte in s.bytes() {
493        match byte {
494            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
495                result.push(byte as char);
496            }
497            _ => {
498                result.push_str(&format!("%{:02X}", byte));
499            }
500        }
501    }
502    result
503}
504
505/// Date components from a Unix epoch timestamp (no chrono dependency).
506pub(crate) struct EpochDate {
507    pub year: u64,
508    pub month: u64, // 1-based
509    pub day: u64,   // 1-based
510    pub hours: u64,
511    pub minutes: u64,
512    pub seconds: u64,
513    /// Days since epoch (for weekday calculation)
514    pub epoch_days: u64,
515}
516
517/// Convert Unix epoch seconds to date components.
518pub(crate) fn epoch_to_date(epoch_secs: u64) -> EpochDate {
519    let secs_per_day = 86400u64;
520    let epoch_days = epoch_secs / secs_per_day;
521    let mut remaining_days = epoch_days;
522    let day_secs = epoch_secs % secs_per_day;
523
524    let mut year = 1970u64;
525    loop {
526        let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
527        let days_in_year = if leap { 366 } else { 365 };
528        if remaining_days < days_in_year {
529            break;
530        }
531        remaining_days -= days_in_year;
532        year += 1;
533    }
534
535    let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
536    let days_per_month: [u64; 12] = [
537        31,
538        if leap { 29 } else { 28 },
539        31,
540        30,
541        31,
542        30,
543        31,
544        31,
545        30,
546        31,
547        30,
548        31,
549    ];
550    let mut month = 0usize;
551    while month < 12 && remaining_days >= days_per_month[month] {
552        remaining_days -= days_per_month[month];
553        month += 1;
554    }
555
556    EpochDate {
557        year,
558        month: (month + 1) as u64,
559        day: remaining_days + 1,
560        hours: day_secs / 3600,
561        minutes: (day_secs % 3600) / 60,
562        seconds: day_secs % 60,
563        epoch_days,
564    }
565}
566
567/// Map a ureq error to a ProviderError.
568fn map_ureq_error(err: ureq::Error) -> ProviderError {
569    match err {
570        ureq::Error::StatusCode(code) => match code {
571            401 | 403 => {
572                error!("[external] HTTP {code}: authentication failed");
573                ProviderError::AuthFailed
574            }
575            429 => {
576                warn!("[external] HTTP 429: rate limited");
577                ProviderError::RateLimited
578            }
579            _ => {
580                error!("[external] HTTP {code}");
581                ProviderError::Http(format!("HTTP {}", code))
582            }
583        },
584        other => {
585            error!("[external] Request failed: {other}");
586            ProviderError::Http(other.to_string())
587        }
588    }
589}
590
591/// Upper bound on pages fetched from one paginated list endpoint. A safety
592/// valve for a provider that never signals its last page; 500 pages covers any
593/// realistic account.
594pub(crate) const MAX_PAGES: u64 = 500;
595
596/// One mapped page from a paginated list endpoint.
597pub(crate) struct PageResult {
598    /// Hosts mapped from this page, appended to the running total.
599    pub hosts: Vec<ProviderHost>,
600    /// Whether another page should be fetched.
601    pub more: bool,
602}
603
604/// Drive a paginated list endpoint under one shared cancellation, runaway-guard
605/// and partial-failure contract, so every JSON list provider behaves the same.
606///
607/// `fetch_page(index)` performs one request (0-based page index), parses it and
608/// maps entries into a `PageResult`. The closure owns its own cursor or
609/// page-number state across calls.
610///
611/// Failure policy, matching what `sync` relies on: a failure on the first page
612/// (nothing collected) propagates as a hard error so the provider is skipped
613/// and the config left untouched. A failure on a later page returns the hosts
614/// gathered so far as `PartialResult`, so add and update still run while remove
615/// and stale marking are suppressed upstream. `AuthFailed`, `RateLimited` and
616/// `Cancelled` always propagate immediately, even mid-run, because they
617/// invalidate the whole sync.
618pub(crate) fn paginate<F>(
619    cancel: &AtomicBool,
620    mut fetch_page: F,
621) -> Result<Vec<ProviderHost>, ProviderError>
622where
623    F: FnMut(u64) -> Result<PageResult, ProviderError>,
624{
625    let mut hosts = Vec::new();
626    let mut index = 0u64;
627    loop {
628        if cancel.load(Ordering::Relaxed) {
629            return Err(ProviderError::Cancelled);
630        }
631        match fetch_page(index) {
632            Ok(page) => {
633                hosts.extend(page.hosts);
634                if !page.more {
635                    return Ok(hosts);
636                }
637            }
638            Err(
639                e @ (ProviderError::Cancelled
640                | ProviderError::AuthFailed
641                | ProviderError::RateLimited),
642            ) => return Err(e),
643            Err(e) => {
644                if hosts.is_empty() {
645                    return Err(e);
646                }
647                debug!(
648                    "[external] paginate: page {} failed after {} hosts collected, returning partial result ({e})",
649                    index + 1,
650                    hosts.len()
651                );
652                return Err(ProviderError::PartialResult {
653                    hosts,
654                    failures: 1,
655                    total: (index + 1) as usize,
656                });
657            }
658        }
659        index += 1;
660        if index >= MAX_PAGES {
661            debug!(
662                "[purple] paginate: reached MAX_PAGES ({MAX_PAGES}) guard, stopping with {} hosts",
663                hosts.len()
664            );
665            return Ok(hosts);
666        }
667    }
668}
669
670#[cfg(test)]
671#[path = "mod_tests.rs"]
672mod tests;