scion-quic 0.8.2

QUIC via SCION as transport
Documentation
// Copyright 2026 Anapaya Systems
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! The client connection bootstrap.
//!
//! The `connect()` function performs `squiche::connect`, drives the QUIC
//! handshake until the connection is established (the client analog of the
//! server endpoint's "establishing set", but far simpler — one connection, no
//! CID/retry/demux), enforces the `h3` ALPN, constructs the [`Http3ClientApp`]
//! via [`on_established`](crate::app::QuicScionApplication::on_established), and
//! builds the [`ConnectionHandle`].
//!
//! Once established, the connection is driven by two cooperating loops on the
//! bootstrap task: the shared [`QuicScionConnDriver`] (writes, timers, and
//! stepping the app) and the client [`ingress`] loop (reads inbound packets —
//! the single-connection analog of the server endpoint's recv
//! loop). The same task drives the handshake and then both loops, so the socket
//! is read continuously and there is no window where nobody is reading it. When
//! the connection closes the driver returns, which cancels the ingress loop; a
//! failing socket ends whichever loop reaches it first. Either way the
//! connection is left marked as no longer driven, so the client establishes a
//! new one rather than handing this one out again.

mod ingress;

use std::{sync::Arc, time::Duration};

use ring::rand::{SecureRandom, SystemRandom};
use sciparse::address::ip_socket_addr::ScionSocketIpAddr;
use tokio::sync::{Notify, oneshot};
use tokio_util::sync::CancellationToken;

use crate::{
    app::QuicScionApplication,
    h3::{
        client::{
            app::{Http3ClientApp, close_connection, on_driver_stopped},
            error::EstablishError,
            lifetime::{CloseReason, ConnLifetime},
        },
        common::H3_INTERNAL_ERROR,
    },
    quic::{
        cert_verifier::RejectionReport,
        connection::{ConnectionHandle, IsdAsnPair, QuicScionConn, QuicScionConnDriver},
    },
    socket::GenericScionUdpSocket,
};

/// Establishes an HTTP/3 connection to `remote` and returns a handle to it.
///
/// Spawns a task that drives the handshake, builds the application and handle,
/// reports the handle back, and then becomes the connection driver for the life
/// of the connection.
///
/// The bootstrap task owns the connection's teardown and `lifetime` for the whole of its life:
/// cancelling `closed` aborts the handshake if it is still running, and closes
/// the connection if it has already completed. Without that, a `closed` that
/// lands in the instant between the handshake finishing and the caller receiving
/// the handle would leave the caller with an error and the connection with
/// nobody to shut it down.
pub(crate) async fn connect(
    remote: ScionSocketIpAddr,
    socket: Arc<dyn GenericScionUdpSocket>,
    server_name: Option<String>,
    mut quiche_config: squiche::Config,
    rejection: Option<RejectionReport>,
    handshake_timeout: Duration,
    closed: CancellationToken,
    lifetime: Arc<ConnLifetime>,
) -> Result<ConnectionHandle<Http3ClientApp>, EstablishError> {
    let (mut tx, rx) = oneshot::channel();

    tokio::spawn(async move {
        // Ensure the connection is marked as closed when this task ends.
        let _lifetime_guard = CloseOnDrop(lifetime.clone());

        // Bound the handshake: an unreachable remote would otherwise only fail once quiche's idle
        // timeout elapses (far longer than callers expect).
        let handshake_result = tokio::select! {
            res = tokio::time::timeout(
                handshake_timeout,
                handshake(remote, &socket, server_name.as_deref(), &mut quiche_config),
            ) => match res {
                Ok(result) => result,
                Err(_elapsed) => Err(EstablishError::Handshake),
            },
            // Nobody is left to hand a connection to: the client was closed, or
            // the caller dropped the request it was establishing for.
            _ = closed.cancelled() => return,
            _ = tx.closed() => return,
        };
        let conn = match handshake_result {
            Ok(conn) => conn,
            Err(err) => {
                // A verifier that refused the chain explains the failure better
                // than the generic TLS error the handshake reports.
                let err = match rejection.as_ref().and_then(RejectionReport::take) {
                    Some(rejected) => EstablishError::CertificateRejected(rejected),
                    None => err,
                };
                let _ = tx.send(Err(err));
                return;
            }
        };

        let mut inner = conn;
        // Enforce the `h3` ALPN: a mismatch means no usable connection.
        if inner.application_proto() != b"h3" {
            let _ = inner.close(true, H3_INTERNAL_ERROR, b"expected h3 alpn");
            let _ = tx.send(Err(EstablishError::AlpnMismatch));
            return;
        }

        let app = Http3ClientApp::on_established(&mut inner, &());
        if app.h3.is_none() {
            let _ = tx.send(Err(EstablishError::H3Init));
            return;
        }

        let asn_pair = IsdAsnPair {
            from: socket.local_addr().isd_asn(),
            to: remote.isd_asn(),
        };
        let handle = ConnectionHandle::new(
            Notify::new(),
            QuicScionConn {
                asn_pair,
                inner,
                app,
            },
        );

        if tx.send(Ok(handle.clone())).is_err() {
            // The caller gave up waiting for the connection; close it here rather
            // than leave it running unreferenced until the idle timeout.
            close_connection(&handle);
        }

        // Drive the established connection with two cooperating loops: the shared
        // driver (writes + timers + stepping the app) and the client ingress loop
        // (feeds inbound packets, the single-connection analog of the server
        // endpoint's recv loop). When the connection closes the driver returns,
        // and `select!` drops — and thereby cancels — the ingress loop.
        //
        // The loops keep running after a close so the driver can flush
        // CONNECTION_CLOSE; they end when the connection reports itself closed.
        let driver = QuicScionConnDriver::new(handle.clone(), socket.clone());
        let reason = tokio::select! {
            res = driver.run() => match res {
                // Check why the connection ended, so the caller can observe it.
                Ok(()) => close_reason(&handle),
                Err(err) => {
                    tracing::warn!(?err, "client connection driver exited with a socket error");
                    CloseReason::Io
                }
            },
            _ = ingress::run(handle.clone(), socket) =>{
                if handle.lock().inner.is_closed() {
                    // If the handle was closed, get reason from the state
                    close_reason(&handle)
                } else {
                    // otherwise, the socket failed.
                    CloseReason::Io
                }
            },
            _ = close_on_cancel(&handle, &closed) => CloseReason::Local,
        };
        // Both loops are gone, so the connection is unusable whatever ended
        // them: a socket failure leaves a transport that still looks live but
        // has nobody to send, receive, or time it out for it.
        on_driver_stopped(&handle);
        lifetime.close(reason);
    });

    rx.await.map_err(|_| EstablishError::Handshake)?
}

