mod limits;
mod wire;
use std::io::{self, ErrorKind, Write as _};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
use std::time::Instant;
use openmoat_core::{CompiledPolicy, Decision, Verdict};
use zeroize::Zeroizing;
pub use self::limits::Limits;
use self::wire::{read_head, read_hello, refuse, respond, summary};
use crate::audit::{Connection, Recorder};
use crate::body::{self, BodyEnd, Framing};
use crate::broker::{Broker, Watch};
use crate::decide::{self, RULE_REQUEST, RULE_SNI};
use crate::local::{self, LocalService};
use crate::request::{Request, Target};
use crate::session::{RULE_SESSION, Sessions};
use crate::slots::{PENDING, Share, Shares};
use crate::tunnel::{self, Forward};
use crate::upstream::{self, Refusal, Resolve};
const MALFORMED_CHUNKS: &str = "malformed chunked request body";
#[derive(Clone, Copy)]
pub struct Proxy<'a> {
pub policy: &'a CompiledPolicy<'a>,
pub resolver: &'a dyn Resolve,
pub recorder: &'a dyn Recorder,
pub limits: &'a Limits,
pub broker: &'a Broker,
pub loopback_ok: &'a [SocketAddr],
pub allow_ech: bool,
pub sessions: Option<&'a Sessions<'a>>,
pub local: Option<&'a dyn LocalService>,
}
impl std::fmt::Debug for Proxy<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Proxy")
.field("limits", &self.limits)
.field("broker", &self.broker)
.field("loopback_ok", &self.loopback_ok)
.field("allow_ech", &self.allow_ech)
.field("sessions", &self.sessions)
.field("local", &self.local.is_some())
.finish_non_exhaustive()
}
}
struct Served<'s> {
addresses: &'s CompiledPolicy<'s>,
shares: Shares,
}
#[derive(Clone, Copy)]
struct Turn<'s> {
session: Option<&'s str>,
started: Instant,
}
impl Proxy<'_> {
pub fn serve(&self, listener: &TcpListener) -> io::Result<()> {
self.limits.check()?;
let address_policy = decide::address_policy(self.policy.policy());
let addresses = CompiledPolicy::compile(&address_policy, self.policy.context())
.map_err(io::Error::other)?;
let served = Served {
addresses: &addresses,
shares: Shares::new(self.limits.max_connections),
};
let served = &served;
let active = AtomicUsize::new(0);
thread::scope(|scope| {
loop {
let client = match listener.accept() {
Ok((client, _)) => client,
Err(e) if is_transient(&e) => continue,
Err(e) => return Err(e),
};
let _ = client.set_write_timeout(Some(self.limits.handshake_timeout));
let slot = Slot::take(&active, self.limits.max_connections);
let Some(slot) = slot else {
respond(&client, 503, "too many connections");
continue;
};
let pending = if self.sessions.is_some() {
let Some(share) = served.shares.take(PENDING) else {
respond(&client, 503, "too many connections waiting to authenticate");
continue;
};
Some(share)
} else {
None
};
let _ = thread::Builder::new().spawn_scoped(scope, move || {
let _slot = slot;
self.handle(&client, served, pending);
});
}
})
}
fn handle(&self, client: &TcpStream, served: &Served<'_>, pending: Option<Share<'_>>) {
let started = Instant::now();
let timeout = Some(self.limits.handshake_timeout);
if client.set_read_timeout(timeout).is_err() || client.set_write_timeout(timeout).is_err() {
return;
}
let mut buf = Zeroizing::new(Vec::new());
let deadline = started + self.limits.handshake_timeout;
if let Some(local) = self.local
&& !local.prefix().is_empty()
&& local::starts_with(client, local.prefix(), deadline)
{
local.serve(client);
return;
}
let request = match read_head(client, &mut buf, self.limits.max_head_bytes, deadline) {
Ok(request) => request,
Err(why) => {
let decision = Decision::single(Verdict::Deny, RULE_REQUEST, why);
let turn = Turn {
session: None,
started,
};
self.record(turn, None, &decision);
refuse(client, 400, &decision.reasons[0]);
return;
}
};
let Some((turn, _share)) = self.admit(client, &request, served, started) else {
return;
};
drop(pending);
let mut watch = Watch::new(self.broker, request.host());
if let Some(leak) = watch.next(&buf) {
let decision = leak.decision(request.host());
self.record(turn, Some(&request), &decision);
refuse(client, 403, &summary(&decision));
return;
}
let rest = Zeroizing::new(buf.split_off(request.head_len));
let decision = decide::host(self.policy, request.host());
if decision.verdict != Verdict::Allow {
self.record(turn, Some(&request), &decision);
refuse(client, 403, &summary(&decision));
return;
}
if let Some(refusal) = self.tainted(turn, &request) {
self.record(turn, Some(&request), &refusal);
refuse(client, 403, &summary(&refusal));
return;
}
let mut body = BodyEnd::new(match &request.target {
Target::Http { head, .. } => body::framing(head),
Target::Connect { .. } => Framing::None,
});
let Ok(body_in_rest) = body.feed(&rest) else {
let decision = Decision::single(Verdict::Deny, RULE_REQUEST, MALFORMED_CHUNKS);
self.record(turn, Some(&request), &decision);
refuse(client, 400, MALFORMED_CHUNKS);
return;
};
let upstream = match upstream::connect(
self.resolver,
request.host(),
request.port(),
served.addresses,
self.loopback_ok,
self.limits.connect_timeout,
) {
Ok(upstream) => upstream,
Err(refusal) => {
let decision = refusal.decision(request.host());
self.record(turn, Some(&request), &decision);
let status = if matches!(refusal, Refusal::Address(..) | Refusal::Denied(..)) {
403
} else {
502
};
refuse(client, status, &summary(&decision));
return;
}
};
let mut inspect = |chunk: &[u8]| {
let Ok(n) = body.feed(chunk) else {
return Forward::Stop;
};
if let Some(leak) = watch.next(&chunk[..n]) {
self.record(turn, Some(&request), &leak.decision(request.host()));
return Forward::Stop;
}
if n < chunk.len() || body.done() {
Forward::Part(n)
} else {
Forward::All
}
};
let (first_bytes, inspect) = match &request.target {
Target::Connect { host, .. } => {
match self.check_tunnel(client, host, rest.to_vec(), &decision, &request, turn) {
Some(hello) => (Zeroizing::new(hello), None),
None => return,
}
}
Target::Http { head, host, .. } => {
let mut decision = decision.clone();
decision
.reasons
.extend(self.broker.withheld(host).iter().map(|id| {
format!("secret `{id}` not injected: plain HTTP needs `plain_http: true`")
}));
if !self.record(turn, Some(&request), &decision) {
refuse(client, 403, "audit log unavailable");
return;
}
let inspect: tunnel::Inspect<'_> = &mut inspect;
let injected = self.broker.inject(host, head);
let head = injected.as_deref().map_or(head.as_slice(), Vec::as_slice);
let body_bytes = &rest[..body_in_rest];
(Zeroizing::new([head, body_bytes].concat()), Some(inspect))
}
};
if (&upstream).write_all(&first_bytes).is_ok() {
tunnel::relay(client, &upstream, self.limits.idle_timeout, inspect);
}
}
fn admit<'s>(
&'s self,
client: &TcpStream,
request: &Request,
served: &'s Served<'_>,
started: Instant,
) -> Option<(Turn<'s>, Option<Share<'s>>)> {
let mut turn = Turn {
session: None,
started,
};
let Some(sessions) = self.sessions else {
return Some((turn, None));
};
let session = match sessions.verify(&request.credentials) {
Ok(session) => session,
Err(why) => {
let decision = Decision::single(Verdict::Deny, RULE_SESSION, why.to_string());
self.record(turn, Some(request), &decision);
refuse(client, 407, &summary(&decision));
return None;
}
};
turn.session = Some(session);
if let Some(share) = served.shares.take(session) {
return Some((turn, Some(share)));
}
let decision = Decision::single(
Verdict::Deny,
RULE_SESSION,
format!(
"the session already holds {} connections, half of the proxy's",
served.shares.cap()
),
);
self.record(turn, Some(request), &decision);
refuse(client, 503, &summary(&decision));
None
}
fn tainted(&self, turn: Turn<'_>, request: &Request) -> Option<Decision> {
let (Some(sessions), Some(session)) = (self.sessions, turn.session) else {
return None;
};
match sessions.taint(session) {
Ok(taint) => decide::tainted(self.policy, request.host(), request.port(), &taint),
Err(why) => Some(Decision::single(
Verdict::Deny,
RULE_SESSION,
format!("the session's history cannot be read, so its taint is unknown: {why}"),
)),
}
}
fn check_tunnel(
&self,
client: &TcpStream,
host: &str,
mut hello: Vec<u8>,
allowed: &Decision,
request: &Request,
turn: Turn<'_>,
) -> Option<Vec<u8>> {
let answered = (&*client).write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n");
let deadline = Instant::now() + self.limits.handshake_timeout;
let read = answered
.map_err(|e| format!("answering the CONNECT: {e}"))
.and_then(|()| read_hello(client, &mut hello, deadline));
let refusal = match read {
Ok((_, true)) if !self.allow_ech => Some(format!(
"TLS ClientHello for CONNECT {host} carries an encrypted server name (ECH), \
so the server may serve another site; `sandbox.proxy_allow_ech: true` \
in the policy allows it"
)),
Ok((Some(name), _)) if name == host => None,
Ok((Some(name), _)) => Some(format!(
"TLS server name {name} does not match CONNECT {host}"
)),
Ok((None, _)) if host.parse::<std::net::IpAddr>().is_ok() => None,
Ok((None, _)) => Some(format!(
"TLS ClientHello for CONNECT {host} has no server name"
)),
Err(why) => Some(why),
};
if let Some(why) = refusal {
let decision = Decision::single(Verdict::Deny, RULE_SNI, why);
self.record(turn, Some(request), &decision);
return None;
}
self.record(turn, Some(request), allowed).then_some(hello)
}
fn record(&self, turn: Turn<'_>, request: Option<&Request>, decision: &Decision) -> bool {
let connection = Connection {
session: turn.session,
method: request.map(|r| r.method.as_str()),
destination: request.map(|r| (r.host(), r.port())),
decision,
latency_us: u64::try_from(turn.started.elapsed().as_micros()).unwrap_or(u64::MAX),
};
match self.recorder.record(&connection) {
Ok(()) => true,
Err(error) => {
self.recorder.unrecorded(&error);
false
}
}
}
}
struct Slot<'a>(&'a AtomicUsize);
impl<'a> Slot<'a> {
fn take(active: &'a AtomicUsize, max: usize) -> Option<Self> {
let slot = Self(active);
(active.fetch_add(1, Ordering::AcqRel) < max).then_some(slot)
}
}
impl Drop for Slot<'_> {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::AcqRel);
}
}
fn is_transient(e: &io::Error) -> bool {
matches!(
e.kind(),
ErrorKind::Interrupted | ErrorKind::ConnectionAborted | ErrorKind::ConnectionReset
)
}
#[cfg(test)]
mod tests;