From 532cc431d1c5ca1ffcf429a4ccb94edc7848fe7a Mon Sep 17 00:00:00 2001 From: metamuffin Date: Thu, 30 May 2024 00:09:11 +0200 Subject: rename filters dir --- src/config.rs | 2 +- src/filters/accesslog.rs | 81 --------- src/filters/auth/basic.rs | 68 -------- src/filters/auth/cookie.rs | 182 --------------------- src/filters/auth/login.html | 35 ---- src/filters/auth/mod.rs | 90 ---------- src/filters/error.rs | 32 ---- src/filters/file.rs | 62 ------- src/filters/files.rs | 390 -------------------------------------------- src/filters/hosts.rs | 45 ----- src/filters/mod.rs | 54 ------ src/filters/proxy.rs | 98 ----------- src/main.rs | 4 +- src/modules/accesslog.rs | 81 +++++++++ src/modules/auth/basic.rs | 68 ++++++++ src/modules/auth/cookie.rs | 182 +++++++++++++++++++++ src/modules/auth/login.html | 35 ++++ src/modules/auth/mod.rs | 90 ++++++++++ src/modules/error.rs | 32 ++++ src/modules/file.rs | 62 +++++++ src/modules/files.rs | 390 ++++++++++++++++++++++++++++++++++++++++++++ src/modules/hosts.rs | 45 +++++ src/modules/mod.rs | 54 ++++++ src/modules/proxy.rs | 98 +++++++++++ 24 files changed, 1140 insertions(+), 1140 deletions(-) delete mode 100644 src/filters/accesslog.rs delete mode 100644 src/filters/auth/basic.rs delete mode 100644 src/filters/auth/cookie.rs delete mode 100644 src/filters/auth/login.html delete mode 100644 src/filters/auth/mod.rs delete mode 100644 src/filters/error.rs delete mode 100644 src/filters/file.rs delete mode 100644 src/filters/files.rs delete mode 100644 src/filters/hosts.rs delete mode 100644 src/filters/mod.rs delete mode 100644 src/filters/proxy.rs create mode 100644 src/modules/accesslog.rs create mode 100644 src/modules/auth/basic.rs create mode 100644 src/modules/auth/cookie.rs create mode 100644 src/modules/auth/login.html create mode 100644 src/modules/auth/mod.rs create mode 100644 src/modules/error.rs create mode 100644 src/modules/file.rs create mode 100644 src/modules/files.rs create mode 100644 src/modules/hosts.rs create mode 100644 src/modules/mod.rs create mode 100644 src/modules/proxy.rs diff --git a/src/config.rs b/src/config.rs index a474e1d..ef90f46 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,5 @@ use crate::{ - filters::{Node, NodeKind}, + modules::{Node, NodeKind}, State, }; use anyhow::Context; diff --git a/src/filters/accesslog.rs b/src/filters/accesslog.rs deleted file mode 100644 index 1da6e5d..0000000 --- a/src/filters/accesslog.rs +++ /dev/null @@ -1,81 +0,0 @@ -use super::{Node, NodeContext, NodeKind, NodeRequest, NodeResponse}; -use crate::{config::DynNode, error::ServiceError}; -use futures::Future; -use log::error; -use serde::Deserialize; -use std::{path::PathBuf, pin::Pin, sync::Arc, time::SystemTime}; -use tokio::{ - fs::{File, OpenOptions}, - io::{AsyncWriteExt, BufWriter}, - sync::RwLock, -}; - -pub struct AccessLogKind; - -#[derive(Deserialize)] -struct AccessLogConfig { - file: PathBuf, - #[serde(default)] - flush: bool, - #[serde(default)] - reject_on_fail: bool, - next: DynNode, -} - -struct AccessLog { - config: AccessLogConfig, - file: RwLock>>, -} - -impl NodeKind for AccessLogKind { - fn name(&self) -> &'static str { - "access_log" - } - fn instanciate(&self, config: serde_yaml::Value) -> anyhow::Result> { - Ok(Arc::new(AccessLog { - config: serde_yaml::from_value::(config)?, - file: Default::default(), - })) - } -} - -impl Node for AccessLog { - fn handle<'a>( - &'a self, - context: &'a mut NodeContext, - request: NodeRequest, - ) -> Pin> + Send + Sync + 'a>> { - Box::pin(async move { - let mut g = self.file.write().await; - let log = match g.as_mut() { - Some(r) => r, - None => g.insert(BufWriter::new( - OpenOptions::new() - .append(true) - .create(true) - .open(&self.config.file) - .await?, - )), - }; - - let method = request.method().as_str(); - let time = SystemTime::UNIX_EPOCH.elapsed().unwrap().as_micros(); - let addr = context.addr; - let mut res = log - .write_all(format!("{time}\t{addr}\t{method}\t{:?}\n", request.uri()).as_bytes()) - .await; - - if self.config.flush && res.is_ok() { - res = log.flush().await; - } - - if self.config.reject_on_fail { - res? - } else if let Err(e) = res { - error!("failed to write log: {e:?}") - } - - self.config.next.handle(context, request).await - }) - } -} diff --git a/src/filters/auth/basic.rs b/src/filters/auth/basic.rs deleted file mode 100644 index a7a74c8..0000000 --- a/src/filters/auth/basic.rs +++ /dev/null @@ -1,68 +0,0 @@ -use crate::{ - config::DynNode, - error::ServiceError, - filters::{Node, NodeContext, NodeKind, NodeRequest, NodeResponse}, -}; -use base64::Engine; -use futures::Future; -use http_body_util::{combinators::BoxBody, BodyExt}; -use hyper::{ - header::{HeaderValue, AUTHORIZATION, WWW_AUTHENTICATE}, - Response, StatusCode, -}; -use log::debug; -use serde::Deserialize; -use serde_yaml::Value; -use std::{collections::HashSet, pin::Pin, sync::Arc}; - -pub struct HttpBasicAuthKind; -impl NodeKind for HttpBasicAuthKind { - fn name(&self) -> &'static str { - "http_basic_auth" - } - fn instanciate(&self, config: Value) -> anyhow::Result> { - Ok(Arc::new(serde_yaml::from_value::(config)?)) - } -} - -#[derive(Deserialize)] -pub struct HttpBasicAuth { - realm: String, - valid: HashSet, - next: DynNode, -} - -impl Node for HttpBasicAuth { - fn handle<'a>( - &'a self, - context: &'a mut NodeContext, - request: NodeRequest, - ) -> Pin> + Send + Sync + 'a>> { - Box::pin(async move { - if let Some(auth) = request.headers().get(AUTHORIZATION) { - let k = auth - .as_bytes() - .strip_prefix(b"Basic ") - .ok_or(ServiceError::BadAuth)?; - let k = base64::engine::general_purpose::STANDARD.decode(k)?; - let k = String::from_utf8(k)?; - if self.valid.contains(&k) { - debug!("valid auth"); - return self.next.handle(context, request).await; - } else { - debug!("invalid auth"); - } - } - debug!("unauthorized; sending auth challenge"); - let mut r = Response::new(BoxBody::<_, ServiceError>::new( - String::new().map_err(|_| unreachable!()), - )); - *r.status_mut() = StatusCode::UNAUTHORIZED; - r.headers_mut().insert( - WWW_AUTHENTICATE, - HeaderValue::from_str(&format!("Basic realm=\"{}\"", self.realm)).unwrap(), - ); - Ok(r) - }) - } -} diff --git a/src/filters/auth/cookie.rs b/src/filters/auth/cookie.rs deleted file mode 100644 index 620911d..0000000 --- a/src/filters/auth/cookie.rs +++ /dev/null @@ -1,182 +0,0 @@ -use crate::{ - config::{return_true, DynNode}, - error::ServiceError, - filters::{Node, NodeContext, NodeKind, NodeRequest, NodeResponse}, -}; -use aes_gcm_siv::{ - aead::{Aead, Payload}, - Nonce, -}; -use base64::Engine; -use futures::Future; -use headers::{Cookie, HeaderMapExt}; -use http_body_util::{combinators::BoxBody, BodyExt}; -use hyper::{ - header::{HeaderValue, LOCATION, REFERER, SET_COOKIE}, - Method, Response, StatusCode, -}; -use log::debug; -use percent_encoding::{percent_decode_str, percent_encode, NON_ALPHANUMERIC}; -use rand::random; -use serde::Deserialize; -use serde_yaml::Value; -use std::fmt::Write; -use std::{pin::Pin, sync::Arc, time::SystemTime}; - -use super::Credentials; - -pub struct CookieAuthKind; -impl NodeKind for CookieAuthKind { - fn name(&self) -> &'static str { - "cookie_auth" - } - fn instanciate(&self, config: Value) -> anyhow::Result> { - Ok(Arc::new(serde_yaml::from_value::(config)?)) - } -} - -#[derive(Deserialize)] -pub struct CookieAuth { - users: Credentials, - expire: Option, - #[serde(default = "return_true")] - secure: bool, - next: DynNode, - fail: DynNode, -} - -impl Node for CookieAuth { - fn handle<'a>( - &'a self, - context: &'a mut NodeContext, - request: NodeRequest, - ) -> Pin> + Send + Sync + 'a>> { - Box::pin(async move { - if request.method() == Method::POST && request.uri().path() == "/_gnix_login" { - let referrer = request.headers().get(REFERER).cloned(); - let d = request - .into_body() - .collect() - .await - .map_err(|_| todo!()) - .unwrap(); - let d = String::from_utf8(d.to_bytes().to_vec()).unwrap(); - - // TODO proper parser - let mut username = "user"; - let mut password = ""; - for kv in d.split("&") { - let (key, value) = kv.split_once("=").ok_or(ServiceError::BadAuth)?; - match key { - "username" => username = value, - "password" => password = value, - _ => (), - } - } - let mut r = Response::new(BoxBody::<_, ServiceError>::new( - String::new().clone().map_err(|_| unreachable!()), - )); - *r.status_mut() = StatusCode::FOUND; - debug!("login attempt for {username:?}"); - if self.users.authentificate(username, password) { - debug!("login success"); - let nonce = [(); 12].map(|_| random::()); - let plaintext = unix_seconds().to_le_bytes(); - let mut ciphertext = context - .state - .crypto_key - .encrypt( - Nonce::from_slice(&nonce), - Payload { - msg: &plaintext, - aad: username.as_bytes(), - }, - ) - .unwrap(); - - ciphertext.extend(nonce); - let auth = base64::engine::general_purpose::URL_SAFE.encode(ciphertext); - - let mut cookie_opts = String::new(); - if let Some(e) = self.expire { - write!(cookie_opts, "; Expire={e}").unwrap(); - } - if self.secure { - write!(cookie_opts, "; Secure").unwrap(); - } - - r.headers_mut().append( - SET_COOKIE, - HeaderValue::from_str(&format!( - "gnix_username={}{}", - percent_encode(username.as_bytes(), NON_ALPHANUMERIC), - cookie_opts - )) - .unwrap(), - ); - r.headers_mut().append( - SET_COOKIE, - HeaderValue::from_str(&format!("gnix_auth={}{}", auth, cookie_opts)) - .unwrap(), - ); - } else { - debug!("login fail"); - } - r.headers_mut() - .append(LOCATION, referrer.unwrap_or(HeaderValue::from_static("/"))); - - Ok(r) - } else { - if let Some(cookie) = request.headers().typed_get::() { - if let Some(auth) = cookie.get("gnix_auth") { - let username = - percent_decode_str(cookie.get("gnix_username").unwrap_or("user")) - .decode_utf8()?; - - let auth = base64::engine::general_purpose::URL_SAFE.decode(auth)?; - if auth.len() < 12 { - return Err(ServiceError::BadAuth); - } - let (msg, nonce) = auth.split_at(auth.len() - 12); - let plaintext = context.state.crypto_key.decrypt( - Nonce::from_slice(nonce), - Payload { - msg, - aad: username.as_bytes(), - }, - ); - if let Ok(plaintext) = plaintext { - let created = u64::from_le_bytes(plaintext[0..8].try_into().unwrap()); - - if self - .expire - .map(|e| created + e > unix_seconds()) - .unwrap_or(true) - { - debug!("valid auth for {username:?}"); - return self.next.handle(context, request).await; - } else { - debug!("auth expired"); - } - } else { - debug!("aead invalid"); - } - } else { - debug!("no auth cookie"); - } - } - debug!("unauthorized"); - let mut r = self.fail.handle(context, request).await?; - *r.status_mut() = StatusCode::UNAUTHORIZED; - Ok(r) - } - }) - } -} - -fn unix_seconds() -> u64 { - SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap() - .as_secs() -} diff --git a/src/filters/auth/login.html b/src/filters/auth/login.html deleted file mode 100644 index c7782bd..0000000 --- a/src/filters/auth/login.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - Gnix Login - - - -
-
-
-
-
- -
- - diff --git a/src/filters/auth/mod.rs b/src/filters/auth/mod.rs deleted file mode 100644 index d6e1a35..0000000 --- a/src/filters/auth/mod.rs +++ /dev/null @@ -1,90 +0,0 @@ -use argon2::PasswordVerifier; -use argon2::{ - password_hash::{Encoding, PasswordHashString}, - Algorithm, Argon2, Params, PasswordHash, Version, -}; -use serde::de::MapAccess; -use serde::{ - de::{value, Error, Visitor}, - Deserialize, -}; -use std::{collections::HashMap, fmt, fs::read_to_string}; - -pub mod basic; -pub mod cookie; - -struct Credentials { - wrong_user: PasswordHashString, - hashes: HashMap, -} - -impl Credentials { - fn get(&self, usernamme: &str) -> &PasswordHashString { - self.hashes.get(usernamme).unwrap_or(&self.wrong_user) - } - pub fn authentificate(&self, usernamme: &str, password: &str) -> bool { - let algo = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default()); - let hash = self.get(usernamme); - match hash.algorithm().as_str() { - "argon2id" => algo - .verify_password( - password.as_bytes(), - &PasswordHash::parse(hash.as_str(), hash.encoding()).unwrap(), - ) - .is_ok(), - "never" => false, - _ => false, - } - } -} - -impl<'de> Deserialize<'de> for Credentials { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - struct StringOrMap; - impl<'de> Visitor<'de> for StringOrMap { - type Value = HashMap; - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("credentials map or file path") - } - fn visit_str(self, val: &str) -> Result - where - E: Error, - { - let path = String::deserialize(value::StrDeserializer::new(val))?; - let c = serde_yaml::from_str(&read_to_string(path).map_err(|io| { - serde::de::Error::custom(format!("cannot read creds file: {io:?}")) - })?) - .map_err(|e| serde::de::Error::custom(format!("cannot parse creds file: {e:?}")))?; - Ok(c) - } - fn visit_map(self, val: A) -> Result - where - A: MapAccess<'de>, - { - Ok(HashMap::deserialize(value::MapAccessDeserializer::new( - val, - ))?) - } - } - let k = deserializer.deserialize_any(StringOrMap)?; - Ok(Credentials { - wrong_user: PasswordHashString::parse("$never", Encoding::B64).unwrap(), - hashes: k - .into_iter() - .map(|(k, v)| { - let hash = PasswordHash::parse(&v, Encoding::B64) - .map_err(|e| { - serde::de::Error::custom(format!( - "phc string for user {k:?} is invalid: {e:?}" - )) - })? - .serialize(); - Ok((k, hash)) - }) - .try_collect()?, - }) - } -} diff --git a/src/filters/error.rs b/src/filters/error.rs deleted file mode 100644 index 504802f..0000000 --- a/src/filters/error.rs +++ /dev/null @@ -1,32 +0,0 @@ -use crate::error::ServiceError; - -use super::{Node, NodeContext, NodeKind, NodeRequest, NodeResponse}; -use futures::Future; -use serde::Deserialize; -use serde_yaml::Value; -use std::{pin::Pin, sync::Arc}; - -pub struct ErrorKind; - -#[derive(Deserialize)] -#[serde(transparent)] -struct Error(String); - -impl NodeKind for ErrorKind { - fn name(&self) -> &'static str { - "error" - } - fn instanciate(&self, config: Value) -> anyhow::Result> { - Ok(Arc::new(serde_yaml::from_value::(config)?)) - } -} - -impl Node for Error { - fn handle<'a>( - &'a self, - _context: &'a mut NodeContext, - _request: NodeRequest, - ) -> Pin> + Send + Sync + 'a>> { - Box::pin(async move { Err(ServiceError::Custom(self.0.clone())) }) - } -} diff --git a/src/filters/file.rs b/src/filters/file.rs deleted file mode 100644 index 53c27f4..0000000 --- a/src/filters/file.rs +++ /dev/null @@ -1,62 +0,0 @@ -use super::{Node, NodeContext, NodeKind, NodeRequest, NodeResponse}; -use crate::error::ServiceError; -use futures::Future; -use http_body_util::{combinators::BoxBody, BodyExt}; -use hyper::{ - header::{HeaderValue, CONTENT_TYPE}, - Response, -}; -use serde::Deserialize; -use serde_yaml::Value; -use std::{fs::read_to_string, path::PathBuf, pin::Pin, sync::Arc}; - -pub struct FileKind; - -#[derive(Debug, Deserialize)] -struct FileConfig { - path: Option, - content: Option, - r#type: Option, -} - -#[derive(Debug, Deserialize)] -struct File { - content: String, - r#type: String, -} - -impl NodeKind for FileKind { - fn name(&self) -> &'static str { - "file" - } - fn instanciate(&self, config: Value) -> anyhow::Result> { - let conf = serde_yaml::from_value::(config)?; - Ok(Arc::new(File { - content: conf - .content - .or(conf - .path - .map(|p| Ok::<_, ServiceError>(read_to_string(p)?)) - .transpose()?) - .unwrap_or_default(), - r#type: conf.r#type.unwrap_or("text/html".to_string()), // TODO infer mime from ext - })) - } -} - -impl Node for File { - fn handle<'a>( - &'a self, - _context: &'a mut NodeContext, - _request: NodeRequest, - ) -> Pin> + Send + Sync + 'a>> { - Box::pin(async move { - let mut r = Response::new(BoxBody::<_, ServiceError>::new( - self.content.clone().map_err(|_| unreachable!()), - )); - r.headers_mut() - .insert(CONTENT_TYPE, HeaderValue::from_str(&self.r#type).unwrap()); - Ok(r) - }) - } -} diff --git a/src/filters/files.rs b/src/filters/files.rs deleted file mode 100644 index 4fdd5cd..0000000 --- a/src/filters/files.rs +++ /dev/null @@ -1,390 +0,0 @@ -use super::{Node, NodeContext, NodeKind, NodeRequest, NodeResponse}; -use crate::{config::return_true, ServiceError}; -use bytes::{Bytes, BytesMut}; -use futures::Future; -use futures_util::{future, future::Either, ready, stream, FutureExt, Stream, StreamExt}; -use headers::{ - AcceptRanges, CacheControl, ContentLength, ContentRange, ContentType, HeaderMapExt, - LastModified, -}; -use http_body_util::{combinators::BoxBody, BodyExt, StreamBody}; -use humansize::FormatSizeOptions; -use hyper::{ - body::Frame, - header::{CONTENT_TYPE, LOCATION}, - http::HeaderValue, - Response, StatusCode, -}; -use log::debug; -use markup::Render; -use percent_encoding::percent_decode_str; -use serde::Deserialize; -use serde_yaml::Value; -use std::{ - fs::Metadata, - io, - ops::Range, - path::{Path, PathBuf}, - pin::Pin, - sync::Arc, - task::Poll, -}; -use tokio::{ - fs::{read_to_string, File}, - io::AsyncSeekExt, -}; -use tokio_util::io::poll_read_buf; - -pub struct FilesKind; -pub struct FileKind; - -#[derive(Debug, Deserialize)] -struct Files { - root: PathBuf, - #[serde(default)] - index: bool, - #[serde(default = "return_true")] - last_modified: bool, - // #[serde(default = "return_true")] - // etag: bool, - #[serde(default)] - cache: CacheMode, -} -#[derive(Debug, Default, Deserialize)] -#[serde(rename_all = "snake_case")] -enum CacheMode { - #[default] - Public, - Private, - NoStore, -} - -impl NodeKind for FilesKind { - fn name(&self) -> &'static str { - "files" - } - fn instanciate(&self, config: Value) -> anyhow::Result> { - Ok(Arc::new(serde_yaml::from_value::(config)?)) - } -} - -impl Node for Files { - fn handle<'a>( - &'a self, - _context: &'a mut NodeContext, - request: NodeRequest, - ) -> Pin> + Send + Sync + 'a>> { - Box::pin(async move { - let rpath = request.uri().path(); - - let mut path = self.root.clone(); - let mut user_path_depth = 0; - for seg in rpath.split("/") { - let seg = percent_decode_str(seg).decode_utf8()?; - - if seg == "" || seg == "." { - continue; - } - - if seg == ".." { - if user_path_depth <= 0 { - return Err(ServiceError::BadPath); - } - path.pop(); - user_path_depth -= 1; - } else { - path.push(seg.as_ref()); - user_path_depth += 1; - } - } - if !path.exists() { - return Err(ServiceError::NotFound); - } - - let metadata = path.metadata()?; - - if metadata.file_type().is_dir() { - debug!("sending index for {path:?}"); - if let Ok(indexhtml) = read_to_string(path.join("index.html")).await { - return Ok(html_string_response(indexhtml)); - } - - if self.index { - if !rpath.ends_with("/") { - let mut r = Response::new(String::new()); - *r.status_mut() = StatusCode::FOUND; - r.headers_mut().insert( - LOCATION, - HeaderValue::from_str(&format!("{}/", rpath)) - .map_err(|_| ServiceError::Other)?, - ); - return Ok(r.map(|b| b.map_err(|e| match e {}).boxed())); - } - - return index(&path, rpath.to_string()) - .await - .map(html_string_response); - } else { - return Err(ServiceError::NotFound); - } - } - - let modified = metadata.modified()?; - - let not_modified = if self.last_modified { - request - .headers() - .typed_get::() - .map(|if_modified_since| { - Ok::<_, ServiceError>(!if_modified_since.is_modified(modified)) - }) - .transpose()? - .unwrap_or_default() - } else { - false - }; - - // let etag = ETag::from_str(&calc_etag(modified)).map_err(|_| ServiceError::Other)?; - // let etag_matches = if self.etag { - // request.headers() - // .typed_get::() - // .map(|if_none_match| if_none_match.precondition_passes(&etag)) - // .unwrap_or_default() - // } else { - // false - // }; - - let range = request.headers().typed_get::(); - let range = bytes_range(range, metadata.len())?; - - debug!("sending file {path:?}"); - let file = File::open(path.clone()).await?; - - // let skip_body = not_modified || etag_matches; - let skip_body = not_modified; - let mut r = if skip_body { - Response::new("".to_string()).map(|b| b.map_err(|e| match e {}).boxed()) - } else { - Response::new(BoxBody::new(StreamBody::new( - StreamBody::new(file_stream(file, 4096, range.clone())) - .map(|e| e.map(|e| Frame::data(e)).map_err(ServiceError::Io)), - ))) - }; - - if !skip_body { - if range.end - range.start != metadata.len() { - *r.status_mut() = StatusCode::PARTIAL_CONTENT; - r.headers_mut().typed_insert( - ContentRange::bytes(range.clone(), metadata.len()) - .expect("valid ContentRange"), - ); - } - } - // if not_modified || etag_matches { - if not_modified { - *r.status_mut() = StatusCode::NOT_MODIFIED; - } - - r.headers_mut().typed_insert(AcceptRanges::bytes()); - r.headers_mut() - .typed_insert(ContentLength(range.end - range.start)); - - let mime = mime_guess::from_path(path).first_or_octet_stream(); - r.headers_mut().typed_insert(ContentType::from(mime)); - - r.headers_mut().typed_insert(match self.cache { - CacheMode::Public => CacheControl::new().with_public(), - CacheMode::Private => CacheControl::new().with_private(), - CacheMode::NoStore => CacheControl::new().with_no_store(), - }); - - // if self.etag { - // r.headers_mut().typed_insert(etag); - // } - if self.last_modified { - r.headers_mut().typed_insert(LastModified::from(modified)); - } - - Ok(r) - }) - } -} - -// Adapted from warp (https://github.com/seanmonstar/warp/blob/master/src/filters/fs.rs). Thanks! -fn file_stream( - mut file: File, - buf_size: usize, - range: Range, -) -> impl Stream> + Send { - use std::io::SeekFrom; - - let seek = async move { - if range.start != 0 { - file.seek(SeekFrom::Start(range.start)).await?; - } - Ok(file) - }; - - seek.into_stream() - .map(move |result| { - let mut buf = BytesMut::new(); - let mut len = range.end - range.start; - let mut f = match result { - Ok(f) => f, - Err(f) => return Either::Left(stream::once(future::err(f))), - }; - - Either::Right(stream::poll_fn(move |cx| { - if len == 0 { - return Poll::Ready(None); - } - reserve_at_least(&mut buf, buf_size); - - let n = match ready!(poll_read_buf(Pin::new(&mut f), cx, &mut buf)) { - Ok(n) => n as u64, - Err(err) => { - debug!("file read error: {}", err); - return Poll::Ready(Some(Err(err))); - } - }; - - if n == 0 { - debug!("file read found EOF before expected length"); - return Poll::Ready(None); - } - - let mut chunk = buf.split().freeze(); - if n > len { - chunk = chunk.split_to(len as usize); - len = 0; - } else { - len -= n; - } - - Poll::Ready(Some(Ok(chunk))) - })) - }) - .flatten() -} - -// Also adapted from warp -fn bytes_range(range: Option, max_len: u64) -> Result, ServiceError> { - use std::ops::Bound; - - let range = if let Some(range) = range { - range - } else { - return Ok(0..max_len); - }; - - let ret = range - .satisfiable_ranges(max_len) - .map(|(start, end)| { - let start = match start { - Bound::Unbounded => 0, - Bound::Included(s) => s, - Bound::Excluded(s) => s + 1, - }; - - let end = match end { - Bound::Unbounded => max_len, - Bound::Included(s) => { - // For the special case where s == the file size - if s == max_len { - s - } else { - s + 1 - } - } - Bound::Excluded(s) => s, - }; - - if start < end && end <= max_len { - Ok(start..end) - } else { - Err(ServiceError::BadRange) - } - }) - .next() - .unwrap_or(Ok(0..max_len)); - ret -} - -fn reserve_at_least(buf: &mut BytesMut, cap: usize) { - if buf.capacity() - buf.len() < cap { - buf.reserve(cap); - } -} - -async fn index(path: &Path, rpath: String) -> Result { - let files = path - .read_dir()? - .map(|e| e.and_then(|e| Ok((e.file_name().into_string().unwrap(), e.metadata()?)))) - .filter(|e| e.as_ref().map(|(e, _)| !e.starts_with(".")).unwrap_or(true)) - .collect::, _>>()?; - let banner = read_to_string(path.join("index.banner.html")).await.ok(); - let mut s = String::new(); - IndexTemplate { - files, - banner, - path: rpath, - } - .render(&mut s) - .unwrap(); - Ok(s) -} - -fn html_string_response(s: String) -> hyper::Response> { - let mut r = Response::new(s); - r.headers_mut() - .insert(CONTENT_TYPE, HeaderValue::from_static("text/html")); - r.map(|b| b.map_err(|e| match e {}).boxed()) -} - -markup::define! { - IndexTemplate(path: String, banner: Option, files: Vec<(String, Metadata)>) { - @markup::doctype() - html { - head { - meta[charset="UTF-8"]; - title { "Index of " @path } - } - body { - @if let Some(banner) = banner { - @markup::raw(banner) - } else { - h1 { "Index of " @path } - } - hr; - table { - @if path != "/" { - tr { td { b { a[href=".."] { "../" } } } } - } - @for (name, meta) in files { tr { - td { a[href=name] { - @name - @if meta.file_type().is_dir() { "/" } - } } - td { - @if meta.file_type().is_dir() { - i { "directory" } - } else { - @humansize::format_size(meta.len(), FormatSizeOptions::default()) - } - } - } } - } - hr; - footer { sub { "served by " a[href="https://codeberg.org/metamuffin/gnix"] { "gnix" } } } - } - } - } -} - -// fn calc_etag(s: SystemTime) -> String { -// // TODO: make this not change after server restart but still unguessable -// let mut hasher = DefaultHasher::new(); -// s.hash(&mut hasher); -// let hash = hasher.finish(); -// hex::encode(hash.to_le_bytes()) -// } diff --git a/src/filters/hosts.rs b/src/filters/hosts.rs deleted file mode 100644 index 286d478..0000000 --- a/src/filters/hosts.rs +++ /dev/null @@ -1,45 +0,0 @@ -use super::{Node, NodeContext, NodeKind, NodeRequest, NodeResponse}; -use crate::{config::DynNode, error::ServiceError}; -use futures::Future; -use hyper::header::HOST; -use serde::Deserialize; -use serde_yaml::Value; -use std::{collections::HashMap, pin::Pin, sync::Arc}; - -#[derive(Deserialize)] -#[serde(transparent)] -struct Hosts(HashMap); - -pub struct HostsKind; -impl NodeKind for HostsKind { - fn name(&self) -> &'static str { - "hosts" - } - fn instanciate(&self, config: Value) -> anyhow::Result> { - Ok(Arc::new(serde_yaml::from_value::(config)?)) - } -} -impl Node for Hosts { - fn handle<'a>( - &'a self, - context: &'a mut NodeContext, - request: NodeRequest, - ) -> Pin> + Send + Sync + 'a>> { - Box::pin(async move { - let host = request - .headers() - .get(HOST) - .and_then(|e| e.to_str().ok()) - .ok_or(ServiceError::NoHost)?; - - let host = remove_port(&host); - let node = self.0.get(host).ok_or(ServiceError::UnknownHost)?; - - node.handle(context, request).await - }) - } -} - -pub fn remove_port(s: &str) -> &str { - s.split_once(":").map(|(s, _)| s).unwrap_or(s) -} diff --git a/src/filters/mod.rs b/src/filters/mod.rs deleted file mode 100644 index 2bee8e3..0000000 --- a/src/filters/mod.rs +++ /dev/null @@ -1,54 +0,0 @@ -use crate::error::ServiceError; -use crate::State; -use accesslog::AccessLogKind; -use auth::{basic::HttpBasicAuthKind, cookie::CookieAuthKind}; -use bytes::Bytes; -use error::ErrorKind; -use file::FileKind; -use files::FilesKind; -use futures::Future; -use hosts::HostsKind; -use http_body_util::combinators::BoxBody; -use hyper::{body::Incoming, Request, Response}; -use proxy::ProxyKind; -use serde_yaml::Value; -use std::{net::SocketAddr, pin::Pin, sync::Arc}; - -pub mod accesslog; -pub mod auth; -pub mod error; -pub mod file; -pub mod files; -pub mod hosts; -pub mod proxy; - -pub type NodeRequest = Request; -pub type NodeResponse = Response>; - -pub static MODULES: &'static [&'static dyn NodeKind] = &[ - &HttpBasicAuthKind, - &CookieAuthKind, - &ProxyKind, - &HostsKind, - &FilesKind, - &FileKind, - &AccessLogKind, - &ErrorKind, -]; - -pub struct NodeContext { - pub state: Arc, - pub addr: SocketAddr, -} - -pub trait NodeKind: Send + Sync + 'static { - fn name(&self) -> &'static str; - fn instanciate(&self, config: Value) -> anyhow::Result>; -} -pub trait Node: Send + Sync + 'static { - fn handle<'a>( - &'a self, - context: &'a mut NodeContext, - request: NodeRequest, - ) -> Pin> + Send + Sync + 'a>>; -} diff --git a/src/filters/proxy.rs b/src/filters/proxy.rs deleted file mode 100644 index ce72f65..0000000 --- a/src/filters/proxy.rs +++ /dev/null @@ -1,98 +0,0 @@ -use super::{Node, NodeContext, NodeKind, NodeRequest, NodeResponse}; -use crate::{helper::TokioIo, ServiceError}; -use futures::Future; -use http_body_util::BodyExt; -use hyper::{http::HeaderValue, upgrade::OnUpgrade, StatusCode}; -use log::{debug, warn}; -use serde::Deserialize; -use serde_yaml::Value; -use std::{net::SocketAddr, pin::Pin, sync::Arc}; -use tokio::net::TcpStream; - -#[derive(Default)] -pub struct ProxyKind; - -#[derive(Debug, Deserialize)] -struct Proxy { - backend: SocketAddr, -} - -impl NodeKind for ProxyKind { - fn name(&self) -> &'static str { - "proxy" - } - fn instanciate(&self, config: Value) -> anyhow::Result> { - Ok(Arc::new(serde_yaml::from_value::(config)?)) - } -} -impl Node for Proxy { - fn handle<'a>( - &'a self, - context: &'a mut NodeContext, - mut request: NodeRequest, - ) -> Pin> + Send + Sync + 'a>> { - Box::pin(async move { - request.headers_mut().insert( - "x-real-ip", - HeaderValue::from_str(&format!("{}", context.addr.ip())).unwrap(), - ); - - let on_upgrade_downstream = request.extensions_mut().remove::(); - - let _limit_guard = context.state.l_outgoing.try_acquire()?; - debug!("\tforwarding to {}", self.backend); - let mut resp = { - let client_stream = TokioIo( - TcpStream::connect(self.backend) - .await - .map_err(|_| ServiceError::CantConnect)?, - ); - - let (mut sender, conn) = hyper::client::conn::http1::handshake(client_stream) - .await - .map_err(ServiceError::Hyper)?; - tokio::task::spawn(async move { - if let Err(err) = conn.with_upgrades().await { - warn!("connection failed: {:?}", err); - } - }); - sender - .send_request(request) - .await - .map_err(ServiceError::Hyper)? - }; - - if resp.status() == StatusCode::SWITCHING_PROTOCOLS { - let on_upgrade_upstream = resp - .extensions_mut() - .remove::() - .ok_or(ServiceError::UpgradeFailed)?; - let on_upgrade_downstream = - on_upgrade_downstream.ok_or(ServiceError::UpgradeFailed)?; - tokio::task::spawn(async move { - debug!("about to upgrade connection"); - match (on_upgrade_upstream.await, on_upgrade_downstream.await) { - (Ok(upgraded_upstream), Ok(upgraded_downstream)) => { - debug!("upgrade successful"); - match tokio::io::copy_bidirectional( - &mut TokioIo(upgraded_downstream), - &mut TokioIo(upgraded_upstream), - ) - .await - { - Ok((from_client, from_server)) => { - debug!("proxy socket terminated: {from_server} sent, {from_client} received") - } - Err(e) => warn!("proxy socket error: {e}"), - } - } - (a, b) => warn!("upgrade error: upstream={a:?} downstream={b:?}"), - } - }); - } - - let resp = resp.map(|b| b.map_err(ServiceError::Hyper).boxed()); - Ok(resp) - }) - } -} diff --git a/src/main.rs b/src/main.rs index 045440e..c63c411 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,14 +5,14 @@ pub mod config; pub mod error; -pub mod filters; +pub mod modules; pub mod helper; use aes_gcm_siv::{aead::generic_array::GenericArray, Aes256GcmSiv, KeyInit}; use anyhow::{anyhow, Context, Result}; use config::{setup_file_watch, Config, NODE_KINDS}; use error::ServiceError; -use filters::{NodeContext, MODULES}; +use modules::{NodeContext, MODULES}; use futures::future::try_join_all; use helper::TokioIo; use http_body_util::{combinators::BoxBody, BodyExt}; diff --git a/src/modules/accesslog.rs b/src/modules/accesslog.rs new file mode 100644 index 0000000..1da6e5d --- /dev/null +++ b/src/modules/accesslog.rs @@ -0,0 +1,81 @@ +use super::{Node, NodeContext, NodeKind, NodeRequest, NodeResponse}; +use crate::{config::DynNode, error::ServiceError}; +use futures::Future; +use log::error; +use serde::Deserialize; +use std::{path::PathBuf, pin::Pin, sync::Arc, time::SystemTime}; +use tokio::{ + fs::{File, OpenOptions}, + io::{AsyncWriteExt, BufWriter}, + sync::RwLock, +}; + +pub struct AccessLogKind; + +#[derive(Deserialize)] +struct AccessLogConfig { + file: PathBuf, + #[serde(default)] + flush: bool, + #[serde(default)] + reject_on_fail: bool, + next: DynNode, +} + +struct AccessLog { + config: AccessLogConfig, + file: RwLock>>, +} + +impl NodeKind for AccessLogKind { + fn name(&self) -> &'static str { + "access_log" + } + fn instanciate(&self, config: serde_yaml::Value) -> anyhow::Result> { + Ok(Arc::new(AccessLog { + config: serde_yaml::from_value::(config)?, + file: Default::default(), + })) + } +} + +impl Node for AccessLog { + fn handle<'a>( + &'a self, + context: &'a mut NodeContext, + request: NodeRequest, + ) -> Pin> + Send + Sync + 'a>> { + Box::pin(async move { + let mut g = self.file.write().await; + let log = match g.as_mut() { + Some(r) => r, + None => g.insert(BufWriter::new( + OpenOptions::new() + .append(true) + .create(true) + .open(&self.config.file) + .await?, + )), + }; + + let method = request.method().as_str(); + let time = SystemTime::UNIX_EPOCH.elapsed().unwrap().as_micros(); + let addr = context.addr; + let mut res = log + .write_all(format!("{time}\t{addr}\t{method}\t{:?}\n", request.uri()).as_bytes()) + .await; + + if self.config.flush && res.is_ok() { + res = log.flush().await; + } + + if self.config.reject_on_fail { + res? + } else if let Err(e) = res { + error!("failed to write log: {e:?}") + } + + self.config.next.handle(context, request).await + }) + } +} diff --git a/src/modules/auth/basic.rs b/src/modules/auth/basic.rs new file mode 100644 index 0000000..08870c4 --- /dev/null +++ b/src/modules/auth/basic.rs @@ -0,0 +1,68 @@ +use crate::{ + config::DynNode, + error::ServiceError, + modules::{Node, NodeContext, NodeKind, NodeRequest, NodeResponse}, +}; +use base64::Engine; +use futures::Future; +use http_body_util::{combinators::BoxBody, BodyExt}; +use hyper::{ + header::{HeaderValue, AUTHORIZATION, WWW_AUTHENTICATE}, + Response, StatusCode, +}; +use log::debug; +use serde::Deserialize; +use serde_yaml::Value; +use std::{collections::HashSet, pin::Pin, sync::Arc}; + +pub struct HttpBasicAuthKind; +impl NodeKind for HttpBasicAuthKind { + fn name(&self) -> &'static str { + "http_basic_auth" + } + fn instanciate(&self, config: Value) -> anyhow::Result> { + Ok(Arc::new(serde_yaml::from_value::(config)?)) + } +} + +#[derive(Deserialize)] +pub struct HttpBasicAuth { + realm: String, + valid: HashSet, + next: DynNode, +} + +impl Node for HttpBasicAuth { + fn handle<'a>( + &'a self, + context: &'a mut NodeContext, + request: NodeRequest, + ) -> Pin> + Send + Sync + 'a>> { + Box::pin(async move { + if let Some(auth) = request.headers().get(AUTHORIZATION) { + let k = auth + .as_bytes() + .strip_prefix(b"Basic ") + .ok_or(ServiceError::BadAuth)?; + let k = base64::engine::general_purpose::STANDARD.decode(k)?; + let k = String::from_utf8(k)?; + if self.valid.contains(&k) { + debug!("valid auth"); + return self.next.handle(context, request).await; + } else { + debug!("invalid auth"); + } + } + debug!("unauthorized; sending auth challenge"); + let mut r = Response::new(BoxBody::<_, ServiceError>::new( + String::new().map_err(|_| unreachable!()), + )); + *r.status_mut() = StatusCode::UNAUTHORIZED; + r.headers_mut().insert( + WWW_AUTHENTICATE, + HeaderValue::from_str(&format!("Basic realm=\"{}\"", self.realm)).unwrap(), + ); + Ok(r) + }) + } +} diff --git a/src/modules/auth/cookie.rs b/src/modules/auth/cookie.rs new file mode 100644 index 0000000..4615938 --- /dev/null +++ b/src/modules/auth/cookie.rs @@ -0,0 +1,182 @@ +use crate::{ + config::{return_true, DynNode}, + error::ServiceError, + modules::{Node, NodeContext, NodeKind, NodeRequest, NodeResponse}, +}; +use aes_gcm_siv::{ + aead::{Aead, Payload}, + Nonce, +}; +use base64::Engine; +use futures::Future; +use headers::{Cookie, HeaderMapExt}; +use http_body_util::{combinators::BoxBody, BodyExt}; +use hyper::{ + header::{HeaderValue, LOCATION, REFERER, SET_COOKIE}, + Method, Response, StatusCode, +}; +use log::debug; +use percent_encoding::{percent_decode_str, percent_encode, NON_ALPHANUMERIC}; +use rand::random; +use serde::Deserialize; +use serde_yaml::Value; +use std::fmt::Write; +use std::{pin::Pin, sync::Arc, time::SystemTime}; + +use super::Credentials; + +pub struct CookieAuthKind; +impl NodeKind for CookieAuthKind { + fn name(&self) -> &'static str { + "cookie_auth" + } + fn instanciate(&self, config: Value) -> anyhow::Result> { + Ok(Arc::new(serde_yaml::from_value::(config)?)) + } +} + +#[derive(Deserialize)] +pub struct CookieAuth { + users: Credentials, + expire: Option, + #[serde(default = "return_true")] + secure: bool, + next: DynNode, + fail: DynNode, +} + +impl Node for CookieAuth { + fn handle<'a>( + &'a self, + context: &'a mut NodeContext, + request: NodeRequest, + ) -> Pin> + Send + Sync + 'a>> { + Box::pin(async move { + if request.method() == Method::POST && request.uri().path() == "/_gnix_login" { + let referrer = request.headers().get(REFERER).cloned(); + let d = request + .into_body() + .collect() + .await + .map_err(|_| todo!()) + .unwrap(); + let d = String::from_utf8(d.to_bytes().to_vec()).unwrap(); + + // TODO proper parser + let mut username = "user"; + let mut password = ""; + for kv in d.split("&") { + let (key, value) = kv.split_once("=").ok_or(ServiceError::BadAuth)?; + match key { + "username" => username = value, + "password" => password = value, + _ => (), + } + } + let mut r = Response::new(BoxBody::<_, ServiceError>::new( + String::new().clone().map_err(|_| unreachable!()), + )); + *r.status_mut() = StatusCode::FOUND; + debug!("login attempt for {username:?}"); + if self.users.authentificate(username, password) { + debug!("login success"); + let nonce = [(); 12].map(|_| random::()); + let plaintext = unix_seconds().to_le_bytes(); + let mut ciphertext = context + .state + .crypto_key + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: &plaintext, + aad: username.as_bytes(), + }, + ) + .unwrap(); + + ciphertext.extend(nonce); + let auth = base64::engine::general_purpose::URL_SAFE.encode(ciphertext); + + let mut cookie_opts = String::new(); + if let Some(e) = self.expire { + write!(cookie_opts, "; Expire={e}").unwrap(); + } + if self.secure { + write!(cookie_opts, "; Secure").unwrap(); + } + + r.headers_mut().append( + SET_COOKIE, + HeaderValue::from_str(&format!( + "gnix_username={}{}", + percent_encode(username.as_bytes(), NON_ALPHANUMERIC), + cookie_opts + )) + .unwrap(), + ); + r.headers_mut().append( + SET_COOKIE, + HeaderValue::from_str(&format!("gnix_auth={}{}", auth, cookie_opts)) + .unwrap(), + ); + } else { + debug!("login fail"); + } + r.headers_mut() + .append(LOCATION, referrer.unwrap_or(HeaderValue::from_static("/"))); + + Ok(r) + } else { + if let Some(cookie) = request.headers().typed_get::() { + if let Some(auth) = cookie.get("gnix_auth") { + let username = + percent_decode_str(cookie.get("gnix_username").unwrap_or("user")) + .decode_utf8()?; + + let auth = base64::engine::general_purpose::URL_SAFE.decode(auth)?; + if auth.len() < 12 { + return Err(ServiceError::BadAuth); + } + let (msg, nonce) = auth.split_at(auth.len() - 12); + let plaintext = context.state.crypto_key.decrypt( + Nonce::from_slice(nonce), + Payload { + msg, + aad: username.as_bytes(), + }, + ); + if let Ok(plaintext) = plaintext { + let created = u64::from_le_bytes(plaintext[0..8].try_into().unwrap()); + + if self + .expire + .map(|e| created + e > unix_seconds()) + .unwrap_or(true) + { + debug!("valid auth for {username:?}"); + return self.next.handle(context, request).await; + } else { + debug!("auth expired"); + } + } else { + debug!("aead invalid"); + } + } else { + debug!("no auth cookie"); + } + } + debug!("unauthorized"); + let mut r = self.fail.handle(context, request).await?; + *r.status_mut() = StatusCode::UNAUTHORIZED; + Ok(r) + } + }) + } +} + +fn unix_seconds() -> u64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() +} diff --git a/src/modules/auth/login.html b/src/modules/auth/login.html new file mode 100644 index 0000000..c7782bd --- /dev/null +++ b/src/modules/auth/login.html @@ -0,0 +1,35 @@ + + + + + + Gnix Login + + + +
+
+
+
+
+ +
+ + diff --git a/src/modules/auth/mod.rs b/src/modules/auth/mod.rs new file mode 100644 index 0000000..d6e1a35 --- /dev/null +++ b/src/modules/auth/mod.rs @@ -0,0 +1,90 @@ +use argon2::PasswordVerifier; +use argon2::{ + password_hash::{Encoding, PasswordHashString}, + Algorithm, Argon2, Params, PasswordHash, Version, +}; +use serde::de::MapAccess; +use serde::{ + de::{value, Error, Visitor}, + Deserialize, +}; +use std::{collections::HashMap, fmt, fs::read_to_string}; + +pub mod basic; +pub mod cookie; + +struct Credentials { + wrong_user: PasswordHashString, + hashes: HashMap, +} + +impl Credentials { + fn get(&self, usernamme: &str) -> &PasswordHashString { + self.hashes.get(usernamme).unwrap_or(&self.wrong_user) + } + pub fn authentificate(&self, usernamme: &str, password: &str) -> bool { + let algo = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default()); + let hash = self.get(usernamme); + match hash.algorithm().as_str() { + "argon2id" => algo + .verify_password( + password.as_bytes(), + &PasswordHash::parse(hash.as_str(), hash.encoding()).unwrap(), + ) + .is_ok(), + "never" => false, + _ => false, + } + } +} + +impl<'de> Deserialize<'de> for Credentials { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct StringOrMap; + impl<'de> Visitor<'de> for StringOrMap { + type Value = HashMap; + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("credentials map or file path") + } + fn visit_str(self, val: &str) -> Result + where + E: Error, + { + let path = String::deserialize(value::StrDeserializer::new(val))?; + let c = serde_yaml::from_str(&read_to_string(path).map_err(|io| { + serde::de::Error::custom(format!("cannot read creds file: {io:?}")) + })?) + .map_err(|e| serde::de::Error::custom(format!("cannot parse creds file: {e:?}")))?; + Ok(c) + } + fn visit_map
(self, val: A) -> Result + where + A: MapAccess<'de>, + { + Ok(HashMap::deserialize(value::MapAccessDeserializer::new( + val, + ))?) + } + } + let k = deserializer.deserialize_any(StringOrMap)?; + Ok(Credentials { + wrong_user: PasswordHashString::parse("$never", Encoding::B64).unwrap(), + hashes: k + .into_iter() + .map(|(k, v)| { + let hash = PasswordHash::parse(&v, Encoding::B64) + .map_err(|e| { + serde::de::Error::custom(format!( + "phc string for user {k:?} is invalid: {e:?}" + )) + })? + .serialize(); + Ok((k, hash)) + }) + .try_collect()?, + }) + } +} diff --git a/src/modules/error.rs b/src/modules/error.rs new file mode 100644 index 0000000..504802f --- /dev/null +++ b/src/modules/error.rs @@ -0,0 +1,32 @@ +use crate::error::ServiceError; + +use super::{Node, NodeContext, NodeKind, NodeRequest, NodeResponse}; +use futures::Future; +use serde::Deserialize; +use serde_yaml::Value; +use std::{pin::Pin, sync::Arc}; + +pub struct ErrorKind; + +#[derive(Deserialize)] +#[serde(transparent)] +struct Error(String); + +impl NodeKind for ErrorKind { + fn name(&self) -> &'static str { + "error" + } + fn instanciate(&self, config: Value) -> anyhow::Result> { + Ok(Arc::new(serde_yaml::from_value::(config)?)) + } +} + +impl Node for Error { + fn handle<'a>( + &'a self, + _context: &'a mut NodeContext, + _request: NodeRequest, + ) -> Pin> + Send + Sync + 'a>> { + Box::pin(async move { Err(ServiceError::Custom(self.0.clone())) }) + } +} diff --git a/src/modules/file.rs b/src/modules/file.rs new file mode 100644 index 0000000..53c27f4 --- /dev/null +++ b/src/modules/file.rs @@ -0,0 +1,62 @@ +use super::{Node, NodeContext, NodeKind, NodeRequest, NodeResponse}; +use crate::error::ServiceError; +use futures::Future; +use http_body_util::{combinators::BoxBody, BodyExt}; +use hyper::{ + header::{HeaderValue, CONTENT_TYPE}, + Response, +}; +use serde::Deserialize; +use serde_yaml::Value; +use std::{fs::read_to_string, path::PathBuf, pin::Pin, sync::Arc}; + +pub struct FileKind; + +#[derive(Debug, Deserialize)] +struct FileConfig { + path: Option, + content: Option, + r#type: Option, +} + +#[derive(Debug, Deserialize)] +struct File { + content: String, + r#type: String, +} + +impl NodeKind for FileKind { + fn name(&self) -> &'static str { + "file" + } + fn instanciate(&self, config: Value) -> anyhow::Result> { + let conf = serde_yaml::from_value::(config)?; + Ok(Arc::new(File { + content: conf + .content + .or(conf + .path + .map(|p| Ok::<_, ServiceError>(read_to_string(p)?)) + .transpose()?) + .unwrap_or_default(), + r#type: conf.r#type.unwrap_or("text/html".to_string()), // TODO infer mime from ext + })) + } +} + +impl Node for File { + fn handle<'a>( + &'a self, + _context: &'a mut NodeContext, + _request: NodeRequest, + ) -> Pin> + Send + Sync + 'a>> { + Box::pin(async move { + let mut r = Response::new(BoxBody::<_, ServiceError>::new( + self.content.clone().map_err(|_| unreachable!()), + )); + r.headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_str(&self.r#type).unwrap()); + Ok(r) + }) + } +} diff --git a/src/modules/files.rs b/src/modules/files.rs new file mode 100644 index 0000000..4fdd5cd --- /dev/null +++ b/src/modules/files.rs @@ -0,0 +1,390 @@ +use super::{Node, NodeContext, NodeKind, NodeRequest, NodeResponse}; +use crate::{config::return_true, ServiceError}; +use bytes::{Bytes, BytesMut}; +use futures::Future; +use futures_util::{future, future::Either, ready, stream, FutureExt, Stream, StreamExt}; +use headers::{ + AcceptRanges, CacheControl, ContentLength, ContentRange, ContentType, HeaderMapExt, + LastModified, +}; +use http_body_util::{combinators::BoxBody, BodyExt, StreamBody}; +use humansize::FormatSizeOptions; +use hyper::{ + body::Frame, + header::{CONTENT_TYPE, LOCATION}, + http::HeaderValue, + Response, StatusCode, +}; +use log::debug; +use markup::Render; +use percent_encoding::percent_decode_str; +use serde::Deserialize; +use serde_yaml::Value; +use std::{ + fs::Metadata, + io, + ops::Range, + path::{Path, PathBuf}, + pin::Pin, + sync::Arc, + task::Poll, +}; +use tokio::{ + fs::{read_to_string, File}, + io::AsyncSeekExt, +}; +use tokio_util::io::poll_read_buf; + +pub struct FilesKind; +pub struct FileKind; + +#[derive(Debug, Deserialize)] +struct Files { + root: PathBuf, + #[serde(default)] + index: bool, + #[serde(default = "return_true")] + last_modified: bool, + // #[serde(default = "return_true")] + // etag: bool, + #[serde(default)] + cache: CacheMode, +} +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +enum CacheMode { + #[default] + Public, + Private, + NoStore, +} + +impl NodeKind for FilesKind { + fn name(&self) -> &'static str { + "files" + } + fn instanciate(&self, config: Value) -> anyhow::Result> { + Ok(Arc::new(serde_yaml::from_value::(config)?)) + } +} + +impl Node for Files { + fn handle<'a>( + &'a self, + _context: &'a mut NodeContext, + request: NodeRequest, + ) -> Pin> + Send + Sync + 'a>> { + Box::pin(async move { + let rpath = request.uri().path(); + + let mut path = self.root.clone(); + let mut user_path_depth = 0; + for seg in rpath.split("/") { + let seg = percent_decode_str(seg).decode_utf8()?; + + if seg == "" || seg == "." { + continue; + } + + if seg == ".." { + if user_path_depth <= 0 { + return Err(ServiceError::BadPath); + } + path.pop(); + user_path_depth -= 1; + } else { + path.push(seg.as_ref()); + user_path_depth += 1; + } + } + if !path.exists() { + return Err(ServiceError::NotFound); + } + + let metadata = path.metadata()?; + + if metadata.file_type().is_dir() { + debug!("sending index for {path:?}"); + if let Ok(indexhtml) = read_to_string(path.join("index.html")).await { + return Ok(html_string_response(indexhtml)); + } + + if self.index { + if !rpath.ends_with("/") { + let mut r = Response::new(String::new()); + *r.status_mut() = StatusCode::FOUND; + r.headers_mut().insert( + LOCATION, + HeaderValue::from_str(&format!("{}/", rpath)) + .map_err(|_| ServiceError::Other)?, + ); + return Ok(r.map(|b| b.map_err(|e| match e {}).boxed())); + } + + return index(&path, rpath.to_string()) + .await + .map(html_string_response); + } else { + return Err(ServiceError::NotFound); + } + } + + let modified = metadata.modified()?; + + let not_modified = if self.last_modified { + request + .headers() + .typed_get::() + .map(|if_modified_since| { + Ok::<_, ServiceError>(!if_modified_since.is_modified(modified)) + }) + .transpose()? + .unwrap_or_default() + } else { + false + }; + + // let etag = ETag::from_str(&calc_etag(modified)).map_err(|_| ServiceError::Other)?; + // let etag_matches = if self.etag { + // request.headers() + // .typed_get::() + // .map(|if_none_match| if_none_match.precondition_passes(&etag)) + // .unwrap_or_default() + // } else { + // false + // }; + + let range = request.headers().typed_get::(); + let range = bytes_range(range, metadata.len())?; + + debug!("sending file {path:?}"); + let file = File::open(path.clone()).await?; + + // let skip_body = not_modified || etag_matches; + let skip_body = not_modified; + let mut r = if skip_body { + Response::new("".to_string()).map(|b| b.map_err(|e| match e {}).boxed()) + } else { + Response::new(BoxBody::new(StreamBody::new( + StreamBody::new(file_stream(file, 4096, range.clone())) + .map(|e| e.map(|e| Frame::data(e)).map_err(ServiceError::Io)), + ))) + }; + + if !skip_body { + if range.end - range.start != metadata.len() { + *r.status_mut() = StatusCode::PARTIAL_CONTENT; + r.headers_mut().typed_insert( + ContentRange::bytes(range.clone(), metadata.len()) + .expect("valid ContentRange"), + ); + } + } + // if not_modified || etag_matches { + if not_modified { + *r.status_mut() = StatusCode::NOT_MODIFIED; + } + + r.headers_mut().typed_insert(AcceptRanges::bytes()); + r.headers_mut() + .typed_insert(ContentLength(range.end - range.start)); + + let mime = mime_guess::from_path(path).first_or_octet_stream(); + r.headers_mut().typed_insert(ContentType::from(mime)); + + r.headers_mut().typed_insert(match self.cache { + CacheMode::Public => CacheControl::new().with_public(), + CacheMode::Private => CacheControl::new().with_private(), + CacheMode::NoStore => CacheControl::new().with_no_store(), + }); + + // if self.etag { + // r.headers_mut().typed_insert(etag); + // } + if self.last_modified { + r.headers_mut().typed_insert(LastModified::from(modified)); + } + + Ok(r) + }) + } +} + +// Adapted from warp (https://github.com/seanmonstar/warp/blob/master/src/filters/fs.rs). Thanks! +fn file_stream( + mut file: File, + buf_size: usize, + range: Range, +) -> impl Stream> + Send { + use std::io::SeekFrom; + + let seek = async move { + if range.start != 0 { + file.seek(SeekFrom::Start(range.start)).await?; + } + Ok(file) + }; + + seek.into_stream() + .map(move |result| { + let mut buf = BytesMut::new(); + let mut len = range.end - range.start; + let mut f = match result { + Ok(f) => f, + Err(f) => return Either::Left(stream::once(future::err(f))), + }; + + Either::Right(stream::poll_fn(move |cx| { + if len == 0 { + return Poll::Ready(None); + } + reserve_at_least(&mut buf, buf_size); + + let n = match ready!(poll_read_buf(Pin::new(&mut f), cx, &mut buf)) { + Ok(n) => n as u64, + Err(err) => { + debug!("file read error: {}", err); + return Poll::Ready(Some(Err(err))); + } + }; + + if n == 0 { + debug!("file read found EOF before expected length"); + return Poll::Ready(None); + } + + let mut chunk = buf.split().freeze(); + if n > len { + chunk = chunk.split_to(len as usize); + len = 0; + } else { + len -= n; + } + + Poll::Ready(Some(Ok(chunk))) + })) + }) + .flatten() +} + +// Also adapted from warp +fn bytes_range(range: Option, max_len: u64) -> Result, ServiceError> { + use std::ops::Bound; + + let range = if let Some(range) = range { + range + } else { + return Ok(0..max_len); + }; + + let ret = range + .satisfiable_ranges(max_len) + .map(|(start, end)| { + let start = match start { + Bound::Unbounded => 0, + Bound::Included(s) => s, + Bound::Excluded(s) => s + 1, + }; + + let end = match end { + Bound::Unbounded => max_len, + Bound::Included(s) => { + // For the special case where s == the file size + if s == max_len { + s + } else { + s + 1 + } + } + Bound::Excluded(s) => s, + }; + + if start < end && end <= max_len { + Ok(start..end) + } else { + Err(ServiceError::BadRange) + } + }) + .next() + .unwrap_or(Ok(0..max_len)); + ret +} + +fn reserve_at_least(buf: &mut BytesMut, cap: usize) { + if buf.capacity() - buf.len() < cap { + buf.reserve(cap); + } +} + +async fn index(path: &Path, rpath: String) -> Result { + let files = path + .read_dir()? + .map(|e| e.and_then(|e| Ok((e.file_name().into_string().unwrap(), e.metadata()?)))) + .filter(|e| e.as_ref().map(|(e, _)| !e.starts_with(".")).unwrap_or(true)) + .collect::, _>>()?; + let banner = read_to_string(path.join("index.banner.html")).await.ok(); + let mut s = String::new(); + IndexTemplate { + files, + banner, + path: rpath, + } + .render(&mut s) + .unwrap(); + Ok(s) +} + +fn html_string_response(s: String) -> hyper::Response> { + let mut r = Response::new(s); + r.headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("text/html")); + r.map(|b| b.map_err(|e| match e {}).boxed()) +} + +markup::define! { + IndexTemplate(path: String, banner: Option, files: Vec<(String, Metadata)>) { + @markup::doctype() + html { + head { + meta[charset="UTF-8"]; + title { "Index of " @path } + } + body { + @if let Some(banner) = banner { + @markup::raw(banner) + } else { + h1 { "Index of " @path } + } + hr; + table { + @if path != "/" { + tr { td { b { a[href=".."] { "../" } } } } + } + @for (name, meta) in files { tr { + td { a[href=name] { + @name + @if meta.file_type().is_dir() { "/" } + } } + td { + @if meta.file_type().is_dir() { + i { "directory" } + } else { + @humansize::format_size(meta.len(), FormatSizeOptions::default()) + } + } + } } + } + hr; + footer { sub { "served by " a[href="https://codeberg.org/metamuffin/gnix"] { "gnix" } } } + } + } + } +} + +// fn calc_etag(s: SystemTime) -> String { +// // TODO: make this not change after server restart but still unguessable +// let mut hasher = DefaultHasher::new(); +// s.hash(&mut hasher); +// let hash = hasher.finish(); +// hex::encode(hash.to_le_bytes()) +// } diff --git a/src/modules/hosts.rs b/src/modules/hosts.rs new file mode 100644 index 0000000..286d478 --- /dev/null +++ b/src/modules/hosts.rs @@ -0,0 +1,45 @@ +use super::{Node, NodeContext, NodeKind, NodeRequest, NodeResponse}; +use crate::{config::DynNode, error::ServiceError}; +use futures::Future; +use hyper::header::HOST; +use serde::Deserialize; +use serde_yaml::Value; +use std::{collections::HashMap, pin::Pin, sync::Arc}; + +#[derive(Deserialize)] +#[serde(transparent)] +struct Hosts(HashMap); + +pub struct HostsKind; +impl NodeKind for HostsKind { + fn name(&self) -> &'static str { + "hosts" + } + fn instanciate(&self, config: Value) -> anyhow::Result> { + Ok(Arc::new(serde_yaml::from_value::(config)?)) + } +} +impl Node for Hosts { + fn handle<'a>( + &'a self, + context: &'a mut NodeContext, + request: NodeRequest, + ) -> Pin> + Send + Sync + 'a>> { + Box::pin(async move { + let host = request + .headers() + .get(HOST) + .and_then(|e| e.to_str().ok()) + .ok_or(ServiceError::NoHost)?; + + let host = remove_port(&host); + let node = self.0.get(host).ok_or(ServiceError::UnknownHost)?; + + node.handle(context, request).await + }) + } +} + +pub fn remove_port(s: &str) -> &str { + s.split_once(":").map(|(s, _)| s).unwrap_or(s) +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs new file mode 100644 index 0000000..2bee8e3 --- /dev/null +++ b/src/modules/mod.rs @@ -0,0 +1,54 @@ +use crate::error::ServiceError; +use crate::State; +use accesslog::AccessLogKind; +use auth::{basic::HttpBasicAuthKind, cookie::CookieAuthKind}; +use bytes::Bytes; +use error::ErrorKind; +use file::FileKind; +use files::FilesKind; +use futures::Future; +use hosts::HostsKind; +use http_body_util::combinators::BoxBody; +use hyper::{body::Incoming, Request, Response}; +use proxy::ProxyKind; +use serde_yaml::Value; +use std::{net::SocketAddr, pin::Pin, sync::Arc}; + +pub mod accesslog; +pub mod auth; +pub mod error; +pub mod file; +pub mod files; +pub mod hosts; +pub mod proxy; + +pub type NodeRequest = Request; +pub type NodeResponse = Response>; + +pub static MODULES: &'static [&'static dyn NodeKind] = &[ + &HttpBasicAuthKind, + &CookieAuthKind, + &ProxyKind, + &HostsKind, + &FilesKind, + &FileKind, + &AccessLogKind, + &ErrorKind, +]; + +pub struct NodeContext { + pub state: Arc, + pub addr: SocketAddr, +} + +pub trait NodeKind: Send + Sync + 'static { + fn name(&self) -> &'static str; + fn instanciate(&self, config: Value) -> anyhow::Result>; +} +pub trait Node: Send + Sync + 'static { + fn handle<'a>( + &'a self, + context: &'a mut NodeContext, + request: NodeRequest, + ) -> Pin> + Send + Sync + 'a>>; +} diff --git a/src/modules/proxy.rs b/src/modules/proxy.rs new file mode 100644 index 0000000..ce72f65 --- /dev/null +++ b/src/modules/proxy.rs @@ -0,0 +1,98 @@ +use super::{Node, NodeContext, NodeKind, NodeRequest, NodeResponse}; +use crate::{helper::TokioIo, ServiceError}; +use futures::Future; +use http_body_util::BodyExt; +use hyper::{http::HeaderValue, upgrade::OnUpgrade, StatusCode}; +use log::{debug, warn}; +use serde::Deserialize; +use serde_yaml::Value; +use std::{net::SocketAddr, pin::Pin, sync::Arc}; +use tokio::net::TcpStream; + +#[derive(Default)] +pub struct ProxyKind; + +#[derive(Debug, Deserialize)] +struct Proxy { + backend: SocketAddr, +} + +impl NodeKind for ProxyKind { + fn name(&self) -> &'static str { + "proxy" + } + fn instanciate(&self, config: Value) -> anyhow::Result> { + Ok(Arc::new(serde_yaml::from_value::(config)?)) + } +} +impl Node for Proxy { + fn handle<'a>( + &'a self, + context: &'a mut NodeContext, + mut request: NodeRequest, + ) -> Pin> + Send + Sync + 'a>> { + Box::pin(async move { + request.headers_mut().insert( + "x-real-ip", + HeaderValue::from_str(&format!("{}", context.addr.ip())).unwrap(), + ); + + let on_upgrade_downstream = request.extensions_mut().remove::(); + + let _limit_guard = context.state.l_outgoing.try_acquire()?; + debug!("\tforwarding to {}", self.backend); + let mut resp = { + let client_stream = TokioIo( + TcpStream::connect(self.backend) + .await + .map_err(|_| ServiceError::CantConnect)?, + ); + + let (mut sender, conn) = hyper::client::conn::http1::handshake(client_stream) + .await + .map_err(ServiceError::Hyper)?; + tokio::task::spawn(async move { + if let Err(err) = conn.with_upgrades().await { + warn!("connection failed: {:?}", err); + } + }); + sender + .send_request(request) + .await + .map_err(ServiceError::Hyper)? + }; + + if resp.status() == StatusCode::SWITCHING_PROTOCOLS { + let on_upgrade_upstream = resp + .extensions_mut() + .remove::() + .ok_or(ServiceError::UpgradeFailed)?; + let on_upgrade_downstream = + on_upgrade_downstream.ok_or(ServiceError::UpgradeFailed)?; + tokio::task::spawn(async move { + debug!("about to upgrade connection"); + match (on_upgrade_upstream.await, on_upgrade_downstream.await) { + (Ok(upgraded_upstream), Ok(upgraded_downstream)) => { + debug!("upgrade successful"); + match tokio::io::copy_bidirectional( + &mut TokioIo(upgraded_downstream), + &mut TokioIo(upgraded_upstream), + ) + .await + { + Ok((from_client, from_server)) => { + debug!("proxy socket terminated: {from_server} sent, {from_client} received") + } + Err(e) => warn!("proxy socket error: {e}"), + } + } + (a, b) => warn!("upgrade error: upstream={a:?} downstream={b:?}"), + } + }); + } + + let resp = resp.map(|b| b.map_err(ServiceError::Hyper).boxed()); + Ok(resp) + }) + } +} -- cgit v1.2.3-70-g09d2