about summary refs log tree commit diff
diff options
context:
space:
mode:
authorgennyble <gen@nyble.dev>2025-03-22 16:11:20 -0500
committergennyble <gen@nyble.dev>2025-03-22 16:11:20 -0500
commit1df2ee6bc406b87d96548da3a21e671490e82513 (patch)
treecf1431a38b5e61019d63308ec444fcc5df6829cd
parent62d3ce6cca2d6be9f8fb77cc7e56a25217070d87 (diff)
downloadcorgi-1df2ee6bc406b87d96548da3a21e671490e82513.tar.gz
corgi-1df2ee6bc406b87d96548da3a21e671490e82513.zip
stats!
-rw-r--r--.gitignore2
-rw-r--r--Cargo.lock290
-rw-r--r--Cargo.toml2
-rw-r--r--README.md1
-rw-r--r--corgi/Cargo.toml3
-rw-r--r--corgi/src/main.rs28
-rw-r--r--corgi/src/stats.rs100
-rw-r--r--stats_module/Cargo.toml12
-rw-r--r--stats_module/src/lib.rs118
9 files changed, 552 insertions, 4 deletions
diff --git a/.gitignore b/.gitignore
index ea8c4bf..cee48f1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,3 @@
 /target
+*.sqlite
+*.db
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
index 1c55731..6e28733 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -33,12 +33,42 @@ dependencies = [
 ]
 
 [[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
 name = "bytes"
 version = "1.10.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
 
 [[package]]
+name = "cc"
+version = "1.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fcb57c740ae1daf453ae85f16e37396f672b039e00d9d866e07ddb24e328e3a"
+dependencies = [
+ "shlex",
+]
+
+[[package]]
 name = "cfg-if"
 version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -54,22 +84,81 @@ checksum = "ea618ded77af626818bde0f0802da7c20d47e38e23e37be40f6f807a76079e82"
 name = "corgi"
 version = "1.0.0"
 dependencies = [
+ "base64",
  "confindent",
  "http-body-util",
  "hyper",
  "hyper-util",
  "libloading",
  "regex-lite",
+ "rusqlite",
+ "sha2",
  "tokio",
 ]
 
 [[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "deranged"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
+dependencies = [
+ "powerfmt",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
+[[package]]
 name = "fnv"
 version = "1.0.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
 
 [[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
 name = "futures-channel"
 version = "0.3.31"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -103,12 +192,40 @@ dependencies = [
 ]
 
 [[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
 name = "gimli"
 version = "0.31.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
 
 [[package]]
+name = "hashbrown"
+version = "0.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashlink"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
+dependencies = [
+ "hashbrown",
+]
+
+[[package]]
 name = "http"
 version = "1.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -211,6 +328,17 @@ dependencies = [
 ]
 
 [[package]]
+name = "libsqlite3-sys"
+version = "0.32.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fbb8270bb4060bd76c6e96f20c52d80620f1d82a3470885694e41e0f81ef6fe7"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
 name = "memchr"
 version = "2.7.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -237,6 +365,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "num-conv"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+
+[[package]]
 name = "object"
 version = "0.36.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -266,18 +400,100 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
 
 [[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.94"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
 name = "regex-lite"
 version = "0.1.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a"
 
 [[package]]
+name = "rusqlite"
+version = "0.34.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37e34486da88d8e051c7c0e23c3f15fd806ea8546260aa2fec247e97242ec143"
+dependencies = [
+ "bitflags",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "hashlink",
+ "libsqlite3-sys",
+ "smallvec",
+ "time",
+]
+
+[[package]]
 name = "rustc-demangle"
 version = "0.1.24"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
 
 [[package]]
+name = "serde"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
 name = "signal-hook-registry"
 version = "1.4.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -303,6 +519,56 @@ dependencies = [
 ]
 
 [[package]]
+name = "stats_module"
+version = "0.1.0"
+dependencies = [
+ "rusqlite",
+ "time",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "time"
+version = "0.3.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d9c75b47bdff86fa3334a3db91356b8d7d86a9b839dab7d0bdc5c3d3a077618"
+dependencies = [
+ "deranged",
+ "itoa",
+ "num-conv",
+ "powerfmt",
+ "serde",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
+
+[[package]]
+name = "time-macros"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29aa485584182073ed57fd5004aa09c371f021325014694e432313345865fd04"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
 name = "tokio"
 version = "1.44.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -319,6 +585,30 @@ dependencies = [
 ]
 
 [[package]]
+name = "typenum"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
 name = "wasi"
 version = "0.11.0+wasi-snapshot-preview1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 8feb4b4..c5c5f05 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,5 @@
 [workspace]
-members = ["corgi", "parrot", "parrot_module"]
+members = ["corgi", "parrot", "parrot_module", "stats_module"]
 resolver = "3"
 
 # use this profile like this:
diff --git a/README.md b/README.md
index 3b94588..c312ee6 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ corgi listens on port 26744 by default.
 ```
 Server
 	Port 26744
+	StatsDb /var/corgi/stats.db
 
 Script <path-to-cgi-script>
 	Match
diff --git a/corgi/Cargo.toml b/corgi/Cargo.toml
index 0abd9df..ea5fe74 100644
--- a/corgi/Cargo.toml
+++ b/corgi/Cargo.toml
@@ -10,10 +10,13 @@ version = "1.0.0"
 edition = "2024"
 
 [dependencies]
+base64 = "0.22.1"
 http-body-util = "0.1.3"
 hyper-util = { version = "0.1.10", features = ["tokio"] }
 libloading = "0.8.6"
 regex-lite = "0.1.6"
+rusqlite = { version = "0.34.0", features = ["bundled"] }
+sha2 = "0.10.8"
 
 [dependencies.confindent]
 version = "2.2.1"
diff --git a/corgi/src/main.rs b/corgi/src/main.rs
index cd3b67c..6a3c528 100644
--- a/corgi/src/main.rs
+++ b/corgi/src/main.rs
@@ -1,7 +1,9 @@
 use std::{
 	net::{IpAddr, SocketAddr},
+	path::PathBuf,
 	pin::Pin,
 	process::Stdio,
+	sync::Arc,
 	time::Instant,
 };
 
@@ -17,9 +19,11 @@ use hyper::{
 };
 use hyper_util::rt::TokioIo;
 use regex_lite::Regex;
+use stats::Stats;
 use tokio::{io::AsyncWriteExt, net::TcpListener, process::Command, runtime::Runtime};
 
 mod caller;
+mod stats;
 
 #[derive(Clone, Debug)]
 pub struct Settings {
@@ -64,8 +68,13 @@ fn main() {
 		}
 	}
 
+	let stats = Stats::new(PathBuf::from(
+		conf.get("Server/StatsDb").unwrap().to_owned(),
+	));
+	stats.create_tables();
+
 	let rt = Runtime::new().unwrap();
-	rt.block_on(async { run(settings).await });
+	rt.block_on(async { run(settings, stats).await });
 }
 
 fn parse_script_conf(conf: &Value) -> Script {
@@ -106,12 +115,13 @@ fn parse_script_conf(conf: &Value) -> Script {
 }
 
 // We have tokio::main at home :)
-async fn run(settings: Settings) {
+async fn run(settings: Settings, stats: Stats) {
 	let addr = SocketAddr::from(([0, 0, 0, 0], settings.port));
 	let listen = TcpListener::bind(addr).await.unwrap();
 
 	let svc = Svc {
 		settings,
+		stats: Arc::new(stats),
 		client_addr: addr,
 	};
 
@@ -129,6 +139,7 @@ async fn run(settings: Settings) {
 #[derive(Clone, Debug)]
 struct Svc {
 	settings: Settings,
+	stats: Arc<Stats>,
 	client_addr: SocketAddr,
 }
 
@@ -140,14 +151,16 @@ impl Service<Request<Incoming>> for Svc {
 	fn call(&self, req: Request<Incoming>) -> Self::Future {
 		let settings = self.settings.clone();
 		let caddr = self.client_addr;
+		let stats = self.stats.clone();
 
-		Box::pin(async move { Ok(Self::handle(settings, caddr, req).await) })
+		Box::pin(async move { Ok(Self::handle(settings, stats, caddr, req).await) })
 	}
 }
 
 impl Svc {
 	async fn handle(
 		settings: Settings,
+		stats: Arc<Stats>,
 		caddr: SocketAddr,
 		req: Request<Incoming>,
 	) -> Response<Full<Bytes>> {
@@ -242,6 +255,13 @@ impl Svc {
 			response = response.header(key, value);
 		}
 
+		let db_req = stats::Request {
+			agent: &uagent,
+			ip_address: &client_addr,
+			script: &script.name,
+			path: &path,
+		};
+
 		println!(
 			"served to [{client_addr}]\n\tscript: {}\n\tpath: {path}\n\tcgi took {}ms. total time {}ms\n\tUA: {uagent}",
 			&script.name,
@@ -249,6 +269,8 @@ impl Svc {
 			start.elapsed().as_millis()
 		);
 
+		stats.log_request(db_req);
+
 		let response_body = cgi_response.body.map(|v| Bytes::from(v)).unwrap_or(Bytes::new());
 		response.body(Full::new(response_body)).unwrap()
 	}
diff --git a/corgi/src/stats.rs b/corgi/src/stats.rs
new file mode 100644
index 0000000..0e3b99a
--- /dev/null
+++ b/corgi/src/stats.rs
@@ -0,0 +1,100 @@
+use std::{
+	net::{IpAddr, SocketAddr},
+	path::PathBuf,
+	sync::Mutex,
+};
+
+use base64::{Engine, prelude::BASE64_STANDARD_NO_PAD};
+use rusqlite::{Connection, OptionalExtension, params};
+use sha2::{Digest, Sha256};
+
+#[derive(Debug)]
+pub struct Stats {
+	conn: Mutex<Connection>,
+}
+
+impl Stats {
+	pub fn new(db_path: PathBuf) -> Self {
+		Self {
+			conn: Mutex::new(Connection::open(db_path).unwrap()),
+		}
+	}
+
+	pub fn create_tables(&self) {
+		let conn = self.conn.lock().unwrap();
+		conn.execute(CREATE_TABLE_AGENT, ()).unwrap();
+		conn.execute(CREATE_TABLE_REQUESTS, ()).unwrap();
+	}
+
+	pub fn log_request(&self, request: Request) {
+		let Request {
+			agent,
+			ip_address,
+			script,
+			path,
+		} = request;
+
+		let mut hasher = Sha256::new();
+		hasher.update(agent.as_bytes());
+		let hash = hasher.finalize();
+		let agent_hash = BASE64_STANDARD_NO_PAD.encode(&hash[..]);
+
+		let conn = self.conn.lock().unwrap();
+
+		// Try to get the agent ID from the hash
+		let maybe_agent_id: Option<i64> = conn
+			.query_row(
+				"SELECT id, hash FROM agents WHERE hash=?1",
+				params![&agent_hash],
+				|row| row.get(0),
+			)
+			.optional()
+			.unwrap();
+
+		// Can't find the agent_id? Push a new entry
+		let agent_id = match maybe_agent_id {
+			Some(aid) => aid,
+			None => {
+				conn.execute(
+					"INSERT INTO agents(hash, agent) VALUES(?1, ?2)",
+					params![&agent_hash, agent],
+				)
+				.unwrap();
+
+				conn.last_insert_rowid()
+			}
+		};
+
+		conn.execute(
+			"INSERT INTO requests(agent_id, ip_address, script, path) VALUES(?1, ?2, ?3, ?4)",
+			params![agent_id, ip_address.to_string(), script, path],
+		)
+		.unwrap();
+	}
+}
+
+pub struct Request<'r> {
+	pub agent: &'r str,
+	pub ip_address: &'r IpAddr,
+	pub script: &'r str,
+	pub path: &'r str,
+}
+
+const CREATE_TABLE_AGENT: &'static str = "\
+	CREATE TABLE IF NOT EXISTS agents(
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		hash TEXT NOT NULL,
+		agent TEXT NOT NULL
+	);";
+
+const CREATE_TABLE_REQUESTS: &'static str = "\
+	CREATE TABLE IF NOT EXISTS requests(
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+		agent_id INTEGER NOT NULL,
+		ip_address TEXT NOT NULL,
+		script TEXT NOT NULL,
+		path TEXT NOT NULL,
+		FOREIGN KEY (agent_id)
+			REFERENCES agents(id)
+	);";
diff --git a/stats_module/Cargo.toml b/stats_module/Cargo.toml
new file mode 100644
index 0000000..ea0f965
--- /dev/null
+++ b/stats_module/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "stats_module"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+name = "stats"
+crate-type = ["cdylib"]
+
+[dependencies]
+rusqlite = { version = "0.34.0", features = ["bundled", "time"] }
+time = "0.3.40"
diff --git a/stats_module/src/lib.rs b/stats_module/src/lib.rs
new file mode 100644
index 0000000..18c0e63
--- /dev/null
+++ b/stats_module/src/lib.rs
@@ -0,0 +1,118 @@
+use std::{
+	ffi::{self, CStr, CString},
+	sync::Mutex,
+};
+
+use rusqlite::{Connection, params};
+use time::{Duration, OffsetDateTime};
+
+#[repr(C)]
+struct ModuleRequest<'req> {
+	headers_len: ffi::c_ulong,
+	headers: &'req [[*const ffi::c_char; 2]],
+	body_len: ffi::c_ulong,
+	body: *const u8,
+}
+
+#[repr(C)]
+struct ModuleResponse {
+	status: ffi::c_ushort,
+	headers_len: ffi::c_ulong,
+	headers: &'static [[*const ffi::c_char; 2]],
+	body_len: ffi::c_ulong,
+	body: *const u8,
+}
+
+const HEADERS: &'static [[*const ffi::c_char; 2]] =
+	&[[c"Content-Type".as_ptr(), c"text/html".as_ptr()]];
+
+#[unsafe(no_mangle)]
+extern "C" fn cgi_handle(req: *const ModuleRequest) -> *const ModuleResponse {
+	let mut ret = String::new();
+
+	// unwrap is bad here
+	let reqref = unsafe { req.as_ref() }.unwrap();
+
+	let mut stats_path = None;
+	for idx in 0..reqref.headers_len {
+		let kvarr = reqref.headers[idx as usize];
+		let k = unsafe { CStr::from_ptr(kvarr[0]) }.to_string_lossy();
+		let v = unsafe { CStr::from_ptr(kvarr[1]) }.to_string_lossy();
+
+		match k.as_ref() {
+			"CORGI_STATS_DB" => stats_path = Some(v.into_owned()),
+			_ => (),
+		}
+	}
+
+	let db = if let Some(path) = stats_path {
+		Connection::open(path).unwrap()
+	} else {
+		return make_error(500, c"could not open stats database");
+	};
+	let now = OffsetDateTime::now_utc();
+	let fifteen_ago = now - Duration::minutes(15);
+
+	let query = "SELECT count(requests.id) AS request_count, agents.agent FROM requests \
+		INNER JOIN agents ON requests.agent_id = agents.id \
+		WHERE requests.timestamp > ?1 \
+		GROUP BY requests.agent_id;";
+
+	let mut prepared = db.prepare(query).unwrap();
+	let mut agents: Vec<(usize, String)> = prepared
+		.query_map(params![fifteen_ago], |row| Ok((row.get(0)?, row.get(1)?)))
+		.unwrap()
+		.map(|r| r.unwrap())
+		.collect();
+
+	agents.sort_by(|a, b| a.0.cmp(&b.0).reverse());
+
+	ret.push_str("<p>In the last fifteen minutes:<br/><code><pre>");
+	ret.push_str("total | req/m | agent\n");
+	for (count, agent) in &agents {
+		ret.push_str(&format!(
+			"{count:<5} | {:<5.1} | {agent}\n",
+			*count as f32 / 15.0
+		));
+	}
+	ret.push_str("</pre></code></p>");
+
+	let body = CString::new(ret).unwrap();
+
+	let resp = ModuleResponse {
+		status: 200,
+		headers_len: 1,
+		headers: HEADERS,
+		body_len: body.as_bytes().len() as u64,
+		body: body.into_raw() as *const u8,
+	};
+
+	let boxed = Box::new(resp);
+	Box::<ModuleResponse>::into_raw(boxed)
+}
+
+fn make_error<S: Into<CString>>(code: u16, msg: S) -> *const ModuleResponse {
+	let body = msg.into();
+
+	let resp = ModuleResponse {
+		status: code,
+		headers_len: 1,
+		headers: HEADERS,
+		body_len: body.as_bytes().len() as u64,
+		body: body.into_raw() as *const u8,
+	};
+
+	let boxed = Box::new(resp);
+	Box::<ModuleResponse>::into_raw(boxed)
+}
+
+#[unsafe(no_mangle)]
+extern "C" fn cgi_cleanup(req: *const ModuleResponse) {
+	// from_raw what we need to here so that these get dropped
+	let boxed = unsafe { Box::from_raw(req as *mut ModuleResponse) };
+	let body = unsafe { CString::from_raw(boxed.body as *mut i8) };
+
+	// Explicitly calling drop here to feel good about myself
+	drop(body);
+	drop(boxed);
+}