Skip to main content

purple_ssh/providers/
mod.rs

1pub mod config;
2mod digitalocean;
3mod hetzner;
4mod linode;
5pub mod sync;
6mod upcloud;
7mod vultr;
8
9use std::sync::atomic::AtomicBool;
10
11use thiserror::Error;
12
13/// A host discovered from a cloud provider API.
14#[derive(Debug, Clone)]
15#[allow(dead_code)]
16pub struct ProviderHost {
17    /// Provider-assigned server ID.
18    pub server_id: String,
19    /// Server name/label.
20    pub name: String,
21    /// Public IP address (IPv4 or IPv6).
22    pub ip: String,
23    /// Provider tags/labels.
24    pub tags: Vec<String>,
25}
26
27/// Errors from provider API calls.
28#[derive(Debug, Error)]
29pub enum ProviderError {
30    #[error("HTTP error: {0}")]
31    Http(String),
32    #[error("Failed to parse response: {0}")]
33    Parse(String),
34    #[error("Authentication failed. Check your API token.")]
35    AuthFailed,
36    #[error("Rate limited. Try again in a moment.")]
37    RateLimited,
38    #[error("Cancelled.")]
39    Cancelled,
40}
41
42/// Trait implemented by each cloud provider.
43pub trait Provider {
44    /// Full provider name (e.g. "digitalocean").
45    fn name(&self) -> &str;
46    /// Short label for aliases (e.g. "do").
47    fn short_label(&self) -> &str;
48    /// Fetch hosts with cancellation support.
49    fn fetch_hosts_cancellable(
50        &self,
51        token: &str,
52        cancel: &AtomicBool,
53    ) -> Result<Vec<ProviderHost>, ProviderError>;
54    /// Fetch all servers from the provider API.
55    fn fetch_hosts(&self, token: &str) -> Result<Vec<ProviderHost>, ProviderError> {
56        self.fetch_hosts_cancellable(token, &AtomicBool::new(false))
57    }
58}
59
60/// All known provider names.
61pub const PROVIDER_NAMES: &[&str] = &["digitalocean", "vultr", "linode", "hetzner", "upcloud"];
62
63/// Get a provider implementation by name.
64pub fn get_provider(name: &str) -> Option<Box<dyn Provider>> {
65    match name {
66        "digitalocean" => Some(Box::new(digitalocean::DigitalOcean)),
67        "vultr" => Some(Box::new(vultr::Vultr)),
68        "linode" => Some(Box::new(linode::Linode)),
69        "hetzner" => Some(Box::new(hetzner::Hetzner)),
70        "upcloud" => Some(Box::new(upcloud::UpCloud)),
71        _ => None,
72    }
73}
74
75/// Display name for a provider (e.g. "digitalocean" -> "DigitalOcean").
76pub fn provider_display_name(name: &str) -> &str {
77    match name {
78        "digitalocean" => "DigitalOcean",
79        "vultr" => "Vultr",
80        "linode" => "Linode",
81        "hetzner" => "Hetzner",
82        "upcloud" => "UpCloud",
83        other => other,
84    }
85}
86
87/// Create an HTTP agent with explicit timeouts.
88pub(crate) fn http_agent() -> ureq::Agent {
89    ureq::AgentBuilder::new()
90        .timeout(std::time::Duration::from_secs(30))
91        .redirects(0)
92        .build()
93}
94
95/// Strip CIDR suffix (/64, /128, etc.) from an IP address.
96/// Some provider APIs return IPv6 addresses with prefix length (e.g. "2600:3c00::1/128").
97/// SSH requires bare addresses without CIDR notation.
98pub(crate) fn strip_cidr(ip: &str) -> &str {
99    // Only strip if it looks like a CIDR suffix (slash followed by digits)
100    if let Some(pos) = ip.rfind('/') {
101        if ip[pos + 1..].bytes().all(|b| b.is_ascii_digit()) && pos + 1 < ip.len() {
102            return &ip[..pos];
103        }
104    }
105    ip
106}
107
108/// Map a ureq error to a ProviderError.
109fn map_ureq_error(err: ureq::Error) -> ProviderError {
110    match err {
111        ureq::Error::Status(401, _) | ureq::Error::Status(403, _) => ProviderError::AuthFailed,
112        ureq::Error::Status(429, _) => ProviderError::RateLimited,
113        ureq::Error::Status(code, _) => ProviderError::Http(format!("HTTP {}", code)),
114        ureq::Error::Transport(t) => ProviderError::Http(t.to_string()),
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn test_strip_cidr_ipv6_with_prefix() {
124        assert_eq!(strip_cidr("2600:3c00::1/128"), "2600:3c00::1");
125        assert_eq!(strip_cidr("2a01:4f8::1/64"), "2a01:4f8::1");
126    }
127
128    #[test]
129    fn test_strip_cidr_bare_ipv6() {
130        assert_eq!(strip_cidr("2600:3c00::1"), "2600:3c00::1");
131    }
132
133    #[test]
134    fn test_strip_cidr_ipv4_passthrough() {
135        assert_eq!(strip_cidr("1.2.3.4"), "1.2.3.4");
136        assert_eq!(strip_cidr("10.0.0.1/24"), "10.0.0.1");
137    }
138
139    #[test]
140    fn test_strip_cidr_empty() {
141        assert_eq!(strip_cidr(""), "");
142    }
143}