1use crate::restream_session::{NodeSession, NodeUplink, UpstreamKeys};
13use crate::uplink_journal::UplinkJournal;
14use hatchery_core::ChannelKind;
15use hatchery_enroll::NodeIdentity;
16use serde_json::Value;
17use std::net::SocketAddr;
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20use tokio::sync::{watch, Mutex};
21
22pub const NODE_CALL_CHANNEL: u32 = 2;
24const HANDSHAKE: Duration = Duration::from_secs(8);
25const CALL_TIMEOUT: Duration = Duration::from_secs(15);
26const OPEN_BACKOFF_MIN: Duration = Duration::from_secs(5);
28const OPEN_BACKOFF_MAX: Duration = Duration::from_secs(30);
29const REKEY_CHECK_EVERY: Duration = Duration::from_secs(2);
31
32#[derive(Default)]
33struct Link {
34 uplink: Option<Arc<NodeUplink>>,
36 session: Option<NodeSession>,
37 open_not_before: Option<Instant>,
39 open_backoff: Option<Duration>,
41}
42
43#[derive(Clone)]
45pub struct NodeCalls {
46 link: Arc<Mutex<Link>>,
47 node_id: String,
48 keys: Option<UpstreamKeys>,
49 legacy: Option<(SocketAddr, Arc<NodeIdentity>)>,
50 config_http: Option<(SocketAddr, Arc<NodeIdentity>)>,
53}
54
55#[derive(Debug)]
56pub enum CallError {
57 Refused,
58 Unavailable(String),
59}
60
61impl NodeCalls {
62 pub fn new(
63 node_id: String,
64 keys: Option<UpstreamKeys>,
65 legacy: Option<(SocketAddr, Arc<NodeIdentity>)>,
66 config_http: Option<(SocketAddr, Arc<NodeIdentity>)>,
67 ) -> Self {
68 Self {
69 link: Arc::default(),
70 node_id,
71 keys,
72 legacy,
73 config_http,
74 }
75 }
76
77 pub async fn set_uplink(&self, uplink: Option<Arc<NodeUplink>>) {
80 let connected = uplink.is_some();
81 {
83 let mut l = self.link.lock().await;
84 let _ = &mut *l;
87 }
88 if connected {
89 self.refresh_product_url().await;
90 } else {
91 crate::m4a_accounts::set_c2_product_url(None);
92 }
93 }
94
95 pub fn spawn_mail_uplink(
98 &self,
99 addr: SocketAddr,
100 keys: UpstreamKeys,
101 journal: Option<Arc<UplinkJournal>>,
102 mut shutdown: watch::Receiver<bool>,
103 ) {
104 let this = self.clone();
105 tokio::spawn(async move {
106 let mut backoff = OPEN_BACKOFF_MIN;
107 loop {
108 if *shutdown.borrow() {
109 break;
110 }
111 match this.dial_mail(addr, keys, journal.clone()).await {
112 Ok(uplink) => {
113 tracing::info!(%addr, channel = NODE_CALL_CHANNEL, "mail uplink open (dedicated TCP)");
114 backoff = OPEN_BACKOFF_MIN;
115 tokio::select! {
117 _ = uplink.closed() => {
118 tracing::warn!(%addr, "mail uplink closed; reconnecting");
119 }
120 _ = shutdown.changed() => {
121 if *shutdown.borrow() {
122 break;
123 }
124 }
125 }
126 let mut l = this.link.lock().await;
127 if l.uplink.as_ref().is_some_and(|u| Arc::ptr_eq(u, &uplink)) {
128 l.uplink = None;
129 l.session = None;
130 }
131 }
132 Err(e) => {
133 tracing::error!(
134 error = %e,
135 %addr,
136 backoff_s = backoff.as_secs(),
137 "mail uplink open failed; backing off"
138 );
139 tokio::select! {
140 _ = tokio::time::sleep(backoff) => {}
141 _ = shutdown.changed() => {
142 if *shutdown.borrow() { break; }
143 }
144 }
145 backoff = (backoff.saturating_mul(2)).max(OPEN_BACKOFF_MIN).min(OPEN_BACKOFF_MAX);
146 }
147 }
148 }
149 let mut l = this.link.lock().await;
150 l.uplink = None;
151 l.session = None;
152 });
153 }
154
155 async fn dial_mail(
156 &self,
157 addr: SocketAddr,
158 keys: UpstreamKeys,
159 journal: Option<Arc<UplinkJournal>>,
160 ) -> Result<Arc<NodeUplink>, String> {
161 let uplink = NodeUplink::connect(addr, journal).await?;
162 let session = uplink
163 .open(&self.node_id, NODE_CALL_CHANNEL, ChannelKind::M4a, keys, HANDSHAKE)
164 .await?;
165 let mut l = self.link.lock().await;
166 l.uplink = Some(Arc::clone(&uplink));
167 l.session = Some(session);
168 l.open_not_before = None;
169 l.open_backoff = None;
170 Ok(uplink)
171 }
172
173 fn next_backoff(current: Option<Duration>) -> Duration {
174 match current {
175 None => OPEN_BACKOFF_MIN,
176 Some(d) => (d.saturating_mul(2)).max(OPEN_BACKOFF_MIN).min(OPEN_BACKOFF_MAX),
177 }
178 }
179
180 pub async fn refresh_product_url(&self) -> bool {
182 let Some((c2, identity)) = self.config_http.clone().or_else(|| self.legacy.clone()) else {
183 return false;
184 };
185 match crate::enroll_client::node_call(
186 c2,
187 &identity,
188 &self.node_id,
189 hatchery_enroll::call::NODE_CONFIG_OP,
190 &serde_json::json!({}),
191 )
192 .await
193 {
194 Ok(v) => {
195 let url = v
196 .get("m4a_product_url")
197 .and_then(|u| u.as_str())
198 .filter(|u| !u.is_empty())
199 .map(str::to_owned);
200 crate::m4a_accounts::set_c2_product_url(url.clone());
201 match url {
202 Some(u) => tracing::info!(product = %u, "m4a product url from C2"),
203 None => tracing::info!("m4a product url from C2 is empty; accounts stay local"),
204 }
205 true
206 }
207 Err(e) => {
208 tracing::warn!(error = ?e, "m4a product url from C2 failed");
209 false
210 }
211 }
212 }
213
214 pub async fn refresh_product_url_retrying(&self) {
216 let mut wait = std::time::Duration::from_secs(5);
217 while !self.refresh_product_url().await {
218 if crate::m4a_accounts::M4aSessionAccounts::resolve_product_url(None).is_some() {
219 return;
220 }
221 tokio::time::sleep(wait).await;
222 wait = (wait * 2).min(std::time::Duration::from_secs(300));
223 }
224 }
225
226 fn sign(&self, op: &str, payload: &Value) -> Option<Value> {
227 let (_, identity) = self.config_http.as_ref().or(self.legacy.as_ref())?;
228 let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_millis() as u64;
229 let call = hatchery_enroll::call::SignedCall::sign(identity, &self.node_id, op, payload, ts).ok()?;
230 serde_json::to_value(call).ok()
231 }
232
233 pub fn spawn_rekey_watch(&self, mut shutdown: watch::Receiver<bool>) {
238 let this = self.clone();
239 tokio::spawn(async move {
240 loop {
241 tokio::select! {
242 _ = tokio::time::sleep(REKEY_CHECK_EVERY) => {}
243 _ = shutdown.changed() => if *shutdown.borrow() { return },
244 }
245 let Ok(mut l) = this.link.try_lock() else { continue };
246 if l.session.as_mut().is_some_and(NodeSession::stale_downlink) {
247 tracing::info!(channel = NODE_CALL_CHANNEL, "core asked for a new Noise session; re-handshaking");
248 l.session = None;
249 l.open_not_before = None;
250 l.open_backoff = None;
251 this.open_locked(&mut l).await;
252 }
253 }
254 });
255 }
256
257 async fn open_locked(&self, l: &mut Link) {
258 if let (Some(uplink), Some(keys)) = (l.uplink.clone(), self.keys) {
259 match uplink.open(&self.node_id, NODE_CALL_CHANNEL, ChannelKind::M4a, keys, HANDSHAKE).await {
260 Ok(s) => {
261 tracing::info!("node calls over the uplink: channel open");
262 l.session = Some(s);
263 l.open_not_before = None;
264 l.open_backoff = None;
265 }
266 Err(e) => {
267 let wait = Self::next_backoff(l.open_backoff);
268 tracing::warn!(error = %e, "node-call channel open failed; backing off {}s", wait.as_secs());
269 l.open_backoff = Some(wait);
270 l.open_not_before = Some(Instant::now() + wait);
271 }
272 }
273 }
274 }
275
276 pub async fn call(&self, op: &str, payload: &Value) -> Result<Value, CallError> {
277 for attempt in 0..2 {
279 match self.call_once(op, payload).await {
280 Err(CallError::Unavailable(e)) if attempt == 0 && e.contains("decrypt") => {
281 tracing::info!(op, "node call hit a stale Noise session; re-handshaking and retrying");
282 let mut l = self.link.lock().await;
283 l.open_not_before = None;
284 l.open_backoff = None;
285 }
286 other => return other,
287 }
288 }
289 unreachable!("the second attempt returns")
290 }
291
292 async fn call_once(&self, op: &str, payload: &Value) -> Result<Value, CallError> {
293 if self.keys.is_some() {
294 let mut l = self.link.lock().await;
295 if l.session.as_mut().is_some_and(NodeSession::stale_downlink) {
296 tracing::info!(channel = NODE_CALL_CHANNEL, "core asked for a new Noise session; re-handshaking");
297 l.session = None;
298 l.open_not_before = None;
299 l.open_backoff = None;
300 }
301 if l.uplink.is_some() {
302 if l.session.is_none() {
303 let gated = l.open_not_before.is_some_and(|t| Instant::now() < t);
304 if !gated {
305 if let (Some(uplink), Some(keys)) = (l.uplink.clone(), self.keys) {
306 match uplink
307 .open(&self.node_id, NODE_CALL_CHANNEL, ChannelKind::M4a, keys, HANDSHAKE)
308 .await
309 {
310 Ok(s) => {
311 tracing::info!("node calls over the uplink: channel open");
312 l.session = Some(s);
313 l.open_not_before = None;
314 l.open_backoff = None;
315 }
316 Err(e) => {
317 let wait = Self::next_backoff(l.open_backoff);
318 tracing::warn!(
319 error = %e,
320 "node-call channel open failed; backing off {}s",
321 wait.as_secs()
322 );
323 l.open_backoff = Some(wait);
324 l.open_not_before = Some(Instant::now() + wait);
325 }
326 }
327 }
328 }
329 }
330 if let Some(session) = l.session.as_mut() {
331 let signed = self.sign(op, payload);
333 return match session.call_signed(op, payload, signed.as_ref(), CALL_TIMEOUT).await {
334 Ok(v) => Ok(v),
335 Err(e) if e.starts_with("403") => Err(CallError::Refused),
336 Err(e)
337 if e.chars().take(3).all(|c| c.is_ascii_digit()) && e.contains(':') =>
338 {
339 Err(CallError::Unavailable(e))
340 }
341 Err(e) => {
342 l.session = None;
343 let wait = Self::next_backoff(l.open_backoff);
344 l.open_backoff = Some(wait);
345 l.open_not_before = Some(Instant::now() + wait);
346 Err(CallError::Unavailable(e))
347 }
348 };
349 }
350 }
351 }
352 match &self.legacy {
353 Some((c2, identity)) => {
354 match crate::enroll_client::node_call(*c2, identity, &self.node_id, op, payload).await
355 {
356 Ok(v) => Ok(v),
357 Err(crate::enroll_client::EnrollClientError::Refused) => Err(CallError::Refused),
358 Err(e) => Err(CallError::Unavailable(format!("{e:?}"))),
359 }
360 }
361 None => Err(CallError::Unavailable("no uplink".into())),
362 }
363 }
364}