lib.rs
1 #![allow(clippy::type_complexity)] 2 #![allow(clippy::too_many_arguments)] 3 #![recursion_limit = "256"] 4 pub mod error; 5 6 use std::collections::HashMap; 7 use std::num::NonZeroUsize; 8 use std::process::Command; 9 use std::str; 10 use std::sync::Arc; 11 use std::time::Duration; 12 13 #[cfg(unix)] 14 use tokio::signal::unix::{signal, SignalKind}; 15 16 use anyhow::Context as _; 17 use axum::body::Body; 18 use axum::http::Request; 19 use axum::response::IntoResponse; 20 use axum::routing::get; 21 use axum::{middleware, Json, Router}; 22 use axum_listener::{DualAddr, DualListener}; 23 use hyper::body::Body as _; 24 use hyper::header::CONTENT_TYPE; 25 use hyper::Method; 26 use tower_http::cors; 27 use tower_http::cors::CorsLayer; 28 use tower_http::trace::TraceLayer; 29 use tracing::Span; 30 31 use radicle::identity::RepoId; 32 use radicle::Profile; 33 34 use crate::api::RADICLE_VERSION; 35 use crate::tracing_extra::{tracing_middleware, ColoredStatus, Paint, RequestId, TracingInfo}; 36 37 mod api; 38 mod axum_extra; 39 mod cache; 40 mod git; 41 mod raw; 42 #[cfg(test)] 43 mod test; 44 mod tracing_extra; 45 46 /// Default cache HTTP size. 47 pub const DEFAULT_CACHE_SIZE: NonZeroUsize = NonZeroUsize::new(100).unwrap(); 48 49 #[derive(Debug, Clone)] 50 pub struct Options { 51 pub aliases: HashMap<String, RepoId>, 52 pub listen: DualAddr, 53 pub cache: Option<NonZeroUsize>, 54 } 55 56 /// Run the Server. 57 pub async fn run(options: Options) -> anyhow::Result<()> { 58 let git_version = Command::new("git") 59 .arg("version") 60 .output() 61 .context("'git' command must be available")? 62 .stdout; 63 64 tracing::info!("{}", str::from_utf8(&git_version)?.trim()); 65 66 let listener = DualListener::bind(&options.listen).await?; 67 tracing::info!("listening on {:?}", &options.listen); 68 69 let profile = Profile::load()?; 70 let request_id = RequestId::new(); 71 72 tracing::info!("using radicle home at {}", profile.home().path().display()); 73 74 let web_config = api::WebConfig::from_profile(&profile); 75 let profile = Arc::new(profile); 76 let ctx = api::Context::new(profile.clone(), web_config.clone(), &options); 77 78 #[cfg(unix)] 79 tokio::spawn(async move { 80 let mut sighup = signal(SignalKind::hangup()).expect("Failed to register SIGHUP handler"); 81 82 loop { 83 sighup.recv().await; 84 tracing::info!("Received SIGHUP, reloading web configuration"); 85 86 match Profile::load() { 87 Ok(new_profile) => { 88 web_config 89 .update(|config| { 90 *config = new_profile.config.web.clone(); 91 }) 92 .await; 93 tracing::info!("Web configuration reloaded successfully"); 94 } 95 Err(e) => { 96 tracing::error!("Failed to reload configuration: {:#}", e); 97 tracing::warn!("Continuing with previous configuration"); 98 } 99 } 100 } 101 }); 102 103 let app = router(options, profile, ctx)? 104 .layer(middleware::from_fn(tracing_middleware)) 105 .layer( 106 TraceLayer::new_for_http() 107 .make_span_with(move |request: &Request<Body>| { 108 if let Some(forwarded) = request.headers().get("X-Forwarded-For").and_then(|s| s.to_str().ok()) { 109 tracing::info_span!("request", id = %request_id.clone().next(), "X-Forwarded-For" = forwarded) 110 } else { 111 tracing::info_span!("request", id = %request_id.clone().next()) 112 } 113 }) 114 .on_response( 115 |response: &hyper::Response<Body>, latency: Duration, _span: &Span| { 116 if let Some(info) = response.extensions().get::<TracingInfo>() { 117 tracing::info!( 118 "{} \"{} {} {:?}\" {} {:?} {}", 119 match info.connect_info.0 { 120 DualAddr::Tcp(c) => c.to_string(), 121 #[cfg(unix)] 122 DualAddr::Uds(_) => "unix-socket".into() 123 }, 124 info.method, 125 info.uri, 126 info.version, 127 ColoredStatus(response.status()), 128 latency, 129 Paint::dim( 130 response 131 .body() 132 .size_hint() 133 .exact() 134 .map(|n| n.to_string()) 135 .unwrap_or("0".to_string()) 136 .into() 137 ), 138 ); 139 } else { 140 tracing::info!("Processed"); 141 } 142 }, 143 ) 144 ).into_make_service_with_connect_info::<DualAddr>(); 145 146 axum::serve(listener, app) 147 .await 148 .map_err(anyhow::Error::from) 149 } 150 151 /// Create a router consisting of other sub-routers. 152 fn router(options: Options, profile: Arc<Profile>, ctx: api::Context) -> anyhow::Result<Router> { 153 let api_router = api::router(ctx); 154 let git_router = git::router(profile.clone(), options.aliases); 155 let raw_router = raw::router(profile); 156 157 let app = Router::new() 158 .route("/", get(root_index_handler)) 159 .merge(git_router) 160 .nest("/api", api_router) 161 .nest("/raw", raw_router) 162 .layer( 163 CorsLayer::new() 164 .max_age(Duration::from_secs(86400)) 165 .allow_origin(cors::Any) 166 .allow_methods([Method::GET]) 167 .allow_headers([CONTENT_TYPE]), 168 ); 169 170 Ok(app) 171 } 172 173 async fn root_index_handler() -> impl IntoResponse { 174 let response = serde_json::json!({ 175 "welcome": "Welcome to the radicle-httpd JSON API, this service doesn't serve the Radicle Explorer web client.", 176 "version": format!("{}-{}", RADICLE_VERSION, env!("GIT_HEAD")), 177 "path": "/", 178 "links": [ 179 { 180 "href": "/api", 181 "rel": "api", 182 "type": "GET" 183 }, 184 { 185 "href": "/raw/:rid/:sha/*path", 186 "rel": "file_by_commit", 187 "type": "GET" 188 }, 189 { 190 "href": "/raw/:rid/head/*path", 191 "rel": "file_by_canonical_head", 192 "type": "GET" 193 }, 194 { 195 "href": "/raw/:rid/blobs/:oid", 196 "rel": "file_by_oid", 197 "type": "GET" 198 }, 199 { 200 "href": "/:rid/*request", 201 "rel": "git", 202 "type": "GET" 203 } 204 ] 205 }); 206 207 Json(response) 208 } 209 210 pub mod logger { 211 use tracing::dispatcher::Dispatch; 212 213 pub fn init() -> Result<(), tracing::subscriber::SetGlobalDefaultError> { 214 tracing::dispatcher::set_global_default(Dispatch::new(subscriber())) 215 } 216 217 #[cfg(feature = "logfmt")] 218 pub fn subscriber() -> impl tracing::Subscriber { 219 use tracing_subscriber::layer::SubscriberExt as _; 220 use tracing_subscriber::EnvFilter; 221 222 tracing_subscriber::Registry::default() 223 .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) 224 .with(tracing_logfmt::layer()) 225 } 226 227 #[cfg(not(feature = "logfmt"))] 228 pub fn subscriber() -> impl tracing::Subscriber { 229 tracing_subscriber::FmtSubscriber::builder() 230 .with_target(false) 231 .with_max_level(tracing::Level::DEBUG) 232 .finish() 233 } 234 } 235 236 #[cfg(test)] 237 mod routes { 238 use std::collections::HashMap; 239 use std::net::SocketAddr; 240 241 use axum::extract::connect_info::MockConnectInfo; 242 use axum::http::StatusCode; 243 use axum_listener::DualAddr; 244 245 use crate::test; 246 247 #[tokio::test] 248 async fn test_invalid_route_returns_404() { 249 let tmp = tempfile::tempdir().unwrap(); 250 let options = super::Options { 251 aliases: HashMap::new(), 252 listen: DualAddr::Tcp(SocketAddr::from(([0, 0, 0, 0], 8080))), 253 cache: None, 254 }; 255 let profile = test::profile(tmp.path(), [0xff; 32]); 256 let web_config = crate::api::WebConfig::from_profile(&profile); 257 let profile = std::sync::Arc::new(profile); 258 let ctx = crate::api::Context::new(profile.clone(), web_config, &options); 259 let app = super::router(options, profile, ctx) 260 .unwrap() 261 .layer(MockConnectInfo(DualAddr::Tcp(SocketAddr::from(( 262 [0, 0, 0, 0], 263 8080, 264 ))))); 265 266 let response = test::get(&app, "/aa/a").await; 267 268 assert_eq!(response.status(), StatusCode::NOT_FOUND); 269 } 270 }