├── taxy ├── dist │ └── .gitkeep ├── .gitignore ├── build.rs ├── src │ ├── proxy │ │ ├── http │ │ │ ├── hyper_tls │ │ │ │ ├── mod.rs │ │ │ │ └── stream.rs │ │ │ ├── error.rs │ │ │ ├── filter.rs │ │ │ ├── route.rs │ │ │ └── pool.rs │ │ ├── tls.rs │ │ └── mod.rs │ ├── lib.rs │ ├── admin │ │ ├── app_info.rs │ │ ├── config.rs │ │ ├── acme.rs │ │ ├── proxies.rs │ │ ├── ports.rs │ │ ├── static_file.rs │ │ ├── certs.rs │ │ ├── logs.rs │ │ └── auth.rs │ ├── server │ │ ├── rpc │ │ │ ├── auth.rs │ │ │ ├── config.rs │ │ │ ├── mod.rs │ │ │ ├── acme.rs │ │ │ ├── proxies.rs │ │ │ ├── certs.rs │ │ │ └── ports.rs │ │ ├── acme_list.rs │ │ ├── port_list.rs │ │ ├── cert_list.rs │ │ └── proxy_list.rs │ ├── config │ │ ├── mod.rs │ │ └── storage.rs │ ├── command.rs │ ├── args.rs │ └── main.rs ├── tests │ ├── udp_test.rs │ ├── tcp_test.rs │ ├── ws_test.rs │ ├── tls_test.rs │ └── wss_test.rs ├── templates │ └── error.stpl └── Cargo.toml ├── docs ├── .gitignore ├── static │ ├── favicon.ico │ ├── mstile-70x70.png │ ├── screenshot.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── mstile-144x144.png │ ├── mstile-150x150.png │ ├── mstile-310x150.png │ ├── mstile-310x310.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── logo.svg │ └── safari-pinned-tab.svg ├── content │ ├── demo.md │ ├── development.md │ └── configuration.md ├── config.toml ├── templates │ ├── _variables.html │ └── index.html └── sass │ └── taxy.scss ├── taxy-webui ├── robots.txt ├── assets │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── mstile-70x70.png │ ├── apple-touch-icon.png │ ├── mstile-144x144.png │ ├── mstile-150x150.png │ ├── mstile-310x150.png │ ├── mstile-310x310.png │ ├── browserconfig.xml │ ├── icons │ │ ├── remove.svg │ │ ├── add.svg │ │ ├── arrow-back.svg │ │ ├── swap-horizontal.svg │ │ ├── log-out.svg │ │ ├── wifi.svg │ │ ├── create.svg │ │ ├── cloud-upload.svg │ │ └── ribbon.svg │ ├── logo.svg │ └── safari-pinned-tab.svg ├── js │ └── logout.js ├── src │ ├── components │ │ ├── mod.rs │ │ └── navbar.rs │ ├── tailwind.css │ ├── pages │ │ ├── logout.rs │ │ ├── new_port.rs │ │ ├── new_proxy.rs │ │ ├── mod.rs │ │ ├── port_view.rs │ │ ├── proxy_view.rs │ │ └── log_view.rs │ ├── main.rs │ ├── auth.rs │ ├── store.rs │ ├── format.rs │ └── event.rs ├── tailwind.config.js ├── Trunk.toml ├── Cargo.toml └── index.html ├── screenshot.png ├── .vscode └── settings.json ├── taxy-api ├── README.md ├── src │ ├── lib.rs │ ├── tls.rs │ ├── event.rs │ ├── auth.rs │ ├── id.rs │ ├── error.rs │ ├── log.rs │ ├── app.rs │ ├── vhost.rs │ ├── acme.rs │ ├── cert.rs │ ├── subject_name.rs │ └── port.rs └── Cargo.toml ├── .deepsource.toml ├── .gitmodules ├── .gitignore ├── .gitpod.Dockerfile ├── .devcontainer └── devcontainer.json ├── Cargo.toml ├── .github ├── release.yml └── workflows │ ├── update-changelog.yaml │ ├── rust.yml │ ├── zola.yml │ └── docker.yml ├── .gitpod.yml ├── logo.svg ├── LICENSE ├── Dockerfile └── Dockerfile.demo /taxy/dist/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | /public -------------------------------------------------------------------------------- /taxy/.gitignore: -------------------------------------------------------------------------------- 1 | dist/webui -------------------------------------------------------------------------------- /taxy-webui/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/picoHz/taxy/HEAD/screenshot.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.showUnlinkedFileNotification": false 3 | } -------------------------------------------------------------------------------- /docs/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/picoHz/taxy/HEAD/docs/static/favicon.ico -------------------------------------------------------------------------------- /docs/static/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/picoHz/taxy/HEAD/docs/static/mstile-70x70.png -------------------------------------------------------------------------------- /docs/static/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/picoHz/taxy/HEAD/docs/static/screenshot.png -------------------------------------------------------------------------------- /docs/static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/picoHz/taxy/HEAD/docs/static/favicon-16x16.png -------------------------------------------------------------------------------- /docs/static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/picoHz/taxy/HEAD/docs/static/favicon-32x32.png -------------------------------------------------------------------------------- /docs/static/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/picoHz/taxy/HEAD/docs/static/mstile-144x144.png -------------------------------------------------------------------------------- /docs/static/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/picoHz/taxy/HEAD/docs/static/mstile-150x150.png -------------------------------------------------------------------------------- /docs/static/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/picoHz/taxy/HEAD/docs/static/mstile-310x150.png -------------------------------------------------------------------------------- /docs/static/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/picoHz/taxy/HEAD/docs/static/mstile-310x310.png -------------------------------------------------------------------------------- /taxy-api/README.md: -------------------------------------------------------------------------------- 1 | # taxy-api 2 | 3 | Type definitions and API for [taxy](https://github.com/picoHz/taxy). -------------------------------------------------------------------------------- /taxy-webui/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/picoHz/taxy/HEAD/taxy-webui/assets/favicon.ico -------------------------------------------------------------------------------- /docs/static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/picoHz/taxy/HEAD/docs/static/apple-touch-icon.png -------------------------------------------------------------------------------- /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [[analyzers]] 4 | name = "rust" 5 | 6 | [analyzers.meta] 7 | msrv = "stable" -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "docs/themes/juice"] 2 | path = docs/themes/juice 3 | url = https://github.com/huhu/juice 4 | -------------------------------------------------------------------------------- /taxy-webui/assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/picoHz/taxy/HEAD/taxy-webui/assets/favicon-16x16.png -------------------------------------------------------------------------------- /taxy-webui/assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/picoHz/taxy/HEAD/taxy-webui/assets/favicon-32x32.png -------------------------------------------------------------------------------- /taxy-webui/assets/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/picoHz/taxy/HEAD/taxy-webui/assets/mstile-70x70.png -------------------------------------------------------------------------------- /taxy-webui/assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/picoHz/taxy/HEAD/taxy-webui/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /taxy-webui/assets/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/picoHz/taxy/HEAD/taxy-webui/assets/mstile-144x144.png -------------------------------------------------------------------------------- /taxy-webui/assets/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/picoHz/taxy/HEAD/taxy-webui/assets/mstile-150x150.png -------------------------------------------------------------------------------- /taxy-webui/assets/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/picoHz/taxy/HEAD/taxy-webui/assets/mstile-310x150.png -------------------------------------------------------------------------------- /taxy-webui/assets/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/picoHz/taxy/HEAD/taxy-webui/assets/mstile-310x310.png -------------------------------------------------------------------------------- /taxy/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | built::write_built_file().expect("Failed to acquire build-time information"); 3 | } 4 | -------------------------------------------------------------------------------- /taxy-webui/js/logout.js: -------------------------------------------------------------------------------- 1 | export function logout() { 2 | if (location.pathname !== "/login") { 3 | location.pathname = "/login"; 4 | } 5 | } -------------------------------------------------------------------------------- /taxy/src/proxy/http/hyper_tls/mod.rs: -------------------------------------------------------------------------------- 1 | // This is the ruslts version of hyper-tls(https://github.com/hyperium/hyper-tls) 2 | pub mod client; 3 | pub mod stream; 4 | -------------------------------------------------------------------------------- /taxy/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod admin; 2 | pub mod args; 3 | pub mod certs; 4 | pub mod command; 5 | pub mod config; 6 | pub mod log; 7 | pub mod proxy; 8 | pub mod server; 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # These are backup files generated by rustfmt 6 | **/*.rs.bk 7 | 8 | # Added by cargo 9 | /target 10 | -------------------------------------------------------------------------------- /taxy-webui/src/components/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod acme_provider; 2 | pub mod custom_acme; 3 | pub mod http_proxy_config; 4 | pub mod navbar; 5 | pub mod port_config; 6 | pub mod proxy_config; 7 | pub mod tcp_proxy_config; 8 | pub mod udp_proxy_config; 9 | -------------------------------------------------------------------------------- /.gitpod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gitpod/workspace-full:latest 2 | 3 | RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash 4 | RUN cargo binstall -y trunk 5 | RUN rustup target add wasm32-unknown-unknown -------------------------------------------------------------------------------- /taxy/src/admin/app_info.rs: -------------------------------------------------------------------------------- 1 | use super::{AppError, AppState}; 2 | use axum::{extract::State, Json}; 3 | use taxy_api::app::AppInfo; 4 | 5 | pub async fn get(State(state): State) -> Result, AppError> { 6 | Ok(Json(state.data.lock().await.app_info.clone())) 7 | } 8 | -------------------------------------------------------------------------------- /taxy-api/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod acme; 2 | pub mod app; 3 | pub mod auth; 4 | pub mod cert; 5 | pub mod error; 6 | pub mod event; 7 | pub mod id; 8 | pub mod log; 9 | pub mod multiaddr; 10 | pub mod port; 11 | pub mod proxy; 12 | pub mod subject_name; 13 | pub mod tls; 14 | pub mod vhost; 15 | -------------------------------------------------------------------------------- /docs/static/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #ffc40d 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /taxy-webui/assets/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #ffc40d 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /taxy-webui/assets/icons/remove.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 7 | -------------------------------------------------------------------------------- /taxy-webui/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mode: "jit", 3 | content: { 4 | files: ["src/**/*.rs", "**/*.html"], 5 | }, 6 | darkMode: "media", // 'media' or 'class' 7 | theme: { 8 | extend: {}, 9 | }, 10 | variants: { 11 | extend: {}, 12 | }, 13 | plugins: [], 14 | }; -------------------------------------------------------------------------------- /taxy-webui/assets/icons/add.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 7 | -------------------------------------------------------------------------------- /taxy-webui/src/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body { 7 | background-color: #fafafa; 8 | height: 100%; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | 13 | html, 14 | body { 15 | background-color: #333; 16 | color: #fff; 17 | height: 100%; 18 | } 19 | } -------------------------------------------------------------------------------- /taxy-webui/assets/icons/arrow-back.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 7 | -------------------------------------------------------------------------------- /taxy-webui/Trunk.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | target = "index.html" 3 | dist = "../taxy/dist/webui" 4 | 5 | [[hooks]] 6 | stage = "post_build" 7 | command = "sh" 8 | command_arguments = [ 9 | "-c", 10 | "[ \"$TRUNK_PROFILE\" = \"release\" ] && find \"$TRUNK_STAGING_DIR\" -type f -exec gzip -9 {} \\; || true", 11 | ] 12 | 13 | [[proxy]] 14 | backend = "http://localhost:46492/api/" 15 | -------------------------------------------------------------------------------- /taxy-webui/assets/icons/swap-horizontal.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 7 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/devcontainers/universal:2", 3 | "features": { 4 | "ghcr.io/devcontainers/features/rust:1": { 5 | "version": "latest", 6 | "profile": "default" 7 | }, 8 | "ghcr.io/lee-orr/rusty-dev-containers/wasm32-unknown-unknown:0": {}, 9 | "ghcr.io/lee-orr/rusty-dev-containers/cargo-binstall:0": { 10 | "packages": "trunk" 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["taxy", "taxy-api", "taxy-webui"] 3 | resolver = "2" 4 | 5 | [patch.crates-io] 6 | taxy-api = { path = "./taxy-api" } 7 | 8 | [profile.release] 9 | strip = true 10 | lto = true 11 | 12 | [profile.dev.package.argon2] 13 | opt-level = 3 14 | 15 | [profile.web-release] 16 | inherits = "release" 17 | opt-level = 'z' 18 | panic = 'abort' 19 | strip = true 20 | lto = true 21 | codegen-units = 1 22 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | categories: 6 | - title: Breaking Changes 7 | labels: 8 | - breaking-change 9 | - title: Bug Fixes 10 | labels: 11 | - bug 12 | - title: New Features 13 | labels: 14 | - enhancement 15 | - title: WebUI Updates 16 | labels: 17 | - webui 18 | - title: Other Changes 19 | labels: 20 | - "*" 21 | -------------------------------------------------------------------------------- /docs/content/demo.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "Demo" 3 | description = "Admin Panel Demo" 4 | weight = 1 5 | +++ 6 | 7 | The demo admin panel is available at [https://demo.taxy.dev/](https://demo.taxy.dev/). 8 | Please use the following credentials to log in: 9 | 10 | - Username: `admin` 11 | - Password: `admin` 12 | 13 | > Please note, you can change the configuration freely, but due to the instance being behind a firewall, the configured proxies are not accessible from the outside. 14 | -------------------------------------------------------------------------------- /taxy-webui/assets/icons/log-out.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 7 | -------------------------------------------------------------------------------- /taxy-webui/src/pages/logout.rs: -------------------------------------------------------------------------------- 1 | use crate::{pages::Route, API_ENDPOINT}; 2 | use gloo_net::http::Request; 3 | use yew::prelude::*; 4 | use yew_router::prelude::*; 5 | 6 | #[function_component(Logout)] 7 | pub fn logout() -> Html { 8 | wasm_bindgen_futures::spawn_local(async move { 9 | Request::get(&format!("{API_ENDPOINT}/logout")) 10 | .send() 11 | .await 12 | .unwrap(); 13 | }); 14 | 15 | html! { 16 | to={Route::Login}/> 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /taxy/src/server/rpc/auth.rs: -------------------------------------------------------------------------------- 1 | use super::RpcMethod; 2 | use crate::server::state::ServerState; 3 | use taxy_api::{ 4 | auth::{LoginRequest, LoginResponse}, 5 | error::Error, 6 | }; 7 | 8 | pub struct VerifyAccount { 9 | pub request: LoginRequest, 10 | } 11 | 12 | #[async_trait::async_trait] 13 | impl RpcMethod for VerifyAccount { 14 | type Output = LoginResponse; 15 | 16 | async fn call(self, state: &mut ServerState) -> Result { 17 | state.storage.verify_account(self.request).await 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /taxy/src/admin/config.rs: -------------------------------------------------------------------------------- 1 | use super::{AppError, AppState}; 2 | use crate::server::rpc::config::{GetConfig, SetConfig}; 3 | use axum::{extract::State, Json}; 4 | use taxy_api::app::AppConfig; 5 | 6 | pub async fn get(State(state): State) -> Result>, AppError> { 7 | Ok(Json(state.call(GetConfig).await?)) 8 | } 9 | 10 | pub async fn put( 11 | State(state): State, 12 | Json(config): Json, 13 | ) -> Result>, AppError> { 14 | Ok(Json(state.call(SetConfig { config }).await?)) 15 | } 16 | -------------------------------------------------------------------------------- /taxy-api/src/tls.rs: -------------------------------------------------------------------------------- 1 | use serde_default::DefaultFromSerde; 2 | use serde_derive::{Deserialize, Serialize}; 3 | use utoipa::ToSchema; 4 | 5 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] 6 | #[serde(rename_all = "snake_case")] 7 | pub enum TlsState { 8 | Active, 9 | } 10 | 11 | #[derive(Debug, Clone, DefaultFromSerde, PartialEq, Eq, Serialize, Deserialize, ToSchema)] 12 | pub struct TlsTermination { 13 | #[serde(default)] 14 | #[schema(example = json!(["*.example.com"]))] 15 | pub server_names: Vec, 16 | } 17 | -------------------------------------------------------------------------------- /taxy/src/config/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | use taxy_api::app::AppInfo; 3 | 4 | pub mod file; 5 | pub mod storage; 6 | 7 | mod build_info { 8 | include!(concat!(env!("OUT_DIR"), "/built.rs")); 9 | } 10 | 11 | pub fn new_appinfo(config_path: &Path, log_path: &Path) -> AppInfo { 12 | AppInfo { 13 | version: build_info::PKG_VERSION, 14 | target: build_info::TARGET, 15 | profile: build_info::PROFILE, 16 | features: &build_info::FEATURES[..], 17 | rustc: build_info::RUSTC_VERSION, 18 | config_path: config_path.to_owned(), 19 | log_path: log_path.to_owned(), 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /taxy-webui/assets/icons/wifi.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /docs/static/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # This configuration file was automatically generated by Gitpod. 2 | # Please adjust to your needs (see https://www.gitpod.io/docs/introduction/learn-gitpod/gitpod-yaml) 3 | # and commit this file to your remote git repository to share the goodness with others. 4 | 5 | # Learn more from ready-to-use templates: https://www.gitpod.io/docs/introduction/getting-started/quickstart 6 | 7 | image: 8 | file: .gitpod.Dockerfile 9 | 10 | tasks: 11 | - before: cd taxy 12 | init: cargo r add-user admin -p admin 13 | command: cargo r start 14 | - before: cd taxy-webui 15 | init: trunk build 16 | command: trunk serve 17 | openMode: split-right 18 | 19 | vscode: 20 | extensions: 21 | - rust-lang.rust-analyzer 22 | -------------------------------------------------------------------------------- /taxy-webui/assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /taxy/src/server/rpc/config.rs: -------------------------------------------------------------------------------- 1 | use super::RpcMethod; 2 | use crate::server::state::ServerState; 3 | use taxy_api::app::AppConfig; 4 | use taxy_api::error::Error; 5 | 6 | pub struct GetConfig; 7 | 8 | #[async_trait::async_trait] 9 | impl RpcMethod for GetConfig { 10 | type Output = AppConfig; 11 | 12 | async fn call(self, state: &mut ServerState) -> Result { 13 | Ok(state.config().clone()) 14 | } 15 | } 16 | 17 | pub struct SetConfig { 18 | pub config: AppConfig, 19 | } 20 | 21 | #[async_trait::async_trait] 22 | impl RpcMethod for SetConfig { 23 | type Output = (); 24 | 25 | async fn call(self, state: &mut ServerState) -> Result { 26 | state.set_config(self.config).await 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /docs/config.toml: -------------------------------------------------------------------------------- 1 | # The URL the site will be built for 2 | base_url = "https://taxy.dev" 3 | 4 | title = "Taxy: Effortless Web Proxy" 5 | 6 | # Whether to automatically compile all Sass files in the sass directory 7 | compile_sass = true 8 | 9 | # Whether to build a search index to be used later on by a JavaScript library 10 | build_search_index = true 11 | 12 | theme = "juice" 13 | 14 | [markdown] 15 | # Whether to do syntax highlighting 16 | # Theme can be customised by setting the `highlight_theme` variable to a theme supported by Zola 17 | highlight_code = true 18 | 19 | [extra] 20 | 21 | juice_logo_name = "" 22 | juice_logo_path = "logo.svg" 23 | juice_extra_menu = [ 24 | { title = "Github", link = "https://github.com/picoHz/taxy"} 25 | ] 26 | repository_url = "https://github.com/picoHz/taxy" 27 | 28 | -------------------------------------------------------------------------------- /taxy-webui/assets/icons/create.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 7 | 9 | -------------------------------------------------------------------------------- /taxy-webui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "taxy-webui" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | yew = { version = "0.21", features = ["csr"] } 8 | wasm-bindgen = "0.2.93" 9 | console_error_panic_hook = "0.1.7" 10 | yew-router = "0.18.0" 11 | gloo-net = "0.6.0" 12 | getrandom = { version = "0.2", features = ["js"] } 13 | wasm-bindgen-futures = "0.4.37" 14 | taxy-api = "0.2.2" 15 | serde_json = "1.0.102" 16 | gloo-utils = "0.2.0" 17 | serde_derive = "1.0.171" 18 | serde = "1.0.171" 19 | yewdux = "0.11.0" 20 | web-sys = { version = "0.3.64", features = ["HtmlSelectElement"] } 21 | futures = "0.3.28" 22 | gloo-dialogs = "0.2.0" 23 | gloo-timers = "0.3.0" 24 | url = "2.4.0" 25 | base64 = "0.22.1" 26 | time = { version = "0.3.36", features = ["formatting"] } 27 | gloo-events = "0.2.0" 28 | web-time = "1.0.0" 29 | fancy-duration = "0.9.1" 30 | -------------------------------------------------------------------------------- /taxy-webui/src/main.rs: -------------------------------------------------------------------------------- 1 | #![recursion_limit = "1024"] 2 | 3 | use components::navbar::Navbar; 4 | use console_error_panic_hook::set_once as set_panic_hook; 5 | use yew::prelude::*; 6 | use yew_router::prelude::*; 7 | 8 | mod auth; 9 | mod components; 10 | mod event; 11 | mod format; 12 | mod pages; 13 | mod store; 14 | 15 | const API_ENDPOINT: &str = "/api"; 16 | 17 | #[function_component(App)] 18 | pub fn app() -> Html { 19 | event::use_event_subscriber(); 20 | html! { 21 | <> 22 | 23 | 24 |
25 | render={pages::switch} /> 26 |
27 |
28 | 29 | } 30 | } 31 | 32 | fn main() { 33 | set_panic_hook(); 34 | yew::Renderer::::new().render(); 35 | } 36 | -------------------------------------------------------------------------------- /taxy-api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "taxy-api" 3 | description = "Type definitions and API for taxy" 4 | version = "0.2.2" 5 | edition = "2021" 6 | authors = ["picoHz "] 7 | keywords = ["tcp", "http", "tls", "proxy", "reverse-proxy"] 8 | categories = [ 9 | "network-programming", 10 | "web-programming", 11 | "web-programming::websocket", 12 | ] 13 | license = "MIT" 14 | repository = "https://github.com/picoHz/taxy" 15 | homepage = "https://taxy.dev/" 16 | 17 | [dependencies] 18 | base64 = "0.22.1" 19 | hex = "0.4.3" 20 | humantime-serde = "1.1.1" 21 | regex = "1.11.1" 22 | rustls-pki-types = "1.10.0" 23 | serde = { version = "1.0.171", features = ["rc"] } 24 | serde_default = "0.2.0" 25 | serde_derive = "1.0.171" 26 | serde_json = "1.0.102" 27 | thiserror = "2.0.0" 28 | time = "0.3.36" 29 | url = { version = "2.4.0", features = ["serde"] } 30 | utoipa = "5.2.0" 31 | -------------------------------------------------------------------------------- /taxy-webui/assets/icons/cloud-upload.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 7 | -------------------------------------------------------------------------------- /taxy-api/src/event.rs: -------------------------------------------------------------------------------- 1 | use crate::acme::AcmeInfo; 2 | use crate::app::AppConfig; 3 | use crate::cert::CertInfo; 4 | use crate::id::ShortId; 5 | use crate::port::PortStatus; 6 | use crate::proxy::ProxyStatus; 7 | use crate::{port::PortEntry, proxy::ProxyEntry}; 8 | use serde_derive::{Deserialize, Serialize}; 9 | use utoipa::ToSchema; 10 | 11 | #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] 12 | #[serde(rename_all = "snake_case", tag = "event")] 13 | #[non_exhaustive] 14 | pub enum ServerEvent { 15 | AppConfigUpdated { config: AppConfig }, 16 | PortTableUpdated { entries: Vec }, 17 | PortStatusUpdated { id: ShortId, status: PortStatus }, 18 | CertsUpdated { entries: Vec }, 19 | ProxiesUpdated { entries: Vec }, 20 | ProxyStatusUpdated { id: ShortId, status: ProxyStatus }, 21 | AcmeUpdated { entries: Vec }, 22 | Shutdown, 23 | } 24 | -------------------------------------------------------------------------------- /docs/templates/_variables.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/workflows/update-changelog.yaml: -------------------------------------------------------------------------------- 1 | name: "Update Changelog" 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | update: 9 | runs-on: ubuntu-latest 10 | 11 | permissions: 12 | contents: write 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v3 17 | with: 18 | ref: ${{ github.event.release.target_commitish }} 19 | 20 | - name: Update Changelog 21 | uses: stefanzweifel/changelog-updater-action@v1 22 | with: 23 | latest-version: ${{ github.event.release.tag_name }} 24 | release-notes: ${{ github.event.release.body }} 25 | 26 | - name: Commit updated CHANGELOG 27 | uses: stefanzweifel/git-auto-commit-action@v4 28 | with: 29 | branch: ${{ github.event.release.target_commitish }} 30 | commit_message: Update CHANGELOG 31 | file_pattern: CHANGELOG.md 32 | -------------------------------------------------------------------------------- /docs/sass/taxy.scss: -------------------------------------------------------------------------------- 1 | .logo { 2 | font-family: "Merriweather Sans", sans-serif; 3 | font-weight: 800; 4 | } 5 | 6 | .heading-text, 7 | .logo { 8 | font-family: "Merriweather Sans", sans-serif; 9 | } 10 | 11 | h1 { 12 | font-weight: 800; 13 | font-size: 1.7rem; 14 | margin-top: 2.5rem; 15 | } 16 | 17 | h2 { 18 | font-weight: 800; 19 | } 20 | code { 21 | border-radius: 0.3rem; 22 | } 23 | 24 | pre { 25 | border-radius: 0.5rem; 26 | } 27 | 28 | .content code { 29 | color: var(--code-background-color); 30 | background-color: #e2dede; 31 | } 32 | 33 | .content pre code { 34 | color: var(--code-color); 35 | background-color: var(--code-background-color); 36 | } 37 | 38 | .content code { 39 | font-size: 1.1rem; 40 | } 41 | 42 | .nav-item { 43 | margin: 0 0.6rem; 44 | font-size: 1.2rem; 45 | } 46 | 47 | @media screen and (max-width: 768px) { 48 | .nav-item { 49 | margin: 0 0.4rem; 50 | font-size: 1.0rem; 51 | } 52 | } -------------------------------------------------------------------------------- /taxy-webui/assets/icons/ribbon.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 7 | 8 | 10 | -------------------------------------------------------------------------------- /taxy-webui/src/auth.rs: -------------------------------------------------------------------------------- 1 | use crate::{pages::Route, API_ENDPOINT}; 2 | use gloo_net::http::Request; 3 | use serde_derive::{Deserialize, Serialize}; 4 | use yew::prelude::*; 5 | use yew_router::prelude::*; 6 | 7 | #[derive(Default, Clone, Serialize, Deserialize)] 8 | pub struct LoginQuery { 9 | #[serde(default)] 10 | pub redirect: Option, 11 | } 12 | 13 | #[hook] 14 | pub fn use_ensure_auth() { 15 | let navigator = use_navigator().unwrap(); 16 | 17 | let query = LoginQuery { 18 | redirect: use_route::().filter(|route| route != &Route::Login), 19 | }; 20 | 21 | wasm_bindgen_futures::spawn_local(async move { 22 | if !test_token().await { 23 | let _ = navigator.replace_with_query(&Route::Login, &query); 24 | } 25 | }); 26 | } 27 | 28 | pub async fn test_token() -> bool { 29 | if let Ok(res) = Request::get(&format!("{API_ENDPOINT}/app_info")) 30 | .send() 31 | .await 32 | { 33 | res.status() == 200 34 | } else { 35 | false 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /taxy-api/src/auth.rs: -------------------------------------------------------------------------------- 1 | use serde_derive::{Deserialize, Serialize}; 2 | use utoipa::ToSchema; 3 | 4 | #[derive(Clone, Serialize, Deserialize)] 5 | pub struct Account { 6 | pub password: String, 7 | 8 | #[serde(default, skip_serializing_if = "Option::is_none")] 9 | pub totp: Option, 10 | } 11 | 12 | #[derive(Deserialize, Serialize, ToSchema)] 13 | pub struct LoginRequest { 14 | #[schema(example = "admin")] 15 | pub username: String, 16 | #[schema(inline)] 17 | #[serde(flatten)] 18 | pub method: LoginMethod, 19 | #[serde(default)] 20 | pub insecure: bool, 21 | } 22 | 23 | #[derive(Deserialize, Serialize, ToSchema)] 24 | #[serde(tag = "method", rename_all = "snake_case")] 25 | pub enum LoginMethod { 26 | Password { 27 | #[schema(example = "passw0rd")] 28 | password: String, 29 | }, 30 | Totp { 31 | #[schema(example = "234567")] 32 | token: String, 33 | }, 34 | } 35 | 36 | #[derive(Debug, Deserialize, Serialize, ToSchema)] 37 | #[serde(rename_all = "snake_case")] 38 | pub enum LoginResponse { 39 | Success, 40 | TotpRequired, 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 picoHz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /taxy-webui/src/store.rs: -------------------------------------------------------------------------------- 1 | use serde_derive::{Deserialize, Serialize}; 2 | use std::collections::HashMap; 3 | use taxy_api::{ 4 | acme::AcmeInfo, 5 | cert::CertInfo, 6 | id::ShortId, 7 | port::{PortEntry, PortStatus}, 8 | proxy::{ProxyEntry, ProxyStatus}, 9 | }; 10 | use yewdux::prelude::*; 11 | 12 | #[derive(Default, Clone, PartialEq, Serialize, Deserialize, Store)] 13 | #[store(storage = "local")] 14 | pub struct SessionStore { 15 | pub token: Option, 16 | } 17 | 18 | #[derive(Default, Clone, PartialEq, Store)] 19 | pub struct PortStore { 20 | pub entries: Vec, 21 | pub statuses: HashMap, 22 | pub loaded: bool, 23 | } 24 | 25 | #[derive(Default, Clone, PartialEq, Store)] 26 | pub struct ProxyStore { 27 | pub entries: Vec, 28 | pub statuses: HashMap, 29 | pub loaded: bool, 30 | } 31 | 32 | #[derive(Default, Clone, PartialEq, Store)] 33 | pub struct CertStore { 34 | pub entries: Vec, 35 | pub loaded: bool, 36 | } 37 | 38 | #[derive(Default, Clone, PartialEq, Store)] 39 | pub struct AcmeStore { 40 | pub entries: Vec, 41 | pub loaded: bool, 42 | } 43 | -------------------------------------------------------------------------------- /taxy-webui/src/format.rs: -------------------------------------------------------------------------------- 1 | use fancy_duration::{DurationPart, FancyDuration}; 2 | use time::{format_description::well_known::Rfc3339, OffsetDateTime}; 3 | use wasm_bindgen::UnwrapThrowExt; 4 | use web_time::{Duration, SystemTime, UNIX_EPOCH}; 5 | 6 | pub fn format_duration(unix_time: i64) -> String { 7 | let time = OffsetDateTime::from_unix_timestamp(unix_time).unwrap_throw(); 8 | let timestamp = time.format(&Rfc3339).unwrap_throw(); 9 | let date = timestamp.split('T').next().unwrap_throw().to_string(); 10 | 11 | let time = UNIX_EPOCH 12 | .checked_add(Duration::from_secs(unix_time as u64)) 13 | .unwrap_throw(); 14 | 15 | let duration = if let Ok(duration) = time.duration_since(SystemTime::now()) { 16 | FancyDuration::new(duration) 17 | .filter(&[ 18 | DurationPart::Years, 19 | DurationPart::Months, 20 | DurationPart::Weeks, 21 | DurationPart::Days, 22 | DurationPart::Hours, 23 | ]) 24 | .truncate(1) 25 | .to_string() 26 | } else { 27 | "Expired".to_string() 28 | }; 29 | 30 | format!("{} ({})", date, duration) 31 | } 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official Rust image as the base image for the builder stage 2 | FROM rust:latest as builder 3 | 4 | # Install trunk 5 | RUN cargo install trunk 6 | RUN rustup target add wasm32-unknown-unknown 7 | 8 | # Set the working directory 9 | WORKDIR /usr/src/app 10 | 11 | # Copy the actual source code 12 | COPY Cargo.toml Cargo.lock ./ 13 | COPY taxy taxy 14 | COPY taxy-api taxy-api 15 | COPY taxy-webui taxy-webui 16 | 17 | # Build the web UI 18 | WORKDIR /usr/src/app/taxy-webui 19 | RUN trunk build --cargo-profile web-release --release 20 | WORKDIR /usr/src/app 21 | 22 | # Build the Rust project 23 | RUN cargo build --release 24 | 25 | # Prepare the final image 26 | FROM debian:bookworm-slim as runtime 27 | 28 | # Install dependencies for the Rust binary 29 | RUN apt-get update && \ 30 | apt-get install -y --no-install-recommends \ 31 | ca-certificates && \ 32 | rm -rf /var/lib/apt/lists/* 33 | 34 | # Set the working directory 35 | WORKDIR /app 36 | 37 | # Copy the Rust binary from the builder stage 38 | COPY --from=builder /usr/src/app/target/release/taxy /usr/bin 39 | 40 | # Set the entrypoint to run the Rust binary 41 | ENTRYPOINT ["taxy", "start", "--webui", "0.0.0.0:46492"] 42 | -------------------------------------------------------------------------------- /taxy/src/config/storage.rs: -------------------------------------------------------------------------------- 1 | use crate::certs::{acme::AcmeEntry, Cert}; 2 | use std::sync::Arc; 3 | use taxy_api::{ 4 | app::AppConfig, 5 | auth::{Account, LoginRequest, LoginResponse}, 6 | error::Error, 7 | id::ShortId, 8 | port::PortEntry, 9 | proxy::ProxyEntry, 10 | }; 11 | 12 | #[async_trait::async_trait] 13 | pub trait Storage: Send + Sync + 'static { 14 | async fn save_app_config(&self, config: &AppConfig); 15 | async fn load_app_config(&self) -> AppConfig; 16 | async fn save_ports(&self, entries: &[PortEntry]); 17 | async fn load_ports(&self) -> Vec; 18 | async fn load_proxies(&self) -> Vec; 19 | async fn save_proxies(&self, proxies: &[ProxyEntry]); 20 | async fn save_cert(&self, cert: &Cert); 21 | async fn save_acme(&self, acme: &AcmeEntry); 22 | async fn delete_acme(&self, id: ShortId); 23 | async fn delete_cert(&self, id: ShortId); 24 | async fn load_acmes(&self) -> Vec; 25 | async fn load_certs(&self) -> Vec>; 26 | async fn add_account(&self, name: &str, password: &str, totp: bool) -> Result; 27 | async fn verify_account(&self, request: LoginRequest) -> Result; 28 | } 29 | -------------------------------------------------------------------------------- /taxy-webui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Taxy Admin 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /taxy/src/command.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | certs::{acme::AcmeOrder, Cert}, 3 | server::rpc::ErasedRpcMethod, 4 | }; 5 | use std::sync::Arc; 6 | 7 | pub enum ServerCommand { 8 | AddCert { 9 | cert: Arc, 10 | }, 11 | SetBroadcastEvents { 12 | enabled: bool, 13 | }, 14 | SetHttpChallenges { 15 | orders: Vec, 16 | }, 17 | CallMethod { 18 | id: usize, 19 | arg: Box, 20 | }, 21 | } 22 | 23 | impl std::fmt::Debug for ServerCommand { 24 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 25 | match self { 26 | Self::AddCert { cert } => f.debug_struct("AddCert").field("id", &cert.id()).finish(), 27 | Self::SetBroadcastEvents { enabled } => f 28 | .debug_struct("SetBroadcastEvents") 29 | .field("enabled", enabled) 30 | .finish(), 31 | Self::SetHttpChallenges { orders } => f 32 | .debug_struct("SetHttpChallenges") 33 | .field("orders", &orders.len()) 34 | .finish(), 35 | Self::CallMethod { id, .. } => f.debug_struct("CallMethod").field("id", id).finish(), 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /taxy/src/admin/acme.rs: -------------------------------------------------------------------------------- 1 | use super::{AppError, AppState}; 2 | use crate::server::rpc::acme::{AddAcme, DeleteAcme, GetAcme, GetAcmeList, UpdateAcme}; 3 | use axum::{ 4 | extract::{Path, State}, 5 | Json, 6 | }; 7 | use taxy_api::{ 8 | acme::{AcmeConfig, AcmeInfo, AcmeRequest}, 9 | id::ShortId, 10 | }; 11 | 12 | pub async fn list(State(state): State) -> Result>>, AppError> { 13 | Ok(Json(state.call(GetAcmeList).await?)) 14 | } 15 | 16 | pub async fn get( 17 | State(state): State, 18 | Path(id): Path, 19 | ) -> Result>, AppError> { 20 | Ok(Json(state.call(GetAcme { id }).await?)) 21 | } 22 | 23 | pub async fn add( 24 | State(state): State, 25 | Json(request): Json, 26 | ) -> Result>, AppError> { 27 | Ok(Json(state.call(AddAcme { request }).await?)) 28 | } 29 | 30 | pub async fn put( 31 | State(state): State, 32 | Path(id): Path, 33 | Json(config): Json, 34 | ) -> Result>, AppError> { 35 | Ok(Json(state.call(UpdateAcme { id, config }).await?)) 36 | } 37 | 38 | pub async fn delete( 39 | State(state): State, 40 | Path(id): Path, 41 | ) -> Result>, AppError> { 42 | Ok(Json(state.call(DeleteAcme { id }).await?)) 43 | } 44 | -------------------------------------------------------------------------------- /docs/content/development.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "Development" 3 | description = "Development" 4 | weight = 0 5 | +++ 6 | 7 | Our project's source code is available on [GitHub](https://github.com/picoHz/taxy). 8 | 9 | # Prerequisites 10 | 11 | Before getting started, make sure to install the following prerequisites: 12 | 13 | - Rust toolchain: You can install it using [rustup.rs](https://rustup.rs/) 14 | - WASM toolchain: After installing the Rust toolchain, add the WASM target by executing `rustup target add wasm32-unknown-unknown` in your terminal 15 | - [Trunk](https://trunkrs.dev/): Visit the website for installation instructions 16 | 17 | # Development Setup 18 | 19 | ```bash 20 | # Clone the repository 21 | git clone https://github.com/picoHz/taxy 22 | 23 | # Start the server 24 | cd taxy 25 | cargo run 26 | 27 | # In a separate terminal, start `trunk serve` for the WebUI 28 | cd taxy-webui 29 | trunk serve 30 | ``` 31 | 32 | # Building for Release 33 | 34 | ```bash 35 | # Build the WebUI 36 | cd taxy/taxy-webui 37 | trunk build --cargo-profile web-release --release 38 | 39 | # Build the Server 40 | cd .. 41 | cargo build --release 42 | 43 | # Start the server 44 | target/release/taxy start 45 | ``` 46 | 47 | # Gitpod 48 | 49 | You can instantly start developing Taxy in your browser using Gitpod. 50 | 51 | [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/picoHz/taxy) 52 | -------------------------------------------------------------------------------- /Dockerfile.demo: -------------------------------------------------------------------------------- 1 | # Use the official Rust image as the base image for the builder stage 2 | FROM rust:latest as builder 3 | 4 | # Install cargo-binstall 5 | RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash 6 | 7 | # Install trunk 8 | RUN cargo binstall trunk -y 9 | RUN rustup target add wasm32-unknown-unknown 10 | 11 | # Set the working directory 12 | WORKDIR /usr/src/app 13 | 14 | # Copy the actual source code 15 | COPY Cargo.toml Cargo.lock ./ 16 | COPY taxy taxy 17 | COPY taxy-api taxy-api 18 | COPY taxy-webui taxy-webui 19 | 20 | # Build the web UI 21 | WORKDIR /usr/src/app/taxy-webui 22 | RUN trunk build --cargo-profile web-release --release 23 | WORKDIR /usr/src/app 24 | 25 | # Build the Rust project 26 | RUN cargo build --all-features --release 27 | 28 | # Prepare the final image 29 | FROM debian:bookworm-slim as runtime 30 | 31 | # Install dependencies for the Rust binary 32 | RUN apt-get update && \ 33 | apt-get install -y --no-install-recommends \ 34 | ca-certificates && \ 35 | rm -rf /var/lib/apt/lists/* 36 | 37 | # Set the working directory 38 | WORKDIR /app 39 | 40 | # Copy the Rust binary from the builder stage 41 | COPY --from=builder /usr/src/app/target/release/taxy /usr/bin 42 | 43 | # Add admin user 44 | RUN taxy add-user admin -p admin 45 | 46 | # Set the entrypoint to run the Rust binary 47 | ENTRYPOINT ["taxy", "start", "--webui", "0.0.0.0:8080"] 48 | -------------------------------------------------------------------------------- /taxy/src/server/rpc/mod.rs: -------------------------------------------------------------------------------- 1 | use super::state::ServerState; 2 | use std::any::Any; 3 | use taxy_api::error::Error; 4 | 5 | pub mod acme; 6 | pub mod auth; 7 | pub mod certs; 8 | pub mod config; 9 | pub mod ports; 10 | pub mod proxies; 11 | 12 | #[async_trait::async_trait] 13 | pub trait RpcMethod: Any + Send + Sync { 14 | type Output: Any + Send + Sync; 15 | async fn call(self, state: &mut ServerState) -> Result; 16 | } 17 | 18 | pub struct RpcWrapper { 19 | inner: Option, 20 | } 21 | 22 | impl RpcWrapper 23 | where 24 | T: RpcMethod, 25 | { 26 | pub fn new(inner: T) -> Self { 27 | Self { inner: Some(inner) } 28 | } 29 | } 30 | 31 | #[async_trait::async_trait] 32 | impl ErasedRpcMethod for RpcWrapper 33 | where 34 | T: RpcMethod, 35 | { 36 | async fn call(&mut self, state: &mut ServerState) -> Result, Error> { 37 | let this = self.inner.take().ok_or(Error::FailedToInvokeRpc)?; 38 | ::call(this, state) 39 | .await 40 | .map(|r| Box::new(r) as Box) 41 | } 42 | } 43 | 44 | #[async_trait::async_trait] 45 | pub trait ErasedRpcMethod: Any + Send + Sync { 46 | async fn call(&mut self, state: &mut ServerState) -> Result, Error>; 47 | } 48 | 49 | pub struct RpcCallback { 50 | pub id: usize, 51 | pub result: Result, Error>, 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | test: 14 | runs-on: ${{ matrix.os }} 15 | 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest, windows-latest, macos-latest] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - uses: actions/cache@v3 23 | with: 24 | path: | 25 | ~/.rustup/toolchains/stable-* 26 | ~/.cargo/registry 27 | ~/.cargo/git 28 | target 29 | key: ${{ runner.os }}-stable-cargo-${{ hashFiles('**/Cargo.lock') }} 30 | - name: Install cargo-nextest 31 | uses: taiki-e/install-action@nextest 32 | - name: Build 33 | run: cargo build --all-features 34 | - name: Run tests 35 | run: cargo nextest run --all-features 36 | 37 | test_freebsd: 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v4 41 | - name: Test in FreeBSD 42 | id: test 43 | uses: vmactions/freebsd-vm@v1 44 | with: 45 | envs: 'CARGO_TERM_COLOR' 46 | usesh: true 47 | prepare: | 48 | pkg install -y curl gcc 49 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 50 | run: | 51 | . "$HOME/.cargo/env" 52 | cargo build --all-features 53 | cargo test --all-features 54 | cargo clean 55 | -------------------------------------------------------------------------------- /taxy/src/proxy/http/error.rs: -------------------------------------------------------------------------------- 1 | use hyper::StatusCode; 2 | use sailfish::TemplateOnce; 3 | use thiserror::Error; 4 | use tokio_rustls::rustls; 5 | 6 | #[derive(Debug, Clone, Error)] 7 | pub enum ProxyError { 8 | #[error("domain fronting detected")] 9 | DomainFrontingDetected, 10 | 11 | #[error("no route found")] 12 | NoRouteFound, 13 | } 14 | 15 | impl ProxyError { 16 | fn code(&self) -> StatusCode { 17 | match self { 18 | Self::DomainFrontingDetected => StatusCode::MISDIRECTED_REQUEST, 19 | Self::NoRouteFound => StatusCode::BAD_GATEWAY, 20 | } 21 | } 22 | } 23 | 24 | pub fn map_error(err: anyhow::Error) -> StatusCode { 25 | if let Some(err) = err.downcast_ref::() { 26 | return err.code(); 27 | } 28 | if let Some(err) = err.downcast_ref::() { 29 | if matches!(err, rustls::Error::InvalidCertificate(_)) { 30 | return StatusCode::from_u16(526).unwrap(); 31 | } else { 32 | return StatusCode::from_u16(525).unwrap(); 33 | } 34 | } 35 | if let Ok(err) = err.downcast::() { 36 | if err.is_timeout() { 37 | return StatusCode::GATEWAY_TIMEOUT; 38 | } else { 39 | return StatusCode::from_u16(523).unwrap(); 40 | } 41 | } 42 | StatusCode::BAD_GATEWAY 43 | } 44 | 45 | #[derive(TemplateOnce)] 46 | #[template(path = "error.stpl")] 47 | pub struct ErrorTemplate { 48 | #[allow(unused_variables)] 49 | pub code: u16, 50 | } 51 | -------------------------------------------------------------------------------- /taxy/src/proxy/http/filter.rs: -------------------------------------------------------------------------------- 1 | use hyper::Request; 2 | use taxy_api::proxy::Route; 3 | use taxy_api::vhost::VirtualHost; 4 | 5 | #[derive(Debug, Default)] 6 | pub struct RequestFilter { 7 | pub vhosts: Vec, 8 | pub path: Vec, 9 | } 10 | 11 | impl RequestFilter { 12 | pub fn new(vhosts: &[VirtualHost], route: &Route) -> Self { 13 | Self { 14 | vhosts: vhosts.to_vec(), 15 | path: route 16 | .path 17 | .split('/') 18 | .filter(|seg| !seg.is_empty()) 19 | .map(|s| s.to_owned()) 20 | .collect(), 21 | } 22 | } 23 | 24 | pub fn test(&self, req: &Request, host: Option<&str>) -> Option { 25 | let host_matched = match host { 26 | Some(host) => self.vhosts.iter().any(|vhost| vhost.test(host)), 27 | None => false, 28 | }; 29 | if !host_matched && !self.vhosts.is_empty() { 30 | return None; 31 | } 32 | let path = req.uri().path().trim_start_matches('/').split('/'); 33 | let count = path 34 | .clone() 35 | .zip(self.path.iter()) 36 | .take_while(|(a, b)| a == b) 37 | .count(); 38 | if count == self.path.len() { 39 | Some(FilterResult { 40 | path_segments: path.skip(count).map(|s| s.to_string()).collect(), 41 | }) 42 | } else { 43 | None 44 | } 45 | } 46 | } 47 | 48 | #[derive(Debug)] 49 | pub struct FilterResult { 50 | pub path_segments: Vec, 51 | } 52 | -------------------------------------------------------------------------------- /taxy/src/admin/proxies.rs: -------------------------------------------------------------------------------- 1 | use super::{AppError, AppState}; 2 | use crate::server::rpc::proxies::{ 3 | AddProxy, DeleteProxy, GetProxy, GetProxyList, GetProxyStatus, UpdateProxy, 4 | }; 5 | use axum::{ 6 | extract::{Path, State}, 7 | Json, 8 | }; 9 | use taxy_api::{ 10 | id::ShortId, 11 | proxy::{Proxy, ProxyEntry, ProxyStatus}, 12 | }; 13 | 14 | pub async fn list(State(state): State) -> Result>>, AppError> { 15 | Ok(Json(state.call(GetProxyList).await?)) 16 | } 17 | 18 | pub async fn get( 19 | State(state): State, 20 | Path(id): Path, 21 | ) -> Result>, AppError> { 22 | Ok(Json(state.call(GetProxy { id }).await?)) 23 | } 24 | 25 | pub async fn status( 26 | State(state): State, 27 | Path(id): Path, 28 | ) -> Result>, AppError> { 29 | Ok(Json(state.call(GetProxyStatus { id }).await?)) 30 | } 31 | 32 | pub async fn delete( 33 | State(state): State, 34 | Path(id): Path, 35 | ) -> Result>, AppError> { 36 | Ok(Json(state.call(DeleteProxy { id }).await?)) 37 | } 38 | 39 | pub async fn add( 40 | State(state): State, 41 | Json(entry): Json, 42 | ) -> Result>, AppError> { 43 | Ok(Json(state.call(AddProxy { entry }).await?)) 44 | } 45 | 46 | pub async fn put( 47 | State(state): State, 48 | Path(id): Path, 49 | Json(entry): Json, 50 | ) -> Result>, AppError> { 51 | let entry = (id, entry).into(); 52 | Ok(Json(state.call(UpdateProxy { entry }).await?)) 53 | } 54 | -------------------------------------------------------------------------------- /docs/static/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /taxy-webui/assets/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /taxy/src/server/acme_list.rs: -------------------------------------------------------------------------------- 1 | use crate::certs::acme::AcmeEntry; 2 | use indexmap::IndexMap; 3 | use taxy_api::{acme::AcmeConfig, error::Error, id::ShortId}; 4 | 5 | #[derive(Debug, Default)] 6 | pub struct AcmeList { 7 | entries: IndexMap, 8 | } 9 | 10 | impl FromIterator for AcmeList { 11 | fn from_iter>(iter: I) -> Self { 12 | Self { 13 | entries: iter.into_iter().map(|acme| (acme.id, acme)).collect(), 14 | } 15 | } 16 | } 17 | 18 | impl AcmeList { 19 | pub fn get(&self, id: ShortId) -> Option<&AcmeEntry> { 20 | self.entries.get(&id) 21 | } 22 | 23 | pub fn entries(&self) -> impl Iterator { 24 | self.entries.values() 25 | } 26 | 27 | pub fn add(&mut self, entry: AcmeEntry) -> Result<(), Error> { 28 | if self.entries.contains_key(&entry.id) { 29 | Err(Error::IdAlreadyExists { id: entry.id }) 30 | } else { 31 | self.entries.insert(entry.id, entry); 32 | Ok(()) 33 | } 34 | } 35 | 36 | pub fn update(&mut self, id: ShortId, config: AcmeConfig) -> Result { 37 | if let Some(entry) = self.entries.get_mut(&id) { 38 | entry.acme.config = config; 39 | Ok(entry.clone()) 40 | } else { 41 | Err(Error::IdNotFound { id: id.to_string() }) 42 | } 43 | } 44 | 45 | pub fn delete(&mut self, id: ShortId) -> Result<(), Error> { 46 | if !self.entries.contains_key(&id) { 47 | Err(Error::IdNotFound { id: id.to_string() }) 48 | } else { 49 | self.entries.swap_remove(&id); 50 | Ok(()) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /taxy/tests/udp_test.rs: -------------------------------------------------------------------------------- 1 | use taxy_api::{ 2 | port::{Port, PortEntry, UpstreamServer}, 3 | proxy::{Proxy, ProxyEntry, ProxyKind, UdpProxy}, 4 | }; 5 | use tokio::net::UdpSocket; 6 | mod common; 7 | use common::{alloc_udp_port, with_server, TestStorage}; 8 | 9 | #[tokio::test] 10 | async fn udp_proxy() -> anyhow::Result<()> { 11 | let listen_port = alloc_udp_port().await?; 12 | let proxy_port = alloc_udp_port().await?; 13 | 14 | let listener = UdpSocket::bind(listen_port.socket_addr()).await?; 15 | let config = TestStorage::builder() 16 | .ports(vec![PortEntry { 17 | id: "test".parse().unwrap(), 18 | port: Port { 19 | active: true, 20 | name: String::new(), 21 | listen: proxy_port.multiaddr_udp(), 22 | opts: Default::default(), 23 | }, 24 | }]) 25 | .proxies(vec![ProxyEntry { 26 | id: "test2".parse().unwrap(), 27 | proxy: Proxy { 28 | ports: vec!["test".parse().unwrap()], 29 | kind: ProxyKind::Udp(UdpProxy { 30 | upstream_servers: vec![UpstreamServer { 31 | addr: listen_port.multiaddr_udp(), 32 | }], 33 | }), 34 | ..Default::default() 35 | }, 36 | }]) 37 | .build(); 38 | 39 | with_server(config, |_| async move { 40 | let data = b"Hello"; 41 | listener 42 | .send_to(&data[..], proxy_port.socket_addr()) 43 | .await?; 44 | let mut buf = [0; 1024]; 45 | let (size, addr) = listener.recv_from(&mut buf).await?; 46 | assert_eq!(&buf[..size], data); 47 | assert_eq!(addr, proxy_port.socket_addr()); 48 | Ok(()) 49 | }) 50 | .await 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/zola.yml: -------------------------------------------------------------------------------- 1 | # Sample workflow for building and deploying a Hugo site to GitHub Pages 2 | name: Deploy Zola site to Pages 3 | 4 | on: 5 | push: 6 | tags: 7 | - "v*" 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | # Default to bash 25 | defaults: 26 | run: 27 | shell: bash 28 | 29 | jobs: 30 | # Build job 31 | build: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Install Zola 35 | uses: taiki-e/install-action@v2 36 | with: 37 | tool: zola@0.17.1 38 | - name: Checkout 39 | uses: actions/checkout@v3 40 | with: 41 | submodules: recursive 42 | - name: Setup Pages 43 | id: pages 44 | uses: actions/configure-pages@v3 45 | - name: Build with Zola 46 | run: zola -r docs build -u "${{ steps.pages.outputs.base_url }}/" 47 | - name: Upload artifact 48 | uses: actions/upload-pages-artifact@v3 49 | with: 50 | path: ./docs/public 51 | 52 | # Deployment job 53 | deploy: 54 | environment: 55 | name: github-pages 56 | url: ${{ steps.deployment.outputs.page_url }} 57 | runs-on: ubuntu-latest 58 | needs: build 59 | steps: 60 | - name: Deploy to GitHub Pages 61 | id: deployment 62 | uses: actions/deploy-pages@v4 63 | -------------------------------------------------------------------------------- /taxy/src/server/port_list.rs: -------------------------------------------------------------------------------- 1 | use crate::proxy::PortContext; 2 | use taxy_api::{id::ShortId, port::PortEntry}; 3 | 4 | #[derive(Default)] 5 | pub struct PortList { 6 | contexts: Vec, 7 | } 8 | 9 | impl PortList { 10 | pub fn entries(&self) -> impl Iterator { 11 | self.contexts.iter().map(|c| c.entry()) 12 | } 13 | 14 | pub fn as_slice(&self) -> &[PortContext] { 15 | &self.contexts 16 | } 17 | 18 | pub fn as_mut_slice(&mut self) -> &mut [PortContext] { 19 | &mut self.contexts 20 | } 21 | 22 | pub fn get(&self, id: ShortId) -> Option<&PortContext> { 23 | self.contexts.iter().find(|p| p.entry().id == id) 24 | } 25 | 26 | pub fn update(&mut self, ctx: PortContext) -> bool { 27 | if let Some(index) = self 28 | .contexts 29 | .iter() 30 | .position(|p| p.entry().id == ctx.entry().id) 31 | { 32 | if self.contexts[index].entry != ctx.entry { 33 | self.contexts[index].apply(ctx); 34 | true 35 | } else { 36 | false 37 | } 38 | } else { 39 | self.contexts.push(ctx); 40 | true 41 | } 42 | } 43 | 44 | pub fn delete(&mut self, id: ShortId) -> bool { 45 | if let Some(index) = self.contexts.iter().position(|p| p.entry().id == id) { 46 | self.contexts.remove(index).reset(); 47 | true 48 | } else { 49 | false 50 | } 51 | } 52 | 53 | pub fn reset(&mut self, id: ShortId) -> bool { 54 | if let Some(index) = self.contexts.iter().position(|p| p.entry().id == id) { 55 | self.contexts[index].reset(); 56 | true 57 | } else { 58 | false 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /taxy/tests/tcp_test.rs: -------------------------------------------------------------------------------- 1 | use axum::{routing::get, Router}; 2 | use taxy_api::{ 3 | port::{Port, PortEntry, UpstreamServer}, 4 | proxy::{Proxy, ProxyEntry, ProxyKind, TcpProxy}, 5 | }; 6 | mod common; 7 | use common::{alloc_tcp_port, with_server, TestStorage}; 8 | 9 | #[tokio::test] 10 | async fn tcp_proxy() -> anyhow::Result<()> { 11 | let listen_port = alloc_tcp_port().await?; 12 | let proxy_port = alloc_tcp_port().await?; 13 | 14 | async fn handler() -> &'static str { 15 | "Hello" 16 | } 17 | let app = Router::new().route("/hello", get(handler)); 18 | 19 | let addr = listen_port.socket_addr(); 20 | tokio::spawn(axum_server::bind(addr).serve(app.into_make_service())); 21 | 22 | let config = TestStorage::builder() 23 | .ports(vec![PortEntry { 24 | id: "test".parse().unwrap(), 25 | port: Port { 26 | active: true, 27 | name: String::new(), 28 | listen: proxy_port.multiaddr_tcp(), 29 | opts: Default::default(), 30 | }, 31 | }]) 32 | .proxies(vec![ProxyEntry { 33 | id: "test2".parse().unwrap(), 34 | proxy: Proxy { 35 | ports: vec!["test".parse().unwrap()], 36 | kind: ProxyKind::Tcp(TcpProxy { 37 | upstream_servers: vec![UpstreamServer { 38 | addr: listen_port.multiaddr_tcp(), 39 | }], 40 | }), 41 | ..Default::default() 42 | }, 43 | }]) 44 | .build(); 45 | 46 | with_server(config, |_| async move { 47 | let resp = reqwest::get(proxy_port.http_url("/hello")) 48 | .await? 49 | .text() 50 | .await?; 51 | assert_eq!(resp, "Hello"); 52 | Ok(()) 53 | }) 54 | .await 55 | } 56 | -------------------------------------------------------------------------------- /taxy/src/admin/ports.rs: -------------------------------------------------------------------------------- 1 | use super::{AppError, AppState}; 2 | use crate::server::rpc::ports::{ 3 | AddPort, DeletePort, GetNetworkInterfaceList, GetPort, GetPortList, GetPortStatus, ResetPort, 4 | UpdatePort, 5 | }; 6 | use axum::{ 7 | extract::{Path, State}, 8 | Json, 9 | }; 10 | use taxy_api::{ 11 | id::ShortId, 12 | port::{NetworkInterface, Port, PortEntry, PortStatus}, 13 | }; 14 | 15 | pub async fn list(State(state): State) -> Result>>, AppError> { 16 | Ok(Json(state.call(GetPortList).await?)) 17 | } 18 | 19 | pub async fn get( 20 | State(state): State, 21 | Path(id): Path, 22 | ) -> Result>, AppError> { 23 | Ok(Json(state.call(GetPort { id }).await?)) 24 | } 25 | 26 | pub async fn status( 27 | State(state): State, 28 | Path(id): Path, 29 | ) -> Result>, AppError> { 30 | Ok(Json(state.call(GetPortStatus { id }).await?)) 31 | } 32 | 33 | pub async fn delete( 34 | State(state): State, 35 | Path(id): Path, 36 | ) -> Result>, AppError> { 37 | Ok(Json(state.call(DeletePort { id }).await?)) 38 | } 39 | 40 | pub async fn add( 41 | State(state): State, 42 | Json(entry): Json, 43 | ) -> Result>, AppError> { 44 | Ok(Json(state.call(AddPort { entry }).await?)) 45 | } 46 | 47 | pub async fn put( 48 | State(state): State, 49 | Path(id): Path, 50 | Json(entry): Json, 51 | ) -> Result>, AppError> { 52 | let entry = (id, entry).into(); 53 | Ok(Json(state.call(UpdatePort { entry }).await?)) 54 | } 55 | 56 | pub async fn reset( 57 | State(state): State, 58 | Path(id): Path, 59 | ) -> Result>, AppError> { 60 | Ok(Json(state.call(ResetPort { id }).await?)) 61 | } 62 | 63 | pub async fn interfaces( 64 | State(state): State, 65 | ) -> Result>>, AppError> { 66 | Ok(Json(state.call(GetNetworkInterfaceList).await?)) 67 | } 68 | -------------------------------------------------------------------------------- /taxy/src/proxy/http/route.rs: -------------------------------------------------------------------------------- 1 | use super::filter::{FilterResult, RequestFilter}; 2 | use hyper::Request; 3 | use taxy_api::{ 4 | id::ShortId, 5 | proxy::{ProxyEntry, ProxyKind, Server}, 6 | }; 7 | 8 | #[derive(Default, Debug)] 9 | pub struct Router { 10 | routes: Vec, 11 | } 12 | 13 | impl Router { 14 | pub fn new(proxies: Vec, https_port: Option, quic_port: Option) -> Self { 15 | let mut routes = vec![]; 16 | for (id, http) in proxies 17 | .into_iter() 18 | .filter_map(|entry| match entry.proxy.kind { 19 | ProxyKind::Http(http) => Some((entry.id, http)), 20 | _ => None, 21 | }) 22 | { 23 | for route in http.routes { 24 | let filter = RequestFilter::new(&http.vhosts, &route); 25 | routes.push(FilteredRoute { 26 | resource_id: id, 27 | filter, 28 | route: ParsedRoute { 29 | servers: route.servers, 30 | }, 31 | https_port, 32 | quic_port, 33 | upgrade_insecure: http.upgrade_insecure, 34 | }); 35 | } 36 | } 37 | Self { routes } 38 | } 39 | 40 | pub fn get_route( 41 | &self, 42 | req: &Request, 43 | host: Option<&str>, 44 | ) -> Option<(&ParsedRoute, FilterResult, &FilteredRoute)> { 45 | self.routes.iter().find_map(|route| { 46 | route 47 | .filter 48 | .test(req, host) 49 | .map(|res| (&route.route, res, route)) 50 | }) 51 | } 52 | } 53 | 54 | #[derive(Debug)] 55 | pub struct FilteredRoute { 56 | pub resource_id: ShortId, 57 | pub filter: RequestFilter, 58 | pub route: ParsedRoute, 59 | pub https_port: Option, 60 | pub quic_port: Option, 61 | pub upgrade_insecure: bool, 62 | } 63 | 64 | #[derive(Debug)] 65 | pub struct ParsedRoute { 66 | pub servers: Vec, 67 | } 68 | -------------------------------------------------------------------------------- /taxy/src/args.rs: -------------------------------------------------------------------------------- 1 | use crate::log::LogFormat; 2 | use clap::{Args, Parser, Subcommand}; 3 | use std::{net::SocketAddr, path::PathBuf}; 4 | use tracing_subscriber::filter::LevelFilter; 5 | 6 | #[derive(Parser)] 7 | pub struct Cli { 8 | #[command(subcommand)] 9 | pub command: Command, 10 | } 11 | 12 | #[derive(Subcommand)] 13 | pub enum Command { 14 | /// Start server 15 | Start(StartArgs), 16 | /// Add user 17 | AddUser(AddUserArgs), 18 | } 19 | 20 | #[derive(Args)] 21 | pub struct StartArgs { 22 | #[clap(long, value_name = "FILE", env = "TAXY_LOG")] 23 | pub log: Option, 24 | 25 | #[clap(long, value_name = "FILE", env = "TAXY_ACCESS_LOG")] 26 | pub access_log: Option, 27 | 28 | #[clap( 29 | long, 30 | short, 31 | value_name = "LEVEL", 32 | default_value = "info", 33 | env = "TAXY_LOG_LEVEL" 34 | )] 35 | pub log_level: LevelFilter, 36 | 37 | #[clap( 38 | long, 39 | short, 40 | value_name = "LEVEL", 41 | default_value = "info", 42 | env = "TAXY_ACCESS_LOG_LEVEL" 43 | )] 44 | pub access_log_level: LevelFilter, 45 | 46 | #[clap( 47 | long, 48 | value_enum, 49 | value_name = "FORMAT", 50 | default_value = "text", 51 | env = "TAXY_LOG_FORMAT" 52 | )] 53 | pub log_format: LogFormat, 54 | 55 | #[clap( 56 | long, 57 | short, 58 | value_name = "ADDR", 59 | default_value = "127.0.0.1:46492", 60 | env = "TAXY_WEBUI" 61 | )] 62 | pub webui: SocketAddr, 63 | 64 | #[clap(long, short, env = "TAXY_NO_WEBUI", conflicts_with = "webui")] 65 | pub no_webui: bool, 66 | 67 | #[clap(long, short, value_name = "DIR", env = "TAXY_CONFIG_DIR")] 68 | pub config_dir: Option, 69 | 70 | #[clap(long, short = 'd', value_name = "DIR", env = "TAXY_LOG_DIR")] 71 | pub log_dir: Option, 72 | } 73 | 74 | #[derive(Args)] 75 | pub struct AddUserArgs { 76 | pub name: String, 77 | 78 | #[clap(long, short, value_name = "PASSWORD")] 79 | pub password: Option, 80 | 81 | #[clap(long, short, value_name = "DIR", env = "TAXY_CONFIG_DIR")] 82 | pub config_dir: Option, 83 | 84 | #[clap(long)] 85 | pub totp: bool, 86 | } 87 | -------------------------------------------------------------------------------- /taxy/templates/error.stpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Gateway Error 5 | 59 | 60 | 61 |
62 |
<%= self.code %>
63 | <% if self.code == 523 { %> 64 |
Origin Is Unreachable
65 | <% } else if self.code == 525 { %> 66 |
SSL Handshake Failed
67 | <% } else if self.code == 526 { %> 68 |
Invalid SSL Certificate
69 | <% } else if self.code == 421 { %> 70 |
Misdirected Request
71 | <% } else { %> 72 |
Bad Gateway
73 | <% } %> 74 |
75 | Powered by Taxy 76 |
77 |
78 | 79 | 80 | -------------------------------------------------------------------------------- /taxy/src/admin/static_file.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | body::Bytes, 3 | http::{HeaderMap, StatusCode, Uri}, 4 | response::IntoResponse, 5 | }; 6 | use fnv::FnvHasher; 7 | use include_dir::{include_dir, Dir}; 8 | use std::{hash::Hasher, path::Path}; 9 | 10 | use super::AppError; 11 | 12 | static STATIC_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist"); 13 | 14 | const IMMUTABLE_FILE_PREFIXES: &[&str] = &["taxy-webui-", "tailwind-"]; 15 | 16 | pub async fn fallback(uri: Uri, req_headers: HeaderMap) -> Result { 17 | let mut headers = HeaderMap::new(); 18 | 19 | let path = uri.path(); 20 | if path.starts_with("/api/") { 21 | return Err(AppError::NotFound); 22 | } 23 | let path_has_extension = path 24 | .rfind('.') 25 | .map(|i| i > path.rfind('/').unwrap_or(0)) 26 | .unwrap_or_default(); 27 | let path = if path == "/" || !path_has_extension { 28 | "index.html" 29 | } else { 30 | path.trim_start_matches('/') 31 | }; 32 | 33 | let path = Path::new("webui").join(format!("{path}.gz")); 34 | 35 | let immutable = IMMUTABLE_FILE_PREFIXES 36 | .iter() 37 | .any(|prefix| path.starts_with(prefix)); 38 | 39 | let cache_control = if immutable { 40 | "public, max-age=31536000, immutable" 41 | } else { 42 | "public, max-age=604800, must-revalidate" 43 | }; 44 | 45 | headers.insert("Content-Encoding", "gzip".parse().unwrap()); 46 | headers.insert("Cache-Control", cache_control.parse().unwrap()); 47 | 48 | if let Some(file) = STATIC_DIR.get_file(path) { 49 | let mut hasher = FnvHasher::default(); 50 | hasher.write(file.contents()); 51 | let etag = format!("{:x}", hasher.finish()); 52 | headers.insert("ETag", etag.parse().unwrap()); 53 | 54 | if req_headers 55 | .get("If-None-Match") 56 | .map(|x| x.to_str().unwrap_or_default()) 57 | == Some(&etag) 58 | { 59 | return Ok((StatusCode::NOT_MODIFIED, headers, Bytes::new())); 60 | } 61 | 62 | let ext = file 63 | .path() 64 | .file_stem() 65 | .and_then(|x| x.to_str()) 66 | .unwrap_or_default(); 67 | let mime = mime_guess::from_path(ext).first_or_octet_stream(); 68 | headers.insert("Content-Type", mime.to_string().parse().unwrap()); 69 | 70 | return Ok((StatusCode::OK, headers, Bytes::from_static(file.contents()))); 71 | } 72 | 73 | Err(AppError::NotFound) 74 | } 75 | -------------------------------------------------------------------------------- /taxy/src/server/rpc/acme.rs: -------------------------------------------------------------------------------- 1 | use super::RpcMethod; 2 | use crate::{certs::acme::AcmeEntry, server::state::ServerState}; 3 | use taxy_api::{ 4 | acme::{AcmeConfig, AcmeInfo, AcmeRequest}, 5 | error::Error, 6 | id::ShortId, 7 | }; 8 | 9 | pub struct GetAcmeList; 10 | 11 | #[async_trait::async_trait] 12 | impl RpcMethod for GetAcmeList { 13 | type Output = Vec; 14 | 15 | async fn call(self, state: &mut ServerState) -> Result { 16 | Ok(state 17 | .acmes 18 | .entries() 19 | .map(|acme| acme.info(&state.certs)) 20 | .collect()) 21 | } 22 | } 23 | 24 | pub struct GetAcme { 25 | pub id: ShortId, 26 | } 27 | 28 | #[async_trait::async_trait] 29 | impl RpcMethod for GetAcme { 30 | type Output = AcmeInfo; 31 | 32 | async fn call(self, state: &mut ServerState) -> Result { 33 | state 34 | .acmes 35 | .get(self.id) 36 | .map(|acme| acme.info(&state.certs)) 37 | .ok_or(Error::IdNotFound { 38 | id: self.id.to_string(), 39 | }) 40 | } 41 | } 42 | 43 | pub struct AddAcme { 44 | pub request: AcmeRequest, 45 | } 46 | 47 | #[async_trait::async_trait] 48 | impl RpcMethod for AddAcme { 49 | type Output = (); 50 | 51 | async fn call(self, state: &mut ServerState) -> Result { 52 | let entry = AcmeEntry::new(state.generate_id(), self.request).await?; 53 | state.acmes.add(entry.clone())?; 54 | state.storage.save_acme(&entry).await; 55 | state.update_acmes().await; 56 | Ok(()) 57 | } 58 | } 59 | 60 | pub struct UpdateAcme { 61 | pub id: ShortId, 62 | pub config: AcmeConfig, 63 | } 64 | 65 | #[async_trait::async_trait] 66 | impl RpcMethod for UpdateAcme { 67 | type Output = (); 68 | 69 | async fn call(self, state: &mut ServerState) -> Result { 70 | let entry = state.acmes.update(self.id, self.config)?; 71 | state.storage.save_acme(&entry).await; 72 | state.update_acmes().await; 73 | Ok(()) 74 | } 75 | } 76 | 77 | pub struct DeleteAcme { 78 | pub id: ShortId, 79 | } 80 | 81 | #[async_trait::async_trait] 82 | impl RpcMethod for DeleteAcme { 83 | type Output = (); 84 | 85 | async fn call(self, state: &mut ServerState) -> Result { 86 | state.acmes.delete(self.id)?; 87 | state.update_acmes().await; 88 | state.storage.delete_acme(self.id).await; 89 | Ok(()) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /taxy-api/src/id.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use serde::{Deserialize, Serialize}; 3 | use std::{fmt, str::FromStr}; 4 | use utoipa::ToSchema; 5 | 6 | #[derive(Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, ToSchema)] 7 | pub struct ShortId([u8; 8]); 8 | 9 | impl ShortId { 10 | pub fn new() -> Self { 11 | Default::default() 12 | } 13 | } 14 | 15 | impl From<[u8; 7]> for ShortId { 16 | fn from(id: [u8; 7]) -> Self { 17 | let mut bytes = [0; 8]; 18 | bytes[1..].copy_from_slice(&id); 19 | Self(bytes) 20 | } 21 | } 22 | 23 | impl fmt::Debug for ShortId { 24 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 25 | write!(f, "ShortId({})", self) 26 | } 27 | } 28 | 29 | impl fmt::Display for ShortId { 30 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 31 | if self.0[0] == 0 { 32 | write!(f, "{}", hex::encode(&self.0[1..])) 33 | } else { 34 | let len = self.0.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1); 35 | write!(f, "{}", String::from_utf8_lossy(&self.0[..len])) 36 | } 37 | } 38 | } 39 | 40 | impl FromStr for ShortId { 41 | type Err = Error; 42 | 43 | fn from_str(s: &str) -> Result { 44 | let mut id = [0; 8]; 45 | if hex::decode_to_slice(s, &mut id[1..]).is_ok() { 46 | return Ok(ShortId(id)); 47 | } 48 | let str = s.to_ascii_lowercase(); 49 | if !str.is_ascii() || str.len() > 8 { 50 | return Err(Error::InvalidShortId { id: s.to_string() }); 51 | } 52 | let bytes = str.as_bytes(); 53 | let len = bytes.len().min(id.len()); 54 | id[..len].copy_from_slice(&bytes[..len]); 55 | Ok(ShortId(id)) 56 | } 57 | } 58 | 59 | impl Serialize for ShortId { 60 | fn serialize(&self, serializer: S) -> Result { 61 | serializer.serialize_str(&self.to_string()) 62 | } 63 | } 64 | 65 | impl<'de> Deserialize<'de> for ShortId { 66 | fn deserialize>(deserializer: D) -> Result { 67 | let s = String::deserialize(deserializer)?; 68 | ShortId::from_str(&s).map_err(serde::de::Error::custom) 69 | } 70 | } 71 | 72 | #[cfg(test)] 73 | mod test { 74 | use super::*; 75 | 76 | #[test] 77 | fn parse_hex() { 78 | let id = ShortId::from_str("f9cf7e3faa1aca").unwrap(); 79 | assert_eq!(id.to_string(), "f9cf7e3faa1aca"); 80 | } 81 | 82 | #[test] 83 | fn parse_hyphen_id() { 84 | let id = ShortId::from_str("djs-vjd").unwrap(); 85 | assert_eq!(id.to_string(), "djs-vjd"); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Create and publish a Docker image 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | # Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. 9 | env: 10 | REGISTRY: ghcr.io 11 | IMAGE_NAME: ${{ github.repository }} 12 | 13 | # There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. 14 | jobs: 15 | build-and-push-image: 16 | runs-on: ubuntu-latest 17 | # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. 18 | permissions: 19 | contents: read 20 | packages: write 21 | # 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v3 25 | # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. 26 | - name: Log in to the Container registry 27 | uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 28 | with: 29 | registry: ${{ env.REGISTRY }} 30 | username: ${{ github.actor }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. 33 | - name: Extract metadata (tags, labels) for Docker 34 | id: meta 35 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 36 | with: 37 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 38 | # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. 39 | # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. 40 | # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. 41 | - name: Build and push Docker image 42 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 43 | with: 44 | context: . 45 | push: true 46 | tags: ${{ steps.meta.outputs.tags }} 47 | labels: ${{ steps.meta.outputs.labels }} 48 | -------------------------------------------------------------------------------- /taxy-api/src/error.rs: -------------------------------------------------------------------------------- 1 | use serde_derive::{Deserialize, Serialize}; 2 | use thiserror::Error; 3 | use utoipa::ToSchema; 4 | 5 | use crate::{id::ShortId, multiaddr::Multiaddr}; 6 | 7 | #[derive(Serialize, Deserialize, ToSchema)] 8 | pub struct ErrorMessage { 9 | pub message: String, 10 | pub error: Option, 11 | } 12 | 13 | #[derive(Debug, Clone, Error, Serialize, Deserialize, ToSchema)] 14 | #[serde(rename_all = "snake_case", tag = "message")] 15 | #[non_exhaustive] 16 | pub enum Error { 17 | #[error("invalid listening address: {addr}")] 18 | InvalidListeningAddress { 19 | #[schema(value_type = String)] 20 | addr: Multiaddr, 21 | }, 22 | 23 | #[error("invalid server address: {addr}")] 24 | InvalidServerAddress { 25 | #[schema(value_type = String)] 26 | addr: Multiaddr, 27 | }, 28 | 29 | #[error("invalid subject name: {name}")] 30 | InvalidSubjectName { name: String }, 31 | 32 | #[error("invalid virtual host: {host}")] 33 | InvalidVirtualHost { host: String }, 34 | 35 | #[error("invalid server url: {url}")] 36 | InvalidServerUrl { url: String }, 37 | 38 | #[error("invalid multiaddr: {addr}")] 39 | InvalidMultiaddr { addr: String }, 40 | 41 | #[error("missing TLS termination config")] 42 | TlsTerminationConfigMissing, 43 | 44 | #[error("failed to generate self-signed certificate")] 45 | FailedToGenerateSelfSignedCertificate, 46 | 47 | #[error("failed to read certificate")] 48 | FailedToReadCertificate, 49 | 50 | #[error("failed to read private key")] 51 | FailedToReadPrivateKey, 52 | 53 | #[error("invalid short id: {id}")] 54 | InvalidShortId { id: String }, 55 | 56 | #[error("port id not found: {id}")] 57 | IdNotFound { id: String }, 58 | 59 | #[error("port id already exists: {id}")] 60 | IdAlreadyExists { id: ShortId }, 61 | 62 | #[error("acme account creation failed")] 63 | AcmeAccountCreationFailed, 64 | 65 | #[error("unauthorized")] 66 | Unauthorized, 67 | 68 | #[error("failed to create account")] 69 | FailedToCreateAccount, 70 | 71 | #[error("invalid login credentials")] 72 | InvalidLoginCredentials, 73 | 74 | #[error("too many login attempts")] 75 | TooManyLoginAttempts, 76 | 77 | #[error("failed to fetch log")] 78 | FailedToFetchLog, 79 | 80 | #[error("failed to invoke rpc")] 81 | FailedToInvokeRpc, 82 | 83 | #[error("failed to list network interfaces")] 84 | FailedToListNetworkInterfaces, 85 | } 86 | 87 | impl Error { 88 | pub fn status_code(&self) -> u16 { 89 | match self { 90 | Self::IdNotFound { .. } => 404, 91 | Self::Unauthorized => 401, 92 | Self::TooManyLoginAttempts => 429, 93 | Self::FailedToFetchLog | Self::FailedToInvokeRpc => 500, 94 | _ => 400, 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /docs/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "juice/templates/index.html" %} 2 | {% block hero %} 3 |
4 |

