feat: add csrf scraping

This commit is contained in:
javalsai 2025-07-02 17:29:22 +02:00
parent 591b30104c
commit f2902f6175
Signed by: javalsai
SSH Key Fingerprint: SHA256:3G83yKhBUWVABVX/vPWH88xnK4+ptMtHkZGCRXD4Mk8
6 changed files with 860 additions and 220 deletions

328
Cargo.lock generated
View File

@ -70,6 +70,12 @@ version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cfg-if"
version = "1.0.1"
@ -144,6 +150,29 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "cssparser"
version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3"
dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "ctrlc"
version = "3.4.7"
@ -177,12 +206,72 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "derive_more"
version = "0.99.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "dtoa"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04"
[[package]]
name = "dtoa-short"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
dependencies = [
"dtoa",
]
[[package]]
name = "ego-tree"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "futf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
dependencies = [
"mac",
"new_debug_unreachable",
]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "getopts"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cba6ae63eb948698e300f645f87c70f76630d505f23b8907cf1e193ee85048c1"
dependencies = [
"unicode-width",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
@ -201,6 +290,18 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "html5ever"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c"
dependencies = [
"log",
"mac",
"markup5ever",
"match_token",
]
[[package]]
name = "httparse"
version = "1.10.1"
@ -246,12 +347,55 @@ dependencies = [
"serde",
]
[[package]]
name = "log"
version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "markup5ever"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18"
dependencies = [
"log",
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
"tendril",
]
[[package]]
name = "match_token"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.30.1"
@ -305,12 +449,76 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_shared",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "proc-macro2"
version = "1.0.95"
@ -329,6 +537,21 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "redox_syscall"
version = "0.5.13"
@ -344,6 +567,40 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scraper"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527e65d9d888567588db4c12da1087598d0f6f8b346cc2c5abc91f05fc2dffe2"
dependencies = [
"cssparser",
"ego-tree",
"getopts",
"html5ever",
"precomputed-hash",
"selectors",
"tendril",
]
[[package]]
name = "selectors"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8"
dependencies = [
"bitflags",
"cssparser",
"derive_more",
"fxhash",
"log",
"new_debug_unreachable",
"phf",
"phf_codegen",
"precomputed-hash",
"servo_arc",
"smallvec",
]
[[package]]
name = "serde"
version = "1.0.219"
@ -373,18 +630,64 @@ dependencies = [
"serde",
]
[[package]]
name = "servo_arc"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "204ea332803bd95a0b60388590d59cf6468ec9becf626e2451f1d26a1d972de4"
dependencies = [
"stable_deref_trait",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "siphasher"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
]
[[package]]
name = "strsim"
version = "0.11.1"
@ -402,6 +705,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "tendril"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
dependencies = [
"futf",
"mac",
"utf-8",
]
[[package]]
name = "thiserror"
version = "2.0.12"
@ -506,6 +820,8 @@ dependencies = [
"dashmap",
"httparse",
"parking_lot",
"paste",
"scraper",
"serde",
"shlex",
"thiserror",
@ -518,6 +834,18 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "unicode-width"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8parse"
version = "0.2.2"

View File

@ -12,6 +12,8 @@ ctrlc = "3"
dashmap = "6"
httparse = "1"
parking_lot = { version = "0", features = ["arc_lock", "serde"] }
paste = "1"
scraper = "0"
serde = { version = "1", features = ["derive"] }
shlex = "1"
thiserror = "2"

View File

@ -49,6 +49,7 @@ pub fn start_client(config: config::Schema) -> anyhow::Result<()> {
Ok(())
}
#[allow(clippy::too_many_lines)]
pub fn handle_daemon_client(mut stream: UnixStream, config: &Arc<config::Schema>) {
let Ok(stream2) = stream.try_clone() else {
return;
@ -65,9 +66,15 @@ pub fn handle_daemon_client(mut stream: UnixStream, config: &Arc<config::Schema>
"help" => {
_ = writeln!(
stream,
"available commands:\n help\n configcount\n confdump\n hosts <list|create|delete> ..."
"available commands (in no particular order):\n help\n configcount\n userdump \n csrfdump\n confdump\n hosts <list|create|delete> ..."
);
}
"userdump" => {
_ = writeln!(stream, "{:#?}", crate::listener::AUTH_MAP);
},
"csrfdump" => {
_ = writeln!(stream, "{:#?}", crate::listener::CSRF_MAP);
},
"configcount" => {
_ = writeln!(
stream,

502
src/listener/mod.rs Normal file
View File

@ -0,0 +1,502 @@
use core::str;
use std::{
io::{self, Read, Write},
net::{Shutdown, TcpListener, TcpStream},
str::Utf8Error,
sync::Arc,
thread,
};
use dashmap::{DashMap, mapref::one::Ref};
use httparse::{Request, Response};
use thiserror::Error;
use crate::{config, constants, utils::headers::HeadersExt};
pub fn start_listener_loop(config_arc: &Arc<config::Schema>, listener: &TcpListener) -> ! {
for stream in listener.incoming() {
match stream {
Ok(mut client) => {
let config_arc = config_arc.clone();
thread::spawn(|| {
if let Err(err) = handle_client(&mut client, config_arc.as_ref()) {
eprintln!("err: invalid req head ({err}), closing...");
_ = client.shutdown(Shutdown::Both);
}
drop(client);
drop(config_arc);
});
}
Err(err) => eprintln!("error with an incoming listener {err:#?}"),
}
}
unreachable!("listener had to be killed unexpectedly");
}
#[derive(Error, Debug)]
pub enum ClientError {
#[error("error reading request head: {0}")]
HeadReadError(io::Error),
#[error("failed to find \"host\" header")]
HostHeaderNotFound,
#[error("requested host {0:?}, but it's not registered")]
HostNotRegistered(String),
#[error("failed to connect to backend: {0}")]
BackendConnectFail(io::Error),
#[error("io error exchanging client and backend: {0}")]
ExchangeIoError(io::Error),
#[error("io error capturing {0} buffer: {0}")]
BufferError(&'static str, io::Error),
#[error("io error writing auth headers: {0}")]
AuthHeadersIoError(io::Error),
#[error("failed to parse response head: {0}")]
ParseResponseHead(httparse::Error),
#[error("attempted to parse an unfinished response")]
UnfinishedResponse,
#[error("error with text encoding: {0}")]
TextEncodingError(Utf8Error),
#[error("error building html csrf finder selector: {0}")]
SelectorBuildError(String),
#[error("couldn't find csrf token in response")]
CsrfNotFound,
#[error("invalid header {0:?}")]
InvalidHeader(&'static str),
}
// fn(req, (adrr, etc)) -> Action
// Action {
// CaptureBody?
// CaptureResponse?
// }
//
// cases:
// * GET login -> get response to access csrf token
// * POST login -> get body to handle authentication
//
// auth headers are just added depending on the ID cookie soo, keep a hashmap of IDs and user
// data??
//
// check authentication first of everything as it changes decisions on everything and give it to fn
// too
// fn(req, Option<AuthData>, (adrr, etc)) -> Action
//
// and also add an action to... hook in the response without even letting backend know (redirect
// ppl out and such), also in no case we need multiple actions. NOPE, POST login should capture
// body and hijack the response, I'll also just make this flexible too.
#[allow(clippy::type_complexity)]
#[derive(Debug)]
enum ProxyAction<'a> {
// combining like this eases a lot the implementation
CaptureBodyAndHijack(String),
// (
// fn(&httparse::Request, &[u8]) -> Result<(), ClientError>,
// fn(&httparse::Request, &mut TcpStream) -> Result<(), ClientError>,
// ),
// ),
CaptureResponse(String),
//fn(&httparse::Request, &[u8]) -> Result<(), ClientError>),
ContWithMaybeAuth(Option<Ref<'a, String, AuthData>>),
}
impl ProxyAction<'_> {
// username - password
#[allow(dead_code)]
type CaptureBodyConclussion = (String, String);
pub fn capture_body(
_req: &httparse::Request,
_id: &str,
_body: &[u8],
) -> Result<Self::CaptureBodyConclussion, ClientError> {
todo!();
}
pub fn hijack(
_req: &httparse::Request,
_body_conclussion: Self::CaptureBodyConclussion,
_stream: &mut TcpStream,
) -> Result<(), ClientError> {
todo!();
}
pub fn capture_response(
_req: &httparse::Request,
_res: &httparse::Response,
id: String,
csrf_keyname: &str,
body: &[u8],
) -> Result<(), ClientError> {
// Find csrf token and add it to list of things
let document = scraper::Html::parse_document(
str::from_utf8(body).map_err(ClientError::TextEncodingError)?,
);
let selector = scraper::Selector::parse(format!("input[name=\"{csrf_keyname}\"]").as_str())
.map_err(|err| ClientError::SelectorBuildError(format!("{err}")))?;
let csrf = document
.select(&selector)
.next()
.and_then(|elem| elem.attr("value"))
.ok_or(ClientError::CsrfNotFound)?;
println!("inserting {id} - {csrf}");
CSRF_MAP.insert(id, csrf.to_string());
Ok(())
}
}
#[derive(Debug)]
pub struct AuthData {
pub username: String,
pub email: String,
pub fullname: Option<String>,
}
impl AuthData {
pub fn write_as_headers(&self, into: &mut impl Write) -> Result<(), ClientError> {
use constants::HTTP_HEADERS_NEWLINE as NL;
let r: Result<_, io::Error> = try {
into.write_all(b"X-WEBAUTH-USER: ")?;
into.write_all(self.username.as_bytes())?;
into.write_all(NL)?;
into.write_all(b"X-WEBAUTH-EMAIL: ")?;
into.write_all(self.email.as_bytes())?;
into.write_all(NL)?;
if let Some(fullname) = self.fullname.as_ref() {
into.write_all(b"X-WEBAUTH-FULLNAME: ")?;
into.write_all(fullname.as_bytes())?;
into.write_all(NL)?;
}
};
r.map_err(ClientError::AuthHeadersIoError)
}
}
fn what_to_do<'a>(
req: &httparse::Request,
id_auth: Option<(String, Option<Ref<'a, String, AuthData>>)>,
) -> ProxyAction<'a> {
let trimedpath = req
.path
.and_then(|path| path.split_once('?').map(|spl| spl.0).or(Some(path)));
// FIXME: auth might not be set up until gitea replies and sets an ID
match (trimedpath, req.method, id_auth) {
(Some("/user/login"), Some("GET"), Some((id, None))) => {
// We gotta get the gitea response to find the csrf token and save that
ProxyAction::CaptureResponse(id)
}
(Some("/user/login"), Some("POST"), Some((id, None))) => {
// Here we gotta get the post body, check csrf, and check passwd
// Then get their account data, add it to their ID and redirect back to home
ProxyAction::CaptureBodyAndHijack(id)
}
(_, _, id_auth) => ProxyAction::ContWithMaybeAuth(id_auth.and_then(|id_auth| id_auth.1)),
}
}
// TODO: might wanna save this one between runtimes
pub static AUTH_MAP: std::sync::LazyLock<DashMap<String, AuthData>> =
std::sync::LazyLock::new(DashMap::new);
// TODO: time expire old keys?
pub static CSRF_MAP: std::sync::LazyLock<DashMap<String, String>> =
std::sync::LazyLock::new(DashMap::new);
#[allow(clippy::too_many_lines)]
fn handle_client(client: &mut TcpStream, config: &config::Schema) -> Result<(), ClientError> {
use ClientError as E;
use ProxyAction as A;
http_bodies::parse_http!(client_req, client, Request, let (req, _, body_over));
let theres_body = req.headers.expect_body();
let host_header = req.headers.header("host").ok_or(E::HostHeaderNotFound)?;
let cookies = req.headers.header("cookie");
// Now find that header and pass everything
let read_hosts = config.hosts.read();
let (addr, id_keyname, csrf_keyname) = read_hosts
.get(host_header.as_ref())
.ok_or_else(|| E::HostNotRegistered(host_header.to_string()))?
.clone();
drop(read_hosts);
// Try to find auth data
let id_auth = if let Some(cookies) = cookies {
if let Some(id_ck) = cookie::Cookie::split_parse(cookies)
.filter_map(Result::ok)
.find(|ck| ck.name() == id_keyname)
{
let id = id_ck.value().to_string();
let val = AUTH_MAP.get(&id);
Some((id, val))
} else {
None
}
} else {
None
};
let action = what_to_do(&req, id_auth);
if let A::CaptureBodyAndHijack(id) = action {
let mut body = body_over.to_vec();
if theres_body {
// not sure if this will append or overwrite, wanna append
io::copy(client, &mut body).map_err(|err| E::BufferError("body", err))?;
}
let c = A::capture_body(&req, id.as_ref(), &body[..])?;
A::hijack(&req, c, client)?;
} else {
let mut stream = TcpStream::connect(addr).map_err(E::BackendConnectFail)?;
// stream.write_all(body_head).map_err(E::ExchangeIoError)?;
stream
.write_all(
format!(
"{} {} HTTP/1.1\r\n",
req.method.unwrap_or("GET"),
req.path.unwrap_or("/")
)
.as_bytes(),
)
.map_err(E::ExchangeIoError)?;
req.headers
.iter()
.filter(|header| !header.name.eq_ignore_ascii_case("accept-encoding"))
.map(|header| {
stream.write_all(
&{
let mut all = Vec::new();
all.extend_from_slice(header.name.as_bytes());
all.extend_from_slice(b": ");
all.extend_from_slice(header.value);
all.extend_from_slice(b"\r\n");
all
}[..],
)
})
.try_fold((), |(), header| header)
.map_err(E::ExchangeIoError)?;
if let A::ContWithMaybeAuth(Some(ref auth)) = action {
auth.write_as_headers(&mut stream)?;
}
stream
.write_all(constants::HTTP_HEADERS_NEWLINE)
.map_err(E::ExchangeIoError)?;
// send our overhead
stream.write_all(body_over).map_err(E::ExchangeIoError)?;
if theres_body {
io::copy(client, &mut stream).map_err(E::ExchangeIoError)?;
}
if let A::CaptureResponse(id) = action {
http_bodies::parse_http!(stream_res, stream, Response, let (resp, _, res_body_over));
let buf = http_bodies::capture_body(
resp.headers,
res_body_over,
&mut stream,
"response buffer",
)?
.unwrap_or_else(Vec::new);
A::capture_response(&req, &resp, id, csrf_keyname.as_ref(), &buf[..])?;
client
.write_all(
format!(
"HTTP/1.1 {} {}\r\n",
resp.code.unwrap_or(200),
resp.reason.unwrap_or("OK")
)
.as_bytes(),
)
.map_err(E::ExchangeIoError)?;
resp.headers
.iter()
.filter(|header| !header.name.eq_ignore_ascii_case("transfer-encoding"))
.map(|header| {
client.write_all(
&{
let mut all = Vec::new();
all.extend_from_slice(header.name.as_bytes());
all.extend_from_slice(b": ");
all.extend_from_slice(header.value);
all.extend_from_slice(b"\r\n");
all
}[..],
)
})
.try_fold((), |(), header| header)
.map_err(E::ExchangeIoError)?;
// FIXME: :grimacing: (append to headers or smth)
client
.write_all(format!("Content-Length: {}\r\n\r\n", buf.len()).as_bytes())
.map_err(E::ExchangeIoError)?;
client
.write_all(constants::HTTP_HEADERS_NEWLINE)
.map_err(E::ExchangeIoError)?;
client.write_all(&buf[..]).map_err(E::ExchangeIoError)?;
} else {
io::copy(&mut stream, client).map_err(E::ExchangeIoError)?;
}
}
Ok(())
}
mod http_bodies {
use crate::{listener::ClientError, utils::headers::HeadersExt};
// pub trait HttpMessage<'h, 'b> {
// fn new(headers: &'h mut [httparse::Header<'b>]) -> Self;
// fn parse(&mut self, buf: &'b [u8]) -> httparse::Result<usize>;
// }
// impl<'h, 'b> HttpMessage<'h, 'b> for httparse::Request<'h, 'b> {
// fn new(headers: &'h mut [httparse::Header<'b>]) -> Self {
// httparse::Request::new(headers)
// }
// fn parse(&mut self, buf: &'b [u8]) -> httparse::Result<usize> {
// self.parse(buf)
// }
// }
// impl<'h, 'b> HttpMessage<'h, 'b> for httparse::Response<'h, 'b> {
// fn new(headers: &'h mut [httparse::Header<'b>]) -> Self {
// httparse::Response::new(headers)
// }
// fn parse(&mut self, buf: &'b [u8]) -> httparse::Result<usize> {
// self.parse(buf)
// }
// }
pub struct ChunckedTransferEncoding<'a, R: Read> {
rem: Vec<u8>,
reader: &'a mut R,
}
// This was a read impl, then tried to go into an interator and ended up being... this
impl<R: Read> ChunckedTransferEncoding<'_, R> {
fn read(&mut self, mut cb: impl FnMut(&[u8])) -> io::Result<()> {
loop {
let Some(busize_after_pos) =
self.rem.windows(b"\r\n".len()).position(|w| w == b"\r\n")
else {
let mut tmp = [0u8; 4096];
let n = self.reader.read(&mut tmp)?;
self.rem.extend_from_slice(&tmp[..n]);
continue;
};
let chunk_len = self.rem[0..busize_after_pos]
.iter()
.try_fold(0usize, |mut acc, i| {
acc <<= 4;
#[allow(clippy::manual_is_ascii_check)]
{
acc += (if (b'0'..=b'9').contains(i) {
i - b'0'
} else if (b'A'..=b'F').contains(i) {
i - b'A'
} else if (b'a'..=b'f').contains(i) {
i - b'f'
} else {
return None;
}) as usize;
}
Some(acc)
})
.ok_or(io::Error::new(
io::ErrorKind::InvalidData,
"invalid chunk size head contents",
))?;
if chunk_len == 0 {
return Ok(());
}
while self.rem.len() < busize_after_pos + chunk_len + (b"\r\n".len() * 2) {
let mut tmp = [0u8; 4096];
let n = self.reader.read(&mut tmp)?;
self.rem.extend_from_slice(&tmp[..n]);
}
cb(&self.rem[(busize_after_pos + b"\r\n".len())
..(busize_after_pos + b"\r\n".len() + chunk_len)]);
self.rem = self.rem
[(busize_after_pos + b"\r\n".len() + chunk_len + b"\r\n".len())..]
.to_vec();
}
}
}
pub fn capture_body(
headers: &[httparse::Header<'_>],
body_over: &[u8],
reader: &mut impl Read,
debug_bufname: &'static str,
) -> Result<Option<Vec<u8>>, ClientError> {
Ok(if let Some(tenc) = headers.header("transfer-encoding") {
if tenc != "chunked" {
return Err(ClientError::InvalidHeader("transfer-encoding"));
}
let mut buf = Vec::new();
(ChunckedTransferEncoding {
rem: body_over.to_vec(),
reader,
})
.read(|chunk| {
buf.extend_from_slice(chunk);
})
.map_err(|err| ClientError::BufferError(debug_bufname, err))?;
Some(buf)
} else if let Some(clen) = headers.header("content-length") {
let n: usize = clen
.parse()
.map_err(|_| ClientError::InvalidHeader("content-length"))?;
let mut buf = Vec::with_capacity(n);
buf.extend_from_slice(body_over);
reader
.read_exact(&mut buf[body_over.len()..])
.map_err(|err| ClientError::BufferError(debug_bufname, err))?;
Some(buf)
} else {
None
})
}
// I hate it too
macro_rules! parse_http {
($trash_state:tt, $stream:expr, $msg_type:ty, $ret:stmt) => {
$crate::listener::http_bodies::parse_http!($trash_state, $stream, $msg_type, 16, {1024 * 8}, $ret);
};
($trash_state:tt, $stream:expr, $msg_type:ty, $headers_len:expr, $buf_size:expr, $ret:stmt) => {
paste::paste! {
let mut [<$trash_state _buf>] = [0u8; $buf_size];
let mut [<$trash_state _pos>] = 0;
let mut [<$trash_state _headers>];
$ret = loop {
[<$trash_state _headers>] = [httparse::EMPTY_HEADER; $headers_len];
let mut res = <$msg_type>::new(&mut [<$trash_state _headers>]);
let n = $stream
.read(&mut [<$trash_state _buf>][[<$trash_state _pos>]..])
.map_err(ClientError::HeadReadError)?;
[<$trash_state _pos>] += n;
if let Ok(httparse::Status::Complete(n)) = res.parse(&[<$trash_state _buf>][0..[<$trash_state _pos>]]) {
break (res, &[<$trash_state _buf>][0..(n - $crate::constants::HTTP_HEADERS_NEWLINE.len())], &[<$trash_state _buf>][n..[<$trash_state _pos>]]);
}
};
}
};
}
use std::io::{self, Read};
pub(crate) use parse_http;
}

View File

@ -1,7 +1,11 @@
//! # Tuxcord Reverse Proxy Header Authenthication
#![allow(incomplete_features)]
#![feature(
anonymous_lifetime_in_impl_trait,
iterator_try_collect,
inherent_associated_types,
stmt_expr_attributes,
read_buf,
rwlock_downgrade,
try_blocks
)]
@ -9,24 +13,20 @@
use std::{
fs::File,
io::{self, Read, Write},
net::{self, Shutdown, TcpListener, TcpStream},
io::{Read, Write},
net::TcpListener,
sync::Arc,
thread,
};
use anyhow::Context;
use clap::Parser;
use dashmap::{DashMap, mapref::one::Ref};
use thiserror::Error;
use crate::utils::headers::HeadersExt;
pub mod args;
pub mod config;
pub mod constants;
#[cfg(feature = "ipc")]
pub mod ipc;
pub mod listener;
pub mod utils;
use args::Args;
@ -66,210 +66,5 @@ fn main() -> anyhow::Result<()> {
#[cfg(feature = "ipc")]
ipc::handle_daemon(config_arc.clone());
for stream in listener.incoming() {
match stream {
Ok(mut client) => {
let config_arc = config_arc.clone();
thread::spawn(|| {
if let Err(err) = handle_client(&mut client, config_arc.as_ref()) {
eprintln!("err: invalid req head ({err}), closing...");
_ = client.shutdown(Shutdown::Both);
}
drop(client);
drop(config_arc);
});
}
Err(err) => eprintln!("error with an incoming listener {err:#?}"),
}
}
unreachable!("listener had to be killed unexpectedly");
}
#[derive(Error, Debug)]
pub enum ClientError {
#[error("error reading request head: {0}")]
HeadReadError(io::Error),
#[error("failed to find \"host\" header")]
HostHeaderNotFound,
#[error("requested host {0:?}, but it's not registered")]
HostNotRegistered(String),
#[error("failed to connect to backend: {0}")]
BackendConnectFail(io::Error),
#[error("io error exchanging client and backend: {0}")]
ExchangeIoError(io::Error),
#[error("io error capturing {0} buffer: {0}")]
BufferError(&'static str, io::Error),
#[error("io error writing auth headers: {0}")]
AuthHeadersIoError(io::Error),
}
// fn(req, (adrr, etc)) -> Action
// Action {
// CaptureBody?
// CaptureResponse?
// }
//
// cases:
// * GET login -> get response to access csrf token
// * POST login -> get body to handle authentication
//
// auth headers are just added depending on the ID cookie soo, keep a hashmap of IDs and user
// data??
//
// check authentication first of everything as it changes decisions on everything and give it to fn
// too
// fn(req, Option<AuthData>, (adrr, etc)) -> Action
//
// and also add an action to... hook in the response without even letting backend know (redirect
// ppl out and such), also in no case we need multiple actions. NOPE, POST login should capture
// body and hijack the response, I'll also just make this flexible too.
#[allow(clippy::type_complexity)]
struct ProxyAction<'a> {
// combining like this eases a lot the implementation
capture_body_and_hijack: Option<(
fn(&httparse::Request, &[u8]) -> Result<(), ClientError>,
fn(&httparse::Request, &mut TcpStream) -> Result<(), ClientError>,
)>,
capture_response: Option<fn(&httparse::Request, &[u8]) -> Result<(), ClientError>>,
auth: Option<Ref<'a, String, AuthData>>,
}
struct AuthData {
pub username: String,
pub email: String,
pub fullname: Option<String>,
}
impl AuthData {
pub fn write_as_headers(&self, into: &mut impl Write) -> Result<(), ClientError> {
use constants::HTTP_HEADERS_NEWLINE as NL;
let r: Result<_, io::Error> = try {
into.write_all(b"X-WEBAUTH-USER: ")?;
into.write_all(self.username.as_bytes())?;
into.write_all(NL)?;
into.write_all(b"X-WEBAUTH-EMAIL: ")?;
into.write_all(self.email.as_bytes())?;
into.write_all(NL)?;
if let Some(fullname) = self.fullname.as_ref() {
into.write_all(b"X-WEBAUTH-FULLNAME: ")?;
into.write_all(fullname.as_bytes())?;
into.write_all(NL)?;
}
};
r.map_err(ClientError::AuthHeadersIoError)
}
}
fn what_to_do<'a>(
req: &httparse::Request,
id_auth: Option<(String, Option<Ref<'a, String, AuthData>>)>,
_backend: (net::SocketAddr, String, String),
) -> ProxyAction<'a> {
let is_authd = id_auth.as_ref().is_some_and(|id_auth| id_auth.1.is_some());
match (req.path, req.method, is_authd) {
(Some("/user/login"), Some("GET"), false) => todo!(),
(Some("/user/login"), Some("POST"), false) => todo!(),
_ => ProxyAction {
capture_body_and_hijack: None,
capture_response: None,
auth: id_auth.and_then(|id_auth| id_auth.1),
},
}
}
// TODO: might wanna save this one between runtimes
static AUTH_MAP: std::sync::LazyLock<DashMap<String, AuthData>> =
std::sync::LazyLock::new(DashMap::new);
static _CSRF_MAP: std::sync::LazyLock<DashMap<String, String>> =
std::sync::LazyLock::new(DashMap::new);
fn handle_client(client: &mut TcpStream, config: &config::Schema) -> Result<(), ClientError> {
use ClientError as E;
use constants::DOUBLE_CRLF;
let mut header_buf = [0u8; 1024 * 8];
let mut read_pos = 0usize;
let mut headers;
let (pos, req) = loop {
headers = [httparse::EMPTY_HEADER; 16];
let mut req = httparse::Request::new(&mut headers);
let n = client
.read(&mut header_buf[read_pos..])
.map_err(E::HeadReadError)?;
read_pos += n;
if let Ok(httparse::Status::Complete(n)) = req.parse(&header_buf[0..read_pos]) {
break (n - DOUBLE_CRLF.len(), req);
}
};
let theres_body = req.headers.has_any(["conten-length", "transfer-encoding"]);
let host_header = req.headers.get("host").ok_or(E::HostHeaderNotFound)?;
let cookies = req.headers.get("cookie");
// Now find that header and pass everything
let read_hosts = config.hosts.read();
let (addr, id_keyname, csrf_keyname) = read_hosts
.get(host_header.as_ref())
.ok_or_else(|| E::HostNotRegistered(host_header.to_string()))?
.clone();
drop(read_hosts);
// Try to find auth data
let id_auth = if let Some(cookies) = cookies {
if let Some(id_ck) = cookie::Cookie::split_parse(cookies)
.filter_map(Result::ok)
.find(|ck| ck.name() == id_keyname)
{
let id = id_ck.value().to_string();
let val = AUTH_MAP.get(&id);
Some((id, val))
} else {
None
}
} else {
None
};
let action = what_to_do(&req, id_auth, (addr, id_keyname, csrf_keyname));
if let Some((cb1, cb2)) = action.capture_body_and_hijack {
let mut body = header_buf[pos..read_pos].to_vec();
if theres_body {
io::copy(client, &mut body).map_err(|err| E::BufferError("body", err))?;
}
cb1(&req, &body[..])?;
cb2(&req, client)?;
} else {
let mut stream = TcpStream::connect(addr).map_err(E::BackendConnectFail)?;
stream
.write_all(&header_buf[0..pos])
.map_err(E::ExchangeIoError)?;
if let Some(auth) = action.auth {
auth.write_as_headers(&mut stream)?;
}
// send our overhead
stream
.write_all(&header_buf[pos..read_pos])
.map_err(E::ExchangeIoError)?;
if theres_body {
io::copy(client, &mut stream).map_err(E::ExchangeIoError)?;
}
if let Some(cb) = action.capture_response {
let mut buf = Vec::new();
stream
.read_to_end(&mut buf)
.map_err(|err| E::BufferError("response", err))?;
cb(&req, &buf[..])?;
client.write_all(&buf[..]).map_err(E::ExchangeIoError)?;
} else {
io::copy(&mut stream, client).map_err(E::ExchangeIoError)?;
}
}
Ok(())
listener::start_listener_loop(&config_arc, &listener);
}

View File

@ -1,7 +1,10 @@
pub mod headers {
use std::borrow::Cow;
use std::borrow::{Borrow, Cow};
pub trait HeadersExt<'a> {
fn expect_body(&self) -> bool {
self.has_any(["conten-length", "transfer-encoding"])
}
fn has_any(&self, headers: impl IntoIterator<Item = &'_ str>) -> bool {
headers.into_iter().any(|h| self.has(h))
}
@ -9,16 +12,19 @@ pub mod headers {
headers.into_iter().all(|h| self.has(h))
}
fn has(&self, header: &str) -> bool;
fn get(&self, header: &str) -> Option<Cow<'a, str>>;
fn header(&self, header: &str) -> Option<Cow<'a, str>>;
}
impl<'a> HeadersExt<'a> for &mut [httparse::Header<'a>] {
impl<'a, T: Borrow<[httparse::Header<'a>]>> HeadersExt<'a> for T {
fn has(&self, header: &str) -> bool {
self.iter().any(|h| h.name.eq_ignore_ascii_case(header))
self.borrow()
.iter()
.any(|h| h.name.eq_ignore_ascii_case(header))
}
fn get(&self, header: &str) -> Option<Cow<'a, str>> {
self.iter()
fn header(&self, header: &str) -> Option<Cow<'a, str>> {
self.borrow()
.iter()
.find(|h| h.name.eq_ignore_ascii_case(header))
.map(|h| String::from_utf8_lossy(h.value))
}