diff --git a/Cargo.lock b/Cargo.lock index b02d5f3..322f192 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,6 +165,7 @@ dependencies = [ "dashmap", "env_logger", "futures", + "jsonwebtoken", "log", "notify", "pingora", @@ -513,6 +514,15 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e60eed09d8c01d3cee5b7d30acb059b76614c918fa0f992e0dd6eeb10daad6f" +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", +] + [[package]] name = "derivative" version = "2.2.0" @@ -774,8 +784,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1229,6 +1241,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "kqueue" version = "1.0.8" @@ -1433,6 +1460,31 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1546,6 +1598,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +dependencies = [ + "base64", + "serde", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -1837,6 +1899,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -1891,7 +1959,7 @@ dependencies = [ "memchr", "parking_lot", "protobuf", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -2263,9 +2331,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.137" +version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "930cfb6e6abf99298aaad7d29abbef7a9999a9a8806a40088f55f0dcec03146b" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ "itoa", "memchr", @@ -2333,6 +2401,18 @@ dependencies = [ "libc", ] +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.12", + "time", +] + [[package]] name = "slab" version = "0.4.9" @@ -2496,7 +2576,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", ] [[package]] @@ -2510,6 +2599,17 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "thread_local" version = "1.1.8" @@ -2530,6 +2630,37 @@ dependencies = [ "trackable 0.2.24", ] +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.7.6" diff --git a/Cargo.toml b/Cargo.toml index 419faea..89796af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,12 +17,13 @@ log = "0.4.27" futures = "0.3.31" notify = "8.0.0" axum = { version = "0.8.3" } -#axum-server = { version = "0.8.3", features = ["tls-rustls"] } - reqwest = { version = "0.12.15", features = ["json"] } serde_yaml = "0.8.26" #hickory-client = "0.25.1" rand = "0.9.0" base64 = "0.22.1" +jsonwebtoken = "9.3.1" +#hmac = "0.12.1" +#sha2 = "0.10.8" diff --git a/README.md b/README.md index d916490..323f821 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ Is a Reverse proxy, service mesh based on Cloudflare's Pingora -**Why Gazan ?** Roots and meaning (Gazan = Գազան = beast / wild animal in Armenian). +**What Gazan means?** +Gazan = Գազան = beast / wild animal in Armenian / Often used as a synonym to something great.. Built on Rust, on top of **Cloudflare’s Pingora engine**, **Gazan** delivers world-class performance, security, and scalability — right out of the box. @@ -60,7 +61,7 @@ Built on Rust, on top of **Cloudflare’s Pingora engine**, **Gazan** delivers w - Optional request headers - Optional TLS for upstreams - Global headers (e.g., CORS) apply to all proxied responses -- Optional authentication (Basic, API Key) — currently commented for example +- Optional authentication (Basic, API Key, JWT) — currently commented for example --- @@ -134,9 +135,54 @@ curl -XPOST --data-binary @./etc/upstreams.txt 127.0.0.1:3000/conf --- +## 🔐 Authentication (Optional) + +- Adds authentication to all requests. +- Only one method can be active at a time. +- `basic` : Standard HTTP Basic Authentication requests. +- `apikey` : Authentication via `x-api-key` header, which should match the value in config. +- `jwt`: JWT authentication implemented vi `x-jwt-token` header. + - To obtain JWT token, you should send **generate** request to built in api server's `/jwt` endpoint. + - `masterkey`: should match configured `masterkey` in `main.yaml` and `upstreams.yaml`. + - `owner` : Just a placeholder, can be anything. + - `valid` : Time in minutes during which the generated token will be valid. + +**Example JWT token generateion request** + +```bash +PAYLOAD='{ + "masterkey": "910517d9-f9a1-48de-8826-dbadacbd84af-cb6f830e-ab16-47ec-9d8f-0090de732774", + "owner": "valod", + "valid": 1 +}' + +TOK=`curl -s -XPOST -H "Content-Type: application/json" -d "$PAYLOAD" http://127.0.0.1:3000/jwt | cut -d '"' -f4` +echo $TOK +``` + +**Example Request with JWT token** + +```bash +curl -H "x-jwt-token: ${TOK}" -H 'Host: myip.mydomain.com' http://127.0.0.1:6193/ +``` + +**Example Request with API Key** + +```bash +curl -H "x-api-key: ${APIKEY}" --header 'Host: myip.mydomain.com' http://127.0.0.1:6193/ + +``` + +**Example Request with Basic Auth** + +```bash +curl -u username:password -H 'Host: myip.mydomain.com' http://127.0.0.1:6193/ + +``` + ## 📃 License -The product is distributed under [Apache License Version 2.0](https://www.apache.org/licenses/LICENSE-2.0) +[Apache License Version 2.0](https://www.apache.org/licenses/LICENSE-2.0) --- diff --git a/etc/main.yaml b/etc/main.yaml index c5ddd35..b42fa38 100644 --- a/etc/main.yaml +++ b/etc/main.yaml @@ -17,4 +17,4 @@ upstreams_conf: etc/upstreams.yaml # the location of upstreams file log_level: info # info, warn, error, debug, trace, off hc_method: HEAD # Healthcheck method (HEAD, GET, POST are supported) hc_interval: 2 #Intervak for Healthcheck in seconds - +master_key: 910517d9-f9a1-48de-8826-dbadacbd84af-cb6f830e-ab16-47ec-9d8f-0090de732774 diff --git a/etc/upstreams.yaml b/etc/upstreams.yaml index ca788ec..e20980e 100644 --- a/etc/upstreams.yaml +++ b/etc/upstreams.yaml @@ -5,11 +5,13 @@ globals: - "Access-Control-Allow-Origin:*" - "Access-Control-Allow-Methods:POST, GET, OPTIONS" - "Access-Control-Max-Age:86400" -# authorization: # if enabled -# - "basic" -# - "zangag:Anhnazand1234" -# - "apikey" -# - "qweasdqweadhbk" + authorization: # if enabled + # - "basic" + # - "zangag:Anhnazand1234" + # - "apikey" + # - "5a28cc4c-ce10-4ff1-824e-743c38835f5c" + - "jwt" + - "910517d9-f9a1-48de-8826-dbadacbd84af-cb6f830e-ab16-47ec-9d8f-0090de732774" consul: # read only if provider is consul servers: - "http://master1:8500" diff --git a/src/utils.rs b/src/utils.rs index 423fc58..a409697 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -3,5 +3,6 @@ pub mod consul; pub mod discovery; mod filewatch; pub mod healthcheck; +pub mod jwt; pub mod parceyaml; pub mod tools; diff --git a/src/utils/auth.rs b/src/utils/auth.rs index aa72a27..fcfed2b 100644 --- a/src/utils/auth.rs +++ b/src/utils/auth.rs @@ -1,3 +1,4 @@ +use crate::utils::jwt::check_jwt; use base64::engine::general_purpose::STANDARD; use base64::Engine; use pingora_proxy::Session; @@ -7,6 +8,7 @@ trait AuthValidator { } struct BasicAuth<'a>(&'a str); struct ApiKeyAuth<'a>(&'a str); +struct JwtAuth<'a>(&'a str); impl AuthValidator for BasicAuth<'_> { fn validate(&self, session: &Session) -> bool { @@ -30,6 +32,16 @@ impl AuthValidator for ApiKeyAuth<'_> { } } +impl AuthValidator for JwtAuth<'_> { + fn validate(&self, session: &Session) -> bool { + let jwtsecret = self.0; + if let Some(header) = session.get_header("x-jwt-token") { + let tok = header.to_str().ok().unwrap(); + return check_jwt(tok, jwtsecret); + } + false + } +} fn validate(auth: &dyn AuthValidator, session: &Session) -> bool { auth.validate(session) } @@ -44,6 +56,10 @@ pub fn authenticate(c: &[String], session: &Session) -> bool { let auth = ApiKeyAuth(c[1].as_str().into()); validate(&auth, session) } + "jwt" => { + let auth = JwtAuth(c[1].as_str().into()); + validate(&auth, session) + } _ => { println!("Unsupported authentication mechanism : {}", c[0]); false diff --git a/src/utils/discovery.rs b/src/utils/discovery.rs index 91b7c81..9739b1c 100644 --- a/src/utils/discovery.rs +++ b/src/utils/discovery.rs @@ -10,6 +10,7 @@ pub struct FromFileProvider { } pub struct APIUpstreamProvider { pub address: String, + pub masterkey: String, } pub struct ConsulProvider { @@ -24,7 +25,7 @@ pub trait Discovery { #[async_trait] impl Discovery for APIUpstreamProvider { async fn start(&self, toreturn: Sender) { - webserver::run_server(self.address.clone(), toreturn).await; + webserver::run_server(self.address.clone(), self.masterkey.clone(), toreturn).await; } } diff --git a/src/utils/jwt.rs b/src/utils/jwt.rs new file mode 100644 index 0000000..3e1e7b2 --- /dev/null +++ b/src/utils/jwt.rs @@ -0,0 +1,16 @@ +use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct Claims { + pub(crate) user: String, + pub(crate) exp: u64, +} +pub fn check_jwt(input: &str, secret: &str) -> bool { + let validation = Validation::new(Algorithm::HS256); + let token_data = decode::(&input, &DecodingKey::from_secret(secret.as_ref()), &validation); + match token_data { + Ok(_) => true, + Err(_) => false, + } +} diff --git a/src/web/proxyhttp.rs b/src/web/proxyhttp.rs index e605ee0..cfca04b 100644 --- a/src/web/proxyhttp.rs +++ b/src/web/proxyhttp.rs @@ -52,12 +52,13 @@ impl BackgroundService for LB { error!("Can't read config file"); } } - let config_address = self.config.get("config_address"); + let masterkey = self.config.get("master_key").unwrap(); match config_address { Some(config_address) => { let api_load = APIUpstreamProvider { address: config_address.to_string(), + masterkey: masterkey.value().to_string(), }; let tx_api = tx.clone(); let _ = tokio::spawn(async move { api_load.start(tx_api).await }); @@ -248,13 +249,13 @@ impl ProxyHttp for LB { let authenticated = authenticate(&auth.value(), &session); if !authenticated { let _ = session.respond_error(401).await; - info!("Forbidden: {:?}, {}", session.client_addr(), session.req_header().uri.path().to_string()); + warn!("Forbidden: {:?}, {}", session.client_addr(), session.req_header().uri.path().to_string()); return Ok(true); } }; if session.req_header().uri.path().starts_with("/denied") { let _ = session.respond_error(403).await; - info!("Forbidden: {:?}, {}", session.client_addr(), session.req_header().uri.path().to_string()); + warn!("Forbidden: {:?}, {}", session.client_addr(), session.req_header().uri.path().to_string()); return Ok(true); }; Ok(false) diff --git a/src/web/webserver.rs b/src/web/webserver.rs index 306220d..c298d67 100644 --- a/src/web/webserver.rs +++ b/src/web/webserver.rs @@ -1,23 +1,41 @@ use crate::utils::parceyaml::Configuration; use axum::body::Body; +use axum::extract::State; use axum::http::{Response, StatusCode}; use axum::response::IntoResponse; use axum::routing::{delete, get, head, post, put}; -use axum::Router; +use axum::{Json, Router}; use futures::channel::mpsc::Sender; use futures::SinkExt; -use log::info; +use jsonwebtoken::{encode, EncodingKey, Header}; +use log::{error, info, warn}; +use serde::{Deserialize, Serialize}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::net::TcpListener; +#[derive(Deserialize)] +struct InputKey { + masterkey: String, + owner: String, + valid: u64, +} + +#[derive(Serialize, Debug)] +struct OutToken { + token: String, +} + #[allow(unused_mut)] -pub async fn run_server(bindaddress: String, mut toreturn: Sender) { +pub async fn run_server(bindaddress: String, masterkey: String, mut toreturn: Sender) { let mut tr = toreturn.clone(); let app = Router::new() - .route("/{*wildcard}", get(getconfig)) - .route("/{*wildcard}", post(getconfig)) - .route("/{*wildcard}", put(getconfig)) - .route("/{*wildcard}", head(getconfig)) - .route("/{*wildcard}", delete(getconfig)) + .route("/{*wildcard}", get(senderror)) + .route("/{*wildcard}", post(senderror)) + .route("/{*wildcard}", put(senderror)) + .route("/{*wildcard}", head(senderror)) + .route("/{*wildcard}", delete(senderror)) + .route("/jwt", post(jwt_gen)) + .with_state(masterkey.clone()) .route( "/conf", post(|up: String| async move { @@ -42,26 +60,32 @@ pub async fn run_server(bindaddress: String, mut toreturn: Sender } #[allow(dead_code)] -async fn getconfig() -> impl IntoResponse { - "Hello from Axum API inside Pingora!\n".to_string(); +async fn senderror() -> impl IntoResponse { Response::builder().status(StatusCode::BAD_GATEWAY).body(Body::from("No live upstream found!\n")).unwrap() } -// curl -XPOST -H 'Content-Type: application/json' --data-binary @./push.json 127.0.0.1:3000/json -// curl -XPOST --data-binary @./etc/upstreams.txt 127.0.0.1:3000/conf -/* -async fn config(Json(payload): Json>) -> impl IntoResponse { - let upstreams = DashMap::new(); - for (key, value) in payload { - upstreams.insert(key, (value.servers, AtomicUsize::new(value.counter))); +async fn jwt_gen(State(masterkey): State, Json(payload): Json) -> (StatusCode, Json) { + if payload.masterkey == masterkey { + let now = SystemTime::now() + Duration::from_secs(payload.valid * 60); + let a = now.duration_since(UNIX_EPOCH).unwrap().as_secs(); + let claim = crate::utils::jwt::Claims { user: payload.owner, exp: a }; + match encode(&Header::default(), &claim, &EncodingKey::from_secret(payload.masterkey.as_ref())) { + Ok(t) => { + let tok = OutToken { token: t }; + info!("Generating token: {:?}", tok); + (StatusCode::CREATED, Json(tok)) + } + Err(e) => { + let tok = OutToken { token: "ERROR".to_string() }; + error!("Failed to generate token: {:?}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(tok)) + } + } + } else { + let tok = OutToken { + token: "Unauthorised".to_string(), + }; + warn!("Unauthorised JWT generate request: {:?}", tok); + (StatusCode::FORBIDDEN, Json(tok)) } - println!("{:?}", upstreams); - Response::builder().status(StatusCode::CREATED).body(Body::from("Config updated!\n")).unwrap() } -async fn parse_upstreams(up: String) -> impl IntoResponse { - println!("Parsing: {}", up); - let serverlist = read_upstreams_from_file(up.as_str()); - println!("{:?}", serverlist); - Response::builder().status(StatusCode::CREATED).body(Body::from("Config updated!\n")).unwrap() -} -*/