purple_ssh/providers/
mod.rs1pub 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#[derive(Debug, Clone)]
15#[allow(dead_code)]
16pub struct ProviderHost {
17 pub server_id: String,
19 pub name: String,
21 pub ip: String,
23 pub tags: Vec<String>,
25}
26
27#[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
42pub trait Provider {
44 fn name(&self) -> &str;
46 fn short_label(&self) -> &str;
48 fn fetch_hosts(&self, token: &str) -> Result<Vec<ProviderHost>, ProviderError>;
50 fn fetch_hosts_cancellable(
52 &self,
53 token: &str,
54 cancel: &AtomicBool,
55 ) -> Result<Vec<ProviderHost>, ProviderError> {
56 let _ = cancel;
57 self.fetch_hosts(token)
58 }
59}
60
61pub const PROVIDER_NAMES: &[&str] = &["digitalocean", "vultr", "linode", "hetzner", "upcloud"];
63
64pub fn get_provider(name: &str) -> Option<Box<dyn Provider>> {
66 match name {
67 "digitalocean" => Some(Box::new(digitalocean::DigitalOcean)),
68 "vultr" => Some(Box::new(vultr::Vultr)),
69 "linode" => Some(Box::new(linode::Linode)),
70 "hetzner" => Some(Box::new(hetzner::Hetzner)),
71 "upcloud" => Some(Box::new(upcloud::UpCloud)),
72 _ => None,
73 }
74}
75
76pub fn provider_display_name(name: &str) -> &str {
78 match name {
79 "digitalocean" => "DigitalOcean",
80 "vultr" => "Vultr",
81 "linode" => "Linode",
82 "hetzner" => "Hetzner",
83 "upcloud" => "UpCloud",
84 other => other,
85 }
86}
87
88pub(crate) fn http_agent() -> ureq::Agent {
90 ureq::AgentBuilder::new()
91 .timeout(std::time::Duration::from_secs(30))
92 .build()
93}
94
95pub(crate) fn strip_cidr(ip: &str) -> &str {
99 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
108fn 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}