summaryrefslogtreecommitdiff
path: root/src/modules
diff options
context:
space:
mode:
Diffstat (limited to 'src/modules')
-rw-r--r--src/modules/accesslog.rs81
-rw-r--r--src/modules/auth/basic.rs68
-rw-r--r--src/modules/auth/cookie.rs182
-rw-r--r--src/modules/auth/login.html35
-rw-r--r--src/modules/auth/mod.rs90
-rw-r--r--src/modules/error.rs32
-rw-r--r--src/modules/file.rs62
-rw-r--r--src/modules/files.rs390
-rw-r--r--src/modules/hosts.rs45
-rw-r--r--src/modules/mod.rs54
-rw-r--r--src/modules/proxy.rs98
11 files changed, 1137 insertions, 0 deletions
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<Option<BufWriter<File>>>,
+}
+
+impl NodeKind for AccessLogKind {
+ fn name(&self) -> &'static str {
+ "access_log"
+ }
+ fn instanciate(&self, config: serde_yaml::Value) -> anyhow::Result<Arc<dyn Node>> {
+ Ok(Arc::new(AccessLog {
+ config: serde_yaml::from_value::<AccessLogConfig>(config)?,
+ file: Default::default(),
+ }))
+ }
+}
+
+impl Node for AccessLog {
+ fn handle<'a>(
+ &'a self,
+ context: &'a mut NodeContext,
+ request: NodeRequest,
+ ) -> Pin<Box<dyn Future<Output = Result<NodeResponse, ServiceError>> + 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<Arc<dyn Node>> {
+ Ok(Arc::new(serde_yaml::from_value::<HttpBasicAuth>(config)?))
+ }
+}
+
+#[derive(Deserialize)]
+pub struct HttpBasicAuth {
+ realm: String,
+ valid: HashSet<String>,
+ next: DynNode,
+}
+
+impl Node for HttpBasicAuth {
+ fn handle<'a>(
+ &'a self,
+ context: &'a mut NodeContext,
+ request: NodeRequest,
+ ) -> Pin<Box<dyn Future<Output = Result<NodeResponse, ServiceError>> + 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<Arc<dyn Node>> {
+ Ok(Arc::new(serde_yaml::from_value::<CookieAuth>(config)?))
+ }
+}
+
+#[derive(Deserialize)]
+pub struct CookieAuth {
+ users: Credentials,
+ expire: Option<u64>,
+ #[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<Box<dyn Future<Output = Result<NodeResponse, ServiceError>> + 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::<u8>());
+ 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::<Cookie>() {
+ 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 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Gnix Login</title>
+ <style>
+ body {
+ background-color: grey;
+ }
+ form {
+ margin: auto;
+ width: 200px;
+ padding: 20px;
+ border: 2px solid black;
+ background-color: white;
+ }
+ input[type="text"],
+ input[type="password"] {
+ box-sizing: border-box;
+ width: 100%;
+ margin-bottom: 1em;
+ }
+ </style>
+ </head>
+ <body>
+ <form action="/_gnix_login" method="post">
+ <label for="username">Username: </label><br />
+ <input type="text" name="username" id="username" /><br />
+ <label for="password">Password: </label><br />
+ <input type="password" name="password" id="password" /><br />
+ <input type="submit" value="Login" />
+ </form>
+ </body>
+</html>
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<String, PasswordHashString>,
+}
+
+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<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ struct StringOrMap;
+ impl<'de> Visitor<'de> for StringOrMap {
+ type Value = HashMap<String, String>;
+ fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+ formatter.write_str("credentials map or file path")
+ }
+ fn visit_str<E>(self, val: &str) -> Result<Self::Value, E>
+ 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<A>(self, val: A) -> Result<Self::Value, A::Error>
+ 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<Arc<dyn Node>> {
+ Ok(Arc::new(serde_yaml::from_value::<Error>(config)?))
+ }
+}
+
+impl Node for Error {
+ fn handle<'a>(
+ &'a self,
+ _context: &'a mut NodeContext,
+ _request: NodeRequest,
+ ) -> Pin<Box<dyn Future<Output = Result<NodeResponse, ServiceError>> + 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<PathBuf>,
+ content: Option<String>,
+ r#type: Option<String>,
+}
+
+#[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<Arc<dyn Node>> {
+ let conf = serde_yaml::from_value::<FileConfig>(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<Box<dyn Future<Output = Result<NodeResponse, ServiceError>> + 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<Arc<dyn Node>> {
+ Ok(Arc::new(serde_yaml::from_value::<Files>(config)?))
+ }
+}
+
+impl Node for Files {
+ fn handle<'a>(
+ &'a self,
+ _context: &'a mut NodeContext,
+ request: NodeRequest,
+ ) -> Pin<Box<dyn Future<Output = Result<NodeResponse, ServiceError>> + 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::<headers::IfModifiedSince>()
+ .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::<headers::IfNoneMatch>()
+ // .map(|if_none_match| if_none_match.precondition_passes(&etag))
+ // .unwrap_or_default()
+ // } else {
+ // false
+ // };
+
+ let range = request.headers().typed_get::<headers::Range>();
+ 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<u64>,
+) -> impl Stream<Item = Result<Bytes, io::Error>> + 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<headers::Range>, max_len: u64) -> Result<Range<u64>, 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<String, ServiceError> {
+ 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::<Result<Vec<_>, _>>()?;
+ 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<BoxBody<Bytes, ServiceError>> {
+ 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<String>, 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<String, DynNode>);
+
+pub struct HostsKind;
+impl NodeKind for HostsKind {
+ fn name(&self) -> &'static str {
+ "hosts"
+ }
+ fn instanciate(&self, config: Value) -> anyhow::Result<Arc<dyn Node>> {
+ Ok(Arc::new(serde_yaml::from_value::<Hosts>(config)?))
+ }
+}
+impl Node for Hosts {
+ fn handle<'a>(
+ &'a self,
+ context: &'a mut NodeContext,
+ request: NodeRequest,
+ ) -> Pin<Box<dyn Future<Output = Result<NodeResponse, ServiceError>> + 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<Incoming>;
+pub type NodeResponse = Response<BoxBody<Bytes, ServiceError>>;
+
+pub static MODULES: &'static [&'static dyn NodeKind] = &[
+ &HttpBasicAuthKind,
+ &CookieAuthKind,
+ &ProxyKind,
+ &HostsKind,
+ &FilesKind,
+ &FileKind,
+ &AccessLogKind,
+ &ErrorKind,
+];
+
+pub struct NodeContext {
+ pub state: Arc<State>,
+ pub addr: SocketAddr,
+}
+
+pub trait NodeKind: Send + Sync + 'static {
+ fn name(&self) -> &'static str;
+ fn instanciate(&self, config: Value) -> anyhow::Result<Arc<dyn Node>>;
+}
+pub trait Node: Send + Sync + 'static {
+ fn handle<'a>(
+ &'a self,
+ context: &'a mut NodeContext,
+ request: NodeRequest,
+ ) -> Pin<Box<dyn Future<Output = Result<NodeResponse, ServiceError>> + 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<Arc<dyn Node>> {
+ Ok(Arc::new(serde_yaml::from_value::<Proxy>(config)?))
+ }
+}
+impl Node for Proxy {
+ fn handle<'a>(
+ &'a self,
+ context: &'a mut NodeContext,
+ mut request: NodeRequest,
+ ) -> Pin<Box<dyn Future<Output = Result<NodeResponse, ServiceError>> + 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::<OnUpgrade>();
+
+ 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::<OnUpgrade>()
+ .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)
+ })
+ }
+}