diff --git a/Cargo.lock b/Cargo.lock index 73b720b..d56da04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -35,10 +44,10 @@ dependencies = [ "memchr", "proc-macro2", "quote", - "rustc-hash", + "rustc-hash 2.1.2", "serde", "serde_derive", - "syn", + "syn 2.0.117", ] [[package]] @@ -117,6 +126,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "basic-toml" version = "0.1.10" @@ -126,12 +141,41 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "lazy_static", + "lazycell", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.117", +] + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -154,6 +198,15 @@ dependencies = [ "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -173,24 +226,75 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "dashcore" version = "0.1.0" dependencies = [ "askama", "axum", + "base64", "chrono", + "hmac", + "pam", "serde", + "serde_json", "serde_yaml", + "sha2", "tokio", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "equivalent" version = "1.0.2" @@ -255,12 +359,37 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -376,6 +505,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -392,6 +530,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "libc" version = "0.2.183" @@ -431,6 +581,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.0" @@ -442,6 +598,16 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -457,6 +623,40 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "pam" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab553c52103edb295d8f7d6a3b593dc22a30b1fb99643c777a8f36915e285ba" +dependencies = [ + "libc", + "memchr", + "pam-macros", + "pam-sys", + "users", +] + +[[package]] +name = "pam-macros" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c94f3b9b97df3c6d4e51a14916639b24e02c7d15d1dba686ce9b1118277cb811" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pam-sys" +version = "1.0.0-alpha5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce9484729b3e52c0bacdc5191cb6a6a5f31ef4c09c5e4ab1209d3340ad9e997b" +dependencies = [ + "bindgen", + "libc", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -525,6 +725,41 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -576,7 +811,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -628,6 +863,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -666,6 +912,23 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -708,7 +971,7 @@ checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -759,6 +1022,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -771,6 +1040,22 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "users" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4227e95324a443c9fcb06e03d4d85e91aabe9a5a02aa818688b6918b6af486" +dependencies = [ + "libc", + "log", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -809,7 +1094,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -843,7 +1128,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -854,7 +1139,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index cad7938..4c0171d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,12 @@ edition = "2021" [dependencies] axum = "0.8.8" serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0" serde_yaml = "0.9.34" -tokio = { version = "1", features = ["full", "rt-multi-thread"] } +tokio = { version = "1", features = ["full", "rt-multi-thread", "process"] } askama = "0.14" chrono = "0.4.44" +pam = "0.8.0" +hmac = "0.12.1" +sha2 = "0.10.8" +base64 = "0.22.1" diff --git a/config.yaml b/config.yaml index edba2a6..a280e57 100644 --- a/config.yaml +++ b/config.yaml @@ -1,3 +1,7 @@ host: "127.0.0.1" port: 8080 debug: true +auth: + secret: "change-me-please" + token_ttl_seconds: 3600 + pam_service: "login" diff --git a/src/api/auth.rs b/src/api/auth.rs new file mode 100644 index 0000000..7ce9a5a --- /dev/null +++ b/src/api/auth.rs @@ -0,0 +1,85 @@ +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use pam::Client; +use serde::{Deserialize, Serialize}; + +use crate::auth::{issue_token, AuthState}; + +#[derive(Debug, Deserialize)] +pub(crate) struct LoginRequest { + username: String, + password: String, +} + +#[derive(Debug, Serialize)] +struct TokenResponse { + token: String, + token_type: String, + expires_in: i64, +} + +#[derive(Debug, Serialize)] +struct ErrorResponse { + error: String, +} + +pub async fn login( + State(state): State, + Json(payload): Json, +) -> impl IntoResponse { + if payload.username.trim().is_empty() || payload.password.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "username/password required".to_string(), + }), + ) + .into_response(); + } + + let service = state.pam_service.clone(); + let username = payload.username.clone(); + let password = payload.password.clone(); + + let pam_result = tokio::task::spawn_blocking(move || { + let mut client = Client::with_password(&service) + .map_err(|e| format!("pam init failed: {}", e))?; + client + .conversation_mut() + .set_credentials(&username, &password); + client + .authenticate() + .map_err(|e| format!("pam auth failed: {}", e))?; + Ok::<(), String>(()) + }) + .await + .map_err(|e| format!("pam task failed: {}", e)); + + match pam_result { + Ok(Ok(())) => { + let (token, ttl) = match issue_token(&payload.username, &state) { + Ok(v) => v, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e }), + ) + .into_response(); + } + }; + + Json(TokenResponse { + token, + token_type: "Bearer".to_string(), + expires_in: ttl, + }) + .into_response() + } + _ => ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: "invalid credentials".to_string(), + }), + ) + .into_response(), + } +} diff --git a/src/api/ip.rs b/src/api/ip.rs new file mode 100644 index 0000000..a50a445 --- /dev/null +++ b/src/api/ip.rs @@ -0,0 +1,710 @@ +use axum::{Json, response::IntoResponse, http::StatusCode, extract::Path}; +use serde::{Deserialize, Serialize}; +use std::process::Stdio; +use tokio::process::Command; + + + +#[derive(Debug, Serialize, Deserialize)] +pub struct IpAddrInfo { + pub interfaces: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct InterfaceInfo { + pub name: String, + pub index: u32, + pub state: String, + pub mac_address: Option, + pub ipv4_addresses: Vec, + pub ipv6_addresses: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Ipv4Address { + pub address: String, + pub prefix_len: u8, + pub scope: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Ipv6Address { + pub address: String, + pub prefix_len: u8, + pub scope: String, +} + + + +#[derive(Debug, Serialize, Deserialize)] +pub struct IpRouteInfo { + pub routes: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RouteEntry { + pub destination: Option, + pub gateway: Option, + pub device: String, + pub metric: Option, + pub route_type: String, +} + + +#[derive(Debug, Serialize, Deserialize)] +pub struct IpLinkInfo { + pub links: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct LinkInfo { + pub index: u32, + pub name: String, + pub state: String, + pub mac_address: Option, + pub mtu: u32, + pub qdisc: Option, + pub link_type: String, +} + +#[derive(Debug, Serialize)] +struct ErrorResponse { + error: String, +} + +#[derive(Debug, Serialize)] +struct OkResponse { + status: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum AddrAction { + Add, + Del, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum RouteAction { + Add, + Del, + Replace, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum IpFamily { + Inet, + Inet6, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct LinkMtuRequest { + pub mtu: u32, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct AddrRequest { + pub action: AddrAction, + pub dev: String, + pub address: String, + pub prefix_len: u8, + pub family: Option, + pub scope: Option, + pub label: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct RouteRequest { + pub action: RouteAction, + pub destination: String, + pub gateway: Option, + pub dev: Option, + pub metric: Option, + pub table: Option, + pub proto: Option, + pub scope: Option, +} +pub async fn get_ip_addr() -> impl IntoResponse { + handle_ip("addr").await +} + +pub async fn get_ip_route() -> impl IntoResponse { + handle_ip("route").await +} + +pub async fn get_ip_link() -> impl IntoResponse { + handle_ip("link").await +} + +pub async fn set_link_up(Path(ifname): Path) -> impl IntoResponse { + handle_link_state(&ifname, "up").await +} + +pub async fn set_link_down(Path(ifname): Path) -> impl IntoResponse { + handle_link_state(&ifname, "down").await +} + +pub async fn set_link_mtu( + Path(ifname): Path, + Json(payload): Json, +) -> impl IntoResponse { + if let Err(e) = validate_ifname(&ifname) { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: e }), + ) + .into_response(); + } + + if payload.mtu < 68 || payload.mtu > 65535 { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "mtu must be between 68 and 65535".to_string(), + }), + ) + .into_response(); + } + + let args = vec![ + "link".to_string(), + "set".to_string(), + "dev".to_string(), + ifname, + "mtu".to_string(), + payload.mtu.to_string(), + ]; + + match run_ip_cmd(&args).await { + Ok(()) => Json(OkResponse { status: "ok".to_string() }).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e }), + ) + .into_response(), + } +} + +pub async fn addr_modify(Json(payload): Json) -> impl IntoResponse { + if let Err(e) = validate_ifname(&payload.dev) { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: e }), + ) + .into_response(); + } + + let family = payload.family.unwrap_or(IpFamily::Inet); + let max_prefix = match family { + IpFamily::Inet => 32, + IpFamily::Inet6 => 128, + }; + if payload.prefix_len > max_prefix { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: format!("prefix_len must be <= {}", max_prefix), + }), + ) + .into_response(); + } + + let mut args: Vec = Vec::new(); + match family { + IpFamily::Inet => args.push("-4".to_string()), + IpFamily::Inet6 => args.push("-6".to_string()), + } + args.push("addr".to_string()); + args.push(match payload.action { + AddrAction::Add => "add".to_string(), + AddrAction::Del => "del".to_string(), + }); + args.push(format!("{}/{}", payload.address, payload.prefix_len)); + args.push("dev".to_string()); + args.push(payload.dev); + + if let Some(scope) = payload.scope { + args.push("scope".to_string()); + args.push(scope); + } + if let Some(label) = payload.label { + args.push("label".to_string()); + args.push(label); + } + + match run_ip_cmd(&args).await { + Ok(()) => Json(OkResponse { status: "ok".to_string() }).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e }), + ) + .into_response(), + } +} + +pub async fn route_modify(Json(payload): Json) -> impl IntoResponse { + if let Some(dev) = payload.dev.as_ref() { + if let Err(e) = validate_ifname(dev) { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: e }), + ) + .into_response(); + } + } + + if payload.gateway.is_none() && payload.dev.is_none() { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "either gateway or dev must be provided".to_string(), + }), + ) + .into_response(); + } + + let mut args: Vec = Vec::new(); + args.push("route".to_string()); + args.push(match payload.action { + RouteAction::Add => "add".to_string(), + RouteAction::Del => "del".to_string(), + RouteAction::Replace => "replace".to_string(), + }); + args.push(payload.destination); + + if let Some(gw) = payload.gateway { + args.push("via".to_string()); + args.push(gw); + } + if let Some(dev) = payload.dev { + args.push("dev".to_string()); + args.push(dev); + } + if let Some(metric) = payload.metric { + args.push("metric".to_string()); + args.push(metric.to_string()); + } + if let Some(table) = payload.table { + args.push("table".to_string()); + args.push(table); + } + if let Some(proto) = payload.proto { + args.push("proto".to_string()); + args.push(proto); + } + if let Some(scope) = payload.scope { + args.push("scope".to_string()); + args.push(scope); + } + + match run_ip_cmd(&args).await { + Ok(()) => Json(OkResponse { status: "ok".to_string() }).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e }), + ) + .into_response(), + } +} + +async fn handle_ip(subcmd: &str) -> impl IntoResponse { + match execute_ip_cmd(subcmd).await { + Ok(data) => Json(data).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e }), + ) + .into_response(), + } +} + +async fn handle_link_state(ifname: &str, state: &str) -> impl IntoResponse { + if let Err(e) = validate_ifname(ifname) { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: e }), + ) + .into_response(); + } + + let args = vec![ + "link".to_string(), + "set".to_string(), + "dev".to_string(), + ifname.to_string(), + state.to_string(), + ]; + + match run_ip_cmd(&args).await { + Ok(()) => Json(OkResponse { status: "ok".to_string() }).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e }), + ) + .into_response(), + } +} + +async fn run_ip_cmd(args: &[String]) -> Result<(), String> { + let output = Command::new("ip") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| format!("Failed to execute ip {}: {}", args.join(" "), e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("ip {} failed: {}", args.join(" "), stderr)); + } + + Ok(()) +} + +fn validate_ifname(name: &str) -> Result<(), String> { + if name.is_empty() { + return Err("ifname must not be empty".to_string()); + } + if name.len() > 64 { + return Err("ifname is слишком длинное".to_string()); + } + if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':' )) + { + return Err("ifname содержит недопустимые символы".to_string()); + } + Ok(()) +} + +async fn execute_ip_cmd(subcmd: &str) -> Result { + // Prefer native JSON output from iproute2 for a complete and robust payload. + let mut json_errors: Vec = Vec::new(); + + match execute_ip_cmd_json(subcmd, true).await { + Ok(value) => return Ok(value), + Err(e) => json_errors.push(e), + } + + // Some ip versions support JSON but not -details. + match execute_ip_cmd_json(subcmd, false).await { + Ok(value) => return Ok(value), + Err(e) => json_errors.push(e), + } + + // Fallback to legacy text parsing for older ip versions without JSON support. + match execute_ip_cmd_legacy(subcmd).await { + Ok(value) => Ok(value), + Err(legacy_err) => { + let mut msg = String::new(); + if !json_errors.is_empty() { + msg.push_str(&format!("JSON attempts failed: {}; ", json_errors.join(" | "))); + } + msg.push_str(&format!("legacy parsing failed: {}", legacy_err)); + Err(msg) + } + } +} + +async fn execute_ip_cmd_json(subcmd: &str, details: bool) -> Result { + let mut args: Vec<&str> = vec!["-j"]; + if details { + args.push("-details"); + } + args.push(subcmd); + + let output = Command::new("ip") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| format!("Failed to execute ip -j {}: {}", subcmd, e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("ip -j {} failed: {}", subcmd, stderr)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + serde_json::from_str::(&stdout) + .map_err(|e| format!("Failed to parse ip -j {} output: {}", subcmd, e)) +} + +async fn execute_ip_cmd_legacy(subcmd: &str) -> Result { + let output = Command::new("ip") + .arg(subcmd) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| format!("Failed to execute ip {}: {}", subcmd, e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("ip {} failed: {}", subcmd, stderr)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + let result = match subcmd { + "addr" => parse_ip_addr(&stdout)?, + "route" => parse_ip_route(&stdout)?, + "link" => parse_ip_link(&stdout)?, + _ => return Err(format!("Unknown subcommand: {}", subcmd)), + }; + + Ok(result) +} + + + +fn parse_ip_addr(output: &str) -> Result { + let mut interfaces = Vec::new(); + let mut current_interface: Option = None; + + for line in output.lines() { + let line = line.trim(); + + // Начало описания интерфейса: "1: lo: ..." + if let Some(idx) = line.split(':').next() { + if let Ok(index) = idx.parse::() { + if let Some(iface) = current_interface.take() { + interfaces.push(iface); + } + + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() >= 3 { + let name = parts[1].trim().trim_start_matches('@').to_string(); + let state = if line.contains("") || line.contains("UP") { + "UP" + } else if line.contains("DOWN") { + "DOWN" + } else { + "UNKNOWN" + }.to_string(); + + current_interface = Some(InterfaceInfo { + name, + index, + state, + mac_address: None, + ipv4_addresses: Vec::new(), + ipv6_addresses: Vec::new(), + }); + } + continue; + } + } + + if let Some(ref mut iface) = current_interface { + // MAC адрес: "link/ether aa:bb:cc:dd:ee:ff" + if line.starts_with("link/") { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 && parts[1] != "none" { + iface.mac_address = Some(parts[1].to_string()); + } + continue; + } + + // IPv4 адрес: "inet 192.168.1.1/24 brd ..." + if line.starts_with("inet ") { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + let addr_parts: Vec<&str> = parts[1].split('/').collect(); + if addr_parts.len() == 2 { + if let Ok(prefix) = addr_parts[1].parse::() { + let scope = if let Some(idx) = line.find("scope") { + line[idx..].split_whitespace().nth(1).unwrap_or("global").to_string() + } else { + "global".to_string() + }; + iface.ipv4_addresses.push(Ipv4Address { + address: addr_parts[0].to_string(), + prefix_len: prefix, + scope, + }); + } + } + } + continue; + } + + // IPv6 адрес: "inet6 ::1/128 scope ..." + if line.starts_with("inet6 ") { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + let addr_parts: Vec<&str> = parts[1].split('/').collect(); + if addr_parts.len() == 2 { + if let Ok(prefix) = addr_parts[1].parse::() { + let scope = if let Some(idx) = line.find("scope") { + line[idx..].split_whitespace().nth(1).unwrap_or("global").to_string() + } else { + "global".to_string() + }; + iface.ipv6_addresses.push(Ipv6Address { + address: addr_parts[0].to_string(), + prefix_len: prefix, + scope, + }); + } + } + } + continue; + } + } + } + + if let Some(iface) = current_interface { + interfaces.push(iface); + } + + Ok(serde_json::to_value(IpAddrInfo { interfaces }).unwrap()) +} + +fn parse_ip_route(output: &str) -> Result { + let mut routes = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + let parts: Vec<&str> = line.split_whitespace().collect(); + let mut route = RouteEntry { + destination: None, + gateway: None, + device: String::new(), + metric: None, + route_type: "unicast".to_string(), + }; + + let mut i = 0; + while i < parts.len() { + match parts[i] { + "default" => { + route.destination = Some("default".to_string()); + } + "via" => { + if i + 1 < parts.len() { + route.gateway = Some(parts[i + 1].to_string()); + i += 1; + } + } + "dev" => { + if i + 1 < parts.len() { + route.device = parts[i + 1].to_string(); + i += 1; + } + } + "metric" => { + if i + 1 < parts.len() { + route.metric = parts[i + 1].parse::().ok(); + i += 1; + } + } + "src" | "proto" | "scope" | "onlink" => { + // Пропускаем эти параметры + } + _ => { + if route.destination.is_none() && !["via", "dev", "metric", "src", "proto", "scope", "onlink"].contains(&parts[i]) { + route.destination = Some(parts[i].to_string()); + } + } + } + i += 1; + } + + if !route.device.is_empty() { + routes.push(route); + } + } + + Ok(serde_json::to_value(IpRouteInfo { routes }).unwrap()) +} + +fn parse_ip_link(output: &str) -> Result { + let mut links = Vec::new(); + let mut current_link: Option = None; + + for line in output.lines() { + let line = line.trim(); + + + if let Some(idx) = line.split(':').next() { + if let Ok(index) = idx.parse::() { + if let Some(link) = current_link.take() { + links.push(link); + } + + let parts: Vec<&str> = line.split(':').collect(); + let name = if parts.len() >= 3 { + parts[1].trim().trim_start_matches('@').to_string() + } else { + "unknown".to_string() + }; + + let state = if line.contains("") || line.contains("UP") { + "UP" + } else if line.contains("DOWN") { + "DOWN" + } else { + "UNKNOWN" + }.to_string(); + + current_link = Some(LinkInfo { + index, + name, + state, + mac_address: None, + mtu: 1500, + qdisc: None, + link_type: "unknown".to_string(), + }); + continue; + } + } + + if let Some(ref mut link) = current_link { + // link/ether или link/type + if line.starts_with("link/") { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + link.link_type = parts[0].trim_start_matches("link/").to_string(); + if parts[1] != "none" { + link.mac_address = Some(parts[1].to_string()); + } + } + continue; + } + + // mtu + if line.starts_with("mtu ") { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + link.mtu = parts[1].parse::().unwrap_or(1500); + } + continue; + } + + // qdisc + if let Some(idx) = line.find("qdisc ") { + let rest = &line[idx + 6..]; + if let Some(qdisc) = rest.split_whitespace().next() { + link.qdisc = Some(qdisc.to_string()); + } + continue; + } + } + } + + if let Some(link) = current_link { + links.push(link); + } + + Ok(serde_json::to_value(IpLinkInfo { links }).unwrap()) +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..317b7f0 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,3 @@ +pub mod ip; +pub mod nft; +pub mod auth; diff --git a/src/api/nft.rs b/src/api/nft.rs new file mode 100644 index 0000000..e61fbf5 --- /dev/null +++ b/src/api/nft.rs @@ -0,0 +1,463 @@ +use axum::{ + extract::Path, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::{Deserialize, Serialize}; +use std::process::Stdio; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; + +#[derive(Debug, Serialize)] +struct ErrorResponse { + error: String, +} + +#[derive(Debug, Serialize)] +struct OkResponse { + status: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum NftFamily { + Ip, + Ip6, + Inet, + Arp, + Bridge, + Netdev, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum TableAction { + Add, + Del, + Flush, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum ChainAction { + Add, + Del, + Flush, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum RuleAction { + Add, + Insert, + Del, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct TableRequest { + pub action: TableAction, + pub family: NftFamily, + pub table: String, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ChainRequest { + pub action: ChainAction, + pub family: NftFamily, + pub table: String, + pub chain: String, + #[serde(rename = "type")] + pub chain_type: Option, + pub hook: Option, + pub priority: Option, + pub policy: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct RuleRequest { + pub action: RuleAction, + pub family: NftFamily, + pub table: String, + pub chain: String, + pub expr: Option, + pub handle: Option, + pub position: Option, +} + +pub async fn get_nft_ruleset() -> impl IntoResponse { + handle_nft_list(vec!["list".to_string(), "ruleset".to_string()]).await +} + +pub async fn get_nft_tables() -> impl IntoResponse { + handle_nft_list(vec!["list".to_string(), "tables".to_string()]).await +} + +pub async fn get_nft_chains() -> impl IntoResponse { + handle_nft_list(vec!["list".to_string(), "chains".to_string()]).await +} + +pub async fn get_nft_rules(Path((family, table, chain)): Path<(String, String, String)>) -> impl IntoResponse { + let family = match normalize_family(&family) { + Ok(v) => v, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: e }), + ) + .into_response(); + } + }; + + if let Err(e) = validate_ident(&table, "table") { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: e }), + ) + .into_response(); + } + if let Err(e) = validate_ident(&chain, "chain") { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: e }), + ) + .into_response(); + } + + handle_nft_list(vec![ + "list".to_string(), + "chain".to_string(), + family, + table, + chain, + ]) + .await +} + +pub async fn table_modify(Json(payload): Json) -> impl IntoResponse { + if let Err(e) = validate_ident(&payload.table, "table") { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: e }), + ) + .into_response(); + } + + let family = family_to_str(&payload.family); + let cmd = match payload.action { + TableAction::Add => format!("add table {} {}", family, payload.table), + TableAction::Del => format!("delete table {} {}", family, payload.table), + TableAction::Flush => format!("flush table {} {}", family, payload.table), + }; + + match run_nft_script(&cmd).await { + Ok(()) => Json(OkResponse { status: "ok".to_string() }).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e }), + ) + .into_response(), + } +} + +pub async fn chain_modify(Json(payload): Json) -> impl IntoResponse { + if let Err(e) = validate_ident(&payload.table, "table") { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: e }), + ) + .into_response(); + } + if let Err(e) = validate_ident(&payload.chain, "chain") { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: e }), + ) + .into_response(); + } + + if let Some(ref t) = payload.chain_type { + if let Err(e) = validate_ident(t, "type") { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: e }), + ) + .into_response(); + } + } + if let Some(ref h) = payload.hook { + if let Err(e) = validate_ident(h, "hook") { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: e }), + ) + .into_response(); + } + } + if let Some(ref p) = payload.policy { + if let Err(e) = validate_ident(p, "policy") { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: e }), + ) + .into_response(); + } + } + + let family = family_to_str(&payload.family); + let base = match payload.action { + ChainAction::Add => "add chain", + ChainAction::Del => "delete chain", + ChainAction::Flush => "flush chain", + }; + + let mut cmd = format!("{} {} {} {}", base, family, payload.table, payload.chain); + + if let ChainAction::Add = payload.action { + if let Some(block) = build_chain_block(&payload) { + cmd.push(' '); + cmd.push_str(&block); + } + } + + match run_nft_script(&cmd).await { + Ok(()) => Json(OkResponse { status: "ok".to_string() }).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e }), + ) + .into_response(), + } +} + +pub async fn rule_modify(Json(payload): Json) -> impl IntoResponse { + if let Err(e) = validate_ident(&payload.table, "table") { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: e }), + ) + .into_response(); + } + if let Err(e) = validate_ident(&payload.chain, "chain") { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: e }), + ) + .into_response(); + } + + if let Some(ref expr) = payload.expr { + if let Err(e) = validate_expr(expr) { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: e }), + ) + .into_response(); + } + } + + let family = family_to_str(&payload.family); + let cmd = match payload.action { + RuleAction::Add => { + let expr = match payload.expr { + Some(e) => e, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: "expr is required for add".to_string() }), + ) + .into_response(); + } + }; + format!("add rule {} {} {} {}", family, payload.table, payload.chain, expr) + } + RuleAction::Insert => { + let expr = match payload.expr { + Some(e) => e, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: "expr is required for insert".to_string() }), + ) + .into_response(); + } + }; + if let Some(pos) = payload.position { + format!( + "insert rule {} {} {} position {} {}", + family, payload.table, payload.chain, pos, expr + ) + } else { + format!("insert rule {} {} {} {}", family, payload.table, payload.chain, expr) + } + } + RuleAction::Del => { + let handle = match payload.handle { + Some(h) => h, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: "handle is required for del".to_string() }), + ) + .into_response(); + } + }; + format!( + "delete rule {} {} {} handle {}", + family, payload.table, payload.chain, handle + ) + } + }; + + match run_nft_script(&cmd).await { + Ok(()) => Json(OkResponse { status: "ok".to_string() }).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e }), + ) + .into_response(), + } +} + +async fn handle_nft_list(args: Vec) -> Response { + match run_nft_json(&args).await { + Ok(data) => Json(data).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e }), + ) + .into_response(), + } +} + +async fn run_nft_json(args: &[String]) -> Result { + let output = Command::new("nft") + .arg("-j") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| format!("Failed to execute nft -j {}: {}", args.join(" "), e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("nft -j {} failed: {}", args.join(" "), stderr)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + serde_json::from_str::(&stdout) + .map_err(|e| format!("Failed to parse nft -j output: {}", e)) +} + +async fn run_nft_script(cmd: &str) -> Result<(), String> { + let mut child = Command::new("nft") + .arg("-f") + .arg("-") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to execute nft: {}", e))?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(cmd.as_bytes()) + .await + .map_err(|e| format!("Failed to write nft stdin: {}", e))?; + stdin + .write_all(b"\n") + .await + .map_err(|e| format!("Failed to write nft stdin: {}", e))?; + } + + let output = child + .wait_with_output() + .await + .map_err(|e| format!("Failed to wait for nft: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("nft failed: {}", stderr)); + } + + Ok(()) +} + +fn family_to_str(family: &NftFamily) -> &'static str { + match family { + NftFamily::Ip => "ip", + NftFamily::Ip6 => "ip6", + NftFamily::Inet => "inet", + NftFamily::Arp => "arp", + NftFamily::Bridge => "bridge", + NftFamily::Netdev => "netdev", + } +} + +fn normalize_family(value: &str) -> Result { + let v = value.to_ascii_lowercase(); + match v.as_str() { + "ip" | "ip6" | "inet" | "arp" | "bridge" | "netdev" => Ok(v), + _ => Err("unknown family".to_string()), + } +} + +fn validate_ident(value: &str, field: &str) -> Result<(), String> { + if value.is_empty() { + return Err(format!("{} must not be empty", field)); + } + if value.len() > 64 { + return Err(format!("{} is слишком длинное", field)); + } + if !value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':' )) + { + return Err(format!("{} содержит недопустимые символы", field)); + } + Ok(()) +} + +fn validate_expr(expr: &str) -> Result<(), String> { + if expr.trim().is_empty() { + return Err("expr must not be empty".to_string()); + } + if expr.contains('\n') || expr.contains('\r') { + return Err("expr must be single-line".to_string()); + } + if expr.contains(';') { + return Err("expr must not contain ';'".to_string()); + } + Ok(()) +} + +fn build_chain_block(req: &ChainRequest) -> Option { + let mut parts: Vec = Vec::new(); + + if req.chain_type.is_some() || req.hook.is_some() || req.priority.is_some() { + let mut line = Vec::new(); + if let Some(ref t) = req.chain_type { + line.push(format!("type {}", t)); + } + if let Some(ref h) = req.hook { + line.push(format!("hook {}", h)); + } + if let Some(p) = req.priority { + line.push(format!("priority {}", p)); + } + if !line.is_empty() { + parts.push(format!("{};", line.join(" "))); + } + } + + if let Some(ref p) = req.policy { + parts.push(format!("policy {};", p)); + } + + if parts.is_empty() { + None + } else { + Some(format!("{{ {} }}", parts.join(" "))) + } +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..2127a74 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,140 @@ +use axum::{ + extract::State, + body::Body, + http::{header::AUTHORIZATION, Request, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, + Json, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::Utc; +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; + +#[derive(Clone)] +pub struct AuthState { + pub secret: String, + pub token_ttl_seconds: i64, + pub pam_service: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Claims { + pub sub: String, + pub exp: usize, + pub iat: usize, +} + +#[derive(Debug, Serialize)] +struct ErrorResponse { + error: String, +} + +pub fn issue_token(username: &str, state: &AuthState) -> Result<(String, i64), String> { + let now = Utc::now().timestamp(); + let ttl = if state.token_ttl_seconds > 0 { + state.token_ttl_seconds + } else { + 3600 + }; + let exp = now + ttl; + + let claims = Claims { + sub: username.to_string(), + iat: now as usize, + exp: exp as usize, + }; + + let payload = serde_json::to_vec(&claims) + .map_err(|e| format!("token encode failed: {}", e))?; + let signature = sign(&payload, state.secret.as_bytes())?; + + let token = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(payload), + URL_SAFE_NO_PAD.encode(signature) + ); + + Ok((token, ttl)) +} + +pub fn validate_token(token: &str, state: &AuthState) -> Result { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 2 { + return Err("invalid token format".to_string()); + } + + let payload = URL_SAFE_NO_PAD + .decode(parts[0].as_bytes()) + .map_err(|_| "invalid token payload".to_string())?; + let signature = URL_SAFE_NO_PAD + .decode(parts[1].as_bytes()) + .map_err(|_| "invalid token signature".to_string())?; + + verify(&payload, &signature, state.secret.as_bytes())?; + + let claims: Claims = serde_json::from_slice(&payload) + .map_err(|_| "invalid token payload".to_string())?; + + let now = Utc::now().timestamp() as usize; + if claims.exp <= now { + return Err("token expired".to_string()); + } + + Ok(claims) +} + +pub async fn auth_middleware( + State(state): State, + mut req: Request, + next: Next, +) -> Response { + let auth_header = req + .headers() + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()); + + let token = match auth_header { + Some(value) if value.starts_with("Bearer ") => &value[7..], + _ => { + return ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: "missing or invalid Authorization header".to_string(), + }), + ) + .into_response(); + } + }; + + match validate_token(token, &state) { + Ok(claims) => { + req.extensions_mut().insert(claims); + next.run(req).await + } + Err(_) => ( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: "invalid or expired token".to_string(), + }), + ) + .into_response(), + } +} + +fn sign(payload: &[u8], secret: &[u8]) -> Result, String> { + let mut mac = Hmac::::new_from_slice(secret) + .map_err(|_| "invalid secret".to_string())?; + mac.update(payload); + Ok(mac.finalize().into_bytes().to_vec()) +} + +fn verify(payload: &[u8], signature: &[u8], secret: &[u8]) -> Result<(), String> { + let mut mac = Hmac::::new_from_slice(secret) + .map_err(|_| "invalid secret".to_string())?; + mac.update(payload); + mac.verify_slice(signature) + .map_err(|_| "invalid token signature".to_string())?; + Ok(()) +} diff --git a/src/config.rs b/src/config.rs index c3a8064..30757d0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,6 +6,14 @@ pub struct Config { pub host: String, pub port: u16, pub debug: bool, + pub auth: AuthConfig, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AuthConfig { + pub secret: String, + pub token_ttl_seconds: i64, + pub pam_service: String, } impl Config { diff --git a/src/main.rs b/src/main.rs index 121de74..54b4474 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use axum::{ - routing::get, + routing::{get, post}, + middleware, Router, response::{Html, IntoResponse}, }; @@ -11,6 +12,9 @@ use crate::config::Config; mod utils; use utils::logger; +mod api; +mod auth; + #[derive(Template)] #[template(path = "index.html")] struct IndexTemplate { @@ -48,10 +52,48 @@ async fn main() -> Result<(), Box> { let addr = format!("{}:{}", cfg.host, cfg.port); logger::success(format!("Running on {}", addr), "Core"); + let auth_state = auth::AuthState { + secret: cfg.auth.secret.clone(), + token_ttl_seconds: cfg.auth.token_ttl_seconds, + pam_service: cfg.auth.pam_service.clone(), + }; + + let protected_api = Router::new() + // IP API + .route("/ip/addr", get(api::ip::get_ip_addr)) + .route("/ip/addr", post(api::ip::addr_modify)) + .route("/ip/route", get(api::ip::get_ip_route)) + .route("/ip/route", post(api::ip::route_modify)) + .route("/ip/link", get(api::ip::get_ip_link)) + .route("/ip/link/:ifname/up", post(api::ip::set_link_up)) + .route("/ip/link/:ifname/down", post(api::ip::set_link_down)) + .route("/ip/link/:ifname/mtu", post(api::ip::set_link_mtu)) + // nftables API + .route("/nft/ruleset", get(api::nft::get_nft_ruleset)) + .route("/nft/tables", get(api::nft::get_nft_tables)) + .route("/nft/chains", get(api::nft::get_nft_chains)) + .route( + "/nft/rules/:family/:table/:chain", + get(api::nft::get_nft_rules), + ) + .route("/nft/table", post(api::nft::table_modify)) + .route("/nft/chain", post(api::nft::chain_modify)) + .route("/nft/rule", post(api::nft::rule_modify)) + .route_layer(middleware::from_fn_with_state( + auth_state.clone(), + auth::auth_middleware, + )); + + let api = Router::new() + .route("/auth/login", post(api::auth::login)) + .merge(protected_api); + // Build our application with routes let app = Router::new() .route("/", get(index)) - .route("/prefs", get(about)); + .route("/prefs", get(about)) + .nest("/api", api) + .with_state(auth_state); // Запускаем сервер let listener = tokio::net::TcpListener::bind(&addr).await?;