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