diff options
-rw-r--r-- | Cargo.lock | 7 | ||||
-rw-r--r-- | Cargo.toml | 11 | ||||
-rw-r--r-- | README.md | 10 | ||||
-rw-r--r-- | corgi.conf | 16 | ||||
-rw-r--r-- | src/main.rs | 169 |
5 files changed, 184 insertions, 29 deletions
diff --git a/Cargo.lock b/Cargo.lock index f7f322a..a379ab8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,6 +57,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "regex-lite", "tokio", ] @@ -245,6 +246,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] +name = "regex-lite" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" + +[[package]] name = "rustc-demangle" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml index f53fcbc..fa58787 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,10 +4,10 @@ version = "0.1.0" edition = "2024" [profile.release] -strip = true -opt-level = "z" -lto = true -codegen-units = 1 +#strip = true +#opt-level = "z" +#lto = true +#codegen-units = 1 # 1538792 none of the above flags # 829896 the above flags reduced binary size by around 700K :) @@ -15,6 +15,7 @@ codegen-units = 1 [dependencies] http-body-util = "0.1.3" hyper-util = { version = "0.1.10", features = ["tokio"] } +regex-lite = "0.1.6" [dependencies.confindent] version = "2.2.1" @@ -23,7 +24,7 @@ branch = "v2" [dependencies.tokio] version = "1.44.0" -features = ["rt", "rt-multi-thread", "io-std", "net", "process"] +features = ["rt", "rt-multi-thread", "io-std", "io-util", "net", "process"] [dependencies.hyper] version = "1.6.0" diff --git a/README.md b/README.md index ef2dd2b..47b3449 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,22 @@ corgi listens on port 26744 by default. `/etc/corgi.conf` ``` +Server + Port 26744 + +Script <path-to-cgi-script> + Match + Regex <regular-expression> + Script <path-to-cgi-script> Environment HTTP_HOST <hostname> ENV_KEY <some-env-value> ``` +Scripts are tried in order, looking for one that matches. If none match, +the first script is ran. + Sets the following environmental variables for the CGI script, many following [RFC 3875][rfc]: - **`GATEWAY_INTERFACE`** to the fixed value `CGI/1.1` - **`PATH_INFO`** to the HTTP path the client requested diff --git a/corgi.conf b/corgi.conf index fb3e7ad..207acc7 100644 --- a/corgi.conf +++ b/corgi.conf @@ -1,3 +1,17 @@ -Script /usr/lib/cgit/cgit.cgi +Server + Port 26744 + +Script git-backend + Path /usr/lib/git-core/git-http-backend + Match + Regex /.+/(info/refs|git-upload-pack) + Environment + GIT_HTTP_EXPORT_ALL 1 + GIT_PROJECT_ROOT /srv/git + HOME /srv/git + HTTP_HOST git.nyble.dev + +Script cgit + Path /usr/lib/cgit/cgit.cgi Environment HTTP_HOST git.nyble.dev \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 0493fdb..9831331 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,25 +1,39 @@ use std::{ net::{IpAddr, SocketAddr}, pin::Pin, + process::Stdio, time::Instant, }; -use confindent::{Confindent, ValueParseError}; +use confindent::{Confindent, Value, ValueParseError}; use http_body_util::{BodyExt, Full}; use hyper::{ HeaderMap, Request, Response, StatusCode, body::{Bytes, Incoming}, - header::HeaderValue, + header::{self, HeaderValue}, server::conn::http1, service::Service, }; use hyper_util::rt::TokioIo; -use tokio::{net::TcpListener, process::Command, runtime::Runtime}; +use regex_lite::Regex; +use tokio::{ + io::{AsyncWriteExt, BufWriter}, + net::TcpListener, + process::Command, + runtime::Runtime, +}; #[derive(Clone, Debug)] pub struct Settings { port: u16, - script_filename: String, + scripts: Vec<Script>, +} + +#[derive(Clone, Debug)] +pub struct Script { + name: String, + regex: Option<Regex>, + filename: String, env: Vec<(String, String)>, } @@ -29,15 +43,9 @@ fn main() { let conf_path = std::env::args().nth(1).unwrap_or(String::from(CONF_DEFAULT)); let conf = Confindent::from_file(conf_path).expect("failed to open conf"); - let script = conf.child("Script").expect("no 'Script' key in conf"); - let environment = script.child("Environment"); - let env = environment - .map(|e| e.values().map(|v| (v.key_owned(), v.value_owned().unwrap())).collect()); - let mut settings = Settings { port: 26744, - script_filename: script.value_owned().expect("'Script' key has no value'"), - env: env.unwrap_or_default(), + scripts: conf.children("Script").into_iter().map(parse_script_conf).collect(), }; if let Some(server) = conf.child("Server") { @@ -55,6 +63,32 @@ fn main() { rt.block_on(async { run(settings).await }); } +fn parse_script_conf(conf: &Value) -> Script { + let name = conf.value_owned().expect("Missing value for 'Script' key"); + let filename = conf.child_owned("Path").expect("Missing 'Path' key"); + let environment = conf.child("Environment"); + let env = environment + .map(|e| e.values().map(|v| (v.key_owned(), v.value_owned().unwrap())).collect()); + + let regex = match conf.get("Match/Regex") { + None => None, + Some(restr) => match Regex::new(restr) { + Err(err) => { + eprintln!("Failed to compile regex: {restr}\nerror: {err}"); + std::process::exit(1); + } + Ok(re) => Some(re), + }, + }; + + Script { + name, + regex, + filename, + env: env.unwrap_or_default(), + } +} + // We have tokio::main at home :) async fn run(settings: Settings) { let addr = SocketAddr::from(([0, 0, 0, 0], settings.port)); @@ -112,6 +146,29 @@ impl Svc { let body = req.into_body().collect().await.unwrap().to_bytes(); let content_length = body.len(); + let mut script = settings.scripts[0].clone(); + + for set_script in settings.scripts { + if let Some(regex) = set_script.regex.as_ref() { + if regex.is_match(&path) { + script = set_script; + break; + } + } else { + script = set_script; + } + } + + let content_type = headers + .get("content-type") + .map(|s| s.to_str().ok()) + .flatten() + .unwrap_or_default() + .to_owned(); + + println!("!!! new request. type {content_type} // {method}"); + println!("!!! {path}?{query}"); + let uagent = headers .get("user-agent") .map(|s| s.to_str().ok()) @@ -133,13 +190,14 @@ impl Svc { .to_str() .expect("failed to decode http host as string"); - let mut cmd = Command::new(&settings.script_filename); - cmd.env("GATEWAY_INTERFACE", "CGI/1.1") - .env("PATH_INFO", path) + let mut cmd = Command::new(&script.filename); + cmd.env("CONTENT_TYPE", content_type) + .env("GATEWAY_INTERFACE", "CGI/1.1") + .env("PATH_INFO", &path) .env("QUERY_STRING", query) .env("REMOTE_ADDR", client_addr.to_string()) .env("REQUEST_METHOD", method) - .env("SCRIPT_NAME", settings.script_filename) + .env("SCRIPT_NAME", script.filename) .env("SERVER_NAME", server_name) .env("SERVER_PORT", settings.port.to_string()) .env("SERVER_PROTOCOL", format!("{:?}", version)) @@ -154,12 +212,21 @@ impl Svc { // Set env specified in the conf. Be sure we do this after we // set the HTTP headers as to overwrite any we might want - for (key, value) in &settings.env { + for (key, value) in &script.env { cmd.env(key.to_ascii_uppercase(), value); } + let debugcgi = script.name == "git-backend"; + + let cgibody = if content_length > 0 { + Some(&body) + } else { + None + }; + let start_cgi = Instant::now(); - let cgi_response = Self::call_and_parse_cgi(cmd).await; + let cgi_response = + Self::call_and_parse_cgi(cmd, cgibody, caddr.ip(), debugcgi, &path).await; let cgi_time = start_cgi.elapsed(); let status = StatusCode::from_u16(cgi_response.status).unwrap(); @@ -170,7 +237,8 @@ impl Svc { } println!( - "served to [{client_addr}]\n\tcgi took {}ms. total time {}ms\n\tUA: {uagent}", + "served to [{client_addr}]\n\tscript: {}\n\tpath: {path}\n\tcgi took {}ms. total time {}ms\n\tUA: {uagent}", + &script.name, cgi_time.as_millis(), start.elapsed().as_millis() ); @@ -214,7 +282,13 @@ impl Svc { } } - async fn call_and_parse_cgi(mut cmd: Command) -> CgiResponse { + async fn call_and_parse_cgi( + mut cmd: Command, + body: Option<&Bytes>, + caddr: IpAddr, + debug: bool, + path: &str, + ) -> CgiResponse { let mut response = CgiResponse { // Default status code is 200 per RFC status: 200, @@ -222,9 +296,35 @@ impl Svc { body: None, }; - let output = cmd.output().await.unwrap(); + println!("!!! before spawn: {path}"); + let cmd = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + let output = if let Some(bytes) = body { + println!("!!! has body len={}", bytes.len()); + let mut child = cmd.stdin(Stdio::piped()).spawn().unwrap(); + + let cmd_stdin = child.stdin.take().unwrap(); + let mut bufwrite = BufWriter::new(cmd_stdin); + bufwrite.write_all(bytes).await.unwrap(); + + drop(bufwrite); + println!("!!! after drop ({path})"); + + child.wait_with_output().await.unwrap() + } else { + cmd.spawn().unwrap().wait_with_output().await.unwrap() + }; + println!("!!! after spawn ({path})"); + let response_raw = output.stdout; + if debug { + std::fs::write( + format!("/tmp/{caddr}-gitbackend-{}", path_to_name(path)), + &response_raw, + ) + .unwrap(); + } + let mut curr = response_raw.as_slice(); loop { // Find the newline to know where this header ends @@ -234,7 +334,15 @@ impl Svc { // Find the colon to separate the key from the value let colon = line.iter().position(|b| *b == b':').expect("no colon in header"); let key = &line[..colon]; - let value = &line[colon + 1..]; + let mut value = &line[colon + 1..]; + + if value[0] == b' ' { + value = &value[1..]; + } + if value[value.len().saturating_sub(1)] == b'\r' { + value = &value[..value.len().saturating_sub(1)]; + } + response.headers.push((key.to_vec(), value.to_vec())); // Is this header a status line? @@ -248,12 +356,16 @@ impl Svc { } // Body next? - if curr[nl + 1] == b'\n' || (curr[nl + 1] == b'\r' && curr[nl + 2] == b'\n') { - let body = &curr[nl + 2..]; + let next_nl = curr[nl + 1] == b'\n'; + let next_crlf = curr[nl + 1] == b'\r' && curr[nl + 2] == b'\n'; + if next_nl || next_crlf { + let offset = if next_nl { 2 } else { 3 }; + let body = &curr[nl + offset..]; if body.len() > 0 { response.body = Some(body.to_vec()); } + println!("!!! before call_and_parse return ({path})"); return response; } @@ -271,3 +383,14 @@ struct CgiResponse { /// CGI response body body: Option<Vec<u8>>, } + +fn path_to_name(path: &str) -> String { + let mut ret = String::with_capacity(path.len()); + for ch in path.chars() { + match ch { + '/' => ret.push('-'), + ch => ret.push(ch), + } + } + ret +} |