14 Commits

Author SHA1 Message Date
xfy
d59b02fae2 chore: bump to 0.2.2 2025-07-03 22:06:32 +08:00
xfy
fffcee1d50 chore: update deps 2025-07-03 22:03:43 +08:00
xfy
25ff344474 feat(lua): add more const for lua 2025-07-02 01:21:52 +08:00
xfy
13c4570e20 fix: default internal error text 2025-07-02 01:16:49 +08:00
xfy
e92b191360 feat(lua): impl set body to lua 2025-07-02 01:11:48 +08:00
xfy
14196c937c feat: impl lua context 2025-07-02 00:55:52 +08:00
xfy
091f0c8eb5 feat: impl lua engine 2025-07-02 00:08:58 +08:00
xfy
f96e440264 chore: add more error detial 2025-07-01 21:38:23 +08:00
xfy
36cd153fab feat: add lua script route 2025-07-01 21:33:59 +08:00
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
11 changed files with 637 additions and 105 deletions

View File

@ -1,13 +1,20 @@
# Changelog
## 0.2.1
## 0.2.2 - 2025-07-03
Features:
- Support lua script
- Add max body size limit
## 0.2.1 - 2025-06-24
Features:
- `auto-index` support
- Stable rust version
## 0.2.0
## 0.2.0 - 2025-06-17
Features:
@ -15,7 +22,7 @@ Features:
- Refactor with axum
- SSL support
## 0.1.1
## 0.1.1 - 2024-07-02
Features:
@ -35,7 +42,7 @@ Fix:
- Custom error page
- Config tests
## 0.1.0
## 0.1.0 - 2024-05-13
Features:

