sdk/src/reverse_proxy.rs
Rama main · réplica de lectura del repositorio original.
//! The loopback reverse proxy, the guest node's side of reverse-proxy mode.
//!
//! The user's browser talks to `http://<app>.<share>.wa.localhost:<port>`, and
//! every request becomes one REVERSE_PROXY stream to the share's host node. The
//! bare `http://wa.localhost:<port>` is used for browser pairing (see
//! [`crate::pairing`]).
use crate::loopback::{
BoxedBody, RequiredCookie, empty_body, error_response, gone_response,
is_protocol_upgrade_request, means_browser_went_away, relay_upgraded_connection, strip_cookies,
strip_hop_by_hop_headers,
};
use crate::pairing::{self, CookieIssuer};
use crate::transports::TransportError;
use crate::{Client, Lookup, ShareId};
use anyhow::{Context, Result};
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::StatusCode;
use hyper::body::Incoming;
use hyper::client::conn::http1 as http1_client;
use hyper::server::conn::http1 as http1_server;
use hyper_util::rt::TokioIo;
use std::collections::HashMap;
use std::convert::Infallible;
use std::sync::Arc;
use tokio::net::{TcpListener, TcpStream};
use tracing::{info, warn};
/// The host-routed proxy's domain. Every browser resolves `*.localhost` to
/// loopback, and the pairing cookie needs a dotted parent to be scoped to.
/// Apps get served at `<app>.<share>.wa.localhost`.
pub const PROXY_DOMAIN: &str = "wa.localhost";
/// Authenticates requests to a proxy port. Demands `cookie` on every request,
/// rejects with 403 otherwise. When in "pairing" mode, also issues the cookie.
#[derive(Clone)]
pub struct Authenticator {
cookie: RequiredCookie,
issuer: Option<Arc<CookieIssuer>>,
}
impl Authenticator {
/// Create an authenticator for app-cookie mode.
pub fn for_cookie(cookie: RequiredCookie) -> Self {
Self {
cookie,
issuer: None,
}
}
/// Create an authenticator for pairing mode.
pub fn for_pairing(issuer: CookieIssuer) -> Self {
Self {
cookie: issuer.required_cookie().clone(),
issuer: Some(Arc::new(issuer)),
}
}
/// True if the request passes authentication.
fn admits(&self, req: &hyper::Request<Incoming>) -> bool {
self.cookie.is_presented_in(req.headers())
}
/// Removes the cookie from the request, so the proxied app can't read it.
/// If the browser is paired with other clients, their pairing cookies get
/// removed too.
fn scrub(&self, headers: &mut hyper::HeaderMap) {
strip_cookies(headers, |name| {
name == self.cookie.name || pairing::is_pairing_cookie(name)
});
}
/// What issues the cookie to browsers, in pairing mode.
pub fn issuer(&self) -> Option<&CookieIssuer> {
self.issuer.as_deref()
}
}
/// Where a listener's requests get their route from.
#[derive(Clone)]
pub enum RouteSource {
/// The `Host` header: `<app>.<share>.wa.localhost`, or the proxy's own
/// `wa.localhost`.
HostHeader,
/// Every request goes to this one app.
Fixed { share: ShareId, app: String },
}
/// Serves every connection against the client's shares, until cancelled.
pub async fn accept_loop(
listener: TcpListener,
client: Client,
route_source: RouteSource,
authenticator: Option<Authenticator>,
) {
loop {
match listener.accept().await {
Ok((tcp_stream, _)) => {
let client = client.clone();
let route_source = route_source.clone();
let authenticator = authenticator.clone();
tokio::spawn(async move {
if let Err(e) =
handle_connection(tcp_stream, client, route_source, authenticator).await
{
warn!(error = format!("{e:#}"), "connection error");
}
});
}
Err(e) => {
warn!(error = format!("{e:#}"), "accept error");
// A reclaimed socket (iOS) fails every accept; don't spin.
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}
}
}
async fn handle_connection(
tcp_stream: TcpStream,
client: Client,
route_source: RouteSource,
authenticator: Option<Authenticator>,
) -> Result<()> {
let tcp_stream = TokioIo::new(tcp_stream);
let service = hyper::service::service_fn(move |req| {
forward(
req,
client.clone(),
route_source.clone(),
authenticator.clone(),
)
});
let served = http1_server::Builder::new()
.serve_connection(tcp_stream, service)
// Allow a 101 to hand the browser socket over for raw relaying (WebSocket).
.with_upgrades()
.await;
match served {
Ok(()) => Ok(()),
Err(e) if means_browser_went_away(&e) => Ok(()),
Err(e) => Err(e).context("HTTP/1 connection error"),
}
}
async fn forward(
mut req: hyper::Request<Incoming>,
client: Client,
route_source: RouteSource,
authenticator: Option<Authenticator>,
) -> Result<hyper::Response<BoxedBody>, Infallible> {
// Determine the share and app...
let (share, app) = match route_source {
RouteSource::Fixed { share, app } => (share.to_string(), app),
RouteSource::HostHeader => {
let Ok(host) = extract_host(&req) else {
return Ok(error_response(
StatusCode::BAD_REQUEST,
"missing host header",
));
};
match parse_host(&host) {
Ok(Route::App { share, app }) => (share, app),
Ok(Route::Proxy) => {
return Ok(answer_own_host(&req, &host, authenticator.as_ref()));
}
Err(e) => return Ok(error_response(StatusCode::NOT_FOUND, &e.to_string())),
}
}
};
// Only the user's own browser gets past here, and the app itself never
// sees the cookie.
if let Some(authenticator) = &authenticator {
if !authenticator.admits(&req) {
return Ok(match authenticator.issuer() {
Some(_) => not_paired_page(),
None => error_response(StatusCode::FORBIDDEN, "forbidden"),
});
}
authenticator.scrub(req.headers_mut());
}
let share = match client.restore_guest_node(&share).await {
Ok(Lookup::Live(share)) => share,
Ok(Lookup::Dead(state)) => return Ok(gone_response(state)),
Ok(Lookup::Unknown) => {
return Ok(error_response(
StatusCode::NOT_FOUND,
&format!("unknown share '{}'", share),
));
}
Err(e) => {
warn!(
share,
error = format!("{e:#}"),
"could not restore the share"
);
return Ok(error_response(
StatusCode::BAD_GATEWAY,
"Wispers Access server unavailable",
));
}
};
// ... and open a REVERSE_PROXY stream to it, naming the app.
let fwd_stream = match share.open_reverse_proxy_stream(&app).await {
Ok(s) => s,
Err(TransportError::Terminal(state)) => return Ok(gone_response(state)),
Err(TransportError::Transient(e)) => {
warn!(
share = share.label(),
error = format!("{e:#}"),
"could not open a stream"
);
return Ok(error_response(
StatusCode::BAD_GATEWAY,
"Wispers Access server unavailable",
));
}
};
let fwd_io = TokioIo::new(fwd_stream);
let (mut sender, conn) = match http1_client::handshake(fwd_io).await {
Ok(hs) => hs,
Err(e) => {
warn!(
share = share.label(),
error = format!("{e:#}"),
"client handshake failed"
);
return Ok(error_response(
StatusCode::BAD_GATEWAY,
"Wispers Access server unavailable",
));
}
};
tokio::spawn(async move {
if let Err(e) = conn.with_upgrades().await {
warn!(error = format!("{e:#}"), "upstream connection error");
}
});
// An Upgrade request (e.g. WebSocket) keeps its handshake headers and, on a
// 101, becomes a raw byte relay. Capture the browser-side upgrade now, before
// the request is consumed; it resolves once we send the 101 back.
let upgrade = is_protocol_upgrade_request(req.headers());
let peer_upgrade = upgrade.then(|| hyper::upgrade::on(&mut req));
// Rewrite the query.
let (mut parts, body) = req.into_parts();
strip_hop_by_hop_headers(&mut parts.headers, upgrade);
if !upgrade {
// One QUIC stream per request: force close so hyper FINs the stream on both
// ends after the single response, instead of holding it open in keep-alive.
// Otherwise the stream is never finished or dropped, quiche never collects
// it, and the peer never returns MAX_STREAMS credit — so open_stream
// eventually blocks and requests hang under load. An upgrade is exempt: it
// deliberately holds one stream open for the socket's lifetime, then FINs.
parts.headers.insert(
hyper::header::CONNECTION,
hyper::header::HeaderValue::from_static("close"),
);
}
let rewritten = hyper::Request::from_parts(parts, body);
// Forward to upstream and get response.
let mut resp = match sender.send_request(rewritten).await {
Ok(r) => r,
Err(e) => {
warn!(
share = share.label(),
error = format!("{e:#}"),
"sending the request failed"
);
return Ok(error_response(
StatusCode::BAD_GATEWAY,
"Wispers Access server unavailable",
));
}
};
// The host node no longer has this app: our list is stale. Refresh it in
// the background and pass the 404 on as it is.
if resp.status() == StatusCode::NOT_FOUND
&& resp
.headers()
.get(ERROR_HEADER)
.is_some_and(|v| v == "app-not-found")
{
info!(
share = share.label(),
app, "app is gone; refreshing the app list"
);
let share = share.clone();
tokio::spawn(async move {
if let Err(crate::guest_node::RefreshError::Transient(e)) = share.refresh().await {
warn!(
share = share.label(),
error = format!("{e:#}"),
"could not refresh the app list"
);
}
});
}
// Successful upgrade: hand both raw byte streams to a relay task and return
// the 101 to the browser with its handshake headers intact.
if resp.status() == StatusCode::SWITCHING_PROTOCOLS {
match peer_upgrade {
Some(peer_upgrade) => {
let upstream_upgrade = hyper::upgrade::on(&mut resp);
tokio::spawn(relay_upgraded_connection(peer_upgrade, upstream_upgrade));
}
None => warn!(
share = share.label(),
"the app returned 101 without an upgrade request"
),
}
let (mut parts, _body) = resp.into_parts();
strip_hop_by_hop_headers(&mut parts.headers, true);
return Ok(hyper::Response::from_parts(parts, empty_body()));
}
// Rewrite the response on the way back.
let (mut parts, body) = resp.into_parts();
strip_hop_by_hop_headers(&mut parts.headers, false);
let body: BoxedBody = body.map_err(std::io::Error::other).boxed();
Ok(hyper::Response::from_parts(parts, body))
}
/// A request to the proxy's own host, `wa.localhost`. Pairing, when the
/// proxy pairs browsers, and nothing else.
fn answer_own_host(
req: &hyper::Request<Incoming>,
host: &str,
authenticator: Option<&Authenticator>,
) -> hyper::Response<BoxedBody> {
match (req.uri().path(), authenticator.and_then(|a| a.issuer())) {
("/pair", Some(issuer)) => answer_pair(req, host, issuer),
_ => error_response(
StatusCode::NOT_FOUND,
&format!("apps are served at http://<app>.<share>.{PROXY_DOMAIN}:<port>"),
),
}
}
/// The pairing URL for a given token and app. Answered by [`answer_pair`].
pub fn pair_url(port: u16, token: &str, share: &str, app: &str) -> String {
format!("http://{PROXY_DOMAIN}:{port}/pair?token={token}&share={share}&app={app}")
}
/// Answers `GET /pair?token=…&share=…&app=…[&path=…]`, `host` being the
/// request's `Host` header, port included. A paired browser is redirected to
/// the app unconditionally, an unpaired one with a live token gets
/// the cookie and a redirect. Any other gets the page that says so.
fn answer_pair(
req: &hyper::Request<Incoming>,
host: &str,
issuer: &CookieIssuer,
) -> hyper::Response<BoxedBody> {
let params = parse_query(req.uri().query().unwrap_or(""));
let (Some(share), Some(app)) = (params.get("share"), params.get("app")) else {
return error_response(StatusCode::BAD_REQUEST, "missing share or app");
};
if !is_dns_label(share) || !is_dns_label(app) {
return error_response(StatusCode::BAD_REQUEST, "invalid share or app");
}
let path = params.get("path").copied().unwrap_or("/");
if !path.starts_with('/') {
return error_response(StatusCode::BAD_REQUEST, "path must be absolute");
}
// Prepend app and share (and optionally append the path) to get the
// redirect location.
let location = format!("http://{app}.{share}.{host}{path}");
if issuer.required_cookie().is_presented_in(req.headers()) {
return redirect(&location, None);
}
if params.get("token").is_some_and(|t| issuer.consume_token(t)) {
return redirect(&location, Some(issuer.set_cookie_header()));
}
not_paired_page()
}
/// The 403 for a browser the proxy does not know, telling the user to come back
/// through the app, to get a pairing link.
fn not_paired_page() -> hyper::Response<BoxedBody> {
let body: BoxedBody = Full::new(Bytes::from_static(NOT_PAIRED_HTML.as_bytes()))
.map_err(|never: Infallible| match never {})
.boxed();
hyper::Response::builder()
.status(StatusCode::FORBIDDEN)
.header("content-type", "text/html; charset=utf-8")
.header("cache-control", "no-store")
.body(body)
.expect("static error response is always valid")
}
const NOT_PAIRED_HTML: &str = include_str!("not_paired.html");
/// Parse the query's `name=value` pairs, without decoding them. Our values are
/// tokens and DNS labels, and `path` is passed on encoded as it came.
fn parse_query(query: &str) -> HashMap<&str, &str> {
query
.split('&')
.filter_map(|pair| pair.split_once('='))
.collect()
}
/// True if s is DNS safe, i.e. can be used as a hostname label.
fn is_dns_label(s: &str) -> bool {
!s.is_empty() && s.len() <= 63 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}
/// A 302 to `location`.
fn redirect(location: &str, set_cookie: Option<String>) -> hyper::Response<BoxedBody> {
let mut builder = hyper::Response::builder()
.status(StatusCode::FOUND)
.header(hyper::header::LOCATION, location)
.header(hyper::header::REFERRER_POLICY, "no-referrer");
if let Some(cookie) = set_cookie {
builder = builder.header(hyper::header::SET_COOKIE, cookie);
}
match builder.body(empty_body()) {
Ok(response) => response,
// Only a path with characters no header may carry gets here.
Err(_) => error_response(StatusCode::BAD_REQUEST, "invalid path"),
}
}
/// Extract host from the request. Works with both HTTP 1 & 2.
fn extract_host(req: &hyper::Request<Incoming>) -> Result<String> {
// Get the host header (or the "authority" in HTTP/2 lingo).
let host = req.uri().authority().map(|a| a.as_str()).or_else(|| {
req.headers()
.get(hyper::header::HOST)
.and_then(|v| v.to_str().ok())
});
let Some(host) = host else {
anyhow::bail!("missing host header");
};
Ok(host.to_owned())
}
/// The header waserver sets on errors it generated itself, so a 404 from
/// the proxy can be told from one the app sent.
const ERROR_HEADER: &str = "x-wispers-access-error";
/// Where a request goes, as its `Host` header says.
enum Route {
/// `<app>.<share>.wa.localhost`, an app of a share.
App { share: String, app: String },
/// `wa.localhost` itself, the proxy's own pages.
Proxy,
}
/// `<app>.<share>.wa.localhost[:port]` or `wa.localhost[:port]`.
fn parse_host(host: &str) -> Result<Route> {
let host = host.rsplit_once(':').map_or(host, |(h, port)| {
if port.chars().all(|c| c.is_ascii_digit()) {
h
} else {
host
}
});
if host == PROXY_DOMAIN {
return Ok(Route::Proxy);
}
let labels = host
.strip_suffix(PROXY_DOMAIN)
.and_then(|prefix| prefix.strip_suffix('.'))
.map(|prefix| prefix.split('.').collect::<Vec<_>>());
match labels.as_deref() {
Some([app, share]) if !app.is_empty() && !share.is_empty() => Ok(Route::App {
share: (*share).to_owned(),
app: (*app).to_owned(),
}),
_ => anyhow::bail!(
"unknown host {host}: apps are served at http://<app>.<share>.{PROXY_DOMAIN}:<port>"
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_clients_pairing_cookie_is_scrubbed() {
let dir =
std::env::temp_dir().join(format!("wispers-access-scrub-{}", uuid::Uuid::new_v4()));
let store = crate::secrets::FileSecretStore::new(dir.clone());
let issuer = CookieIssuer::restore_or_mint(&store, std::time::Duration::MAX).unwrap();
let ours = issuer.required_cookie().name.clone();
let mut headers = hyper::HeaderMap::new();
headers.insert(
hyper::header::COOKIE,
format!(
"a=1; {ours}=x; __wispers_access_pairing_0123456789abcdef=y; \
__wispers_access_pairing=z; b=2"
)
.parse()
.unwrap(),
);
Authenticator::for_pairing(issuer).scrub(&mut headers);
let left: Vec<_> = headers.get_all(hyper::header::COOKIE).iter().collect();
assert_eq!(left, ["a=1; b=2"]);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn only_labels_and_absolute_paths_make_a_pair_target() {
assert!(is_dns_label("round-trip"));
assert!(!is_dns_label(""));
assert!(!is_dns_label("a.b"));
assert!(!is_dns_label("evil.com/"));
let params = parse_query("token=t&share=s&app=a&path=/x?y=1");
assert_eq!(params["path"], "/x?y=1");
assert_eq!(params.len(), 4);
}
fn target(host: &str) -> Result<(String, String)> {
match parse_host(host)? {
Route::App { share, app } => Ok((share, app)),
Route::Proxy => anyhow::bail!("the proxy's own host"),
}
}
#[test]
fn targets_are_app_dot_share_under_the_proxy_domain() {
assert_eq!(
target("echo.round-trip.wa.localhost:8000").unwrap(),
("round-trip".to_owned(), "echo".to_owned())
);
assert_eq!(
target("echo.round-trip.wa.localhost").unwrap(),
("round-trip".to_owned(), "echo".to_owned())
);
assert!(matches!(parse_host("wa.localhost:8000"), Ok(Route::Proxy)));
assert!(matches!(parse_host("wa.localhost"), Ok(Route::Proxy)));
// One label is not enough, and neither is a foreign host or the old shape.
assert!(target("round-trip.wa.localhost:8000").is_err());
assert!(target("localhost:8000").is_err());
assert!(target("echo.round-trip.localhost").is_err());
assert!(target("echo.round-trip.example.com").is_err());
assert!(target(".round-trip.wa.localhost").is_err());
assert!(target("a.b.c.wa.localhost").is_err());
assert!(target("evilwa.localhost").is_err());
}
}