Skip to content

Commit 5949d60

Browse files
committed
Release v0.2.0: HTTP proxy support and expanded integration tests
Added: - HTTP proxy support via CONNECT tunneling with authentication - ProxyConfig for explicit proxy settings or from $HTTP_PROXY env - Integration tests against SNIP Demo Caster and IGS - Dynamic connection tests for RTK2go and Centipede Changed: - Expanded tested casters documentation in README - Improved testing documentation
1 parent c6b661a commit 5949d60

9 files changed

Lines changed: 723 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.2.0] - 2026-01-10
11+
12+
### Added
13+
14+
- HTTP proxy support via CONNECT tunneling
15+
- Integration tests against SNIP Demo Caster and IGS
16+
- Dynamic connection tests for RTK2go and Centipede (no credentials required)
17+
- `ProxyConfig` struct for proxy configuration
18+
- `NtripConfig::with_proxy()` for explicit proxy settings
19+
- `NtripConfig::with_proxy_from_env()` to read from `$HTTP_PROXY` environment variable
20+
- `ProxyConfig::from_url()` for parsing proxy URLs
21+
- Proxy authentication support (Basic auth)
22+
23+
### Changed
24+
25+
- Improved README with feature highlights and usage examples
26+
- Added documentation for logging via `tracing`
27+
- Expanded tested casters documentation in README
28+
1029
## [0.1.0] - 2025-12-20
1130

1231
### Added
@@ -27,5 +46,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2746
- Integration tests against public casters
2847
- GitHub Actions CI workflow
2948

30-
[Unreleased]: https://github.com/greenforge-labs/ntrip-core/compare/v0.1.0...HEAD
49+
[Unreleased]: https://github.com/greenforge-labs/ntrip-core/compare/v0.2.0...HEAD
50+
[0.2.0]: https://github.com/greenforge-labs/ntrip-core/compare/v0.1.0...v0.2.0
3151
[0.1.0]: https://github.com/greenforge-labs/ntrip-core/releases/tag/v0.1.0

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "ntrip-core"
3-
version = "0.1.0"
3+
version = "0.2.0"
44
edition = "2021"
55
rust-version = "1.75"
66
authors = ["GreenForge Labs"]

README.md

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# ntrip-core
22

