Skip to content

Commit 65c565e

Browse files
authored
Merge branch 'main' into dependabot/cargo/rust-dependencies-0945292d1f
2 parents 6696ce0 + 8f935aa commit 65c565e

5 files changed

Lines changed: 185 additions & 10 deletions

File tree

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ page.
3535
| [rate-limiting.yaml](configs/traffic-management/rate-limiting.yaml) | Token bucket rate limiter with per-IP and global modes |
3636
| [static-response.yaml](configs/traffic-management/static-response.yaml) | Fixed response without upstream |
3737
| [redirect.yaml](configs/traffic-management/redirect.yaml) | 3xx redirects with path/query template substitution |
38+
| [hostname-upstream.yaml](configs/traffic-management/hostname-upstream.yaml) | Resolve hostname upstream endpoints such as `localhost:9000` |
3839

3940
### Payload Processing
4041

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Hostname Upstream Endpoints
2+
#
3+
# Demonstrates using DNS hostnames instead of IP addresses for
4+
# upstream endpoints. The proxy resolves hostnames to IP addresses
5+
# at connection time, preferring IPv4 when multiple records exist.
6+
#
7+
# Example:
8+
#
9+
# curl http://localhost:8080/
10+
#
11+
# The request is forwarded to the backend resolved from the
12+
# hostname endpoint.
13+
14+
listeners:
15+
- name: default
16+
address: "0.0.0.0:8080"
17+
filter_chains: [main]
18+
19+
filter_chains:
20+
- name: main
21+
filters:
22+
- filter: router
23+
routes:
24+
- path_prefix: "/"
25+
cluster: backend
26+
- filter: load_balancer
27+
clusters:
28+
- name: backend
29+
endpoints:
30+
- "localhost:9000"

protocol/src/http/pingora/handler/upstream_peer.rs

Lines changed: 125 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
//!
66
//! [`Upstream`]: praxis_core::connectivity::Upstream
77
8-
use std::sync::Arc;
8+
use std::{net::ToSocketAddrs, sync::Arc};
99

