Skip to content

Commit 6ffb5fb

Browse files
feat(pyth-lazer-agent): add HTTP proxy support for WebSocket connections
Add proxy_url configuration option to support connecting through HTTP/HTTPS proxies. Implements manual HTTP CONNECT handshake with Basic authentication support and TLS upgrade for secure WebSocket connections. - Add proxy_url: Option<Url> to Config struct - Implement connect_through_proxy function with HTTP CONNECT method - Support Basic authentication via proxy URL credentials - Add tokio-native-tls dependency for TLS support - Update README with proxy configuration examples - Bump version from 0.6.1 to 0.7.0 Co-Authored-By: Mike Rolish <[email protected]>
1 parent b914d46 commit 6ffb5fb

File tree

7 files changed

+143
-15
lines changed

7 files changed

+143
-15
lines changed

Cargo.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/pyth-lazer-agent/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "pyth-lazer-agent"
3-
version = "0.6.1"
3+
version = "0.7.0"
44
edition = "2024"
55
description = "Pyth Lazer Agent"
66
license = "Apache-2.0"
@@ -32,6 +32,7 @@ serde_json = "1.0.140"
3232
soketto = { version = "0.8.1", features = ["http"] }
3333
solana-keypair = "2.2.1"
3434
tokio = { version = "1.44.1", features = ["full"] }
35+
tokio-native-tls = "0.3.1"
3536
tokio-tungstenite = { version = "0.26.2", features = ["native-tls", "url"] }
3637
tokio-util = { version = "0.7.14", features = ["compat"] }
3738
tracing = "0.1.41"

apps/pyth-lazer-agent/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ authorization_token = "your_token"
5050
listen_address = "0.0.0.0:8910"
5151
publish_interval_duration = "25ms"
5252
enable_update_deduplication = false
53+
# Optional proxy configuration
54+
# proxy_url = "http://proxy.example.com:8080"
55+
# proxy_url = "http://username:[email protected]:8080" # With authentication
5356
```
5457

5558
- `relayers_urls`: The Lazer team will provide these.
@@ -58,3 +61,4 @@ enable_update_deduplication = false
5861
- `listen_address`: The local port the agent will be listening on; can be anything you want.
5962
- `publisher_interval`: The agent will batch and send transaction bundles at this interval. The Lazer team will provide guidance here.
6063
- `enable_update_deduplication`: The agent will deduplicate updates based inside each batch before sending it to Lazer.
64+
- `proxy_url` (optional): HTTP/HTTPS proxy URL for WebSocket connections. Supports Basic authentication via URL credentials (e.g., `http://user:pass@proxy:port`).

apps/pyth-lazer-agent/src/config.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ pub struct Config {
2323
pub enable_update_deduplication: bool,
2424
#[serde(with = "humantime_serde", default = "default_update_deduplication_ttl")]
2525
pub update_deduplication_ttl: Duration,
26+
pub proxy_url: Option<Url>,
2627
}
2728

2829
#[derive(Deserialize, Derivative, Clone, PartialEq)]

apps/pyth-lazer-agent/src/jrpc_handle.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ pub mod tests {
312312
history_service_url: None,
313313
enable_update_deduplication: false,
314314
update_deduplication_ttl: Default::default(),
315+
proxy_url: None,
315316
};
316317

317318
println!("{:?}", get_metadata(config).await.unwrap());

apps/pyth-lazer-agent/src/lazer_publisher.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ impl LazerPublisher {
8080
token: authorization_token.clone(),
8181
receiver: relayer_sender.subscribe(),
8282
is_ready: is_ready.clone(),
83+
proxy_url: config.proxy_url.clone(),
8384
};
8485
tokio::spawn(async move { task.run().await });
8586
}
@@ -301,6 +302,7 @@ mod tests {
301302
history_service_url: None,
302303
enable_update_deduplication: false,
303304
update_deduplication_ttl: Default::default(),
305+
proxy_url: None,
304306
};
305307

306308
let (relayer_sender, mut relayer_receiver) = broadcast::channel(CHANNEL_CAPACITY);

apps/pyth-lazer-agent/src/relayer_session.rs