5 | Taxy: Effortless Web Proxy 6 |

7 |

8 | A reverse proxy server with built-in WebUI. 9 |

10 | 11 |
12 | 13 |
14 | Getting Started ⤵︎ 15 |
16 | 48 | {% endblock hero %} 49 | 50 | {% block favicon %} 51 | 52 | 53 | 54 | 55 | 56 | 58 | 59 | 60 | 61 | 63 | 65 | {% endblock favicon %} 66 | 67 | {% block fonts %} 68 | 69 | {% endblock fonts %} 70 | 71 | {% block head %} 72 | 73 | {% endblock head %} 74 | 75 | {% block footer %} 76 |
77 | 78 | Zola theme by Huhu.io © 2021 79 | 80 |
81 | {% endblock footer %} -------------------------------------------------------------------------------- /taxy/src/server/rpc/proxies.rs: -------------------------------------------------------------------------------- 1 | use super::RpcMethod; 2 | use crate::server::state::ServerState; 3 | use taxy_api::error::Error; 4 | use taxy_api::id::ShortId; 5 | use taxy_api::proxy::{Proxy, ProxyEntry, ProxyStatus}; 6 | 7 | pub struct GetProxyList; 8 | 9 | #[async_trait::async_trait] 10 | impl RpcMethod for GetProxyList { 11 | type Output = Vec; 12 | 13 | async fn call(self, state: &mut ServerState) -> Result { 14 | Ok(state.proxies.entries().cloned().collect()) 15 | } 16 | } 17 | 18 | pub struct GetProxy { 19 | pub id: ShortId, 20 | } 21 | 22 | #[async_trait::async_trait] 23 | impl RpcMethod for GetProxy { 24 | type Output = ProxyEntry; 25 | 26 | async fn call(self, state: &mut ServerState) -> Result { 27 | state 28 | .proxies 29 | .get(self.id) 30 | .map(|ctx| ctx.entry.clone()) 31 | .ok_or(Error::IdNotFound { 32 | id: self.id.to_string(), 33 | }) 34 | } 35 | } 36 | 37 | pub struct GetProxyStatus { 38 | pub id: ShortId, 39 | } 40 | 41 | #[async_trait::async_trait] 42 | impl RpcMethod for GetProxyStatus { 43 | type Output = ProxyStatus; 44 | 45 | async fn call(self, state: &mut ServerState) -> Result { 46 | state 47 | .proxies 48 | .get(self.id) 49 | .map(|ctx| ctx.status) 50 | .ok_or(Error::IdNotFound { 51 | id: self.id.to_string(), 52 | }) 53 | } 54 | } 55 | 56 | pub struct DeleteProxy { 57 | pub id: ShortId, 58 | } 59 | 60 | #[async_trait::async_trait] 61 | impl RpcMethod for DeleteProxy { 62 | type Output = (); 63 | 64 | async fn call(self, state: &mut ServerState) -> Result { 65 | state.proxies.delete(self.id)?; 66 | state.update_proxies().await; 67 | state.reload_proxies().await; 68 | Ok(()) 69 | } 70 | } 71 | 72 | pub struct AddProxy { 73 | pub entry: Proxy, 74 | } 75 | 76 | #[async_trait::async_trait] 77 | impl RpcMethod for AddProxy { 78 | type Output = (); 79 | 80 | async fn call(self, state: &mut ServerState) -> Result { 81 | if state.proxies.set((state.generate_id(), self.entry).into()) { 82 | state.update_proxies().await; 83 | state.reload_proxies().await; 84 | } 85 | Ok(()) 86 | } 87 | } 88 | 89 | pub struct UpdateProxy { 90 | pub entry: ProxyEntry, 91 | } 92 | 93 | #[async_trait::async_trait] 94 | impl RpcMethod for UpdateProxy { 95 | type Output = (); 96 | 97 | async fn call(self, state: &mut ServerState) -> Result { 98 | if state.proxies.set(self.entry) { 99 | state.update_proxies().await; 100 | state.reload_proxies().await; 101 | } 102 | Ok(()) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /taxy-api/src/log.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::{collections::HashMap, fmt::Display}; 3 | use time::OffsetDateTime; 4 | use utoipa::{IntoParams, ToSchema}; 5 | 6 | #[derive(Clone, Copy, Serialize, Deserialize, ToSchema)] 7 | #[serde(rename_all = "snake_case")] 8 | pub enum LogLevel { 9 | Error, 10 | Warn, 11 | Info, 12 | Debug, 13 | Trace, 14 | } 15 | 16 | impl TryFrom for LogLevel { 17 | type Error = (); 18 | 19 | fn try_from(value: u8) -> Result { 20 | match value { 21 | 1 => Ok(LogLevel::Error), 22 | 2 => Ok(LogLevel::Warn), 23 | 3 => Ok(LogLevel::Info), 24 | 4 => Ok(LogLevel::Debug), 25 | 5 => Ok(LogLevel::Trace), 26 | _ => Err(()), 27 | } 28 | } 29 | } 30 | 31 | impl Display for LogLevel { 32 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 33 | match self { 34 | LogLevel::Error => write!(f, "error"), 35 | LogLevel::Warn => write!(f, "warn"), 36 | LogLevel::Info => write!(f, "info"), 37 | LogLevel::Debug => write!(f, "debug"), 38 | LogLevel::Trace => write!(f, "trace"), 39 | } 40 | } 41 | } 42 | 43 | #[derive(Clone, Serialize, Deserialize, ToSchema)] 44 | pub struct SystemLogRow { 45 | #[serde( 46 | serialize_with = "serialize_timestamp", 47 | deserialize_with = "deserialize_timestamp" 48 | )] 49 | #[schema(value_type = u64)] 50 | pub timestamp: OffsetDateTime, 51 | #[schema(value_type = String, example = "info")] 52 | pub level: LogLevel, 53 | pub resource_id: String, 54 | pub message: String, 55 | pub fields: HashMap, 56 | } 57 | 58 | fn serialize_timestamp(timestamp: &OffsetDateTime, serializer: S) -> Result 59 | where 60 | S: serde::Serializer, 61 | { 62 | serializer.serialize_i64(timestamp.unix_timestamp()) 63 | } 64 | 65 | fn deserialize_timestamp<'de, D>(deserializer: D) -> Result 66 | where 67 | D: serde::Deserializer<'de>, 68 | { 69 | let timestamp = i64::deserialize(deserializer)?; 70 | OffsetDateTime::from_unix_timestamp(timestamp).map_err(serde::de::Error::custom) 71 | } 72 | 73 | #[derive(Deserialize, IntoParams)] 74 | #[into_params(parameter_in = Query)] 75 | pub struct LogQuery { 76 | #[serde(default, deserialize_with = "deserialize_time")] 77 | #[param(value_type = Option)] 78 | pub since: Option, 79 | #[serde(default, deserialize_with = "deserialize_time")] 80 | #[param(value_type = Option)] 81 | pub until: Option, 82 | pub limit: Option, 83 | } 84 | 85 | fn deserialize_time<'de, D>(deserializer: D) -> Result, D::Error> 86 | where 87 | D: serde::Deserializer<'de>, 88 | { 89 | let timestamp = Option::::deserialize(deserializer)?; 90 | Ok(timestamp.and_then(|timestamp| OffsetDateTime::from_unix_timestamp(timestamp).ok())) 91 | } 92 | -------------------------------------------------------------------------------- /taxy-api/src/app.rs: -------------------------------------------------------------------------------- 1 | use serde_default::DefaultFromSerde; 2 | use serde_derive::{Deserialize, Serialize}; 3 | use std::{net::SocketAddr, path::PathBuf, time::Duration}; 4 | use utoipa::ToSchema; 5 | 6 | #[derive(Debug, DefaultFromSerde, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] 7 | pub struct AppConfig { 8 | #[serde(with = "humantime_serde", default = "default_background_task_interval")] 9 | #[schema(value_type = String, example = "1h")] 10 | pub background_task_interval: Duration, 11 | 12 | #[serde(default)] 13 | pub admin: AdminConfig, 14 | 15 | #[serde(default)] 16 | pub log: LogConfig, 17 | 18 | #[serde(default = "default_http_challenge_addr")] 19 | #[schema(value_type = String, example = "0.0.0.0:80")] 20 | pub http_challenge_addr: SocketAddr, 21 | } 22 | 23 | fn default_background_task_interval() -> Duration { 24 | Duration::from_secs(60 * 60) 25 | } 26 | 27 | fn default_http_challenge_addr() -> SocketAddr { 28 | SocketAddr::from(([0, 0, 0, 0], 80)) 29 | } 30 | 31 | #[derive(Clone, Serialize, ToSchema)] 32 | pub struct AppInfo { 33 | #[schema(example = "0.0.0")] 34 | pub version: &'static str, 35 | #[schema(example = "aarch64-apple-darwin")] 36 | pub target: &'static str, 37 | #[schema(example = "debug")] 38 | pub profile: &'static str, 39 | #[schema(example = json!([]))] 40 | pub features: &'static [&'static str], 41 | #[schema(example = "rustc 1.69.0 (84c898d65 2023-04-16)")] 42 | pub rustc: &'static str, 43 | #[schema(value_type = String, example = "/home/taxy/.config/taxy")] 44 | pub config_path: PathBuf, 45 | #[schema(value_type = String, example = "/home/taxy/.config/taxy")] 46 | pub log_path: PathBuf, 47 | } 48 | 49 | #[derive(Debug, DefaultFromSerde, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] 50 | pub struct AdminConfig { 51 | #[serde(with = "humantime_serde", default = "default_admin_session_expiry")] 52 | #[schema(value_type = String, example = "1d")] 53 | pub session_expiry: Duration, 54 | 55 | #[serde(default = "default_max_attempts")] 56 | pub max_login_attempts: u32, 57 | 58 | #[serde(with = "humantime_serde", default = "default_login_attempts_reset")] 59 | #[schema(value_type = String, example = "15m")] 60 | pub login_attempts_reset: Duration, 61 | } 62 | 63 | fn default_admin_session_expiry() -> Duration { 64 | Duration::from_secs(60 * 60) 65 | } 66 | 67 | fn default_max_attempts() -> u32 { 68 | 10 69 | } 70 | 71 | fn default_login_attempts_reset() -> Duration { 72 | Duration::from_secs(60 * 15) 73 | } 74 | 75 | #[derive(Debug, DefaultFromSerde, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] 76 | pub struct LogConfig { 77 | #[serde(with = "humantime_serde", default = "default_database_log_retention")] 78 | #[schema(value_type = String, example = "3months")] 79 | pub database_log_retention: Duration, 80 | } 81 | 82 | fn default_database_log_retention() -> Duration { 83 | Duration::from_secs(60 * 60 * 24 * 30 * 3) 84 | } 85 | -------------------------------------------------------------------------------- /taxy/tests/ws_test.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::{ws::WebSocket, WebSocketUpgrade}, 3 | response::IntoResponse, 4 | routing::any, 5 | Router, 6 | }; 7 | use futures::{SinkExt, StreamExt}; 8 | use hyper::Uri; 9 | use taxy_api::{ 10 | port::{Port, PortEntry}, 11 | proxy::{HttpProxy, Proxy, ProxyEntry, ProxyKind, Route}, 12 | }; 13 | use tokio_tungstenite::{connect_async, tungstenite::Message}; 14 | 15 | mod common; 16 | use common::{alloc_tcp_port, with_server, TestStorage}; 17 | 18 | #[tokio::test] 19 | async fn ws_proxy() -> anyhow::Result<()> { 20 | let listen_port = alloc_tcp_port().await?; 21 | let proxy_port = alloc_tcp_port().await?; 22 | 23 | let app = Router::new().route("/ws", any(ws_handler)); 24 | let addr = listen_port.socket_addr(); 25 | tokio::spawn(axum_server::bind(addr).serve(app.into_make_service())); 26 | 27 | let config = TestStorage::builder() 28 | .ports(vec![PortEntry { 29 | id: "test".parse().unwrap(), 30 | port: Port { 31 | active: true, 32 | name: String::new(), 33 | listen: proxy_port.multiaddr_http(), 34 | opts: Default::default(), 35 | }, 36 | }]) 37 | .proxies(vec![ProxyEntry { 38 | id: "test2".parse().unwrap(), 39 | proxy: Proxy { 40 | ports: vec!["test".parse().unwrap()], 41 | kind: ProxyKind::Http(HttpProxy { 42 | vhosts: vec!["localhost".parse().unwrap()], 43 | routes: vec![Route { 44 | path: "/".into(), 45 | servers: vec![taxy_api::proxy::Server { 46 | url: listen_port.http_url("/").try_into().unwrap(), 47 | }], 48 | }], 49 | upgrade_insecure: false, 50 | }), 51 | ..Default::default() 52 | }, 53 | }]) 54 | .build(); 55 | 56 | with_server(config, |_| async move { 57 | let url = Uri::try_from(&format!( 58 | "ws://localhost:{}/ws", 59 | proxy_port.socket_addr().port() 60 | ))?; 61 | let (mut ws_stream, _) = connect_async(url).await?; 62 | ws_stream 63 | .send(Message::Text("Hello, server!".to_string().into())) 64 | .await?; 65 | 66 | let message = ws_stream.next().await.unwrap(); 67 | match message { 68 | Ok(msg) => assert_eq!("Hello, server!", msg.into_text()?.as_str()), 69 | Err(e) => panic!("{e}"), 70 | } 71 | Ok(()) 72 | }) 73 | .await 74 | } 75 | 76 | async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse { 77 | ws.on_upgrade(handle_socket) 78 | } 79 | 80 | async fn handle_socket(mut socket: WebSocket) { 81 | while let Some(msg) = socket.recv().await { 82 | if let Ok(msg) = msg { 83 | socket.send(msg).await.unwrap(); 84 | } else { 85 | return; 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /taxy/tests/tls_test.rs: -------------------------------------------------------------------------------- 1 | use axum::{routing::get, Router}; 2 | use axum_server::tls_rustls::RustlsConfig; 3 | use std::sync::Arc; 4 | use taxy::certs::Cert; 5 | use taxy_api::{ 6 | port::{Port, PortEntry, PortOptions, UpstreamServer}, 7 | proxy::{Proxy, ProxyEntry, ProxyKind, TcpProxy}, 8 | tls::TlsTermination, 9 | }; 10 | 11 | mod common; 12 | use common::{alloc_tcp_port, with_server, TestStorage}; 13 | 14 | #[tokio::test] 15 | async fn tls_proxy() -> anyhow::Result<()> { 16 | let listen_port = alloc_tcp_port().await?; 17 | let proxy_port = alloc_tcp_port().await?; 18 | 19 | let root = Arc::new(Cert::new_ca().unwrap()); 20 | let cert = Arc::new(Cert::new_self_signed(&["localhost".parse().unwrap()], &root).unwrap()); 21 | 22 | let config = RustlsConfig::from_pem( 23 | cert.pem_chain.to_vec(), 24 | cert.pem_key.as_ref().unwrap().to_vec(), 25 | ) 26 | .await 27 | .unwrap(); 28 | 29 | async fn handler() -> &'static str { 30 | "Hello" 31 | } 32 | let app = Router::new().route("/hello", get(handler)); 33 | 34 | let addr = listen_port.socket_addr(); 35 | tokio::spawn(axum_server::bind_rustls(addr, config).serve(app.into_make_service())); 36 | 37 | let config = TestStorage::builder() 38 | .ports(vec![PortEntry { 39 | id: "test".parse().unwrap(), 40 | port: Port { 41 | active: true, 42 | name: String::new(), 43 | listen: proxy_port.multiaddr_tls(), 44 | opts: PortOptions { 45 | tls_termination: Some(TlsTermination { 46 | server_names: vec!["localhost".into()], 47 | }), 48 | }, 49 | }, 50 | }]) 51 | .proxies(vec![ProxyEntry { 52 | id: "test2".parse().unwrap(), 53 | proxy: Proxy { 54 | ports: vec!["test".parse().unwrap()], 55 | kind: ProxyKind::Tcp(TcpProxy { 56 | upstream_servers: vec![UpstreamServer { 57 | addr: format!( 58 | "/dns/localhost/tcp/{}/tls", 59 | listen_port.socket_addr().port() 60 | ) 61 | .parse() 62 | .unwrap(), 63 | }], 64 | }), 65 | ..Default::default() 66 | }, 67 | }]) 68 | .certs( 69 | [(root.id, root.clone()), (cert.id, cert.clone())] 70 | .into_iter() 71 | .collect(), 72 | ) 73 | .build(); 74 | 75 | let ca = reqwest::Certificate::from_pem(&root.pem_chain)?; 76 | 77 | with_server(config, |_| async move { 78 | let client = reqwest::Client::builder() 79 | .add_root_certificate(ca) 80 | .build()?; 81 | let resp = client 82 | .get(proxy_port.https_url("/hello")) 83 | .send() 84 | .await? 85 | .text() 86 | .await?; 87 | assert_eq!(resp, "Hello"); 88 | Ok(()) 89 | }) 90 | .await 91 | } 92 | -------------------------------------------------------------------------------- /taxy/src/admin/certs.rs: -------------------------------------------------------------------------------- 1 | use super::{AppError, AppState}; 2 | use crate::{ 3 | certs::Cert, 4 | server::rpc::certs::{AddCert, DeleteCert, DownloadCert, GetCert, GetCertList}, 5 | }; 6 | use axum::{ 7 | extract::{Multipart, Path, Query, State}, 8 | http::HeaderMap, 9 | response::IntoResponse, 10 | Json, 11 | }; 12 | use std::{ops::Deref, sync::Arc}; 13 | use taxy_api::{ 14 | cert::{CertInfo, SelfSignedCertRequest, UploadQuery}, 15 | id::ShortId, 16 | }; 17 | 18 | pub async fn list(State(state): State) -> Result>>, AppError> { 19 | Ok(Json(state.call(GetCertList).await?)) 20 | } 21 | 22 | pub async fn get( 23 | State(state): State, 24 | Path(id): Path, 25 | ) -> Result>, AppError> { 26 | let cert = state.call(GetCert { id }).await?; 27 | Ok(Json(Box::new(cert.info()))) 28 | } 29 | 30 | pub async fn self_sign( 31 | State(state): State, 32 | Json(request): Json, 33 | ) -> Result>, AppError> { 34 | let cert = if let Some(ca_cert) = request.ca_cert { 35 | let ca = state.call(GetCert { id: ca_cert }).await?; 36 | Cert::new_self_signed(&request.san, &ca)? 37 | } else { 38 | let ca = Arc::new(Cert::new_ca()?); 39 | state.call(AddCert { cert: ca.clone() }).await?; 40 | Cert::new_self_signed(&request.san, &ca)? 41 | }; 42 | let cert = Arc::new(cert); 43 | Ok(Json(state.call(AddCert { cert }).await?)) 44 | } 45 | 46 | pub async fn upload( 47 | State(state): State, 48 | Query(query): Query, 49 | mut multipart: Multipart, 50 | ) -> Result>, AppError> { 51 | let mut chain = Vec::new(); 52 | let mut key = Vec::new(); 53 | while let Ok(Some(field)) = multipart.next_field().await { 54 | if field.name() == Some("chain") { 55 | if let Ok(buf) = field.bytes().await { 56 | chain = buf.to_vec(); 57 | } 58 | } else if field.name() == Some("key") { 59 | if let Ok(buf) = field.bytes().await { 60 | key = buf.to_vec(); 61 | } 62 | } 63 | } 64 | 65 | let key = if key.is_empty() { None } else { Some(key) }; 66 | let cert = Arc::new(Cert::new(query.kind, chain, key)?); 67 | Ok(Json(state.call(AddCert { cert }).await?)) 68 | } 69 | 70 | pub async fn delete( 71 | State(state): State, 72 | Path(id): Path, 73 | ) -> Result>, AppError> { 74 | Ok(Json(state.call(DeleteCert { id }).await?)) 75 | } 76 | 77 | pub async fn download( 78 | State(state): State, 79 | Path(id): Path, 80 | ) -> Result { 81 | let file = state.call(DownloadCert { id }).await?; 82 | let mut headers = HeaderMap::new(); 83 | headers.insert("Content-Type", "application/gzip".parse().unwrap()); 84 | headers.insert( 85 | "Content-Disposition", 86 | format!("attachment; filename=\"{}.tar.gz\"", id) 87 | .parse() 88 | .unwrap(), 89 | ); 90 | Ok((headers, file.deref().clone())) 91 | } 92 | -------------------------------------------------------------------------------- /taxy-api/src/vhost.rs: -------------------------------------------------------------------------------- 1 | use crate::{error::Error, subject_name::SubjectName}; 2 | use regex::Regex; 3 | use serde::{Deserialize, Serialize}; 4 | use std::fmt; 5 | use std::str::FromStr; 6 | 7 | #[derive(Debug, Clone)] 8 | pub enum VirtualHost { 9 | SubjectName(SubjectName), 10 | Regex(Regex), 11 | } 12 | 13 | impl VirtualHost { 14 | pub fn test(&self, name: &str) -> bool { 15 | match self { 16 | VirtualHost::SubjectName(n) => n.test(name), 17 | VirtualHost::Regex(r) => r.is_match(name), 18 | } 19 | } 20 | } 21 | 22 | impl PartialEq for VirtualHost { 23 | fn eq(&self, other: &Self) -> bool { 24 | match (self, other) { 25 | (VirtualHost::SubjectName(a), VirtualHost::SubjectName(b)) => a == b, 26 | (VirtualHost::Regex(a), VirtualHost::Regex(b)) => a.as_str() == b.as_str(), 27 | _ => false, 28 | } 29 | } 30 | } 31 | 32 | impl Eq for VirtualHost {} 33 | 34 | impl fmt::Display for VirtualHost { 35 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 36 | match self { 37 | VirtualHost::SubjectName(name) => write!(f, "{}", name), 38 | VirtualHost::Regex(r) => write!(f, "{}", r), 39 | } 40 | } 41 | } 42 | 43 | impl FromStr for VirtualHost { 44 | type Err = Error; 45 | 46 | fn from_str(s: &str) -> Result { 47 | if let Ok(n) = SubjectName::from_str(s) { 48 | Ok(VirtualHost::SubjectName(n)) 49 | } else if let Ok(r) = Regex::new(s) { 50 | Ok(VirtualHost::Regex(r)) 51 | } else { 52 | Err(Error::InvalidVirtualHost { host: s.into() }) 53 | } 54 | } 55 | } 56 | 57 | impl Serialize for VirtualHost { 58 | fn serialize(&self, serializer: S) -> Result 59 | where 60 | S: serde::Serializer, 61 | { 62 | serializer.serialize_str(&self.to_string()) 63 | } 64 | } 65 | 66 | impl<'de> Deserialize<'de> for VirtualHost { 67 | fn deserialize(deserializer: D) -> Result 68 | where 69 | D: serde::Deserializer<'de>, 70 | { 71 | let s = String::deserialize(deserializer)?; 72 | Self::from_str(&s).map_err(serde::de::Error::custom) 73 | } 74 | } 75 | 76 | #[cfg(test)] 77 | mod test { 78 | #[test] 79 | fn test_virtual_host() { 80 | use super::VirtualHost; 81 | use std::str::FromStr; 82 | 83 | let host = VirtualHost::from_str("localhost").unwrap(); 84 | assert_eq!(host.to_string(), "localhost"); 85 | assert!(host.test("localhost")); 86 | assert!(!host.test("example.com")); 87 | 88 | let host = VirtualHost::from_str("^.*\\.example\\.com$").unwrap(); 89 | assert_eq!(host.to_string(), "^.*\\.example\\.com$"); 90 | assert!(!host.test("localhost")); 91 | assert!(host.test("www.example.com")); 92 | 93 | let host = VirtualHost::from_str("^([a-z]+\\.)+my\\.vow$").unwrap(); 94 | assert_eq!(host.to_string(), "^([a-z]+\\.)+my\\.vow$"); 95 | assert!(!host.test("localhost")); 96 | assert!(host.test("sphinx.of.black.quartz.judge.my.vow")); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /taxy-api/src/acme.rs: -------------------------------------------------------------------------------- 1 | use crate::{id::ShortId, subject_name::SubjectName}; 2 | use base64::{engine::general_purpose, Engine as _}; 3 | use serde_default::DefaultFromSerde; 4 | use serde_derive::{Deserialize, Serialize}; 5 | use utoipa::ToSchema; 6 | 7 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, ToSchema)] 8 | pub struct Acme { 9 | #[schema(inline)] 10 | #[serde(flatten)] 11 | pub config: AcmeConfig, 12 | #[schema(value_type = [String], example = json!(["example.com"]))] 13 | pub identifiers: Vec, 14 | #[schema(value_type = String, example = "http-01")] 15 | pub challenge_type: String, 16 | } 17 | 18 | #[derive(Debug, DefaultFromSerde, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] 19 | pub struct AcmeConfig { 20 | #[serde(default = "default_active", skip_serializing_if = "is_true")] 21 | pub active: bool, 22 | #[serde(default)] 23 | #[schema(example = "Let's Encrypt")] 24 | pub provider: String, 25 | #[schema(example = "60")] 26 | #[serde(default = "default_renewal_days")] 27 | pub renewal_days: u64, 28 | } 29 | 30 | fn default_active() -> bool { 31 | true 32 | } 33 | 34 | fn is_true(b: &bool) -> bool { 35 | *b 36 | } 37 | 38 | fn default_renewal_days() -> u64 { 39 | 60 40 | } 41 | 42 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] 43 | pub struct AcmeInfo { 44 | pub id: ShortId, 45 | #[schema(inline)] 46 | #[serde(flatten)] 47 | pub config: AcmeConfig, 48 | #[schema(example = json!(["example.com"]))] 49 | pub identifiers: Vec, 50 | #[schema(value_type = String, example = "http-01")] 51 | pub challenge_type: String, 52 | pub next_renewal: Option, 53 | } 54 | 55 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, ToSchema)] 56 | pub struct AcmeRequest { 57 | #[schema(example = "https://acme-staging-v02.api.letsencrypt.org/directory")] 58 | pub server_url: String, 59 | #[schema(example = json!(["mailto:admin@example.com"]))] 60 | pub contacts: Vec, 61 | #[serde(default)] 62 | pub eab: Option, 63 | #[schema(inline)] 64 | #[serde(flatten)] 65 | pub acme: Acme, 66 | } 67 | 68 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, ToSchema)] 69 | pub struct ExternalAccountBinding { 70 | #[schema(example = "f9cf7e3faa1aca7e6086")] 71 | pub key_id: String, 72 | #[schema(value_type = String, example = "TszzWRgQWTUqo04dxmSuKDH06")] 73 | #[serde( 74 | serialize_with = "serialize_hmac_key", 75 | deserialize_with = "deserialize_hmac_key" 76 | )] 77 | pub hmac_key: Vec, 78 | } 79 | 80 | fn serialize_hmac_key(hmac_key: &[u8], serializer: S) -> Result 81 | where 82 | S: serde::Serializer, 83 | { 84 | serializer.serialize_str(&general_purpose::URL_SAFE_NO_PAD.encode(hmac_key)) 85 | } 86 | 87 | fn deserialize_hmac_key<'de, D>(deserializer: D) -> Result, D::Error> 88 | where 89 | D: serde::Deserializer<'de>, 90 | { 91 | use serde::de::Deserialize; 92 | let hmac_key = String::deserialize(deserializer)?; 93 | general_purpose::URL_SAFE_NO_PAD 94 | .decode(hmac_key.as_bytes()) 95 | .map_err(serde::de::Error::custom) 96 | } 97 | -------------------------------------------------------------------------------- /taxy-webui/src/pages/new_port.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | auth::use_ensure_auth, components::port_config::PortConfig, pages::Route, API_ENDPOINT, 3 | }; 4 | use gloo_net::http::Request; 5 | use std::collections::HashMap; 6 | use taxy_api::port::Port; 7 | use yew::prelude::*; 8 | use yew_router::prelude::*; 9 | 10 | #[function_component(NewPort)] 11 | pub fn new_port() -> Html { 12 | use_ensure_auth(); 13 | 14 | let navigator = use_navigator().unwrap(); 15 | let entry = use_state::>, _>(|| Err(Default::default())); 16 | let entry_cloned = entry.clone(); 17 | let onchanged: Callback>> = 18 | Callback::from(move |updated| { 19 | entry_cloned.set(updated); 20 | }); 21 | 22 | let navigator_cloned = navigator.clone(); 23 | let cancel_onclick = Callback::from(move |_| { 24 | navigator_cloned.push(&Route::Ports); 25 | }); 26 | 27 | let is_loading = use_state(|| false); 28 | 29 | let entry_cloned = entry.clone(); 30 | let is_loading_cloned = is_loading; 31 | let onsubmit = Callback::from(move |event: SubmitEvent| { 32 | event.prevent_default(); 33 | if *is_loading_cloned { 34 | return; 35 | } 36 | let navigator = navigator.clone(); 37 | let is_loading_cloned = is_loading_cloned.clone(); 38 | if let Ok(entry) = (*entry_cloned).clone() { 39 | is_loading_cloned.set(true); 40 | wasm_bindgen_futures::spawn_local(async move { 41 | if create_port(&entry).await.is_ok() { 42 | navigator.push(&Route::Ports); 43 | } 44 | is_loading_cloned.set(false); 45 | }); 46 | } 47 | }); 48 | 49 | html! { 50 | <> 51 |
52 | 53 | 54 |
55 | 58 | 61 |
62 | 63 | 64 | } 65 | } 66 | 67 | async fn create_port(entry: &Port) -> Result<(), gloo_net::Error> { 68 | Request::post(&format!("{API_ENDPOINT}/ports")) 69 | .json(entry)? 70 | .send() 71 | .await? 72 | .json() 73 | .await 74 | } 75 | -------------------------------------------------------------------------------- /taxy-webui/src/pages/new_proxy.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | auth::use_ensure_auth, components::proxy_config::ProxyConfig, pages::Route, API_ENDPOINT, 3 | }; 4 | use gloo_net::http::Request; 5 | use std::collections::HashMap; 6 | use taxy_api::proxy::Proxy; 7 | use yew::prelude::*; 8 | use yew_router::prelude::*; 9 | 10 | #[function_component(NewProxy)] 11 | pub fn new_proxy() -> Html { 12 | use_ensure_auth(); 13 | 14 | let navigator = use_navigator().unwrap(); 15 | 16 | let entry = use_state::>, _>(|| Err(Default::default())); 17 | let entry_cloned = entry.clone(); 18 | let onchanged: Callback>> = 19 | Callback::from(move |updated| { 20 | entry_cloned.set(updated); 21 | }); 22 | 23 | let navigator_cloned = navigator.clone(); 24 | let cancel_onclick = Callback::from(move |_| { 25 | navigator_cloned.push(&Route::Proxies); 26 | }); 27 | 28 | let is_loading = use_state(|| false); 29 | 30 | let entry_cloned = entry.clone(); 31 | let is_loading_cloned = is_loading; 32 | let onsubmit = Callback::from(move |event: SubmitEvent| { 33 | event.prevent_default(); 34 | if *is_loading_cloned { 35 | return; 36 | } 37 | let navigator = navigator.clone(); 38 | let is_loading_cloned = is_loading_cloned.clone(); 39 | if let Ok(entry) = (*entry_cloned).clone() { 40 | is_loading_cloned.set(true); 41 | wasm_bindgen_futures::spawn_local(async move { 42 | if create_port(&entry).await.is_ok() { 43 | navigator.push(&Route::Proxies); 44 | } 45 | is_loading_cloned.set(false); 46 | }); 47 | } 48 | }); 49 | 50 | html! { 51 | <> 52 |
53 | 54 | 55 |
56 | 59 | 62 |
63 | 64 | 65 | } 66 | } 67 | 68 | async fn create_port(entry: &Proxy) -> Result<(), gloo_net::Error> { 69 | Request::post(&format!("{API_ENDPOINT}/proxies")) 70 | .json(entry)? 71 | .send() 72 | .await? 73 | .json() 74 | .await 75 | } 76 | -------------------------------------------------------------------------------- /taxy-webui/src/pages/mod.rs: -------------------------------------------------------------------------------- 1 | use serde_derive::{Deserialize, Serialize}; 2 | use taxy_api::id::ShortId; 3 | use yew::prelude::*; 4 | use yew_router::prelude::*; 5 | 6 | mod cert_list; 7 | mod log_view; 8 | mod login; 9 | mod logout; 10 | mod new_acme; 11 | mod new_port; 12 | mod new_proxy; 13 | mod port_list; 14 | mod port_view; 15 | mod proxy_list; 16 | mod proxy_view; 17 | mod self_sign; 18 | mod upload; 19 | 20 | #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Routable)] 21 | #[serde(rename_all = "snake_case")] 22 | pub enum Route { 23 | #[at("/")] 24 | Home, 25 | #[at("/login")] 26 | Login, 27 | #[at("/logout")] 28 | Logout, 29 | #[at("/ports")] 30 | Ports, 31 | #[at("/ports/new")] 32 | NewPort, 33 | #[at("/ports/:id")] 34 | PortView { id: ShortId }, 35 | #[at("/ports/:id/log")] 36 | PortLogView { id: ShortId }, 37 | #[at("/proxies")] 38 | Proxies, 39 | #[at("/proxies/:id/log")] 40 | ProxyLogView { id: ShortId }, 41 | #[at("/certs")] 42 | Certs, 43 | #[at("/certs/self_sign")] 44 | SelfSign, 45 | #[at("/certs/upload")] 46 | Upload, 47 | #[at("/certs/new_acme")] 48 | NewAcme, 49 | #[at("/certs/:id/log")] 50 | CertLogView { id: String }, 51 | #[at("/proxies/new")] 52 | NewProxy, 53 | #[at("/proxies/:id")] 54 | ProxyView { id: ShortId }, 55 | #[not_found] 56 | #[at("/404")] 57 | NotFound, 58 | } 59 | 60 | impl Route { 61 | pub fn root(&self) -> Option { 62 | match self { 63 | Route::Home => Some(Route::Home), 64 | Route::Ports | Route::NewPort | Route::PortView { .. } | Route::PortLogView { .. } => { 65 | Some(Route::Ports) 66 | } 67 | Route::Certs 68 | | Route::SelfSign 69 | | Route::Upload 70 | | Route::NewAcme 71 | | Route::CertLogView { .. } => Some(Route::Certs), 72 | Route::Proxies 73 | | Route::NewProxy 74 | | Route::ProxyView { .. } 75 | | Route::ProxyLogView { .. } => Some(Route::Proxies), 76 | _ => None, 77 | } 78 | } 79 | } 80 | 81 | pub fn switch(routes: Route) -> Html { 82 | match routes { 83 | Route::Home => html! { to={Route::Ports}/> }, 84 | Route::Login => html! { }, 85 | Route::Logout => html! { }, 86 | Route::Ports => html! { }, 87 | Route::NewPort => html! { }, 88 | Route::PortView { id } => html! { }, 89 | Route::PortLogView { id } => html! { }, 90 | Route::Proxies => html! { }, 91 | Route::ProxyLogView { id } => html! { }, 92 | Route::ProxyView { id } => html! { }, 93 | Route::NewProxy => html! { }, 94 | Route::Certs => html! { }, 95 | Route::SelfSign => html! { }, 96 | Route::NewAcme => html! { }, 97 | Route::CertLogView { id } => html! { }, 98 | Route::Upload => html! { }, 99 | Route::NotFound => html! { to={Route::Home}/> }, 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /taxy/src/admin/logs.rs: -------------------------------------------------------------------------------- 1 | use super::{AppError, AppState}; 2 | use axum::{ 3 | extract::{Path, Query, State}, 4 | Json, 5 | }; 6 | use sqlx::ConnectOptions; 7 | use sqlx::{sqlite::SqliteConnectOptions, Row, SqlitePool}; 8 | use std::time::Duration; 9 | use taxy_api::{ 10 | error::Error, 11 | log::{LogLevel, LogQuery, SystemLogRow}, 12 | }; 13 | use time::OffsetDateTime; 14 | 15 | const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); 16 | const REQUEST_INTERVAL: Duration = Duration::from_secs(1); 17 | const REQUEST_DEFAULT_LIMIT: u32 = 100; 18 | 19 | pub async fn get( 20 | State(state): State, 21 | Path(id): Path, 22 | Query(query): Query, 23 | ) -> Result>, AppError> { 24 | let log = state.data.lock().await.log.clone(); 25 | let rows = log 26 | .fetch_system_log(&id, query.since, query.until, query.limit) 27 | .await?; 28 | Ok(Json(rows)) 29 | } 30 | 31 | pub struct LogReader { 32 | pool: SqlitePool, 33 | } 34 | 35 | impl LogReader { 36 | pub async fn new(path: &std::path::Path) -> anyhow::Result { 37 | let opt = SqliteConnectOptions::new() 38 | .filename(path) 39 | .read_only(true) 40 | .log_statements(log::LevelFilter::Trace); 41 | let pool = SqlitePool::connect_with(opt).await?; 42 | Ok(Self { pool }) 43 | } 44 | 45 | pub async fn fetch_system_log( 46 | &self, 47 | resource_id: &str, 48 | since: Option, 49 | until: Option, 50 | limit: Option, 51 | ) -> Result, Error> { 52 | let mut timeout = tokio::time::interval(REQUEST_TIMEOUT); 53 | timeout.tick().await; 54 | 55 | loop { 56 | let rows = sqlx::query("select * from system_log WHERE resource_id = ? AND (timestamp BETWEEN ? AND ?) ORDER BY timestamp DESC LIMIT ?") 57 | .bind(resource_id) 58 | .bind(since.unwrap_or(OffsetDateTime::UNIX_EPOCH)) 59 | .bind(until.unwrap_or_else(OffsetDateTime::now_utc)) 60 | .bind(limit.unwrap_or(REQUEST_DEFAULT_LIMIT)) 61 | .fetch_all(&self.pool); 62 | tokio::select! { 63 | _ = timeout.tick() => { 64 | break; 65 | } 66 | rows = rows => { 67 | match rows { 68 | Ok(rows) if !rows.is_empty() || until.is_some() => { 69 | return Ok(rows 70 | .into_iter() 71 | .map(|row| SystemLogRow { 72 | timestamp: row.get(0), 73 | level: row.get::<'_, u8, _>(1).try_into().unwrap_or(LogLevel::Debug), 74 | resource_id: row.get(2), 75 | message: row.get(3), 76 | fields: serde_json::from_str(row.get(4)).unwrap_or_default(), 77 | }) 78 | .rev() 79 | .collect()); 80 | }, 81 | Err(_) => { 82 | return Err(Error::FailedToFetchLog); 83 | } 84 | _ => { 85 | tokio::time::sleep(REQUEST_INTERVAL).await; 86 | } 87 | } 88 | } 89 | } 90 | } 91 | 92 | Ok(vec![]) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /taxy-api/src/cert.rs: -------------------------------------------------------------------------------- 1 | use crate::id::ShortId; 2 | use crate::subject_name::SubjectName; 3 | use serde_default::DefaultFromSerde; 4 | use serde_derive::{Deserialize, Serialize}; 5 | use std::fmt; 6 | use std::time::{Duration, SystemTime, UNIX_EPOCH}; 7 | use utoipa::{IntoParams, ToSchema}; 8 | 9 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] 10 | #[serde(rename_all = "snake_case")] 11 | pub enum CertKind { 12 | Server, 13 | Client, 14 | Root, 15 | } 16 | 17 | impl fmt::Display for CertKind { 18 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 19 | write!( 20 | f, 21 | "{}", 22 | match self { 23 | CertKind::Server => "server", 24 | CertKind::Client => "client", 25 | CertKind::Root => "root", 26 | } 27 | ) 28 | } 29 | } 30 | 31 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] 32 | pub struct CertInfo { 33 | #[schema(example = "a13e1ecc080e42cfcdd5")] 34 | pub id: ShortId, 35 | pub kind: CertKind, 36 | #[schema(example = "a13e1ecc080e42cfcdd5b77fec8450c777554aa7269c029b242a7c548d0d73da")] 37 | pub fingerprint: String, 38 | #[schema(example = "CN=taxy self signed cert")] 39 | pub issuer: String, 40 | pub root_cert: Option, 41 | #[schema(value_type = [String], example = json!(["localhost"]))] 42 | pub san: Vec, 43 | #[schema(example = "67090118400")] 44 | pub not_after: i64, 45 | #[schema(example = "157766400")] 46 | pub not_before: i64, 47 | pub is_ca: bool, 48 | pub has_private_key: bool, 49 | pub metadata: Option, 50 | } 51 | 52 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] 53 | pub struct SelfSignedCertRequest { 54 | #[schema(value_type = [String], example = json!(["localhost"]))] 55 | pub san: Vec, 56 | #[schema(example = "f9cf7e3faa1aca7e6086")] 57 | pub ca_cert: Option, 58 | } 59 | 60 | #[derive(DefaultFromSerde, Clone, Serialize, Deserialize, IntoParams)] 61 | #[into_params(parameter_in = Query)] 62 | pub struct UploadQuery { 63 | #[serde(default = "default_cert_kind")] 64 | pub kind: CertKind, 65 | } 66 | 67 | fn default_cert_kind() -> CertKind { 68 | CertKind::Server 69 | } 70 | 71 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, ToSchema)] 72 | pub struct CertMetadata { 73 | pub acme_id: ShortId, 74 | #[serde( 75 | serialize_with = "serialize_created_at", 76 | deserialize_with = "deserialize_created_at" 77 | )] 78 | #[schema(value_type = u64)] 79 | pub created_at: SystemTime, 80 | } 81 | 82 | fn serialize_created_at(time: &SystemTime, serializer: S) -> Result 83 | where 84 | S: serde::Serializer, 85 | { 86 | let timestamp = time 87 | .duration_since(UNIX_EPOCH) 88 | .map_err(|_| serde::ser::Error::custom("invalid timestamp"))?; 89 | serializer.serialize_u64(timestamp.as_secs()) 90 | } 91 | 92 | fn deserialize_created_at<'de, D>(deserializer: D) -> Result 93 | where 94 | D: serde::Deserializer<'de>, 95 | { 96 | use serde::Deserialize; 97 | let timestamp = u64::deserialize(deserializer)?; 98 | Ok(UNIX_EPOCH + Duration::from_secs(timestamp)) 99 | } 100 | 101 | #[derive(ToSchema)] 102 | pub struct CertPostBody { 103 | #[schema(format = Binary)] 104 | pub chain: String, 105 | #[schema(format = Binary)] 106 | pub key: String, 107 | } 108 | -------------------------------------------------------------------------------- /taxy/src/server/cert_list.rs: -------------------------------------------------------------------------------- 1 | use crate::certs::Cert; 2 | use indexmap::IndexMap; 3 | use log::warn; 4 | use std::sync::Arc; 5 | use taxy_api::{cert::CertKind, error::Error, id::ShortId}; 6 | use tokio_rustls::rustls::RootCertStore; 7 | 8 | #[derive(Debug)] 9 | pub struct CertList { 10 | certs: IndexMap>, 11 | system_root_certs: RootCertStore, 12 | root_certs: RootCertStore, 13 | } 14 | 15 | impl CertList { 16 | pub async fn new>>(iter: I) -> Self { 17 | let mut certs = iter 18 | .into_iter() 19 | .map(|cert| (cert.id(), cert)) 20 | .collect::>(); 21 | certs.sort_unstable_by(|_, v1, _, v2| v1.partial_cmp(v2).unwrap()); 22 | 23 | let mut system_root_certs = RootCertStore::empty(); 24 | if let Ok(result) = 25 | tokio::task::spawn_blocking(rustls_native_certs::load_native_certs).await 26 | { 27 | for cert in result.certs { 28 | if let Err(err) = system_root_certs.add(cert) { 29 | warn!("failed to add native certs: {err}"); 30 | } 31 | } 32 | for error in result.errors { 33 | warn!("failed to load native certs: {error}"); 34 | } 35 | } 36 | 37 | let mut this = Self { 38 | certs, 39 | system_root_certs: system_root_certs.clone(), 40 | root_certs: RootCertStore::empty(), 41 | }; 42 | this.update_root_certs(); 43 | this 44 | } 45 | 46 | pub fn iter(&self) -> impl Iterator> { 47 | self.certs.values() 48 | } 49 | 50 | pub fn root_certs(&self) -> &RootCertStore { 51 | &self.root_certs 52 | } 53 | 54 | pub fn find_certs_by_acme(&self, acme: ShortId) -> Vec<&Arc> { 55 | self.certs 56 | .values() 57 | .filter(|cert| { 58 | cert.metadata 59 | .as_ref() 60 | .is_some_and(|meta| meta.acme_id == acme) 61 | }) 62 | .collect() 63 | } 64 | 65 | pub fn get(&self, id: ShortId) -> Option<&Arc> { 66 | self.certs.get(&id) 67 | } 68 | 69 | pub fn add(&mut self, cert: Arc) { 70 | self.certs.insert(cert.id(), cert.clone()); 71 | self.certs 72 | .sort_unstable_by(|_, v1, _, v2| v1.partial_cmp(v2).unwrap()); 73 | if cert.kind == CertKind::Root { 74 | self.update_root_certs(); 75 | } 76 | } 77 | 78 | pub fn delete(&mut self, id: ShortId) -> Result<(), Error> { 79 | if !self.certs.contains_key(&id) { 80 | Err(Error::IdNotFound { id: id.to_string() }) 81 | } else { 82 | if let Some(cert) = self.certs.swap_remove(&id) { 83 | if cert.kind == CertKind::Root { 84 | self.update_root_certs(); 85 | } 86 | } 87 | Ok(()) 88 | } 89 | } 90 | 91 | fn update_root_certs(&mut self) { 92 | let mut root_certs = self.system_root_certs.clone(); 93 | for cert in self.certs.values() { 94 | if cert.kind == CertKind::Root { 95 | if let Ok(certs) = cert.certificates() { 96 | for cert in certs { 97 | if let Err(err) = root_certs.add(cert) { 98 | warn!("failed to add root cert: {}", err); 99 | } 100 | } 101 | } 102 | } 103 | } 104 | self.root_certs = root_certs; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /taxy/src/server/rpc/certs.rs: -------------------------------------------------------------------------------- 1 | use super::RpcMethod; 2 | use crate::{certs::Cert, server::state::ServerState}; 3 | use flate2::{write::GzEncoder, Compression}; 4 | use hyper::body::Bytes; 5 | use std::{sync::Arc, time::SystemTime}; 6 | use tar::Header; 7 | use taxy_api::{cert::CertInfo, error::Error, id::ShortId}; 8 | 9 | pub struct GetCertList; 10 | 11 | #[async_trait::async_trait] 12 | impl RpcMethod for GetCertList { 13 | type Output = Vec; 14 | 15 | async fn call(self, state: &mut ServerState) -> Result { 16 | Ok(state.certs.iter().map(|item| item.info()).collect()) 17 | } 18 | } 19 | 20 | pub struct GetCert { 21 | pub id: ShortId, 22 | } 23 | 24 | #[async_trait::async_trait] 25 | impl RpcMethod for GetCert { 26 | type Output = Arc; 27 | 28 | async fn call(self, state: &mut ServerState) -> Result { 29 | state.certs.get(self.id).cloned().ok_or(Error::IdNotFound { 30 | id: self.id.to_string(), 31 | }) 32 | } 33 | } 34 | 35 | pub struct AddCert { 36 | pub cert: Arc, 37 | } 38 | 39 | #[async_trait::async_trait] 40 | impl RpcMethod for AddCert { 41 | type Output = (); 42 | 43 | async fn call(self, state: &mut ServerState) -> Result { 44 | state.certs.add(self.cert.clone()); 45 | state.update_certs().await; 46 | state.reload_proxies().await; 47 | state.storage.save_cert(&self.cert).await; 48 | Ok(()) 49 | } 50 | } 51 | 52 | pub struct DeleteCert { 53 | pub id: ShortId, 54 | } 55 | 56 | #[async_trait::async_trait] 57 | impl RpcMethod for DeleteCert { 58 | type Output = (); 59 | 60 | async fn call(self, state: &mut ServerState) -> Result { 61 | state.certs.delete(self.id)?; 62 | state.update_certs().await; 63 | state.reload_proxies().await; 64 | state.storage.delete_cert(self.id).await; 65 | Ok(()) 66 | } 67 | } 68 | 69 | pub struct DownloadCert { 70 | pub id: ShortId, 71 | } 72 | 73 | #[async_trait::async_trait] 74 | impl RpcMethod for DownloadCert { 75 | type Output = Bytes; 76 | 77 | async fn call(self, state: &mut ServerState) -> Result { 78 | state 79 | .certs 80 | .get(self.id) 81 | .map(|cert| cert_to_tar_gz(cert).unwrap()) 82 | .ok_or(Error::IdNotFound { 83 | id: self.id.to_string(), 84 | }) 85 | } 86 | } 87 | 88 | fn cert_to_tar_gz(cert: &Cert) -> anyhow::Result { 89 | let mut buf = Vec::::new(); 90 | 91 | { 92 | let enc = GzEncoder::new(&mut buf, Compression::default()); 93 | let mut tar = tar::Builder::new(enc); 94 | 95 | let mut chain = cert.pem_chain.as_slice(); 96 | 97 | let mtime = SystemTime::now() 98 | .duration_since(SystemTime::UNIX_EPOCH) 99 | .unwrap_or_default() 100 | .as_secs(); 101 | 102 | let mut header = Header::new_old(); 103 | header.set_size(chain.len() as _); 104 | header.set_mtime(mtime); 105 | header.set_mode(0o644); 106 | header.set_cksum(); 107 | tar.append_data(&mut header, "chain.pem", &mut chain)?; 108 | 109 | if let Some(key) = &cert.pem_key { 110 | let mut key = key.as_slice(); 111 | let mut header = Header::new_old(); 112 | header.set_size(key.len() as _); 113 | header.set_mtime(mtime); 114 | header.set_mode(0o644); 115 | header.set_cksum(); 116 | tar.append_data(&mut header, "key.pem", &mut key)?; 117 | } 118 | 119 | tar.finish()?; 120 | } 121 | 122 | Ok(buf.into()) 123 | } 124 | -------------------------------------------------------------------------------- /taxy-webui/src/components/navbar.rs: -------------------------------------------------------------------------------- 1 | use crate::pages::Route; 2 | use yew::prelude::*; 3 | use yew_router::prelude::*; 4 | 5 | struct MenuItem { 6 | name: &'static str, 7 | icon: &'static str, 8 | route: Route, 9 | } 10 | 11 | const ITEMS: &[MenuItem] = { 12 | &[ 13 | MenuItem { 14 | name: "Ports", 15 | icon: "/assets/icons/wifi.svg", 16 | route: Route::Ports, 17 | }, 18 | MenuItem { 19 | name: "Proxies", 20 | icon: "/assets/icons/swap-horizontal.svg", 21 | route: Route::Proxies, 22 | }, 23 | MenuItem { 24 | name: "Certificates", 25 | icon: "/assets/icons/ribbon.svg", 26 | route: Route::Certs, 27 | }, 28 | ] 29 | }; 30 | 31 | #[function_component(Navbar)] 32 | pub fn navbar() -> Html { 33 | let navigator = use_navigator().unwrap(); 34 | let route = use_route::().unwrap(); 35 | 36 | let navigator_cloned = navigator.clone(); 37 | let logout_onclick = Callback::from(move |e: MouseEvent| { 38 | e.prevent_default(); 39 | if gloo_dialogs::confirm("Are you sure to log out?") { 40 | navigator_cloned.push(&Route::Logout); 41 | } 42 | }); 43 | 44 | let navigator_cloned = navigator.clone(); 45 | let logo_onclick = Callback::from(move |e: MouseEvent| { 46 | e.prevent_default(); 47 | navigator_cloned.push(&Route::Home); 48 | }); 49 | 50 | if route == Route::Login { 51 | return html! {}; 52 | } 53 | 54 | html! { 55 | <> 56 |
57 |
58 | 59 | 60 | 61 | 62 | 63 | if let Some(root) = route.root() { 64 | { ITEMS.iter().map(|entry| { 65 | let navigator = navigator.clone(); 66 | let onclick = Callback::from(move |e: MouseEvent| { 67 | e.prevent_default(); 68 | navigator.push(&entry.route); 69 | }); 70 | let is_active = root == entry.route; 71 | let mut classes = vec!["px-4", "py-3", "border-neutral-800", "border-b-2", "inline-block", "cursor-pointer", "hover:bg-neutral-600", "text-md", "flex", "items-center"]; 72 | if is_active { 73 | classes.push("border-b-neutral-100"); 74 | classes.push("bg-neutral-700"); 75 | } 76 | html! { 77 | 78 | 79 | 80 | 81 | } 82 | }).collect::() } 83 | } 84 |
85 |
86 | 87 | 88 | 89 | 90 |
91 |
92 | 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /taxy/src/proxy/http/pool.rs: -------------------------------------------------------------------------------- 1 | use crate::proxy::http::{hyper_tls::client::HttpsConnector, HTTP2_MAX_FRAME_SIZE}; 2 | use bytes::Bytes; 3 | use http_body_util::{combinators::BoxBody, BodyExt, Full}; 4 | use hyper::{header::UPGRADE, Request, Response}; 5 | use hyper_util::{ 6 | client::legacy::{connect::HttpConnector, Client}, 7 | rt::{TokioExecutor, TokioIo}, 8 | }; 9 | use std::sync::Arc; 10 | use tokio_rustls::rustls::ClientConfig; 11 | use tracing::error; 12 | 13 | use super::rewriter::ResponseRewriter; 14 | 15 | pub struct ConnectionPool { 16 | pub client: Client, BoxBody>, 17 | } 18 | 19 | impl ConnectionPool { 20 | pub fn new(tls_client_config: Arc) -> Self { 21 | let https = HttpsConnector::new(tls_client_config.clone()); 22 | let client = Client::builder(TokioExecutor::new()) 23 | .http2_max_frame_size(Some(HTTP2_MAX_FRAME_SIZE as u32)) 24 | .build(https); 25 | Self { client } 26 | } 27 | 28 | pub async fn request( 29 | &self, 30 | mut req: Request>, 31 | ) -> Result>, anyhow::Error> { 32 | let upgrading_req = if req.headers().contains_key(UPGRADE) { 33 | let mut cloned_req = Request::builder().uri(req.uri()).body(BoxBody::< 34 | Bytes, 35 | anyhow::Error, 36 | >::new( 37 | Full::new(Bytes::new()).map_err(Into::into), 38 | ))?; 39 | cloned_req.headers_mut().clone_from(req.headers()); 40 | let mut cloned_req = Some(cloned_req); 41 | req = cloned_req.replace(req).unwrap(); 42 | cloned_req 43 | } else { 44 | None 45 | }; 46 | 47 | *req.version_mut() = hyper::Version::HTTP_11; 48 | 49 | let mut result: Result<_, anyhow::Error> = self 50 | .client 51 | .request(req) 52 | .await 53 | .map_err(|err| err.into()) 54 | .map(|res| res.map(|body| BoxBody::new(body.map_err(|err| err.into())))); 55 | 56 | match (&result, upgrading_req) { 57 | (Ok(res), Some(upgrading_req)) 58 | if res.status() == hyper::StatusCode::SWITCHING_PROTOCOLS => 59 | { 60 | let mut cloned_res = Response::builder().status(res.status()); 61 | cloned_res.headers_mut().unwrap().clone_from(res.headers()); 62 | 63 | let upgrading_res = std::mem::replace( 64 | &mut result, 65 | Ok(cloned_res 66 | .body(BoxBody::new(Full::new(Bytes::new()).map_err(Into::into)))?), 67 | ) 68 | .unwrap(); 69 | tokio::spawn(async move { 70 | upgrade_connection(upgrading_req, upgrading_res).await; 71 | }); 72 | } 73 | _ => (), 74 | } 75 | 76 | if let Err(err) = &result { 77 | error!(%err); 78 | } 79 | 80 | ResponseRewriter::default().map_response(result) 81 | } 82 | } 83 | 84 | async fn upgrade_connection( 85 | req: Request>, 86 | res: Response>, 87 | ) { 88 | match tokio::try_join!(hyper::upgrade::on(req), hyper::upgrade::on(res)) { 89 | Ok((req, res)) => { 90 | let mut req = TokioIo::new(req); 91 | let mut res = TokioIo::new(res); 92 | if let Err(err) = tokio::io::copy_bidirectional(&mut req, &mut res).await { 93 | error!("upgraded io error: {}", err); 94 | } 95 | } 96 | Err(err) => { 97 | error!("upgrading io error: {}", err); 98 | } 99 | }; 100 | } 101 | -------------------------------------------------------------------------------- /taxy-api/src/subject_name.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use rustls_pki_types::ServerName; 3 | use serde::{Deserialize, Serialize}; 4 | use std::{fmt::Display, net::IpAddr, str::FromStr}; 5 | 6 | #[derive(Debug, Clone, PartialEq, Eq)] 7 | pub enum SubjectName { 8 | DnsName(String), 9 | WildcardDnsName(String), 10 | IPAddress(IpAddr), 11 | } 12 | 13 | impl SubjectName { 14 | pub fn test(&self, name: &str) -> bool { 15 | match self { 16 | Self::DnsName(n) => n.eq_ignore_ascii_case(name), 17 | Self::WildcardDnsName(n) => n.eq_ignore_ascii_case( 18 | name.trim_start_matches(|c| c != '.') 19 | .trim_start_matches('.'), 20 | ), 21 | Self::IPAddress(addr) => match addr { 22 | IpAddr::V4(addr) => name.eq_ignore_ascii_case(&addr.to_string()), 23 | IpAddr::V6(addr) => name.eq_ignore_ascii_case(&addr.to_string()), 24 | }, 25 | } 26 | } 27 | } 28 | 29 | impl Serialize for SubjectName { 30 | fn serialize(&self, serializer: S) -> Result 31 | where 32 | S: serde::Serializer, 33 | { 34 | serializer.serialize_str(&self.to_string()) 35 | } 36 | } 37 | 38 | impl<'de> Deserialize<'de> for SubjectName { 39 | fn deserialize(deserializer: D) -> Result 40 | where 41 | D: serde::Deserializer<'de>, 42 | { 43 | let s = String::deserialize(deserializer)?; 44 | Self::from_str(&s).map_err(serde::de::Error::custom) 45 | } 46 | } 47 | 48 | impl Display for SubjectName { 49 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 50 | match self { 51 | Self::DnsName(name) => write!(f, "{}", name), 52 | Self::WildcardDnsName(name) => write!(f, "*.{}", name), 53 | Self::IPAddress(addr) => write!(f, "{}", addr), 54 | } 55 | } 56 | } 57 | 58 | impl FromStr for SubjectName { 59 | type Err = Error; 60 | 61 | fn from_str(s: &str) -> Result { 62 | if ServerName::try_from(s.replace('*', "a")).is_err() { 63 | return Err(Error::InvalidSubjectName { 64 | name: s.to_string(), 65 | }); 66 | } 67 | let wildcard = s.starts_with("*."); 68 | let name = s.trim_start_matches("*."); 69 | let ipaddr: Result = name.parse(); 70 | match ipaddr { 71 | Ok(addr) => Ok(Self::IPAddress(addr)), 72 | _ => { 73 | if wildcard { 74 | Ok(Self::WildcardDnsName(name.to_string())) 75 | } else { 76 | Ok(Self::DnsName(name.to_string())) 77 | } 78 | } 79 | } 80 | } 81 | } 82 | 83 | #[cfg(test)] 84 | mod test { 85 | use super::*; 86 | 87 | #[test] 88 | fn test_subject_name() { 89 | assert_eq!( 90 | SubjectName::from_str("*.example.com").unwrap(), 91 | SubjectName::WildcardDnsName("example.com".to_owned()) 92 | ); 93 | assert_eq!( 94 | SubjectName::from_str("example.com").unwrap(), 95 | SubjectName::DnsName("example.com".to_owned()) 96 | ); 97 | assert_eq!( 98 | SubjectName::from_str("127.0.0.1").unwrap(), 99 | SubjectName::IPAddress(IpAddr::V4([127, 0, 0, 1].into())) 100 | ) 101 | } 102 | 103 | #[test] 104 | fn test_subject_name_test() { 105 | assert!(SubjectName::from_str("*.example.com") 106 | .unwrap() 107 | .test("app.example.com")); 108 | assert!(SubjectName::from_str("example.com") 109 | .unwrap() 110 | .test("example.com")); 111 | assert!(SubjectName::from_str("127.0.0.1") 112 | .unwrap() 113 | .test("127.0.0.1")); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /taxy-webui/src/event.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | store::{AcmeStore, CertStore, PortStore, ProxyStore}, 3 | API_ENDPOINT, 4 | }; 5 | use futures::StreamExt; 6 | use gloo_net::eventsource::futures::EventSource; 7 | use gloo_timers::callback::Timeout; 8 | use gloo_utils::format::JsValueSerdeExt; 9 | use serde_derive::{Deserialize, Serialize}; 10 | use taxy_api::event::ServerEvent; 11 | use wasm_bindgen_futures::spawn_local; 12 | use yew::prelude::*; 13 | use yewdux::prelude::*; 14 | 15 | #[derive(Default, Clone, PartialEq, Serialize, Deserialize, Store)] 16 | struct EventSession { 17 | active: bool, 18 | } 19 | 20 | #[hook] 21 | pub fn use_event_subscriber() { 22 | let (event, dispatcher) = use_store::(); 23 | let (_, ports) = use_store::(); 24 | let (_, certs) = use_store::(); 25 | let (_, acme) = use_store::(); 26 | let (_, proxies) = use_store::(); 27 | if !event.active { 28 | let mut es = EventSource::new(&format!("{API_ENDPOINT}/events")).unwrap(); 29 | let mut stream = es.subscribe("message").unwrap(); 30 | 31 | dispatcher.set(EventSession { active: true }); 32 | spawn_local(async move { 33 | let _es = es; 34 | while let Some(Ok((_, msg))) = stream.next().await { 35 | if let Ok(s) = msg.data().into_serde::() { 36 | if let Ok(event) = serde_json::from_str::(&s) { 37 | match event { 38 | ServerEvent::PortTableUpdated { entries } => { 39 | ports.reduce(|state| { 40 | PortStore { 41 | entries, 42 | ..(*state).clone() 43 | } 44 | .into() 45 | }); 46 | } 47 | ServerEvent::CertsUpdated { entries } => { 48 | certs.set(CertStore { 49 | entries, 50 | loaded: true, 51 | }); 52 | } 53 | ServerEvent::AcmeUpdated { entries } => { 54 | acme.set(AcmeStore { 55 | entries, 56 | loaded: true, 57 | }); 58 | } 59 | ServerEvent::ProxiesUpdated { entries } => { 60 | proxies.reduce(|state| { 61 | ProxyStore { 62 | entries, 63 | loaded: true, 64 | ..(*state).clone() 65 | } 66 | .into() 67 | }); 68 | } 69 | ServerEvent::PortStatusUpdated { id, status } => { 70 | ports.reduce(|state| { 71 | let mut cloned = (*state).clone(); 72 | cloned.statuses.insert(id, status); 73 | cloned.into() 74 | }); 75 | } 76 | ServerEvent::ProxyStatusUpdated { id, status } => { 77 | proxies.reduce(|state| { 78 | let mut cloned = (*state).clone(); 79 | cloned.statuses.insert(id, status); 80 | cloned.into() 81 | }); 82 | } 83 | _ => (), 84 | } 85 | } 86 | } 87 | } 88 | Timeout::new(5000, move || { 89 | dispatcher.set(EventSession { active: false }); 90 | }) 91 | .forget(); 92 | }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /taxy/src/proxy/tls.rs: -------------------------------------------------------------------------------- 1 | use crate::certs::Cert; 2 | use crate::server::cert_list::CertList; 3 | use dashmap::DashMap; 4 | use std::fmt; 5 | use std::str::FromStr; 6 | use std::sync::Arc; 7 | use taxy_api::cert::CertKind; 8 | use taxy_api::error::Error; 9 | use taxy_api::id::ShortId; 10 | use taxy_api::subject_name::SubjectName; 11 | use taxy_api::tls::TlsState; 12 | use tokio_rustls::rustls::server::{ClientHello, ResolvesServerCert}; 13 | use tokio_rustls::rustls::sign::CertifiedKey; 14 | use tokio_rustls::rustls::ServerConfig; 15 | use tokio_rustls::TlsAcceptor; 16 | use tracing::error; 17 | 18 | pub struct TlsTermination { 19 | pub server_names: Vec, 20 | pub acceptor: Option, 21 | pub alpn_protocols: Vec>, 22 | } 23 | 24 | impl fmt::Debug for TlsTermination { 25 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 26 | f.debug_struct("TlsTermination") 27 | .field("server_names", &self.server_names) 28 | .finish() 29 | } 30 | } 31 | 32 | impl TlsTermination { 33 | pub fn new( 34 | config: &taxy_api::tls::TlsTermination, 35 | alpn_protocols: Vec>, 36 | ) -> Result { 37 | let mut server_names = Vec::new(); 38 | for name in &config.server_names { 39 | let name = SubjectName::from_str(name)?; 40 | server_names.push(name); 41 | } 42 | Ok(Self { 43 | server_names, 44 | acceptor: None, 45 | alpn_protocols, 46 | }) 47 | } 48 | 49 | pub async fn setup(&mut self, certs: &CertList) -> TlsState { 50 | let resolver: Arc = Arc::new(CertResolver::new( 51 | certs 52 | .iter() 53 | .filter(|cert| cert.kind == CertKind::Server) 54 | .cloned() 55 | .collect(), 56 | self.server_names.clone(), 57 | true, 58 | )); 59 | 60 | let mut server_config = ServerConfig::builder() 61 | .with_no_client_auth() 62 | .with_cert_resolver(resolver); 63 | server_config 64 | .alpn_protocols 65 | .clone_from(&self.alpn_protocols); 66 | 67 | let server_config = Arc::new(server_config); 68 | self.acceptor = Some(TlsAcceptor::from(server_config)); 69 | 70 | TlsState::Active 71 | } 72 | } 73 | 74 | #[derive(Debug, Default)] 75 | pub struct CertResolver { 76 | certs: Vec>, 77 | default_names: Vec, 78 | sni: bool, 79 | cache: DashMap>, 80 | } 81 | 82 | impl CertResolver { 83 | pub fn new(certs: Vec>, default_names: Vec, sni: bool) -> Self { 84 | Self { 85 | certs, 86 | default_names, 87 | sni, 88 | cache: DashMap::new(), 89 | } 90 | } 91 | } 92 | 93 | impl ResolvesServerCert for CertResolver { 94 | fn resolve(&self, client_hello: ClientHello) -> Option> { 95 | let sni = client_hello 96 | .server_name() 97 | .filter(|_| self.sni) 98 | .map(|sni| SubjectName::DnsName(sni.into())) 99 | .into_iter() 100 | .collect::>(); 101 | 102 | let names = if sni.is_empty() { 103 | &self.default_names 104 | } else { 105 | &sni 106 | }; 107 | 108 | let cert = self 109 | .certs 110 | .iter() 111 | .find(|cert| cert.is_valid() && names.iter().all(|name| cert.has_subject_name(name)))?; 112 | 113 | if let Some(cert) = self.cache.get(&cert.id()) { 114 | Some(cert.clone()) 115 | } else { 116 | let certified = match cert.certified_key() { 117 | Ok(certified) => Arc::new(certified), 118 | Err(err) => { 119 | error!("failed to load certified key: {}", err); 120 | return None; 121 | } 122 | }; 123 | self.cache.insert(cert.id(), certified.clone()); 124 | Some(certified) 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /taxy/src/proxy/http/hyper_tls/stream.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | use std::io; 3 | use std::io::IoSlice; 4 | use std::pin::Pin; 5 | use std::task::{Context, Poll}; 6 | 7 | use hyper::rt::{Read, ReadBufCursor, Write}; 8 | use hyper_util::{ 9 | client::legacy::connect::{Connected, Connection}, 10 | rt::TokioIo, 11 | }; 12 | pub use tokio_rustls::client::TlsStream; 13 | 14 | /// A stream that might be protected with TLS. 15 | #[allow(clippy::large_enum_variant)] 16 | pub enum MaybeHttpsStream { 17 | /// A stream over plain text. 18 | Http(T), 19 | /// A stream protected with TLS. 20 | Https(TokioIo>>), 21 | } 22 | 23 | // ===== impl MaybeHttpsStream ===== 24 | 25 | impl fmt::Debug for MaybeHttpsStream { 26 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 27 | match self { 28 | MaybeHttpsStream::Http(s) => f.debug_tuple("Http").field(s).finish(), 29 | MaybeHttpsStream::Https(s) => f.debug_tuple("Https").field(s).finish(), 30 | } 31 | } 32 | } 33 | 34 | impl From for MaybeHttpsStream { 35 | fn from(inner: T) -> Self { 36 | MaybeHttpsStream::Http(inner) 37 | } 38 | } 39 | 40 | impl From>> for MaybeHttpsStream { 41 | fn from(inner: TlsStream>) -> Self { 42 | MaybeHttpsStream::Https(TokioIo::new(inner)) 43 | } 44 | } 45 | 46 | impl From>>> for MaybeHttpsStream { 47 | fn from(inner: TokioIo>>) -> Self { 48 | MaybeHttpsStream::Https(inner) 49 | } 50 | } 51 | 52 | impl Read for MaybeHttpsStream { 53 | #[inline] 54 | fn poll_read( 55 | self: Pin<&mut Self>, 56 | cx: &mut Context, 57 | buf: ReadBufCursor<'_>, 58 | ) -> Poll> { 59 | match Pin::get_mut(self) { 60 | MaybeHttpsStream::Http(s) => Pin::new(s).poll_read(cx, buf), 61 | MaybeHttpsStream::Https(s) => Pin::new(s).poll_read(cx, buf), 62 | } 63 | } 64 | } 65 | 66 | impl Write for MaybeHttpsStream { 67 | #[inline] 68 | fn poll_write( 69 | self: Pin<&mut Self>, 70 | cx: &mut Context<'_>, 71 | buf: &[u8], 72 | ) -> Poll> { 73 | match Pin::get_mut(self) { 74 | MaybeHttpsStream::Http(s) => Pin::new(s).poll_write(cx, buf), 75 | MaybeHttpsStream::Https(s) => Pin::new(s).poll_write(cx, buf), 76 | } 77 | } 78 | 79 | fn poll_write_vectored( 80 | self: Pin<&mut Self>, 81 | cx: &mut Context<'_>, 82 | bufs: &[IoSlice<'_>], 83 | ) -> Poll> { 84 | match Pin::get_mut(self) { 85 | MaybeHttpsStream::Http(s) => Pin::new(s).poll_write_vectored(cx, bufs), 86 | MaybeHttpsStream::Https(s) => Pin::new(s).poll_write_vectored(cx, bufs), 87 | } 88 | } 89 | 90 | fn is_write_vectored(&self) -> bool { 91 | match self { 92 | MaybeHttpsStream::Http(s) => s.is_write_vectored(), 93 | MaybeHttpsStream::Https(s) => s.is_write_vectored(), 94 | } 95 | } 96 | 97 | #[inline] 98 | fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 99 | match Pin::get_mut(self) { 100 | MaybeHttpsStream::Http(s) => Pin::new(s).poll_flush(cx), 101 | MaybeHttpsStream::Https(s) => Pin::new(s).poll_flush(cx), 102 | } 103 | } 104 | 105 | #[inline] 106 | fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 107 | match Pin::get_mut(self) { 108 | MaybeHttpsStream::Http(s) => Pin::new(s).poll_shutdown(cx), 109 | MaybeHttpsStream::Https(s) => Pin::new(s).poll_shutdown(cx), 110 | } 111 | } 112 | } 113 | 114 | impl Connection for MaybeHttpsStream { 115 | fn connected(&self) -> Connected { 116 | match self { 117 | MaybeHttpsStream::Http(s) => s.connected(), 118 | MaybeHttpsStream::Https(s) => s.inner().get_ref().0.connected(), 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /taxy/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "taxy" 3 | version = "0.3.40" 4 | edition = "2021" 5 | include = ["/src", "/templates", "/build.rs", "/LICENSE", "/dist/webui"] 6 | build = "build.rs" 7 | description = "A reverse proxy server with built-in WebUI, supporting TCP/UDP/HTTP/TLS/WebSocket." 8 | authors = ["picoHz "] 9 | keywords = ["tcp", "http", "tls", "proxy", "reverse-proxy"] 10 | categories = [ 11 | "network-programming", 12 | "web-programming", 13 | "web-programming::websocket", 14 | ] 15 | license = "MIT" 16 | repository = "https://github.com/picoHz/taxy" 17 | homepage = "https://taxy.dev/" 18 | readme = "../README.md" 19 | default-run = "taxy" 20 | 21 | [dependencies] 22 | anyhow = "1.0.71" 23 | arc-swap = "1.6.0" 24 | argon2 = "0.5.0" 25 | async-trait = "0.1.71" 26 | axum = { version = "0.8.1", features = ["ws", "multipart"] } 27 | axum-extra = { version = "0.10.0", features = ["cookie"] } 28 | axum-server = { version = "0.7.1", features = ["tls-rustls-no-provider"] } 29 | backoff = { version = "0.4.0", features = ["tokio"] } 30 | base64 = "0.22.1" 31 | bytes = "1.8.0" 32 | clap = { version = "4.3.11", features = ["derive", "env"] } 33 | dashmap = "6.0.1" 34 | directories = "6.0.0" 35 | flate2 = "1.0.26" 36 | fnv = "1.0.7" 37 | futures = "0.3.28" 38 | globwalk = "0.9.1" 39 | h3 = "0.0.7" 40 | h3-quinn = "0.0.9" 41 | hex = "0.4.3" 42 | hickory-resolver = { version = "0.24.1", features = [ 43 | "tokio-runtime", 44 | "system-config", 45 | ] } 46 | http-body-util = "0.1.2" 47 | humantime-serde = "1.1.1" 48 | hyper = { version = "1.4.1", features = ["full"] } 49 | hyper-util = { version = "0.1.10", features = [ 50 | "full", 51 | "http1", 52 | "http2", 53 | "server", 54 | ] } 55 | include_dir = "0.7.3" 56 | indexmap = { version = "2.0.0", features = ["serde"] } 57 | instant-acme = "0.7.1" 58 | log = "0.4.19" 59 | mime_guess = "2.0.4" 60 | network-interface = "2.0.0" 61 | once_cell = "1.18.0" 62 | percent-encoding = "2.3.0" 63 | phf = { version = "0.11.2", features = ["macros"] } 64 | pin-project-lite = "0.2.10" 65 | pkcs8 = { version = "0.10.2", features = ["pem"] } 66 | quinn = { version = "0.11.6", default-features = false, features = [ 67 | "runtime-tokio", 68 | "rustls", 69 | "ring", 70 | ] } 71 | rand = "0.8.5" 72 | rcgen = { version = "0.13.0", features = ["pem", "x509-parser"] } 73 | rpassword = "7.2.0" 74 | rustls-native-certs = "0.8.0" 75 | rustls-pemfile = "2.0.0" 76 | sailfish = "0.9.0" 77 | serde = { version = "1.0.171", features = ["rc"] } 78 | serde_default = "0.2.0" 79 | serde_derive = "1.0.171" 80 | serde_json = "1.0.102" 81 | serde_qs = "0.14.0" 82 | sha2 = "0.10.7" 83 | shellexpand = "3.1.0" 84 | socket2 = "0.5.9" 85 | sqlx = { version = "0.8.2", features = [ 86 | "runtime-tokio-rustls", 87 | "sqlite", 88 | "time", 89 | ] } 90 | tar = "0.4.38" 91 | taxy-api = { version = "0.2.2" } 92 | thiserror = "2.0.0" 93 | time = { version = "0.3.36", features = ["serde"] } 94 | tokio = { version = "1.29.1", features = [ 95 | "macros", 96 | "rt-multi-thread", 97 | "net", 98 | "signal", 99 | "io-util", 100 | ] } 101 | tokio-rustls = { version = "0.26.0", default-features = false, features = [ 102 | "tls12", 103 | "ring", 104 | ] } 105 | tokio-stream = { version = "0.1.14", features = ["sync", "net"] } 106 | toml = "0.8.8" 107 | toml_edit = { version = "0.22.9", features = ["serde"] } 108 | totp-rs = { version = "5.1.0", features = ["gen_secret", "zeroize"] } 109 | tower-service = "0.3.3" 110 | tower_governor = "0.6.0" 111 | tracing = { version = "0.1.37", features = ["release_max_level_info"] } 112 | tracing-appender = "0.2.2" 113 | tracing-subscriber = { version = "0.3.17", features = ["json"] } 114 | url = { version = "2.4.0", features = ["serde"] } 115 | utoipa = "5.2.0" 116 | webpki = "0.22.4" 117 | x509-parser = "0.17.0" 118 | 119 | [build-dependencies] 120 | built = "0.6.1" 121 | 122 | [dev-dependencies] 123 | mockito = "1.6.1" 124 | net2 = "0.2.39" 125 | reqwest = { version = "0.12.1", default-features = false, features = [ 126 | "rustls-tls", 127 | "gzip", 128 | "brotli", 129 | "json", 130 | "stream", 131 | "http2", 132 | "hickory-dns", 133 | ] } 134 | tokio-tungstenite = { version = "0.26.0", features = [ 135 | "rustls-tls-native-roots", 136 | ] } 137 | -------------------------------------------------------------------------------- /taxy/src/main.rs: -------------------------------------------------------------------------------- 1 | #![forbid(unsafe_code)] 2 | 3 | use clap::Parser; 4 | use directories::ProjectDirs; 5 | use std::fs; 6 | use std::path::PathBuf; 7 | use taxy::args::Command; 8 | use taxy::args::StartArgs; 9 | use taxy::config::file::FileStorage; 10 | use taxy::config::new_appinfo; 11 | use taxy::config::storage::Storage; 12 | use taxy::log::DatabaseLayer; 13 | use taxy::server::Server; 14 | use tracing::{error, info}; 15 | use tracing_subscriber::filter::{self, FilterExt}; 16 | use tracing_subscriber::prelude::*; 17 | 18 | #[tokio::main] 19 | async fn main() -> anyhow::Result<()> { 20 | let args = taxy::args::Cli::parse(); 21 | 22 | match args.command { 23 | Command::Start(args) => start(args).await?, 24 | Command::AddUser(args) => add_user(args).await?, 25 | } 26 | 27 | Ok(()) 28 | } 29 | 30 | async fn start(args: StartArgs) -> anyhow::Result<()> { 31 | let log_dir = get_log_dir(args.log_dir)?; 32 | fs::create_dir_all(&log_dir)?; 33 | 34 | let (log, _guard) = taxy::log::create_layer( 35 | &log_dir, 36 | args.log, 37 | "taxy.log", 38 | args.log_level, 39 | args.log_format, 40 | )?; 41 | let (access_log, _guard) = taxy::log::create_layer( 42 | &log_dir, 43 | args.access_log, 44 | "access.log", 45 | args.access_log_level, 46 | args.log_format, 47 | )?; 48 | let db = DatabaseLayer::new(&log_dir.join("log.db"), args.log_level).await?; 49 | 50 | let access_log_filter = 51 | filter::filter_fn(|metadata| metadata.target().starts_with("taxy::access_log")); 52 | let is_span = filter::filter_fn(|metadata| metadata.is_span()); 53 | tracing_subscriber::registry() 54 | .with(log.with_filter(access_log_filter.clone().not())) 55 | .with(access_log.with_filter(access_log_filter.or(is_span))) 56 | .with(db) 57 | .init(); 58 | 59 | let config_dir = get_config_dir(args.config_dir)?; 60 | fs::create_dir_all(&config_dir)?; 61 | 62 | let config = FileStorage::new(&config_dir); 63 | let app_info = new_appinfo(&config_dir, &log_dir); 64 | 65 | let (server, channels) = Server::new(app_info.clone(), config).await; 66 | let server_task = tokio::spawn(server.start()); 67 | let event_send = channels.event.clone(); 68 | 69 | let webui_enabled = !args.no_webui; 70 | tokio::select! { 71 | r = taxy::admin::start_admin(app_info, args.webui, channels.command, channels.callback, channels.event), if webui_enabled => { 72 | if let Err(err) = r { 73 | error!("admin error: {}", err); 74 | } 75 | } 76 | _ = tokio::signal::ctrl_c() => { 77 | info!("received ctrl-c signal"); 78 | } 79 | }; 80 | 81 | let _ = event_send.send(taxy_api::event::ServerEvent::Shutdown); 82 | server_task.await??; 83 | 84 | Ok(()) 85 | } 86 | 87 | async fn add_user(args: taxy::args::AddUserArgs) -> anyhow::Result<()> { 88 | let config_dir = get_config_dir(args.config_dir)?; 89 | let config = FileStorage::new(&config_dir); 90 | let password = if let Some(password) = args.password { 91 | password 92 | } else { 93 | rpassword::prompt_password("password?: ")? 94 | }; 95 | let account = config.add_account(&args.name, &password, args.totp).await?; 96 | if let Some(totp) = account.totp { 97 | println!("\nUse this code to setup your TOTP client:\n{totp}\n"); 98 | } 99 | Ok(()) 100 | } 101 | 102 | fn get_config_dir(dir: Option) -> anyhow::Result { 103 | if let Some(dir) = dir { 104 | Ok(dir) 105 | } else { 106 | let dir = ProjectDirs::from("", "", "taxy").ok_or_else(|| { 107 | anyhow::anyhow!("failed to get project directories, try setting --config-dir") 108 | })?; 109 | Ok(dir.config_dir().to_owned()) 110 | } 111 | } 112 | 113 | fn get_log_dir(dir: Option) -> anyhow::Result { 114 | if let Some(dir) = dir { 115 | Ok(dir) 116 | } else { 117 | let dir = ProjectDirs::from("", "", "taxy").ok_or_else(|| { 118 | anyhow::anyhow!("failed to get project directories, try setting --log-dir") 119 | })?; 120 | Ok(dir.data_dir().join("logs")) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /taxy-api/src/port.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | id::ShortId, 3 | multiaddr::Multiaddr, 4 | tls::{TlsState, TlsTermination}, 5 | }; 6 | use serde_derive::{Deserialize, Serialize}; 7 | use std::{ 8 | net::IpAddr, 9 | time::{Duration, SystemTime}, 10 | }; 11 | use utoipa::ToSchema; 12 | 13 | #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] 14 | #[serde(rename_all = "snake_case")] 15 | pub enum SocketState { 16 | Listening, 17 | Inactive, 18 | AddressAlreadyInUse, 19 | PermissionDenied, 20 | AddressNotAvailable, 21 | Error, 22 | #[default] 23 | Unknown, 24 | } 25 | 26 | #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] 27 | pub struct PortStatus { 28 | pub state: PortState, 29 | #[serde( 30 | serialize_with = "serialize_started_at", 31 | deserialize_with = "deserialize_started_at" 32 | )] 33 | #[schema(value_type = Option)] 34 | pub started_at: Option, 35 | } 36 | 37 | #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] 38 | pub struct PortState { 39 | pub socket: SocketState, 40 | pub tls: Option, 41 | } 42 | 43 | fn serialize_started_at( 44 | started_at: &Option, 45 | serializer: S, 46 | ) -> Result 47 | where 48 | S: serde::Serializer, 49 | { 50 | if let Some(started_at) = started_at { 51 | let started_at = started_at 52 | .duration_since(SystemTime::UNIX_EPOCH) 53 | .unwrap() 54 | .as_secs(); 55 | serializer.serialize_some(&started_at) 56 | } else { 57 | serializer.serialize_none() 58 | } 59 | } 60 | 61 | fn deserialize_started_at<'de, D>(deserializer: D) -> Result, D::Error> 62 | where 63 | D: serde::Deserializer<'de>, 64 | { 65 | use serde::Deserialize; 66 | let started_at = Option::::deserialize(deserializer)?; 67 | if let Some(started_at) = started_at { 68 | Ok(Some( 69 | SystemTime::UNIX_EPOCH + Duration::from_secs(started_at), 70 | )) 71 | } else { 72 | Ok(None) 73 | } 74 | } 75 | 76 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] 77 | pub struct UpstreamServer { 78 | #[schema(value_type = String, example = "/dns/example.com/tcp/8080")] 79 | pub addr: Multiaddr, 80 | } 81 | 82 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] 83 | pub struct PortEntry { 84 | pub id: ShortId, 85 | #[schema(inline)] 86 | #[serde(flatten)] 87 | pub port: Port, 88 | } 89 | 90 | impl From<(ShortId, Port)> for PortEntry { 91 | fn from((id, port): (ShortId, Port)) -> Self { 92 | Self { id, port } 93 | } 94 | } 95 | 96 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] 97 | pub struct Port { 98 | #[serde(default = "default_active", skip_serializing_if = "is_true")] 99 | pub active: bool, 100 | #[serde(default, skip_serializing_if = "String::is_empty")] 101 | pub name: String, 102 | #[schema(value_type = String, example = "/ip4/127.0.0.1/tcp/8080")] 103 | pub listen: Multiaddr, 104 | #[serde(flatten, default)] 105 | pub opts: PortOptions, 106 | } 107 | 108 | fn default_active() -> bool { 109 | true 110 | } 111 | 112 | fn is_true(b: &bool) -> bool { 113 | *b 114 | } 115 | 116 | impl From for (ShortId, Port) { 117 | fn from(entry: PortEntry) -> Self { 118 | (entry.id, entry.port) 119 | } 120 | } 121 | 122 | #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] 123 | pub struct PortOptions { 124 | #[serde(default, skip_serializing_if = "Option::is_none")] 125 | pub tls_termination: Option, 126 | } 127 | 128 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] 129 | pub struct NetworkInterface { 130 | pub name: String, 131 | pub addrs: Vec, 132 | pub mac: Option, 133 | } 134 | 135 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] 136 | pub struct NetworkAddr { 137 | #[schema(value_type = String, example = "127.0.0.1")] 138 | pub ip: IpAddr, 139 | #[schema(value_type = String, example = "255.255.255.0")] 140 | pub mask: Option, 141 | } 142 | -------------------------------------------------------------------------------- /taxy/src/server/proxy_list.rs: -------------------------------------------------------------------------------- 1 | use indexmap::map::Entry; 2 | use indexmap::IndexMap; 3 | use taxy_api::error::Error; 4 | use taxy_api::id::ShortId; 5 | use taxy_api::port::PortEntry; 6 | use taxy_api::proxy::{Proxy, ProxyEntry, ProxyKind, ProxyState, ProxyStatus}; 7 | 8 | #[derive(Debug)] 9 | pub struct ProxyContext { 10 | pub entry: ProxyEntry, 11 | pub status: ProxyStatus, 12 | } 13 | 14 | impl ProxyContext { 15 | fn new(entry: ProxyEntry) -> Self { 16 | let state = if entry.proxy.active && !entry.proxy.ports.is_empty() { 17 | ProxyState::Active 18 | } else { 19 | ProxyState::Inactive 20 | }; 21 | Self { 22 | entry, 23 | status: ProxyStatus { state }, 24 | } 25 | } 26 | } 27 | 28 | #[derive(Debug, Default)] 29 | pub struct ProxyList { 30 | entries: IndexMap, 31 | } 32 | 33 | impl FromIterator for ProxyList { 34 | fn from_iter>(iter: I) -> Self { 35 | Self { 36 | entries: iter 37 | .into_iter() 38 | .map(|proxy| (proxy.id, ProxyContext::new(proxy))) 39 | .collect(), 40 | } 41 | } 42 | } 43 | 44 | impl ProxyList { 45 | pub fn get(&self, id: ShortId) -> Option<&ProxyContext> { 46 | self.entries.get(&id) 47 | } 48 | 49 | pub fn entries(&self) -> impl Iterator { 50 | self.entries.values().map(|ctx| &ctx.entry) 51 | } 52 | 53 | pub fn contexts(&self) -> impl Iterator { 54 | self.entries.values() 55 | } 56 | 57 | pub fn set(&mut self, entry: ProxyEntry) -> bool { 58 | self.remove_deplicate_ports(&entry.proxy); 59 | match self.entries.entry(entry.id) { 60 | Entry::Occupied(mut e) => { 61 | if e.get().entry.proxy != entry.proxy { 62 | e.insert(ProxyContext::new(entry)); 63 | true 64 | } else { 65 | false 66 | } 67 | } 68 | Entry::Vacant(inner) => { 69 | inner.insert(ProxyContext::new(entry)); 70 | true 71 | } 72 | } 73 | } 74 | 75 | pub fn delete(&mut self, id: ShortId) -> Result<(), Error> { 76 | if !self.entries.contains_key(&id) { 77 | Err(Error::IdNotFound { id: id.to_string() }) 78 | } else { 79 | self.entries.swap_remove(&id); 80 | Ok(()) 81 | } 82 | } 83 | 84 | pub fn remove_incompatible_ports(&mut self, ports: &[PortEntry]) -> bool { 85 | let mut changed = false; 86 | for ctx in self.entries.values_mut() { 87 | let len = ctx.entry.proxy.ports.len(); 88 | ctx.entry.proxy.ports = ctx 89 | .entry 90 | .proxy 91 | .ports 92 | .drain(..) 93 | .filter(|port| { 94 | ports 95 | .iter() 96 | .find(|p| p.id == *port) 97 | .map(|port| match ctx.entry.proxy.kind { 98 | ProxyKind::Http(_) => port.port.listen.is_http(), 99 | ProxyKind::Tcp(_) => { 100 | !port.port.listen.is_udp() && !port.port.listen.is_http() 101 | } 102 | ProxyKind::Udp(_) => { 103 | port.port.listen.is_udp() && !port.port.listen.is_http() 104 | } 105 | }) 106 | .unwrap_or_default() 107 | }) 108 | .collect(); 109 | changed |= len != ctx.entry.proxy.ports.len(); 110 | } 111 | changed 112 | } 113 | 114 | fn remove_deplicate_ports(&mut self, proxy: &Proxy) { 115 | if let ProxyKind::Tcp(_) = &proxy.kind { 116 | for ctx in self.entries.values_mut() { 117 | ctx.entry.proxy.ports = ctx 118 | .entry 119 | .proxy 120 | .ports 121 | .drain(..) 122 | .filter(|port| !proxy.ports.contains(port)) 123 | .collect(); 124 | } 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /taxy-webui/src/pages/port_view.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crate::{ 4 | auth::use_ensure_auth, components::port_config::PortConfig, pages::Route, store::PortStore, 5 | API_ENDPOINT, 6 | }; 7 | use gloo_net::http::Request; 8 | use taxy_api::{ 9 | id::ShortId, 10 | port::{Port, PortEntry}, 11 | }; 12 | use yew::prelude::*; 13 | use yew_router::prelude::*; 14 | use yewdux::prelude::*; 15 | 16 | #[derive(Properties, PartialEq)] 17 | pub struct Props { 18 | pub id: ShortId, 19 | } 20 | 21 | #[function_component(PortView)] 22 | pub fn port_view(props: &Props) -> Html { 23 | use_ensure_auth(); 24 | 25 | let (ports, _) = use_store::(); 26 | let port = use_state(|| ports.entries.iter().find(|e| e.id == props.id).cloned()); 27 | let id = props.id; 28 | let port_cloned = port.clone(); 29 | use_effect_with((),move |_| { 30 | wasm_bindgen_futures::spawn_local(async move { 31 | if let Ok(entry) = get_port(id).await { 32 | port_cloned.set(Some(entry)); 33 | } 34 | }); 35 | }); 36 | 37 | let navigator = use_navigator().unwrap(); 38 | 39 | let navigator_cloned = navigator.clone(); 40 | let cancel_onclick = Callback::from(move |_| { 41 | navigator_cloned.push(&Route::Ports); 42 | }); 43 | 44 | let entry = use_state::>, _>(|| Err(Default::default())); 45 | let entry_cloned = entry.clone(); 46 | let onchanged: Callback>> = 47 | Callback::from(move |updated| { 48 | entry_cloned.set(updated); 49 | }); 50 | 51 | let is_loading = use_state(|| false); 52 | 53 | let id = props.id; 54 | let entry_cloned = entry.clone(); 55 | let is_loading_cloned = is_loading; 56 | let onsubmit = Callback::from(move |event: SubmitEvent| { 57 | event.prevent_default(); 58 | if *is_loading_cloned { 59 | return; 60 | } 61 | let navigator = navigator.clone(); 62 | let is_loading_cloned = is_loading_cloned.clone(); 63 | if let Ok(entry) = (*entry_cloned).clone() { 64 | is_loading_cloned.set(true); 65 | wasm_bindgen_futures::spawn_local(async move { 66 | if update_port(id, &entry).await.is_ok() { 67 | navigator.push(&Route::Ports); 68 | } 69 | is_loading_cloned.set(false); 70 | }); 71 | } 72 | }); 73 | 74 | html! { 75 | <> 76 | if let Some(port_entry) = &*port { 77 |
78 | 79 | 80 |
81 | 84 | 87 |
88 | 89 | } else { 90 | to={Route::Ports}/> 91 | } 92 | 93 | } 94 | } 95 | 96 | async fn get_port(id: ShortId) -> Result { 97 | Request::get(&format!("{API_ENDPOINT}/ports/{id}")) 98 | .send() 99 | .await? 100 | .json() 101 | .await 102 | } 103 | 104 | async fn update_port(id: ShortId, entry: &Port) -> Result<(), gloo_net::Error> { 105 | Request::put(&format!("{API_ENDPOINT}/ports/{id}")) 106 | .json(entry)? 107 | .send() 108 | .await? 109 | .json() 110 | .await 111 | } 112 | -------------------------------------------------------------------------------- /taxy/tests/wss_test.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::{ws::WebSocket, WebSocketUpgrade}, 3 | response::IntoResponse, 4 | routing::any, 5 | Router, 6 | }; 7 | use axum_server::tls_rustls::RustlsConfig; 8 | use core::panic; 9 | use futures::{SinkExt, StreamExt}; 10 | use hyper::Uri; 11 | use std::sync::Arc; 12 | use taxy::certs::Cert; 13 | use taxy_api::{ 14 | port::{Port, PortEntry, PortOptions}, 15 | proxy::{HttpProxy, Proxy, ProxyEntry, ProxyKind, Route}, 16 | tls::TlsTermination, 17 | }; 18 | use tokio_rustls::rustls::{client::ClientConfig, RootCertStore}; 19 | use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message, Connector}; 20 | 21 | mod common; 22 | use common::{alloc_tcp_port, with_server, TestStorage}; 23 | 24 | #[tokio::test] 25 | async fn wss_proxy() -> anyhow::Result<()> { 26 | let listen_port = alloc_tcp_port().await?; 27 | let proxy_port = alloc_tcp_port().await?; 28 | 29 | let root = Arc::new(Cert::new_ca().unwrap()); 30 | let cert = Arc::new(Cert::new_self_signed(&["localhost".parse().unwrap()], &root).unwrap()); 31 | 32 | let config = RustlsConfig::from_pem( 33 | cert.pem_chain.to_vec(), 34 | cert.pem_key.as_ref().unwrap().to_vec(), 35 | ) 36 | .await 37 | .unwrap(); 38 | let app = Router::new().route("/ws", any(ws_handler)); 39 | let addr = listen_port.socket_addr(); 40 | tokio::spawn(axum_server::bind_rustls(addr, config).serve(app.into_make_service())); 41 | 42 | let config = TestStorage::builder() 43 | .ports(vec![PortEntry { 44 | id: "test".parse().unwrap(), 45 | port: Port { 46 | active: true, 47 | name: String::new(), 48 | listen: proxy_port.multiaddr_https(), 49 | opts: PortOptions { 50 | tls_termination: Some(TlsTermination { 51 | server_names: vec!["localhost".into()], 52 | }), 53 | }, 54 | }, 55 | }]) 56 | .proxies(vec![ProxyEntry { 57 | id: "test2".parse().unwrap(), 58 | proxy: Proxy { 59 | ports: vec!["test".parse().unwrap()], 60 | kind: ProxyKind::Http(HttpProxy { 61 | vhosts: vec!["localhost".parse().unwrap()], 62 | routes: vec![Route { 63 | path: "/".into(), 64 | servers: vec![taxy_api::proxy::Server { 65 | url: listen_port.https_url("/").try_into().unwrap(), 66 | }], 67 | }], 68 | upgrade_insecure: false, 69 | }), 70 | ..Default::default() 71 | }, 72 | }]) 73 | .certs( 74 | [(root.id, root.clone()), (cert.id, cert.clone())] 75 | .into_iter() 76 | .collect(), 77 | ) 78 | .build(); 79 | 80 | let mut root_certs = RootCertStore::empty(); 81 | for cert in root.certified_key().unwrap().cert { 82 | root_certs.add(cert).unwrap(); 83 | } 84 | 85 | let client = ClientConfig::builder() 86 | .with_root_certificates(root_certs) 87 | .with_no_client_auth(); 88 | 89 | with_server(config, |_| async move { 90 | let url = Uri::try_from(&format!( 91 | "wss://localhost:{}/ws", 92 | proxy_port.socket_addr().port() 93 | ))?; 94 | let (mut ws_stream, _) = connect_async_tls_with_config( 95 | url, 96 | None, 97 | false, 98 | Some(Connector::Rustls(Arc::new(client))), 99 | ) 100 | .await?; 101 | ws_stream 102 | .send(Message::Text("Hello, server!".to_string().into())) 103 | .await?; 104 | 105 | let message = ws_stream.next().await.unwrap(); 106 | match message { 107 | Ok(msg) => assert_eq!("Hello, server!", msg.into_text()?.as_str()), 108 | Err(e) => panic!("{e}"), 109 | } 110 | Ok(()) 111 | }) 112 | .await 113 | } 114 | 115 | async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse { 116 | ws.on_upgrade(handle_socket) 117 | } 118 | 119 | async fn handle_socket(mut socket: WebSocket) { 120 | while let Some(msg) = socket.recv().await { 121 | if let Ok(msg) = msg { 122 | socket.send(msg).await.unwrap(); 123 | } else { 124 | return; 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /taxy-webui/src/pages/proxy_view.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | auth::use_ensure_auth, components::proxy_config::ProxyConfig, pages::Route, store::ProxyStore, 3 | API_ENDPOINT, 4 | }; 5 | use gloo_net::http::Request; 6 | use std::collections::HashMap; 7 | use taxy_api::{ 8 | id::ShortId, 9 | proxy::{Proxy, ProxyEntry}, 10 | }; 11 | use yew::prelude::*; 12 | use yew_router::prelude::*; 13 | use yewdux::prelude::*; 14 | 15 | #[derive(Properties, PartialEq)] 16 | pub struct Props { 17 | pub id: ShortId, 18 | } 19 | 20 | #[function_component(ProxyView)] 21 | pub fn proxy_view(props: &Props) -> Html { 22 | use_ensure_auth(); 23 | 24 | let (proxies, _) = use_store::(); 25 | let site = use_state(|| proxies.entries.iter().find(|e| e.id == props.id).cloned()); 26 | let id = props.id; 27 | let proxy_cloned = site.clone(); 28 | use_effect_with((),move |_| { 29 | wasm_bindgen_futures::spawn_local(async move { 30 | if let Ok(entry) = get_site(id).await { 31 | proxy_cloned.set(Some(entry)); 32 | } 33 | }); 34 | }); 35 | 36 | let navigator = use_navigator().unwrap(); 37 | 38 | let navigator_cloned = navigator.clone(); 39 | let cancel_onclick = Callback::from(move |_| { 40 | navigator_cloned.push(&Route::Proxies); 41 | }); 42 | 43 | let entry = use_state::>, _>(|| Err(Default::default())); 44 | let entry_cloned = entry.clone(); 45 | let onchanged: Callback>> = 46 | Callback::from(move |updated| { 47 | entry_cloned.set(updated); 48 | }); 49 | 50 | let is_loading = use_state(|| false); 51 | 52 | let id = props.id; 53 | let entry_cloned = entry.clone(); 54 | let is_loading_cloned = is_loading; 55 | let onsubmit = Callback::from(move |event: SubmitEvent| { 56 | event.prevent_default(); 57 | if *is_loading_cloned { 58 | return; 59 | } 60 | let navigator = navigator.clone(); 61 | let is_loading_cloned = is_loading_cloned.clone(); 62 | if let Ok(entry) = (*entry_cloned).clone() { 63 | is_loading_cloned.set(true); 64 | wasm_bindgen_futures::spawn_local(async move { 65 | if update_site(id, &entry).await.is_ok() { 66 | navigator.push(&Route::Proxies); 67 | } 68 | is_loading_cloned.set(false); 69 | }); 70 | } 71 | }); 72 | 73 | html! { 74 | <> 75 | if let Some(proxy_entry) = &*site { 76 |
77 | 78 | 79 |
80 | 83 | 86 |
87 | 88 | } else { 89 | to={Route::Proxies}/> 90 | } 91 | 92 | } 93 | } 94 | 95 | async fn get_site(id: ShortId) -> Result { 96 | Request::get(&format!("{API_ENDPOINT}/proxies/{id}")) 97 | .send() 98 | .await? 99 | .json() 100 | .await 101 | } 102 | 103 | async fn update_site(id: ShortId, entry: &Proxy) -> Result<(), gloo_net::Error> { 104 | Request::put(&format!("{API_ENDPOINT}/proxies/{id}")) 105 | .json(entry)? 106 | .send() 107 | .await? 108 | .json() 109 | .await 110 | } 111 | -------------------------------------------------------------------------------- /docs/content/configuration.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "Configuration" 3 | description = "Configuration" 4 | weight = 0 5 | +++ 6 | 7 | # Ports 8 | 9 | Before configuring a proxy, you need to bind a port to listen on. You can do this in the "Ports" section. 10 | 11 | Taxy supports six types of ports: 12 | 13 | - HTTP 14 | - HTTPS (HTTP over TLS) 15 | - HTTP over QUIC (HTTP/3) 16 | - TCP 17 | - TCP over TLS 18 | - UDP 19 | 20 | ## Resetting a Port 21 | 22 | Changing the port configuration does not affect existing connections. Old connections will continue to use the old configuration. To forcibly close existing connections, you can reset the port. 23 | 24 | # Proxies 25 | 26 | Taxy supports three types of proxies: 27 | 28 | - HTTP / HTTPS 29 | - TCP / TCP over TLS 30 | - UDP 31 | 32 | Multiple ports can be bound to a proxy. However, it's not possible to bind TCP / TCP over TLS ports to an HTTP / HTTPS proxy and vice versa. 33 | 34 | ## HTTP/2 35 | 36 | Taxy supports HTTP/2 for HTTP and HTTPS proxies in both upstream and downstream connections. HTTP/2 is automatically negotiated if the client supports it. However, most web browsers will only use HTTP/2 if the connection is over TLS because they have no prior knowledge of the server's support for HTTP/2 without ALPN (Application-Layer Protocol Negotiation). 37 | 38 | ## WebSocket 39 | 40 | Taxy supports WebSocket (and HTTP upgrading) for HTTP and HTTPS proxies. You don't need to do anything special to enable WebSocket support. 41 | 42 | ## HTTP/3 43 | 44 | To enable HTTP/3 proxying, bind a QUIC port in the Ports section and select HTTP over QUIC as the protocol. Note that HTTP/3 is only supported for incoming connections—upstream connections will be downgraded to HTTP/2 or HTTP/1.1. 45 | 46 | WebTransport is not supported. 47 | 48 | # Certificates 49 | 50 | ## Server Certificates 51 | 52 | For TCP over TLS and HTTPS proxy, Taxy requires a server certificate. There are three ways to install a server certificate: 53 | 54 | 1. Generate a self-signed certificate 55 | 2. Import a certificate from a file (PEM format only) 56 | 3. Use [ACME](https://letsencrypt.org/how-it-works/) to automatically provision a certificate 57 | 58 | Taxy will automatically search for a certificate from SNI (Server Name Indication) in the TLS client hello message. 59 | 60 | ## Root Certificates 61 | 62 | If your upstream server uses certificates not trusted by the system, you will need to add them to the root certificate store. Taxy automatically trusts all certificates signed by the root certificate, in addition to the system's root certificates. 63 | 64 | Also, if you generate a self-signed certificate, Taxy will automatically generate a CA certificate and add it to the root certificate store. 65 | 66 | # ACME 67 | 68 | Taxy supports automatic certificate provisioning using [ACME](https://letsencrypt.org/docs/client-options/) (Automatic Certificate Management Environment). ACME is supported by many certificate authorities, such as Let's Encrypt, ZeroSSL, and Google Trust Services. 69 | 70 | Taxy supports ACME v2 with HTTP challenge only. Make sure that TCP port 80 is open and accessible from the internet. 71 | 72 | # Configuration Files 73 | 74 | Taxy uses TOML files for storing its configuration. The location of these files varies according to the operating system: 75 | 76 | - Linux: `$XDG_CONFIG_HOME/taxy` or `$HOME/.config/taxy` 77 | - macOS: `$HOME/Library/Application Support/taxy` 78 | - Windows: `%APPDATA%\taxy\config` 79 | 80 | You can override the default location by setting the `TAXY_CONFIG_DIR` environment variable or the `--config-dir` command-line option. 81 | 82 | If needed, these files can be edited manually. Note, however, that Taxy does not automatically detect changes made to the configuration files. To ensure any changes take effect, you must restart the server after editing a configuration file. 83 | 84 | # WebUI 85 | 86 | Taxy includes a built-in WebUI. By default, it is served on localhost:46492. However, you can customize the port using the `TAXY_WEBUI` environment variable or the `--webui` command-line option. If you wish to disable the WebUI, set the `TAXY_NO_WEBUI=1` environment variable or use the `--no-webui` command-line option. 87 | 88 | # Logging 89 | 90 | Taxy logs to the standard output as its default setting. You can change this behavior by setting the `TAXY_LOG`, `TAXY_ACCESS_LOG` environment variable or using the `--log`, `--access-log` command-line option. 91 | 92 | ```bash 93 | $ taxy start --log /var/log/taxy.log --access-log /var/log/taxy-access.log 94 | ``` 95 | 96 | If you want to adjust the log level, you can do so by setting the `TAXY_LOG_LEVEL`, `TAXY_ACCESS_LOG_LEVEL` environment variable or using the `--log-level`, `--access-log-level` command-line option. 97 | -------------------------------------------------------------------------------- /taxy/src/admin/auth.rs: -------------------------------------------------------------------------------- 1 | use super::{AppError, AppState}; 2 | use crate::server::rpc::auth::VerifyAccount; 3 | use axum::{ 4 | extract::{Request, State}, 5 | middleware::Next, 6 | response::{IntoResponse, Response}, 7 | Json, 8 | }; 9 | use axum_extra::extract::{ 10 | cookie::{Cookie, SameSite}, 11 | CookieJar, 12 | }; 13 | use rand::distributions::{Alphanumeric, DistString}; 14 | use std::{ 15 | collections::HashMap, 16 | time::{Duration, Instant}, 17 | }; 18 | use taxy_api::{ 19 | auth::{LoginMethod, LoginRequest, LoginResponse}, 20 | error::Error, 21 | }; 22 | 23 | const MINIMUM_SESSION_EXPIRY: Duration = Duration::from_secs(60 * 5); // 5 minutes 24 | const SESSION_TOKEN_LENGTH: usize = 32; 25 | 26 | pub async fn login( 27 | State(state): State, 28 | jar: CookieJar, 29 | Json(request): Json, 30 | ) -> Result { 31 | let username = request.username.clone(); 32 | let token = jar.get("token").map(|c| c.value().to_string()); 33 | 34 | if let LoginMethod::Totp { .. } = &request.method { 35 | let mut data = state.data.lock().await; 36 | let expiry = data.config.admin.session_expiry; 37 | let ok = data 38 | .sessions 39 | .verify(SessionKind::Login, &token.unwrap_or_default(), expiry) 40 | .map(|session| session.username == username) 41 | .unwrap_or_default(); 42 | if !ok { 43 | return Err(Error::InvalidLoginCredentials.into()); 44 | } 45 | } 46 | 47 | let insecure = request.insecure; 48 | let result = state.call(VerifyAccount { request }).await?; 49 | 50 | let session = match *result { 51 | LoginResponse::Success => SessionKind::Admin, 52 | _ => SessionKind::Login, 53 | }; 54 | 55 | let token = state 56 | .data 57 | .lock() 58 | .await 59 | .sessions 60 | .new_token(session, &username); 61 | 62 | let cookie = Cookie::build(("token", token)) 63 | .http_only(true) 64 | .same_site(SameSite::Strict) 65 | .secure(!insecure) 66 | .build(); 67 | 68 | Ok((jar.add(cookie), Json(result))) 69 | } 70 | 71 | pub async fn logout(State(state): State, jar: CookieJar) -> impl IntoResponse { 72 | if let Some(token) = jar.get("token") { 73 | state.data.lock().await.sessions.remove(token.value()); 74 | } 75 | jar.remove("token") 76 | } 77 | 78 | pub async fn verify( 79 | State(state): State, 80 | jar: CookieJar, 81 | request: Request, 82 | next: Next, 83 | ) -> Response { 84 | if let Some(token) = jar.get("token") { 85 | let mut data = state.data.lock().await; 86 | let expiry = data.config.admin.session_expiry; 87 | if data 88 | .sessions 89 | .verify(SessionKind::Admin, token.value(), expiry) 90 | .is_some() 91 | { 92 | std::mem::drop(data); 93 | let response = next.run(request).await; 94 | return response; 95 | } 96 | } 97 | AppError::Taxy(Error::Unauthorized).into_response() 98 | } 99 | 100 | #[derive(Debug, Clone)] 101 | pub struct Session { 102 | pub kind: SessionKind, 103 | pub username: String, 104 | pub started_at: Instant, 105 | } 106 | 107 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 108 | pub enum SessionKind { 109 | Login, 110 | Admin, 111 | } 112 | 113 | #[derive(Default)] 114 | pub struct SessionStore { 115 | tokens: HashMap, 116 | } 117 | 118 | impl SessionStore { 119 | pub fn new_token(&mut self, kind: SessionKind, username: &str) -> String { 120 | let token = Alphanumeric.sample_string(&mut rand::thread_rng(), SESSION_TOKEN_LENGTH); 121 | self.tokens.insert( 122 | token.clone(), 123 | Session { 124 | kind, 125 | username: username.to_string(), 126 | started_at: Instant::now(), 127 | }, 128 | ); 129 | token 130 | } 131 | 132 | pub fn verify(&mut self, kind: SessionKind, token: &str, expiry: Duration) -> Option<&Session> { 133 | let expiry = expiry.max(MINIMUM_SESSION_EXPIRY); 134 | self.tokens = self 135 | .tokens 136 | .drain() 137 | .filter(|(_, t)| t.started_at.elapsed() < expiry) 138 | .collect(); 139 | self.tokens 140 | .get(token) 141 | .filter(|session| session.kind == kind) 142 | } 143 | 144 | pub fn remove(&mut self, token: &str) { 145 | self.tokens.remove(token); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /taxy/src/proxy/mod.rs: -------------------------------------------------------------------------------- 1 | use self::{http::HttpPortContext, tcp::TcpPortContext, udp::UdpPortContext}; 2 | use crate::server::cert_list::CertList; 3 | use once_cell::sync::OnceCell; 4 | use taxy_api::error::Error; 5 | use taxy_api::multiaddr::Multiaddr; 6 | use taxy_api::port::{PortStatus, SocketState}; 7 | use taxy_api::{ 8 | port::{Port, PortEntry}, 9 | proxy::ProxyEntry, 10 | }; 11 | 12 | pub mod http; 13 | pub mod tcp; 14 | pub mod tls; 15 | pub mod udp; 16 | 17 | #[derive(Debug, Clone, PartialEq, Eq)] 18 | pub enum PortContextEvent { 19 | SocketStateUpdated(SocketState), 20 | } 21 | 22 | #[derive(Debug)] 23 | pub struct PortContext { 24 | pub entry: PortEntry, 25 | pub kind: PortContextKind, 26 | } 27 | 28 | impl PortContext { 29 | pub fn new(entry: PortEntry) -> Result { 30 | let kind = if entry.port.listen.is_quic() { 31 | PortContextKind::Http3(HttpPortContext::new(&entry)?) 32 | } else if entry.port.listen.is_udp() { 33 | PortContextKind::Udp(UdpPortContext::new(&entry)?) 34 | } else if entry.port.listen.is_http() { 35 | PortContextKind::Http(HttpPortContext::new(&entry)?) 36 | } else { 37 | PortContextKind::Tcp(TcpPortContext::new(&entry)?) 38 | }; 39 | Ok(Self { entry, kind }) 40 | } 41 | 42 | pub fn reserved() -> Self { 43 | Self { 44 | entry: PortEntry { 45 | id: "reserved".parse().unwrap(), 46 | port: Port { 47 | active: true, 48 | name: String::new(), 49 | listen: Multiaddr::default(), 50 | opts: Default::default(), 51 | }, 52 | }, 53 | kind: PortContextKind::Reserved, 54 | } 55 | } 56 | 57 | pub fn entry(&self) -> &PortEntry { 58 | &self.entry 59 | } 60 | 61 | pub fn kind(&self) -> &PortContextKind { 62 | &self.kind 63 | } 64 | 65 | pub fn kind_mut(&mut self) -> &mut PortContextKind { 66 | &mut self.kind 67 | } 68 | 69 | pub async fn setup( 70 | &mut self, 71 | ports: &[PortEntry], 72 | certs: &CertList, 73 | proxies: Vec, 74 | ) -> Result<(), Error> { 75 | match &mut self.kind { 76 | PortContextKind::Tcp(ctx) => ctx.setup(certs, proxies).await, 77 | PortContextKind::Http(ctx) => ctx.setup(ports, certs, proxies).await, 78 | PortContextKind::Udp(ctx) => ctx.setup(proxies).await, 79 | PortContextKind::Http3(ctx) => ctx.setup(ports, certs, proxies).await, 80 | PortContextKind::Reserved => Ok(()), 81 | } 82 | } 83 | 84 | pub fn apply(&mut self, new: Self) { 85 | match (&mut self.kind, new.kind) { 86 | (PortContextKind::Tcp(old), PortContextKind::Tcp(new)) => old.apply(new), 87 | (PortContextKind::Udp(old), PortContextKind::Udp(new)) => old.apply(new), 88 | (PortContextKind::Http(old), PortContextKind::Http(new)) => old.apply(new), 89 | (PortContextKind::Http3(old), PortContextKind::Http3(new)) => old.apply(new), 90 | (old, new) => *old = new, 91 | } 92 | self.entry = new.entry; 93 | } 94 | 95 | pub fn event(&mut self, event: PortContextEvent) { 96 | match &mut self.kind { 97 | PortContextKind::Tcp(ctx) => ctx.event(event), 98 | PortContextKind::Udp(ctx) => ctx.event(event), 99 | PortContextKind::Http(ctx) => ctx.event(event), 100 | PortContextKind::Http3(ctx) => ctx.event(event), 101 | PortContextKind::Reserved => (), 102 | } 103 | } 104 | 105 | pub fn status(&self) -> &PortStatus { 106 | match &self.kind { 107 | PortContextKind::Tcp(ctx) => ctx.status(), 108 | PortContextKind::Udp(ctx) => ctx.status(), 109 | PortContextKind::Http(ctx) => ctx.status(), 110 | PortContextKind::Http3(ctx) => ctx.status(), 111 | PortContextKind::Reserved => { 112 | static STATUS: OnceCell = OnceCell::new(); 113 | STATUS.get_or_init(PortStatus::default) 114 | } 115 | } 116 | } 117 | 118 | pub fn reset(&mut self) { 119 | match &mut self.kind { 120 | PortContextKind::Tcp(ctx) => ctx.reset(), 121 | PortContextKind::Udp(ctx) => ctx.reset(), 122 | PortContextKind::Http(ctx) => ctx.reset(), 123 | PortContextKind::Http3(ctx) => ctx.reset(), 124 | PortContextKind::Reserved => (), 125 | } 126 | } 127 | } 128 | 129 | #[derive(Debug)] 130 | pub enum PortContextKind { 131 | Tcp(TcpPortContext), 132 | Udp(UdpPortContext), 133 | Http(HttpPortContext), 134 | Http3(HttpPortContext), 135 | Reserved, 136 | } 137 | -------------------------------------------------------------------------------- /taxy-webui/src/pages/log_view.rs: -------------------------------------------------------------------------------- 1 | use crate::{auth::use_ensure_auth, API_ENDPOINT}; 2 | use gloo_net::http::Request; 3 | use gloo_timers::callback::Timeout; 4 | use taxy_api::log::{LogLevel, SystemLogRow}; 5 | use time::{format_description::well_known::Rfc3339, OffsetDateTime}; 6 | use web_sys::Element; 7 | use yew::prelude::*; 8 | use yew_router::prelude::use_navigator; 9 | 10 | #[derive(Properties, PartialEq)] 11 | pub struct Props { 12 | pub id: String, 13 | } 14 | 15 | #[function_component(LogView)] 16 | pub fn log_view(props: &Props) -> Html { 17 | use_ensure_auth(); 18 | 19 | let ul_ref = use_node_ref(); 20 | 21 | let log: UseStateHandle> = use_state(Vec::::new); 22 | let id = props.id.clone(); 23 | let log_cloned = log.clone(); 24 | let ul_ref_cloned = ul_ref.clone(); 25 | use_effect_with((),move |_| { 26 | poll_log(id.clone(), ul_ref_cloned.clone(), log_cloned, vec![], None); 27 | }); 28 | 29 | let navigator = use_navigator().unwrap(); 30 | let back_onclick = Callback::from(move |_| { 31 | navigator.back(); 32 | }); 33 | 34 | html! { 35 | <> 36 |
37 |
38 | 42 |
43 |
44 |
    45 | { log.iter().map(|entry| { 46 | let timestamp = entry.timestamp.format(&Rfc3339).unwrap(); 47 | let fields = entry.fields.iter().map(|(k, v)| { 48 | format!("{}={}", k, v) 49 | }).collect::>().join(" "); 50 | let log_class = match entry.level { 51 | LogLevel::Error => "text-red-600", 52 | LogLevel::Warn => "text-yellow-600", 53 | LogLevel::Info => "text-green-600", 54 | LogLevel::Debug => "text-blue-600", 55 | LogLevel::Trace => "text-neutral-600", 56 | }; 57 | html! { 58 |
  • 59 | {timestamp} 60 | { 61 | format!("{: <5}", entry.level.to_string().to_ascii_uppercase()) 62 | } 63 | {entry.message.clone()} 64 | {fields} 65 |
  • 66 | } 67 | }).collect::() 68 | } 69 | if log.is_empty() { 70 |
  • {"No logs."}
  • 71 | } 72 |
