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);
29
30#[derive(Default)]
31struct Link {
32 uplink: Option<Arc<NodeUplink>>,
34 session: Option<NodeSession>,
35 open_not_before: Option<Instant>,
37 open_backoff: Option<Duration>,
39}
40
41#[derive(Clone)]
43pub struct NodeCalls {
44 link: Arc<Mutex<Link>>,
45 node_id: String,
46 keys: Option<UpstreamKeys>,
47 legacy: Option<(SocketAddr, Arc<NodeIdentity>)>,
48 config_http: Option<(SocketAddr, Arc<NodeIdentity>)>,
51}
52
53#[derive(Debug)]
54pub enum CallError {
55 Refused,
56 Unavailable(String),
57}
58
59impl NodeCalls {
60 pub fn new(
61 node_id: String,
62 keys: Option<UpstreamKeys>,
63 legacy: Option<(SocketAddr, Arc<NodeIdentity>)>,
64 config_http: Option<(SocketAddr, Arc<NodeIdentity>)>,
65 ) -> Self {
66 Self {
67 link: Arc::default(),
68 node_id,
69 keys,
70 legacy,
71 config_http,
72 }
73 }
74
75 pub async fn set_uplink(&self, uplink: Option<Arc<NodeUplink>>) {
78 let connected = uplink.is_some();
79 {
81 let mut l = self.link.lock().await;
82 let _ = &mut *l;
85 }
86 if connected {
87 self.refresh_product_url().await;
88 } else {
89 crate::m4a_accounts::set_c2_product_url(None);
90 }
91 }
92
93 pub fn spawn_mail_uplink(
96 &self,
97 addr: SocketAddr,
98 keys: UpstreamKeys,
99 journal: Option<Arc<UplinkJournal>>,
100 mut shutdown: watch::Receiver<bool>,
101 ) {
102 let this = self.clone();
103 tokio::spawn(async move {
104 let mut backoff = OPEN_BACKOFF_MIN;
105 loop {
106 if *shutdown.borrow() {
107 break;
108 }
109 match this.dial_mail(addr, keys, journal.clone()).await {
110 Ok(uplink) => {
111 tracing::info!(%addr, channel = NODE_CALL_CHANNEL, "mail uplink open (dedicated TCP)");
112 backoff = OPEN_BACKOFF_MIN;
113 tokio::select! {
115 _ = uplink.closed() => {
116 tracing::warn!(%addr, "mail uplink closed; reconnecting");
117 }
118 _ = shutdown.changed() => {
119 if *shutdown.borrow() {
120 break;
121 }
122 }
123 }
124 let mut l = this.link.lock().await;
125 if l.uplink.as_ref().is_some_and(|u| Arc::ptr_eq(u, &uplink)) {
126 l.uplink = None;
127 l.session = None;
128 }
129 }
130 Err(e) => {
131 tracing::error!(
132 error = %e,
133 %addr,
134 backoff_s = backoff.as_secs(),
135 "mail uplink open failed; backing off"
136 );
137 tokio::select! {
138 _ = tokio::time::sleep(backoff) => {}
139 _ = shutdown.changed() => {
140 if *shutdown.borrow() { break; }
141 }
142 }
143 backoff = (backoff.saturating_mul(2)).max(OPEN_BACKOFF_MIN).min(OPEN_BACKOFF_MAX);
144 }
145 }
146 }
147 let mut l = this.link.lock().await;
148 l.uplink = None;
149 l.session = None;
150 });
151 }
152
153 async fn dial_mail(
154 &self,
155 addr: SocketAddr,
156 keys: UpstreamKeys,
157 journal: Option<Arc<UplinkJournal>>,
158 ) -> Result<Arc<NodeUplink>, String> {
159 let uplink = NodeUplink::connect(addr, journal).await?;
160 let session = uplink
161 .open(&self.node_id, NODE_CALL_CHANNEL, ChannelKind::M4a, keys, HANDSHAKE)
162 .await?;
163 let mut l = self.link.lock().await;
164 l.uplink = Some(Arc::clone(&uplink));
165 l.session = Some(session);
166 l.open_not_before = None;
167 l.open_backoff = None;
168 Ok(uplink)
169 }
170
171 fn next_backoff(current: Option<Duration>) -> Duration {
172 match current {
173 None => OPEN_BACKOFF_MIN,
174 Some(d) => (d.saturating_mul(2)).max(OPEN_BACKOFF_MIN).min(OPEN_BACKOFF_MAX),
175 }
176 }
177
178 pub async fn refresh_product_url(&self) -> bool {
180 let Some((c2, identity)) = self.config_http.clone().or_else(|| self.legacy.clone()) else {
181 return false;
182 };
183 match crate::enroll_client::node_call(
184 c2,
185 &identity,
186 &self.node_id,
187 hatchery_enroll::call::NODE_CONFIG_OP,
188 &serde_json::json!({}),
189 )
190 .await
191 {
192 Ok(v) => {
193 let url = v
194 .get("m4a_product_url")
195 .and_then(|u| u.as_str())
196 .filter(|u| !u.is_empty())
197 .map(str::to_owned);
198 crate::m4a_accounts::set_c2_product_url(url.clone());
199 match url {
200 Some(u) => tracing::info!(product = %u, "m4a product url from C2"),
201 None => tracing::info!("m4a product url from C2 is empty; accounts stay local"),
202 }
203 true
204 }
205 Err(e) => {
206 tracing::warn!(error = ?e, "m4a product url from C2 failed");
207 false
208 }
209 }
210 }
211
212 pub async fn refresh_product_url_retrying(&self) {
214 let mut wait = std::time::Duration::from_secs(5);
215 while !self.refresh_product_url().await {
216 if crate::m4a_accounts::M4aSessionAccounts::resolve_product_url(None).is_some() {
217 return;
218 }
219 tokio::time::sleep(wait).await;
220 wait = (wait * 2).min(std::time::Duration::from_secs(300));
221 }
222 }
223
224 pub async fn call(&self, op: &str, payload: &Value) -> Result<Value, CallError> {
225 if self.keys.is_some() {
226 let mut l = self.link.lock().await;
227 if l.uplink.is_some() {
228 if l.session.is_none() {
229 let gated = l.open_not_before.is_some_and(|t| Instant::now() < t);
230 if !gated {
231 if let (Some(uplink), Some(keys)) = (l.uplink.clone(), self.keys) {
232 match uplink
233 .open(&self.node_id, NODE_CALL_CHANNEL, ChannelKind::M4a, keys, HANDSHAKE)
234 .await
235 {
236 Ok(s) => {
237 tracing::info!("node calls over the uplink: channel open");
238 l.session = Some(s);
239 l.open_not_before = None;
240 l.open_backoff = None;
241 }
242 Err(e) => {
243 let wait = Self::next_backoff(l.open_backoff);
244 tracing::warn!(
245 error = %e,
246 "node-call channel open failed; backing off {}s",
247 wait.as_secs()
248 );
249 l.open_backoff = Some(wait);
250 l.open_not_before = Some(Instant::now() + wait);
251 }
252 }
253 }
254 }
255 }
256 if let Some(session) = l.session.as_mut() {
257 return match session.call(op, payload, CALL_TIMEOUT).await {
258 Ok(v) => Ok(v),
259 Err(e) if e.starts_with("403") => Err(CallError::Refused),
260 Err(e)
261 if e.chars().take(3).all(|c| c.is_ascii_digit()) && e.contains(':') =>
262 {
263 Err(CallError::Unavailable(e))
264 }
265 Err(e) => {
266 l.session = None;
267 let wait = Self::next_backoff(l.open_backoff);
268 l.open_backoff = Some(wait);
269 l.open_not_before = Some(Instant::now() + wait);
270 Err(CallError::Unavailable(e))
271 }
272 };
273 }
274 }
275 }
276 match &self.legacy {
277 Some((c2, identity)) => {
278 match crate::enroll_client::node_call(*c2, identity, &self.node_id, op, payload).await
279 {
280 Ok(v) => Ok(v),
281 Err(crate::enroll_client::EnrollClientError::Refused) => Err(CallError::Refused),
282 Err(e) => Err(CallError::Unavailable(format!("{e:?}"))),
283 }
284 }
285 None => Err(CallError::Unavailable("no uplink".into())),
286 }
287 }
288}