Lines changed: 131 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
use anyhow::{Result, bail};
1+
use anyhow::{Context, Result, bail};
22
use backoff::ExponentialBackoffBuilder;
33
use backoff::backoff::Backoff;
4+
use base64::Engine;
45
use futures_util::stream::{SplitSink, SplitStream};
56
use futures_util::{SinkExt, StreamExt};
67
use http::HeaderValue;
@@ -9,32 +10,151 @@ use pyth_lazer_publisher_sdk::transaction::SignedLazerTransaction;
910
use std::sync::Arc;
1011
use std::sync::atomic::{AtomicBool, Ordering};
1112
use std::time::{Duration, Instant};
13+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
1214
use tokio::net::TcpStream;
1315
use tokio::select;
1416
use tokio::sync::broadcast;
1517
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
1618
use tokio_tungstenite::{
17-
MaybeTlsStream, WebSocketStream, connect_async_with_config,
19+
MaybeTlsStream, WebSocketStream, client_async, connect_async_with_config,
1820
tungstenite::Message as TungsteniteMessage,
1921
};
2022
use url::Url;
2123

2224
type RelayerWsSender = SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, TungsteniteMessage>;
2325
type RelayerWsReceiver = SplitStream<WebSocketStream<MaybeTlsStream<TcpStream>>>;
2426

25-
async fn connect_to_relayer(url: Url, token: &str) -> Result<(RelayerWsSender, RelayerWsReceiver)> {
26-
tracing::info!("connecting to the relayer at {}", url);
27-
let mut req = url.clone().into_client_request()?;
27+
async fn connect_through_proxy(
28+
proxy_url: &Url,
29+
target_url: &Url,
30+
token: &str,
31+
) -> Result<(RelayerWsSender, RelayerWsReceiver)> {
32+
tracing::info!(
33+
"connecting to the relayer at {} via proxy {}",
34+
target_url,
35+
proxy_url
36+
);
37+
38+
let proxy_host = proxy_url.host_str().context("Proxy URL must have a host")?;
39+
let proxy_port = proxy_url
40+
.port()
41+
.unwrap_or(if proxy_url.scheme() == "https" {
42+
443
43+
} else {
44+
80
45+
});
46+
47+
let proxy_addr = format!("{}:{}", proxy_host, proxy_port);
48+
let mut stream = TcpStream::connect(&proxy_addr)
49+
.await
50+
.context(format!("Failed to connect to proxy at {}", proxy_addr))?;
51+
52+
let target_host = target_url
53+
.host_str()
54+
.context("Target URL must have a host")?;
55+
let target_port = target_url
56+
.port()
57+
.unwrap_or(if target_url.scheme() == "wss" {
58+
443
59+
} else {
60+
80
61+
});
62+
63+
let mut connect_request = format!(
64+
"CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n",
65+
target_host, target_port, target_host, target_port
66+
);
67+
68+
let username = proxy_url.username();
69+
if !username.is_empty() {
70+
let password = proxy_url.password().unwrap_or("");
71+
let credentials = format!("{}:{}", username, password);
72+
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials.as_bytes());
73+
connect_request = format!(
74+
"{}Proxy-Authorization: Basic {}\r\n",
75+
connect_request, encoded
76+
);
77+
}
78+
79+
connect_request = format!("{}\r\n", connect_request);
80+
81+
stream
82+
.write_all(connect_request.as_bytes())
83+
.await
84+
.context("Failed to send CONNECT request to proxy")?;
85+
86+
let mut response = vec![0u8; 1024];
87+
let n = stream
88+
.read(&mut response)
89+
.await
90+
.context("Failed to read CONNECT response from proxy")?;
91+
92+
let response_str = String::from_utf8_lossy(&response[..n]);
93+
94+
if !response_str.starts_with("HTTP/1.1 200") && !response_str.starts_with("HTTP/1.0 200") {
95+
bail!(
96+
"Proxy CONNECT failed: {}",
97+
response_str.lines().next().unwrap_or("Unknown error")
98+
);
99+
}
100+
101+
tracing::info!("Successfully connected through proxy");
102+
103+
let mut req = target_url.clone().into_client_request()?;
28104
let headers = req.headers_mut();
29105
headers.insert(
30106
"Authorization",
31-
HeaderValue::from_str(&format!("Bearer {token}"))?,
107+
HeaderValue::from_str(&format!("Bearer {}", token))?,
108+
);
109+
110+
let maybe_tls_stream = if target_url.scheme() == "wss" {
111+
let tls_connector = tokio_native_tls::native_tls::TlsConnector::builder()
112+
.build()
113+
.context("Failed to build TLS connector")?;
114+
let tokio_connector = tokio_native_tls::TlsConnector::from(tls_connector);
115+
let domain = target_host;
116+
let tls_stream = tokio_connector
117+
.connect(domain, stream)
118+
.await
119+
.context("Failed to establish TLS connection")?;
120+
121+
MaybeTlsStream::NativeTls(tls_stream)
122+
} else {
123+
MaybeTlsStream::Plain(stream)
124+
};
125+
126+
let (ws_stream, _) = client_async(req, maybe_tls_stream)
127+
.await
128+
.context("Failed to complete WebSocket handshake")?;
129+
130+
tracing::info!(
131+
"WebSocket connection established to relayer at {}",
132+
target_url
32133
);
33-
let (ws_stream, _) = connect_async_with_config(req, None, true).await?;
34-
tracing::info!("connected to the relayer at {}", url);
35134
Ok(ws_stream.split())
36135
}
37136