/// Ends a connection's lifetime when the task that owns it goes away.
///
/// If a close reason is already set, no action is taken.
struct CloseOnDrop(Arc<ConnLifetime>);

impl Drop for CloseOnDrop {
    fn drop(&mut self) {
        self.0.close(CloseReason::Other);
    }
}

/// Reads off a closed connection why it ended.
fn close_reason(handle: &ConnectionHandle<Http3ClientApp>) -> CloseReason {
    let conn = handle.lock();
    if conn.app.locally_closed {
        CloseReason::Local
    } else if conn.inner.is_timed_out() {
        CloseReason::IdleTimeout
    } else if conn.inner.peer_error().is_some() {
        CloseReason::Peer
    } else {
        CloseReason::Other
    }
}

/// Closes the connection once `closed` is cancelled, then parks forever so the
/// driver finishes the teardown and ends the `select!` that runs it.
async fn close_on_cancel(handle: &ConnectionHandle<Http3ClientApp>, closed: &CancellationToken) {
    closed.cancelled().await;
    close_connection(handle);
    std::future::pending::<()>().await
}

/// Drives the QUIC handshake until the connection is established, returning the
/// established `squiche::Connection`.
async fn handshake(
    remote: ScionSocketIpAddr,
    socket: &Arc<dyn GenericScionUdpSocket>,
    server_name: Option<&str>,
    config: &mut squiche::Config,
) -> Result<squiche::Connection, EstablishError> {
    let scid = generate_connection_id();
    let local_addr = socket.local_addr().socket_addr();
    let remote_addr = remote.socket_addr();

    let mut conn = squiche::connect(server_name, &scid, local_addr, remote_addr, config)
        .map_err(EstablishError::Quic)?;

    let mut send_buf = Box::new([0u8; 65535]);
    let mut recv_buf = Box::new([0u8; 65535]);

    loop {
        // Flush everything the connection has queued (handshake packets,
        // retransmissions, ACKs).
        loop {
            match conn.send(send_buf.as_mut()) {
                Ok((n, info)) => {
                    let to = ScionSocketIpAddr::new(remote.isd_asn(), info.to.ip(), info.to.port());
                    socket
                        .send_to(&send_buf[..n], to)
                        .await
                        .map_err(EstablishError::Io)?;
                }
                Err(squiche::Error::Done) => break,
                Err(err) => return Err(EstablishError::Quic(err)),
            }
        }

        if conn.is_established() {
            return Ok(conn);
        }
        if conn.is_closed() {
            return Err(EstablishError::Handshake);
        }

        // Wait for an inbound packet or the connection's timeout.
        let sleep = conn.timeout().unwrap_or(Duration::from_secs(1));
        tokio::select! {
            res = socket.recv_from(recv_buf.as_mut()) => {
                let (len, from) = res.map_err(EstablishError::Io)?;
                let from = from.socket_addr();
                let to = socket.local_addr().socket_addr();

                let recv_info = squiche::RecvInfo { from, to };
                if let Err(err) = conn.recv(&mut recv_buf[..len], recv_info) {
                    tracing::warn!(?err, "client handshake: error feeding inbound packet");
                }
            }
            _ = tokio::time::sleep(sleep) => {
                conn.on_timeout();
            }
        }
    }
}

/// Generates a random QUIC source connection ID.
fn generate_connection_id() -> squiche::ConnectionId<'static> {
    let mut scid = [0u8; squiche::MAX_CONN_ID_LEN];
    // `fill` only fails for unsupported lengths; a fixed-size array never does.
    SystemRandom::new()
        .fill(&mut scid)
        .expect("system RNG fill");
    squiche::ConnectionId::from_vec(scid.to_vec())
}