mod url;
#[cfg(feature = "web-monitoring")]
pub mod completion_sink;
#[cfg(feature = "web-monitoring")]
pub mod openapi;
#[cfg(feature = "web-monitoring")]
pub mod operator_facts;
#[cfg(feature = "web-monitoring")]
pub mod remote_control_api;
#[cfg(feature = "web-monitoring")]
pub mod state;
#[cfg(all(unix, feature = "web-monitoring"))]
pub mod unix_socket;
#[cfg(all(test, unix, feature = "web-monitoring"))]
mod listener_tests;
#[cfg(feature = "web-monitoring")]
use axum::{
http::{header, StatusCode},
response::{Html, IntoResponse, Response},
routing::get,
Router,
};
#[cfg(feature = "web-monitoring")]
use std::net::SocketAddr;
#[cfg(feature = "web-monitoring")]
use std::sync::Arc;
#[cfg(feature = "web-monitoring")]
use tower_http::trace::TraceLayer;
#[cfg(feature = "web-monitoring")]
use tracing::{debug, info};
#[cfg(feature = "web-monitoring")]
pub use state::WebState;
pub use url::build_access_url;
#[derive(Debug, Clone)]
pub struct WebConfig {
pub enabled: bool,
pub port: u16,
pub bind: String,
pub refresh_interval_secs: u64,
pub auth_token: Option<String>,
pub auth_token_env: Option<String>,
pub allowed_origins: Vec<String>,
}
impl Default for WebConfig {
fn default() -> Self {
Self {
enabled: false,
port: 0, bind: "127.0.0.1".to_string(),
refresh_interval_secs: 5, auth_token: None,
auth_token_env: None,
allowed_origins: Vec::new(),
}
}
}
impl WebConfig {
pub fn enabled(port: u16, bind: String) -> Self {
Self {
enabled: true,
port,
bind,
..Self::default()
}
}
pub fn with_tcp_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
#[allow(dead_code)]
pub fn with_refresh_interval(mut self, secs: u64) -> Self {
self.refresh_interval_secs = secs;
self
}
pub fn with_auth(
mut self,
auth_token: Option<String>,
auth_token_env: Option<String>,
allowed_origins: Vec<String>,
) -> Self {
self.auth_token = auth_token;
self.auth_token_env = auth_token_env;
self.allowed_origins = allowed_origins;
self
}
pub fn is_loopback_bind(&self) -> bool {
let host = self
.bind
.trim_start_matches('[')
.trim_end_matches(']')
.to_ascii_lowercase();
if host == "localhost" || host == "::1" {
return true;
}
host.parse::<std::net::IpAddr>()
.map(|ip| ip.is_loopback())
.unwrap_or(false)
}
pub fn resolve_auth_token(&self) -> Option<String> {
if let Some(var) = self.auth_token_env.as_deref() {
return std::env::var(var).ok().filter(|value| !value.is_empty());
}
self.auth_token.clone().filter(|value| !value.is_empty())
}
pub fn validate(&self) -> Result<(), String> {
if self.auth_token.is_some() && self.auth_token_env.is_some() {
return Err(
"--web-auth-token and --web-auth-token-env are mutually exclusive".to_string(),
);
}
#[cfg(feature = "web-monitoring")]
for origin in &self.allowed_origins {
if remote_control_api::auth::normalize_origin(origin).is_none() {
return Err(format!(
"invalid --web-allowed-origin '{origin}': expected an exact \
http(s)://host[:port] value with no wildcard or path"
));
}
}
if self.enabled
&& !self.is_loopback_bind()
&& self
.resolve_auth_token()
.as_deref()
.unwrap_or("")
.is_empty()
{
return Err(format!(
"web monitoring on non-loopback address '{}' requires \
--web-auth-token or --web-auth-token-env",
self.bind
));
}
Ok(())
}
}
#[cfg(feature = "web-monitoring")]
mod static_files {
pub const INDEX_HTML: &str = include_str!("../../web/index.html");
pub const STYLE_CSS: &str = include_str!("../../web/style.css");
pub const APP_JS: &str = include_str!("../../web/app.js");
}
#[cfg(feature = "web-monitoring")]
async fn serve_index() -> Html<&'static str> {
Html(static_files::INDEX_HTML)
}
#[cfg(feature = "web-monitoring")]
async fn serve_css() -> Response {
(
StatusCode::OK,
[(header::CONTENT_TYPE, "text/css")],
static_files::STYLE_CSS,
)
.into_response()
}
#[cfg(feature = "web-monitoring")]
async fn serve_js() -> Response {
(
StatusCode::OK,
[(header::CONTENT_TYPE, "application/javascript")],
static_files::APP_JS,
)
.into_response()
}
#[cfg(feature = "web-monitoring")]
fn static_router() -> Router {
Router::new()
.route("/", get(serve_index))
.route("/style.css", get(serve_css))
.route("/app.js", get(serve_js))
}
#[cfg(feature = "web-monitoring")]
pub fn remote_control_router(
config: &WebConfig,
state: Arc<WebState>,
) -> Result<Router, Box<dyn std::error::Error + Send + Sync>> {
let auth = remote_control_api::auth::RemoteControlAuth::new(
config.resolve_auth_token(),
&config.allowed_origins,
)?;
let runtime = state.remote_control();
Ok(remote_control_api::router(
remote_control_api::RemoteControlState::new(
runtime.projection(),
Arc::new(auth),
runtime.clone(),
)
.with_gate(runtime.gate())
.with_execution_facts(runtime.execution_facts())
.with_execution_contract(runtime.execution_contract())
.with_completion_sinks(runtime.completion_sinks())
.with_worktrees(runtime),
))
}
#[cfg(feature = "web-monitoring")]
fn build_app(
config: &WebConfig,
state: Arc<WebState>,
) -> Result<Router, Box<dyn std::error::Error + Send + Sync>> {
Ok(static_router()
.merge(remote_control_router(config, state)?)
.layer(TraceLayer::new_for_http()))
}
#[cfg(all(test, feature = "web-monitoring"))]
pub(crate) fn build_app_for_test(config: &WebConfig, state: Arc<WebState>) -> Router {
build_app(config, state).expect("test configuration must be valid")
}
#[cfg(feature = "web-monitoring")]
#[derive(Debug, Clone, Default)]
pub struct ListenerPlan {
#[cfg(unix)]
pub unix_socket: Option<std::path::PathBuf>,
pub tcp: bool,
}
#[cfg(feature = "web-monitoring")]
impl ListenerPlan {
pub fn is_empty(&self) -> bool {
#[cfg(unix)]
let unix = self.unix_socket.is_none();
#[cfg(not(unix))]
let unix = true;
unix && !self.tcp
}
}
#[cfg(feature = "web-monitoring")]
pub struct ServerHandle {
endpoints: Vec<String>,
tcp_url: Option<String>,
shutdown: tokio_util::sync::CancellationToken,
tasks: Vec<tokio::task::JoinHandle<()>>,
#[cfg(unix)]
socket: Option<unix_socket::SocketGuard>,
state: Arc<WebState>,
}
#[cfg(feature = "web-monitoring")]
impl std::fmt::Debug for ServerHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ServerHandle")
.field("endpoints", &self.endpoints)
.field("tcp_url", &self.tcp_url)
.finish_non_exhaustive()
}
}
#[cfg(feature = "web-monitoring")]
impl ServerHandle {
pub fn endpoints(&self) -> &[String] {
&self.endpoints
}
pub fn tcp_url(&self) -> Option<&str> {
self.tcp_url.as_deref()
}
pub async fn shutdown(mut self) {
if let Some(registry) = self.state.completion_sinks() {
registry.owner_stopping().await;
}
self.shutdown.cancel();
for task in std::mem::take(&mut self.tasks) {
let _ = task.await;
}
#[cfg(unix)]
if let Some(socket) = &self.socket {
socket.release();
}
}
}
#[cfg(feature = "web-monitoring")]
async fn abort_started_listeners(
shutdown: &tokio_util::sync::CancellationToken,
tasks: Vec<tokio::task::JoinHandle<()>>,
) {
shutdown.cancel();
for task in tasks {
let _ = task.await;
}
}
#[cfg(feature = "web-monitoring")]
pub async fn start_listeners(
config: WebConfig,
plan: ListenerPlan,
state: Arc<WebState>,
) -> Result<ServerHandle, Box<dyn std::error::Error + Send + Sync>> {
config.validate()?;
let app = build_app(&config, state.clone())?;
let shutdown = tokio_util::sync::CancellationToken::new();
let mut endpoints = Vec::new();
let mut tasks: Vec<tokio::task::JoinHandle<()>> = Vec::new();
#[cfg(unix)]
let mut socket_guard = None;
#[cfg(unix)]
if let Some(path) = plan.unix_socket.as_deref() {
let (listener, guard) = unix_socket::bind_unix_listener(path).await?;
endpoints.push(unix_socket::unix_endpoint(path));
socket_guard = Some(guard);
info!(
"Local API listening on {}",
unix_socket::unix_endpoint(path)
);
let app = app
.clone()
.layer(axum::Extension(remote_control_api::ApiTransport::Unix));
let token = shutdown.clone();
tasks.push(tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async move { token.cancelled().await })
.await
{
debug!("Unix API listener stopped: {}", e);
}
}));
}
let mut tcp_url = None;
if plan.tcp {
let bound = async {
let addr: SocketAddr = format!("{}:{}", config.bind, config.port).parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
let actual_port = listener.local_addr()?.port();
Ok::<_, Box<dyn std::error::Error + Send + Sync>>((listener, actual_port))
}
.await;
let (listener, actual_port) = match bound {
Ok(bound) => bound,
Err(error) => {
abort_started_listeners(&shutdown, tasks).await;
return Err(error);
}
};
let url = build_access_url(&config.bind, actual_port);
info!("Web monitoring server listening on {}", url);
endpoints.push(url.clone());
tcp_url = Some(url);
let app = app
.clone()
.layer(axum::Extension(remote_control_api::ApiTransport::Tcp));
let token = shutdown.clone();
tasks.push(tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async move { token.cancelled().await })
.await
{
debug!("TCP API listener stopped: {}", e);
}
}));
}
crate::repo_lock::publish_endpoints(&endpoints);
if config.refresh_interval_secs > 0 && !endpoints.is_empty() {
let state = state.clone();
let interval_secs = config.refresh_interval_secs;
let token = shutdown.clone();
tasks.push(tokio::spawn(async move {
let mut interval =
tokio::time::interval(tokio::time::Duration::from_secs(interval_secs));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = token.cancelled() => break,
_ = interval.tick() => {
if let Err(e) = state.refresh_from_disk().await {
debug!("Periodic refresh failed: {}", e);
}
}
}
}
}));
}
Ok(ServerHandle {
endpoints,
tcp_url,
shutdown,
tasks,
#[cfg(unix)]
socket: socket_guard,
state,
})
}
#[cfg(not(feature = "web-monitoring"))]
pub async fn start_server(
_config: WebConfig,
_state: (),
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Err("Web monitoring feature is not enabled. Compile with --features web-monitoring".into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_web_config_default() {
let config = WebConfig::default();
assert!(!config.enabled);
assert_eq!(config.port, 0); assert_eq!(config.bind, "127.0.0.1");
}
#[test]
fn test_web_config_enabled() {
let config = WebConfig::enabled(9000, "0.0.0.0".to_string());
assert!(config.enabled);
assert_eq!(config.port, 9000);
assert_eq!(config.bind, "0.0.0.0");
}
#[test]
fn test_web_config_auto_assign_port() {
let config = WebConfig::enabled(0, "127.0.0.1".to_string());
assert!(config.enabled);
assert_eq!(config.port, 0);
assert_eq!(config.bind, "127.0.0.1");
}
}