1010
use pingora_core::{Result, upstreams::peer::HttpPeer};
1111
use praxis_core::connectivity::Upstream;
@@ -49,13 +49,7 @@ pub(super) fn execute(ctx: &mut PingoraRequestCtx) -> Result<Box<HttpPeer>> {
4949
/// [`HttpPeer`]: pingora_core::upstreams::peer::HttpPeer
5050
/// [`CachedClusterTls`]: praxis_tls::CachedClusterTls
5151
fn build_peer(upstream: &Upstream) -> Result<Box<HttpPeer>> {
52-
let addr: std::net::SocketAddr = upstream.address.parse().map_err(|e| {
53-
tracing::warn!(address = %upstream.address, error = %e, "failed to parse upstream address");
54-
pingora_core::Error::explain(
55-
pingora_core::ErrorType::InternalError,
56-
"upstream address resolution failed".to_owned(),
57-
)
58-
})?;
52+
let addr: std::net::SocketAddr = resolve_address(&upstream.address)?;
5953

6054
let tls_enabled = upstream.tls.is_some();
6155
let sni = upstream
@@ -133,6 +127,57 @@ fn derive_sni(address: &str) -> String {
133127
host.to_owned()
134128
}
135129

130+
/// Resolve an upstream address to a [`SocketAddr`].
131+
///
132+
/// Tries direct [`SocketAddr`] parsing first. If that fails (e.g. the
133+
/// address contains a hostname like `api.openai.com:443`), falls back
134+
/// to [`ToSocketAddrs`] which performs DNS resolution.
135+
///
136+
/// When DNS returns multiple records, prefers IPv4 addresses to avoid
137+
/// connectivity issues in dual-stack environments where IPv6 may be
138+
/// unreachable.
139+
///
140+
/// Note: DNS resolution is synchronous and runs on the request path.
141+
/// A future iteration may move resolution to config/load-balancer
142+
/// time or integrate an async cached resolver.
143+
///
144+
/// [`SocketAddr`]: std::net::SocketAddr
145+
/// [`ToSocketAddrs`]: std::net::ToSocketAddrs
146+
fn resolve_address(address: &str) -> Result<std::net::SocketAddr> {
147+
if let Ok(addr) = address.parse::<std::net::SocketAddr>() {
148+
return Ok(addr);
149+
}
150+
151+
let addrs: Vec<std::net::SocketAddr> = address
152+
.to_socket_addrs()
153+
.map_err(|e| {
154+
tracing::warn!(address, error = %e, "failed to resolve upstream address");
155+
pingora_core::Error::explain(
156+
pingora_core::ErrorType::InternalError,
157+
format!("upstream address resolution failed for '{address}': {e}"),
158+
)
159+
})?
160+
.collect();
161+
162+
select_preferred_address(&addrs, address)
163+
}
164+
165+
/// Select the preferred address from resolved results, favoring IPv4.
166+
fn select_preferred_address(addrs: &[std::net::SocketAddr], address: &str) -> Result<std::net::SocketAddr> {
167+
addrs
168+
.iter()
169+
.find(|a| a.is_ipv4())
170+
.or_else(|| addrs.first())
171+
.copied()
172+
.ok_or_else(|| {
173+
tracing::warn!(address, "DNS resolved but returned no addresses");
174+
pingora_core::Error::explain(
175+
pingora_core::ErrorType::InternalError,
176+
format!("upstream address '{address}' resolved to zero addresses"),
177+
)
178+
})
179+
}
180+
136181
// -----------------------------------------------------------------------------
137182
// Tests
138183
// -----------------------------------------------------------------------------
@@ -145,6 +190,7 @@ fn derive_sni(address: &str) -> String {
145190
clippy::field_reassign_with_default,
146191
clippy::too_many_lines,
147192
clippy::significant_drop_tightening,
193+
clippy::print_stderr,
148194
reason = "tests"
149195
)]
150196
mod tests {
@@ -250,11 +296,73 @@ mod tests {
250296
);
251297
}
252298

299+
#[test]
300+
fn resolve_address_parses_socket_addr() {
301+
let addr = resolve_address("127.0.0.1:8080").expect("socket addr should parse");
302+
assert_eq!(addr.port(), 8080, "port should match");
303+
}
304+
305+
#[test]
306+
fn resolve_address_resolves_localhost() {
307+
if !localhost_resolution_available() {
308+
eprintln!("skipping: localhost did not resolve in this environment");
309+
return;
310+
}
311+
let addr = resolve_address("localhost:8080").expect("localhost should resolve");
312+
assert_eq!(addr.port(), 8080, "port should match");
313+
}
314+
315+
#[test]
316+
fn resolve_address_fails_for_no_port() {
317+
assert!(
318+
resolve_address("127.0.0.1").is_err(),
319+
"address without port should return error"
320+
);
321+
}
322+
323+
#[test]
324+
fn hostname_address_builds_peer() {
325+
if !localhost_resolution_available() {
326+
eprintln!("skipping: localhost did not resolve in this environment");
327+
return;
328+
}
329+
assert!(
330+
build_peer(&make_upstream("localhost:8080")).is_ok(),
331+
"hostname address should build peer via DNS resolution"
332+
);
333+
}
334+
335+
#[test]
336+
fn select_preferred_address_prefers_ipv4_from_mixed_results() {
337+
let ipv6: std::net::SocketAddr = "[::1]:8080".parse().unwrap();
338+
let ipv4: std::net::SocketAddr = "127.0.0.1:8080".parse().unwrap();
339+
let selected =
340+
select_preferred_address(&[ipv6, ipv4], "mixed.example:8080").expect("mixed results should select address");
341+
assert_eq!(selected, ipv4, "IPv4 should be preferred over IPv6");
342+
}
343+
344+
#[test]
345+
fn select_preferred_address_returns_ipv6_when_ipv6_only() {
346+
let ipv6: std::net::SocketAddr = "[::1]:8080".parse().unwrap();
347+
let selected =
348+
select_preferred_address(&[ipv6], "ipv6.example:8080").expect("IPv6-only results should select IPv6");
349+
assert_eq!(selected, ipv6, "IPv6 should be used when it is the only result");
350+
}
351+
352+
#[test]
353+
fn select_preferred_address_errors_on_empty_results() {
354+
let err = select_preferred_address(&[], "empty.example:8080").expect_err("empty DNS result should fail");
355+
assert!(
356+
err.to_string().contains("resolved to zero addresses"),
357+
"unexpected error: {err}"
358+
);
359+
}
360+
253361
#[test]
254362
fn invalid_address_returns_error() {
255363
assert!(
256-
build_peer(&make_upstream("not-an-address")).is_err(),
257-
"invalid address should return error"
364+
build_peer(&make_upstream("invalid host:8080")).is_err(),
365+
"syntactically invalid address should return error"
258366
);
259367
}
260368

@@ -367,6 +475,13 @@ mod tests {
367475
// Test Utilities
368476
// -------------------------------------------------------------------------
369477

478+
/// Check whether `localhost` DNS resolution is available in this environment.
479+
fn localhost_resolution_available() -> bool {
480+
"localhost:8080"
481+
.to_socket_addrs()
482+
.is_ok_and(|mut addrs| addrs.next().is_some())
483+
}
484+
370485
/// Create a test upstream with the given address (no TLS).
371486
fn make_upstream(address: &str) -> Upstream {
372487
Upstream {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// SPDX-License-Identifier: MIT
2+
// Copyright (c) 2026 Praxis Contributors
3+
4+
//! Tests for the hostname upstream example configuration.
5+
6+
use std::collections::HashMap;
7+
8+
use praxis_test_utils::{free_port, http_get, start_backend_with_shutdown};
9+
10+
// -----------------------------------------------------------------------------
11+
// Tests
12+
// -----------------------------------------------------------------------------
13+
14+
#[test]
15+
fn hostname_upstream_routes_to_backend() {
16+
let backend_port_guard = start_backend_with_shutdown("hostname-backend");
17+
let backend_port = backend_port_guard.port();
18+
let proxy_port = free_port();
19+
let config = super::load_example_config(
20+
"traffic-management/hostname-upstream.yaml",
21+
proxy_port,
22+
HashMap::from([("localhost:9000", backend_port)]),
23+
);
24+
let proxy = praxis_test_utils::start_proxy(&config);
25+
let (status, body) = http_get(proxy.addr(), "/", None);
26+
assert_eq!(status, 200, "hostname upstream example should return 200");
27+
assert_eq!(body, "hostname-backend", "proxy should forward to hostname upstream");
28+
}

tests/integration/tests/suite/examples/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ mod conditional_filters;
1515
mod default_config;
1616
mod header_manipulation;
1717
mod health_checks;
18+
mod hostname_upstream;
1819
mod least_connections;
1920
mod logging;
2021
mod max_body_guard;

0 commit comments

Comments
 (0)