Skip to main content

purple_ssh/providers/
mod.rs

1pub mod config;
2mod digitalocean;
3mod hetzner;
4mod linode;
5mod proxmox;
6pub mod sync;
7mod upcloud;
8mod vultr;
9
10use std::sync::atomic::AtomicBool;
11
12use thiserror::Error;
13
14/// A host discovered from a cloud provider API.
15#[derive(Debug, Clone)]
16#[allow(dead_code)]
17pub struct ProviderHost {
18    /// Provider-assigned server ID.
19    pub server_id: String,
20    /// Server name/label.
21    pub name: String,
22    /// Public IP address (IPv4 or IPv6).
23    pub ip: String,
24    /// Provider tags/labels.
25    pub tags: Vec<String>,
26}
27
28/// Errors from provider API calls.
29#[derive(Debug, Error)]
30pub enum ProviderError {
31    #[error("HTTP error: {0}")]
32    Http(String),
33    #[error("Failed to parse response: {0}")]
34    Parse(String),
35    #[error("Authentication failed. Check your API token.")]
36    AuthFailed,
37    #[error("Rate limited. Try again in a moment.")]
38    RateLimited,
39    #[error("Cancelled.")]
40    Cancelled,
41    /// Some hosts were fetched but others failed. The caller should use the
42    /// hosts but suppress destructive operations like --remove.
43    #[error("Partial result: {failures} of {total} failed")]
44    PartialResult {
45        hosts: Vec<ProviderHost>,
46        failures: usize,
47        total: usize,
48    },
49}
50
51/// Trait implemented by each cloud provider.
52pub trait Provider {
53    /// Full provider name (e.g. "digitalocean").
54    fn name(&self) -> &str;
55    /// Short label for aliases (e.g. "do").
56    fn short_label(&self) -> &str;
57    /// Fetch hosts with cancellation support.
58    fn fetch_hosts_cancellable(
59        &self,
60        token: &str,
61        cancel: &AtomicBool,
62    ) -> Result<Vec<ProviderHost>, ProviderError>;
63    /// Fetch all servers from the provider API.
64    #[allow(dead_code)]
65    fn fetch_hosts(&self, token: &str) -> Result<Vec<ProviderHost>, ProviderError> {
66        self.fetch_hosts_cancellable(token, &AtomicBool::new(false))
67    }
68    /// Fetch hosts with progress reporting. Default delegates to fetch_hosts_cancellable.
69    fn fetch_hosts_with_progress(
70        &self,
71        token: &str,
72        cancel: &AtomicBool,
73        _progress: &dyn Fn(&str),
74    ) -> Result<Vec<ProviderHost>, ProviderError> {
75        self.fetch_hosts_cancellable(token, cancel)
76    }
77}
78
79/// All known provider names.
80pub const PROVIDER_NAMES: &[&str] = &["digitalocean", "vultr", "linode", "hetzner", "upcloud", "proxmox"];
81
82/// Get a provider implementation by name.
83pub fn get_provider(name: &str) -> Option<Box<dyn Provider>> {
84    match name {
85        "digitalocean" => Some(Box::new(digitalocean::DigitalOcean)),
86        "vultr" => Some(Box::new(vultr::Vultr)),
87        "linode" => Some(Box::new(linode::Linode)),
88        "hetzner" => Some(Box::new(hetzner::Hetzner)),
89        "upcloud" => Some(Box::new(upcloud::UpCloud)),
90        "proxmox" => Some(Box::new(proxmox::Proxmox {
91            base_url: String::new(),
92            verify_tls: true,
93        })),
94        _ => None,
95    }
96}
97
98/// Get a provider implementation configured from a provider section.
99/// For providers that need extra config (e.g. Proxmox base URL), this
100/// creates a properly configured instance.
101pub fn get_provider_with_config(name: &str, section: &config::ProviderSection) -> Option<Box<dyn Provider>> {
102    match name {
103        "proxmox" => Some(Box::new(proxmox::Proxmox {
104            base_url: section.url.clone(),
105            verify_tls: section.verify_tls,
106        })),
107        _ => get_provider(name),
108    }
109}
110
111/// Display name for a provider (e.g. "digitalocean" -> "DigitalOcean").
112pub fn provider_display_name(name: &str) -> &str {
113    match name {
114        "digitalocean" => "DigitalOcean",
115        "vultr" => "Vultr",
116        "linode" => "Linode",
117        "hetzner" => "Hetzner",
118        "upcloud" => "UpCloud",
119        "proxmox" => "Proxmox VE",
120        other => other,
121    }
122}
123
124/// Create an HTTP agent with explicit timeouts.
125pub(crate) fn http_agent() -> ureq::Agent {
126    ureq::AgentBuilder::new()
127        .timeout(std::time::Duration::from_secs(30))
128        .redirects(0)
129        .build()
130}
131
132/// Create an HTTP agent that accepts invalid/self-signed TLS certificates.
133pub(crate) fn http_agent_insecure() -> Result<ureq::Agent, ProviderError> {
134    let tls = ureq::native_tls::TlsConnector::builder()
135        .danger_accept_invalid_certs(true)
136        .danger_accept_invalid_hostnames(true)
137        .build()
138        .map_err(|e| ProviderError::Http(format!("TLS setup failed: {}", e)))?;
139    Ok(ureq::AgentBuilder::new()
140        .timeout(std::time::Duration::from_secs(30))
141        .redirects(0)
142        .tls_connector(std::sync::Arc::new(tls))
143        .build())
144}
145
146/// Strip CIDR suffix (/64, /128, etc.) from an IP address.
147/// Some provider APIs return IPv6 addresses with prefix length (e.g. "2600:3c00::1/128").
148/// SSH requires bare addresses without CIDR notation.
149pub(crate) fn strip_cidr(ip: &str) -> &str {
150    // Only strip if it looks like a CIDR suffix (slash followed by digits)
151    if let Some(pos) = ip.rfind('/') {
152        if ip[pos + 1..].bytes().all(|b| b.is_ascii_digit()) && pos + 1 < ip.len() {
153            return &ip[..pos];
154        }
155    }
156    ip
157}
158
159/// Map a ureq error to a ProviderError.
160fn map_ureq_error(err: ureq::Error) -> ProviderError {
161    match err {
162        ureq::Error::Status(401, _) | ureq::Error::Status(403, _) => ProviderError::AuthFailed,
163        ureq::Error::Status(429, _) => ProviderError::RateLimited,
164        ureq::Error::Status(code, _) => ProviderError::Http(format!("HTTP {}", code)),
165        ureq::Error::Transport(t) => ProviderError::Http(t.to_string()),
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn test_strip_cidr_ipv6_with_prefix() {
175        assert_eq!(strip_cidr("2600:3c00::1/128"), "2600:3c00::1");
176        assert_eq!(strip_cidr("2a01:4f8::1/64"), "2a01:4f8::1");
177    }
178
179    #[test]
180    fn test_strip_cidr_bare_ipv6() {
181        assert_eq!(strip_cidr("2600:3c00::1"), "2600:3c00::1");
182    }
183
184    #[test]
185    fn test_strip_cidr_ipv4_passthrough() {
186        assert_eq!(strip_cidr("1.2.3.4"), "1.2.3.4");
187        assert_eq!(strip_cidr("10.0.0.1/24"), "10.0.0.1");
188    }
189
190    #[test]
191    fn test_strip_cidr_empty() {
192        assert_eq!(strip_cidr(""), "");
193    }
194}