73 | 74 | } 75 | } 76 | 77 | fn poll_log( 78 | id: String, 79 | ul_ref: NodeRef, 80 | setter: UseStateHandle>, 81 | mut history: Vec, 82 | time: Option, 83 | ) { 84 | wasm_bindgen_futures::spawn_local(async move { 85 | if let Ok(mut list) = get_log(&id, time).await { 86 | let time = list.last().map(|row| row.timestamp).or(time); 87 | history.append(&mut list); 88 | setter.set(history.clone()); 89 | 90 | if let Some(elem) = ul_ref.cast::() { 91 | if elem.scroll_top() == elem.scroll_height() - elem.client_height() { 92 | Timeout::new(0, move || { 93 | elem.set_scroll_top(elem.scroll_height()); 94 | }) 95 | .forget(); 96 | } 97 | poll_log(id, ul_ref, setter, history, time); 98 | } 99 | } 100 | }); 101 | } 102 | 103 | async fn get_log( 104 | id: &str, 105 | time: Option, 106 | ) -> Result, gloo_net::Error> { 107 | let mut req = Request::get(&format!("{API_ENDPOINT}/logs/{id}")); 108 | if let Some(time) = time { 109 | req = req.query([("since", &time.unix_timestamp().to_string())]); 110 | } 111 | req.send().await?.json().await 112 | } 113 | -------------------------------------------------------------------------------- /taxy/src/server/rpc/ports.rs: -------------------------------------------------------------------------------- 1 | use super::RpcMethod; 2 | use crate::proxy::PortContext; 3 | use crate::server::state::ServerState; 4 | use network_interface::NetworkInterfaceConfig; 5 | use taxy_api::error::Error; 6 | use taxy_api::id::ShortId; 7 | use taxy_api::port::{NetworkAddr, NetworkInterface, Port, PortEntry, PortStatus}; 8 | 9 | pub struct GetPortList; 10 | 11 | #[async_trait::async_trait] 12 | impl RpcMethod for GetPortList { 13 | type Output = Vec; 14 | 15 | async fn call(self, state: &mut ServerState) -> Result { 16 | Ok(state.ports.entries().cloned().collect()) 17 | } 18 | } 19 | 20 | pub struct GetPort { 21 | pub id: ShortId, 22 | } 23 | 24 | #[async_trait::async_trait] 25 | impl RpcMethod for GetPort { 26 | type Output = PortEntry; 27 | 28 | async fn call(self, state: &mut ServerState) -> Result { 29 | state 30 | .ports 31 | .get(self.id) 32 | .map(|port| port.entry().clone()) 33 | .ok_or(Error::IdNotFound { 34 | id: self.id.to_string(), 35 | }) 36 | } 37 | } 38 | 39 | pub struct GetPortStatus { 40 | pub id: ShortId, 41 | } 42 | 43 | #[async_trait::async_trait] 44 | impl RpcMethod for GetPortStatus { 45 | type Output = PortStatus; 46 | 47 | async fn call(self, state: &mut ServerState) -> Result { 48 | state 49 | .ports 50 | .get(self.id) 51 | .map(|port| *port.status()) 52 | .ok_or(Error::IdNotFound { 53 | id: self.id.to_string(), 54 | }) 55 | } 56 | } 57 | 58 | pub struct DeletePort { 59 | pub id: ShortId, 60 | } 61 | 62 | #[async_trait::async_trait] 63 | impl RpcMethod for DeletePort { 64 | type Output = (); 65 | 66 | async fn call(self, state: &mut ServerState) -> Result { 67 | if state.ports.delete(self.id) { 68 | state.update_ports().await; 69 | state.reload_proxies().await; 70 | Ok(()) 71 | } else { 72 | Err(Error::IdNotFound { 73 | id: self.id.to_string(), 74 | }) 75 | } 76 | } 77 | } 78 | 79 | pub struct AddPort { 80 | pub entry: Port, 81 | } 82 | 83 | #[async_trait::async_trait] 84 | impl RpcMethod for AddPort { 85 | type Output = (); 86 | 87 | async fn call(self, state: &mut ServerState) -> Result { 88 | let entry: PortEntry = (state.generate_id(), self.entry).into(); 89 | if state.ports.get(entry.id).is_some() { 90 | Err(Error::IdAlreadyExists { id: entry.id }) 91 | } else { 92 | state.update_port(PortContext::new(entry)?).await; 93 | Ok(()) 94 | } 95 | } 96 | } 97 | 98 | pub struct UpdatePort { 99 | pub entry: PortEntry, 100 | } 101 | 102 | #[async_trait::async_trait] 103 | impl RpcMethod for UpdatePort { 104 | type Output = (); 105 | 106 | async fn call(self, state: &mut ServerState) -> Result { 107 | if state.ports.get(self.entry.id).is_some() { 108 | state.update_port(PortContext::new(self.entry)?).await; 109 | Ok(()) 110 | } else { 111 | Err(Error::IdNotFound { 112 | id: self.entry.id.to_string(), 113 | }) 114 | } 115 | } 116 | } 117 | 118 | pub struct ResetPort { 119 | pub id: ShortId, 120 | } 121 | 122 | #[async_trait::async_trait] 123 | impl RpcMethod for ResetPort { 124 | type Output = (); 125 | 126 | async fn call(self, state: &mut ServerState) -> Result { 127 | if state.ports.reset(self.id) { 128 | Ok(()) 129 | } else { 130 | Err(Error::IdNotFound { 131 | id: self.id.to_string(), 132 | }) 133 | } 134 | } 135 | } 136 | 137 | pub struct GetNetworkInterfaceList; 138 | 139 | #[async_trait::async_trait] 140 | impl RpcMethod for GetNetworkInterfaceList { 141 | type Output = Vec; 142 | 143 | async fn call(self, _state: &mut ServerState) -> Result { 144 | Ok(network_interface::NetworkInterface::show() 145 | .map_err(|_| Error::FailedToListNetworkInterfaces)? 146 | .into_iter() 147 | .map(|iface| { 148 | let addrs = iface 149 | .addr 150 | .into_iter() 151 | .map(|net| NetworkAddr { 152 | ip: net.ip(), 153 | mask: net.netmask(), 154 | }) 155 | .collect::>(); 156 | NetworkInterface { 157 | name: iface.name, 158 | addrs, 159 | mac: iface.mac_addr, 160 | } 161 | }) 162 | .collect()) 163 | } 164 | } 165 | --------------------------------------------------------------------------------