5 Commits

Author SHA1 Message Date
xfy
f0da74a06e feat: add lua script execture 2025-06-29 20:24:13 +08:00
xfy
a5041197c7 feat: add 301 forward for auto index 2025-06-29 19:01:43 +08:00
xfy
515b36176d feat: add mlua 2025-06-26 22:02:19 +08:00
xfy
46c2f90f92 chore: update default config
add lua script path
2025-06-26 21:53:24 +08:00
xfy
64a85dd5a4 feat: add max body size support 2025-06-25 22:51:09 +08:00
8 changed files with 363 additions and 80 deletions

185
Cargo.lock generated
View File

@ -316,7 +316,7 @@ dependencies = [
"bitflags",
"cexpr",
"clang-sys",
"itertools",
"itertools 0.12.1",
"lazy_static",
"lazycell",
"log",
@ -324,10 +324,10 @@ dependencies = [
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"rustc-hash 1.1.0",
"shlex",
"syn",
"which",
"which 4.4.2",
]
[[package]]
@ -366,6 +366,16 @@ dependencies = [
"alloc-stdlib",
]
[[package]]
name = "bstr"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.17.0"
@ -405,6 +415,7 @@ dependencies = [
"md5",
"mimalloc",
"mime_guess",
"mlua",
"reqwest",
"serde",
"serde_json",
@ -654,12 +665,28 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "env_home"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erased-serde"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7"
dependencies = [
"serde",
"typeid",
]
[[package]]
name = "errno"
version = "0.3.11"
@ -1221,6 +1248,15 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.14"
@ -1318,6 +1354,25 @@ version = "0.4.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e"
[[package]]
name = "lua-src"
version = "547.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1edaf29e3517b49b8b746701e5648ccb5785cde1c119062cbabbc5d5cd115e42"
dependencies = [
"cc",
]
[[package]]
name = "luajit-src"
version = "210.5.12+a4f56a4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3a8e7962a5368d5f264d045a5a255e90f9aa3fc1941ae15a8d2940d42cac671"
dependencies = [
"cc",
"which 7.0.3",
]
[[package]]
name = "matchers"
version = "0.1.0"
@ -1335,9 +1390,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "md5"
version = "0.7.0"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0"
[[package]]
name = "memchr"
@ -1396,6 +1451,55 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "mlua"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1f5f8fbebc7db5f671671134b9321c4b9aa9adeafccfd9a8c020ae45c6a35d0"
dependencies = [
"anyhow",
"bstr",
"either",
"erased-serde",
"futures-util",
"mlua-sys",
"mlua_derive",
"num-traits",
"parking_lot",
"rustc-hash 2.1.1",
"rustversion",
"serde",
"serde-value",
]
[[package]]
name = "mlua-sys"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "380c1f7e2099cafcf40e51d3a9f20a346977587aa4d012eae1f043149a728a93"
dependencies = [
"cc",
"cfg-if",
"lua-src",
"luajit-src",
"pkg-config",
]
[[package]]
name = "mlua_derive"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "870d71c172fcf491c6b5fb4c04160619a2ee3e5a42a1402269c66bcbf1dd4deb"
dependencies = [
"itertools 0.13.0",
"once_cell",
"proc-macro-error2",
"proc-macro2",
"quote",
"regex",
"syn",
]
[[package]]
name = "native-tls"
version = "0.2.14"
@ -1511,6 +1615,15 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "ordered-float"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
dependencies = [
"num-traits",
]
[[package]]
name = "overload"
version = "0.1.1"
@ -1583,6 +1696,28 @@ dependencies = [
"syn",
]
[[package]]
name = "proc-macro-error-attr2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "proc-macro-error2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
dependencies = [
"proc-macro-error-attr2",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.93"
@ -1730,6 +1865,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustix"
version = "0.38.44"
@ -1857,6 +1998,16 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-value"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
dependencies = [
"ordered-float",
"serde",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
@ -2339,6 +2490,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.18.0"
@ -2549,6 +2706,18 @@ dependencies = [
"rustix 0.38.44",
]
[[package]]
name = "which"
version = "7.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762"
dependencies = [
"either",
"env_home",
"rustix 1.0.5",
"winsafe",
]
[[package]]
name = "winapi"
version = "0.3.9"
@ -2732,6 +2901,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "winsafe"
version = "0.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"

View File

@ -32,6 +32,16 @@ reqwest = { version = "0.12.20", features = [
'deflate',
"stream",
] }
# lua
mlua = { version = "0.10.5", features = [
"lua54",
"vendored",
"send",
"async",
"macros",
"serialize",
"anyhow",
] }
# tools
thiserror = "2.0.12"
anyhow = "1.0.98"
@ -39,7 +49,7 @@ serde = { version = "1.0.219", features = ["derive"] }
toml = "0.8.23"
bytes = "1.10.1"
const_format = "0.2.34"
md5 = "0.7.0"
md5 = "0.8.0"
dashmap = { version = "6.1.0", features = ["serde"] }
# logging
tracing = "0.1.41"

View File

@ -1,7 +1,7 @@
# Virtual host
[[host]]
ip = "0.0.0.0"
port = 4000
port = 80
# Connection timeout
timeout = 15
# SSL certificate
@ -11,11 +11,9 @@ timeout = 15
# sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ./html/selfsigned.key -out ./html/selfsigned.crt
certificate = "./html/selfsigned.crt"
certificate_key = "./html/selfsigned.key"
# Add custom headers to response
[host.headers]
X-Powered-By = "candy"
# Routes for virtual host
[[host.route]]
# Route location
@ -23,12 +21,11 @@ location = "/"
# Static file root
# or proxy_pass
# or redirect
root = "./html"
root = "html"
# Only use for root field
index = ["index.html"]
# List directory
auto_index = true
# Custom 500 page
[host.route.error_page]
status = 500
@ -38,8 +35,22 @@ page = "500.html"
status = 404
page = "404.html"
# Reverse proxy
[[host]]
ip = "0.0.0.0"
port = 8080
[[host.route]]
location = "/proxy/"
location = "/"
proxy_pass = "http://localhost:3000/"
# Timeout for connect to upstream
proxy_timeout = 10
# Client request max body size (bytes)
max_body_size = 2048
# Lua script
[[host]]
ip = "0.0.0.0"
port = 8081
[[host.route]]
location = "/"
lua_script = "html/index.lua"

View File

@ -40,6 +40,11 @@ pub struct SettingRoute {
/// Timeout for connect to upstream
#[serde(default = "upstream_timeout_default")]
pub proxy_timeout: u16,
/// Request max body size (bytes)
pub max_body_size: Option<u64>,
/// Lua script
pub lua_script: Option<String>,
}
/// Host routes

27
src/http/lua.rs Normal file
View File

@ -0,0 +1,27 @@
pub async fn lua(
req_uri: Uri,
path: Option<Path<String>>,
Host(host): Host,
mut req: Request<Body>,
) -> RouteResult<impl IntoResponse> {
let req_path = req.uri().path();
let path_query = req
.uri()
.path_and_query()
.map(|v| v.as_str())
.unwrap_or(req_path);
let scheme = req.uri().scheme_str().unwrap_or("http");
let port = parse_port_from_host(&host, scheme).ok_or(RouteError::BadRequest())?;
let route_map = &HOSTS.get(&port).ok_or(RouteError::BadRequest())?.route_map;
tracing::debug!("Route map entries: {:?}", route_map);
let parent_path = resolve_parent_path(&req_uri, path.as_ref());
let route_config = route_map
.get(&parent_path)
.ok_or(RouteError::RouteNotFound())?;
let lua_script = route_config
.lua_script
.as_ref()
.ok_or(RouteError::InternalError())?;
}

View File

@ -1,9 +1,10 @@
use std::{net::SocketAddr, sync::LazyLock, time::Duration};
use anyhow::anyhow;
use axum::{Router, middleware, routing::get};
use axum::{Router, extract::DefaultBodyLimit, middleware, routing::get};
use axum_server::{Handle, tls_rustls::RustlsConfig};
use dashmap::DashMap;
use mlua::Lua;
use tower::ServiceBuilder;
use tower_http::{compression::CompressionLayer, timeout::TimeoutLayer};
use tracing::{debug, info, warn};
@ -33,6 +34,9 @@ pub mod reverse_proxy;
/// }
pub static HOSTS: LazyLock<DashMap<u16, SettingHost>> = LazyLock::new(DashMap::new);
/// lua 脚本执行器
pub static LUA_EXECUTOR: LazyLock<Lua> = LazyLock::new(Lua::new);
pub async fn make_server(host: SettingHost) -> anyhow::Result<()> {
let mut router = Router::new();
let host_to_save = host.clone();
@ -40,72 +44,86 @@ pub async fn make_server(host: SettingHost) -> anyhow::Result<()> {
// convert to axum routes
// register routes
for host_route in &host.route {
// lua script
if let Some(lua_path) = &host_route.lua_script {
// papare lua script
continue;
}
// reverse proxy
if host_route.proxy_pass.is_some() {
router = router.route(host_route.location.as_ref(), get(reverse_proxy::serve));
// register wildcard path /doc/*
let route_path = format!("{}{{*path}}", host_route.location);
router = router.route(route_path.as_ref(), get(reverse_proxy::serve));
// Set request max body size
if let Some(max_body_size) = host_route.max_body_size {
router = router.layer(DefaultBodyLimit::max(max_body_size as usize));
}
// save route path to map
{
host_to_save
.route_map
.insert(host_route.location.clone(), host_route.clone());
}
} else {
// static file
if host_route.root.is_none() {
warn!("root field not found for route: {:?}", host_route.location);
continue;
}
// resister with location
// location = "/doc"
// route: GET /doc/*
// resister with file path
// index = ["index.html", "index.txt"]
// route: GET /doc/index.html
// route: GET /doc/index.txt
// register parent path /doc
let path_morethan_one = host_route.location.len() > 1;
let route_path = if path_morethan_one && host_route.location.ends_with('/') {
// first register path with slash /doc
router = router.route(&host_route.location, get(serve::serve));
debug!("registed route {}", host_route.location);
let len = host_route.location.len();
let path_without_slash = host_route.location.chars().collect::<Vec<_>>()
[0..len - 1]
.iter()
.collect::<String>();
// then register path without slash /doc/
router = router.route(&path_without_slash, get(serve::serve));
debug!("registed route {}", path_without_slash);
host_route.location.clone()
} else if path_morethan_one {
// first register path without slash /doc
router = router.route(&host_route.location, get(serve::serve));
debug!("registed route {}", host_route.location);
// then register path with slash /doc/
let path = format!("{}/", host_route.location);
router = router.route(&path, get(serve::serve));
debug!("registed route {}", path);
path
} else {
// register path /doc/
router = router.route(&host_route.location, get(serve::serve));
debug!("registed route {}", host_route.location);
host_route.location.clone()
};
// save route path to map
{
host_to_save
.route_map
.insert(route_path.clone(), host_route.clone());
}
let route_path = format!("{route_path}{{*path}}");
// register wildcard path /doc/*
router = router.route(route_path.as_ref(), get(serve::serve));
debug!("registed route: {}", route_path);
continue;
}
// static file
if host_route.root.is_none() {
warn!("root field not found for route: {:?}", host_route.location);
continue;
}
// Set request max body size
if let Some(max_body_size) = host_route.max_body_size {
router = router.layer(DefaultBodyLimit::max(max_body_size as usize));
}
// resister with location
// location = "/doc"
// route: GET /doc/*
// resister with file path
// index = ["index.html", "index.txt"]
// route: GET /doc/index.html
// route: GET /doc/index.txt
// register parent path /doc
let path_morethan_one = host_route.location.len() > 1;
let route_path = if path_morethan_one && host_route.location.ends_with('/') {
// first register path with slash /doc
router = router.route(&host_route.location, get(serve::serve));
debug!("registed route {}", host_route.location);
let len = host_route.location.len();
let path_without_slash = host_route.location.chars().collect::<Vec<_>>()[0..len - 1]
.iter()
.collect::<String>();
// then register path without slash /doc/
router = router.route(&path_without_slash, get(serve::serve));
debug!("registed route {}", path_without_slash);
host_route.location.clone()
} else if path_morethan_one {
// first register path without slash /doc
router = router.route(&host_route.location, get(serve::serve));
debug!("registed route {}", host_route.location);
// then register path with slash /doc/
let path = format!("{}/", host_route.location);
router = router.route(&path, get(serve::serve));
debug!("registed route {}", path);
path
} else {
// register path /doc/
router = router.route(&host_route.location, get(serve::serve));
debug!("registed route {}", host_route.location);
host_route.location.clone()
};
// save route path to map
{
host_to_save
.route_map
.insert(route_path.clone(), host_route.clone());
}
let route_path = format!("{route_path}{{*path}}");
// register wildcard path /doc/*
router = router.route(route_path.as_ref(), get(serve::serve));
debug!("registed route: {}", route_path);
}
// save host to map

View File

@ -188,7 +188,6 @@ pub async fn serve(
// forward request body
let body = req.into_body();
// TODO: set body size limit
let bytes = axum::body::to_bytes(body, 2048).await.map_err(|err| {
tracing::error!("Failed to proxy request: {}", err);
RouteError::InternalError()

View File

@ -15,7 +15,7 @@ use axum_extra::extract::Host;
use dashmap::mapref::one::Ref;
use http::{
HeaderMap, HeaderValue, StatusCode, Uri,
header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH},
header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH, LOCATION},
};
use mime_guess::from_path;
use tokio::fs::{self, File};
@ -75,7 +75,7 @@ async fn custom_page(
match stream_file(path.into(), request, Some(status)).await {
Ok(res) => RouteResult::Ok(res),
Err(e) => {
println!("Failed to stream file: {:?}", e);
error!("Failed to stream file: {:?}", e);
RouteResult::Err(RouteError::InternalError())
}
}
@ -149,7 +149,7 @@ pub async fn serve(
#[allow(clippy::unnecessary_to_owned)]
let path = path.to_string();
if path.contains('.') {
(root.into(), vec![format!("{}/{}", root, path)])
(root.into(), vec![format!("{}{}", root, path)])
} else {
generate_default_index(&host_route, &format!("{root}/{path}"))
}
@ -157,6 +157,7 @@ pub async fn serve(
generate_default_index(&host_route, root)
};
debug!("request index file {:?}", path_arr);
debug!("req_path: {:?}", req_path);
// Try each candidate path in order:
// - Return the first successfully streamed file.
// - If all fail, return a `RouteNotFound` error.
@ -172,6 +173,26 @@ pub async fn serve(
let path_exists = match path_exists {
Some(path_exists) => path_exists,
None => {
let uri_path = uri.path();
// 如果请求路径不以 / 结尾,则返回 301 Moved Permanently 状态码
if !uri_path.ends_with('/') {
let mut response = Response::builder();
let stream = empty_stream().await?;
let body = Body::from_stream(stream);
response
.headers_mut()
.with_context(|| "insert header failed")?
.insert(
LOCATION,
HeaderValue::from_str(format!("{uri_path}/").as_str())
.with_context(|| "insert header failed")?,
);
response = response.status(StatusCode::MOVED_PERMANENTLY);
let response = response
.body(body)
.with_context(|| "Failed to build HTTP response with body")?;
return Ok(response);
}
// 生成自动目录索引
if host_route.auto_index {
// HTML 中的标题路径,需要移除掉配置文件中的 root = "./html" 字段
@ -267,16 +288,8 @@ async fn stream_file(
}
}
#[cfg(windows)]
let null = PathBuf::from("NUL");
#[cfg(not(windows))]
let null = PathBuf::from("/dev/null");
let stream = if not_modified {
let empty = File::open(null)
.await
.with_context(|| "open /dev/null failed")?;
ReaderStream::new(empty)
empty_stream().await?
} else {
ReaderStream::new(file)
};
@ -598,7 +611,11 @@ async fn list_dir(host_root_str: &str, path: &PathBuf) -> anyhow::Result<Vec<Dir
.strip_prefix(&host_root_str)
.ok_or(anyhow!("strip prefix failed"))?
.to_string();
let path = format!("./{path}");
let path = if is_dir {
format!("./{path}/")
} else {
format!("./{path}")
};
// 创建并返回目录条目信息
let dir = DirList {
name,
@ -619,3 +636,24 @@ async fn list_dir(host_root_str: &str, path: &PathBuf) -> anyhow::Result<Vec<Dir
Ok(list)
}
/// 创建一个空数据流,用于返回空响应或占位数据
///
/// 在不同操作系统上,会自动选择对应的空设备文件:
/// - Windows: NUL
/// - Unix/Linux: /dev/null
///
/// 返回一个异步流,内容为一个空文件的数据流
///
/// # 错误处理
/// 如果无法打开空设备文件,会返回带有上下文信息的错误
pub async fn empty_stream() -> anyhow::Result<ReaderStream<File>> {
#[cfg(windows)]
let null = PathBuf::from("NUL");
#[cfg(not(windows))]
let null = PathBuf::from("/dev/null");
let empty = File::open(null)
.await
.with_context(|| "open /dev/null failed")?;
Ok(ReaderStream::new(empty))
}