137+
async fn connect_to_relayer(
138+
url: Url,
139+
token: &str,
140+
proxy_url: Option<&Url>,
141+
) -> Result<(RelayerWsSender, RelayerWsReceiver)> {
142+
if let Some(proxy) = proxy_url {
143+
connect_through_proxy(proxy, &url, token).await
144+
} else {
145+
tracing::info!("connecting to the relayer at {}", url);
146+
let mut req = url.clone().into_client_request()?;
147+
let headers = req.headers_mut();
148+
headers.insert(
149+
"Authorization",
150+
HeaderValue::from_str(&format!("Bearer {token}"))?,
151+
);
152+
let (ws_stream, _) = connect_async_with_config(req, None, true).await?;
153+
tracing::info!("connected to the relayer at {}", url);
154+
Ok(ws_stream.split())
155+
}
156+
}
157+
38158
struct RelayerWsSession {
39159
ws_sender: RelayerWsSender,
40160
}
@@ -58,11 +178,11 @@ impl RelayerWsSession {
58178
}
59179

60180
pub struct RelayerSessionTask {
61-
// connection state
62181
pub url: Url,
63182
pub token: String,
64183
pub receiver: broadcast::Receiver<SignedLazerTransaction>,
65184
pub is_ready: Arc<AtomicBool>,
185+
pub proxy_url: Option<Url>,
66186
}
67187

68188
impl RelayerSessionTask {
@@ -108,10 +228,8 @@ impl RelayerSessionTask {
108228
}
109229

110230
pub async fn run_relayer_connection(&mut self) -> Result<()> {
111-
// Establish relayer connection
112-
// Relayer will drop the connection if no data received in 5s
113231
let (relayer_ws_sender, mut relayer_ws_receiver) =
114-
connect_to_relayer(self.url.clone(), &self.token).await?;
232+
connect_to_relayer(self.url.clone(), &self.token, self.proxy_url.as_ref()).await?;
115233
let mut relayer_ws_session = RelayerWsSession {
116234
ws_sender: relayer_ws_sender,
117235
};
@@ -236,11 +354,11 @@ mod tests {
236354
let (relayer_sender, relayer_receiver) = broadcast::channel(RELAYER_CHANNEL_CAPACITY);
237355

238356
let mut relayer_session_task = RelayerSessionTask {
239-
// connection state
240357
url: Url::parse("ws://127.0.0.1:12346").unwrap(),
241358
token: "token1".to_string(),
242359
receiver: relayer_receiver,
243360
is_ready: Arc::new(AtomicBool::new(false)),
361+
proxy_url: None,
244362
};
245363
tokio::spawn(async move { relayer_session_task.run().await });
246364
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;

0 commit comments

Comments
 (0)