@@ -4,26 +4,30 @@ mod monitor_connection;
44use establish_connection:: connect;
55use monitor_connection:: run_stream;
66
7- use crate :: config:: Config ;
7+ use crate :: auth:: generate_auth_headers;
8+ use crate :: config:: { Config , WebSocketHighAvailability } ;
9+ use crate :: endpoints:: get_cll_avail_origins_header;
810
911use chainlink_data_streams_report:: feed_id:: ID ;
1012use chainlink_data_streams_report:: report:: Report ;
1113
14+ use reqwest:: Client as HttpClient ;
1215use serde:: { Deserialize , Serialize } ;
1316use std:: {
1417 collections:: HashMap ,
1518 sync:: {
1619 atomic:: { AtomicUsize , Ordering } ,
1720 Arc ,
1821 } ,
22+ time:: { SystemTime , UNIX_EPOCH } ,
1923} ;
2024use tokio:: {
2125 net:: TcpStream ,
2226 sync:: { broadcast, mpsc, Mutex } ,
2327 time:: { sleep, Duration } ,
2428} ;
2529use tokio_tungstenite:: { MaybeTlsStream , WebSocketStream as TungsteniteWebSocketStream } ;
26- use tracing:: { debug, error, info} ;
30+ use tracing:: { debug, error, info, warn } ;
2731
2832pub const DEFAULT_WS_CONNECT_TIMEOUT : Duration = Duration :: from_secs ( 5 ) ;
2933pub const MIN_WS_RECONNECT_INTERVAL : Duration = Duration :: from_millis ( 1000 ) ;
@@ -70,7 +74,7 @@ struct Stats {
7074#[ derive( Debug ) ]
7175pub enum WebSocketConnection {
7276 Single ( TungsteniteWebSocketStream < MaybeTlsStream < TcpStream > > ) ,
73- Multiple ( Vec < TungsteniteWebSocketStream < MaybeTlsStream < TcpStream > > > ) ,
77+ Multiple ( Vec < ( TungsteniteWebSocketStream < MaybeTlsStream < TcpStream > > , String ) > ) ,
7478}
7579
7680/// Stream represents a realtime report stream.
@@ -141,7 +145,26 @@ impl Stream {
141145 active_connections : AtomicUsize :: new ( 0 ) ,
142146 } ) ;
143147
144- let conn = connect ( config, & feed_ids, stats. clone ( ) ) . await ?;
148+ let origins: Vec < String > = if config. ws_ha == WebSocketHighAvailability :: Enabled {
149+ match fetch_ha_origins ( config) . await {
150+ Ok ( o) if !o. is_empty ( ) => {
151+ info ! ( "HA mode: discovered {} origins" , o. len( ) ) ;
152+ o
153+ }
154+ Ok ( _) => {
155+ warn ! ( "HA mode: no origins returned from HEAD request, degrading to single connection" ) ;
156+ vec ! [ ]
157+ }
158+ Err ( e) => {
159+ warn ! ( "HA mode: origin discovery failed ({}), degrading to single connection" , e) ;
160+ vec ! [ ]
161+ }
162+ }
163+ } else {
164+ vec ! [ ]
165+ } ;
166+
167+ let conn = connect ( config, & origins, & feed_ids, stats. clone ( ) ) . await ?;
145168
146169 let water_mark = Arc :: new ( Mutex :: new ( HashMap :: new ( ) ) ) ;
147170
@@ -176,6 +199,7 @@ impl Stream {
176199
177200 tokio:: spawn ( run_stream (
178201 stream,
202+ String :: new ( ) , // no X-Cll-Origin header for non-HA connections
179203 report_sender,
180204 shutdown_receiver,
181205 stats,
@@ -185,7 +209,7 @@ impl Stream {
185209 ) ) ;
186210 }
187211 WebSocketConnection :: Multiple ( streams) => {
188- for stream in streams {
212+ for ( stream, origin ) in streams {
189213 let report_sender = self . report_sender . clone ( ) ;
190214 let shutdown_receiver = self . shutdown_sender . subscribe ( ) ;
191215 let stats = self . stats . clone ( ) ;
@@ -195,6 +219,7 @@ impl Stream {
195219
196220 tokio:: spawn ( run_stream (
197221 stream,
222+ origin,
198223 report_sender,
199224 shutdown_receiver,
200225 stats,
@@ -284,3 +309,139 @@ pub struct StatsSnapshot {
284309 /// Current number of active connections
285310 pub active_connections : usize ,
286311}
312+
313+ fn parse_origins_from_header ( header_value : & str ) -> Vec < String > {
314+ let inner = header_value
315+ . strip_prefix ( '{' )
316+ . and_then ( |s| s. strip_suffix ( '}' ) )
317+ . unwrap_or ( header_value) ;
318+ if inner. is_empty ( ) {
319+ return vec ! [ ] ;
320+ }
321+ inner
322+ . split ( ',' )
323+ . map ( |s| s. trim ( ) . to_string ( ) )
324+ . filter ( |s| !s. is_empty ( ) )
325+ . collect ( )
326+ }
327+
328+ fn convert_ws_to_http_scheme ( ws_url : & str ) -> String {
329+ if let Some ( rest) = ws_url. strip_prefix ( "wss://" ) {
330+ format ! ( "https://{}" , rest)
331+ } else if let Some ( rest) = ws_url. strip_prefix ( "ws://" ) {
332+ format ! ( "http://{}" , rest)
333+ } else {
334+ ws_url. to_string ( )
335+ }
336+ }
337+
338+ async fn fetch_ha_origins ( config : & Config ) -> Result < Vec < String > , StreamError > {
339+ let http = HttpClient :: builder ( )
340+ . danger_accept_invalid_certs ( config. insecure_skip_verify . to_bool ( ) )
341+ . build ( )
342+ . map_err ( |e| StreamError :: ConnectionError ( e. to_string ( ) ) ) ?;
343+
344+ // Parse URL, normalize path to "/", keep scheme+host+port so the HMAC-signed
345+ // path "/" matches the actual request path even when ws_url carries a subpath.
346+ let http_url = {
347+ let mut u = reqwest:: Url :: parse ( & convert_ws_to_http_scheme ( & config. ws_url ) )
348+ . map_err ( |e| StreamError :: ConnectionError ( format ! ( "Invalid ws_url: {}" , e) ) ) ?;
349+ u. set_path ( "/" ) ;
350+ u. set_query ( None ) ;
351+ u. to_string ( )
352+ } ;
353+
354+ let request_timestamp = SystemTime :: now ( )
355+ . duration_since ( UNIX_EPOCH )
356+ . expect ( "System time error" )
357+ . as_millis ( ) ;
358+
359+ let auth_headers = generate_auth_headers (
360+ "HEAD" ,
361+ "/" ,
362+ b"" ,
363+ & config. api_key ,
364+ & config. api_secret ,
365+ request_timestamp,
366+ ) ?;
367+
368+ let response = http
369+ . head ( & http_url)
370+ . headers ( auth_headers)
371+ . send ( )
372+ . await
373+ . map_err ( |e| StreamError :: ConnectionError ( format ! ( "HA origin discovery request failed: {}" , e) ) ) ?;
374+
375+ if !response. status ( ) . is_success ( ) {
376+ return Err ( StreamError :: ConnectionError ( format ! (
377+ "HA origin discovery HEAD request returned status {}" ,
378+ response. status( )
379+ ) ) ) ;
380+ }
381+
382+ let header_value = response
383+ . headers ( )
384+ . get ( get_cll_avail_origins_header ( ) )
385+ . and_then ( |v| v. to_str ( ) . ok ( ) )
386+ . unwrap_or ( "" )
387+ . to_string ( ) ;
388+
389+ Ok ( parse_origins_from_header ( & header_value) )
390+ }
391+
392+ #[ cfg( test) ]
393+ mod tests {
394+ use super :: * ;
395+
396+ #[ test]
397+ fn test_parse_origins_from_header_empty ( ) {
398+ assert_eq ! ( parse_origins_from_header( "" ) , Vec :: <String >:: new( ) ) ;
399+ }
400+
401+ #[ test]
402+ fn test_parse_origins_from_header_with_braces ( ) {
403+ let result = parse_origins_from_header ( "{001,002}" ) ;
404+ assert_eq ! ( result, vec![ "001" . to_string( ) , "002" . to_string( ) ] ) ;
405+ }
406+
407+ #[ test]
408+ fn test_parse_origins_from_header_without_braces ( ) {
409+ let result = parse_origins_from_header ( "001,002" ) ;
410+ assert_eq ! ( result, vec![ "001" . to_string( ) , "002" . to_string( ) ] ) ;
411+ }
412+
413+ #[ test]
414+ fn test_parse_origins_from_header_single_origin ( ) {
415+ let result = parse_origins_from_header ( "{001}" ) ;
416+ assert_eq ! ( result, vec![ "001" . to_string( ) ] ) ;
417+ }
418+
419+ #[ test]
420+ fn test_parse_origins_from_header_empty_braces ( ) {
421+ assert_eq ! ( parse_origins_from_header( "{}" ) , Vec :: <String >:: new( ) ) ;
422+ }
423+
424+ #[ test]
425+ fn test_convert_ws_scheme_wss ( ) {
426+ assert_eq ! (
427+ convert_ws_to_http_scheme( "wss://ws.dataengine.chain.link" ) ,
428+ "https://ws.dataengine.chain.link"
429+ ) ;
430+ }
431+
432+ #[ test]
433+ fn test_convert_ws_scheme_ws ( ) {
434+ assert_eq ! (
435+ convert_ws_to_http_scheme( "ws://127.0.0.1:8080" ) ,
436+ "http://127.0.0.1:8080"
437+ ) ;
438+ }
439+
440+ #[ test]
441+ fn test_convert_ws_scheme_passthrough ( ) {
442+ assert_eq ! (
443+ convert_ws_to_http_scheme( "https://already.https.com" ) ,
444+ "https://already.https.com"
445+ ) ;
446+ }
447+ }
0 commit comments