pub mod dashboard;
pub mod public_routes;
use std::convert::Infallible;
use std::sync::Arc;
use crate::platform::config::ApiConfig;
use bytes::Bytes;
use http_body_util::{BodyExt, Full, StreamBody, combinators::BoxBody};
use hyper::body::{Frame, Incoming};
use hyper::service::service_fn;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use shared::{Command, Event, Response as RpResponse};
use tracing::{error, info, warn};
use super::agent;
use super::web_ui;
use crate::api::dispatch::dispatch;
use crate::bootstrap::state::AppState;
use crate::features::ingress::TlsManager;
type ApiBody = BoxBody<Bytes, Infallible>;
pub async fn run(state: AppState, cfg: ApiConfig, tls: Option<Arc<TlsManager>>) {
if let Some(t) = cfg.token.as_deref()
&& t != t.trim()
{
warn!(
"API: api.token tem espaço/quebra de linha em volta — ignorados \
na comparação, mas vale limpar a config"
);
}
let token = cfg
.token
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned);
if cfg.is_public_bind() && token.is_none() {
warn!(
bind = %cfg.bind_address,
"API: bind não-loopback sem token configurado — listener NÃO iniciado. \
Defina api.token (ou RUSTPLOY_API_TOKEN) para expor remotamente."
);
return;
}
let addr = format!("{}:{}", cfg.bind_address, cfg.port);
let listener = match tokio::net::TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) => {
error!(
error = %e,
addr = %addr,
"API: falha ao bind, listener desabilitado"
);
return;
}
};
let acceptor: Option<tokio_rustls::TlsAcceptor> =
match cfg.domain.as_deref().filter(|d| !d.is_empty()) {
Some(domain) => {
let Some(tls) = tls else {
error!(
domain,
"API: api.domain configurado mas TlsManager indisponível — \
listener NÃO iniciado"
);
return;
};
if let Err(e) = tls.ensure_cert(domain).await {
warn!(
domain, error = %e,
"API: falha ao provisionar certificado TLS — o handshake HTTPS \
falhará até o cert existir (confira ACME habilitado + porta 80)"
);
}
info!(domain, "API HTTP/SSE: TLS ativo (cert automático via ACME)");
Some(tls.tls_acceptor())
}
None => None,
};
let token = Arc::new(token);
info!(
addr = %addr,
auth = token.is_some(),
tls = acceptor.is_some(),
"API HTTP/SSE: escutando"
);
loop {
let (stream, peer) = match listener.accept().await {
Ok(v) => v,
Err(e) => {
warn!(error = %e, "API: accept falhou");
continue;
}
};
let state = state.clone();
let token = token.clone();
let acceptor = acceptor.clone();
tokio::spawn(async move {
match acceptor {
Some(acc) => match acc.accept(stream).await {
Ok(tls_stream) => {
serve_conn(TokioIo::new(tls_stream), state, token, peer).await
}
Err(e) => tracing::debug!(
peer = %peer, error = %e, "API: TLS handshake falhou"
),
},
None => serve_conn(TokioIo::new(stream), state, token, peer).await,
}
});
}
}
async fn serve_conn<I>(
io: I,
state: AppState,
token: Arc<Option<String>>,
peer: std::net::SocketAddr,
) where
I: hyper::rt::Read + hyper::rt::Write + Unpin + 'static,
{
let svc = service_fn(move |req| handle(req, state.clone(), token.clone(), peer));
if let Err(e) = hyper::server::conn::http1::Builder::new()
.serve_connection(io, svc)
.await
{
tracing::debug!(peer = %peer, error = %e, "API: conexão encerrada");
}
}
async fn handle(
req: Request<Incoming>,
state: AppState,
token: Arc<Option<String>>,
peer: std::net::SocketAddr,
) -> Result<Response<ApiBody>, Infallible> {
if req.uri().path().starts_with("/webhook/") {
return Ok(boxed(public_routes::webhook(req, state).await));
}
match (req.method(), req.uri().path()) {
(&Method::GET, "/oauth/gitea/callback" | "/oauth/github/callback") => {
return Ok(boxed(public_routes::oauth_callback(req, state).await));
}
(&Method::GET, "/api/version") => {
return Ok(text(
StatusCode::OK,
format!(
"rustployd {} ({})\n",
env!("CARGO_PKG_VERSION"),
env!("RUSTPLOY_GIT_SHA")
),
));
}
(&Method::POST, "/api/login") => {
let scheme = if state
.api
.domain
.as_deref()
.is_some_and(|d| !d.trim().is_empty())
{
"https"
} else {
"http"
};
return Ok(login(req, token.as_deref(), peer, scheme).await);
}
(&Method::GET, p) => {
if let Some(resp) = web_ui::serve(p) {
return Ok(resp);
}
}
_ => {}
}
if let Some(expected) = token.as_ref()
&& let Err(motivo) = auth_check(&req, expected)
{
warn!(
peer = %peer,
path = %req.uri().path(),
motivo = %motivo,
"API: 401 — requisição recusada"
);
return Ok(text(StatusCode::UNAUTHORIZED, "unauthorized"));
}
if req.method() == Method::POST {
let path = req.uri().path().to_owned();
if let Some(id) = path
.strip_prefix("/api/services/")
.and_then(|s| s.strip_suffix("/archive"))
{
return Ok(if id.is_empty() {
text(StatusCode::NOT_FOUND, "not found")
} else {
service_archive_upload(req, state, id.to_string()).await
});
}
}
Ok(match (req.method(), req.uri().path()) {
(&Method::POST, "/api/rpc") => rpc(req, state).await,
(&Method::POST, "/mcp") => mcp(req, state).await,
(&Method::GET | &Method::DELETE, "/mcp") => {
text(StatusCode::METHOD_NOT_ALLOWED, "use POST /mcp")
}
(&Method::GET, "/api/agent/manual") => Response::builder()
.header(hyper::header::CONTENT_TYPE, "text/markdown; charset=utf-8")
.body(Full::new(Bytes::from_static(agent::MANUAL.as_bytes())).boxed())
.unwrap(),
(&Method::GET, "/api/access-key") => {
let chave = agent::access_key_for(
&agent::public_address(&state.api, &crate::platform::config::outbound_ip()),
token.as_deref(),
);
json_value(StatusCode::OK, &serde_json::json!({ "key": chave }))
}
(&Method::GET, "/api/events") => events(state),
(&Method::GET, "/api/health") => text(StatusCode::OK, "ok"),
(&Method::GET, p) if p.starts_with("/api/services/") && p.ends_with("/logs") => {
let id = p
.strip_prefix("/api/services/")
.and_then(|s| s.strip_suffix("/logs"))
.unwrap_or("");
if id.is_empty() {
text(StatusCode::NOT_FOUND, "not found")
} else {
service_logs(state, id.to_string())
}
}
(&Method::GET, p) if p.starts_with("/api/deployments/") && p.ends_with("/build-logs") => {
let id = p
.strip_prefix("/api/deployments/")
.and_then(|s| s.strip_suffix("/build-logs"))
.unwrap_or("");
if id.is_empty() {
text(StatusCode::NOT_FOUND, "not found")
} else {
deployment_build_logs(state, id.to_string())
}
}
(&Method::GET, p) if p.starts_with("/api/jobs/runs/") && p.ends_with("/logs") => {
let id = p
.strip_prefix("/api/jobs/runs/")
.and_then(|s| s.strip_suffix("/logs"))
.unwrap_or("");
if id.is_empty() {
text(StatusCode::NOT_FOUND, "not found")
} else {
job_run_logs(state, id.to_string())
}
}
_ => text(StatusCode::NOT_FOUND, "not found"),
})
}
async fn login(
req: Request<Incoming>,
expected: Option<&str>,
peer: std::net::SocketAddr,
scheme: &str,
) -> Response<ApiBody> {
let host = req
.headers()
.get(hyper::header::HOST)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let body = match req.into_body().collect().await {
Ok(c) => c.to_bytes(),
Err(_) => return text(StatusCode::BAD_REQUEST, "erro ao ler o corpo"),
};
let (status, resposta) = agent::login(&body, expected, host.as_deref(), scheme);
if status == StatusCode::UNAUTHORIZED {
warn!(peer = %peer, "API: login com chave de outra instalação — recusado");
}
json_value(status, &resposta)
}
async fn mcp(req: Request<Incoming>, state: AppState) -> Response<ApiBody> {
let server = req
.headers()
.get(hyper::header::HOST)
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|h| !h.is_empty())
.map(str::to_owned)
.unwrap_or_else(|| {
agent::public_address(&state.api, &crate::platform::config::outbound_ip())
});
let body = match req.into_body().collect().await {
Ok(c) => c.to_bytes(),
Err(_) => return text(StatusCode::BAD_REQUEST, "erro ao ler o corpo"),
};
match agent::mcp::handle(&state, &body, &server).await {
Some((status, resposta)) => json_value(status, &resposta),
None => Response::builder()
.status(StatusCode::ACCEPTED)
.body(Full::new(Bytes::new()).boxed())
.unwrap(),
}
}
fn json_value(status: StatusCode, body: &serde_json::Value) -> Response<ApiBody> {
Response::builder()
.status(status)
.header(
hyper::header::CONTENT_TYPE,
"application/json; charset=utf-8",
)
.body(
Full::new(Bytes::from(
serde_json::to_vec_pretty(body).unwrap_or_default(),
))
.boxed(),
)
.unwrap()
}
async fn service_archive_upload(
req: Request<Incoming>,
state: AppState,
service_id: String,
) -> Response<ApiBody> {
let accept_gzip = accepts_gzip(&req);
let filename = req
.headers()
.get("x-rustploy-filename")
.and_then(|v| v.to_str().ok())
.filter(|s| !s.trim().is_empty())
.map(|s| s.to_string());
let body = match req.into_body().collect().await {
Ok(c) => c.to_bytes(),
Err(_) => return text(StatusCode::BAD_REQUEST, "erro ao ler o corpo"),
};
if body.len() > super::handlers::services::service_archive_upload::MAX_ZIP_BYTES {
return text(StatusCode::PAYLOAD_TOO_LARGE, "zip excede 100 MiB");
}
let resp = super::handlers::services::service_archive_upload::handle(
state, service_id, body, filename,
)
.await;
match serde_json::to_vec(&resp) {
Ok(bytes) => json_response(bytes, accept_gzip),
Err(e) => text(
StatusCode::INTERNAL_SERVER_ERROR,
format!("erro ao serializar resposta: {e}"),
),
}
}
async fn rpc(req: Request<Incoming>, state: AppState) -> Response<ApiBody> {
let accept_gzip = accepts_gzip(&req);
let body = match req.into_body().collect().await {
Ok(c) => c.to_bytes(),
Err(_) => return text(StatusCode::BAD_REQUEST, "erro ao ler o corpo"),
};
let cmd: Command = match serde_json::from_slice(&body) {
Ok(c) => c,
Err(e) => return text(StatusCode::BAD_REQUEST, format!("comando inválido: {e}")),
};
let resp = dispatch(state, cmd).await;
match serde_json::to_vec(&resp) {
Ok(bytes) => json_response(bytes, accept_gzip),
Err(e) => text(
StatusCode::INTERNAL_SERVER_ERROR,
format!("erro ao serializar resposta: {e}"),
),
}
}
fn events(state: AppState) -> Response<ApiBody> {
let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(64);
tokio::spawn(async move {
use tokio::sync::broadcast::error::RecvError;
let mut bus_rx = state.bus.subscribe();
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(2));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
if tx
.send(sse_frame("snapshot", &snapshot(&state).await))
.await
.is_err()
{
return;
}
loop {
tokio::select! {
_ = ticker.tick() => {
if tx.send(
sse_frame("snapshot", &snapshot(&state).await)
).await.is_err() {
break;
}
}
r = bus_rx.recv() => match r {
Ok(ev) => {
if matches!(
ev,
Event::LogLine { .. } | Event::BuildLog { .. } | Event::JobLogLine { .. }
) {
continue;
}
let wrapped = serde_json::json!({ "kind": "bus", "event": ev });
if let Ok(js) = serde_json::to_string(&wrapped)
&& tx.send(sse_frame("bus", &js)).await.is_err() {
break;
}
}
Err(RecvError::Lagged(skipped)) => {
tracing::warn!(skipped, "SSE de eventos: eventos descartados do canal (consumidor lento)");
continue;
}
Err(RecvError::Closed) => break,
}
}
}
});
sse_response(rx)
}
fn service_logs(state: AppState, service_id: String) -> Response<ApiBody> {
let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(64);
tokio::spawn(async move {
use tokio::sync::broadcast::error::RecvError;
let mut bus_rx = state.bus.subscribe();
const LOG_BATCH_MAX: usize = 400;
let mut batch: Vec<Event> = Vec::new();
let mut batch_flush = tokio::time::interval(std::time::Duration::from_millis(80));
batch_flush.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
_ = batch_flush.tick() => {
if !batch.is_empty()
&& send_log_batch(&tx, std::mem::take(&mut batch)).await.is_err()
{
break;
}
}
r = bus_rx.recv() => match r {
Ok(ev) => {
let keep = matches!(
&ev,
Event::LogLine { service_id: sid, .. } if *sid == service_id
);
if keep {
batch.push(ev);
if batch.len() >= LOG_BATCH_MAX
&& send_log_batch(&tx, std::mem::take(&mut batch)).await.is_err()
{
break;
}
}
}
Err(RecvError::Lagged(skipped)) => {
tracing::warn!(service_id = %service_id, skipped, "SSE de logs do serviço: eventos descartados do canal (consumidor lento)");
}
Err(RecvError::Closed) => break,
}
}
}
});
sse_response(rx)
}
fn deployment_build_logs(state: AppState, deployment_id: String) -> Response<ApiBody> {
let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(64);
tokio::spawn(async move {
use tokio::sync::broadcast::error::RecvError;
let connected_at = chrono::Utc::now();
let mut bus_rx = state.bus.subscribe();
let mut seen = SeenLines::new();
let mut service_id = String::new();
const LOG_BATCH_MAX: usize = 400;
let mut batch: Vec<Event> = Vec::new();
let mut batch_flush = tokio::time::interval(std::time::Duration::from_millis(80));
batch_flush.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
_ = batch_flush.tick() => {
if !batch.is_empty()
&& send_log_batch(&tx, std::mem::take(&mut batch)).await.is_err()
{
break;
}
}
r = bus_rx.recv() => match r {
Ok(ev) => {
let keep = matches!(
&ev,
Event::BuildLog { deployment_id: did, .. } if *did == deployment_id
);
if keep {
if let Event::BuildLog { timestamp, line, service_id: sid, .. } = &ev {
service_id.clone_from(sid);
if !mark_seen(&mut seen, *timestamp, line) {
continue;
}
}
batch.push(ev);
if batch.len() >= LOG_BATCH_MAX
&& send_log_batch(&tx, std::mem::take(&mut batch)).await.is_err()
{
break;
}
}
}
Err(RecvError::Lagged(skipped)) => {
tracing::warn!(deployment_id = %deployment_id, skipped, "SSE de build logs: eventos descartados do canal, recuperando do banco");
if let Ok(db_lines) = crate::store::build_logs::get_for_deployment(&state.db, &deployment_id).await {
for l in recover_lines(db_lines, connected_at, &mut seen) {
batch.push(Event::BuildLog {
deployment_id: deployment_id.clone(),
service_id: service_id.clone(),
line: l.line,
timestamp: l.timestamp,
});
}
}
}
Err(RecvError::Closed) => break,
}
}
}
});
sse_response(rx)
}
fn job_run_logs(state: AppState, job_run_id: String) -> Response<ApiBody> {
let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(64);
tokio::spawn(async move {
use tokio::sync::broadcast::error::RecvError;
let connected_at = chrono::Utc::now();
let mut bus_rx = state.bus.subscribe();
let mut seen = SeenLines::new();
let mut job_id = String::new();
const LOG_BATCH_MAX: usize = 400;
let mut batch: Vec<Event> = Vec::new();
let mut batch_flush = tokio::time::interval(std::time::Duration::from_millis(80));
batch_flush.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
_ = batch_flush.tick() => {
if !batch.is_empty()
&& send_log_batch(&tx, std::mem::take(&mut batch)).await.is_err()
{
break;
}
}
r = bus_rx.recv() => match r {
Ok(ev) => {
let keep = matches!(
&ev,
Event::JobLogLine { job_run_id: rid, .. } if *rid == job_run_id
);
if keep {
if let Event::JobLogLine { timestamp, line, job_id: jid, .. } = &ev {
job_id.clone_from(jid);
if !mark_seen(&mut seen, *timestamp, line) {
continue;
}
}
batch.push(ev);
if batch.len() >= LOG_BATCH_MAX
&& send_log_batch(&tx, std::mem::take(&mut batch)).await.is_err()
{
break;
}
}
}
Err(RecvError::Lagged(skipped)) => {
tracing::warn!(job_run_id = %job_run_id, skipped, "SSE de logs do job: eventos descartados do canal, recuperando do banco");
if let Ok(db_lines) = crate::store::job_log::get_for_run(&state.db, &job_run_id).await {
for l in recover_lines(db_lines, connected_at, &mut seen) {
batch.push(Event::JobLogLine {
job_run_id: job_run_id.clone(),
job_id: job_id.clone(),
line: l.line,
timestamp: l.timestamp,
stream: l.stream,
});
}
}
}
Err(RecvError::Closed) => break,
}
}
}
});
sse_response(rx)
}
const SEEN_LINES_MAX: usize = 50_000;
type SeenLines = std::collections::HashSet<(chrono::DateTime<chrono::Utc>, String)>;
fn mark_seen(seen: &mut SeenLines, ts: chrono::DateTime<chrono::Utc>, line: &str) -> bool {
if seen.len() >= SEEN_LINES_MAX {
seen.clear();
}
seen.insert((ts, line.to_string()))
}
fn recover_lines(
db_lines: Vec<shared::protocol::BuildLogLine>,
since: chrono::DateTime<chrono::Utc>,
seen: &mut SeenLines,
) -> Vec<shared::protocol::BuildLogLine> {
db_lines
.into_iter()
.filter(|l| l.timestamp >= since)
.filter(|l| mark_seen(seen, l.timestamp, &l.line))
.collect()
}
const SSE_KEEPALIVE: std::time::Duration = std::time::Duration::from_secs(15);
fn sse_response(mut rx: tokio::sync::mpsc::Receiver<Bytes>) -> Response<ApiBody> {
let (out_tx, mut out_rx) = tokio::sync::mpsc::channel::<Bytes>(64);
tokio::spawn(async move {
let mut beat = tokio::time::interval(SSE_KEEPALIVE);
beat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
beat.tick().await; loop {
let chunk = tokio::select! {
msg = rx.recv() => match msg {
Some(bytes) => bytes,
None => break,
},
_ = beat.tick() => Bytes::from_static(b": keepalive\n\n"),
};
if out_tx.send(chunk).await.is_err() {
break;
}
}
});
let stream = futures::stream::poll_fn(move |cx| {
out_rx
.poll_recv(cx)
.map(|opt| opt.map(|b| Ok::<_, Infallible>(Frame::data(b))))
});
let body = StreamBody::new(stream).boxed();
Response::builder()
.status(StatusCode::OK)
.header(hyper::header::CONTENT_TYPE, "text/event-stream")
.header(hyper::header::CACHE_CONTROL, "no-cache")
.header("X-Accel-Buffering", "no")
.body(body)
.unwrap()
}
pub(crate) async fn snapshot(state: &AppState) -> String {
use serde_json::{Map, Value, json};
let mut obj = Map::new();
obj.insert("kind".into(), Value::String("snapshot".into()));
if let RpResponse::DaemonStatus(d) = dispatch(state.clone(), Command::DaemonStatus).await {
obj.insert(
"status".into(),
serde_json::to_value(d).unwrap_or(Value::Null),
);
}
if let RpResponse::DeploymentSummaries(list) =
dispatch(state.clone(), Command::RecentDeployments { limit: 40 }).await
{
obj.insert(
"deployments".into(),
serde_json::to_value(list).unwrap_or(Value::Null),
);
}
if let RpResponse::Projects(projects) = dispatch(state.clone(), Command::ProjectList).await {
let container_index =
crate::engine::containers::index_containers(&state.docker.inner).await;
let mut services = Vec::new();
let mut projects_json = Vec::new();
for p in &projects {
let mut pv = serde_json::to_value(p).unwrap_or(Value::Null);
if let RpResponse::SecretNames(names) = dispatch(
state.clone(),
Command::SecretList {
project_id: p.id.clone(),
},
)
.await
&& let Value::Object(m) = &mut pv
{
m.insert(
"secrets".into(),
serde_json::to_value(names).unwrap_or(Value::Null),
);
}
projects_json.push(pv);
if let RpResponse::Services(svcs) = dispatch(
state.clone(),
Command::ServiceList {
project_id: p.id.clone(),
},
)
.await
{
for s in svcs {
let conts = container_index.for_service(&s);
let mut sv = serde_json::to_value(&s).unwrap_or(Value::Null);
if let Value::Object(m) = &mut sv {
m.insert(
"containers".into(),
serde_json::to_value(conts).unwrap_or(Value::Null),
);
}
services.push(json!({ "project_name": p.name, "service": sv }));
}
}
}
obj.insert("projects".into(), Value::Array(projects_json));
obj.insert("services".into(), Value::Array(services));
}
obj.insert(
"dashboard".into(),
serde_json::to_value(dashboard::load(state).await).unwrap_or(Value::Null),
);
if let RpResponse::ServiceWarmStatuses(list) =
dispatch(state.clone(), Command::ServiceWarmStatusList).await
{
obj.insert(
"service_warm".into(),
serde_json::to_value(list).unwrap_or(Value::Null),
);
}
if let RpResponse::DockerImages(list) = dispatch(state.clone(), Command::DockerImages).await {
obj.insert(
"docker_images".into(),
serde_json::to_value(list).unwrap_or(Value::Null),
);
}
if let RpResponse::DockerVolumes(list) = dispatch(state.clone(), Command::DockerVolumes).await {
obj.insert(
"docker_volumes".into(),
serde_json::to_value(list).unwrap_or(Value::Null),
);
}
if let RpResponse::DockerNetworks(list) = dispatch(state.clone(), Command::DockerNetworks).await
{
obj.insert(
"docker_networks".into(),
serde_json::to_value(list).unwrap_or(Value::Null),
);
}
if let RpResponse::DockerContainers(list) =
dispatch(state.clone(), Command::DockerContainers).await
{
obj.insert(
"docker_containers".into(),
serde_json::to_value(list).unwrap_or(Value::Null),
);
}
if let RpResponse::JobSummaries(list) = dispatch(state.clone(), Command::JobListAll).await {
obj.insert(
"jobs".into(),
serde_json::to_value(list).unwrap_or(Value::Null),
);
}
if let RpResponse::DeployEngineStatus(eng) =
dispatch(state.clone(), Command::DeployEngineStatus).await
{
obj.insert(
"engine".into(),
serde_json::to_value(eng).unwrap_or(Value::Null),
);
}
if let RpResponse::RegistryStatus(info) = dispatch(state.clone(), Command::RegistryStatus).await
{
obj.insert(
"registry_status".into(),
serde_json::to_value(info).unwrap_or(Value::Null),
);
}
if let RpResponse::RegistryRepos(list) =
dispatch(state.clone(), Command::RegistryRepoList).await
{
obj.insert(
"registry_repos".into(),
serde_json::to_value(list).unwrap_or(Value::Null),
);
}
Value::Object(obj).to_string()
}
fn sse_frame(event: &str, data: &str) -> Bytes {
Bytes::from(format!("event: {event}\ndata: {data}\n\n"))
}
async fn send_log_batch(
tx: &tokio::sync::mpsc::Sender<Bytes>,
batch: Vec<Event>,
) -> Result<(), ()> {
let wrapped = serde_json::json!({ "kind": "bus_batch", "events": batch });
match serde_json::to_string(&wrapped) {
Ok(js) => tx.send(sse_frame("bus", &js)).await.map_err(|_| ()),
Err(_) => Ok(()),
}
}
fn auth_check<B>(req: &Request<B>, expected: &str) -> Result<(), String> {
let Some(raw) = req.headers().get(hyper::header::AUTHORIZATION) else {
return Err("sem header Authorization".into());
};
let Ok(raw) = raw.to_str() else {
return Err("header Authorization não é ASCII válido".into());
};
let got = match raw.split_once(' ') {
Some((scheme, rest)) if scheme.eq_ignore_ascii_case("bearer") => rest,
Some((scheme, _)) => {
return Err(format!("esquema de autorização inesperado: {scheme}"));
}
None => raw,
};
if constant_time_eq(got.as_bytes(), expected.as_bytes()) {
return Ok(());
}
if got.len() != expected.len() {
return Err(format!(
"token não confere (recebido {} bytes, esperado {} bytes)",
got.len(),
expected.len()
));
}
Err("token não confere (mesmo comprimento, conteúdo diferente)".into())
}
fn boxed(resp: Response<Full<Bytes>>) -> Response<ApiBody> {
resp.map(|body| body.boxed())
}
fn text(status: StatusCode, body: impl Into<Bytes>) -> Response<ApiBody> {
Response::builder()
.status(status)
.header(hyper::header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Full::new(body.into()).boxed())
.unwrap()
}
fn json_ok(bytes: Vec<u8>) -> Response<ApiBody> {
Response::builder()
.status(StatusCode::OK)
.header(hyper::header::CONTENT_TYPE, "application/json")
.body(Full::new(Bytes::from(bytes)).boxed())
.unwrap()
}
const GZIP_MIN: usize = 1024;
fn accepts_gzip(req: &Request<Incoming>) -> bool {
req.headers()
.get(hyper::header::ACCEPT_ENCODING)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.to_ascii_lowercase().contains("gzip"))
}
fn json_response(bytes: Vec<u8>, accept_gzip: bool) -> Response<ApiBody> {
if accept_gzip
&& bytes.len() >= GZIP_MIN
&& let Ok(gz) = gzip(&bytes)
{
return Response::builder()
.status(StatusCode::OK)
.header(hyper::header::CONTENT_TYPE, "application/json")
.header(hyper::header::CONTENT_ENCODING, "gzip")
.body(Full::new(Bytes::from(gz)).boxed())
.unwrap();
}
json_ok(bytes)
}
fn gzip(data: &[u8]) -> std::io::Result<Vec<u8>> {
use std::io::Write;
let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(data)?;
enc.finish()
}
pub(super) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::*;
fn req_com_auth(valor: Option<&str>) -> Request<()> {
let mut b = Request::builder().uri("/api/rpc");
if let Some(v) = valor {
b = b.header(hyper::header::AUTHORIZATION, v);
}
b.body(()).unwrap()
}
async fn estado_de_teste() -> AppState {
let tmp = std::env::temp_dir().join(format!("rustploy-sse-test-{}", ulid::Ulid::new()));
let config = crate::platform::config::RustployConfig::default();
let db = std::sync::Arc::new(crate::store::connect(&tmp).await.unwrap());
let secrets = std::sync::Arc::new(
crate::platform::secrets::SecretsManager::new(&tmp.join("master.key"), db.clone())
.unwrap(),
);
let tls = std::sync::Arc::new(
crate::features::ingress::TlsManager::new(
tmp.join("certs"),
config.ingress.acme.clone(),
)
.unwrap(),
);
AppState::new(
db,
std::sync::Arc::new(crate::engine::DockerClient::for_tests()),
std::sync::Arc::new(crate::features::ingress::IngressController::new()),
std::sync::Arc::new(crate::platform::event_bus::EventBus::new()),
secrets,
tls,
tmp.join("db"),
tmp.join("backup"),
30,
config.api,
None,
None,
)
}
async fn linhas_do_sse(
mut body: ApiBody,
esperadas: usize,
limite: std::time::Duration,
) -> Vec<String> {
use http_body_util::BodyExt;
let mut texto = String::new();
let mut linhas: Vec<String> = Vec::new();
let fim = tokio::time::Instant::now() + limite;
while linhas.len() < esperadas {
let Ok(Some(Ok(frame))) = tokio::time::timeout_at(fim, body.frame()).await else {
break;
};
let Some(chunk) = frame.data_ref() else {
continue;
};
texto.push_str(&String::from_utf8_lossy(chunk));
while let Some(pos) = texto.find("\n\n") {
let bruto: String = texto.drain(..pos + 2).collect();
let Some(dados) = bruto.lines().find_map(|l| l.strip_prefix("data: ")) else {
continue;
};
let v: serde_json::Value = serde_json::from_str(dados).unwrap();
for ev in v["events"].as_array().cloned().unwrap_or_default() {
if let Some(l) = ev["JobLogLine"]["line"].as_str() {
linhas.push(l.to_string());
}
}
}
}
linhas
}
#[tokio::test]
async fn sse_de_job_entrega_rajada_completa_e_em_ordem() {
use std::time::Duration;
for espacamento_us in [0u64, 300] {
let state = estado_de_teste().await;
let resp = job_run_logs(state.clone(), "jrun_T".to_string());
let body = resp.into_body();
tokio::time::sleep(Duration::from_millis(100)).await;
const N: usize = 413;
let coletor = tokio::spawn(linhas_do_sse(body, N, Duration::from_secs(5)));
for i in 0..N {
state.bus.publish(shared::Event::JobLogLine {
job_run_id: "jrun_T".into(),
job_id: "job_1".into(),
line: format!("linha {i}"),
timestamp: chrono::Utc::now(),
stream: shared::protocol::LogStream::Stdout,
});
if espacamento_us == 0 {
tokio::task::yield_now().await;
} else {
tokio::time::sleep(Duration::from_micros(espacamento_us)).await;
}
}
let linhas = coletor.await.unwrap();
let esperadas: Vec<String> = (0..N).map(|i| format!("linha {i}")).collect();
assert_eq!(
linhas.len(),
esperadas.len(),
"espaçamento {espacamento_us}µs: faltaram {} linhas",
esperadas.len().saturating_sub(linhas.len())
);
assert_eq!(
linhas, esperadas,
"espaçamento {espacamento_us}µs: fora de ordem"
);
}
}
fn linha_bd(i: i64, base: chrono::DateTime<chrono::Utc>) -> shared::protocol::BuildLogLine {
shared::protocol::BuildLogLine {
stream: shared::protocol::LogStream::Stdout,
line: format!("linha {i}"),
timestamp: base + chrono::Duration::microseconds(i),
}
}
#[test]
fn recover_lines_repoe_so_o_que_falta_depois_de_conectar() {
let base = chrono::Utc::now();
let mut seen = SeenLines::new();
assert!(mark_seen(
&mut seen,
base + chrono::Duration::microseconds(2),
"linha 2"
));
assert!(mark_seen(
&mut seen,
base + chrono::Duration::microseconds(3),
"linha 3"
));
let no_banco: Vec<_> = (0..6).map(|i| linha_bd(i, base)).collect();
let since = base + chrono::Duration::microseconds(1);
let repostas: Vec<String> = recover_lines(no_banco.clone(), since, &mut seen)
.into_iter()
.map(|l| l.line)
.collect();
assert_eq!(repostas, ["linha 1", "linha 4", "linha 5"]);
assert!(recover_lines(no_banco, since, &mut seen).is_empty());
}
#[tokio::test]
async fn sse_de_job_repoe_pelo_banco_o_que_o_canal_descartou() {
use std::time::Duration;
let state = estado_de_teste().await;
let resp = job_run_logs(state.clone(), "jrun_L".to_string());
let body = resp.into_body();
tokio::time::sleep(Duration::from_millis(100)).await;
const N: i64 = 5000;
let base = chrono::Utc::now();
for i in 0..N {
let l = linha_bd(i, base);
crate::store::job_log::append(&state.db, "jrun_L", &l.stream, &l.line, l.timestamp)
.await
.unwrap();
}
for i in 0..N {
let l = linha_bd(i, base);
state.bus.publish(shared::Event::JobLogLine {
job_run_id: "jrun_L".into(),
job_id: "job_1".into(),
line: l.line,
timestamp: l.timestamp,
stream: l.stream,
});
}
let linhas = linhas_do_sse(body, N as usize, Duration::from_secs(10)).await;
let esperadas: Vec<String> = (0..N).map(|i| format!("linha {i}")).collect();
assert_eq!(linhas.len(), esperadas.len(), "faltaram ou sobraram linhas");
assert_eq!(linhas, esperadas, "fora de ordem ou duplicadas");
}
#[test]
fn auth_check_aceita_token_certo_e_explica_a_recusa() {
let esperado = "s3cr3t-token";
assert!(auth_check(&req_com_auth(Some("Bearer s3cr3t-token")), esperado).is_ok());
assert!(auth_check(&req_com_auth(Some("bearer s3cr3t-token")), esperado).is_ok());
assert!(auth_check(&req_com_auth(Some("s3cr3t-token")), esperado).is_ok());
let e = auth_check(&req_com_auth(None), esperado).unwrap_err();
assert!(e.contains("sem header"), "{e}");
let e = auth_check(&req_com_auth(Some("Basic s3cr3t-token")), esperado).unwrap_err();
assert!(e.contains("esquema"), "{e}");
let e = auth_check(&req_com_auth(Some("Bearer s3cr3t")), esperado).unwrap_err();
assert!(e.contains("6 bytes") && e.contains("12 bytes"), "{e}");
assert!(
!e.contains("s3cr3t"),
"o motivo não pode vazar o token: {e}"
);
let e = auth_check(&req_com_auth(Some("Bearer 0000000token")), esperado).unwrap_err();
assert!(e.contains("mesmo comprimento"), "{e}");
}
fn content_encoding(resp: &Response<ApiBody>) -> Option<String> {
resp.headers()
.get(hyper::header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
}
#[test]
fn json_response_comprime_grande_e_deixa_pequeno_cru() {
use std::io::Read;
let big = vec![b'{'; GZIP_MIN + 10]; let small = b"{\"ok\":true}".to_vec();
let resp = json_response(big.clone(), true);
assert_eq!(content_encoding(&resp).as_deref(), Some("gzip"));
assert_eq!(content_encoding(&json_response(small.clone(), true)), None);
assert_eq!(content_encoding(&json_response(big.clone(), false)), None);
let gz = gzip(&big).unwrap();
let mut back = Vec::new();
flate2::read::GzDecoder::new(&gz[..])
.read_to_end(&mut back)
.unwrap();
assert_eq!(back, big);
}
}