3+
[![Crates.io](https://img.shields.io/crates/v/ntrip-core.svg)](https://crates.io/crates/ntrip-core)
4+
[![Documentation](https://docs.rs/ntrip-core/badge.svg)](https://docs.rs/ntrip-core)
5+
[![CI](https://github.com/greenforge-labs/ntrip-core/workflows/CI/badge.svg)](https://github.com/greenforge-labs/ntrip-core/actions)
6+
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7+
[![MSRV](https://img.shields.io/badge/MSRV-1.75-blue.svg)](https://www.rust-lang.org)
8+
39
An async NTRIP client library for Rust.
410

511
## Features
@@ -9,6 +15,20 @@ An async NTRIP client library for Rust.
915
- **Discovery**: Sourcetable retrieval and nearest mountpoint selection
1016
- **Async**: Built on Tokio for efficient async I/O
1117
- **Robust**: Read timeouts, automatic reconnection (configurable), proper error handling
18+
- **Proxy**: HTTP proxy support via CONNECT tunneling
19+
20+
See the [API documentation](https://docs.rs/ntrip-core) for complete usage details.
21+
22+
## Why ntrip-core?
23+
24+
- **No OpenSSL dependency** - TLS via rustls simplifies cross-compilation and deployment
25+
- **Async-native** - Built on Tokio from the ground up, not blocking wrappers
26+
- **Complete protocol support** - Both NTRIP v1 and v2 with automatic version detection
27+
- **Sourcetable discovery** - Parse sourcetables and find nearest mountpoint by coordinates
28+
- **HTTP proxy support** - CONNECT tunneling with optional proxy authentication
29+
- **Cancellation-safe** - Works naturally with `tokio::select!` for timeouts and shutdown
30+
- **GGA position reporting** - Both in-stream and v2 header methods supported
31+
- **Automatic reconnection** - Configurable retry with GGA state preservation
1232

1333
## Quick Start
1434

@@ -62,6 +82,25 @@ let config = NtripConfig::new("secure-caster.example.com", 443, "MOUNT")
6282

6383
By default, connections use plain TCP. Call `.with_tls()` to enable TLS.
6484

85+
## HTTP Proxy
86+
87+
Connect through an HTTP proxy using the CONNECT method:
88+
89+
```rust
90+
use ntrip_core::{NtripConfig, ProxyConfig};
91+
92+
// Explicit proxy configuration
93+
let proxy = ProxyConfig::new("proxy.example.com", 8080)
94+
.with_credentials("proxy_user", "proxy_pass");
95+
96+
let config = NtripConfig::new("caster.example.com", 2101, "MOUNT")
97+
.with_proxy(proxy);
98+
99+
// Or read from $HTTP_PROXY environment variable
100+
let config = NtripConfig::new("caster.example.com", 2101, "MOUNT")
101+
.with_proxy_from_env();
102+
```
103+
65104
## Sourcetable Discovery
66105

67106
```rust
@@ -95,13 +134,42 @@ cargo run --example connect -- rtk2go.com Laguna01 2101 --user=you@example.com -
95134
# Run unit tests
96135
cargo test
97136

98-
# Run comprehensive test suite against real casters
99-
bash ./scripts/test_suite.sh --quick # Sourcetable only
100-
bash ./scripts/test_suite.sh --full # + nearest mountpoint tests
101-
bash ./scripts/test_suite.sh --connect # + connection tests (requires credentials)
137+
# Run integration tests against real public casters (no credentials needed)
138+
just test-integration
139+
# or: cargo test --test integration -- --ignored
140+
141+
# Run shell-based test suite against real casters
142+
just test-suite # Sourcetable only
143+
just test-suite-full # + nearest mountpoint tests
144+
just test-suite-connect # + connection tests (requires credentials)
145+
```
146+
147+
The integration tests fetch sourcetables from all public casters listed below, and include dynamic connection tests that find active mountpoints and stream real RTCM data from RTK2go and Centipede - no private credentials required.
148+
149+
For the shell-based connection tests, copy `scripts/credentials.env.template` to `scripts/credentials.env` and fill in your credentials.
150+
151+
## Logging
152+
153+
This crate uses [`tracing`](https://docs.rs/tracing) for structured logging. Enable with a subscriber:
154+
155+
```rust
156+
tracing_subscriber::fmt::init();
102157
```
103158

104-
For connection tests, copy `scripts/credentials.env.template` to `scripts/credentials.env` and fill in your credentials.
159+
## Tested Casters
160+
161+
This library is regularly tested against these public NTRIP casters:
162+
163+
| Caster | Host | Auth | Notes |
164+
|--------|------|------|-------|
165+
| **RTK2go** | rtk2go.com:2101 | Email/none | Large public caster, 1000+ streams |
166+
| **Centipede** | caster.centipede.fr:2101 | None | Open French/EU RTK network |
167+
| **EUREF** | euref-ip.net:2101 | None* | European reference stations |
168+
| **IGS** | igs-ip.net:2101 | None* | International GNSS Service |
169+
| **SNIP Demo** | ntrip.use-snip.com:2101 | None | Demo caster for testing |
170+
| **AUSCORS** | ntrip.data.gnss.ga.gov.au:443 | Required | Geoscience Australia (HTTPS) |
171+
172+
\* Some streams may require registration
105173

106174
## Development
107175

scripts/test_suite.sh

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,18 +127,21 @@ print_summary() {
127127
# =============================================================================
128128
run_sourcetable_tests() {
129129
echo -e "\n${BLUE}=== SOURCETABLE TESTS ===${NC}"
130-
130+
131131
# RTK2go - Large public caster
132132
run_test "RTK2go sourcetable" "$SOURCETABLE" rtk2go.com 2101
133-
133+
134134
# EUREF - European reference stations
135135
run_test "EUREF sourcetable" "$SOURCETABLE" euref-ip.net 2101
136-
136+
137137
# Centipede - French open community
138138
run_test "Centipede sourcetable" "$SOURCETABLE" caster.centipede.fr 2101
139-
139+
140140
# IGS - International GNSS Service
141141
run_test "IGS sourcetable" "$SOURCETABLE" igs-ip.net 2101
142+
143+
# SNIP Demo Caster - Good for protocol testing
144+
run_test "SNIP Demo sourcetable" "$SOURCETABLE" ntrip.use-snip.com 2101
142145
}
143146

144147
# =============================================================================

src/client.rs

Lines changed: 141 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,17 @@
1010
//! - Auto-detection of protocol version from server response
1111
//! - Basic Authentication
1212
//! - TLS/HTTPS support for secure connections
13+
//! - HTTP proxy support via CONNECT tunneling
1314
//! - GGA position reporting
1415
1516
use std::time::Duration;
16-
use tokio::io::{AsyncReadExt, AsyncWriteExt};
17+
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
1718
use tokio::net::TcpStream;
1819
use tracing::{debug, error, info, warn};
1920

2021
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
2122

22-
use crate::config::{NtripConfig, NtripVersion};
23+
use crate::config::{NtripConfig, NtripVersion, ProxyConfig};
2324
use crate::gga::GgaSentence;
2425
use crate::sourcetable::Sourcetable;
2526
use crate::stream::NtripStream;
@@ -79,6 +80,130 @@ impl NtripClient {
7980
self.connect_with_gga(None).await
8081
}
8182

83+
/// Establish a TCP connection, optionally through an HTTP proxy.
84+
///
85+
/// If a proxy is configured, this connects to the proxy and uses HTTP CONNECT
86+
/// to establish a tunnel to the target host:port.
87+
async fn establish_tcp_connection(
88+
target_host: &str,
89+
target_port: u16,
90+
proxy: Option<&ProxyConfig>,
91+
timeout_secs: u32,
92+
) -> Result<TcpStream, Error> {
93+
let timeout = Duration::from_secs(timeout_secs as u64);
94+
95+
match proxy {
96+
Some(proxy_config) => {
97+
let proxy_addr = format!("{}:{}", proxy_config.host, proxy_config.port);
98+
info!(
99+
proxy = %proxy_addr,
100+
target = %format!("{}:{}", target_host, target_port),
101+
"Connecting via HTTP proxy"
102+
);
103+
104+
// Connect to proxy
105+
let mut stream =
106+
match tokio::time::timeout(timeout, TcpStream::connect(&proxy_addr)).await {
107+
Ok(Ok(s)) => s,
108+
Ok(Err(e)) => {
109+
return Err(Error::ProxyError {
110+
message: format!(
111+
"Failed to connect to proxy {}:{}: {}",
112+
proxy_config.host, proxy_config.port, e
113+
),
114+
})
115+
}
116+
Err(_) => return Err(Error::Timeout { timeout_secs }),
117+
};
118+
119+
// Build HTTP CONNECT request
120+
let mut connect_request = format!(
121+
"CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n",
122+
target_host, target_port, target_host, target_port
123+
);
124+
125+
// Add proxy authentication if configured
126+
if let (Some(user), Some(pass)) = (&proxy_config.username, &proxy_config.password) {
127+
let credentials = format!("{}:{}", user, pass);
128+
let encoded = BASE64.encode(credentials);
129+
connect_request
130+
.push_str(&format!("Proxy-Authorization: Basic {}\r\n", encoded));
131+
}
132+
133+
connect_request.push_str("\r\n");
134+
135+
debug!("Sending HTTP CONNECT request to proxy");
136+
137+
// Send CONNECT request
138+
stream
139+
.write_all(connect_request.as_bytes())
140+
.await
141+
.map_err(|e| Error::ProxyError {
142+
message: format!("Failed to send CONNECT request: {}", e),
143+
})?;
144+
145+
// Read response (first line should be "HTTP/1.x 200 ...")
146+
let mut reader = BufReader::new(&mut stream);
147+
let mut response_line = String::new();
148+
149+
match tokio::time::timeout(timeout, reader.read_line(&mut response_line)).await {
150+
Ok(Ok(0)) => {
151+
return Err(Error::ProxyError {
152+
message: "Proxy closed connection without response".to_string(),
153+
})
154+
}
155+
Ok(Ok(_)) => {}
156+
Ok(Err(e)) => {
157+
return Err(Error::ProxyError {
158+
message: format!("Failed to read proxy response: {}", e),
159+
})
160+
}
161+
Err(_) => return Err(Error::Timeout { timeout_secs }),
162+
}
163+
164+
// Check for 200 response
165+
if !response_line.contains("200") {
166+
return Err(Error::ProxyError {
167+
message: format!("Proxy CONNECT failed: {}", response_line.trim()),
168+
});
169+
}
170+
171+
debug!(response = %response_line.trim(), "Proxy tunnel established");
172+
173+
// Consume remaining headers until empty line
174+
loop {
175+
let mut header_line = String::new();
176+
match tokio::time::timeout(timeout, reader.read_line(&mut header_line)).await {
177+
Ok(Ok(0)) => break,
178+
Ok(Ok(_)) => {
179+
if header_line.trim().is_empty() {
180+
break;
181+
}
182+
}
183+
Ok(Err(e)) => {
184+
return Err(Error::ProxyError {
185+
message: format!("Failed to read proxy headers: {}", e),
186+
})
187+
}
188+
Err(_) => return Err(Error::Timeout { timeout_secs }),
189+
}
190+
}
191+
192+
info!("HTTP proxy tunnel established successfully");
193+
Ok(stream)
194+
}
195+
None => {
196+
// Direct connection (no proxy)
197+
let addr = format!("{}:{}", target_host, target_port);
198+
match tokio::time::timeout(timeout, TcpStream::connect(&addr)).await {
199+
Ok(Ok(s)) => Ok(s),
200+
Ok(Err(e)) => Err(Error::connection_failed(target_host, target_port, e)),
201+
Err(_) => Err(Error::Timeout { timeout_secs }),
202+
}
203+
}
204+
}
205+
}
206+
82207
/// Connect to the NTRIP caster with an optional initial GGA position.
83208
///
84209
/// For NTRIP v2, the GGA sentence is sent in the `Ntrip-GGA` header,
@@ -96,21 +221,14 @@ impl NtripClient {
96221

97222
info!(addr = %addr, "Connecting to NTRIP caster");
98223

99-
// 1. Establish TCP connection
100-
let tcp_stream = match tokio::time::timeout(
101-
Duration::from_secs(self.config.connection.timeout_secs as u64),
102-
TcpStream::connect(&addr),
224+
// 1. Establish TCP connection (directly or via proxy)
225+
let tcp_stream = Self::establish_tcp_connection(
226+
host,
227+
port,
228+
self.config.proxy.as_ref(),
229+
self.config.connection.timeout_secs,
103230
)
104-
.await
105-
{
106-
Ok(Ok(s)) => s,
107-
Ok(Err(e)) => return Err(Error::connection_failed(host, port, e)),
108-
Err(_) => {
109-
return Err(Error::Timeout {
110-
timeout_secs: self.config.connection.timeout_secs,
111-
})
112-
}
113-
};
231+
.await?;
114232

115233
// 2. Optionally upgrade to TLS
116234
let mut stream: NtripStream = if self.config.use_tls {
@@ -488,21 +606,14 @@ impl NtripClient {
488606

489607
info!(addr = %addr, "Fetching sourcetable from NTRIP caster");
490608

491-
// 1. Establish TCP connection
492-
let tcp_stream = match tokio::time::timeout(
493-
Duration::from_secs(config.connection.timeout_secs as u64),
494-
TcpStream::connect(&addr),
609+
// 1. Establish TCP connection (directly or via proxy)
610+
let tcp_stream = Self::establish_tcp_connection(
611+
host,
612+
port,
613+
config.proxy.as_ref(),
614+
config.connection.timeout_secs,
495615
)
496-
.await
497-
{
498-
Ok(Ok(s)) => s,
499-
Ok(Err(e)) => return Err(Error::connection_failed(host, port, e)),
500-
Err(_) => {
501-
return Err(Error::Timeout {
502-
timeout_secs: config.connection.timeout_secs,
503-
})
504-
}
505-
};
616+
.await?;
506617

507618
// 2. Optionally upgrade to TLS
508619
let mut stream: NtripStream = if config.use_tls {

0 commit comments

Comments
 (0)