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