rust-mc-status 3.0.0

High-performance asynchronous Rust library for querying Minecraft server status (Java & Bedrock)
Documentation
//! HTTP CONNECT proxy tunnel implementation.
//!
//! HTTP CONNECT is a standard TCP tunnelling method supported by Squid, Nginx,
//! HAProxy, and most corporate HTTP proxies.  The proxy establishes a raw TCP
//! pipe to the target and the client then speaks any TCP protocol through it.
//!
//! **UDP is not supported** — Bedrock pings through an HTTP proxy always return
//! [`McError::Proxy(ProxyError::UdpUnsupported)`](crate::error::ProxyError).
//!
//! # Protocol flow
//!
//! ```text
//! Client → Proxy:
//!   CONNECT mc.hypixel.net:25565 HTTP/1.1\r\n
//!   Host: mc.hypixel.net:25565\r\n
//!   [Proxy-Authorization: Basic <base64(user:pass)>\r\n]
//!   \r\n
//!
//! Proxy → Client (success):
//!   HTTP/1.1 200 Connection established\r\n\r\n
//!
//! Proxy → Client (failure examples):
//!   HTTP/1.1 407 Proxy Authentication Required\r\n\r\n
//!   HTTP/1.1 403 Forbidden\r\n\r\n
//!
//! After 200: raw TCP tunnel — Minecraft handshake proceeds normally.
//! ```
//!
//! # Error handling
//!
//! - Non-2xx status codes produce [`ProxyError::Connection`](crate::error::ProxyError)
//!   with the HTTP status code and reason phrase.
//! - If the proxy closes the connection before sending `\r\n\r\n`, the error
//!   includes the partial response and byte count for easier debugging.
//! - Responses larger than 4096 bytes are rejected to guard against
//!   misbehaving proxies.

use std::time::Duration;

use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::timeout;

use crate::error::McError;
use super::ProxyAuth;

/// Connect to `target_host:target_port` through an HTTP CONNECT proxy at
/// `proxy_addr`, optionally with `auth` credentials.
///
/// Returns a `TcpStream` that is already tunnelled through the proxy and ready
/// for the Minecraft handshake — callers treat it identically to a direct
/// `TcpStream`.
pub(crate) async fn connect(
    proxy_addr:  &str,
    target_host: &str,
    target_port: u16,
    auth:        Option<&ProxyAuth>,
    tout:        Duration,
) -> Result<TcpStream, McError> {
    // ── 1. TCP connect to the proxy ───────────────────────────────────────────
    let mut stream = timeout(tout, TcpStream::connect(proxy_addr))
        .await
        .map_err(|_| McError::timeout())?
        .map_err(|e| McError::connection(e.to_string()))?;

    stream.set_nodelay(true).map_err(McError::Io)?;

    // ── 2. Send CONNECT request ───────────────────────────────────────────────
    let target = format!("{target_host}:{target_port}");
    let mut req = format!(
        "CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n"
    );

    if let Some(a) = auth {
        let credentials = format!("{}:{}", a.username, a.password);
        let encoded = B64.encode(credentials.as_bytes());
        req.push_str(&format!("Proxy-Authorization: Basic {encoded}\r\n"));
    }
    req.push_str("\r\n");

    timeout(tout, stream.write_all(req.as_bytes()))
        .await
        .map_err(|_| McError::timeout())?
        .map_err(McError::Io)?;

    // ── 3. Read and validate the proxy response ───────────────────────────────
    //
    // We read byte-by-byte until we see the `\r\n\r\n` header terminator.
    // The buffer is tiny — typical proxy responses are 40–80 bytes.
    let response = read_response(&mut stream, tout).await?;
    validate_connect_response(&response)?;

    Ok(stream)
}

/// Read HTTP response headers, stopping at the blank line (`\r\n\r\n`).
async fn read_response(stream: &mut TcpStream, tout: Duration) -> Result<String, McError> {
    let mut buf   = Vec::with_capacity(256);
    let mut byte  = [0u8; 1];

    loop {
        match timeout(tout, stream.read_exact(&mut byte)).await {
            Err(_)                                     => return Err(McError::timeout()),
            Ok(Err(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
                // Proxy closed the socket before sending a complete response.
                // Common causes: auth required but not provided, target blocked,
                // or the proxy server is misconfigured.
                let partial = String::from_utf8_lossy(&buf).into_owned();
                return Err(McError::proxy_error(format!(
                    "proxy closed connection after {} bytes (partial response: {:?})",
                    partial.len(),
                    partial.lines().next().unwrap_or("(empty)")
                )));
            }
            Ok(Err(e)) => return Err(McError::Io(e)),
            Ok(Ok(_)) => {}
        }

        buf.push(byte[0]);

        // Detect end of headers: \r\n\r\n
        if buf.ends_with(b"\r\n\r\n") {
            break;
        }

        // Guard against a misbehaving proxy sending an enormous response
        if buf.len() > 4096 {
            return Err(McError::proxy_error("HTTP CONNECT response exceeds 4096 bytes"));
        }
    }

    String::from_utf8(buf)
        .map_err(|_| McError::proxy_error("HTTP CONNECT response is not valid UTF-8"))
}

/// Parse the status line and ensure the proxy returned 2xx.
pub(crate) fn validate_connect_response(response: &str) -> Result<(), McError> {
    // Expected first line: "HTTP/1.x 200 ..."
    let status_line = response.lines().next().unwrap_or("");
    let mut parts   = status_line.splitn(3, ' ');

    // Skip HTTP version token
    parts.next();

    let code: u16 = parts
        .next()
        .and_then(|s| s.parse().ok())
        .ok_or_else(|| McError::proxy_error(
            format!("could not parse HTTP CONNECT response: {status_line}")
        ))?;

    if (200..300).contains(&code) {
        Ok(())
    } else {
        let reason = parts.next().unwrap_or("").trim();
        Err(McError::proxy_error(format!(
            "HTTP CONNECT failed with {code} {reason}"
        )))
    }
}

/// Test-only re-export of `validate_connect_response`.
#[doc(hidden)]
pub fn validate_response_test(response: &str) -> Result<(), crate::McError> {
    validate_connect_response(response)
}