| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | |
|
| 9 | |
|
| 10 | |
|
| 11 | |
|
| 12 | | use crate::api::ApiError; |
| 13 | | use crate::db; |
| 14 | | use crate::Shared; |
| 15 | | use axum::extract::State; |
| 16 | | use axum::http::{HeaderMap, StatusCode}; |
| 17 | | use axum::Json; |
| 18 | | use flow_core::api::{Announce, Announced, SwarmAddr, SwarmPeer}; |
| 19 | | use jet::swarm::{Ticket, TICKET_LIFE_MS}; |
| 20 | | use std::collections::HashMap; |
| 21 | | use std::sync::Mutex; |
| 22 | |
|
| 23 | |
|
| 24 | | pub const INTERVAL_S: u64 = 15; |
| 25 | |
|
| 26 | | const ADDR_FRESH_MS: i64 = 2 * 60 * 1000; |
| 27 | |
|
| 28 | | const FETCHING_FRESH_MS: i64 = 3 * INTERVAL_S as i64 * 1000; |
| 29 | |
|
| 30 | | const TICKET_RENEW_MS: u64 = 5 * 60 * 1000; |
| 31 | |
|
| 32 | | #[derive(Default)] |
| 33 | | pub struct Tracker { |
| 34 | | |
| 35 | | addrs: Mutex<HashMap<i64, (SwarmAddr, i64)>>, |
| 36 | | |
| 37 | | fetching: Mutex<HashMap<String, HashMap<i64, i64>>>, |
| 38 | | |
| 39 | | tickets: Mutex<HashMap<i64, (String, u64)>>, |
| 40 | | } |
| 41 | |
|
| 42 | | impl Tracker { |
| 43 | | |
| 44 | | |
| 45 | 224× | pub fn claimed(&self, runner: i64, swarm: Option<&SwarmAddr>) { |
| 46 | 224× | let mut addrs = self.addrs.lock().unwrap_or_else(|p| p.into_inner()); |
| 47 | | |
| 48 | | |
| 49 | 224× | match swarm.filter(|s| plausible(&s.addr) && jet::images::is_hash(&s.cert)) { |
| 50 | 5× | Some(s) => { |
| 51 | 5× | addrs.insert(runner, (s.clone(), db::now_ms())); |
| 52 | 5× | } |
| 53 | 219× | None => { |
| 54 | 219× | addrs.remove(&runner); |
| 55 | 219× | } |
| 56 | | } |
| 57 | 224× | } |
| 58 | |
|
| 59 | | |
| 60 | 335× | pub fn addr(&self, runner: i64) -> Option<SwarmAddr> { |
| 61 | 335× | let addrs = self.addrs.lock().unwrap_or_else(|p| p.into_inner()); |
| 62 | 335× | addrs.get(&runner).filter(|(_, seen)| db::now_ms() - seen < ADDR_FRESH_MS).map(|(a, _)| a.clone()) |
| 63 | 335× | } |
| 64 | |
|
| 65 | 4× | fn fetch(&self, image: &str, runner: i64, state: &str) { |
| 66 | 4× | let mut fetching = self.fetching.lock().unwrap_or_else(|p| p.into_inner()); |
| 67 | 4× | let now = db::now_ms(); |
| 68 | 4× | for who in fetching.values_mut() { |
| 69 | 4× | who.retain(|_, seen| now - *seen < FETCHING_FRESH_MS); |
| 70 | | } |
| 71 | 4× | fetching.retain(|_, who| !who.is_empty()); |
| 72 | 4× | if state == "fetching" { |
| 73 | 3× | fetching.entry(image.to_string()).or_default().insert(runner, now); |
| 74 | 3× | } else if let Some(who) = fetching.get_mut(image) { |
| 75 | 1× | who.remove(&runner); |
| 76 | 1× | } |
| 77 | 4× | } |
| 78 | |
|
| 79 | | |
| 80 | 2× | pub fn fetching_now(&self) -> HashMap<String, Vec<i64>> { |
| 81 | 2× | let fetching = self.fetching.lock().unwrap_or_else(|p| p.into_inner()); |
| 82 | 2× | let now = db::now_ms(); |
| 83 | 2× | fetching |
| 84 | 2× | .iter() |
| 85 | 2× | .map(|(image, who)| { |
| 86 | 0× | (image.clone(), who.iter().filter(|(_, seen)| now - **seen < FETCHING_FRESH_MS).map(|(r, _)| *r).collect::<Vec<_>>()) |
| 87 | 0× | }) |
| 88 | 2× | .filter(|(_, who)| !who.is_empty()) |
| 89 | 2× | .collect() |
| 90 | 2× | } |
| 91 | |
|
| 92 | 4× | fn fetchers(&self, image: &str) -> Vec<i64> { |
| 93 | 4× | let fetching = self.fetching.lock().unwrap_or_else(|p| p.into_inner()); |
| 94 | 4× | fetching.get(image).map(|who| who.keys().copied().collect()).unwrap_or_default() |
| 95 | 4× | } |
| 96 | | } |
| 97 | |
|
| 98 | |
|
| 99 | 13× | fn plausible(addr: &str) -> bool { |
| 100 | 13× | addr.len() <= 255 && addr.rsplit_once(':').is_some_and(|(host, port)| !host.is_empty() && port.parse::<u16>().is_ok_and(|p| p > 0)) |
| 101 | 13× | } |
| 102 | |
|
| 103 | |
|
| 104 | |
|
| 105 | 8× | pub async fn announce(State(app): State<Shared>, headers: HeaderMap, Json(a): Json<Announce>) -> Result<Json<Announced>, ApiError> { |
| 106 | 8× | let me = crate::flow::runner_auth(&app, &headers).await?; |
| 107 | 7× | if !jet::images::is_hash(&a.image) || !matches!(a.state.as_str(), "fetching" | "done" | "stopped") { |
| 108 | 1× | return Err(ApiError(StatusCode::BAD_REQUEST, "announce an image's hash with state fetching, done or stopped".into())); |
| 109 | 6× | } |
| 110 | 6× | let image = a.image.to_ascii_lowercase(); |
| 111 | 6× | let Some(mine) = app.swarm.addr(me.id) else { |
| 112 | 2× | return Err(ApiError( |
| 113 | 2× | StatusCode::CONFLICT, |
| 114 | 2× | "this runner has not said where peers reach it: give it a [runner.swarm] section and let it claim".into(), |
| 115 | 2× | )); |
| 116 | | }; |
| 117 | 4× | app.swarm.fetch(&image, me.id, &a.state); |
| 118 | 4× | let fetchers = app.swarm.fetchers(&image); |
| 119 | 4× | let mut peers = Vec::new(); |
| 120 | 4× | if a.state == "fetching" { |
| 121 | 9× | for r in db::runners::list(&app.db).await? { |
| 122 | 9× | if r.id == me.id || r.state == "disabled" || !r.online() { |
| 123 | 3× | continue; |
| 124 | 6× | } |
| 125 | 6× | let Some(addr) = app.swarm.addr(r.id).filter(|s| s.site == mine.site) else { continue }; |
| 126 | 4× | let complete = r.image_list().is_some_and(|held| held.iter().any(|h| h.eq_ignore_ascii_case(&image))); |
| 127 | 4× | if complete || fetchers.contains(&r.id) { |
| 128 | 3× | peers.push(SwarmPeer { runner: r.id, name: r.name.clone(), addr: addr.addr, cert: addr.cert, complete }); |
| 129 | 3× | } |
| 130 | | } |
| 131 | 1× | } |
| 132 | 4× | let ticket = ticket(&app, &me, &mine.site)?; |
| 133 | | |
| 134 | | |
| 135 | 4× | let seed = Some( |
| 136 | 4× | sqlx::query_scalar::<_, String>("SELECT source FROM jet_images WHERE hash = $1") |
| 137 | 4× | .bind(&image) |
| 138 | 4× | .fetch_optional(&app.db) |
| 139 | 4× | .await? |
| 140 | 4× | .is_none_or(|s| s != "peer"), |
| 141 | | ); |
| 142 | 4× | Ok(Json(Announced { ticket, peers, interval_s: INTERVAL_S, seed })) |
| 143 | 8× | } |
| 144 | |
|
| 145 | |
|
| 146 | |
|
| 147 | 4× | fn ticket(app: &Shared, me: &db::runners::Runner, site: &str) -> Result<String, ApiError> { |
| 148 | 4× | let now = db::now_ms() as u64; |
| 149 | 4× | let mut tickets = app.swarm.tickets.lock().unwrap_or_else(|p| p.into_inner()); |
| 150 | 4× | if let Some((t, until)) = tickets.get(&me.id) { |
| 151 | 2× | if *until > now + TICKET_RENEW_MS && Ticket::open(t, &app.hub_key.identity, now).is_ok_and(|t| t.site == site) { |
| 152 | 1× | return Ok(t.clone()); |
| 153 | 1× | } |
| 154 | 2× | } |
| 155 | 3× | let (key, secret) = crate::keys::load_secret(&app.config.keys_dir()) |
| 156 | 3× | .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("the hub key: {e}")))?; |
| 157 | 3× | let expires_ms = now + TICKET_LIFE_MS; |
| 158 | 3× | let t = Ticket { hub: app.hub_key.key.to_hex(), runner: me.id, name: me.name.clone(), site: site.to_string(), expires_ms } |
| 159 | 3× | .seal(&key, &secret); |
| 160 | 3× | tickets.insert(me.id, (t.clone(), expires_ms)); |
| 161 | 3× | Ok(t) |
| 162 | 4× | } |
| 163 | |
|
| 164 | | #[cfg(test)] |
| 165 | | mod tests { |
| 166 | | use super::*; |
| 167 | |
|
| 168 | | #[test] |
| 169 | 1× | fn only_host_and_port_addresses_are_kept() { |
| 170 | 1× | assert!(plausible("10.0.0.5:7401")); |
| 171 | 1× | assert!(plausible("[::1]:7401")); |
| 172 | 1× | assert!(plausible("runner.lan:7401")); |
| 173 | 1× | assert!(!plausible("10.0.0.5")); |
| 174 | 1× | assert!(!plausible(":7401")); |
| 175 | 1× | assert!(!plausible("10.0.0.5:0")); |
| 176 | 1× | assert!(!plausible("10.0.0.5:99999")); |
| 177 | 1× | } |
| 178 | | } |