mirror of
https://github.com/DefectingCat/candy
synced 2025-07-15 16:51:34 +00:00
Compare commits
75 Commits
v0.1
...
515b36176d
Author | SHA1 | Date | |
---|---|---|---|
515b36176d | |||
46c2f90f92 | |||
64a85dd5a4 | |||
a1b3cecbea | |||
c9484397a1 | |||
00aa21cb79 | |||
b8dde2c801 | |||
d905af58f5 | |||
7ffc681825 | |||
e110af6c3d | |||
b0ce533bde | |||
1e17d517bf | |||
9c0120a51e | |||
f73fc1db67 | |||
0c925f289b | |||
6bb543a9bd | |||
61934b6c0d | |||
88098dca5c | |||
16bb3d33c8 | |||
b805cddf91 | |||
b4ac8c83c8 | |||
a6ddba3a2b | |||
4d795d1a62 | |||
879e51c493 | |||
70c4b29e7f | |||
10160ab5e5 | |||
f4c679fd10 | |||
ec68d20aed | |||
2bc6adce84 | |||
1b3fcf7443 | |||
136345e872 | |||
bcf088c347 | |||
8d35c26d90 | |||
6a47df5ba3 | |||
3d8c9d44e1 | |||
17ef670de5 | |||
d5751f1e14 | |||
f86bb996b1 | |||
02d02b68ee | |||
23523c6a37 | |||
b5656ff75a | |||
e870704cb6 | |||
fdbecfbbe9 | |||
87e34d655d | |||
3d0e8ba591 | |||
67df0d7db5 | |||
e0b5e1a280 | |||
c860ac1a55 | |||
ae612fc20d | |||
b37ff4607b | |||
07094124d0 | |||
60a12113a9 | |||
f9e0c7839f | |||
b313f870df | |||
a4187b831e | |||
58e7fdc255 | |||
718d13a13a | |||
c6ff8af73e | |||
f100fbd0ee | |||
6c0e6dd341 | |||
5be7cf0cf4 | |||
6756daf8ef | |||
a39d5a8a24 | |||
93629c75ae | |||
251f47f760 | |||
14cdf3b35d | |||
1fe812ef4c | |||
32e068c1a4 | |||
c879e07a63 | |||
45d8c36136 | |||
e8c425d5fe | |||
c66a3f1322 | |||
654a51227b | |||
d862e2eece | |||
e169478bbf |
@ -8,7 +8,7 @@ charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{yml,yaml}]
|
||||
[*.{yml,yaml,md}]
|
||||
indent_size = 2
|
||||
|
||||
[Makefile]
|
||||
|
59
.github/workflows/macos-arm.yml
vendored
Normal file
59
.github/workflows/macos-arm.yml
vendored
Normal file
@ -0,0 +1,59 @@
|
||||
name: Candy macOS arm
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [""]
|
||||
pull_request:
|
||||
branches: [""]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ["self-hosted", "macos-arm"]
|
||||
steps:
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
- uses: actions/checkout@v4
|
||||
- name: Clippy check
|
||||
run: make lint
|
||||
|
||||
test:
|
||||
runs-on: ["self-hosted", "macos-arm"]
|
||||
steps:
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
- uses: actions/checkout@v4
|
||||
- name: Unit test
|
||||
run: make test
|
||||
|
||||
build:
|
||||
runs-on: ["self-hosted", "macos-arm"]
|
||||
steps:
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build release
|
||||
run: make release
|
59
.github/workflows/macos-x86.yml
vendored
Normal file
59
.github/workflows/macos-x86.yml
vendored
Normal file
@ -0,0 +1,59 @@
|
||||
name: Candy macOS x86
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [""]
|
||||
pull_request:
|
||||
branches: [""]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ["self-hosted", "macos-x86"]
|
||||
steps:
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
- uses: actions/checkout@v4
|
||||
- name: Clippy check
|
||||
run: make lint
|
||||
|
||||
test:
|
||||
runs-on: ["self-hosted", "macos-x86"]
|
||||
steps:
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
- uses: actions/checkout@v4
|
||||
- name: Unit test
|
||||
run: make test
|
||||
|
||||
build:
|
||||
runs-on: ["self-hosted", "macos-x86"]
|
||||
steps:
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build release
|
||||
run: make release
|
29
CHANGELOG.md
29
CHANGELOG.md
@ -1,8 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.1
|
||||
|
||||
Features:
|
||||
|
||||
- `auto-index` support
|
||||
- Stable rust version
|
||||
|
||||
## 0.2.0
|
||||
|
||||
Features:
|
||||
|
||||
- Reverse proxy
|
||||
- Refactor with axum
|
||||
- SSL support
|
||||
|
||||
## 0.1.1
|
||||
|
||||
Features:
|
||||
|
||||
- Gitlab CI integration
|
||||
- GitLab CI integration
|
||||
- FreeBSD support
|
||||
- Reverse proxy
|
||||
- Connection timeout
|
||||
@ -24,11 +41,11 @@ Features:
|
||||
|
||||
- Graceful shutdown
|
||||
- `keep-alive` timeout setting
|
||||
- HTTP Etag: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag#etag_value
|
||||
- HTTP Etag: <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag#etag_value>
|
||||
- Content compress
|
||||
- zstd
|
||||
- gzip
|
||||
- deflate
|
||||
- br
|
||||
- zstd
|
||||
- gzip
|
||||
- deflate
|
||||
- br
|
||||
- Stream file
|
||||
- Stream content compress
|
||||
|
1654
Cargo.lock
generated
1654
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
101
Cargo.toml
101
Cargo.toml
@ -1,76 +1,77 @@
|
||||
cargo-features = ["profile-rustflags", "trim-paths"]
|
||||
# cargo-features = ["profile-rustflags", "trim-paths"]
|
||||
|
||||
[package]
|
||||
name = "candy"
|
||||
version = "0.1.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.80.0"
|
||||
|
||||
[features]
|
||||
ring = ["rustls/ring", "tokio-rustls/ring", "hyper-rustls/ring"]
|
||||
aws-lc-rs = [
|
||||
"rustls/aws-lc-rs",
|
||||
"tokio-rustls/aws-lc-rs",
|
||||
"hyper-rustls/aws-lc-rs",
|
||||
]
|
||||
default = ["ring"]
|
||||
version = "0.2.1"
|
||||
edition = "2024"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
# core
|
||||
tokio = { version = "1.44.1", features = ["full"] }
|
||||
tokio-util = "0.7.14"
|
||||
tokio = { version = "1.45.1", features = ["full"] }
|
||||
tokio-util = "0.7.15"
|
||||
tokio-rustls = "0.26.2"
|
||||
hyper = { version = "1.6.0", features = ["full"] }
|
||||
hyper-util = { version = "0.1.10", features = ["full"] }
|
||||
hyper-util = { version = "0.1.14", features = ["full"] }
|
||||
http = "1.3.1"
|
||||
http-body-util = "0.1.3"
|
||||
futures-util = "0.3.31"
|
||||
async-compression = { version = "0.4.22", features = [
|
||||
"tokio",
|
||||
mimalloc = "0.1.47"
|
||||
axum = { version = "0.8.4", features = ["macros"] }
|
||||
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 = [
|
||||
# "rustls-tls",
|
||||
"native-tls-vendored",
|
||||
"zstd",
|
||||
"gzip",
|
||||
"deflate",
|
||||
"brotli",
|
||||
'gzip',
|
||||
'brotli',
|
||||
'deflate',
|
||||
"stream",
|
||||
] }
|
||||
mimalloc = "0.1.44"
|
||||
# ssl
|
||||
rustls = { version = "0.23.25", default-features = false, features = [
|
||||
"logging",
|
||||
"std",
|
||||
"tls12",
|
||||
] }
|
||||
tokio-rustls = { version = "0.26.2", default-features = false, features = [
|
||||
"logging",
|
||||
"tls12",
|
||||
] }
|
||||
hyper-rustls = { version = "0.27.5", default-features = false, features = [
|
||||
"logging",
|
||||
"tls12",
|
||||
"native-tokio",
|
||||
"http1",
|
||||
# lua
|
||||
mlua = { version = "0.10.5", features = [
|
||||
"lua54",
|
||||
"vendored",
|
||||
"send",
|
||||
"async",
|
||||
"macros",
|
||||
"serialize",
|
||||
"anyhow",
|
||||
] }
|
||||
# tools
|
||||
thiserror = "2.0.12"
|
||||
anyhow = "1.0.97"
|
||||
anyhow = "1.0.98"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
toml = "0.8.20"
|
||||
toml = "0.8.23"
|
||||
bytes = "1.10.1"
|
||||
const_format = "0.2.34"
|
||||
md5 = "0.8.0"
|
||||
dashmap = { version = "6.1.0", features = ["serde"] }
|
||||
# logging
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||
clap = { version = "4.5.33", features = ["derive"] }
|
||||
rustls-pemfile = "2.2.0"
|
||||
clap = { version = "4.5.40", features = ["derive"] }
|
||||
serde_repr = "0.1.20"
|
||||
serde_json = "1.0.140"
|
||||
mime_guess = "2.0.5"
|
||||
chrono = "0.4.41"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.20.0"
|
||||
|
||||
[profile.dev]
|
||||
incremental = true # Compile your binary in smaller steps.
|
||||
rustflags = ["-Zthreads=8"] # Better compile performance.
|
||||
incremental = true # Compile your binary in smaller steps.
|
||||
# rustflags = ["-Zthreads=8"] # Better compile performance.
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1 # Allows LLVM to perform better optimization.
|
||||
lto = true # Enables link-time-optimizations.
|
||||
opt-level = 3 # Prioritizes small binary size. Use `3` if you prefer speed.
|
||||
panic = "abort" # Higher performance by disabling panic handlers.
|
||||
strip = true # Ensures debug symbols are removed.
|
||||
trim-paths = "all" # Removes potentially privileged information from your binaries.
|
||||
rustflags = ["-Cdebuginfo=0", "-C", "target-cpu=native", "-Z", "threads=8"]
|
||||
codegen-units = 1 # Allows LLVM to perform better optimization.
|
||||
lto = true # Enables link-time-optimizations.
|
||||
opt-level = 3 # Prioritizes small binary size. Use `3` if you prefer speed.
|
||||
panic = "abort" # Higher performance by disabling panic handlers.
|
||||
strip = true # Ensures debug symbols are removed.
|
||||
# trim-paths = "all" # Removes potentially privileged information from your binaries.
|
||||
# rustflags = ["-Cdebuginfo=0", "-C", "target-cpu=native", "-Z", "threads=8"]
|
||||
|
2
Makefile
2
Makefile
@ -14,7 +14,7 @@ dev:
|
||||
CANDY_LOG=debug $(CARGO) watch -x run
|
||||
|
||||
run:
|
||||
$(CARGO) run
|
||||
CANDY_LOG=debug $(CARGO) run
|
||||
|
||||
test:
|
||||
$(CARGO) test
|
||||
|
62
TODO.md
62
TODO.md
@ -1,49 +1,51 @@
|
||||
## TODO
|
||||
# TODO
|
||||
|
||||
- [ ] Dockerization
|
||||
- [ ] Docs
|
||||
- [x] Build with compile info
|
||||
- [x] Refactor HTTP 1
|
||||
- [x] Graceful shutdown
|
||||
- [x] `keep-alive` timeout setting
|
||||
- [x] HTTP Etag: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag#etag_value
|
||||
- [x] Content compress
|
||||
- [x] zstd
|
||||
- [x] gzip
|
||||
- [x] deflate
|
||||
- [x] br
|
||||
## Configuration
|
||||
|
||||
### Configuration
|
||||
|
||||
- [x] File MIME type
|
||||
- [x] Overwrite headers in config
|
||||
- [x] Config init tests
|
||||
- [x] Error page
|
||||
- [x] Custom error page with stats code
|
||||
- [ ] Logging to file
|
||||
- [ ] Benchs
|
||||
- [ ] Benches
|
||||
- [ ] Max body size
|
||||
- [ ] HTTP 2
|
||||
- [x] HTTP 2
|
||||
|
||||
### Features
|
||||
## Features
|
||||
|
||||
- [x] Cross platform compile
|
||||
- [x] x86_64-unknown-linux-gnu
|
||||
- [x] x86_64-unknown-linux-musl
|
||||
- [x] aarch64-unknown-linux-gnu
|
||||
- [x] aarch64-unknown-linux-musl
|
||||
- [x] x86_64-pc-windows-gnu
|
||||
- [x] x86_64-unknown-freebsd
|
||||
- [x] loongarch64-unknown-linux-gnu
|
||||
- [x] x86_64-unknown-linux-gnu
|
||||
- [x] x86_64-unknown-linux-musl
|
||||
- [x] aarch64-unknown-linux-gnu
|
||||
- [x] aarch64-unknown-linux-musl
|
||||
- [x] x86_64-pc-windows-gnu
|
||||
- [x] x86_64-unknown-freebsd
|
||||
- [x] loongarch64-unknown-linux-gnu
|
||||
- [ ] HTTP redirect
|
||||
- [ ] Load balance
|
||||
- [ ] Pingora
|
||||
- [ ] Proxy
|
||||
- [x] Reverse Proxy
|
||||
- [x] Connect to upstream timeout setting
|
||||
- [x] Follow http 301
|
||||
- [x] Custom headers
|
||||
- [x] Connect to upstream timeout setting
|
||||
- [x] Follow http 301
|
||||
- [x] Custom headers
|
||||
- [x] SSL
|
||||
- [x] Cli
|
||||
- [x] Specific custom config location
|
||||
- [x] HTTP 2
|
||||
- [ ] HTTP 3
|
||||
- [ ] Specify thread numbers
|
||||
- [ ] Dockerization
|
||||
- [ ] Docs
|
||||
- [ ] Multiple virtual hosts
|
||||
- [x] Build with compile info
|
||||
- [x] Refactor HTTP 1
|
||||
- [x] Graceful shutdown
|
||||
- [x] `keep-alive` timeout setting
|
||||
- [x] HTTP Etag: <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag#etag_value>
|
||||
- [x] 304 Not Modified
|
||||
- [ ] List directory
|
||||
- [x] Content compress
|
||||
- [x] zstd
|
||||
- [x] gzip
|
||||
- [x] deflate
|
||||
- [x] br
|
||||
|
@ -1,9 +1,15 @@
|
||||
[[host]]
|
||||
ip = "0.0.0.0"
|
||||
port = 4000
|
||||
|
||||
port = 80
|
||||
[[host.route]]
|
||||
# route path: GET /
|
||||
location = "/"
|
||||
# static file path
|
||||
root = "./html"
|
||||
|
||||
[[host]]
|
||||
ip = "0.0.0.0"
|
||||
port = 8080
|
||||
[[host.route]]
|
||||
location = "/"
|
||||
proxy_pass = "https://www.google.com/"
|
||||
|
@ -1,47 +1,56 @@
|
||||
# Default file type for unknow file
|
||||
default_type = "application/octet-stream"
|
||||
# Custom MIME types
|
||||
[types]
|
||||
wasm = "application/wasm"
|
||||
|
||||
# Virtual host
|
||||
[[host]]
|
||||
ip = "0.0.0.0"
|
||||
port = 4000
|
||||
port = 80
|
||||
# Connection timeout
|
||||
timeout = 15
|
||||
# SSL certificate
|
||||
# ssl enable
|
||||
# ssl = true
|
||||
# Self sign a certificate
|
||||
# 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]]
|
||||
# If has index field, it will be used as static file host
|
||||
index = ["index.html"]
|
||||
# Route location
|
||||
location = "/"
|
||||
root = "./html"
|
||||
[host.route.error_page]
|
||||
status = 404
|
||||
page = "404.html"
|
||||
|
||||
[[host.route]]
|
||||
# Static file root
|
||||
# or proxy_pass
|
||||
# or redirect
|
||||
root = "html"
|
||||
# Only use for root field
|
||||
index = ["index.html"]
|
||||
location = "/candy/"
|
||||
root = "./docs/build"
|
||||
# List directory
|
||||
auto_index = true
|
||||
# Custom 500 page
|
||||
[host.route.error_page]
|
||||
status = 500
|
||||
page = "500.html"
|
||||
# Custom 404 page
|
||||
[host.route.not_found_page]
|
||||
status = 404
|
||||
page = "404.html"
|
||||
|
||||
# Reverse proxy
|
||||
[[host]]
|
||||
ip = "0.0.0.0"
|
||||
port = 8080
|
||||
[[host.route]]
|
||||
location = "/proxy/"
|
||||
# If has root field, it will be used as reverse 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"
|
||||
|
@ -1,2 +0,0 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
139
src/config.rs
139
src/config.rs
@ -1,13 +1,11 @@
|
||||
use crate::{
|
||||
consts::{
|
||||
host_index, insert_default_mimes, mime_default, timeout_default, types_default,
|
||||
upstream_timeout_default,
|
||||
},
|
||||
consts::{default_disabled, host_index, timeout_default, upstream_timeout_default},
|
||||
error::Result,
|
||||
};
|
||||
use std::{borrow::Cow, collections::BTreeMap, fs};
|
||||
use std::fs;
|
||||
|
||||
use anyhow::Context;
|
||||
use dashmap::DashMap;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
@ -21,61 +19,74 @@ pub struct ErrorRoute {
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct SettingRoute {
|
||||
/// The register route
|
||||
/// for axum route
|
||||
pub location: String,
|
||||
/// The static assets root folder
|
||||
pub root: Option<String>,
|
||||
/// List directory
|
||||
#[serde(default = "default_disabled")]
|
||||
pub auto_index: bool,
|
||||
|
||||
/// Index files format
|
||||
#[serde(default = "host_index")]
|
||||
pub index: Vec<String>,
|
||||
/// Custom error page
|
||||
pub error_page: Option<ErrorRoute>,
|
||||
/// Custom 404 page
|
||||
pub not_found_page: Option<ErrorRoute>,
|
||||
|
||||
/// Reverse proxy url
|
||||
pub proxy_pass: Option<String>,
|
||||
/// 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
|
||||
/// Each host can have multiple routes
|
||||
pub type HostRouteMap = BTreeMap<String, SettingRoute>;
|
||||
pub type HostRouteMap = DashMap<String, SettingRoute>;
|
||||
/// headers
|
||||
pub type HeaderMap = DashMap<String, String>;
|
||||
|
||||
/// Virtual host
|
||||
/// Each host can listen on one port and one ip
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
#[derive(Deserialize, Clone, Debug, Default)]
|
||||
pub struct SettingHost {
|
||||
/// Host ip
|
||||
pub ip: String,
|
||||
/// Host port
|
||||
pub port: u32,
|
||||
pub port: u16,
|
||||
/// SSL enable
|
||||
#[serde(default = "default_disabled")]
|
||||
pub ssl: bool,
|
||||
/// SSL certificate location
|
||||
pub certificate: Option<String>,
|
||||
/// ssl key location
|
||||
pub certificate_key: Option<String>,
|
||||
route: Vec<Option<SettingRoute>>,
|
||||
/// Host route map
|
||||
#[serde(skip_deserializing, skip_serializing)]
|
||||
/// Routes in config file
|
||||
pub route: Vec<SettingRoute>,
|
||||
/// Host routes convert from Vec<SettingRoute> to DashMap<String, SettingRoute>
|
||||
/// {
|
||||
/// "/doc": <SettingRoute>
|
||||
/// }
|
||||
#[serde(skip)]
|
||||
pub route_map: HostRouteMap,
|
||||
/// HTTP keep-alive timeout
|
||||
#[serde(default = "timeout_default")]
|
||||
pub timeout: u16,
|
||||
/// HTTP headers
|
||||
/// Used to overwrite headers in config
|
||||
pub headers: Option<BTreeMap<String, String>>,
|
||||
pub headers: Option<HeaderMap>,
|
||||
}
|
||||
|
||||
pub type MIMEType = BTreeMap<Cow<'static, str>, Cow<'static, str>>;
|
||||
|
||||
/// Whole config settings
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
#[derive(Deserialize, Clone, Debug, Default)]
|
||||
pub struct Settings {
|
||||
/// Default file type for unknow file
|
||||
#[serde(default = "mime_default")]
|
||||
pub default_type: Cow<'static, str>,
|
||||
/// MIME types
|
||||
#[serde(default = "types_default")]
|
||||
pub types: MIMEType,
|
||||
/// Virtual host
|
||||
pub host: Vec<SettingHost>,
|
||||
}
|
||||
@ -83,21 +94,77 @@ pub struct Settings {
|
||||
impl Settings {
|
||||
pub fn new(path: &str) -> Result<Self> {
|
||||
let file = fs::read_to_string(path).with_context(|| format!("read {path} failed"))?;
|
||||
let mut settings: Settings = toml::from_str(&file)?;
|
||||
|
||||
// convert route map
|
||||
settings.host.iter_mut().for_each(|host| {
|
||||
host.route
|
||||
.iter_mut()
|
||||
.filter_map(Option::take)
|
||||
.for_each(|route| {
|
||||
host.route_map.insert(route.location.to_string(), route);
|
||||
});
|
||||
});
|
||||
|
||||
// combine mime types
|
||||
insert_default_mimes(&mut settings.types);
|
||||
|
||||
let settings: Settings = toml::from_str(&file)?;
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[test]
|
||||
fn test_settings_new() {
|
||||
// Create a temporary TOML config file
|
||||
let mut file = NamedTempFile::new().unwrap();
|
||||
writeln!(
|
||||
file,
|
||||
r#"
|
||||
default_type = "text/plain"
|
||||
types = {{ "txt" = "text/plain", "html" = "text/html" }}
|
||||
|
||||
[[host]]
|
||||
ip = "127.0.0.1"
|
||||
port = 8080
|
||||
ssl = false
|
||||
timeout = 30
|
||||
|
||||
[[host.route]]
|
||||
location = "/"
|
||||
root = "/var/www"
|
||||
index = ["index.html", "index.txt"]
|
||||
proxy_timeout = 10
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let path = file.path().to_str().unwrap();
|
||||
let settings = Settings::new(path).unwrap();
|
||||
|
||||
// Verify host settings
|
||||
let host = &settings.host[0];
|
||||
assert_eq!(host.ip, "127.0.0.1");
|
||||
assert_eq!(host.port, 8080);
|
||||
assert_eq!(host.timeout, 30);
|
||||
|
||||
// Verify route settings
|
||||
let route = &host.route[0];
|
||||
assert_eq!(route.location, "/");
|
||||
assert_eq!(route.root, Some("/var/www".to_string()));
|
||||
assert_eq!(route.proxy_timeout, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_missing_file() {
|
||||
let result = Settings::new("nonexistent.toml");
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("read nonexistent.toml failed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_invalid_toml() {
|
||||
let mut file = NamedTempFile::new().unwrap();
|
||||
writeln!(file, "invalid toml content").unwrap();
|
||||
|
||||
let path = file.path().to_str().unwrap();
|
||||
let result = Settings::new(path);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
@ -1,15 +1,4 @@
|
||||
use std::{borrow::Cow, collections::BTreeMap, env, sync::OnceLock};
|
||||
|
||||
use crate::{
|
||||
config::{MIMEType, Settings},
|
||||
error::{Error, Result},
|
||||
};
|
||||
|
||||
// global settings
|
||||
pub static SETTINGS: OnceLock<Settings> = OnceLock::new();
|
||||
pub fn get_settings() -> Result<&'static Settings> {
|
||||
SETTINGS.get().ok_or(Error::Empty)
|
||||
}
|
||||
use std::env;
|
||||
|
||||
// pre defined
|
||||
pub const NAME: &str = env!("CARGO_PKG_NAME");
|
||||
@ -31,88 +20,13 @@ pub fn timeout_default() -> u16 {
|
||||
TIMEOUT_EFAULT
|
||||
}
|
||||
|
||||
// default mime type for unknow file
|
||||
pub const MIME_DEFAULT: &str = "application/octet-stream";
|
||||
pub fn mime_default() -> Cow<'static, str> {
|
||||
MIME_DEFAULT.into()
|
||||
}
|
||||
|
||||
// default reverse proxy upstream timeout
|
||||
pub const UPSTREAM_TIMEOUT: u16 = 5;
|
||||
pub fn upstream_timeout_default() -> u16 {
|
||||
UPSTREAM_TIMEOUT
|
||||
}
|
||||
|
||||
// default mime types
|
||||
pub fn types_default() -> MIMEType {
|
||||
BTreeMap::new()
|
||||
}
|
||||
macro_rules! insert_mime {
|
||||
($name:literal, $mime:ident, $map:ident) => {
|
||||
$map.entry($name.into()).or_insert($mime.into());
|
||||
};
|
||||
}
|
||||
pub fn insert_default_mimes(map: &mut MIMEType) {
|
||||
use crate::http::mime::*;
|
||||
|
||||
insert_mime!("html", TEXT_HTML, map);
|
||||
insert_mime!("htm", TEXT_HTML, map);
|
||||
insert_mime!("shtml", TEXT_HTML, map);
|
||||
insert_mime!("css", TEXT_CSS, map);
|
||||
insert_mime!("xml", TEXT_XML, map);
|
||||
insert_mime!("rss", TEXT_XML, map);
|
||||
insert_mime!("txt", TEXT_PLAIN, map);
|
||||
|
||||
insert_mime!("gif", IMAGE_GIF, map);
|
||||
insert_mime!("jpg", IMAGE_JPEG, map);
|
||||
insert_mime!("jpeg", IMAGE_JPEG, map);
|
||||
insert_mime!("png", IMAGE_PNG, map);
|
||||
insert_mime!("ico", IMAGE_ICON, map);
|
||||
insert_mime!("jng", IMAGE_JNG, map);
|
||||
insert_mime!("wbmp", IMAGE_WBMP, map);
|
||||
insert_mime!("webp", IMAGE_WEBP, map);
|
||||
insert_mime!("avif", IMAGE_AVIF, map);
|
||||
insert_mime!("svg", IMAGE_SVG, map);
|
||||
insert_mime!("svgz", IMAGE_SVG, map);
|
||||
insert_mime!("tif", IMAGE_TIFF, map);
|
||||
insert_mime!("tiff", IMAGE_TIFF, map);
|
||||
insert_mime!("bmp", IMAGE_BMP, map);
|
||||
|
||||
insert_mime!("js", APPLICATION_JAVASCRIPT, map);
|
||||
insert_mime!("wasm", APPLICATION_WASM, map);
|
||||
insert_mime!("json", APPLICATION_JSON, map);
|
||||
insert_mime!("jar", APPLICATION_JAVA_ARCHIVE, map);
|
||||
insert_mime!("war", APPLICATION_JAVA_ARCHIVE, map);
|
||||
insert_mime!("ear", APPLICATION_JAVA_ARCHIVE, map);
|
||||
insert_mime!("m3u8", APPLICATION_APPLE_MPEGURL, map);
|
||||
insert_mime!("bin", APPLICATION_OCTET_STREAM, map);
|
||||
insert_mime!("exe", APPLICATION_OCTET_STREAM, map);
|
||||
insert_mime!("dll", APPLICATION_OCTET_STREAM, map);
|
||||
insert_mime!("deb", APPLICATION_OCTET_STREAM, map);
|
||||
insert_mime!("dmg", APPLICATION_OCTET_STREAM, map);
|
||||
insert_mime!("iso", APPLICATION_OCTET_STREAM, map);
|
||||
insert_mime!("img", APPLICATION_OCTET_STREAM, map);
|
||||
insert_mime!("msi", APPLICATION_OCTET_STREAM, map);
|
||||
insert_mime!("msp", APPLICATION_OCTET_STREAM, map);
|
||||
insert_mime!("msm", APPLICATION_OCTET_STREAM, map);
|
||||
|
||||
insert_mime!("woff", FONT_WOFF, map);
|
||||
insert_mime!("woff2", FONT_WOFF2, map);
|
||||
|
||||
insert_mime!("ts", VIDEO_MP2T, map);
|
||||
insert_mime!("3gpp", VIDEO_3GPP, map);
|
||||
insert_mime!("3gp", VIDEO_3GPP, map);
|
||||
insert_mime!("mp4", VIDEO_MP4, map);
|
||||
insert_mime!("mpeg", VIDEO_MPEG, map);
|
||||
insert_mime!("mpg", VIDEO_MPEG, map);
|
||||
insert_mime!("mov", VIDEO_QUICKTIME, map);
|
||||
insert_mime!("webm", VIDEO_WEBM, map);
|
||||
|
||||
insert_mime!("flv", VIDEO_X_FLV, map);
|
||||
insert_mime!("m4v", VIDEO_X_M4V, map);
|
||||
insert_mime!("mng", VIDEO_X_MNG, map);
|
||||
insert_mime!("asx", VIDEO_X_MS_ASF, map);
|
||||
insert_mime!("asf", VIDEO_X_MS_ASF, map);
|
||||
insert_mime!("wmv", VIDEO_X_MS_WMV, map);
|
||||
insert_mime!("avi", VIDEO_X_MSVIDEO, map);
|
||||
// default boolean false
|
||||
pub fn default_disabled() -> bool {
|
||||
false
|
||||
}
|
||||
|
23
src/error.rs
23
src/error.rs
@ -1,8 +1,7 @@
|
||||
use std::{borrow::Cow, io, num::TryFromIntError, sync::PoisonError, time::SystemTimeError};
|
||||
use std::{io, num::TryFromIntError, time::SystemTimeError};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use http::uri::InvalidUri;
|
||||
use hyper::header::{InvalidHeaderValue, ToStrError};
|
||||
use hyper::header::ToStrError;
|
||||
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
@ -24,24 +23,8 @@ pub enum Error {
|
||||
InvalidUri(#[from] InvalidUri),
|
||||
#[error("hyper {0}")]
|
||||
HyperError(#[from] hyper::Error),
|
||||
|
||||
// http
|
||||
#[error("route not found {0}")]
|
||||
NotFound(Cow<'static, str>),
|
||||
#[error("internal server error {0}")]
|
||||
InternalServerError(#[from] anyhow::Error),
|
||||
#[error("invalide header value {0}")]
|
||||
InvalidHeader(#[from] InvalidHeaderValue),
|
||||
#[error("")]
|
||||
Empty,
|
||||
#[error("missing header {0}")]
|
||||
MissingHeader(&'static str),
|
||||
Any(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
pub type Result<T, E = Error> = anyhow::Result<T, E>;
|
||||
|
||||
impl<T> From<PoisonError<T>> for Error {
|
||||
fn from(err: PoisonError<T>) -> Self {
|
||||
Self::InternalServerError(anyhow!("global cache poisoned {err}"))
|
||||
}
|
||||
}
|
||||
|
@ -1,101 +0,0 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
use bytes::Bytes;
|
||||
use http::{request::Parts, HeaderValue, Request, Response, Uri};
|
||||
use http_body_util::Full;
|
||||
use hyper::body::Incoming;
|
||||
use hyper_rustls::ConfigBuilderExt;
|
||||
use hyper_util::{client::legacy::Client, rt::TokioExecutor};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
const MAX_REDIRECTS: usize = 10;
|
||||
|
||||
/// Get http response
|
||||
///
|
||||
/// ## Arguments
|
||||
///
|
||||
/// `url`: http url
|
||||
/// `parts`: http request parts
|
||||
/// `body`: http request body
|
||||
///
|
||||
/// ## Return
|
||||
///
|
||||
/// `anyhow::Result<Response<Incoming>>`
|
||||
pub async fn get_inner(url: Uri, parts: Parts, body: Bytes) -> anyhow::Result<Response<Incoming>> {
|
||||
// Set a process wide default crypto provider.
|
||||
#[cfg(feature = "ring")]
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
#[cfg(feature = "aws-lc-rs")]
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
|
||||
// Prepare the TLS client config
|
||||
// Default TLS client config with native roots
|
||||
let tls = rustls::ClientConfig::builder()
|
||||
.with_native_roots()?
|
||||
.with_no_client_auth();
|
||||
|
||||
// Prepare the HTTPS connector
|
||||
let https = hyper_rustls::HttpsConnectorBuilder::new()
|
||||
.with_tls_config(tls)
|
||||
.https_or_http()
|
||||
.enable_http1()
|
||||
.build();
|
||||
|
||||
// Build the hyper client from the HTTPS connector.
|
||||
let client: Client<_, Full<Bytes>> = Client::builder(TokioExecutor::new()).build(https);
|
||||
let host_url = url.clone();
|
||||
let host = host_url.host().ok_or(Error::InternalServerError(anyhow!(
|
||||
"proxy pass host incorrect"
|
||||
)))?;
|
||||
let mut req: Request<Full<Bytes>> = hyper::Request::builder()
|
||||
.method(parts.method.clone())
|
||||
.uri(url)
|
||||
.body(Full::from(body))
|
||||
.with_context(|| "request builder")?;
|
||||
// Add client request headers to request, and remove host header
|
||||
req.headers_mut().extend(parts.headers);
|
||||
req.headers_mut()
|
||||
.insert("host", HeaderValue::from_str(host)?);
|
||||
|
||||
let res = client.request(req).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Get http response Body
|
||||
/// And follo redirects
|
||||
///
|
||||
/// ## Arguments
|
||||
///
|
||||
/// `url`: http url
|
||||
/// `parts`: http request parts
|
||||
/// `body`: http request body
|
||||
///
|
||||
/// ## Return
|
||||
///
|
||||
/// `anyhow::Result<Response<Incoming>>`
|
||||
pub async fn get(url: Uri, parts: Parts, body: Bytes) -> anyhow::Result<Response<Incoming>> {
|
||||
let mut redirects = 0;
|
||||
|
||||
let mut res = get_inner(url, parts.clone(), body.clone()).await?;
|
||||
while (res.status() == 301 || res.status() == 302) && redirects < MAX_REDIRECTS {
|
||||
let (parts_inner, body_inner) = (parts.clone(), body.clone());
|
||||
redirects += 1;
|
||||
let location = res
|
||||
.headers()
|
||||
.get("location")
|
||||
.ok_or(Error::MissingHeader("location"))
|
||||
.with_context(|| "missing header location")?
|
||||
.to_str()
|
||||
.with_context(|| "failed to convert header value to str")?
|
||||
.to_string();
|
||||
let url = Uri::from_str(&location).with_context(|| "failed to convert str to url")?;
|
||||
debug!("proxy redirect to {url}");
|
||||
res = get_inner(url, parts_inner, body_inner).await?;
|
||||
}
|
||||
|
||||
debug!("get_inner response headers: {:?}", res.headers());
|
||||
Ok(res)
|
||||
}
|
100
src/http/error.rs
Normal file
100
src/http/error.rs
Normal file
@ -0,0 +1,100 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use crate::consts::{NAME, VERSION};
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use const_format::{concatcp, formatcp};
|
||||
use serde_repr::*;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum RouteError {
|
||||
// Common errors
|
||||
#[error("{0}")]
|
||||
Any(#[from] anyhow::Error),
|
||||
#[error("{0}")]
|
||||
Infallible(#[from] std::convert::Infallible),
|
||||
|
||||
// Route errors
|
||||
#[error("route not found")]
|
||||
RouteNotFound(),
|
||||
#[error("internal error")]
|
||||
InternalError(),
|
||||
#[error("bad request")]
|
||||
BadRequest(),
|
||||
}
|
||||
|
||||
#[derive(Serialize_repr, Deserialize_repr, PartialEq, Debug)]
|
||||
#[repr(u16)]
|
||||
pub enum ErrorCode {
|
||||
Normal = 200,
|
||||
InternalError = 500,
|
||||
NotFound = 404,
|
||||
BadRequest = 400,
|
||||
}
|
||||
|
||||
/// Normal error message
|
||||
const SERVER_ERROR_STR: &str = concatcp!(
|
||||
r#"Internal Server Error
|
||||
{NAME} v{VERSION}
|
||||
Powered by RUA
|
||||
"#
|
||||
);
|
||||
|
||||
/// Not found error message
|
||||
const NOT_FOUND_STR: &str = formatcp!(
|
||||
r#"Resource Not Found
|
||||
{NAME} v{VERSION}
|
||||
Powered by RUA
|
||||
"#
|
||||
);
|
||||
|
||||
const BAD_REQUEST_STR: &str = formatcp!(
|
||||
r#"Bad Request
|
||||
{NAME} v{VERSION}
|
||||
Powered by RUA
|
||||
"#
|
||||
);
|
||||
|
||||
impl Display for ErrorCode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
use ErrorCode::*;
|
||||
|
||||
let res = match self {
|
||||
Normal => "",
|
||||
InternalError => SERVER_ERROR_STR,
|
||||
NotFound => NOT_FOUND_STR,
|
||||
BadRequest => BAD_REQUEST_STR,
|
||||
};
|
||||
f.write_str(res)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Log and return INTERNAL_SERVER_ERROR
|
||||
fn log_internal_error<T: Display>(err: T) -> (StatusCode, String) {
|
||||
use ErrorCode::*;
|
||||
|
||||
error!("{err}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, InternalError.to_string())
|
||||
}
|
||||
|
||||
// Tell axum how to convert `AppError` into a response.
|
||||
impl IntoResponse for RouteError {
|
||||
fn into_response(self) -> Response {
|
||||
use RouteError::*;
|
||||
|
||||
let (status_code, err_message) = match self {
|
||||
Any(err) => log_internal_error(err),
|
||||
RouteNotFound() => (StatusCode::NOT_FOUND, ErrorCode::NotFound.to_string()),
|
||||
InternalError() => (StatusCode::NOT_FOUND, ErrorCode::InternalError.to_string()),
|
||||
// Infallible(infallible) => todo!(),
|
||||
BadRequest() => (StatusCode::NOT_FOUND, ErrorCode::BadRequest.to_string()),
|
||||
};
|
||||
(status_code, err_message).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub type RouteResult<T, E = RouteError> = Result<T, E>;
|
@ -1,78 +0,0 @@
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
|
||||
#![allow(dead_code)]
|
||||
|
||||
macro_rules! mime {
|
||||
($a:ident, $b:literal) => {
|
||||
pub const $a: &str = $b;
|
||||
};
|
||||
}
|
||||
|
||||
mime!(TEXT_PLAIN, "text/plain");
|
||||
mime!(TEXT_PLAIN_UTF_8, "text/plain; charset=utf-8");
|
||||
mime!(TEXT_HTML, "text/html");
|
||||
mime!(TEXT_HTML_UTF_8, "text/html; charset=utf-8");
|
||||
mime!(TEXT_CSS, "text/css");
|
||||
mime!(TEXT_CSS_UTF_8, "text/css; charset=utf-8");
|
||||
mime!(TEXT_JAVASCRIPT, "text/javascript");
|
||||
mime!(TEXT_XML, "text/xml");
|
||||
mime!(TEXT_EVENT_STREAM, "text/event-stream");
|
||||
mime!(TEXT_CSV, "text/csv");
|
||||
mime!(TEXT_CSV_UTF_8, "text/csv; charset=utf-8");
|
||||
mime!(TEXT_TAB_SEPARATED_VALUES, "text/tab-separated-values");
|
||||
mime!(
|
||||
TEXT_TAB_SEPARATED_VALUES_UTF_8,
|
||||
"text/tab-separated-values; charset=utf-8"
|
||||
);
|
||||
mime!(TEXT_VCARD, "text/vcard");
|
||||
|
||||
mime!(IMAGE_JPEG, "image/jpeg");
|
||||
mime!(IMAGE_GIF, "image/gif");
|
||||
mime!(IMAGE_PNG, "image/png");
|
||||
mime!(IMAGE_ICON, "image/x-icon");
|
||||
mime!(IMAGE_JNG, "image/x-jng");
|
||||
mime!(IMAGE_WBMP, "image/vnd.wap.wbmp ");
|
||||
mime!(IMAGE_BMP, "image/bmp");
|
||||
mime!(IMAGE_SVG, "image/svg+xml");
|
||||
mime!(IMAGE_AVIF, "image/avif");
|
||||
mime!(IMAGE_TIFF, "image/tiff");
|
||||
mime!(IMAGE_WEBP, "image/webp");
|
||||
|
||||
mime!(VIDEO_3GPP, "video/3gpp");
|
||||
mime!(VIDEO_MP2T, "video/mp2t");
|
||||
mime!(VIDEO_MP4, "video/mp4");
|
||||
mime!(VIDEO_MPEG, "video/mpeg");
|
||||
mime!(VIDEO_QUICKTIME, "video/quicktime");
|
||||
mime!(VIDEO_WEBM, "video/webm");
|
||||
mime!(VIDEO_X_FLV, "video/x-flv");
|
||||
mime!(VIDEO_X_M4V, "video/x-m4v");
|
||||
mime!(VIDEO_X_MNG, "video/x-mng");
|
||||
mime!(VIDEO_X_MS_ASF, "video/x-ms-asf");
|
||||
mime!(VIDEO_X_MS_WMV, "video/x-ms-wmv");
|
||||
mime!(VIDEO_X_MSVIDEO, "video/x-msvideo");
|
||||
|
||||
mime!(FONT_WOFF, "font/woff");
|
||||
mime!(FONT_WOFF2, "font/woff2");
|
||||
|
||||
mime!(APPLICATION_JSON, "application/json");
|
||||
mime!(APPLICATION_JAVASCRIPT, "application/javascript");
|
||||
mime!(APPLICATION_WASM, "application/wasm");
|
||||
mime!(
|
||||
APPLICATION_JAVASCRIPT_UTF_8,
|
||||
"application/javascript; charset=utf-8"
|
||||
);
|
||||
mime!(
|
||||
APPLICATION_WWW_FORM_URLENCODED,
|
||||
"application/x-www-form-urlencoded"
|
||||
);
|
||||
mime!(APPLICATION_OCTET_STREAM, "application/octet-stream");
|
||||
mime!(APPLICATION_MSGPACK, "application/msgpack");
|
||||
mime!(APPLICATION_PDF, "application/pdf");
|
||||
mime!(APPLICATION_DNS, "application/dns-message");
|
||||
mime!(APPLICATION_JAVA_ARCHIVE, "application/java-archive");
|
||||
mime!(APPLICATION_APPLE_MPEGURL, "application/vnd.apple.mpegurl");
|
||||
|
||||
mime!(STAR_STAR, "*/*");
|
||||
mime!(TEXT_STAR, "text/*");
|
||||
mime!(IMAGE_STAR, "image/*");
|
||||
mime!(VIDEO_STAR, "video/*");
|
||||
mime!(AUDIO_STAR, "audio/*");
|
177
src/http/mod.rs
177
src/http/mod.rs
@ -1,5 +1,174 @@
|
||||
pub mod client;
|
||||
pub mod mime;
|
||||
pub mod response;
|
||||
use std::{net::SocketAddr, sync::LazyLock, time::Duration};
|
||||
|
||||
pub use response::*;
|
||||
use anyhow::anyhow;
|
||||
use axum::{Router, extract::DefaultBodyLimit, middleware, routing::get};
|
||||
use axum_server::{Handle, tls_rustls::RustlsConfig};
|
||||
use dashmap::DashMap;
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::{compression::CompressionLayer, timeout::TimeoutLayer};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::{
|
||||
config::SettingHost,
|
||||
middlewares::{add_headers, add_version, logging_route},
|
||||
utils::graceful_shutdown,
|
||||
};
|
||||
|
||||
pub mod error;
|
||||
// handle static file
|
||||
pub mod serve;
|
||||
// handle reverse proxy
|
||||
pub mod reverse_proxy;
|
||||
|
||||
/// Host configuration
|
||||
/// use virtual host port as key
|
||||
/// use SettingHost as value
|
||||
/// Use port as parent part
|
||||
/// Use host.route.location as key
|
||||
/// Use host.route struct as value
|
||||
/// {
|
||||
/// 80: {
|
||||
/// "/doc": <SettingRoute>
|
||||
/// }
|
||||
/// }
|
||||
pub static HOSTS: LazyLock<DashMap<u16, SettingHost>> = LazyLock::new(DashMap::new);
|
||||
|
||||
pub async fn make_server(host: SettingHost) -> anyhow::Result<()> {
|
||||
let mut router = Router::new();
|
||||
let host_to_save = host.clone();
|
||||
// find routes in config
|
||||
// convert to axum routes
|
||||
// register routes
|
||||
for host_route in &host.route {
|
||||
// lua script
|
||||
if let Some(lua_path) = &host_route.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());
|
||||
}
|
||||
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
|
||||
HOSTS.insert(host.port, host_to_save);
|
||||
|
||||
router = router.layer(
|
||||
ServiceBuilder::new()
|
||||
.layer(middleware::from_fn(add_version))
|
||||
.layer(middleware::from_fn(add_headers))
|
||||
.layer(TimeoutLayer::new(Duration::from_secs(host.timeout.into())))
|
||||
.layer(CompressionLayer::new()),
|
||||
);
|
||||
|
||||
router = logging_route(router);
|
||||
|
||||
let addr = format!("{}:{}", host.ip, host.port);
|
||||
let addr: SocketAddr = addr.parse()?;
|
||||
|
||||
let handle = Handle::new();
|
||||
// Spawn a task to gracefully shutdown server.
|
||||
tokio::spawn(graceful_shutdown(handle.clone()));
|
||||
|
||||
// check ssl eanbled or not
|
||||
// if ssl enabled
|
||||
// then create ssl listener
|
||||
// else create tcp listener
|
||||
if host.ssl && host.certificate.is_some() && host.certificate_key.is_some() {
|
||||
let cert = host
|
||||
.certificate
|
||||
.as_ref()
|
||||
.ok_or(anyhow!("certificate not found"))?;
|
||||
let key = host
|
||||
.certificate_key
|
||||
.as_ref()
|
||||
.ok_or(anyhow!("certificate_key not found"))?;
|
||||
debug!("certificate {} certificate_key {}", cert, key);
|
||||
|
||||
let rustls_config = RustlsConfig::from_pem_file(cert, key).await?;
|
||||
info!("listening on https://{}", addr);
|
||||
axum_server::bind_rustls(addr, rustls_config)
|
||||
.handle(handle)
|
||||
.serve(router.into_make_service())
|
||||
.await?;
|
||||
} else {
|
||||
info!("listening on http://{}", addr);
|
||||
axum_server::bind(addr)
|
||||
.handle(handle)
|
||||
.serve(router.into_make_service())
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -1,376 +0,0 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
str::FromStr,
|
||||
time::{Duration, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::{SettingHost, SettingRoute},
|
||||
consts::{NAME, VERSION},
|
||||
error::{Error, Result},
|
||||
get_settings,
|
||||
http::client,
|
||||
utils::{
|
||||
compress::{stream_compress, CompressType},
|
||||
find_route, parse_assets_path,
|
||||
},
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
use futures_util::TryStreamExt;
|
||||
use http::{response::Builder, Method};
|
||||
use http_body_util::{combinators::BoxBody, BodyExt, Full, StreamBody};
|
||||
use hyper::{
|
||||
body::{Bytes, Frame, Incoming},
|
||||
Request, Response, StatusCode,
|
||||
};
|
||||
|
||||
use tokio::{
|
||||
fs::File,
|
||||
io::{AsyncBufRead, BufReader},
|
||||
select,
|
||||
};
|
||||
use tokio_util::io::ReaderStream;
|
||||
use tracing::{debug, error, instrument};
|
||||
|
||||
/// Candy handler
|
||||
///
|
||||
/// The lifetime of the handler is the lifetime of the request. the request and response will be
|
||||
/// move into the handler. Not the reference.
|
||||
#[derive(Debug)]
|
||||
pub struct CandyHandler<'req> {
|
||||
/// Request from hyper
|
||||
pub req: Request<Incoming>,
|
||||
/// Hyper response
|
||||
pub res: Builder,
|
||||
/// Config host field
|
||||
host: &'static SettingHost,
|
||||
/// Router
|
||||
router: Option<&'req SettingRoute>,
|
||||
/// Current request's assets path
|
||||
assets_path: Option<&'req str>,
|
||||
}
|
||||
|
||||
pub type CandyBody<T, E = Error> = BoxBody<T, E>;
|
||||
type CandyResponse = Result<Response<CandyBody<Bytes>>>;
|
||||
impl CandyHandler<'_> {
|
||||
/// Create a new handler with hyper incoming request
|
||||
pub fn new(req: Request<Incoming>, host: &'static SettingHost) -> Self {
|
||||
Self {
|
||||
req,
|
||||
res: Response::builder(),
|
||||
host,
|
||||
router: None,
|
||||
assets_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Traverse the headers from config add to response
|
||||
/// these headers will be add before proxy headers and will override by proxy headers
|
||||
pub fn add_headers(&mut self) -> Result<()> {
|
||||
let headers = self
|
||||
.res
|
||||
.headers_mut()
|
||||
.ok_or(Error::InternalServerError(anyhow!("build response failed")))?;
|
||||
let server = format!("{}/{}", NAME, VERSION);
|
||||
headers.insert("Server", server.parse()?);
|
||||
// config headers overrite
|
||||
let Some(c_headers) = &self.host.headers else {
|
||||
return Ok(());
|
||||
};
|
||||
for (k, v) in c_headers {
|
||||
headers.insert(k.as_str(), v.parse()?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle static file or reverse proxy
|
||||
pub async fn handle(mut self) -> CandyResponse {
|
||||
let uri = self.req.uri().clone();
|
||||
let req_path = uri.path();
|
||||
// find route path
|
||||
let (router, assets_path) = find_route(req_path, &self.host.route_map)?;
|
||||
self.router = Some(router);
|
||||
self.assets_path = Some(assets_path);
|
||||
|
||||
// reverse proxy
|
||||
if router.proxy_pass.is_some() {
|
||||
self.proxy().await
|
||||
} else {
|
||||
// static file
|
||||
self.file().await
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle reverse proxy
|
||||
///
|
||||
/// Only use with the `proxy_pass` field in config
|
||||
pub async fn proxy(self) -> CandyResponse {
|
||||
let (router, assets_path) = (
|
||||
self.router
|
||||
.ok_or(Error::NotFound("handler router is empty".into()))?,
|
||||
self.assets_path
|
||||
.ok_or(Error::NotFound("handler assets_path is empty".into()))?,
|
||||
);
|
||||
let (req, mut res) = (self.req, self.res);
|
||||
let (parts, body) = req.into_parts();
|
||||
|
||||
let assets_path = if !assets_path.is_empty() {
|
||||
format!("/{assets_path}")
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
// check on outside
|
||||
let proxy = router.proxy_pass.as_ref().ok_or(Error::Empty)?;
|
||||
let proxy = proxy.trim_end_matches('/');
|
||||
let path_query = parts.uri.query().unwrap_or("");
|
||||
let path_query = if !path_query.is_empty() {
|
||||
format!("?{path_query}")
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
let uri: hyper::Uri = format!("{proxy}{assets_path}{path_query}")
|
||||
.parse()
|
||||
.with_context(|| format!("parse proxy uri failed: {}", proxy))?;
|
||||
|
||||
let host = uri.host().ok_or(Error::InternalServerError(anyhow!(
|
||||
"proxy pass host incorrect"
|
||||
)))?;
|
||||
let uri = uri.clone();
|
||||
debug!("proxy pass to: {uri}");
|
||||
let timeout = router.proxy_timeout;
|
||||
let body = body.collect().await?.to_bytes();
|
||||
let body = select! {
|
||||
body = client::get(uri, parts, body) => {
|
||||
body.with_context(|| "proxy body error")?
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_secs(timeout.into())) => {
|
||||
return Err(anyhow!("connect upstream {host:?} timeout").into());
|
||||
}
|
||||
};
|
||||
res.headers_mut()
|
||||
.ok_or(Error::MissingHeader("missing response headers"))
|
||||
.with_context(|| "build response failed")?
|
||||
.extend(body.headers().clone());
|
||||
let res_body = res.body(body.map_err(Error::HyperError).boxed())?;
|
||||
Ok(res_body)
|
||||
}
|
||||
|
||||
/// Handle static files,
|
||||
/// try find static file from local path
|
||||
///
|
||||
/// Only use with the `proxy_pass` field not in config
|
||||
pub async fn file(self) -> CandyResponse {
|
||||
let (router, assets_path) = (
|
||||
self.router
|
||||
.ok_or(Error::NotFound("handler router is empty".into()))?,
|
||||
self.assets_path
|
||||
.ok_or(Error::NotFound("handler assets_path is empty".into()))?,
|
||||
);
|
||||
let (req, res) = (self.req, self.res);
|
||||
|
||||
let req_method = req.method();
|
||||
|
||||
// find resource local file path
|
||||
let mut path = None;
|
||||
for index in router.index.iter() {
|
||||
let Some(root) = &router.root else {
|
||||
continue;
|
||||
};
|
||||
let p = parse_assets_path(assets_path, root, index);
|
||||
if Path::new(&p).exists() {
|
||||
path = Some(p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let path = match path {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return handle_not_found(req, res, router, "").await;
|
||||
}
|
||||
};
|
||||
|
||||
// http method handle
|
||||
let res = match *req_method {
|
||||
Method::GET => handle_get(req, res, &path).await?,
|
||||
Method::POST => handle_get(req, res, &path).await?,
|
||||
// Return the 404 Not Found for other routes.
|
||||
_ => {
|
||||
let Some(err_page) = &router.error_page else {
|
||||
return Ok(not_found());
|
||||
};
|
||||
let res = res.status(err_page.status);
|
||||
handle_get(req, res, &err_page.page).await?
|
||||
}
|
||||
};
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
/// Open local file and check last modified time,
|
||||
/// Then determine stream file or use cache file
|
||||
///
|
||||
/// ## Arguments
|
||||
///
|
||||
/// `path`: local file path
|
||||
pub async fn open_file(path: &str) -> Result<File> {
|
||||
// Open file for reading
|
||||
let file = File::open(path).await.map_err(|err| {
|
||||
error!("Unable to open file {err}");
|
||||
});
|
||||
let Ok(file) = file else {
|
||||
return Err(Error::NotFound(format!("path not found {}", path).into()));
|
||||
};
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// Open then use `ReaderStream` to stream to client.
|
||||
/// Stream a file more suitable for large file, but its slower than read file to memory.
|
||||
pub async fn stream_file<R>(file: R) -> CandyBody<Bytes>
|
||||
where
|
||||
R: AsyncBufRead + Sync + Send + 'static,
|
||||
{
|
||||
// Wrap to a tokio_util::io::ReaderStream
|
||||
let reader_stream = ReaderStream::new(file);
|
||||
// Convert to http_body_util::BoxBody
|
||||
let stream_body = StreamBody::new(reader_stream.map_ok(Frame::data));
|
||||
// let boxed_body = stream_body.map_err(|e| Error::IoError(e)).boxed();
|
||||
BodyExt::map_err(stream_body, Error::Io).boxed()
|
||||
}
|
||||
|
||||
// pub async fn read_file_bytes(file: &mut File, size: u64) -> Result<Vec<u8>> {
|
||||
// let mut buffer = vec![0u8; size.try_into()?];
|
||||
// file.read_exact(&mut buffer[..]).await?;
|
||||
// Ok(buffer)
|
||||
// }
|
||||
|
||||
// Open local file to memory
|
||||
// pub async fn read_file(file: &mut File, size: u64) -> Result<CandyBody<Bytes>> {
|
||||
// let bytes = read_file_bytes(file, size).await?;
|
||||
// let body = Full::new(bytes.into()).map_err(|e| match e {}).boxed();
|
||||
// Ok(body)
|
||||
// }
|
||||
|
||||
// HTTP status code 404
|
||||
static NOT_FOUND: &[u8] = b"Not Found";
|
||||
pub fn not_found() -> Response<CandyBody<Bytes>> {
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Full::new(NOT_FOUND.into()).map_err(|e| match e {}).boxed())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
static INTERNAL_SERVER_ERROR: &[u8] = b"Internal Server Error";
|
||||
pub fn internal_server_error() -> Response<CandyBody<Bytes>> {
|
||||
Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
Full::new(INTERNAL_SERVER_ERROR.into())
|
||||
.map_err(|e| match e {})
|
||||
.boxed(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// HTTP methods
|
||||
/// handle http get method
|
||||
/// read static file and check If-None-Match cache
|
||||
#[instrument(level = "debug")]
|
||||
pub async fn handle_get(
|
||||
req: Request<Incoming>,
|
||||
mut res: Builder,
|
||||
path: &str,
|
||||
) -> Result<Response<CandyBody<Bytes>>> {
|
||||
use CompressType::*;
|
||||
use Error::*;
|
||||
|
||||
let headers = res
|
||||
.headers_mut()
|
||||
.ok_or(InternalServerError(anyhow!("build response failed")))?;
|
||||
|
||||
// file bytes
|
||||
let file = open_file(path).await?;
|
||||
// file info
|
||||
let metadata = file.metadata().await?;
|
||||
let size = metadata.len();
|
||||
let last_modified = metadata.modified()?.duration_since(UNIX_EPOCH)?.as_secs();
|
||||
let etag = format!("{last_modified}-{size}");
|
||||
let extension = PathBuf::from_str(path).map_err(|err| InternalServerError(anyhow!(err)))?;
|
||||
let extension = extension
|
||||
.extension()
|
||||
.ok_or(InternalServerError(anyhow!("read file extension failed")))?;
|
||||
|
||||
let settings = get_settings()?;
|
||||
let content_type = settings.types.get(
|
||||
extension
|
||||
.to_str()
|
||||
.ok_or(InternalServerError(anyhow!("read file extension failed")))?,
|
||||
);
|
||||
headers.insert(
|
||||
"Content-Type",
|
||||
content_type.unwrap_or(&settings.default_type).parse()?,
|
||||
);
|
||||
headers.insert("Etag", etag.parse()?);
|
||||
|
||||
// check cache
|
||||
let if_none_match = req.headers().get("If-None-Match");
|
||||
match if_none_match {
|
||||
Some(inm) if *inm == *etag => {
|
||||
let res = res.status(304);
|
||||
let body = Full::new(vec![].into()).map_err(|e| match e {}).boxed();
|
||||
return Ok(res.body(body)?);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let file_reader = BufReader::new(file);
|
||||
// prepare compress
|
||||
let accept_encoding = req.headers().get("Accept-Encoding");
|
||||
let boxed_body = match accept_encoding {
|
||||
Some(accept) => {
|
||||
let accept = accept.to_str()?;
|
||||
debug!("Accept-Encoding {}", accept);
|
||||
match accept {
|
||||
str if str.contains("zstd") => {
|
||||
headers.insert("Content-Encoding", "zstd".parse()?);
|
||||
stream_compress(Zstd, file_reader)
|
||||
}
|
||||
str if str.contains("gzip") => {
|
||||
headers.insert("Content-Encoding", "gzip".parse()?);
|
||||
stream_compress(Gzip, file_reader)
|
||||
}
|
||||
str if str.contains("deflate") => {
|
||||
headers.insert("Content-Encoding", "deflate".parse()?);
|
||||
stream_compress(Deflate, file_reader)
|
||||
}
|
||||
str if str.contains("br") => {
|
||||
headers.insert("Content-Encoding", "br".parse()?);
|
||||
stream_compress(Brotli, file_reader)
|
||||
}
|
||||
_ => stream_file(file_reader).await,
|
||||
}
|
||||
}
|
||||
None => stream_file(file_reader).await,
|
||||
};
|
||||
|
||||
Ok(res.body(boxed_body)?)
|
||||
}
|
||||
|
||||
pub async fn handle_not_found(
|
||||
req: Request<Incoming>,
|
||||
res: Builder,
|
||||
router: &SettingRoute,
|
||||
assets_path: &str,
|
||||
) -> Result<Response<CandyBody<Bytes>>> {
|
||||
let Some(err_page) = &router.error_page else {
|
||||
return Ok(not_found());
|
||||
};
|
||||
let Some(root) = &router.root else {
|
||||
return Ok(not_found());
|
||||
};
|
||||
let res = res.status(err_page.status);
|
||||
let path = parse_assets_path(assets_path, root, &err_page.page);
|
||||
let res = handle_get(req, res, &path).await?;
|
||||
Ok(res)
|
||||
}
|
249
src/http/reverse_proxy.rs
Normal file
249
src/http/reverse_proxy.rs
Normal file
@ -0,0 +1,249 @@
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, Request},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use axum_extra::extract::Host;
|
||||
use dashmap::mapref::one::Ref;
|
||||
use http::{
|
||||
HeaderName, HeaderValue, StatusCode, Uri,
|
||||
header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH},
|
||||
};
|
||||
use mime_guess::from_path;
|
||||
use reqwest::Client;
|
||||
use tokio::fs::File;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
use crate::{
|
||||
config::SettingRoute,
|
||||
http::serve::{calculate_etag, resolve_parent_path},
|
||||
utils::parse_port_from_host,
|
||||
};
|
||||
|
||||
use super::{
|
||||
HOSTS,
|
||||
error::{RouteError, RouteResult},
|
||||
};
|
||||
|
||||
/// 处理自定义错误页面(如404、500等)的请求
|
||||
///
|
||||
/// 该函数根据配置信息加载自定义错误页面文件,并根据HTTP缓存机制
|
||||
/// 决定是返回完整内容还是304 Not Modified状态码。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `host_config`: 主机路由配置,包含错误页面路径和根目录信息
|
||||
/// - `request`: 原始HTTP请求
|
||||
/// - `is_error_page`: 是否为错误页面(true: 错误页,false: 404页)
|
||||
///
|
||||
/// # 返回
|
||||
/// - `Ok(Response)`: 成功时返回HTTP响应
|
||||
/// - `Err(RouteError)`: 失败时返回路由错误
|
||||
pub async fn handle_custom_page(
|
||||
host_config: Ref<'_, String, SettingRoute>,
|
||||
request: Request<Body>,
|
||||
is_error_page: bool,
|
||||
) -> RouteResult<Response<Body>> {
|
||||
// 根据请求类型选择相应的页面配置
|
||||
let page = if is_error_page {
|
||||
host_config
|
||||
.error_page
|
||||
.as_ref()
|
||||
.ok_or(RouteError::RouteNotFound())?
|
||||
} else {
|
||||
host_config
|
||||
.not_found_page
|
||||
.as_ref()
|
||||
.ok_or(RouteError::RouteNotFound())?
|
||||
};
|
||||
|
||||
// 获取站点根目录配置
|
||||
let root = host_config
|
||||
.root
|
||||
.as_ref()
|
||||
.ok_or(RouteError::InternalError())?;
|
||||
|
||||
// 构建完整文件路径
|
||||
let path = format!("{}/{}", root, page.page);
|
||||
tracing::debug!("custom not found path: {:?}", path);
|
||||
|
||||
// 打开文件并计算ETag用于缓存验证
|
||||
let file = File::open(path.clone())
|
||||
.await
|
||||
.with_context(|| "open file failed")?;
|
||||
|
||||
let etag = calculate_etag(&file, path.as_str()).await?;
|
||||
let mut response = Response::builder();
|
||||
let mut not_modified = false;
|
||||
|
||||
// 检查客户端缓存验证头(If-None-Match)
|
||||
if let Some(if_none_match) = request.headers().get(IF_NONE_MATCH) {
|
||||
if let Ok(if_none_match_str) = if_none_match.to_str() {
|
||||
if if_none_match_str == etag {
|
||||
// 资源未修改,返回304状态码
|
||||
response = response.status(StatusCode::NOT_MODIFIED);
|
||||
not_modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 准备响应主体
|
||||
let stream = if not_modified {
|
||||
// 304响应返回空内容
|
||||
let empty = File::open(PathBuf::from("/dev/null"))
|
||||
.await
|
||||
.with_context(|| "open /dev/null failed")?;
|
||||
ReaderStream::new(empty)
|
||||
} else {
|
||||
// 正常响应返回文件内容
|
||||
ReaderStream::new(file)
|
||||
};
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
// 设置响应头:内容类型和ETag
|
||||
let mime = from_path(path).first_or_octet_stream();
|
||||
response
|
||||
.headers_mut()
|
||||
.with_context(|| "insert header failed")?
|
||||
.insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_str(mime.as_ref()).with_context(|| "insert header failed")?,
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.with_context(|| "insert header failed")?
|
||||
.insert(
|
||||
ETAG,
|
||||
HeaderValue::from_str(&etag).with_context(|| "insert header failed")?,
|
||||
);
|
||||
|
||||
// 构建最终响应
|
||||
let response = response
|
||||
.body(body)
|
||||
.with_context(|| "Failed to build HTTP response with body")?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Handles the reverse proxy logic for incoming requests.
|
||||
/// This function:
|
||||
/// 1. Extracts the request path, host, and other details.
|
||||
/// 2. Resolves the parent path and proxy configuration.
|
||||
/// 3. Forwards the request to the configured proxy server.
|
||||
/// 4. Returns the response from the proxy server to the client.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `req_uri` - The URI of the incoming request.
|
||||
/// * `path` - Optional path parameter extracted from the request.
|
||||
/// * `host` - The host header from the request.
|
||||
/// * `req` - The incoming HTTP request.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `RouteResult` containing the response from the proxy server or an error.
|
||||
#[axum::debug_handler]
|
||||
pub async fn serve(
|
||||
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());
|
||||
tracing::debug!("parent path: {:?}", parent_path);
|
||||
let proxy_config = route_map
|
||||
.get(&parent_path)
|
||||
.ok_or(RouteError::RouteNotFound())?;
|
||||
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())?;
|
||||
|
||||
let timeout = proxy_config.proxy_timeout;
|
||||
|
||||
// forward request headers
|
||||
let client = Client::new();
|
||||
let mut forward_req = client
|
||||
.request(req.method().clone(), uri)
|
||||
.timeout(Duration::from_secs(timeout.into()));
|
||||
for (name, value) in req.headers().iter() {
|
||||
if !is_exclude_header(name) {
|
||||
forward_req = forward_req.header(name.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// forward request body
|
||||
let body = req.into_body();
|
||||
let bytes = axum::body::to_bytes(body, 2048).await.map_err(|err| {
|
||||
tracing::error!("Failed to proxy request: {}", err);
|
||||
RouteError::InternalError()
|
||||
})?;
|
||||
let body_str = String::from_utf8(bytes.to_vec()).map_err(|err| {
|
||||
tracing::error!("Failed to proxy request: {}", err);
|
||||
RouteError::InternalError()
|
||||
})?;
|
||||
forward_req = forward_req.body(body_str);
|
||||
|
||||
// send reverse proxy request
|
||||
let reqwest_response = forward_req.send().await.map_err(|e| {
|
||||
tracing::error!("Failed to proxy request: {}", e);
|
||||
RouteError::BadRequest()
|
||||
})?;
|
||||
|
||||
// response from reverse proxy server
|
||||
let mut response_builder = Response::builder().status(reqwest_response.status());
|
||||
copy_headers(
|
||||
reqwest_response.headers(),
|
||||
response_builder
|
||||
.headers_mut()
|
||||
.ok_or(RouteError::InternalError())?,
|
||||
);
|
||||
let res = response_builder
|
||||
.body(Body::from_stream(reqwest_response.bytes_stream()))
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to proxy request: {}", e);
|
||||
RouteError::BadRequest()
|
||||
})?;
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Checks if a given header should be excluded from being forwarded in the reverse proxy.
|
||||
/// Headers like "host", "connection", etc., are typically excluded to avoid conflicts or security issues.
|
||||
fn is_exclude_header(name: &HeaderName) -> bool {
|
||||
matches!(
|
||||
name.as_str(),
|
||||
"host"
|
||||
| "connection"
|
||||
| "proxy-authenticate"
|
||||
| "upgrade"
|
||||
| "proxy-authorization"
|
||||
| "keep-alive"
|
||||
| "transfer-encoding"
|
||||
| "te"
|
||||
)
|
||||
}
|
||||
|
||||
/// Copies headers from one `HeaderMap` to another, excluding headers specified in `is_exclude_header`.
|
||||
/// This ensures only relevant headers are forwarded, avoiding conflicts or security issues.
|
||||
fn copy_headers(from: &http::HeaderMap, to: &mut http::HeaderMap) {
|
||||
for (name, value) in from.iter() {
|
||||
if !is_exclude_header(name) {
|
||||
to.append(name.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
621
src/http/serve.rs
Normal file
621
src/http/serve.rs
Normal file
@ -0,0 +1,621 @@
|
||||
use std::{
|
||||
fmt::{Display, Formatter},
|
||||
path::PathBuf,
|
||||
str::FromStr,
|
||||
time::UNIX_EPOCH,
|
||||
};
|
||||
|
||||
use anyhow::{Context, anyhow};
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, Request},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use axum_extra::extract::Host;
|
||||
use dashmap::mapref::one::Ref;
|
||||
use http::{
|
||||
HeaderMap, HeaderValue, StatusCode, Uri,
|
||||
header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH},
|
||||
};
|
||||
use mime_guess::from_path;
|
||||
use tokio::fs::{self, File};
|
||||
use tokio_util::io::ReaderStream;
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use crate::{
|
||||
config::SettingRoute,
|
||||
consts::HOST_INDEX,
|
||||
http::{HOSTS, error::RouteError},
|
||||
utils::parse_port_from_host,
|
||||
};
|
||||
|
||||
use super::error::RouteResult;
|
||||
|
||||
/// 处理自定义页面请求(如404错误页或自定义错误页面)
|
||||
///
|
||||
/// 此函数根据请求类型(错误页或404页)加载相应的自定义页面,
|
||||
/// 构建完整文件路径并尝试流式传输文件内容作为HTTP响应。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `host_route`: 主机路由配置引用,包含页面位置和根目录信息
|
||||
/// - `request`: 原始HTTP请求
|
||||
/// - `is_error_page`: 是否为错误页面(true: 错误页,false: 404页)
|
||||
///
|
||||
/// # 返回
|
||||
/// - `RouteResult<Response>`: 成功时返回HTTP响应,失败时返回路由错误
|
||||
async fn custom_page(
|
||||
host_route: Ref<'_, String, SettingRoute>,
|
||||
request: Request<Body>,
|
||||
is_error_page: bool,
|
||||
) -> RouteResult<Response<Body>> {
|
||||
let page = if is_error_page {
|
||||
host_route
|
||||
.error_page
|
||||
.as_ref()
|
||||
.ok_or(RouteError::RouteNotFound())?
|
||||
} else {
|
||||
host_route
|
||||
.not_found_page
|
||||
.as_ref()
|
||||
.ok_or(RouteError::RouteNotFound())?
|
||||
};
|
||||
|
||||
let root = host_route
|
||||
.root
|
||||
.as_ref()
|
||||
.ok_or(RouteError::InternalError())?;
|
||||
|
||||
let path = format!("{}/{}", root, page.page);
|
||||
|
||||
let status = StatusCode::from_str(page.status.to_string().as_ref())
|
||||
.map_err(|_| RouteError::BadRequest())?;
|
||||
|
||||
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);
|
||||
RouteResult::Err(RouteError::InternalError())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve static files.
|
||||
///
|
||||
/// This function handles requests for static files by:
|
||||
/// 1. Resolving the parent path from the URI or provided path.
|
||||
/// 2. Looking up the route in `ROUTE_MAP` to find the root directory.
|
||||
/// 3. Attempting to serve the requested file or a default index file.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `uri`: The request URI, used to extract the full path.
|
||||
/// - `path`: Optional path segment provided by the router.
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(Response)`: If the file is found and successfully streamed.
|
||||
/// - `Err(RouteError)`: If the route or file is not found.
|
||||
#[axum::debug_handler]
|
||||
pub async fn serve(
|
||||
uri: Uri,
|
||||
path: Option<Path<String>>,
|
||||
Host(host): Host,
|
||||
request: Request,
|
||||
) -> RouteResult<impl IntoResponse> {
|
||||
// find parent path
|
||||
// if requested path is /doc
|
||||
// then params path is None
|
||||
// when Path is None, then use uri.path() as path
|
||||
|
||||
// if request path is /doc/index.html
|
||||
// uri path is /doc/index.html
|
||||
// path is index.html
|
||||
// find parent path by path length
|
||||
// /doc/index.html
|
||||
// /doc/
|
||||
// index.html
|
||||
|
||||
debug!(
|
||||
"Request - uri: {:?}, path: {:?}, request: {:?}",
|
||||
uri, path, request
|
||||
);
|
||||
|
||||
let parent_path = resolve_parent_path(&uri, path.as_ref());
|
||||
// parent_path is key in route map
|
||||
// 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;
|
||||
debug!("Route map entries: {:?}", route_map);
|
||||
let host_route = route_map
|
||||
.get(&parent_path)
|
||||
.ok_or(RouteError::RouteNotFound())?;
|
||||
debug!("route: {:?}", host_route);
|
||||
// after route found
|
||||
// check static file root configuration
|
||||
// if root is None, then return InternalError
|
||||
let Some(ref root) = host_route.root else {
|
||||
return custom_page(host_route, request, true).await;
|
||||
};
|
||||
// try find index file first
|
||||
// build index filename as vec
|
||||
// ["./html/index.html", "./html/index.txt"]
|
||||
// Build the list of candidate file paths to try:
|
||||
// - If `path` is provided, use it and check is file or not.
|
||||
// - If `path` is None, use the default index files (either from `host_route.index` or `HOST_INDEX`).
|
||||
// path_arr 是包含默认索引文件的数组
|
||||
// req_path 是请求的路径
|
||||
let (req_path, path_arr) = if let Some(path) = path {
|
||||
#[allow(clippy::unnecessary_to_owned)]
|
||||
let path = path.to_string();
|
||||
if path.contains('.') {
|
||||
(root.into(), vec![format!("{}/{}", root, path)])
|
||||
} else {
|
||||
generate_default_index(&host_route, &format!("{root}/{path}"))
|
||||
}
|
||||
} else {
|
||||
generate_default_index(&host_route, root)
|
||||
};
|
||||
debug!("request index file {:?}", path_arr);
|
||||
// Try each candidate path in order:
|
||||
// - Return the first successfully streamed file.
|
||||
// - If all fail, return a `RouteNotFound` error.
|
||||
let mut path_exists = None;
|
||||
for path in path_arr {
|
||||
if fs::metadata(path.clone()).await.is_ok() {
|
||||
path_exists = Some(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 检查路径是否存在
|
||||
// 不存时,检查是否开启自动生成目录索引
|
||||
let path_exists = match path_exists {
|
||||
Some(path_exists) => path_exists,
|
||||
None => {
|
||||
// 生成自动目录索引
|
||||
if host_route.auto_index {
|
||||
// HTML 中的标题路径,需要移除掉配置文件中的 root = "./html" 字段
|
||||
let host_root = if let Some(root) = &host_route.root {
|
||||
root
|
||||
} else {
|
||||
return custom_page(host_route, request, false).await;
|
||||
};
|
||||
let req_path_str = req_path.to_string_lossy();
|
||||
debug!("req_path_str: {:?}", req_path_str);
|
||||
let host_root = &req_path_str.strip_prefix(host_root).unwrap_or(host_root);
|
||||
let list = list_dir(&req_path_str, &req_path).await?;
|
||||
let list_html = render_list_html(host_root, list);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/html"));
|
||||
return Ok((headers, list_html).into_response());
|
||||
} else {
|
||||
debug!("No valid file found in path candidates");
|
||||
return custom_page(host_route, request, false).await;
|
||||
}
|
||||
}
|
||||
};
|
||||
match stream_file(path_exists.into(), request, None).await {
|
||||
Ok(res) => Ok(res),
|
||||
Err(e) => {
|
||||
error!("Failed to stream file: {}", e);
|
||||
Err(RouteError::InternalError())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate default index files
|
||||
/// if request path is not a file
|
||||
/// this read config index field
|
||||
/// and build with root: ["./html/index.html", "./html/index.txt"]
|
||||
///
|
||||
/// ## Arguments
|
||||
/// - `host_route`: the host route config
|
||||
/// - `root`: the root path
|
||||
///
|
||||
/// ## Returns
|
||||
/// - PathBuf: 客户端访问的路径
|
||||
/// - Vec<String>: 包含默认索引文件名的数组
|
||||
fn generate_default_index(
|
||||
host_route: &Ref<'_, String, SettingRoute>,
|
||||
root: &str,
|
||||
) -> (PathBuf, Vec<String>) {
|
||||
let indices = if host_route.index.is_empty() {
|
||||
let host_iter = HOST_INDEX
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
host_iter.into_iter()
|
||||
} else {
|
||||
host_route.index.clone().into_iter()
|
||||
};
|
||||
// indices 就是 host_route.index 的中配置的 index 文件名
|
||||
(
|
||||
root.into(),
|
||||
indices.map(|s| format!("{root}/{s}")).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Stream a file as an HTTP response.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `path`: The filesystem path to the file.
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(Response)`: If the file is successfully opened and streamed.
|
||||
/// - `Err(anyhow::Error)`: If the file cannot be opened or read.
|
||||
async fn stream_file(
|
||||
path: PathBuf,
|
||||
request: Request,
|
||||
status: Option<StatusCode>,
|
||||
) -> RouteResult<Response<Body>> {
|
||||
let file = File::open(path.clone())
|
||||
.await
|
||||
.with_context(|| "open file failed")?;
|
||||
|
||||
let path_str = path.to_str().ok_or(anyhow!("convert path to str failed"))?;
|
||||
let etag = calculate_etag(&file, path_str).await?;
|
||||
|
||||
let mut response = Response::builder();
|
||||
let mut not_modified = false;
|
||||
// check request if-none-match
|
||||
if let Some(if_none_match) = request.headers().get(IF_NONE_MATCH) {
|
||||
if let Ok(if_none_match_str) = if_none_match.to_str() {
|
||||
if if_none_match_str == etag {
|
||||
response = response.status(StatusCode::NOT_MODIFIED);
|
||||
not_modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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)
|
||||
} else {
|
||||
ReaderStream::new(file)
|
||||
};
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
let mime = from_path(path).first_or_octet_stream();
|
||||
response
|
||||
.headers_mut()
|
||||
.with_context(|| "insert header failed")?
|
||||
.insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_str(mime.as_ref()).with_context(|| "insert header failed")?,
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.with_context(|| "insert header failed")?
|
||||
.insert(
|
||||
ETAG,
|
||||
HeaderValue::from_str(&etag).with_context(|| "insert header failed")?,
|
||||
);
|
||||
if let Some(status) = status {
|
||||
response = response.status(status);
|
||||
}
|
||||
let response = response
|
||||
.body(body)
|
||||
.with_context(|| "Failed to build HTTP response with body")?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn calculate_etag(file: &File, path: &str) -> anyhow::Result<String> {
|
||||
// calculate file metadata as etag
|
||||
let metadata = file
|
||||
.metadata()
|
||||
.await
|
||||
.with_context(|| "get file metadata failed")?;
|
||||
let created_timestamp = metadata
|
||||
.created()
|
||||
.with_context(|| "get file created failed")?
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.with_context(|| "calculate unix timestamp failed")?
|
||||
.as_secs();
|
||||
let modified_timestamp = metadata
|
||||
.modified()
|
||||
.with_context(|| "get file created failed")?
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.with_context(|| "calculate unix timestamp failed")?
|
||||
.as_secs();
|
||||
// file path - created - modified - len
|
||||
let etag = format!(
|
||||
"{}-{}-{}-{}",
|
||||
path,
|
||||
created_timestamp,
|
||||
modified_timestamp,
|
||||
metadata.len()
|
||||
);
|
||||
let etag = format!("W/\"{:?}\"", md5::compute(etag));
|
||||
debug!("file {:?} etag: {:?}", path, etag);
|
||||
Ok(etag)
|
||||
}
|
||||
|
||||
// Resolve the parent path:
|
||||
// - If `path` is provided, extract the parent segment from the URI.
|
||||
// - If `path` is None, use the URI path directly (ensuring it ends with '/').
|
||||
/// Resolves the parent path from the URI and optional path segment.
|
||||
pub fn resolve_parent_path(uri: &Uri, path: Option<&Path<String>>) -> String {
|
||||
match path {
|
||||
Some(path) => {
|
||||
let uri_path = uri.path();
|
||||
// use path sub to this uri path
|
||||
// to find parent path that store in ROUTE_MAP
|
||||
// uri: /assets/css/styles.07713cb6.css, path: Some(Path("assets/css/styles.07713cb6.css")
|
||||
let parent_path = uri_path.get(0..uri_path.len() - path.len());
|
||||
parent_path.unwrap_or("/").to_string()
|
||||
}
|
||||
None => {
|
||||
// uri needs end with /
|
||||
// because global ROUTE_MAP key is end with /
|
||||
// so we need add / to uri path to get correct Route
|
||||
let uri_path = uri.path().to_string();
|
||||
if uri_path.ends_with('/') {
|
||||
uri_path
|
||||
} else {
|
||||
format!("{uri_path}/")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成一个 HTML 目录列表页面,展示指定目录中的文件和子目录。
|
||||
///
|
||||
/// 该函数将一个 `DirList` 结构体的向量转换为 HTML 表格格式,
|
||||
/// 每个条目包含名称(带链接)、最后修改时间和大小信息。
|
||||
///
|
||||
/// # 参数
|
||||
/// * `root_path` - 目录路径 显示在 HTML 中的根目录
|
||||
/// * `list` - 包含目录项信息的 `DirList` 结构体向量
|
||||
///
|
||||
/// # 返回值
|
||||
/// 格式化后的 HTML 字符串,可直接作为 HTTP 响应返回
|
||||
///
|
||||
/// # 示例
|
||||
/// ```rust
|
||||
/// let dir_entries = vec![
|
||||
/// DirList {
|
||||
/// path: PathBuf::from("/home/user/docs"),
|
||||
/// name: "documents".to_string(),
|
||||
/// last_modified: "2023-05-15 14:30".to_string(),
|
||||
/// size: "4.2K".to_string(),
|
||||
/// is_dir: true
|
||||
/// },
|
||||
/// // 更多条目...
|
||||
/// ];
|
||||
///
|
||||
/// let html_output = render_list_html(dir_entries);
|
||||
/// println!("{}", html_output);
|
||||
/// ```
|
||||
fn render_list_html(root_path: &str, list: Vec<DirList>) -> String {
|
||||
debug!(
|
||||
"render list html list: {:?} root_path: {:?}",
|
||||
list, root_path
|
||||
);
|
||||
// 先生成目标目录下所有文件的行
|
||||
let body_rows = list
|
||||
.iter()
|
||||
.map(|dist| {
|
||||
if dist.is_dir {
|
||||
format!(
|
||||
r#"<tr><td><a href="{}">{}/</a></td><td>{}</td><td>{}</td></tr>"#,
|
||||
dist.path, dist.name, dist.last_modified, dist.size,
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r#"<tr><td><a href="{}">{}</a></td><td>{}</td><td>{}</td></tr>"#,
|
||||
dist.path, dist.name, dist.last_modified, dist.size,
|
||||
)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join("");
|
||||
|
||||
let list_html = format!(
|
||||
r#"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Index of {root_path}</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
}}
|
||||
h1 {{
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
}}
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #dddddd;
|
||||
}}
|
||||
th, td {{
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #dddddd;
|
||||
}}
|
||||
th {{
|
||||
background-color: #f0f0f0;
|
||||
font-weight: bold;
|
||||
}}
|
||||
tr:nth-child(even) {{
|
||||
background-color: #f9f9f9;
|
||||
}}
|
||||
tr:hover {{
|
||||
background-color: #f0f0f0;
|
||||
}}
|
||||
.dir {{
|
||||
color: #0066cc;
|
||||
font-weight: bold;
|
||||
}}
|
||||
.file {{
|
||||
color: #000000;
|
||||
}}
|
||||
a {{
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}}
|
||||
a:hover {{
|
||||
text-decoration: underline;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Index of {root_path}</h1>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Last Modified</th>
|
||||
<th>Size</th>
|
||||
</tr>
|
||||
<tbody id="directory-content">
|
||||
{body_rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"#,
|
||||
);
|
||||
list_html
|
||||
}
|
||||
|
||||
const KB: u64 = 1024;
|
||||
const KB1: u64 = KB + 1;
|
||||
const MB: u64 = 1024 * 1024;
|
||||
const MB1: u64 = MB + 1;
|
||||
const GB: u64 = 1024 * 1024 * 1024;
|
||||
const GB1: u64 = GB + 1;
|
||||
const TB: u64 = 1024 * 1024 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ByteUnit(u64);
|
||||
|
||||
impl Display for ByteUnit {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self.0 {
|
||||
0..=KB => write!(f, "{} B", self.0),
|
||||
KB1..=MB => write!(f, "{:.2} KB", self.0 as f64 / 1024.0),
|
||||
MB1..=GB => write!(f, "{:.2} MB", self.0 as f64 / 1024.0 / 1024.0),
|
||||
GB1..=TB => write!(f, "{:.2} TB", self.0 as f64 / 1024.0 / 1024.0 / 1024.0),
|
||||
_ => write!(f, "{} B", self.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DirList {
|
||||
pub name: String, // 文件或目录名称
|
||||
pub path: String, // 文件或目录的完整路径
|
||||
pub is_dir: bool, // 是否为目录
|
||||
pub size: ByteUnit, // 文件大小(字节)
|
||||
pub last_modified: String, // 最后修改时间的字符串表示
|
||||
}
|
||||
|
||||
/// 异步列出指定目录下的所有文件和子目录信息
|
||||
///
|
||||
/// # 参数
|
||||
/// * `path` - 要列出内容的目录路径
|
||||
///
|
||||
/// # 返回
|
||||
/// 成功时返回包含 `DirList` 结构的向量,失败时返回错误
|
||||
///
|
||||
/// # 错误
|
||||
/// 可能返回与文件系统操作相关的错误,如目录不存在、权限不足等
|
||||
async fn list_dir(host_root_str: &str, path: &PathBuf) -> anyhow::Result<Vec<DirList>> {
|
||||
use chrono::{Local, TimeZone};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
let mut list = vec![];
|
||||
// 异步读取目录条目
|
||||
let mut entries = fs::read_dir(path)
|
||||
.await
|
||||
.with_context(|| format!("无法读取目录: {}", path.display()))?;
|
||||
|
||||
debug!("list dir path: {:?}", path);
|
||||
|
||||
let mut tasks = vec![];
|
||||
// 遍历目录中的每个条目
|
||||
while let Some(entry) = entries
|
||||
.next_entry()
|
||||
.await
|
||||
.with_context(|| format!("读取目录条目失败: {}", path.display()))?
|
||||
{
|
||||
let host_root_str = host_root_str.to_string();
|
||||
// 为每个条目创建异步任务,并行获取元数据
|
||||
let task = tokio::task::spawn(async move {
|
||||
// 获取文件元数据
|
||||
let metadata = entry
|
||||
.metadata()
|
||||
.await
|
||||
.with_context(|| "获取文件元数据失败")?;
|
||||
|
||||
// 获取并格式化最后修改时间
|
||||
let last_modified = metadata
|
||||
.modified()
|
||||
.with_context(|| "获取文件修改时间失败")?;
|
||||
let last_modified = last_modified
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.with_context(|| "计算 Unix 时间戳失败")?;
|
||||
|
||||
// 转换为本地时间,处理可能的歧义情况
|
||||
let datetime = match Local
|
||||
.timestamp_opt(last_modified.as_secs() as i64, last_modified.subsec_nanos())
|
||||
{
|
||||
chrono::LocalResult::Ambiguous(earlier, later) => {
|
||||
warn!("发现歧义时间: {} 和 {}", earlier, later);
|
||||
earlier
|
||||
}
|
||||
chrono::offset::LocalResult::Single(single) => {
|
||||
// warn!("发现歧义时间: {}", single);
|
||||
single
|
||||
}
|
||||
chrono::offset::LocalResult::None => {
|
||||
error!("无法解析时间时使用当前时间");
|
||||
Local::now()
|
||||
}
|
||||
};
|
||||
let last_modified = datetime.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
|
||||
// 收集其他元数据
|
||||
let size = ByteUnit(metadata.len());
|
||||
let is_dir = metadata.is_dir();
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
let path = entry
|
||||
.path()
|
||||
.to_string_lossy()
|
||||
.strip_prefix(&host_root_str)
|
||||
.ok_or(anyhow!("strip prefix failed"))?
|
||||
.to_string();
|
||||
let path = format!("./{path}");
|
||||
// 创建并返回目录条目信息
|
||||
let dir = DirList {
|
||||
name,
|
||||
path,
|
||||
is_dir,
|
||||
size,
|
||||
last_modified,
|
||||
};
|
||||
anyhow::Ok(dir)
|
||||
});
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
// 等待所有异步任务完成并收集结果
|
||||
for task in tasks {
|
||||
list.push(task.await??);
|
||||
}
|
||||
|
||||
Ok(list)
|
||||
}
|
33
src/main.rs
33
src/main.rs
@ -1,13 +1,17 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
// #![feature(iterator_try_collect)]
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::error;
|
||||
|
||||
use clap::Parser;
|
||||
use config::Settings;
|
||||
use consts::{COMMIT, COMPILER};
|
||||
use http::make_server;
|
||||
use tokio::task::JoinSet;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::{
|
||||
consts::{get_settings, ARCH, NAME, OS, SETTINGS, VERSION},
|
||||
consts::{ARCH, NAME, OS, VERSION},
|
||||
utils::init_logger,
|
||||
};
|
||||
|
||||
@ -21,35 +25,34 @@ mod config;
|
||||
mod consts;
|
||||
mod error;
|
||||
mod http;
|
||||
mod service;
|
||||
mod middlewares;
|
||||
mod utils;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = cli::Cli::parse();
|
||||
init_logger();
|
||||
let settings = Settings::new(&args.config).with_context(|| "init config failed")?;
|
||||
SETTINGS
|
||||
.set(settings)
|
||||
.map_err(|err| anyhow!("init config failed {err:?}"))?;
|
||||
|
||||
// global config
|
||||
let settings = get_settings().with_context(|| "get global settings failed")?;
|
||||
let settings = Settings::new(&args.config).with_context(|| "init config failed")?;
|
||||
debug!("settings {:?}", settings);
|
||||
info!("{}/{} {}", NAME, VERSION, COMMIT);
|
||||
info!("{}", COMPILER);
|
||||
info!("OS: {} {}", OS, ARCH);
|
||||
|
||||
let mut servers = settings
|
||||
.host
|
||||
.iter()
|
||||
.map(|host| host.mk_server())
|
||||
.collect::<JoinSet<_>>();
|
||||
let hosts = settings.host;
|
||||
let mut servers = hosts.into_iter().map(make_server).collect::<JoinSet<_>>();
|
||||
|
||||
info!("server started");
|
||||
|
||||
while let Some(res) = servers.join_next().await {
|
||||
res??;
|
||||
match res {
|
||||
Ok(err) => {
|
||||
err.map_err(|err| error!("server error: {}", err)).ok();
|
||||
}
|
||||
Err(err) => {
|
||||
error!("server error: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
141
src/middlewares/mod.rs
Normal file
141
src/middlewares/mod.rs
Normal file
@ -0,0 +1,141 @@
|
||||
use std::{fmt::Display, time::Duration};
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::{Body, Bytes},
|
||||
extract::Request,
|
||||
http::{HeaderMap, HeaderValue},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use axum_extra::extract::Host;
|
||||
use http::HeaderName;
|
||||
use tower_http::classify::ServerErrorsFailureClass;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::{Span, debug, error, info, info_span};
|
||||
|
||||
use crate::{
|
||||
consts::{NAME, VERSION},
|
||||
http::HOSTS,
|
||||
utils::parse_port_from_host,
|
||||
};
|
||||
|
||||
/// Middleware for adding version information to each response's headers.
|
||||
///
|
||||
/// This middleware takes an incoming `Request` and a `Next` handler, which represents the
|
||||
/// subsequent middleware or route in the chain. It then asynchronously runs the next handler,
|
||||
/// obtaining the response. After receiving the response, it appends two headers:
|
||||
/// - "Server": The name of the server extracted from the Cargo package name.
|
||||
/// - "S-Version": The version of the server extracted from the Cargo package version.
|
||||
pub async fn add_version(req: Request<Body>, next: Next) -> impl IntoResponse {
|
||||
let mut res = next.run(req).await;
|
||||
let headers = res.headers_mut();
|
||||
headers.append("Server", HeaderValue::from_static(NAME));
|
||||
headers.append("RUA-Version", HeaderValue::from_static(VERSION));
|
||||
res
|
||||
}
|
||||
|
||||
/// Middleware for dynamically adding headers to responses based on the requested host and port.
|
||||
///
|
||||
/// This middleware:
|
||||
/// 1. Extracts the `Host` header from the incoming request.
|
||||
/// 2. Parses the host string to determine the port (defaulting to `80` if unspecified).
|
||||
/// 3. Looks up the host configuration in the global `HOST` map (shared state) for the resolved port.
|
||||
/// 4. Appends any configured headers from the host's `SettingHost` to the response.
|
||||
///
|
||||
/// # Behavior
|
||||
/// - If the `Host` header is missing or malformed, the request proceeds unchanged.
|
||||
/// - If the port is invalid or the host configuration is not found, the request proceeds unchanged.
|
||||
/// - Headers are appended to the response only if they are explicitly configured for the host.
|
||||
///
|
||||
/// # Error Handling
|
||||
/// - Silently skips header addition for:
|
||||
/// - Missing or unparseable `Host` headers.
|
||||
/// - Invalid ports (non-numeric or out-of-range).
|
||||
/// - Missing host configurations in `HOST`.
|
||||
/// - Uses `debug!` for logging the resolved port.
|
||||
///
|
||||
/// # Example
|
||||
/// Given a request to `example.com:8080` and a `HOST` entry for port `8080` with headers:
|
||||
/// ```toml
|
||||
/// [hosts."8080"]
|
||||
/// headers = { "X-Custom" = "value" }
|
||||
pub async fn add_headers(Host(host): Host, req: Request, next: Next) -> impl IntoResponse {
|
||||
let Some(scheme) = req.uri().scheme_str() else {
|
||||
return next.run(req).await;
|
||||
};
|
||||
debug!("scheme {:?}", scheme);
|
||||
let Some(port) = parse_port_from_host(&host, scheme) else {
|
||||
return next.run(req).await;
|
||||
};
|
||||
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;
|
||||
};
|
||||
let Some(headers) = host.headers.as_ref() else {
|
||||
return res;
|
||||
};
|
||||
headers.iter().for_each(|entery| {
|
||||
let (key, value) = (entery.key(), entery.value());
|
||||
let Ok(header_name) = HeaderName::from_bytes(key.as_bytes()) else {
|
||||
error!("Invalid header name: {key}");
|
||||
return;
|
||||
};
|
||||
let Ok(header_value) = HeaderValue::from_bytes(value.as_bytes()) else {
|
||||
error!("Invalid header value: {value}");
|
||||
return;
|
||||
};
|
||||
req_headers.append(header_name, header_value);
|
||||
});
|
||||
res
|
||||
}
|
||||
|
||||
/// Middleware for logging each request.
|
||||
///
|
||||
/// This middleware will calculate each request latency
|
||||
/// and add request's information to each info_span.
|
||||
pub fn logging_route(router: Router) -> Router {
|
||||
let make_span = |req: &Request<_>| {
|
||||
let unknown = &HeaderValue::from_static("Unknown");
|
||||
let empty = &HeaderValue::from_static("");
|
||||
let headers = req.headers();
|
||||
let ua = headers
|
||||
.get("User-Agent")
|
||||
.unwrap_or(unknown)
|
||||
.to_str()
|
||||
.unwrap_or("Unknown");
|
||||
let host = headers.get("Host").unwrap_or(empty).to_str().unwrap_or("");
|
||||
info_span!("HTTP", method = ?req.method(), host, uri = ?req.uri(), ua)
|
||||
};
|
||||
|
||||
let trace_layer = TraceLayer::new_for_http()
|
||||
.make_span_with(make_span)
|
||||
.on_request(|_req: &Request<_>, _span: &Span| {})
|
||||
.on_response(|res: &Response, latency: Duration, _span: &Span| {
|
||||
info!("{}", format_latency(latency, res.status()));
|
||||
})
|
||||
.on_body_chunk(|_chunk: &Bytes, _latency: Duration, _span: &Span| {})
|
||||
.on_eos(|_trailers: Option<&HeaderMap>, _stream_duration: Duration, _span: &Span| {})
|
||||
.on_failure(
|
||||
|error: ServerErrorsFailureClass, latency: Duration, _span: &Span| {
|
||||
error!("{}", format_latency(latency, error));
|
||||
},
|
||||
);
|
||||
|
||||
router.layer(trace_layer)
|
||||
}
|
||||
|
||||
/// Format request latency and status message
|
||||
/// return a string
|
||||
fn format_latency(latency: Duration, status: impl Display) -> String {
|
||||
let micros = latency.as_micros();
|
||||
let millis = latency.as_millis();
|
||||
if micros >= 1000 {
|
||||
format!("{status} {millis}ms")
|
||||
} else {
|
||||
format!("{status} {micros}μs")
|
||||
}
|
||||
}
|
209
src/service.rs
209
src/service.rs
@ -1,209 +0,0 @@
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
pin::pin,
|
||||
sync::Arc,
|
||||
time::{self, Duration},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::SettingHost,
|
||||
error::Error,
|
||||
http::{internal_server_error, not_found, CandyHandler},
|
||||
utils::{io_error, load_certs, load_private_key},
|
||||
};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use futures_util::Future;
|
||||
use http::Request;
|
||||
use hyper::body::Incoming;
|
||||
use hyper_util::{
|
||||
rt::{TokioExecutor, TokioIo},
|
||||
server::{self, graceful::GracefulShutdown},
|
||||
};
|
||||
use rustls::ServerConfig;
|
||||
use tokio::{
|
||||
net::{TcpListener, TcpStream},
|
||||
select,
|
||||
};
|
||||
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
impl SettingHost {
|
||||
pub fn mk_server(&'static self) -> impl Future<Output = anyhow::Result<()>> + 'static {
|
||||
let addr = format!("{}:{}", self.ip, self.port);
|
||||
async move {
|
||||
let listener = TcpListener::bind(&addr).await?;
|
||||
info!("host bind on {}", addr);
|
||||
|
||||
let server = server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new());
|
||||
let graceful = server::graceful::GracefulShutdown::new();
|
||||
let mut ctrl_c = pin!(tokio::signal::ctrl_c());
|
||||
|
||||
// load ssl certificate
|
||||
let tls_acceptor: Option<TlsAcceptor> =
|
||||
if self.certificate.is_some() && self.certificate_key.is_some() {
|
||||
// Set a process wide default crypto provider.
|
||||
#[cfg(feature = "ring")]
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
#[cfg(feature = "aws-lc-rs")]
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
// Load public certificate.
|
||||
let certs = load_certs(
|
||||
self.certificate
|
||||
.as_ref()
|
||||
.ok_or(anyhow!("cannot read certificate"))?,
|
||||
)?;
|
||||
info!("load ssl certificate success");
|
||||
// Load private key.
|
||||
let key = load_private_key(
|
||||
self.certificate_key
|
||||
.as_ref()
|
||||
.ok_or(anyhow!("cannot read private key"))?,
|
||||
)?;
|
||||
info!("loading ssl private key success");
|
||||
// Build TLS configuration.
|
||||
let mut server_config = ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)
|
||||
.map_err(|e| io_error(e.to_string()))?;
|
||||
server_config.alpn_protocols =
|
||||
vec![b"h2".to_vec(), b"http/1.1".to_vec(), b"http/1.0".to_vec()];
|
||||
let tls_acceptor = TlsAcceptor::from(Arc::new(server_config));
|
||||
Some(tls_acceptor)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
loop {
|
||||
let tls_acceptor = tls_acceptor.clone();
|
||||
tokio::select! {
|
||||
conn = listener.accept() => {
|
||||
let conn = match conn {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
error!("accept error: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
handle_connection(conn, self, &server, &graceful, tls_acceptor).await;
|
||||
},
|
||||
_ = ctrl_c.as_mut() => {
|
||||
drop(listener);
|
||||
info!("Ctrl-C received, starting shutdown");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
select! {
|
||||
_ = graceful.shutdown() => {
|
||||
info!("Gracefully shutdown!");
|
||||
},
|
||||
_ = tokio::time::sleep(Duration::from_secs(self.timeout.into())) => {
|
||||
error!("Waited 10 seconds for graceful shutdown, aborting...");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Use to handle connection
|
||||
///
|
||||
/// ## Arguments
|
||||
///
|
||||
/// `$stream`: TcpStream or TlsStream
|
||||
/// `$server`: hyper_util server Builder
|
||||
/// `$service`: hyper service
|
||||
/// `$graceful`: hyper_util server graceful shutdown
|
||||
/// `$peer_addr`: SocketAddr
|
||||
macro_rules! handle_connection {
|
||||
($stream:expr, $server:expr, $service:expr, $graceful:expr, $peer_addr:expr) => {
|
||||
let stream = TokioIo::new(Box::pin($stream));
|
||||
let conn =
|
||||
$server.serve_connection_with_upgrades(stream, hyper::service::service_fn($service));
|
||||
let conn = $graceful.watch(conn.into_owned());
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = conn.await {
|
||||
error!("connection error: {}", err);
|
||||
}
|
||||
debug!("connection dropped: {}", $peer_addr);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/// Handle tcp connection from client
|
||||
/// then use hyper service to handle response
|
||||
///
|
||||
/// ## Arguments
|
||||
///
|
||||
/// `conn`: connection accepted from TcpListener
|
||||
/// `host`: SettingHost from config file
|
||||
/// `server`: hyper_util server Builder
|
||||
/// `graceful`: hyper_util server graceful shutdown
|
||||
async fn handle_connection(
|
||||
conn: (TcpStream, SocketAddr),
|
||||
host: &'static SettingHost,
|
||||
server: &server::conn::auto::Builder<TokioExecutor>,
|
||||
graceful: &GracefulShutdown,
|
||||
tls_acceptor: Option<TlsAcceptor>,
|
||||
) {
|
||||
let (stream, peer_addr) = conn;
|
||||
debug!("incomming connection accepted: {}", peer_addr);
|
||||
|
||||
let service = move |req: Request<Incoming>| async move {
|
||||
let start_time = time::Instant::now();
|
||||
let method = req.method().clone();
|
||||
let uri = req.uri().clone();
|
||||
let path = uri.path();
|
||||
let version = req.version();
|
||||
let mut handler = CandyHandler::new(req, host);
|
||||
// Connection handler in service_fn
|
||||
// then decide whether to handle proxy or static file based on config
|
||||
handler
|
||||
.add_headers()
|
||||
.map_err(|err| error!("add headers to response failed {}", err))
|
||||
.ok();
|
||||
let res = handler.handle().await;
|
||||
let response = match res {
|
||||
Ok(res) => res,
|
||||
Err(Error::NotFound(err)) => {
|
||||
warn!("{err}");
|
||||
not_found()
|
||||
}
|
||||
Err(err) => {
|
||||
error!("{err}");
|
||||
internal_server_error()
|
||||
}
|
||||
};
|
||||
let instant_elapsed = start_time.elapsed();
|
||||
let micros = instant_elapsed.as_micros();
|
||||
let millis = instant_elapsed.as_millis();
|
||||
let end_time = if micros >= 1000 {
|
||||
format!("{millis:.3}ms")
|
||||
} else {
|
||||
format!("{micros:.3}μs")
|
||||
};
|
||||
let res_status = response.status();
|
||||
info!("\"{peer_addr}\" {method} {path} {version:?} {res_status} {end_time}");
|
||||
anyhow::Ok(response)
|
||||
};
|
||||
|
||||
if host.certificate.is_some() && host.certificate_key.is_some() {
|
||||
let Some(tls_acceptor) = tls_acceptor else {
|
||||
warn!("tls_acceptor is None");
|
||||
return;
|
||||
};
|
||||
let tls_stream = match tls_acceptor.accept(stream).await {
|
||||
Ok(tls_stream) => tls_stream,
|
||||
Err(err) => {
|
||||
debug!("failed to perform tls handshake: {err:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
handle_connection!(tls_stream, server, service, graceful, peer_addr);
|
||||
} else {
|
||||
handle_connection!(stream, server, service, graceful, peer_addr);
|
||||
}
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
use async_compression::tokio::bufread::{BrotliEncoder, DeflateEncoder, GzipEncoder, ZstdEncoder};
|
||||
use futures_util::TryStreamExt;
|
||||
use http_body_util::{BodyExt, StreamBody};
|
||||
use hyper::body::{Bytes, Frame};
|
||||
use tokio::io::{AsyncBufRead, BufReader};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
use crate::{error::Error, http::CandyBody};
|
||||
|
||||
pub enum CompressType {
|
||||
Zstd,
|
||||
Gzip,
|
||||
Deflate,
|
||||
Brotli,
|
||||
}
|
||||
|
||||
macro_rules! encode {
|
||||
($encoder:ident, $file:ident) => {{
|
||||
let encoder_stream = $encoder::new($file);
|
||||
let reader_stream = ReaderStream::new(encoder_stream);
|
||||
let stream_body = StreamBody::new(reader_stream.map_ok(Frame::data));
|
||||
let boxed_body = BodyExt::map_err(stream_body, Error::Io).boxed();
|
||||
boxed_body
|
||||
}};
|
||||
}
|
||||
|
||||
pub fn stream_compress<R>(compress_type: CompressType, reader: R) -> CandyBody<Bytes>
|
||||
where
|
||||
R: AsyncBufRead + Sync + Send + 'static,
|
||||
{
|
||||
use CompressType::*;
|
||||
|
||||
let file_reader = BufReader::new(reader);
|
||||
|
||||
match compress_type {
|
||||
Zstd => {
|
||||
encode!(ZstdEncoder, file_reader)
|
||||
}
|
||||
Gzip => encode!(GzipEncoder, file_reader),
|
||||
Deflate => encode!(DeflateEncoder, file_reader),
|
||||
Brotli => encode!(BrotliEncoder, file_reader),
|
||||
}
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
use tracing_subscriber::{
|
||||
EnvFilter,
|
||||
fmt::{self},
|
||||
prelude::*,
|
||||
registry, EnvFilter,
|
||||
registry,
|
||||
};
|
||||
|
||||
pub fn init_logger() {
|
||||
|
@ -1,4 +1,3 @@
|
||||
pub mod compress;
|
||||
pub mod logging;
|
||||
pub mod service;
|
||||
|
||||
|
@ -1,132 +1,135 @@
|
||||
use std::{fs, io};
|
||||
use std::time::Duration;
|
||||
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
|
||||
use tracing::debug;
|
||||
use axum_server::Handle;
|
||||
use tokio::{signal, time::sleep};
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
// Asynchronously waits for a shutdown signal and executes a callback function when a signal is received.
|
||||
//
|
||||
// This function listens for shutdown signals in the form of `Ctrl+C` and termination signals. When one of
|
||||
// these signals is received, it invokes the provided callback function `shutdown_cb`.
|
||||
//
|
||||
// The behavior of the signal handling depends on the operating system:
|
||||
//
|
||||
// - On Unix-based systems (e.g., Linux, macOS), it listens for termination signals (such as SIGTERM).
|
||||
// - On non-Unix systems (e.g., Windows), it only listens for `Ctrl+C` and ignores termination signals.
|
||||
//
|
||||
// The `shutdown_cb` callback function is executed when either signal is received. This function should
|
||||
// contain the logic needed to gracefully shut down the application or perform any necessary cleanup tasks.
|
||||
// # Parameters
|
||||
//
|
||||
// - `shutdown_cb`: A closure or function to call when a shutdown signal is received. The function should
|
||||
// have the signature `Fn()`. This callback is executed without any parameters.
|
||||
//
|
||||
// # Errors
|
||||
//
|
||||
// - If setting up the signal handlers fails, the function will panic with an error message.
|
||||
//
|
||||
// # Panics
|
||||
//
|
||||
// - Panics if the setup for `Ctrl+C` or termination signal handlers fails.
|
||||
//
|
||||
// # Platform-specific behavior
|
||||
//
|
||||
// - On Unix-based systems, termination signals are handled using the `signal` crate for Unix signals.
|
||||
// - On non-Unix systems, only `Ctrl+C` signals are handled, and termination signals are not supported.
|
||||
//
|
||||
// # Future
|
||||
//
|
||||
// This function returns a future that resolves when either `Ctrl+C` or a termination signal is received
|
||||
// and the callback function has been executed.
|
||||
// pub async fn shutdown_signal<F>(shutdown_cb: F)
|
||||
// where
|
||||
// F: Fn(),
|
||||
// {
|
||||
// let ctrl_c = async {
|
||||
// signal::ctrl_c()
|
||||
// .await
|
||||
// .expect("failed to install Ctrl+C handler");
|
||||
// };
|
||||
//
|
||||
// #[cfg(unix)]
|
||||
// let terminate = async {
|
||||
// signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||
// .expect("failed to install signal handler")
|
||||
// .recv()
|
||||
// .await;
|
||||
// };
|
||||
//
|
||||
// #[cfg(not(unix))]
|
||||
// let terminate = std::future::pending::<()>();
|
||||
//
|
||||
// tokio::select! {
|
||||
// _ = ctrl_c => {
|
||||
// },
|
||||
// _ = terminate => {
|
||||
// },
|
||||
// }
|
||||
//
|
||||
// tracing::info!("Received termination signal shutting down");
|
||||
// shutdown_cb()
|
||||
// }
|
||||
|
||||
use crate::config::{HostRouteMap, SettingRoute};
|
||||
pub async fn graceful_shutdown(handle: Handle) {
|
||||
let ctrl_c = async {
|
||||
signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
/// Parse assets file path
|
||||
///
|
||||
/// ## Arguments
|
||||
///
|
||||
/// `assets_path`: the rest part of client request path
|
||||
/// `assets_root`: local directory path from config file
|
||||
/// `index_file`: index file format from config file
|
||||
#[inline]
|
||||
pub fn parse_assets_path(assets_path: &str, assets_root: &str, index_file: &str) -> String {
|
||||
match assets_path {
|
||||
str if str.ends_with('/') => {
|
||||
format!("{}{}{}", assets_root, assets_path, index_file)
|
||||
}
|
||||
str if str.contains('.') && !str.starts_with('/') => {
|
||||
format!("{}/{}", assets_root, assets_path)
|
||||
}
|
||||
str if !str.starts_with('/') => {
|
||||
format!("{}/{}/{}", assets_root, assets_path, index_file)
|
||||
}
|
||||
_ => {
|
||||
format!("{}/{}/{}", assets_root, assets_path, index_file)
|
||||
}
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install signal handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {
|
||||
},
|
||||
_ = terminate => {
|
||||
},
|
||||
}
|
||||
|
||||
info!("Received termination signal shutting down");
|
||||
info!("Server shuting down");
|
||||
|
||||
// Signal the server to shutdown using Handle.
|
||||
handle.graceful_shutdown(Some(Duration::from_secs(30)));
|
||||
|
||||
// Print alive connection count every second.
|
||||
loop {
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
debug!("alive connections: {}", handle.connection_count());
|
||||
}
|
||||
}
|
||||
|
||||
/// Find target route by req path
|
||||
///
|
||||
/// ## Arguments
|
||||
///
|
||||
/// `req_path`: client request path
|
||||
/// `route_map`: router map from config
|
||||
///
|
||||
/// ## Return
|
||||
///
|
||||
/// a result. return none when path not registried
|
||||
/// `router`: host from config file
|
||||
/// `assets_path`: the rest part of client request path
|
||||
pub fn find_route<'a>(
|
||||
req_path: &'a str,
|
||||
route_map: &'a HostRouteMap,
|
||||
) -> Result<(&'a SettingRoute, &'a str)> {
|
||||
let not_found_err = format!("resource {} not found", &req_path);
|
||||
// /public/www/test
|
||||
// convert req path to chars
|
||||
let all_chars = req_path.chars().collect::<Vec<_>>();
|
||||
let mut last_router = None;
|
||||
// then loop all req path
|
||||
// until found the route
|
||||
// /public/www/test
|
||||
// /public/www/tes
|
||||
// /public/www/te
|
||||
// /public/www/t
|
||||
// /public/www/
|
||||
for (i, _) in all_chars.iter().enumerate().rev() {
|
||||
let index = i + 1;
|
||||
let path = &all_chars[..index];
|
||||
let path_str = path.iter().collect::<String>();
|
||||
let Some(router) = route_map.get(&path_str) else {
|
||||
continue;
|
||||
};
|
||||
last_router = Some((router, &req_path[index..]));
|
||||
break;
|
||||
}
|
||||
|
||||
let (router, assets_path) = last_router.ok_or(Error::NotFound(not_found_err.into()))?;
|
||||
debug!("router {:?}", &router);
|
||||
debug!("assets_path {assets_path}");
|
||||
Ok((router, assets_path))
|
||||
}
|
||||
|
||||
pub fn io_error(err: String) -> io::Error {
|
||||
io::Error::other(err)
|
||||
}
|
||||
|
||||
// Load public certificate from file.
|
||||
pub fn load_certs(filename: &str) -> io::Result<Vec<CertificateDer<'static>>> {
|
||||
// Open certificate file.
|
||||
let certfile = fs::File::open(filename)
|
||||
.map_err(|e| io_error(format!("failed to open {}: {}", filename, e)))?;
|
||||
let mut reader = io::BufReader::new(certfile);
|
||||
|
||||
// Load and return certificate.
|
||||
rustls_pemfile::certs(&mut reader).collect()
|
||||
}
|
||||
|
||||
// Load private key from file.
|
||||
pub fn load_private_key(filename: &str) -> io::Result<PrivateKeyDer<'static>> {
|
||||
// Open keyfile.
|
||||
let keyfile = fs::File::open(filename)
|
||||
.map_err(|e| io_error(format!("failed to open {}: {}", filename, e)))?;
|
||||
let mut reader = io::BufReader::new(keyfile);
|
||||
|
||||
// Load and return a single private key.
|
||||
rustls_pemfile::private_key(&mut reader).map(|key| key.unwrap())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_assets_path_works() {
|
||||
let path = parse_assets_path("/docs/", "./public", "index.html");
|
||||
assert_eq!(path, "./public/docs/index.html".to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_route_works() {
|
||||
let setting_route = SettingRoute {
|
||||
location: "/".to_string(),
|
||||
root: Some("./public".to_string()),
|
||||
index: vec!["index.html".into()],
|
||||
error_page: None,
|
||||
proxy_pass: None,
|
||||
proxy_timeout: 10,
|
||||
};
|
||||
let map = BTreeMap::from([("/".to_string(), setting_route)]);
|
||||
let (_, assets_path) = find_route("/docs/home", &map).unwrap();
|
||||
assert_eq!(assets_path, "docs/home")
|
||||
}
|
||||
/// Parse port from host
|
||||
/// if host is localhost:8080
|
||||
/// return 8080
|
||||
/// if host is localhost
|
||||
/// return 80
|
||||
pub fn parse_port_from_host(host: &str, scheme: &str) -> Option<u16> {
|
||||
// localhost:8080
|
||||
// ["localhost", "8080"]
|
||||
// localhost
|
||||
// ["localhost"]
|
||||
let host_parts = host.split(':').collect::<Vec<&str>>();
|
||||
let port = if host_parts.len() == 1 {
|
||||
match scheme {
|
||||
"http" => 80,
|
||||
"https" => 443,
|
||||
_ => {
|
||||
debug!("scheme not support");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
host_parts.get(1)?.parse::<u16>().ok()?
|
||||
};
|
||||
Some(port)
|
||||
}
|
||||
|
Reference in New Issue
Block a user