1use anyhow::{bail, Context, Result};
19use async_trait::async_trait;
20use floating_ip::{FloatingIpMachine, FloatingIpProvider, FloatingIpState, FloatingIpTarget};
21use serde::Deserialize;
22
23const HETZNER_BASE: &str = "https://api.hetzner.cloud/v1";
24
25#[derive(Clone)]
28pub struct HetznerFloatingIp {
29 http: reqwest::Client,
30 token: String,
31 base_url: String,
32}
33
34impl HetznerFloatingIp {
35 pub fn new(token: impl Into<String>) -> Self {
37 Self {
38 http: reqwest::Client::new(),
39 token: token.into(),
40 base_url: HETZNER_BASE.to_string(),
41 }
42 }
43
44 pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
47 self.base_url = url.into();
48 self
49 }
50}
51
52#[async_trait]
53impl FloatingIpProvider for HetznerFloatingIp {
54 fn id(&self) -> &'static str {
55 "hetzner"
56 }
57
58 async fn resolve_target(&self, machine: &FloatingIpMachine) -> Result<FloatingIpTarget> {
62 let zone = hetzner_network_zone(machine.location()).with_context(|| {
63 format!(
64 "floating_ip: resolving target for machine {:?}",
65 machine.name
66 )
67 })?;
68 let resp = self
69 .http
70 .get(format!("{}/servers", self.base_url))
71 .query(&[("name", machine.name.as_str())])
72 .bearer_auth(&self.token)
73 .send()
74 .await
75 .context("hetzner: GET /servers")?;
76 let status = resp.status();
77 if !status.is_success() {
78 let body = resp.text().await.unwrap_or_default();
79 bail!("hetzner GET /servers failed: {status} {body}");
80 }
81 let parsed: HetznerServersResponse = resp
82 .json()
83 .await
84 .context("hetzner: decode GET /servers response")?;
85 let server = parsed
86 .servers
87 .into_iter()
88 .find(|s| s.name.as_deref() == Some(machine.name.as_str()))
89 .with_context(|| format!("hetzner: no server named {:?}", machine.name))?;
90 Ok(FloatingIpTarget {
91 attach_id: server.id.to_string(),
92 zone: zone.to_string(),
93 })
94 }
95
96 async fn current_assignment(&self, ip_id: &str) -> Result<FloatingIpState> {
98 let resp = self
99 .http
100 .get(format!("{}/floating_ips/{}", self.base_url, ip_id))
101 .bearer_auth(&self.token)
102 .send()
103 .await
104 .context("hetzner: GET /floating_ips/{id}")?;
105 let status = resp.status();
106 if !status.is_success() {
107 let body = resp.text().await.unwrap_or_default();
108 bail!("hetzner GET /floating_ips/{ip_id} failed: {status} {body}");
109 }
110 let parsed: HetznerFloatingIpResponse = resp
111 .json()
112 .await
113 .context("hetzner: decode GET /floating_ips/{id} response")?;
114 Ok(FloatingIpState {
115 zone: parsed.floating_ip.home_location.network_zone,
116 attached_to: parsed.floating_ip.server.map(|id| id.to_string()),
117 })
118 }
119
120 async fn reassign(&self, ip_id: &str, target: &FloatingIpTarget) -> Result<()> {
122 let server_id: u64 = target.attach_id.parse().with_context(|| {
123 format!(
124 "hetzner: attach_id {:?} is not a numeric server id",
125 target.attach_id
126 )
127 })?;
128 let resp = self
129 .http
130 .post(format!(
131 "{}/floating_ips/{}/actions/assign",
132 self.base_url, ip_id
133 ))
134 .bearer_auth(&self.token)
135 .json(&serde_json::json!({ "server": server_id }))
136 .send()
137 .await
138 .context("hetzner: POST /floating_ips/{id}/actions/assign")?;
139 let status = resp.status();
140 if !status.is_success() {
141 let body = resp.text().await.unwrap_or_default();
142 bail!("hetzner POST /floating_ips/{ip_id}/actions/assign failed: {status} {body}");
143 }
144 Ok(())
145 }
146}
147
148fn hetzner_network_zone(location: &str) -> Result<&'static str> {
153 match location {
154 "hil" => Ok("us-west"),
155 "ash" => Ok("us-east"),
156 "fsn1" | "nbg1" | "hel1" => Ok("eu-central"),
157 "sin" => Ok("ap-southeast"),
158 "" => bail!("hetzner: machine has no `location` set — required to derive its network zone"),
159 other => bail!("hetzner: unknown location {other:?}, cannot derive network zone"),
160 }
161}
162
163#[derive(Deserialize)]
164struct HetznerServersResponse {
165 servers: Vec<HetznerServerLite>,
166}
167
168#[derive(Deserialize)]
169struct HetznerServerLite {
170 id: u64,
171 #[serde(default)]
172 name: Option<String>,
173}
174
175#[derive(Deserialize)]
176struct HetznerFloatingIpResponse {
177 floating_ip: HetznerFloatingIpBody,
178}
179
180#[derive(Deserialize)]
181struct HetznerFloatingIpBody {
182 home_location: HetznerHomeLocation,
183 server: Option<u64>,
184}
185
186#[derive(Deserialize)]
187struct HetznerHomeLocation {
188 network_zone: String,
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use floating_ip::on_ingress_owner_changed;
195 use std::sync::atomic::{AtomicU32, Ordering};
196 use std::sync::{Arc, Mutex};
197
198 fn hil_machine(name: &str) -> FloatingIpMachine {
199 FloatingIpMachine {
200 name: name.into(),
201 provider: "hetzner".into(),
202 location: Some("hil".into()),
203 region: Some("us-west".into()),
204 ingress_floating_ip: None,
205 }
206 }
207
208 async fn spawn_mock(
216 network_zone: &'static str,
217 initial_server: Option<u64>,
218 ) -> (String, Arc<AtomicU32>, tokio::task::JoinHandle<()>) {
219 let attached = Arc::new(Mutex::new(initial_server));
220 let assign_calls = Arc::new(AtomicU32::new(0));
221
222 let servers_route = {
223 axum::routing::get(move || async move {
224 axum::Json(serde_json::json!({ "servers": [ { "id": 555, "name": "edge-a" } ] }))
225 })
226 };
227
228 let floating_ip_get = {
229 let attached = attached.clone();
230 axum::routing::get(move || {
231 let attached = attached.clone();
232 async move {
233 let server = *attached.lock().unwrap();
234 axum::Json(serde_json::json!({
235 "floating_ip": {
236 "home_location": { "network_zone": network_zone },
237 "server": server,
238 }
239 }))
240 }
241 })
242 };
243
244 let assign_route = {
245 let attached = attached.clone();
246 let calls = assign_calls.clone();
247 axum::routing::post(move |axum::Json(body): axum::Json<serde_json::Value>| {
248 let attached = attached.clone();
249 let calls = calls.clone();
250 async move {
251 calls.fetch_add(1, Ordering::SeqCst);
252 let server = body.get("server").and_then(|v| v.as_u64());
253 *attached.lock().unwrap() = server;
254 axum::Json(serde_json::json!({ "action": { "status": "success" } }))
255 }
256 })
257 };
258
259 let app = axum::Router::new()
260 .route("/servers", servers_route)
261 .route("/floating_ips/{id}", floating_ip_get)
262 .route("/floating_ips/{id}/actions/assign", assign_route);
263
264 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
265 let addr = listener.local_addr().unwrap();
266 let handle = tokio::spawn(async move {
267 let _ = axum::serve(listener, app).await;
268 });
269
270 (format!("http://{addr}"), assign_calls, handle)
271 }
272
273 #[tokio::test]
274 async fn ingress_owner_flip_drives_exactly_one_reassign_call() {
275 let (base, calls, handle) = spawn_mock("us-west", Some(999)).await;
279 let client = HetznerFloatingIp::new("test-token").with_base_url(base);
280 let machine = hil_machine("edge-a");
281
282 let outcome = on_ingress_owner_changed(&client, &machine, "42")
283 .await
284 .unwrap();
285 assert!(outcome.reassigned, "owner flip must drive a reassign");
286 assert_eq!(outcome.attached_to, "555");
287 assert_eq!(calls.load(Ordering::SeqCst), 1);
288
289 handle.abort();
290 }
291
292 #[tokio::test]
293 async fn reapplying_the_same_owner_is_a_zero_call_noop() {
294 let (base, calls, handle) = spawn_mock("us-west", Some(555)).await;
296 let client = HetznerFloatingIp::new("test-token").with_base_url(base);
297 let machine = hil_machine("edge-a");
298
299 let outcome = on_ingress_owner_changed(&client, &machine, "42")
300 .await
301 .unwrap();
302 assert!(
303 !outcome.reassigned,
304 "re-applying the same owner must be a no-op"
305 );
306 assert_eq!(
307 calls.load(Ordering::SeqCst),
308 0,
309 "must not call the reassign endpoint"
310 );
311
312 handle.abort();
313 }
314
315 #[tokio::test]
316 async fn cross_zone_target_is_rejected_before_any_reassign_call() {
317 let (base, calls, handle) = spawn_mock("eu-central", None).await;
319 let client = HetznerFloatingIp::new("test-token").with_base_url(base);
320 let machine = hil_machine("edge-a"); let err = on_ingress_owner_changed(&client, &machine, "42")
323 .await
324 .unwrap_err();
325 let msg = format!("{err:#}");
326 assert!(
327 msg.contains("zone"),
328 "expected a zone-mismatch error, got: {msg}"
329 );
330 assert_eq!(
331 calls.load(Ordering::SeqCst),
332 0,
333 "zone mismatch must never call reassign"
334 );
335
336 handle.abort();
337 }
338
339 #[test]
340 fn hetzner_network_zone_maps_known_locations() {
341 assert_eq!(hetzner_network_zone("hil").unwrap(), "us-west");
342 assert_eq!(hetzner_network_zone("ash").unwrap(), "us-east");
343 assert_eq!(hetzner_network_zone("fsn1").unwrap(), "eu-central");
344 assert_eq!(hetzner_network_zone("nbg1").unwrap(), "eu-central");
345 assert_eq!(hetzner_network_zone("hel1").unwrap(), "eu-central");
346 }
347
348 #[test]
349 fn hetzner_network_zone_rejects_unknown_or_missing() {
350 assert!(hetzner_network_zone("mars1").is_err());
351 assert!(hetzner_network_zone("").is_err());
352 }
353}