mod assets;
pub(crate) mod command;
pub(crate) mod error;
mod extract;
mod holds;
mod notes;
mod ops;
mod origin;
mod policy;
mod wire;
#[cfg(test)]
mod tests;
use crate::app::App;
use axum::Router;
use axum::http::{HeaderValue, header};
use axum::routing::{get, post};
use cyberbrain_core::{Error, Result};
use error::{ApiError, ApiResult};
use holds::Holds;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tower_http::set_header::SetResponseHeaderLayer;
#[derive(Debug, Default, Clone, Copy)]
pub struct ScanTimes {
pub last_scan: Option<jiff::Timestamp>,
pub last_full_scan: Option<jiff::Timestamp>,
}
pub struct ServeState {
pub app: Arc<App>,
pub holds: Holds,
pub scans: Mutex<ScanTimes>,
pub self_exe: PathBuf,
pub terminal: Option<crate::terminal::TerminalConfig>,
pub origins: Vec<String>,
}
pub(crate) async fn blocking<T, F>(f: F) -> ApiResult<T>
where
T: Send + 'static,
F: FnOnce() -> ApiResult<T> + Send + 'static,
{
tokio::task::spawn_blocking(f)
.await
.map_err(|e| ApiError::internal(format!("request worker failed: {e}")))?
}
async fn no_store(
req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
let mut resp = next.run(req).await;
resp.headers_mut()
.entry(header::CACHE_CONTROL)
.or_insert(HeaderValue::from_static("no-store"));
resp
}
pub fn router_with(
app: Arc<App>,
self_exe: PathBuf,
terminal: Option<crate::terminal::TerminalConfig>,
origins: Vec<String>,
) -> Router {
let state = Arc::new(ServeState {
app,
holds: Holds::new(),
scans: Mutex::new(ScanTimes::default()),
self_exe,
terminal,
origins: origins.clone(),
});
let hosts = Arc::new(origin::hosts_of(&origins));
let ours = Arc::new(origins);
let api = Router::new()
.route("/status", get(ops::status))
.route("/hub", get(ops::hub_status))
.route("/recall", get(ops::recall))
.route("/recall/{citation}", get(ops::expand))
.route("/notes", get(notes::list_notes).post(notes::post_note))
.route(
"/notes/{target}",
get(notes::get_note)
.put(notes::put_note)
.delete(notes::delete_note),
)
.route("/holds/{id}", post(notes::resolve_hold))
.route("/graph", get(notes::graph))
.route("/policy/egress", get(policy::egress))
.route("/policy/obligations", get(policy::obligations))
.route("/policy/audit", get(policy::audit))
.route("/policy/pii", get(policy::pii))
.route("/policy/retention", get(policy::retention))
.route("/policy/retention/apply", post(policy::retention_apply))
.route("/policy/model-card", get(policy::model_cards))
.route("/policy/subject", get(policy::subject))
.route("/usage", get(ops::usage))
.route("/doctor", get(ops::doctor))
.route("/scan", post(ops::scan))
.route("/command", post(command::run))
.route("/terminal", get(crate::terminal::open))
.route(
"/terminal/profiles",
get(crate::terminal::list_profiles).put(crate::terminal::put_profiles),
)
.layer(axum::middleware::from_fn(no_store))
.with_state(state);
Router::new()
.nest("/api/v1", api)
.fallback(assets::fallback)
.layer(axum::middleware::from_fn(
move |req: axum::extract::Request, next: axum::middleware::Next| {
let ours = ours.clone();
async move { origin::guard(ours, req, next).await }
},
))
.layer(axum::middleware::from_fn(
move |req: axum::extract::Request, next: axum::middleware::Next| {
let hosts = hosts.clone();
async move { origin::host_guard(hosts, req, next).await }
},
))
.layer(SetResponseHeaderLayer::overriding(
header::CONTENT_SECURITY_POLICY,
assets::csp_header(),
))
.layer(SetResponseHeaderLayer::overriding(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
))
.layer(SetResponseHeaderLayer::overriding(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
))
}
pub const NO_PAGE_MARKER: &str = "cyberbrain serve: no web page in this build";
pub async fn serve(app: Arc<App>, port: u16, open: bool, terminal: bool) -> Result<()> {
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
let listener = tokio::net::TcpListener::bind(addr)
.await
.map_err(|e| Error::Config(format!("cannot bind {addr}: {e}")))?;
let bound = listener
.local_addr()
.map_err(|e| Error::Config(format!("cannot read the bound address: {e}")))?;
if !assets::bundle_present() {
println!("{NO_PAGE_MARKER}");
eprintln!(
"cyberbrain serve: the web UI bundle is not embedded (ui/dist was missing at build time); the API works, the page will 404"
);
}
if assets::meta_csp().is_none() {
eprintln!(
"cyberbrain serve: the built page carries no CSP <meta>; sending a strict fallback header, the inline theme bootstrap will be blocked"
);
}
let terminal = terminal.then(crate::terminal::TerminalConfig::new);
let origins = vec![
format!("http://127.0.0.1:{}", bound.port()),
format!("http://localhost:{}", bound.port()),
];
let url = match &terminal {
Some(t) => format!("http://{bound}/#/?t={}", t.token),
None => format!("http://{bound}/"),
};
println!(
"cyberbrain serve: http://{bound}/ (loopback only, no authentication; API at /api/v1)"
);
if terminal.is_some() {
println!(
"cyberbrain serve: terminal enabled. Open this address yourself, and nothing \
else — the token in it is what opens a terminal, it is new every run, and it \
does not belong in a ticket or a log:\n{url}\n\
cyberbrain serve: not opening a browser for you, because the address would go \
into that browser's command line, where every account on this machine can read \
it."
);
}
if should_open(open, terminal.is_some()) {
open_browser(&url);
}
axum::serve(
listener,
router_with(
app,
std::env::current_exe().unwrap_or_default(),
terminal,
origins,
),
)
.await
.map_err(|e| Error::Config(format!("serve: {e}")))
}
fn should_open(asked: bool, terminal: bool) -> bool {
asked && !terminal
}
fn open_browser(url: &str) {
#[cfg(target_os = "windows")]
let result = std::process::Command::new("cmd")
.args(["/c", "start", "", url])
.spawn();
#[cfg(target_os = "macos")]
let result = std::process::Command::new("open").arg(url).spawn();
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
let result = std::process::Command::new("xdg-open").arg(url).spawn();
if let Err(e) = result {
eprintln!(
"cyberbrain serve: could not open a browser ({e}); open the address above yourself"
);
}
}