Files
RustProxy/src/main/tasks.rs
T
2026-10-08 11:27:18 +02:00

422 lines
17 KiB
Rust

fn parse_sse_event(event: &str) -> (Option<&str>, Option<String>) {
let mut kind = None;
let mut data = Vec::new();
for raw in event.lines() {
let line = raw.trim_end_matches('\r');
if let Some(value) = line.strip_prefix("event:") {
kind = Some(value.trim());
} else if let Some(value) = line.strip_prefix("data:") {
data.push(value.trim_start());
}
}
(kind, (!data.is_empty()).then(|| data.join("\n")))
}
type LocalAdminHttpClient = Client<HttpsConnector<HttpConnector>, Body>;
#[derive(Debug)]
struct LocalAdminCertificateVerifier {
expected: Vec<[u8; 32]>,
signature_verifier: Arc<WebPkiServerVerifier>,
}
impl ServerCertVerifier for LocalAdminCertificateVerifier {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> std::result::Result<ServerCertVerified, rustls::Error> {
let actual = Sha256::digest(end_entity.as_ref());
if !self
.expected
.iter()
.any(|expected| actual[..] == expected[..])
{
return Err(rustls::Error::General(
"local administration TLS certificate is not present in data-dir/certs".into(),
));
}
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
self.signature_verifier
.verify_tls12_signature(message, cert, dss)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
self.signature_verifier
.verify_tls13_signature(message, cert, dss)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.signature_verifier.supported_verify_schemes()
}
}
fn local_admin_public_roots() -> RootCertStore {
let mut roots = RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
roots
}
fn local_admin_certificate_pins(data_dir: &Path) -> Result<Vec<[u8; 32]>> {
let directory = data_dir.join("certs");
let mut pins = Vec::new();
for entry in std::fs::read_dir(&directory).with_context(|| {
format!(
"cannot read local certificate directory {}",
directory.display()
)
})? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) != Some("pem") {
continue;
}
let metadata = entry.metadata()?;
if !metadata.is_file() || metadata.len() > 512 * 1024 {
continue;
}
let bytes = std::fs::read(&path)
.with_context(|| format!("cannot read local certificate {}", path.display()))?;
let mut reader = bytes.as_slice();
let Some(Ok(certificate)) = rustls_pemfile::certs(&mut reader).next() else {
continue;
};
let digest = Sha256::digest(certificate.as_ref());
let mut pin = [0_u8; 32];
pin.copy_from_slice(&digest);
pins.push(pin);
}
pins.sort_unstable();
pins.dedup();
ensure!(
!pins.is_empty(),
"no local TLS certificate was found in {}; start rust-proxy serve first",
directory.display()
);
Ok(pins)
}
fn local_admin_scheme_from_probe(
status: StatusCode,
location: Option<&str>,
) -> Result<&'static str> {
if status == StatusCode::PERMANENT_REDIRECT {
ensure!(
location.is_some_and(|value| value.starts_with("https://")),
"administration HTTP probe returned 308 without an HTTPS location"
);
return Ok("https");
}
ensure!(
status.is_success(),
"administration health probe returned {status}"
);
Ok("http")
}
async fn detect_local_admin_scheme(addr: SocketAddr) -> Result<&'static str> {
let mut connector = HttpConnector::new();
connector.set_connect_timeout(Some(Duration::from_secs(3)));
let client: Client<_, Body> = Client::builder(TokioExecutor::new()).build(connector);
let request = Request::builder()
.method(Method::GET)
.uri(format!("http://{addr}/healthz"))
.header("accept", "application/json")
.body(Body::empty())?;
let response = tokio::time::timeout(Duration::from_secs(5), client.request(request))
.await
.context("administration health probe timed out")??;
let location = response
.headers()
.get(http::header::LOCATION)
.and_then(|value| value.to_str().ok());
local_admin_scheme_from_probe(response.status(), location)
}
#[derive(Clone)]
struct LocalAdminClient {
addr: SocketAddr,
scheme: &'static str,
client: LocalAdminHttpClient,
}
impl LocalAdminClient {
async fn connect(data_dir: &Path, addr: SocketAddr) -> Result<Self> {
ensure!(
addr.ip().is_loopback(),
"CLI administration requires a loopback address"
);
let scheme = detect_local_admin_scheme(addr).await?;
let provider = Arc::new(rustls::crypto::ring::default_provider());
let mut config = ClientConfig::builder_with_provider(provider.clone())
.with_safe_default_protocol_versions()?
.with_root_certificates(local_admin_public_roots())
.with_no_client_auth();
if scheme == "https" {
let data_dir = data_dir.to_path_buf();
let expected =
tokio::task::spawn_blocking(move || local_admin_certificate_pins(&data_dir))
.await??;
let signature_verifier = WebPkiServerVerifier::builder_with_provider(
Arc::new(local_admin_public_roots()),
provider,
)
.build()?;
config
.dangerous()
.set_certificate_verifier(Arc::new(LocalAdminCertificateVerifier {
expected,
signature_verifier,
}));
}
let mut http = HttpConnector::new();
http.enforce_http(false);
http.set_connect_timeout(Some(Duration::from_secs(3)));
http.set_nodelay(true);
let connector = HttpsConnectorBuilder::new()
.with_tls_config(config)
.https_or_http()
.enable_http1()
.wrap_connector(http);
Ok(Self {
addr,
scheme,
client: Client::builder(TokioExecutor::new()).build(connector),
})
}
fn uri(&self, path: &str) -> String {
format!("{}://{}{}", self.scheme, self.addr, path)
}
fn origin(&self) -> String {
format!("{}://{}", self.scheme, self.addr)
}
}
async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
ensure!(
addr.ip().is_loopback(),
"--livelogs requires the local loopback administration listener"
);
auth::prepare_directory(&dir)?;
let path = dir.join("config.sqlite3");
ensure!(
path.is_file(),
"database does not exist at {}; start rust-proxy serve first",
path.display()
);
auth::prepare_database(&path)?;
let store = Store::open(&path, &Config::default()).await?;
let admin = LocalAdminClient::connect(&dir, addr).await?;
let result = async {
let record = store.admin_record().await?.context("create the administrator first")?;
let mut session = store
.create_live_log_session(record.generation)
.await?
.context("credentials changed; retry --livelogs")?;
let (command_tx, mut command_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
std::thread::spawn(move || {
let stdin = io::stdin();
for line in stdin.lock().lines() {
match line {
Ok(line) => {
if command_tx.send(line).is_err() {
break;
}
}
Err(_) => break,
}
}
});
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
println!("RustProxy live logs 0.28.2 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
if interactive { print!("> "); io::stdout().flush()?; }
let mut filters = LiveLogFilters::default();
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();
let mut last_id = 0_u64;
let mut command_input_open = true;
'reconnect: loop {
let request = Request::builder()
.method(Method::GET)
.uri(admin.uri("/api/live-logs"))
.header("cookie", format!("{}={}", auth::COOKIE_NAME, session.secret))
.header("accept", "text/event-stream")
.body(Body::empty())?;
let response = match tokio::time::timeout(Duration::from_secs(10), admin.client.request(request)).await {
Ok(Ok(response)) if response.status().is_success() => response,
Ok(Ok(response)) => {
eprintln!("live-log stream returned {}; reconnecting", response.status());
let replacement = store.create_live_log_session(record.generation).await?.context("administrator credentials changed; --livelogs stopped")?;
let old = std::mem::replace(&mut session, replacement); let _ = store.end_session(&old.session).await;
tokio::time::sleep(Duration::from_secs(1)).await; continue;
}
Ok(Err(error)) => { eprintln!("live-log stream unavailable: {error}; reconnecting"); tokio::time::sleep(Duration::from_secs(1)).await; continue; }
Err(_) => { eprintln!("live-log stream connect timeout; reconnecting"); tokio::time::sleep(Duration::from_secs(1)).await; continue; }
};
// The server subscribes before the SSE response is returned. Read the
// retained history only after that point, so requests cannot disappear
// between the initial snapshot and the live subscription. Buffered live
// duplicates are ignored by request id below.
let history_limit = if last_id == 0 { 25 } else { 1024 };
if let Ok(snapshot) = api(
&admin,
&session,
Method::GET,
&format!("/api/logs?limit={history_limit}"),
None,
)
.await
{
if let Ok(snapshot) = serde_json::from_str::<serde_json::Value>(&snapshot) {
if let Some(entries) = snapshot.get("entries").and_then(|value| value.as_array()) {
let mut entries = entries.clone();
entries.reverse();
for entry in entries {
let id = entry.get("id").and_then(|value| value.as_u64()).unwrap_or(0);
if id > 0 && id <= last_id {
continue;
}
if id > 0 {
last_id = id;
history.insert(id, entry.clone());
}
if !filters.paused && live_log_matches(&entry, &filters) {
print_live_entry(&entry, filters.detail);
}
}
}
}
}
let mut body = response.into_body();
let mut buffer = Vec::new();
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => break 'reconnect,
command = command_rx.recv(), if command_input_open => {
let Some(command) = command else {
command_input_open = false;
continue;
};
match handle_live_command(&command, &mut filters, &history) {
Ok(true) => {}
Ok(false) => break 'reconnect,
Err(error) => eprintln!("command error: {error:#}"),
}
if interactive { print!("> "); io::stdout().flush()?; }
}
frame = body.frame() => {
let Some(frame) = frame else {
eprintln!("live-log stream closed; reconnecting");
tokio::time::sleep(Duration::from_millis(500)).await;
continue 'reconnect;
};
let frame = match frame {
Ok(frame) => frame,
Err(error) => { eprintln!("live-log stream error: {error}; reconnecting"); tokio::time::sleep(Duration::from_millis(500)).await; continue 'reconnect; }
};
let Ok(data) = frame.into_data() else { continue; };
buffer.extend_from_slice(&data);
ensure!(buffer.len() <= 2 * 1024 * 1024, "live-log stream frame buffer exceeded 2 MiB");
while let Some(raw) = take_sse_event(&mut buffer) {
let raw = std::str::from_utf8(&raw).context("live-log SSE event is not UTF-8")?;
let (kind, data) = parse_sse_event(raw);
match (kind, data) {
(Some("log"), Some(data)) => {
let entry: serde_json::Value = serde_json::from_str(&data)?;
let id = entry.get("id").and_then(|value| value.as_u64()).unwrap_or(0);
if id > 0 && id <= last_id { continue; }
if id > 0 { last_id = id; history.insert(id, entry.clone()); }
while history.len() > 1024 { if let Some(id) = history.keys().next().copied() { history.remove(&id); } else { break; } }
if !filters.paused && live_log_matches(&entry, &filters) { print_live_entry(&entry, filters.detail); }
}
(Some("gap"), Some(data)) => {
let dropped = serde_json::from_str::<serde_json::Value>(&data).ok().and_then(|value| value.get("dropped").and_then(|v| v.as_u64())).unwrap_or(0);
eprintln!("*** live-log receiver lagged: {dropped} entries were dropped before display ***");
}
_ => {}
}
}
if interactive { io::stdout().flush()?; }
}
}
}
}
let _ = store.end_session(&session.session).await;
Ok::<_, anyhow::Error>(())
}.await;
store.close().await;
result
}
async fn api(
admin: &LocalAdminClient,
session: &auth::SessionToken,
method: Method,
path: &str,
body: Option<String>,
) -> Result<String> {
let request = Request::builder()
.method(method)
.uri(admin.uri(path))
.header(
"cookie",
format!("{}={}", auth::COOKIE_NAME, session.secret),
)
.header("origin", admin.origin())
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &session.session.csrf)
.header("content-type", "application/json")
.body(Body::from(body.unwrap_or_default()))?;
tokio::time::timeout(Duration::from_secs(15), async {
let response = admin
.client
.request(request)
.await
.context("admin API unavailable; start rust-proxy serve first")?;
let status = response.status();
let bytes = Limited::new(response.into_body(), config::MAX_CONFIG_BYTES + 4096)
.collect()
.await
.map_err(|e| anyhow::anyhow!("admin body error: {e}"))?
.to_bytes();
let text = String::from_utf8(bytes.to_vec())?;
if !status.is_success() {
bail!("admin API returned {status}: {text}");
}
Ok(text)
})
.await
.context("admin API timed out")?
}
async fn read_config_file(file: PathBuf) -> Result<Config> {
ensure!(
tokio::fs::metadata(&file).await?.len() <= config::MAX_CONFIG_BYTES as u64,
"config file exceeds 256 KiB"
);
// Bound actual reads as well as metadata; replacement/append races cannot grow memory.
let data = tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(file)?;
let mut bytes = Vec::new();
file.take((config::MAX_CONFIG_BYTES + 1) as u64)
.read_to_end(&mut bytes)?;
anyhow::ensure!(
bytes.len() <= config::MAX_CONFIG_BYTES,
"config file exceeds 256 KiB"
);
Ok::<_, anyhow::Error>(bytes)
})
.await??;
Ok(serde_json::from_slice(&data)?)
}