208
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"
@ -386,7 +396,7 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "candy"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"anyhow",
"axum",
@ -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"
@ -1190,6 +1217,17 @@ dependencies = [
"hashbrown 0.15.2",
]
[[package]]
name = "io-uring"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013"
dependencies = [
"bitflags",
"cfg-if",
"libc",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@ -1221,6 +1259,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 +1365,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 +1401,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 +1462,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 +1626,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 +1707,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"
@ -1662,9 +1808,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "reqwest"
version = "0.12.20"
version = "0.12.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813"
checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531"
dependencies = [
"async-compression",
"base64 0.22.1",
@ -1730,6 +1876,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 +2009,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"
@ -2107,17 +2269,19 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.45.1"
version = "1.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
checksum = "1140bb80481756a8cbe10541f37433b459c5aa1e727b4c020fbfebdc25bf3ec4"
dependencies = [
"backtrace",
"bytes",
"io-uring",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"slab",
"socket2",
"tokio-macros",
"windows-sys 0.52.0",
@ -2339,6 +2503,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 +2719,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 +2914,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

@ -2,14 +2,14 @@
[package]
name = "candy"
version = "0.2.1"
version = "0.2.2"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
# core
tokio = { version = "1.45.1", features = ["full"] }
tokio = { version = "1.46.0", features = ["full"] }
tokio-util = "0.7.15"
tokio-rustls = "0.26.2"
hyper = { version = "1.6.0", features = ["full"] }
@ -23,7 +23,7 @@ axum-extra = { version = "0.10.1", features = ["typed-header"] }
axum-server = { version = "0.7.2", features = ["tls-rustls"] }
tower = { version = "0.5.2", features = ["full"] }
tower-http = { version = "0.6.6", features = ["full"] }
reqwest = { version = "0.12.20", features = [
reqwest = { version = "0.12.22", features = [
# "rustls-tls",
"native-tls-vendored",
"zstd",
@ -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

View File

@ -5,7 +5,7 @@ use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use const_format::{concatcp, formatcp};
use const_format::formatcp;
use serde_repr::*;
use tracing::error;
@ -36,7 +36,7 @@ pub enum ErrorCode {
}
/// Normal error message
const SERVER_ERROR_STR: &str = concatcp!(
const SERVER_ERROR_STR: &str = formatcp!(
r#"Internal Server Error
{NAME} v{VERSION}
Powered by RUA

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

@ -0,0 +1,149 @@
use std::str::FromStr;
use anyhow::{Context, anyhow};
use axum::{
body::Body,
extract::{Path, Request},
response::{IntoResponse, Response},
};
use axum_extra::extract::Host;
use http::{HeaderMap, HeaderName, HeaderValue, Uri};
use mlua::{UserData, UserDataMethods, UserDataRef};
use tokio::fs::{self};
use tracing::error;
use crate::{
http::{HOSTS, LUA_ENGINE, error::RouteError, serve::resolve_parent_path},
utils::parse_port_from_host,
};
use super::error::RouteResult;
/// 为 Lua 脚本提供 HTTP 请求上下文
#[derive(Clone, Debug)]
struct CandyRequest {
#[allow(dead_code)]
method: String,
/// Uri 在路由中被添加到上下文中
uri: Uri,
}
/// 为 Lua 脚本提供 HTTP 响应上下文
#[derive(Clone, Debug)]
struct CandyResponse {
status: u16,
headers: HeaderMap,
body: String,
}
// HTTP 请求上下文,可在 Lua 中使用
#[derive(Clone, Debug)]
struct RequestContext {
req: CandyRequest,
res: CandyResponse,
}
impl UserData for RequestContext {
fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
// 获取请求路径
methods.add_method("get_path", |_, this, ()| {
Ok(this.req.uri.path().to_string())
});
// 获取请求方法
methods.add_method("get_method", |_, this, ()| Ok(this.req.method.to_string()));
// 设置响应状态码
methods.add_method_mut("set_status", |_, this, status: u16| {
this.res.status = status;
Ok(())
});
// 设置响应内容
methods.add_method_mut("set_body", |_, this, body: String| {
this.res.body = format!("{}{}", this.res.body, body);
Ok(())
});
// 设置响应头
methods.add_method_mut("set_header", |_, this, (key, value): (String, String)| {
this.res.headers.insert(
HeaderName::from_str(&key).map_err(|err| anyhow!("header name error: {err}"))?,
HeaderValue::from_str(&value)
.map_err(|err| anyhow!("header value error: {err}"))?,
);
Ok(())
});
}
}
pub async fn lua(
req_uri: Uri,
path: Option<Path<String>>,
Host(host): Host,
req: Request<Body>,
) -> RouteResult<impl IntoResponse> {
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())
.with_context(|| {
format!("Hosts not found for port: {port}, host: {host}, scheme: {scheme}")
})?
.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())
.with_context(|| format!("route not found: {parent_path}"))?;
let lua_script = route_config
.lua_script
.as_ref()
.ok_or(RouteError::InternalError())
.with_context(|| "lua script not found")?;
let method = req.method().to_string();
let lua = &LUA_ENGINE.lua;
let script = fs::read_to_string(lua_script)
.await
.with_context(|| format!("Failed to read lua script file: {lua_script}",))?;
lua.globals()
.set(
"ctx",
RequestContext {
req: CandyRequest {
method,
uri: req_uri,
},
res: CandyResponse {
status: 200,
headers: HeaderMap::new(),
body: "".to_string(),
},
},
)
.map_err(|err| {
error!("Lua script {lua_script} exec error: {err}");
RouteError::InternalError()
})?;
lua.load(script).exec_async().await.map_err(|err| {
error!("Lua script {lua_script} exec error: {err}");
RouteError::InternalError()
})?;
// 获取修改后的上下文并返回响应
let ctx: UserDataRef<RequestContext> = lua.globals().get("ctx").map_err(|err| {
error!("Lua script {lua_script} exec error: {err}");
RouteError::InternalError()
})?;
let res = ctx.res.clone();
let mut response = Response::builder();
let body = Body::from(res.body);
response = response.status(res.status);
let response = response
.body(body)
.with_context(|| "Failed to build HTTP response with lua")?;
Ok(response)
}

View File

@ -1,15 +1,21 @@
use std::{net::SocketAddr, sync::LazyLock, time::Duration};
use std::{
net::SocketAddr,
sync::{Arc, 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};
use crate::{
config::SettingHost,
consts::{ARCH, COMMIT, COMPILER, NAME, OS, VERSION},
middlewares::{add_headers, add_version, logging_route},
utils::graceful_shutdown,
};
@ -19,6 +25,8 @@ pub mod error;
pub mod serve;
// handle reverse proxy
pub mod reverse_proxy;
// handle lua script
pub mod lua;
/// Host configuration
/// use virtual host port as key
@ -33,6 +41,80 @@ pub mod reverse_proxy;
/// }
pub static HOSTS: LazyLock<DashMap<u16, SettingHost>> = LazyLock::new(DashMap::new);
pub struct LuaEngine {
pub lua: Lua,
/// Lua 共享字典
#[allow(dead_code)]
pub shared_table: Arc<DashMap<String, String>>,
}
impl LuaEngine {
pub fn new() -> Self {
let lua = Lua::new();
let shared_table: DashMap<String, String> = DashMap::new();
let shared_table = Arc::new(shared_table);
let module = lua.create_table().expect("create table failed");
let shared_api = lua.create_table().expect("create shared table failed");
// 创建共享字典到 lua 中
let shared_table_get = shared_table.clone();
shared_api
.set(
"set",
lua.create_function(move |_, (key, value): (String, String)| {
shared_table_get.insert(key, value.clone());
Ok(())
})
.expect("create set function failed"),
)
.expect("set failed");
let shared_table_get = shared_table.clone();
shared_api
.set(
"get",
lua.create_function(move |_, key: String| {
let t = shared_table_get.get(&key).ok_or(anyhow!("key not found"))?;
Ok(t.clone())
})
.expect("create get function failed"),
)
.expect("get failed");
module
.set("shared", shared_api)
.expect("set shared_api failed");
// 日志函数
module
.set(
"log",
lua.create_function(move |_, msg: String| {
tracing::info!("Lua: {}", msg);
Ok(())
})
.expect("create log function failed"),
)
.expect("set log failed");
module.set("version", VERSION).expect("set version failed");
module.set("name", NAME).expect("set name failed");
module.set("os", OS).expect("set os failed");
module.set("arch", ARCH).expect("set arch failed");
module
.set("compiler", COMPILER)
.expect("set compiler failed");
module.set("commit", COMMIT).expect("set commit failed");
// 全局变量 candy
lua.globals()
.set("candy", module)
.expect("set candy table to lua engine failed");
Self { lua, shared_table }
}
}
/// lua 脚本执行器
pub static LUA_ENGINE: LazyLock<LuaEngine> = LazyLock::new(LuaEngine::new);
pub async fn make_server(host: SettingHost) -> anyhow::Result<()> {
let mut router = Router::new();
let host_to_save = host.clone();
@ -40,72 +122,95 @@ pub async fn make_server(host: SettingHost) -> anyhow::Result<()> {
// convert to axum routes
// register routes
for host_route in &host.route {
// 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/*
// lua script
if host_route.lua_script.is_some() {
// papare lua script
router = router.route(host_route.location.as_ref(), get(lua::lua));
let route_path = format!("{}{{*path}}", host_route.location);
router = router.route(route_path.as_ref(), get(reverse_proxy::serve));
router = router.route(route_path.as_ref(), get(lua::lua));
// 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;
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));
}
// 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());
.insert(host_route.location.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

@ -157,21 +157,30 @@ pub async fn serve(
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;
let route_map = &HOSTS
.get(&port)
.ok_or(RouteError::BadRequest())
.with_context(|| {
format!("Hosts not found for port: {port}, host: {host}, scheme: {scheme}")
})?
.route_map;
tracing::debug!("Route map entries: {:?}", route_map);
let parent_path = resolve_parent_path(&req_uri, path.as_ref());
tracing::debug!("parent path: {:?}", parent_path);
let proxy_config = route_map
.get(&parent_path)
.ok_or(RouteError::RouteNotFound())?;
.ok_or(RouteError::RouteNotFound())
.with_context(|| format!("route not found: {parent_path}"))?;
tracing::debug!("proxy pass: {:?}", proxy_config);
let Some(ref proxy_pass) = proxy_config.proxy_pass else {
return handle_custom_page(proxy_config, req, true).await;
};
let uri = format!("{proxy_pass}{path_query}");
tracing::debug!("reverse proxy uri: {:?}", &uri);
*req.uri_mut() = Uri::try_from(uri.clone()).map_err(|_| RouteError::InternalError())?;
*req.uri_mut() = Uri::try_from(uri.clone())
.map_err(|_| RouteError::InternalError())
.with_context(|| format!("uri not found: {uri}"))?;
let timeout = proxy_config.proxy_timeout;
@ -188,7 +197,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()
@ -211,7 +219,8 @@ pub async fn serve(
reqwest_response.headers(),
response_builder
.headers_mut()
.ok_or(RouteError::InternalError())?,
.ok_or(RouteError::InternalError())
.with_context(|| "headers not found")?,
);
let res = response_builder
.body(Body::from_stream(reqwest_response.bytes_stream()))

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};
@ -52,30 +52,34 @@ async fn custom_page(
host_route
.error_page
.as_ref()
.ok_or(RouteError::RouteNotFound())?
.ok_or(RouteError::RouteNotFound())
.with_context(|| "error page not found")?
} else {
host_route
.not_found_page
.as_ref()
.ok_or(RouteError::RouteNotFound())?
.ok_or(RouteError::RouteNotFound())
.with_context(|| "not found page not found")?
};
let root = host_route
.root
.as_ref()
.ok_or(RouteError::InternalError())?;
.ok_or(RouteError::InternalError())
.with_context(|| "root not found")?;
let path = format!("{}/{}", root, page.page);
let status = StatusCode::from_str(page.status.to_string().as_ref())
.map_err(|_| RouteError::BadRequest())?;
.map_err(|_| RouteError::BadRequest())
.with_context(|| format!("status code not found: {}", page.status))?;
tracing::debug!("custom not found path: {:?}", path);
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())
}
}
@ -125,11 +129,18 @@ pub async fn serve(
// which is `host_route.location`
let scheme = request.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;
let route_map = &HOSTS
.get(&port)
.ok_or(RouteError::BadRequest())
.with_context(|| {
format!("Hosts not found for port: {port}, host: {host}, scheme: {scheme}")
})?
.route_map;
debug!("Route map entries: {:?}", route_map);
let host_route = route_map
.get(&parent_path)
.ok_or(RouteError::RouteNotFound())?;
.ok_or(RouteError::RouteNotFound())
.with_context(|| format!("route not found: {parent_path}"))?;
debug!("route: {:?}", host_route);
// after route found
// check static file root configuration
@ -149,7 +160,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 +168,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 +184,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 +299,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 +622,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 +647,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))
}

View File

@ -71,7 +71,6 @@ pub async fn add_headers(Host(host): Host, req: Request, next: Next) -> impl Int
debug!("port {:?}", port);
let mut res = next.run(req).await;
let req_headers = res.headers_mut();
// let host = HOSTS.read().await;
let Some(host) = HOSTS.get(&port) else {
return res;
};