Compare commits

...

17 Commits
2.0.0 ... KL-S1

Author SHA1 Message Date
mbecker20
a2d9fa931e default rate limit attempts to 5 per 15 sec 2025-12-07 16:56:22 -08:00
mbecker20
1621043a21 KL-4 must fallback to axum extracted IP for cases not using reverse proxy 2025-12-04 19:31:53 -08:00
mbecker20
6fa5acd1e3 KL-4: Align log consistency 2025-12-01 13:26:45 -08:00
mbecker20
45d6b21a61 KL-1: core_allowed_origins example config should be empty 2025-12-01 12:53:06 -08:00
mbecker20
33bd744052 KL-8 modify action state internal behavior comments 2025-12-01 12:37:16 -08:00
mbecker20
967629545f KL-7 Improve typescript safety: disable allow any 2025-12-01 12:17:22 -08:00
mbecker20
581ef4f7da KL-6 Improve error handling in startup paths 2025-12-01 12:02:41 -08:00
mbecker20
ffc125e288 KL-6 Improve alert cache error handling 2025-12-01 11:42:38 -08:00
mbecker20
23430cc3cb KL-6 Remove monitoring_interval panic 2025-12-01 11:03:41 -08:00
mbecker20
d44be15c14 KL-5 ext: Tighten the skew tolerance re https://curity.io/resources/learn/jwt-best-practices/?utm_source=chatgpt.com#9-dealing-with-time-based-claims 2025-11-30 12:57:54 -08:00
mbecker20
afeb4ac526 KL-5 JWT clock skew tolerance 2025-11-30 12:54:15 -08:00
mbecker20
3e3639231d cargo clippy and bump to rust 1.91.1 2025-11-30 01:30:49 -08:00
mbecker20
dd3fb92eb8 KL-4 ext 2: Improve rate limiting / attempt state conveyance with response 2025-11-30 01:30:24 -08:00
mbecker20
30929838d2 KL-4 ext: Remove brute-force compromising credential failure reasons to improve auth rate limiter effectiveness 2025-11-29 17:53:29 -08:00
mbecker20
8b0c2299be KL-4 Authentication rate limiting 2025-11-29 17:31:46 -08:00
mbecker20
3164cdf5fe KL-2/3 Input validation for local auth, service users, api keys, and variables 2025-11-28 19:11:22 -08:00
mbecker20
4a50b780a6 KL-1 Configurable CORS support 2025-11-28 00:37:44 -08:00
67 changed files with 2018 additions and 801 deletions

127
Cargo.lock generated
View File

@@ -144,9 +144,9 @@ checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
[[package]]
name = "async-compression"
version = "0.4.33"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93c1f86859c1af3d514fa19e8323147ff10ea98684e6c7b307912509f50e67b2"
checksum = "0e86f6d3dc9dc4352edeea6b8e499e13e3f5dc3b964d7ca5fd411415a3498473"
dependencies = [
"compression-codecs",
"compression-core",
@@ -193,9 +193,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-config"
version = "1.8.10"
version = "1.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1856b1b48b65f71a4dd940b1c0931f9a7b646d4a924b9828ffefc1454714668a"
checksum = "a0149602eeaf915158e14029ba0c78dedb8c08d554b024d54c8f239aab46511d"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -223,9 +223,9 @@ dependencies = [
[[package]]
name = "aws-credential-types"
version = "1.2.9"
version = "1.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86590e57ea40121d47d3f2e131bfd873dea15d78dc2f4604f4734537ad9e56c4"
checksum = "b01c9521fa01558f750d183c8c68c81b0155b9d193a4ba7f84c36bd1b6d04a06"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -260,9 +260,9 @@ dependencies = [
[[package]]
name = "aws-runtime"
version = "1.5.14"
version = "1.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fe0fd441565b0b318c76e7206c8d1d0b0166b3e986cf30e890b61feb6192045"
checksum = "7ce527fb7e53ba9626fc47824f25e256250556c40d8f81d27dd92aa38239d632"
dependencies = [
"aws-credential-types",
"aws-sigv4",
@@ -284,9 +284,9 @@ dependencies = [
[[package]]
name = "aws-sdk-ec2"
version = "1.188.0"
version = "1.194.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce23f4f9971b682019024aed0bf0fd971df7bf86519e41cf2f7ec7a631049395"
checksum = "f55f9073b102788b24a8382fdadd27f5bf34d5ff4869eb88cdf251a455599e63"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -307,9 +307,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sso"
version = "1.89.0"
version = "1.90.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9c1b1af02288f729e95b72bd17988c009aa72e26dcb59b3200f86d7aea726c9"
checksum = "4f18e53542c522459e757f81e274783a78f8c81acdfc8d1522ee8a18b5fb1c66"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -329,9 +329,9 @@ dependencies = [
[[package]]
name = "aws-sdk-ssooidc"
version = "1.91.0"
version = "1.92.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e8122301558dc7c6c68e878af918880b82ff41897a60c8c4e18e4dc4d93e9f1"
checksum = "532f4d866012ffa724a4385c82e8dd0e59f0ca0e600f3f22d4c03b6824b34e4a"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -351,9 +351,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sts"
version = "1.92.0"
version = "1.94.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0c7808adcff8333eaa76a849e6de926c6ac1a1268b9fd6afe32de9c29ef29d2"
checksum = "1be6fbbfa1a57724788853a623378223fe828fc4c09b146c992f0c95b6256174"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -867,7 +867,7 @@ dependencies = [
"getrandom 0.2.16",
"getrandom 0.3.3",
"hex",
"indexmap 2.12.0",
"indexmap 2.12.1",
"js-sys",
"once_cell",
"rand 0.9.2",
@@ -1025,9 +1025,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.52"
version = "4.5.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8120877db0e5c011242f96806ce3c94e0737ab8108532a76a3300a01db2ab8"
checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8"
dependencies = [
"clap_builder",
"clap_derive",
@@ -1035,9 +1035,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.52"
version = "4.5.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02576b399397b659c26064fbc92a75fede9d18ffd5f80ca1cd74ddab167016e1"
checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00"
dependencies = [
"anstream",
"anstyle",
@@ -1110,9 +1110,9 @@ dependencies = [
[[package]]
name = "compression-codecs"
version = "0.4.32"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680dc087785c5230f8e8843e2e57ac7c1c90488b6a91b88caa265410568f441b"
checksum = "302266479cb963552d11bd042013a58ef1adc56768016c8b82b4199488f2d4ad"
dependencies = [
"compression-core",
"flate2",
@@ -1121,16 +1121,16 @@ dependencies = [
[[package]]
name = "compression-core"
version = "0.4.30"
version = "0.4.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a9b614a5787ef0c8802a55766480563cb3a93b435898c422ed2a359cf811582"
checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
[[package]]
name = "config"
version = "2.0.0-dev-91"
dependencies = [
"colored",
"indexmap 2.12.0",
"indexmap 2.12.1",
"regex",
"serde",
"serde_json",
@@ -1769,9 +1769,9 @@ dependencies = [
[[package]]
name = "english-to-cron"
version = "0.1.6"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e26fb7377cbec9a94f60428e6e6afbe10c699a14639b4d3d4b67b25c0bbe0806"
checksum = "3c3d16f6dc9dc43a9a2fd5bce09b6cf8df250dcf77cffdaa66be21c527e2d05c"
dependencies = [
"regex",
]
@@ -2107,7 +2107,7 @@ dependencies = [
"futures-sink",
"futures-util",
"http 0.2.12",
"indexmap 2.12.0",
"indexmap 2.12.1",
"slab",
"tokio",
"tokio-util",
@@ -2126,7 +2126,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http 1.3.1",
"indexmap 2.12.0",
"indexmap 2.12.1",
"slab",
"tokio",
"tokio-util",
@@ -2158,9 +2158,9 @@ dependencies = [
[[package]]
name = "hashbrown"
version = "0.16.0"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "headers"
@@ -2681,12 +2681,12 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.12.0"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f"
checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
dependencies = [
"equivalent",
"hashbrown 0.16.0",
"hashbrown 0.16.1",
"serde",
"serde_core",
]
@@ -2865,7 +2865,7 @@ dependencies = [
"derive_variants",
"envy",
"futures-util",
"indexmap 2.12.0",
"indexmap 2.12.1",
"ipnetwork",
"mongo_indexed",
"partial_derive2",
@@ -2922,7 +2922,7 @@ dependencies = [
"git",
"hex",
"hmac",
"indexmap 2.12.0",
"indexmap 2.12.1",
"interpolate",
"jsonwebtoken",
"komodo_client",
@@ -2931,6 +2931,7 @@ dependencies = [
"openidconnect",
"partial_derive2",
"periphery_client",
"rate_limit",
"regex",
"reqwest",
"resolver_api",
@@ -2957,6 +2958,7 @@ dependencies = [
"url",
"urlencoding",
"uuid",
"validations",
"wildcard",
]
@@ -4142,6 +4144,17 @@ dependencies = [
"getrandom 0.3.3",
]
[[package]]
name = "rate_limit"
version = "2.0.0-dev-91"
dependencies = [
"anyhow",
"axum",
"cache",
"serror",
"tokio",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -4653,7 +4666,7 @@ version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
dependencies = [
"indexmap 2.12.0",
"indexmap 2.12.1",
"itoa",
"memchr",
"ryu",
@@ -4734,7 +4747,7 @@ dependencies = [
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.12.0",
"indexmap 2.12.1",
"schemars 0.9.0",
"schemars 1.0.4",
"serde_core",
@@ -4761,7 +4774,7 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f"
dependencies = [
"indexmap 2.12.0",
"indexmap 2.12.1",
"itoa",
"ryu",
"serde",
@@ -5348,7 +5361,7 @@ version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8"
dependencies = [
"indexmap 2.12.0",
"indexmap 2.12.1",
"serde_core",
"serde_spanned",
"toml_datetime",
@@ -5440,7 +5453,7 @@ checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
dependencies = [
"futures-core",
"futures-util",
"indexmap 2.12.0",
"indexmap 2.12.1",
"pin-project-lite",
"slab",
"sync_wrapper",
@@ -5453,9 +5466,9 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.6.6"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456"
dependencies = [
"bitflags 2.9.4",
"bytes",
@@ -5493,9 +5506,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.41"
version = "0.1.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647"
dependencies = [
"log",
"pin-project-lite",
@@ -5505,9 +5518,9 @@ dependencies = [
[[package]]
name = "tracing-attributes"
version = "0.1.30"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
@@ -5516,9 +5529,9 @@ dependencies = [
[[package]]
name = "tracing-core"
version = "0.1.34"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c"
dependencies = [
"once_cell",
"valuable",
@@ -5566,9 +5579,9 @@ dependencies = [
[[package]]
name = "tracing-subscriber"
version = "0.3.20"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"nu-ansi-term",
"serde",
@@ -5804,6 +5817,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "validations"
version = "2.0.0-dev-91"
dependencies = [
"anyhow",
"bson",
"regex",
"url",
]
[[package]]
name = "valuable"
version = "0.1.1"

View File

@@ -26,7 +26,9 @@ environment_file = { path = "lib/environment_file" }
environment = { path = "lib/environment" }
interpolate = { path = "lib/interpolate" }
secret_file = { path = "lib/secret_file" }
validations = { path = "lib/validations" }
formatting = { path = "lib/formatting" }
rate_limit = { path = "lib/rate_limit" }
transport = { path = "lib/transport" }
database = { path = "lib/database" }
encoding = { path = "lib/encoding" }
@@ -64,13 +66,13 @@ arc-swap = "1.7.1"
# SERVER
tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-native-roots"] }
axum-extra = { version = "0.12.2", features = ["typed-header"] }
tower-http = { version = "0.6.6", features = ["fs", "cors"] }
tower-http = { version = "0.6.7", features = ["fs", "cors", "set-header"] }
axum-server = { version = "0.7.3", features = ["tls-rustls"] }
axum = { version = "0.8.7", features = ["ws", "json", "macros"] }
# SER/DE
ipnetwork = { version = "0.21.1", features = ["serde"] }
indexmap = { version = "2.12.0", features = ["serde"] }
indexmap = { version = "2.12.1", features = ["serde"] }
serde = { version = "1.0.227", features = ["derive"] }
strum = { version = "0.27.2", features = ["derive"] }
bson = { version = "2.15.0" } # must keep in sync with mongodb version
@@ -87,14 +89,14 @@ thiserror = "2.0.17"
# LOGGING
opentelemetry-otlp = { version = "0.31.0", features = ["tls-roots", "reqwest-rustls"] }
opentelemetry_sdk = { version = "0.31.0", features = ["rt-tokio"] }
tracing-subscriber = { version = "0.3.20", features = ["json"] }
tracing-subscriber = { version = "0.3.22", features = ["json"] }
opentelemetry-semantic-conventions = "0.31.0"
tracing-opentelemetry = "0.32.0"
opentelemetry = "0.31.0"
tracing = "0.1.41"
tracing = "0.1.43"
# CONFIG
clap = { version = "4.5.52", features = ["derive"] }
clap = { version = "4.5.53", features = ["derive"] }
dotenvy = "0.15.7"
envy = "0.4.2"
@@ -127,18 +129,18 @@ sysinfo = "0.37.1"
shlex = "1.3.0"
# CLOUD
aws-config = "1.8.10"
aws-sdk-ec2 = "1.188.0"
aws-credential-types = "1.2.9"
aws-config = "1.8.11"
aws-sdk-ec2 = "1.194.0"
aws-credential-types = "1.2.10"
## CRON
english-to-cron = "0.1.6"
english-to-cron = "0.1.7"
chrono-tz = "0.10.4"
chrono = "0.4.42"
croner = "3.0.1"
# MISC
async-compression = { version = "0.4.33", features = ["tokio", "gzip"] }
async-compression = { version = "0.4.34", features = ["tokio", "gzip"] }
derive_builder = "0.20.2"
comfy-table = "7.2.1"
typeshare = "1.0.4"

View File

@@ -1,7 +1,7 @@
## Builds the Komodo Core, Periphery, and Util binaries
## for a specific architecture.
FROM rust:1.90.0-bullseye AS builder
FROM rust:1.91.1-bullseye AS builder
RUN cargo install cargo-strip
WORKDIR /builder

View File

@@ -1,4 +1,4 @@
FROM rust:1.90.0-bullseye AS builder
FROM rust:1.91.1-bullseye AS builder
RUN cargo install cargo-strip
WORKDIR /builder

View File

@@ -20,7 +20,9 @@ periphery_client.workspace = true
environment_file.workspace = true
interpolate.workspace = true
secret_file.workspace = true
validations.workspace = true
formatting.workspace = true
rate_limit.workspace = true
transport.workspace = true
database.workspace = true
encoding.workspace = true

View File

@@ -1,7 +1,7 @@
## All in one, multi stage compile + runtime Docker build for your architecture.
# Build Core
FROM rust:1.90.0-trixie AS core-builder
FROM rust:1.91.1-trixie AS core-builder
RUN cargo install cargo-strip
WORKDIR /builder

View File

@@ -232,9 +232,9 @@ pub async fn send_alert(
format!(
"{level} | {message}{}",
if details.is_empty() {
format_args!("")
String::new()
} else {
format_args!("\n{details}")
format!("\n{details}")
}
)
}

View File

@@ -466,9 +466,9 @@ fn standard_alert_content(alert: &Alert) -> String {
format!(
"{level} | {message}{}",
if details.is_empty() {
format_args!("")
String::new()
} else {
format_args!("\n{details}")
format!("\n{details}")
}
)
}

View File

@@ -1,8 +1,18 @@
use std::{sync::OnceLock, time::Instant};
use std::{
net::{IpAddr, SocketAddr},
sync::OnceLock,
time::Instant,
};
use axum::{Router, extract::Path, http::HeaderMap, routing::post};
use axum::{
Router,
extract::{ConnectInfo, Path},
http::HeaderMap,
routing::post,
};
use derive_variants::{EnumVariants, ExtractVariant};
use komodo_client::{api::auth::*, entities::user::User};
use rate_limit::WithFailureRateLimit;
use reqwest::StatusCode;
use resolver_api::Resolve;
use response::Response;
@@ -21,14 +31,16 @@ use crate::{
},
config::core_config,
helpers::query::get_user,
state::jwt_client,
state::{auth_rate_limiter, jwt_client},
};
use super::Variant;
#[derive(Default)]
pub struct AuthArgs {
pub headers: HeaderMap,
/// Prefer extracting IP from headers.
/// This IP will be the IP of reverse proxy itself.
pub ip: IpAddr,
}
#[typeshare]
@@ -78,6 +90,7 @@ pub fn router() -> Router {
async fn variant_handler(
headers: HeaderMap,
info: ConnectInfo<SocketAddr>,
Path(Variant { variant }): Path<Variant>,
Json(params): Json<serde_json::Value>,
) -> serror::Result<axum::response::Response> {
@@ -85,11 +98,12 @@ async fn variant_handler(
"type": variant,
"params": params,
}))?;
handler(headers, Json(req)).await
handler(headers, info, Json(req)).await
}
async fn handler(
headers: HeaderMap,
ConnectInfo(info): ConnectInfo<SocketAddr>,
Json(request): Json<AuthRequest>,
) -> serror::Result<axum::response::Response> {
let timer = Instant::now();
@@ -98,7 +112,12 @@ async fn handler(
"/auth request {req_id} | METHOD: {:?}",
request.extract_variant()
);
let res = request.resolve(&AuthArgs { headers }).await;
let res = request
.resolve(&AuthArgs {
headers,
ip: info.ip(),
})
.await;
if let Err(e) = &res {
debug!("/auth request {req_id} | error: {:#}", e.error);
}
@@ -135,25 +154,37 @@ impl Resolve<AuthArgs> for GetLoginOptions {
impl Resolve<AuthArgs> for ExchangeForJwt {
async fn resolve(
self,
_: &AuthArgs,
AuthArgs { headers, ip }: &AuthArgs,
) -> serror::Result<ExchangeForJwtResponse> {
jwt_client()
.redeem_exchange_token(&self.token)
.with_failure_rate_limit_using_headers(
auth_rate_limiter(),
headers,
Some(*ip),
)
.await
.map_err(Into::into)
}
}
impl Resolve<AuthArgs> for GetUser {
async fn resolve(
self,
AuthArgs { headers }: &AuthArgs,
AuthArgs { headers, ip }: &AuthArgs,
) -> serror::Result<User> {
let user_id = get_user_id_from_headers(headers)
.await
.status_code(StatusCode::UNAUTHORIZED)?;
get_user(&user_id)
.await
.status_code(StatusCode::UNAUTHORIZED)
async {
let user_id = get_user_id_from_headers(headers)
.await
.status_code(StatusCode::UNAUTHORIZED)?;
get_user(&user_id)
.await
.status_code(StatusCode::UNAUTHORIZED)
}
.with_failure_rate_limit_using_headers(
auth_rate_limiter(),
headers,
Some(*ip),
)
.await
}
}

View File

@@ -5,10 +5,9 @@ use hmac::{Hmac, Mac};
use serde::Deserialize;
use sha2::Sha256;
use crate::{
config::core_config,
listener::{ExtractBranch, VerifySecret},
};
use crate::config::core_config;
use super::{ExtractBranch, VerifySecret};
type HmacSha256 = Hmac<Sha256>;
@@ -18,7 +17,7 @@ pub struct Github;
impl VerifySecret for Github {
#[instrument("VerifyGithubSecret", skip_all)]
fn verify_secret(
headers: HeaderMap,
headers: &HeaderMap,
body: &str,
custom_secret: &str,
) -> anyhow::Result<()> {

View File

@@ -1,10 +1,10 @@
use anyhow::{Context, anyhow};
use axum::http::HeaderMap;
use serde::Deserialize;
use crate::{
config::core_config,
listener::{ExtractBranch, VerifySecret},
};
use crate::config::core_config;
use super::{ExtractBranch, VerifySecret};
/// Listener implementation for Gitlab type API
pub struct Gitlab;
@@ -12,7 +12,7 @@ pub struct Gitlab;
impl VerifySecret for Gitlab {
#[instrument("VerifyGitlabSecret", skip_all)]
fn verify_secret(
headers: axum::http::HeaderMap,
headers: &HeaderMap,
_body: &str,
custom_secret: &str,
) -> anyhow::Result<()> {

View File

@@ -0,0 +1,4 @@
pub mod github;
pub mod gitlab;
use super::{ExtractBranch, VerifySecret};

View File

@@ -32,7 +32,7 @@ trait CustomSecret: KomodoResource {
/// Implemented on the integration struct, eg [integrations::github::Github]
trait VerifySecret {
fn verify_secret(
headers: HeaderMap,
headers: &HeaderMap,
body: &str,
custom_secret: &str,
) -> anyhow::Result<()>;

View File

@@ -1,14 +1,22 @@
use axum::{Router, extract::Path, http::HeaderMap, routing::post};
use std::net::{IpAddr, SocketAddr};
use axum::{
Router,
extract::{ConnectInfo, Path},
http::HeaderMap,
routing::post,
};
use komodo_client::entities::{
action::Action, build::Build, procedure::Procedure, repo::Repo,
resource::Resource, stack::Stack, sync::ResourceSync,
};
use rate_limit::WithFailureRateLimit;
use reqwest::StatusCode;
use serde::Deserialize;
use serror::AddStatusCode;
use tracing::Instrument;
use crate::resource::KomodoResource;
use crate::{resource::KomodoResource, state::auth_rate_limiter};
use super::{
CustomSecret, ExtractBranch, VerifySecret,
@@ -47,9 +55,9 @@ pub fn router<P: VerifySecret + ExtractBranch>() -> Router {
.route(
"/build/{id}",
post(
|Path(Id { id }), headers: HeaderMap, body: String| async move {
|Path(Id { id }), headers: HeaderMap, ConnectInfo(info): ConnectInfo<SocketAddr>, body: String| async move {
let build =
auth_webhook::<P, Build>(&id, headers, &body).await?;
auth_webhook::<P, Build>(&id, &headers, info.ip(), &body).await?;
tokio::spawn(async move {
let span = info_span!("BuildWebhook", id);
async {
@@ -73,9 +81,9 @@ pub fn router<P: VerifySecret + ExtractBranch>() -> Router {
.route(
"/repo/{id}/{option}",
post(
|Path(IdAndOption::<RepoWebhookOption> { id, option }), headers: HeaderMap, body: String| async move {
|Path(IdAndOption::<RepoWebhookOption> { id, option }), headers: HeaderMap, ConnectInfo(info): ConnectInfo<SocketAddr>, body: String| async move {
let repo =
auth_webhook::<P, Repo>(&id, headers, &body).await?;
auth_webhook::<P, Repo>(&id, &headers, info.ip(), &body).await?;
tokio::spawn(async move {
let span = info_span!("RepoWebhook", id);
async {
@@ -99,9 +107,9 @@ pub fn router<P: VerifySecret + ExtractBranch>() -> Router {
.route(
"/stack/{id}/{option}",
post(
|Path(IdAndOption::<StackWebhookOption> { id, option }), headers: HeaderMap, body: String| async move {
|Path(IdAndOption::<StackWebhookOption> { id, option }), headers: HeaderMap, ConnectInfo(info): ConnectInfo<SocketAddr>, body: String| async move {
let stack =
auth_webhook::<P, Stack>(&id, headers, &body).await?;
auth_webhook::<P, Stack>(&id, &headers, info.ip(), &body).await?;
tokio::spawn(async move {
let span = info_span!("StackWebhook", id);
async {
@@ -125,9 +133,9 @@ pub fn router<P: VerifySecret + ExtractBranch>() -> Router {
.route(
"/sync/{id}/{option}",
post(
|Path(IdAndOption::<SyncWebhookOption> { id, option }), headers: HeaderMap, body: String| async move {
|Path(IdAndOption::<SyncWebhookOption> { id, option }), headers: HeaderMap, ConnectInfo(info): ConnectInfo<SocketAddr>, body: String| async move {
let sync =
auth_webhook::<P, ResourceSync>(&id, headers, &body).await?;
auth_webhook::<P, ResourceSync>(&id, &headers, info.ip(), &body).await?;
tokio::spawn(async move {
let span = info_span!("ResourceSyncWebhook", id);
async {
@@ -151,9 +159,9 @@ pub fn router<P: VerifySecret + ExtractBranch>() -> Router {
.route(
"/procedure/{id}/{branch}",
post(
|Path(IdAndBranch { id, branch }), headers: HeaderMap, body: String| async move {
|Path(IdAndBranch { id, branch }), headers: HeaderMap, ConnectInfo(info): ConnectInfo<SocketAddr>, body: String| async move {
let procedure =
auth_webhook::<P, Procedure>(&id, headers, &body).await?;
auth_webhook::<P, Procedure>(&id, &headers, info.ip(), &body).await?;
tokio::spawn(async move {
let span = info_span!("ProcedureWebhook", id);
async {
@@ -177,9 +185,9 @@ pub fn router<P: VerifySecret + ExtractBranch>() -> Router {
.route(
"/action/{id}/{branch}",
post(
|Path(IdAndBranch { id, branch }), headers: HeaderMap, body: String| async move {
|Path(IdAndBranch { id, branch }), headers: HeaderMap, ConnectInfo(info): ConnectInfo<SocketAddr>, body: String| async move {
let action =
auth_webhook::<P, Action>(&id, headers, &body).await?;
auth_webhook::<P, Action>(&id, &headers, info.ip(), &body).await?;
tokio::spawn(async move {
let span = info_span!("ActionWebhook", id);
async {
@@ -204,17 +212,26 @@ pub fn router<P: VerifySecret + ExtractBranch>() -> Router {
async fn auth_webhook<P, R>(
id: &str,
headers: HeaderMap,
headers: &HeaderMap,
ip: IpAddr,
body: &str,
) -> serror::Result<Resource<R::Config, R::Info>>
where
P: VerifySecret,
R: KomodoResource + CustomSecret,
{
let resource = crate::resource::get::<R>(id)
.await
.status_code(StatusCode::BAD_REQUEST)?;
P::verify_secret(headers, body, R::custom_secret(&resource))
.status_code(StatusCode::UNAUTHORIZED)?;
Ok(resource)
async {
let resource = crate::resource::get::<R>(id)
.await
.status_code(StatusCode::BAD_REQUEST)?;
P::verify_secret(headers, body, R::custom_secret(&resource))
.status_code(StatusCode::UNAUTHORIZED)?;
serror::Result::Ok(resource)
}
.with_failure_rate_limit_using_headers(
auth_rate_limiter(),
headers,
Some(ip),
)
.await
}

View File

@@ -1,11 +1,70 @@
use axum::{
Router,
http::{HeaderName, HeaderValue},
routing::get,
};
use tower_http::{
services::{ServeDir, ServeFile},
set_header::SetResponseHeaderLayer,
};
use crate::{
config::{core_config, cors_layer},
ts_client,
};
pub mod auth;
pub mod execute;
pub mod read;
pub mod terminal;
pub mod user;
pub mod write;
mod listener;
mod terminal;
mod ws;
#[derive(serde::Deserialize)]
struct Variant {
variant: String,
}
pub fn app() -> Router {
let config = core_config();
// Setup static frontend services
let frontend_path = &config.frontend_path;
let frontend_index =
ServeFile::new(format!("{frontend_path}/index.html"));
let serve_frontend = ServeDir::new(frontend_path)
.not_found_service(frontend_index.clone());
Router::new()
.route("/version", get(|| async { env!("CARGO_PKG_VERSION") }))
.nest("/auth", auth::router())
.nest("/user", user::router())
.nest("/read", read::router())
.nest("/write", write::router())
.nest("/execute", execute::router())
.nest("/terminal", terminal::router())
.nest("/listener", listener::router())
.nest("/ws", ws::router())
.nest("/client", ts_client::router())
.fallback_service(serve_frontend)
.layer(cors_layer())
.layer(SetResponseHeaderLayer::overriding(
HeaderName::from_static("x-content-type-options"),
HeaderValue::from_static("nosniff"),
))
.layer(SetResponseHeaderLayer::overriding(
HeaderName::from_static("x-frame-options"),
HeaderValue::from_static("DENY"),
))
.layer(SetResponseHeaderLayer::overriding(
HeaderName::from_static("x-xss-protection"),
HeaderValue::from_static("1; mode=block"),
))
.layer(SetResponseHeaderLayer::overriding(
HeaderName::from_static("referrer-policy"),
HeaderValue::from_static("strict-origin-when-cross-origin"),
))
}

View File

@@ -14,13 +14,16 @@ use komodo_client::{
api::user::*,
entities::{api_key::ApiKey, komodo_timestamp, user::User},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use response::Response;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serror::{AddStatusCode, AddStatusCodeError};
use typeshare::typeshare;
use uuid::Uuid;
use crate::helpers::validations::validate_api_key_name;
use crate::{
auth::auth_request, helpers::query::get_user, state::db_client,
};
@@ -94,6 +97,9 @@ impl Resolve<UserArgs> for PushRecentlyViewed {
let user = get_user(&user.id).await?;
let (resource_type, id) = self.resource.extract_variant_id();
let field = format!("recents.{resource_type}");
let update = match user.recents.get(&resource_type) {
Some(recents) => {
let mut recents = recents
@@ -101,13 +107,16 @@ impl Resolve<UserArgs> for PushRecentlyViewed {
.filter(|_id| !id.eq(*_id))
.take(RECENTLY_VIEWED_MAX - 1)
.collect::<VecDeque<_>>();
recents.push_front(id);
doc! { format!("recents.{resource_type}"): to_bson(&recents)? }
doc! { &field: to_bson(&recents)? }
}
None => {
doc! { format!("recents.{resource_type}"): [id] }
doc! { &field: [id] }
}
};
update_one_by_id(
&db_client().users,
&user.id,
@@ -115,9 +124,7 @@ impl Resolve<UserArgs> for PushRecentlyViewed {
None,
)
.await
.with_context(|| {
format!("failed to update recents.{resource_type}")
})?;
.with_context(|| format!("Failed to update user '{field}'"))?;
Ok(PushRecentlyViewedResponse {})
}
@@ -137,7 +144,8 @@ impl Resolve<UserArgs> for SetLastSeenUpdate {
None,
)
.await
.context("failed to update user last_update_view")?;
.context("Failed to update user 'last_update_view'")?;
Ok(SetLastSeenUpdateResponse {})
}
}
@@ -157,10 +165,13 @@ impl Resolve<UserArgs> for CreateApiKey {
) -> serror::Result<CreateApiKeyResponse> {
let user = get_user(&user.id).await?;
validate_api_key_name(&self.name)
.status_code(StatusCode::BAD_REQUEST)?;
let key = format!("K-{}", random_string(SECRET_LENGTH));
let secret = format!("S-{}", random_string(SECRET_LENGTH));
let secret_hash = bcrypt::hash(&secret, BCRYPT_COST)
.context("failed at hashing secret string")?;
.context("Failed at hashing secret string")?;
let api_key = ApiKey {
name: self.name,
@@ -170,11 +181,13 @@ impl Resolve<UserArgs> for CreateApiKey {
created_at: komodo_timestamp(),
expires: self.expires,
};
db_client()
.api_keys
.insert_one(api_key)
.await
.context("failed to create api key on db")?;
.context("Failed to create api key on database")?;
Ok(CreateApiKeyResponse { key, secret })
}
}
@@ -190,20 +203,27 @@ impl Resolve<UserArgs> for DeleteApiKey {
UserArgs { user }: &UserArgs,
) -> serror::Result<DeleteApiKeyResponse> {
let client = db_client();
let key = client
.api_keys
.find_one(doc! { "key": &self.key })
.await
.context("failed at db query")?
.context("no api key with key found")?;
.context("Failed at database query")?
.context("No api key with key found")?;
if user.id != key.user_id {
return Err(anyhow!("api key does not belong to user").into());
return Err(
anyhow!("Api key does not belong to user")
.status_code(StatusCode::FORBIDDEN),
);
}
client
.api_keys
.delete_one(doc! { "key": key.key })
.await
.context("failed to delete api key from db")?;
.context("Failed to delete api key from database")?;
Ok(DeleteApiKeyResponse {})
}
}

View File

@@ -1,10 +1,5 @@
use std::str::FromStr;
use anyhow::{Context, anyhow};
use database::mungos::{
by_id::find_one_by_id,
mongodb::bson::{doc, oid::ObjectId},
};
use database::mungos::{by_id::find_one_by_id, mongodb::bson::doc};
use komodo_client::{
api::{user::CreateApiKey, write::*},
entities::{
@@ -12,9 +7,15 @@ use komodo_client::{
user::{User, UserConfig},
},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::{AddStatusCode as _, AddStatusCodeError as _};
use crate::{api::user::UserArgs, state::db_client};
use crate::{
api::user::UserArgs,
helpers::validations::{validate_api_key_name, validate_username},
state::db_client,
};
use super::WriteArgs;
@@ -33,16 +34,19 @@ impl Resolve<WriteArgs> for CreateServiceUser {
WriteArgs { user }: &WriteArgs,
) -> serror::Result<CreateServiceUserResponse> {
if !user.admin {
return Err(anyhow!("user not admin").into());
}
if ObjectId::from_str(&self.username).is_ok() {
return Err(
anyhow!("username cannot be valid ObjectId").into(),
anyhow!("Only Admins can manage Service Users")
.status_code(StatusCode::FORBIDDEN),
);
}
validate_username(&self.username)
.status_code(StatusCode::BAD_REQUEST)?;
let config = UserConfig::Service {
description: self.description,
};
let mut user = User {
id: Default::default(),
username: self.username,
@@ -57,6 +61,7 @@ impl Resolve<WriteArgs> for CreateServiceUser {
all: Default::default(),
updated_at: komodo_timestamp(),
};
user.id = db_client()
.users
.insert_one(&user)
@@ -66,6 +71,7 @@ impl Resolve<WriteArgs> for CreateServiceUser {
.as_object_id()
.context("inserted id is not object id")?
.to_string();
Ok(user)
}
}
@@ -85,18 +91,28 @@ impl Resolve<WriteArgs> for UpdateServiceUserDescription {
WriteArgs { user }: &WriteArgs,
) -> serror::Result<UpdateServiceUserDescriptionResponse> {
if !user.admin {
return Err(anyhow!("user not admin").into());
return Err(
anyhow!("Only Admins can manage Service Users")
.status_code(StatusCode::FORBIDDEN),
);
}
let db = db_client();
let service_user = db
.users
.find_one(doc! { "username": &self.username })
.await
.context("failed to query db for user")?
.context("no user with given username")?;
.context("Failed to query db for user")?
.context("No user with given username")?;
let UserConfig::Service { .. } = &service_user.config else {
return Err(anyhow!("user is not service user").into());
return Err(
anyhow!("Target user is not Service User")
.status_code(StatusCode::FORBIDDEN),
);
};
db.users
.update_one(
doc! { "username": &self.username },
@@ -104,13 +120,15 @@ impl Resolve<WriteArgs> for UpdateServiceUserDescription {
)
.await
.context("failed to update user on db")?;
let res = db
let service_user = db
.users
.find_one(doc! { "username": &self.username })
.await
.context("failed to query db for user")?
.context("user with username not found")?;
Ok(res)
Ok(service_user)
}
}
@@ -130,16 +148,28 @@ impl Resolve<WriteArgs> for CreateApiKeyForServiceUser {
WriteArgs { user }: &WriteArgs,
) -> serror::Result<CreateApiKeyForServiceUserResponse> {
if !user.admin {
return Err(anyhow!("user not admin").into());
return Err(
anyhow!("Only Admins can manage Service Users")
.status_code(StatusCode::FORBIDDEN),
);
}
validate_api_key_name(&self.name)
.status_code(StatusCode::BAD_REQUEST)?;
let service_user =
find_one_by_id(&db_client().users, &self.user_id)
.await
.context("failed to query db for user")?
.context("no user found with id")?;
.context("Failed to query db for user")?
.context("No user found with id")?;
let UserConfig::Service { .. } = &service_user.config else {
return Err(anyhow!("user is not service user").into());
return Err(
anyhow!("Target user is not Service User")
.status_code(StatusCode::FORBIDDEN),
);
};
CreateApiKey {
name: self.name,
expires: self.expires,
@@ -163,23 +193,34 @@ impl Resolve<WriteArgs> for DeleteApiKeyForServiceUser {
WriteArgs { user }: &WriteArgs,
) -> serror::Result<DeleteApiKeyForServiceUserResponse> {
if !user.admin {
return Err(anyhow!("user not admin").into());
return Err(
anyhow!("Only Admins can manage Service Users")
.status_code(StatusCode::FORBIDDEN),
);
}
let db = db_client();
let api_key = db
.api_keys
.find_one(doc! { "key": &self.key })
.await
.context("failed to query db for api key")?
.context("did not find matching api key")?;
let service_user =
find_one_by_id(&db_client().users, &api_key.user_id)
.await
.context("failed to query db for user")?
.context("no user found with id")?;
let UserConfig::Service { .. } = &service_user.config else {
return Err(anyhow!("user is not service user").into());
return Err(
anyhow!("Target user is not Service User")
.status_code(StatusCode::FORBIDDEN),
);
};
db.api_keys
.delete_one(doc! { "key": self.key })
.await

View File

@@ -15,9 +15,13 @@ use komodo_client::{
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::AddStatusCodeError;
use serror::{AddStatusCode as _, AddStatusCodeError};
use crate::{config::core_config, state::db_client};
use crate::{
config::core_config,
helpers::validations::{validate_password, validate_username},
state::db_client,
};
use super::WriteArgs;
@@ -38,24 +42,15 @@ impl Resolve<WriteArgs> for CreateLocalUser {
) -> serror::Result<CreateLocalUserResponse> {
if !admin.admin {
return Err(
anyhow!("This method is admin-only.")
anyhow!("This method is Admin Only.")
.status_code(StatusCode::FORBIDDEN),
);
}
if self.username.is_empty() {
return Err(anyhow!("Username cannot be empty.").into());
}
if ObjectId::from_str(&self.username).is_ok() {
return Err(
anyhow!("Username cannot be valid ObjectId").into(),
);
}
if self.password.is_empty() {
return Err(anyhow!("Password cannot be empty.").into());
}
validate_username(&self.username)
.status_code(StatusCode::BAD_REQUEST)?;
validate_password(&self.password)
.status_code(StatusCode::BAD_REQUEST)?;
let db = db_client();
@@ -130,17 +125,11 @@ impl Resolve<WriteArgs> for UpdateUserUsername {
);
}
}
if self.username.is_empty() {
return Err(anyhow!("Username cannot be empty.").into());
}
if ObjectId::from_str(&self.username).is_ok() {
return Err(
anyhow!("Username cannot be valid ObjectId").into(),
);
}
validate_username(&self.username)?;
let db = db_client();
if db
.users
.find_one(doc! { "username": &self.username })
@@ -150,8 +139,10 @@ impl Resolve<WriteArgs> for UpdateUserUsername {
{
return Err(anyhow!("Username already taken.").into());
}
let id = ObjectId::from_str(&user.id)
.context("User id not valid ObjectId.")?;
db.users
.update_one(
doc! { "_id": id },
@@ -159,6 +150,7 @@ impl Resolve<WriteArgs> for UpdateUserUsername {
)
.await
.context("Failed to update user username on database.")?;
Ok(NoData {})
}
}
@@ -185,7 +177,12 @@ impl Resolve<WriteArgs> for UpdateUserPassword {
);
}
}
validate_password(&self.password)
.status_code(StatusCode::BAD_REQUEST)?;
db_client().set_user_password(user, &self.password).await?;
Ok(NoData {})
}
}
@@ -211,15 +208,19 @@ impl Resolve<WriteArgs> for DeleteUser {
.status_code(StatusCode::FORBIDDEN),
);
}
if admin.username == self.user || admin.id == self.user {
return Err(anyhow!("User cannot delete themselves.").into());
}
let query = if let Ok(id) = ObjectId::from_str(&self.user) {
doc! { "_id": id }
} else {
doc! { "username": self.user }
};
let db = db_client();
let Some(user) = db
.users
.find_one(query.clone())
@@ -230,21 +231,25 @@ impl Resolve<WriteArgs> for DeleteUser {
anyhow!("No user found with given id / username").into(),
);
};
if user.super_admin {
return Err(
anyhow!("Cannot delete a super admin user.").into(),
);
}
if user.admin && !admin.super_admin {
return Err(
anyhow!("Only a Super Admin can delete an admin user.")
.into(),
);
}
db.users
.delete_one(query)
.await
.context("Failed to delete user from database")?;
// Also remove user id from all user groups
if let Err(e) = db
.user_groups
@@ -253,6 +258,7 @@ impl Resolve<WriteArgs> for DeleteUser {
{
warn!("Failed to remove deleted user from user groups | {e:?}");
};
Ok(user)
}
}

View File

@@ -6,12 +6,13 @@ use komodo_client::{
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::AddStatusCodeError;
use serror::{AddStatusCode as _, AddStatusCodeError};
use crate::{
helpers::{
query::get_variable,
update::{add_update, make_update},
validations::{validate_variable_name, validate_variable_value},
},
state::db_client,
};
@@ -35,7 +36,7 @@ impl Resolve<WriteArgs> for CreateVariable {
) -> serror::Result<CreateVariableResponse> {
if !user.admin {
return Err(
anyhow!("Only admins can create variables")
anyhow!("Only Admins can create Variables")
.status_code(StatusCode::FORBIDDEN),
);
}
@@ -47,6 +48,11 @@ impl Resolve<WriteArgs> for CreateVariable {
is_secret,
} = self;
validate_variable_name(&name)
.status_code(StatusCode::BAD_REQUEST)?;
validate_variable_value(&value)
.status_code(StatusCode::BAD_REQUEST)?;
let variable = Variable {
name,
value,
@@ -58,7 +64,7 @@ impl Resolve<WriteArgs> for CreateVariable {
.variables
.insert_one(&variable)
.await
.context("Failed to create variable on db")?;
.context("Failed to create Variable on db")?;
let mut update = make_update(
ResourceTarget::system(),
@@ -67,7 +73,8 @@ impl Resolve<WriteArgs> for CreateVariable {
);
update
.push_simple_log("create variable", format!("{variable:#?}"));
.push_simple_log("Create Variable", format!("{variable:#?}"));
update.finalize();
add_update(update).await?;
@@ -91,13 +98,18 @@ impl Resolve<WriteArgs> for UpdateVariableValue {
) -> serror::Result<UpdateVariableValueResponse> {
if !user.admin {
return Err(
anyhow!("Only admins can update variables")
anyhow!("Only Admins can update Variables")
.status_code(StatusCode::FORBIDDEN),
);
}
let UpdateVariableValue { name, value } = self;
validate_variable_name(&name)
.status_code(StatusCode::BAD_REQUEST)?;
validate_variable_value(&value)
.status_code(StatusCode::BAD_REQUEST)?;
let variable = get_variable(&name).await?;
if value == variable.value {
@@ -156,10 +168,11 @@ impl Resolve<WriteArgs> for UpdateVariableDescription {
) -> serror::Result<UpdateVariableDescriptionResponse> {
if !user.admin {
return Err(
anyhow!("Only admins can update variables")
anyhow!("Only Admins can update Variables")
.status_code(StatusCode::FORBIDDEN),
);
}
db_client()
.variables
.update_one(
@@ -168,6 +181,7 @@ impl Resolve<WriteArgs> for UpdateVariableDescription {
)
.await
.context("Failed to update variable description on db")?;
Ok(get_variable(&self.name).await?)
}
}
@@ -188,10 +202,11 @@ impl Resolve<WriteArgs> for UpdateVariableIsSecret {
) -> serror::Result<UpdateVariableIsSecretResponse> {
if !user.admin {
return Err(
anyhow!("Only admins can update variables")
anyhow!("Only Admins can update Variables")
.status_code(StatusCode::FORBIDDEN),
);
}
db_client()
.variables
.update_one(
@@ -199,7 +214,8 @@ impl Resolve<WriteArgs> for UpdateVariableIsSecret {
doc! { "$set": { "is_secret": self.is_secret } },
)
.await
.context("Failed to update variable is secret on db")?;
.context("Failed to update Variable 'is_secret' on db")?;
Ok(get_variable(&self.name).await?)
}
}
@@ -219,16 +235,18 @@ impl Resolve<WriteArgs> for DeleteVariable {
) -> serror::Result<DeleteVariableResponse> {
if !user.admin {
return Err(
anyhow!("Only admins can delete variables")
anyhow!("Only Admins can delete Variables")
.status_code(StatusCode::FORBIDDEN),
);
}
let variable = get_variable(&self.name).await?;
db_client()
.variables
.delete_one(doc! { "name": &self.name })
.await
.context("Failed to delete variable on db")?;
.context("Failed to delete Variable on db")?;
let mut update = make_update(
ResourceTarget::system(),

101
bin/core/src/api/ws/mod.rs Normal file
View File

@@ -0,0 +1,101 @@
use std::net::IpAddr;
use crate::{
auth::{auth_api_key_check_enabled, auth_jwt_check_enabled},
helpers::query::get_user,
state::auth_rate_limiter,
};
use anyhow::{Context, anyhow};
use axum::{
Router,
extract::ws::{self, WebSocket},
http::HeaderMap,
routing::get,
};
use komodo_client::{entities::user::User, ws::WsLoginMessage};
use rate_limit::WithFailureRateLimit;
use reqwest::StatusCode;
use serror::{AddStatusCode, AddStatusCodeError};
mod terminal;
mod update;
pub fn router() -> Router {
Router::new()
// Periphery facing
.route("/periphery", get(crate::connection::server::handler))
// User facing
.route("/update", get(update::handler))
.route("/terminal", get(terminal::handler))
}
async fn user_ws_login(
mut socket: WebSocket,
headers: &HeaderMap,
fallback_ip: IpAddr,
) -> Option<(WebSocket, User)> {
let res = async {
let message = match socket
.recv()
.await
.context("Failed to receive message over socket: Closed")
.status_code(StatusCode::BAD_REQUEST)?
.context("Failed to recieve message over socket: Error")
.status_code(StatusCode::BAD_REQUEST)?
{
ws::Message::Text(utf8_bytes) => utf8_bytes.to_string(),
ws::Message::Binary(bytes) => String::from_utf8(bytes.into())
.context("Received invalid message bytes: Not UTF-8")
.status_code(StatusCode::BAD_REQUEST)?,
message => {
return Err(
anyhow!("Received invalid message: {message:?}")
.status_code(StatusCode::BAD_REQUEST),
);
}
};
match WsLoginMessage::from_json_str(&message)
.context("Invalid login message")
.status_code(StatusCode::BAD_REQUEST)?
{
WsLoginMessage::Jwt { jwt } => auth_jwt_check_enabled(&jwt)
.await
.status_code(StatusCode::UNAUTHORIZED),
WsLoginMessage::ApiKeys { key, secret } => {
auth_api_key_check_enabled(&key, &secret)
.await
.status_code(StatusCode::UNAUTHORIZED)
}
}
}
.with_failure_rate_limit_using_headers(
auth_rate_limiter(),
headers,
Some(fallback_ip),
)
.await;
match res {
Ok(user) => {
let _ = socket.send(ws::Message::text("LOGGED_IN")).await;
Some((socket, user))
}
Err(e) => {
let _ = socket
.send(ws::Message::text(format!(
"[{}]: {:#}",
e.status, e.error
)))
.await;
None
}
}
}
async fn check_user_valid(user_id: &str) -> anyhow::Result<User> {
let user = get_user(user_id).await?;
if !user.enabled {
return Err(anyhow!("User not enabled"));
}
Ok(user)
}

View File

@@ -1,7 +1,9 @@
use std::net::SocketAddr;
use anyhow::anyhow;
use axum::{
extract::{FromRequestParts, WebSocketUpgrade, ws},
http::request,
extract::{ConnectInfo, FromRequestParts, WebSocketUpgrade, ws},
http::{HeaderMap, request},
response::IntoResponse,
};
use bytes::Bytes;
@@ -22,11 +24,14 @@ use crate::{
#[instrument("ConnectTerminal", skip(ws))]
pub async fn handler(
Qs(query): Qs<ConnectTerminalQuery>,
ConnectInfo(info): ConnectInfo<SocketAddr>,
headers: HeaderMap,
ws: WebSocketUpgrade,
) -> impl IntoResponse {
ws.on_upgrade(|socket| async move {
let ip = info.ip();
ws.on_upgrade(move |socket| async move {
let Some((mut client_socket, user)) =
super::user_ws_login(socket).await
super::user_ws_login(socket, &headers, ip).await
else {
return;
};

View File

@@ -1,6 +1,9 @@
use std::net::SocketAddr;
use anyhow::anyhow;
use axum::{
extract::{WebSocketUpgrade, ws::Message},
extract::{ConnectInfo, WebSocketUpgrade, ws::Message},
http::HeaderMap,
response::IntoResponse,
};
use futures_util::{SinkExt, StreamExt};
@@ -16,17 +19,24 @@ use crate::helpers::{
channel::update_channel, query::get_user_permission_on_target,
};
pub async fn handler(ws: WebSocketUpgrade) -> impl IntoResponse {
pub async fn handler(
headers: HeaderMap,
ConnectInfo(info): ConnectInfo<SocketAddr>,
ws: WebSocketUpgrade,
) -> impl IntoResponse {
// get a reveiver for internal update messages.
let mut receiver = update_channel().receiver.resubscribe();
let ip = info.ip();
// handle http -> ws updgrade
ws.on_upgrade(|socket| async move {
let Some((socket, user)) = super::user_ws_login(socket).await else {
return
ws.on_upgrade(move |socket| async move {
let Some((client_socket, user)) =
super::user_ws_login(socket, &headers, ip).await
else {
return;
};
let (mut ws_sender, mut ws_reciever) = socket.split();
let (mut ws_sender, mut ws_reciever) = client_socket.split();
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();

View File

@@ -1,20 +1,28 @@
use std::net::SocketAddr;
use anyhow::{Context, anyhow};
use axum::{
Router, extract::Query, response::Redirect, routing::get,
Router,
extract::{ConnectInfo, Query},
http::HeaderMap,
response::Redirect,
routing::get,
};
use database::mongo_indexed::Document;
use database::mungos::mongodb::bson::doc;
use futures_util::TryFutureExt;
use komodo_client::entities::{
komodo_timestamp, random_string,
user::{User, UserConfig},
};
use rate_limit::WithFailureRateLimit;
use reqwest::StatusCode;
use serde::Deserialize;
use serror::AddStatusCode;
use serror::{AddStatusCode, AddStatusCodeError as _};
use crate::{
config::core_config,
state::{db_client, jwt_client},
state::{auth_rate_limiter, db_client, jwt_client},
};
use self::client::github_oauth_client;
@@ -28,21 +36,31 @@ pub fn router() -> Router {
.route(
"/login",
get(|Query(query): Query<RedirectQuery>| async {
Redirect::to(
&github_oauth_client()
.as_ref()
// OK: the router is only mounted in case that the client is populated
.unwrap()
.get_login_redirect_url(query.redirect)
.await,
)
let uri = github_oauth_client()
.as_ref()
.context("Github Oauth not configured")
.status_code(StatusCode::UNAUTHORIZED)?
.get_login_redirect_url(query.redirect)
.await;
serror::Result::Ok(Redirect::to(&uri))
}),
)
.route(
"/callback",
get(|query| async {
callback(query).await.status_code(StatusCode::UNAUTHORIZED)
}),
get(
|query,
headers: HeaderMap,
ConnectInfo(info): ConnectInfo<SocketAddr>| async move {
callback(query)
.map_err(|e| e.status_code(StatusCode::UNAUTHORIZED))
.with_failure_rate_limit_using_headers(
auth_rate_limiter(),
&headers,
Some(info.ip()),
)
.await
},
),
)
}

View File

@@ -1,21 +1,29 @@
use std::net::SocketAddr;
use anyhow::{Context, anyhow};
use async_timing_util::unix_timestamp_ms;
use axum::{
Router, extract::Query, response::Redirect, routing::get,
Router,
extract::{ConnectInfo, Query},
http::HeaderMap,
response::Redirect,
routing::get,
};
use database::mongo_indexed::Document;
use database::mungos::mongodb::bson::doc;
use futures_util::TryFutureExt;
use komodo_client::entities::{
random_string,
user::{User, UserConfig},
};
use rate_limit::WithFailureRateLimit;
use reqwest::StatusCode;
use serde::Deserialize;
use serror::AddStatusCode;
use serror::{AddStatusCode, AddStatusCodeError as _};
use crate::{
config::core_config,
state::{db_client, jwt_client},
state::{auth_rate_limiter, db_client, jwt_client},
};
use self::client::google_oauth_client;
@@ -29,21 +37,31 @@ pub fn router() -> Router {
.route(
"/login",
get(|Query(query): Query<RedirectQuery>| async move {
Redirect::to(
&google_oauth_client()
.as_ref()
// OK: its not mounted unless the client is populated
.unwrap()
.get_login_redirect_url(query.redirect)
.await,
)
let uri = google_oauth_client()
.as_ref()
.context("Google Oauth not configured")
.status_code(StatusCode::UNAUTHORIZED)?
.get_login_redirect_url(query.redirect)
.await;
serror::Result::Ok(Redirect::to(&uri))
}),
)
.route(
"/callback",
get(|query| async {
callback(query).await.status_code(StatusCode::UNAUTHORIZED)
}),
get(
|query,
headers: HeaderMap,
ConnectInfo(info): ConnectInfo<SocketAddr>| async move {
callback(query)
.map_err(|e| e.status_code(StatusCode::UNAUTHORIZED))
.with_failure_rate_limit_using_headers(
auth_rate_limiter(),
&headers,
Some(info.ip()),
)
.await
},
),
)
}

View File

@@ -12,9 +12,13 @@ use komodo_client::{
api::auth::JwtResponse,
entities::{config::core::CoreConfig, random_string},
};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use serror::{AddStatusCode as _, AddStatusCodeError as _};
use tokio::sync::Mutex;
use crate::auth::EXCHANGE_TOKEN_CLOCK_SKEW_TOLERANCE_MS;
type ExchangeTokenMap = Mutex<HashMap<String, (JwtResponse, u128)>>;
#[derive(Serialize, Deserialize, Clone)]
@@ -64,14 +68,14 @@ impl JwtClient {
exp,
};
let jwt = encode(&self.header, &claims, &self.encoding_key)
.context("failed at signing claim")?;
.context("Failed at signing claim")?;
Ok(JwtResponse { user_id, jwt })
}
pub fn decode(&self, jwt: &str) -> anyhow::Result<JwtClaims> {
decode::<JwtClaims>(jwt, &self.decoding_key, &self.validation)
.map(|res| res.claims)
.context("failed to decode token claims")
.context("Failed to decode token claims")
}
pub async fn create_exchange_token(
@@ -93,17 +97,26 @@ impl JwtClient {
pub async fn redeem_exchange_token(
&self,
exchange_token: &str,
) -> anyhow::Result<JwtResponse> {
) -> serror::Result<JwtResponse> {
let (jwt, valid_until) = self
.exchange_tokens
.lock()
.await
.remove(exchange_token)
.context("invalid exchange token: unrecognized")?;
if unix_timestamp_ms() < valid_until {
.context("Invalid exchange token")
.status_code(StatusCode::UNAUTHORIZED)?;
// Apply clock skew tolerance.
// Token is valid if expiration is greater than (now - tolerance)
if valid_until
> unix_timestamp_ms()
.saturating_sub(EXCHANGE_TOKEN_CLOCK_SKEW_TOLERANCE_MS)
{
Ok(jwt)
} else {
Err(anyhow!("invalid exchange token: expired"))
Err(
anyhow!("Invalid exchange token")
.status_code(StatusCode::UNAUTHORIZED),
)
}
}
}

View File

@@ -1,10 +1,10 @@
use std::str::FromStr;
use std::sync::{Arc, OnceLock};
use anyhow::{Context, anyhow};
use async_timing_util::unix_timestamp_ms;
use database::{
hash_password,
mungos::mongodb::bson::{Document, doc, oid::ObjectId},
mungos::mongodb::bson::{Document, doc},
};
use komodo_client::{
api::auth::{
@@ -13,136 +13,190 @@ use komodo_client::{
},
entities::user::{User, UserConfig},
};
use rate_limit::{RateLimiter, WithFailureRateLimit};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::{AddStatusCode as _, AddStatusCodeError};
use crate::{
api::auth::AuthArgs,
config::core_config,
state::{db_client, jwt_client},
helpers::validations::{validate_password, validate_username},
state::{auth_rate_limiter, db_client, jwt_client},
};
impl Resolve<AuthArgs> for SignUpLocalUser {
#[instrument("SignUpLocalUser", skip(self))]
async fn resolve(
self,
_: &AuthArgs,
AuthArgs { headers, ip }: &AuthArgs,
) -> serror::Result<SignUpLocalUserResponse> {
let core_config = core_config();
if !core_config.local_auth {
return Err(anyhow!("Local auth is not enabled").into());
}
if self.username.is_empty() {
return Err(anyhow!("Username cannot be empty string").into());
}
if ObjectId::from_str(&self.username).is_ok() {
return Err(
anyhow!("Username cannot be valid ObjectId").into(),
);
}
if self.password.is_empty() {
return Err(anyhow!("Password cannot be empty string").into());
}
let db = db_client();
let no_users_exist =
db.users.find_one(Document::new()).await?.is_none();
if !no_users_exist && core_config.disable_user_registration {
return Err(anyhow!("User registration is disabled").into());
}
if db
.users
.find_one(doc! { "username": &self.username })
sign_up_local_user(self)
.with_failure_rate_limit_using_headers(
auth_rate_limiter(),
headers,
Some(*ip),
)
.await
.context("Failed to query for existing users")?
.is_some()
{
return Err(anyhow!("Username already taken.").into());
}
let ts = unix_timestamp_ms() as i64;
let hashed_password = hash_password(self.password)?;
let user = User {
id: Default::default(),
username: self.username,
enabled: no_users_exist || core_config.enable_new_users,
admin: no_users_exist,
super_admin: no_users_exist,
create_server_permissions: no_users_exist,
create_build_permissions: no_users_exist,
updated_at: ts,
last_update_view: 0,
recents: Default::default(),
all: Default::default(),
config: UserConfig::Local {
password: hashed_password,
},
};
let user_id = db_client()
.users
.insert_one(user)
.await
.context("failed to create user")?
.inserted_id
.as_object_id()
.context("inserted_id is not ObjectId")?
.to_string();
jwt_client()
.encode(user_id.clone())
.context("failed to generate jwt for user")
.map_err(Into::into)
}
}
async fn sign_up_local_user(
req: SignUpLocalUser,
) -> serror::Result<SignUpLocalUserResponse> {
let config = core_config();
if !config.local_auth {
return Err(anyhow!("Local auth is not enabled").into());
}
validate_username(&req.username)
.status_code(StatusCode::BAD_REQUEST)?;
validate_password(&req.password)
.status_code(StatusCode::BAD_REQUEST)?;
let db = db_client();
let no_users_exist =
db.users.find_one(Document::new()).await?.is_none();
if !no_users_exist && config.disable_user_registration {
return Err(
anyhow!("User registration is disabled")
.status_code(StatusCode::UNAUTHORIZED),
);
}
if db
.users
.find_one(doc! { "username": &req.username })
.await
.context("Failed to query for existing users")?
.is_some()
{
// When user registration is enabled, there is no way around allowing
// potential attackers to gain some insight about which usernames exist
// if they are allowed to register accounts. Since this can be easily inferred,
// might as well be clear. The auth rate limiter is critical here.
return Err(
anyhow!("Username already taken.")
.status_code(StatusCode::BAD_REQUEST),
);
}
let ts = unix_timestamp_ms() as i64;
let hashed_password = hash_password(req.password)?;
let user = User {
id: Default::default(),
username: req.username,
enabled: no_users_exist || config.enable_new_users,
admin: no_users_exist,
super_admin: no_users_exist,
create_server_permissions: no_users_exist,
create_build_permissions: no_users_exist,
updated_at: ts,
last_update_view: 0,
recents: Default::default(),
all: Default::default(),
config: UserConfig::Local {
password: hashed_password,
},
};
let user_id = db_client()
.users
.insert_one(user)
.await
.context("Failed to create user on database")?
.inserted_id
.as_object_id()
.context("The 'inserted_id' is not ObjectId")?
.to_string();
jwt_client()
.encode(user_id)
.context("Failed to generate JWT for user")
.map_err(Into::into)
}
/// Local login method has a dedicated rate limiter
/// so the UI background calls using existing JWT do
/// not influence the number of attempts user has
/// to log in.
fn login_local_user_rate_limiter() -> &'static RateLimiter {
static LOGIN_LOCAL_USER_RATE_LIMITER: OnceLock<Arc<RateLimiter>> =
OnceLock::new();
LOGIN_LOCAL_USER_RATE_LIMITER.get_or_init(|| {
let config = core_config();
RateLimiter::new(
config.auth_rate_limit_disabled,
config.auth_rate_limit_max_attempts as usize,
config.auth_rate_limit_window_seconds,
)
})
}
impl Resolve<AuthArgs> for LoginLocalUser {
async fn resolve(
self,
_: &AuthArgs,
AuthArgs { headers, ip }: &AuthArgs,
) -> serror::Result<LoginLocalUserResponse> {
if !core_config().local_auth {
return Err(anyhow!("local auth is not enabled").into());
}
let user = db_client()
.users
.find_one(doc! { "username": &self.username })
login_local_user(self)
.with_failure_rate_limit_using_headers(
login_local_user_rate_limiter(),
headers,
Some(*ip),
)
.await
.context("failed at db query for users")?
.with_context(|| {
format!("did not find user with username {}", self.username)
})?;
let UserConfig::Local {
password: user_pw_hash,
} = user.config
else {
return Err(
anyhow!(
"non-local auth users can not log in with a password"
)
.into(),
);
};
let verified = bcrypt::verify(self.password, &user_pw_hash)
.context("failed at verify password")?;
if !verified {
return Err(anyhow!("invalid credentials").into());
}
jwt_client()
.encode(user.id.clone())
.context("failed at generating jwt for user")
.map_err(Into::into)
}
}
async fn login_local_user(
req: LoginLocalUser,
) -> serror::Result<LoginLocalUserResponse> {
if !core_config().local_auth {
return Err(
anyhow!("Local auth is not enabled")
.status_code(StatusCode::UNAUTHORIZED),
);
}
validate_username(&req.username)
.status_code(StatusCode::BAD_REQUEST)?;
let user = db_client()
.users
.find_one(doc! { "username": &req.username })
.await
.context("Failed at db query for users")?
.context("Invalid login credentials")
.status_code(StatusCode::UNAUTHORIZED)?;
let UserConfig::Local {
password: user_pw_hash,
} = user.config
else {
return Err(
anyhow!("Invalid login credentials")
.status_code(StatusCode::UNAUTHORIZED),
);
};
let verified = bcrypt::verify(req.password, &user_pw_hash)
.context("Invalid login credentials")
.status_code(StatusCode::UNAUTHORIZED)?;
if !verified {
return Err(
anyhow!("Invalid login credentials")
.status_code(StatusCode::UNAUTHORIZED),
);
}
jwt_client()
.encode(user.id)
// This is in internal error (500), not auth error
.context("Failed to generate JWT for user")
.map_err(Into::into)
}

View File

@@ -1,18 +1,24 @@
use std::net::SocketAddr;
use anyhow::{Context, anyhow};
use async_timing_util::unix_timestamp_ms;
use axum::{
extract::Request, http::HeaderMap, middleware::Next,
extract::{ConnectInfo, Request},
http::HeaderMap,
middleware::Next,
response::Response,
};
use database::mungos::mongodb::bson::doc;
use futures_util::TryFutureExt;
use komodo_client::entities::{komodo_timestamp, user::User};
use rate_limit::WithFailureRateLimit;
use reqwest::StatusCode;
use serde::Deserialize;
use serror::AddStatusCode;
use serror::AddStatusCodeError as _;
use crate::{
helpers::query::get_user,
state::{db_client, jwt_client},
state::{auth_rate_limiter, db_client, jwt_client},
};
use self::jwt::JwtClaims;
@@ -24,7 +30,14 @@ pub mod oidc;
mod local;
/// Length of random token in Oauth / OIDC 'state'
const STATE_PREFIX_LENGTH: usize = 20;
/// JWT Clock skew tolerance in milliseconds (10 seconds for JWTs)
const JWT_CLOCK_SKEW_TOLERANCE_MS: u128 = 10 * 1000;
/// Exchange Token Clock skew tolerance in milliseconds (5 seconds for Exchange tokens)
const EXCHANGE_TOKEN_CLOCK_SKEW_TOLERANCE_MS: u128 = 5 * 1000;
/// Api Key Clock skew tolerance in milliseconds (5 minutes for Api Keys)
const API_KEY_CLOCK_SKEW_TOLERANCE_MS: i64 = 5 * 60 * 1000;
#[derive(Debug, Deserialize)]
struct RedirectQuery {
@@ -36,9 +49,18 @@ pub async fn auth_request(
mut req: Request,
next: Next,
) -> serror::Result<Response> {
let fallback = req
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|addr| addr.ip());
let user = authenticate_check_enabled(&headers)
.await
.status_code(StatusCode::UNAUTHORIZED)?;
.map_err(|e| e.status_code(StatusCode::UNAUTHORIZED))
.with_failure_rate_limit_using_headers(
auth_rate_limiter(),
&headers,
fallback,
)
.await?;
req.extensions_mut().insert(user);
Ok(next.run(req).await)
}
@@ -53,23 +75,21 @@ pub async fn get_user_id_from_headers(
) {
(Some(jwt), _, _) => {
// USE JWT
let jwt = jwt.to_str().context("jwt is not str")?;
auth_jwt_get_user_id(jwt)
.await
.context("failed to authenticate jwt")
let jwt = jwt.to_str().context("JWT is not valid UTF-8")?;
auth_jwt_get_user_id(jwt).await
}
(None, Some(key), Some(secret)) => {
// USE API KEY / SECRET
let key = key.to_str().context("key is not str")?;
let secret = secret.to_str().context("secret is not str")?;
auth_api_key_get_user_id(key, secret)
.await
.context("failed to authenticate api key")
let key =
key.to_str().context("X-API-KEY is not valid UTF-8")?;
let secret =
secret.to_str().context("X-API-SECRET is not valid UTF-8")?;
auth_api_key_get_user_id(key, secret).await
}
_ => {
// AUTH FAIL
Err(anyhow!(
"must attach either AUTHORIZATION header with jwt OR pass X-API-KEY and X-API-SECRET"
"Must attach either AUTHORIZATION header with jwt OR pass X-API-KEY and X-API-SECRET"
))
}
}
@@ -79,22 +99,30 @@ pub async fn authenticate_check_enabled(
headers: &HeaderMap,
) -> anyhow::Result<User> {
let user_id = get_user_id_from_headers(headers).await?;
let user = get_user(&user_id).await?;
let user = get_user(&user_id)
.await
.map_err(|_| anyhow!("Invalid user credentials"))?;
if user.enabled {
Ok(user)
} else {
Err(anyhow!("user not enabled"))
Err(anyhow!("Invalid user credentials"))
}
}
pub async fn auth_jwt_get_user_id(
jwt: &str,
) -> anyhow::Result<String> {
let claims: JwtClaims = jwt_client().decode(jwt)?;
if claims.exp > unix_timestamp_ms() {
let claims: JwtClaims = jwt_client()
.decode(jwt)
.map_err(|_| anyhow!("Invalid user credentials"))?;
// Apply clock skew tolerance.
// Token is valid if expiration is greater than (now - tolerance)
if claims.exp
> unix_timestamp_ms().saturating_sub(JWT_CLOCK_SKEW_TOLERANCE_MS)
{
Ok(claims.id)
} else {
Err(anyhow!("token has expired"))
Err(anyhow!("Invalid user credentials"))
}
}
@@ -113,19 +141,25 @@ pub async fn auth_api_key_get_user_id(
.api_keys
.find_one(doc! { "key": key })
.await
.context("failed to query db")?
.context("no api key matching key")?;
if key.expires != 0 && key.expires < komodo_timestamp() {
return Err(anyhow!("api key expired"));
.context("Failed to query db")?
.context("Invalid user credentials")?;
// Apply clock skew tolerance.
// Token is invalid if expiration is less than (now - tolerance)
if key.expires != 0
&& key.expires
< komodo_timestamp()
.saturating_sub(API_KEY_CLOCK_SKEW_TOLERANCE_MS)
{
return Err(anyhow!("Invalid user credentials"));
}
if bcrypt::verify(secret, &key.secret)
.context("failed to verify secret hash")?
.map_err(|_| anyhow!("Invalid user credentials"))?
{
// secret matches
Ok(key.user_id)
} else {
// secret mismatch
Err(anyhow!("invalid api secret"))
Err(anyhow!("Invalid user credentials"))
}
}
@@ -142,6 +176,6 @@ async fn check_enabled(user_id: String) -> anyhow::Result<User> {
if user.enabled {
Ok(user)
} else {
Err(anyhow!("user not enabled"))
Err(anyhow!("Invalid user credentials"))
}
}

View File

@@ -1,12 +1,17 @@
use std::sync::OnceLock;
use std::{net::SocketAddr, sync::OnceLock};
use anyhow::{Context, anyhow};
use axum::{
Router, extract::Query, response::Redirect, routing::get,
Router,
extract::{ConnectInfo, Query},
http::HeaderMap,
response::Redirect,
routing::get,
};
use client::oidc_client;
use dashmap::DashMap;
use database::mungos::mongodb::bson::{Document, doc};
use futures_util::TryFutureExt;
use komodo_client::entities::{
komodo_timestamp, random_string,
user::{User, UserConfig},
@@ -17,13 +22,14 @@ use openidconnect::{
PkceCodeChallenge, PkceCodeVerifier, Scope, TokenResponse,
core::{CoreAuthenticationFlow, CoreGenderClaim},
};
use rate_limit::WithFailureRateLimit;
use reqwest::StatusCode;
use serde::Deserialize;
use serror::AddStatusCode;
use serror::{AddStatusCode as _, AddStatusCodeError};
use crate::{
config::core_config,
state::{db_client, jwt_client},
state::{auth_rate_limiter, db_client, jwt_client},
};
use super::RedirectQuery;
@@ -68,9 +74,20 @@ pub fn router() -> Router {
)
.route(
"/callback",
get(|query| async {
callback(query).await.status_code(StatusCode::UNAUTHORIZED)
}),
get(
|query,
headers: HeaderMap,
ConnectInfo(info): ConnectInfo<SocketAddr>| async move {
callback(query)
.map_err(|e| e.status_code(StatusCode::UNAUTHORIZED))
.with_failure_rate_limit_using_headers(
auth_rate_limiter(),
&headers,
Some(info.ip()),
)
.await
},
),
)
}

View File

@@ -1,6 +1,7 @@
use std::{path::PathBuf, sync::OnceLock};
use anyhow::Context;
use axum::http::HeaderValue;
use colored::Colorize;
use config::ConfigLoader;
use environment_file::{
@@ -14,6 +15,7 @@ use komodo_client::entities::{
logger::LogConfig,
};
use noise::key::{RotatableKeyPair, SpkiPublicKey};
use tower_http::cors::CorsLayer;
/// Should call in startup to ensure Core errors without valid private key.
pub fn core_keys() -> &'static RotatableKeyPair {
@@ -89,6 +91,52 @@ pub fn periphery_public_keys() -> Option<&'static [SpkiPublicKey]> {
.as_deref()
}
/// Creates a CORS layer based on the Core configuration.
///
/// - If `cors_allowed_origins` is empty: Allows all origins (backward compatibility)
/// - If `cors_allowed_origins` is set: Only allows the specified origins
/// - Methods and headers are always allowed (Any)
/// - Credentials are only allowed if `cors_allow_credentials` is true
pub fn cors_layer() -> CorsLayer {
let config = core_config();
let mut cors = CorsLayer::new()
.allow_methods(tower_http::cors::Any)
.allow_headers(tower_http::cors::Any)
.allow_credentials(config.cors_allow_credentials);
if config.cors_allowed_origins.is_empty() {
cors = cors.allow_origin(tower_http::cors::Any)
} else {
cors = cors.allow_origin(
config
.cors_allowed_origins
.iter()
.filter_map(|origin| {
HeaderValue::from_str(origin)
.inspect_err(|e| {
warn!("Invalid CORS allowed origin: {origin} | {e:?}")
})
.ok()
})
.collect::<Vec<_>>(),
);
};
cors
}
pub fn monitoring_interval() -> async_timing_util::Timelength {
static MONITORING_INTERVAL: OnceLock<
async_timing_util::Timelength,
> = OnceLock::new();
*MONITORING_INTERVAL.get_or_init(|| {
core_config().monitoring_interval.try_into().unwrap_or_else(
|_| {
error!("Invalid 'monitoring_interval', using default 15-sec");
async_timing_util::Timelength::FifteenSeconds
},
)
})
}
pub fn core_config() -> &'static CoreConfig {
static CORE_CONFIG: OnceLock<CoreConfig> = OnceLock::new();
CORE_CONFIG.get_or_init(|| {
@@ -281,6 +329,21 @@ pub fn core_config() -> &'static CoreConfig {
.komodo_frontend_path
.unwrap_or(config.frontend_path),
jwt_ttl: env.komodo_jwt_ttl.unwrap_or(config.jwt_ttl),
auth_rate_limit_disabled: env
.komodo_auth_rate_limit_disabled
.unwrap_or(config.auth_rate_limit_disabled),
auth_rate_limit_max_attempts: env
.komodo_auth_rate_limit_max_attempts
.unwrap_or(config.auth_rate_limit_max_attempts),
auth_rate_limit_window_seconds: env
.komodo_auth_rate_limit_window_seconds
.unwrap_or(config.auth_rate_limit_window_seconds),
cors_allowed_origins: env
.komodo_cors_allowed_origins
.unwrap_or(config.cors_allowed_origins),
cors_allow_credentials: env
.komodo_cors_allow_credentials
.unwrap_or(config.cors_allow_credentials),
sync_directory: env
.komodo_sync_directory
.unwrap_or(config.sync_directory),
@@ -336,6 +399,9 @@ pub fn core_config() -> &'static CoreConfig {
.komodo_lock_login_credentials_for
.unwrap_or(config.lock_login_credentials_for),
local_auth: env.komodo_local_auth.unwrap_or(config.local_auth),
min_password_length: env
.komodo_min_password_length
.unwrap_or(config.min_password_length),
logging: LogConfig {
level: env
.komodo_logging_level

View File

@@ -1,3 +1,16 @@
//! # Action State Management
//!
//! This module provides thread-safe state management for resource exections.
//! It prevents concurrent execution of exections on the same resource using
//! a Mutex-based locking mechanism with RAII guards.
//!
//! ## Safety
//!
//! - Uses RAII pattern to ensure locks are always released
//! - Handles lock poisoning gracefully
//! - Prevents race conditions through per-resource locks
//! - No deadlock risk: each resource has independent locks
use std::sync::{Arc, Mutex};
use anyhow::anyhow;
@@ -29,7 +42,16 @@ pub struct ActionStates {
CloneCache<String, Arc<ActionState<ResourceSyncActionState>>>,
}
/// Need to be able to check "busy" with write lock acquired.
/// Thread-safe state container for resource executions.
///
/// Uses a Mutex to prevent concurrent executions and provides
/// RAII-based locking through [UpdateGuard].
///
/// # Safety
///
/// - Each resource has its own ActionState instance
/// - State is reset to default when [UpdateGuard] is dropped
/// - Lock poisoning error handling is handled gracefully with anyhow::Error
#[derive(Default)]
pub struct ActionState<States: Default + Send + 'static>(
Mutex<States>,
@@ -43,7 +65,7 @@ impl<States: Default + Busy + Copy + Send + 'static>
*self
.0
.lock()
.map_err(|e| anyhow!("action state lock poisoned | {e:?}"))?,
.map_err(|e| anyhow!("Action state lock poisoned | {e:?}"))?,
)
}
@@ -52,14 +74,33 @@ impl<States: Default + Busy + Copy + Send + 'static>
self
.0
.lock()
.map_err(|e| anyhow!("action state lock poisoned | {e:?}"))?
.map_err(|e| anyhow!("Action state lock poisoned | {e:?}"))?
.busy(),
)
}
/// Will acquire lock, check busy, and if not will
/// run the provided update function on the states.
/// Returns a guard that returns the states to default (not busy) when dropped.
/// Acquires lock, checks if resource is busy, and if not,
/// runs the provided update function on the states.
///
/// Returns an `UpdateGuard` that automatically resets the state
/// to default (not busy) when dropped.
///
/// # Errors
///
/// Returns an error if:
/// - The lock is poisoned
/// - The resource is currently busy
///
/// # Example
///
/// ```rust
/// let guard = action_state.update(|state| {
/// *state = SomeNewState;
/// })?;
/// // State is locked and marked as busy
/// // ... perform work ...
/// drop(guard) // Guard is dropped, state returns to default
/// ```
pub fn update(
&self,
update_fn: impl Fn(&mut States),
@@ -92,10 +133,22 @@ impl<States: Default + Busy + Copy + Send + 'static>
}
}
/// When dropped will return the inner state to default.
/// The inner mutex guard must already be dropped BEFORE this is dropped,
/// which is guaranteed as the inner guard is dropped by all public methods before
/// user could drop UpdateGuard.
/// RAII guard that automatically resets the action state when dropped.
///
/// # Safety
///
/// The inner mutex guard is guaranteed to be dropped before this guard
/// is dropped, preventing deadlocks. This is ensured by all public methods
/// that create UpdateGuard instances.
///
/// # Behavior
///
/// When dropped, this guard will:
/// 1. Re-acquire the lock
/// 2. Call the provided return function (typically resetting to default)
/// 3. Release the lock
///
/// If the lock is poisoned, an error is logged but the drop continues.
pub struct UpdateGuard<'a, States: Default + Send + 'static>(
&'a Mutex<States>,
Box<dyn Fn(&mut States) + Send>,

View File

@@ -33,8 +33,7 @@ pub mod query;
pub mod swarm;
pub mod terminal;
pub mod update;
// pub mod resource;
pub mod validations;
pub fn empty_or_only_spaces(word: &str) -> bool {
if word.is_empty() {

View File

@@ -56,8 +56,8 @@ pub async fn get_user(user: &str) -> anyhow::Result<User> {
.users
.find_one(id_or_username_filter(user))
.await
.context("failed to query mongo for user")?
.with_context(|| format!("no user found with {user}"))
.context("Failed to query mongo for user")?
.with_context(|| format!("No user found matching '{user}'"))
}
pub async fn get_server_with_state(

View File

@@ -0,0 +1,85 @@
//! # Input Validation Module
//!
//! This module provides validation functions for user inputs to prevent
//! invalid data from entering the system and improve security.
use anyhow::Context;
use validations::{StringValidator, StringValidatorMatches};
use crate::config::core_config;
/// Minimum length for usernames
pub const MIN_USERNAME_LENGTH: usize = 1;
/// Maximum length for usernames
pub const MAX_USERNAME_LENGTH: usize = 100;
/// Validate usernames
///
/// - Between [MIN_USERNAME_LENGTH] and [MAX_USERNAME_LENGTH] characters
/// - Matches `^[a-zA-Z0-9._@-]+$`
pub fn validate_username(username: &str) -> anyhow::Result<()> {
StringValidator::default()
.min_length(MIN_USERNAME_LENGTH)
.max_length(MAX_USERNAME_LENGTH)
.matches(StringValidatorMatches::Username)
.validate(username)
.context("Failed to validate username")
}
/// Maximum length for passwords
pub const MAX_PASSWORD_LENGTH: usize = 1000;
/// Validate passwords
///
/// - Between [CoreConfig::min_password_length][komodo_client::entities::config::core::CoreConfig::min_password_length] and [MAX_PASSWORD_LENGTH] characters
pub fn validate_password(password: &str) -> anyhow::Result<()> {
StringValidator::default()
.min_length(core_config().min_password_length as usize)
.max_length(MAX_PASSWORD_LENGTH)
.validate(password)
.context("Failed to validate password")
}
/// Maximum length for API key names
pub const MAX_API_KEY_NAME_LENGTH: usize = 200;
/// Validate api key names
///
/// - Greater than [MAX_API_KEY_NAME_LENGTH] characters
pub fn validate_api_key_name(name: &str) -> anyhow::Result<()> {
StringValidator::default()
.max_length(MAX_API_KEY_NAME_LENGTH)
.validate(name)
.context("Failed to validate api key name")
}
/// Minimum length for variable names
pub const MIN_VARIABLE_NAME_LENGTH: usize = 1;
/// Maximum length for variable names
pub const MAX_VARIABLE_NAME_LENGTH: usize = 500;
/// Validate variable names
///
/// - Between [MIN_VARIABLE_NAME_LENGTH] and [MAX_VARIABLE_NAME_LENGTH] characters
/// - Matches `^[a-zA-Z_][a-zA-Z0-9_]*$`
pub fn validate_variable_name(name: &str) -> anyhow::Result<()> {
StringValidator::default()
.min_length(MIN_VARIABLE_NAME_LENGTH)
.max_length(MAX_VARIABLE_NAME_LENGTH)
.matches(StringValidatorMatches::VariableName)
.validate(name)
.context("Failed to validate variable name")
}
/// Maximum length for variable values
pub const MAX_VARIABLE_VALUE_LENGTH: usize = 10000;
/// Validate variable values
///
/// - Less than [MAX_VARIABLE_VALUE_LENGTH] characters
pub fn validate_variable_value(value: &str) -> anyhow::Result<()> {
StringValidator::default()
.max_length(MAX_VARIABLE_VALUE_LENGTH)
.validate(value)
.context("Failed to validate variable value")
}

View File

@@ -1,2 +0,0 @@
pub mod github;
pub mod gitlab;

View File

@@ -6,12 +6,7 @@ extern crate tracing;
use std::{net::SocketAddr, str::FromStr};
use anyhow::Context;
use axum::{Router, routing::get};
use axum_server::{Handle, tls_rustls::RustlsConfig};
use tower_http::{
cors::{Any, CorsLayer},
services::{ServeDir, ServeFile},
};
use tracing::Instrument;
use crate::config::{core_config, core_keys};
@@ -23,7 +18,6 @@ mod cloud;
mod config;
mod connection;
mod helpers;
mod listener;
mod monitor;
mod network;
mod periphery;
@@ -35,7 +29,6 @@ mod startup;
mod state;
mod sync;
mod ts_client;
mod ws;
async fn app() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
@@ -89,32 +82,8 @@ async fn app() -> anyhow::Result<()> {
.instrument(startup_span)
.await;
// Setup static frontend services
let frontend_path = &config.frontend_path;
let frontend_index =
ServeFile::new(format!("{frontend_path}/index.html"));
let serve_frontend = ServeDir::new(frontend_path)
.not_found_service(frontend_index.clone());
let app = Router::new()
.route("/version", get(|| async { env!("CARGO_PKG_VERSION") }))
.nest("/auth", api::auth::router())
.nest("/user", api::user::router())
.nest("/read", api::read::router())
.nest("/write", api::write::router())
.nest("/execute", api::execute::router())
.nest("/terminal", api::terminal::router())
.nest("/listener", listener::router())
.nest("/ws", ws::router())
.nest("/client", ts_client::router())
.fallback_service(serve_frontend)
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
)
.into_make_service();
let app =
api::app().into_make_service_with_connect_info::<SocketAddr>();
let addr =
format!("{}:{}", core_config().bind_ip, core_config().port);

View File

@@ -21,7 +21,7 @@ use serror::Serror;
use tokio::sync::Mutex;
use crate::{
config::core_config,
config::monitoring_interval,
helpers::periphery_client,
monitor::{alert::check_alerts, record::record_server_stats},
state::{
@@ -46,19 +46,14 @@ pub use swarm::update_cache_for_swarm;
const ADDITIONAL_MS: u128 = 500;
pub fn spawn_monitoring_loops() {
let interval = core_config()
.monitoring_interval
.try_into()
.expect("Invalid monitoring interval");
spawn_server_monitoring_loop(interval);
swarm::spawn_swarm_monitoring_loop(interval);
spawn_server_monitoring_loop();
swarm::spawn_swarm_monitoring_loop();
}
fn spawn_server_monitoring_loop(
interval: async_timing_util::Timelength,
) {
fn spawn_server_monitoring_loop() {
tokio::spawn(async move {
refresh_server_cache(komodo_timestamp()).await;
let interval = monitoring_interval();
loop {
let ts = (wait_until_timelength(interval, ADDITIONAL_MS).await
- ADDITIONAL_MS) as i64;

View File

@@ -1,5 +1,6 @@
use std::{
collections::HashSet,
hash::Hash,
sync::{Mutex, OnceLock},
};
@@ -37,9 +38,8 @@ use crate::{
};
/// (StackId, Service)
fn stack_alert_sent_cache()
-> &'static Mutex<HashSet<(String, String)>> {
static CACHE: OnceLock<Mutex<HashSet<(String, String)>>> =
fn stack_alert_sent_cache() -> &'static AlertCache<(String, String)> {
static CACHE: OnceLock<AlertCache<(String, String)>> =
OnceLock::new();
CACHE.get_or_init(Default::default)
}
@@ -51,6 +51,7 @@ pub async fn update_stack_cache(
images: &[ImageListItem],
) {
let stack_status_cache = stack_status_cache();
let stack_alert_sent_cache = stack_alert_sent_cache();
for stack in stacks {
let services = extract_services_from_stack(&stack);
let mut services_with_containers = services.iter().map(|StackServiceNames { service_name, container_name, image }| {
@@ -86,16 +87,12 @@ pub async fn update_stack_cache(
if update_available {
if !stack.config.auto_update
&& stack.config.send_alerts
&& container.is_some()
&& container.as_ref().unwrap().state == ContainerStateStatusEnum::Running
&& !stack_alert_sent_cache()
.lock()
.unwrap()
&& let Some(container) = &container
&& container.state == ContainerStateStatusEnum::Running
&& !stack_alert_sent_cache
.contains(&(stack.id.clone(), service_name.clone()))
{
stack_alert_sent_cache()
.lock()
.unwrap()
stack_alert_sent_cache
.insert((stack.id.clone(), service_name.clone()));
let ts = komodo_timestamp();
let alert = Alert {
@@ -125,9 +122,10 @@ pub async fn update_stack_cache(
});
}
} else {
stack_alert_sent_cache()
.lock()
.unwrap()
// If it sees there is no longer update available, remove
// from the sent cache, so on next `update_available = true`
// the cache is empty and a fresh alert will be sent.
stack_alert_sent_cache
.remove(&(stack.id.clone(), service_name.clone()));
}
StackService {
@@ -238,8 +236,8 @@ pub async fn update_stack_cache(
}
}
fn deployment_alert_sent_cache() -> &'static Mutex<HashSet<String>> {
static CACHE: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
fn deployment_alert_sent_cache() -> &'static AlertCache<String> {
static CACHE: OnceLock<AlertCache<String>> = OnceLock::new();
CACHE.get_or_init(Default::default)
}
@@ -251,6 +249,8 @@ pub async fn update_deployment_cache(
builds: &[Build],
) {
let deployment_status_cache = deployment_status_cache();
let deployment_alert_sent_cache = deployment_alert_sent_cache();
for deployment in deployments {
let container = containers
.iter()
@@ -364,16 +364,10 @@ pub async fn update_deployment_cache(
}
} else if state == DeploymentState::Running
&& deployment.config.send_alerts
&& !deployment_alert_sent_cache()
.lock()
.unwrap()
.contains(&deployment.id)
&& deployment_alert_sent_cache.contains(&deployment.id)
{
// Add that it is already sent to the cache, so another alert won't be sent.
deployment_alert_sent_cache()
.lock()
.unwrap()
.insert(deployment.id.clone());
deployment_alert_sent_cache.insert(deployment.id.clone());
let ts = komodo_timestamp();
let alert = Alert {
id: Default::default(),
@@ -402,10 +396,7 @@ pub async fn update_deployment_cache(
// If it sees there is no longer update available, remove
// from the sent cache, so on next `update_available = true`
// the cache is empty and a fresh alert will be sent.
deployment_alert_sent_cache()
.lock()
.unwrap()
.remove(&deployment.id);
deployment_alert_sent_cache.remove(&deployment.id);
}
deployment_status_cache
.insert(
@@ -424,3 +415,40 @@ pub async fn update_deployment_cache(
.await;
}
}
struct AlertCache<K>(Mutex<HashSet<K>>);
impl<K> Default for AlertCache<K> {
fn default() -> Self {
Self(Default::default())
}
}
impl<K: Eq + Hash> AlertCache<K> {
/// Checks the cache for an existing key entry.
/// If the cache is poisoned, will always return 'true' and include error log.
fn contains(&self, key: &K) -> bool {
self
.0
.lock()
.map(|cache| cache.contains(key))
.map_err(|_| error!("Alert Cache poisoned, this blocks container state change alerts, please restart Komodo Core."))
.unwrap_or(true)
}
fn insert(&self, key: K) {
let _ = self
.0
.lock()
.map(|mut cache| cache.insert(key))
.map_err(|_| error!("Alert Cache poisoned, this blocks container state change alerts, please restart Komodo Core."));
}
fn remove(&self, key: &K) {
let _ = self
.0
.lock()
.map(|mut cache| cache.remove(key))
.map_err(|_| error!("Alert Cache poisoned, this blocks container state change alerts, please restart Komodo Core."));
}
}

View File

@@ -20,17 +20,17 @@ use periphery_client::api::swarm::{
use tokio::sync::Mutex;
use crate::{
config::monitoring_interval,
helpers::swarm::swarm_request_custom_timeout,
state::{CachedSwarmStatus, db_client, swarm_status_cache},
};
const ADDITIONAL_MS: u128 = 1000;
pub fn spawn_swarm_monitoring_loop(
interval: async_timing_util::Timelength,
) {
pub fn spawn_swarm_monitoring_loop() {
tokio::spawn(async move {
refresh_swarm_cache(komodo_timestamp()).await;
let interval = monitoring_interval();
loop {
let ts = (wait_until_timelength(interval, ADDITIONAL_MS).await
- ADDITIONAL_MS) as i64;

View File

@@ -1,5 +1,9 @@
use std::str::FromStr;
use std::{
net::{IpAddr, Ipv4Addr},
str::FromStr,
};
use anyhow::Context;
use colored::Colorize;
use database::mungos::{
find::find_collect,
@@ -125,8 +129,15 @@ async fn in_progress_update_cleanup() {
"Komodo shutdown during execution. If this is a build, the builder may not have been terminated.",
),
);
// This static log won't fail to serialize, unwrap ok.
let log = to_document(&log).unwrap();
let log = match to_document(&log)
.context("Failed to serialize log to document")
{
Ok(log) => log,
Err(e) => {
error!("Failed to cleanup in progress update | {e:#}");
return;
}
};
if let Err(e) = db_client()
.updates
.update_many(
@@ -292,19 +303,37 @@ async fn ensure_init_user_and_resources() {
// Init admin user if set in config.
if let Some(username) = &config.init_admin_username {
info!("Creating init admin user...");
SignUpLocalUser {
if let Err(e) = (SignUpLocalUser {
username: username.clone(),
password: config.init_admin_password.clone(),
}
.resolve(&AuthArgs::default())
})
.resolve(&AuthArgs {
headers: Default::default(),
ip: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
})
.await
.expect("Failed to initialize default admin user.");
db.users
{
error!("Failed to create init admin user | {:#}", e.error);
return;
}
match db
.users
.find_one(doc! { "username": username })
.await
.expect("Failed to query database for initial user")
.expect("Failed to find initial user after creation");
};
.context(
"Failed to query database for init admin user after creation",
) {
Ok(Some(_)) => {
info!("Successfully created init admin user.")
}
Ok(None) => {
error!("Failed to find init admin user after creation");
}
Err(e) => {
error!("{e:#}");
}
}
}
if config.disable_init_resources {
info!("System resources init {}", "DISABLED".red());
@@ -487,38 +516,55 @@ async fn clean_up_server_templates() {
let db = db_client();
tokio::join!(
async {
db.permissions
if let Err(e) = db
.permissions
.delete_many(doc! {
"resource_target.type": "ServerTemplate",
})
.await
.expect(
"Failed to clean up server template permissions on db",
{
error!(
"Failed to clean up server template permissions on database | {e:#}"
);
}
},
async {
db.updates
if let Err(e) = db
.updates
.delete_many(doc! { "target.type": "ServerTemplate" })
.await
.expect("Failed to clean up server template updates on db");
{
error!(
"Failed to clean up server template updates on database | {e:#}"
);
}
},
async {
db.users
if let Err(e) = db.users
.update_many(
Document::new(),
doc! { "$unset": { "recents.ServerTemplate": 1, "all.ServerTemplate": 1 } }
)
.await
.expect("Failed to clean up server template updates on db");
{
error!(
"Failed to clean up server template user references on database | {e:#}"
);
}
},
async {
db.user_groups
if let Err(e) = db
.user_groups
.update_many(
Document::new(),
doc! { "$unset": { "all.ServerTemplate": 1 } },
)
.await
.expect("Failed to clean up server template updates on db");
{
error!(
"Failed to clean up server template user group references on database | {e:#}"
);
}
},
);
}

View File

@@ -1,6 +1,6 @@
use std::sync::{Arc, OnceLock};
use anyhow::Context;
use anyhow::{Context, anyhow};
use arc_swap::ArcSwap;
use cache::CloneCache;
use komodo_client::entities::{
@@ -18,6 +18,7 @@ use komodo_client::entities::{
stats::{SystemInformation, SystemStats},
swarm::SwarmState,
};
use rate_limit::RateLimiter;
use crate::{
auth::jwt::JwtClient,
@@ -31,20 +32,34 @@ use crate::{
static DB_CLIENT: OnceLock<database::Client> = OnceLock::new();
pub fn db_client() -> &'static database::Client {
DB_CLIENT
.get()
.expect("db_client accessed before initialized")
DB_CLIENT.get().unwrap_or_else(|| {
error!(
"FATAL: db_client accessed before initialized | Ensure init_db_client() is called during startup | Exiting..."
);
std::process::exit(1)
})
}
/// Must be called in app startup sequence.
pub async fn init_db_client() {
let client = database::Client::new(&core_config().database)
.await
.context("failed to initialize database client")
.unwrap();
DB_CLIENT
.set(client)
.expect("db_client initialized more than once");
let init = async {
let client = database::Client::new(&core_config().database)
.await
.context("failed to initialize database client")?;
DB_CLIENT.set(client).map_err(|_| {
anyhow!(
"db_client initialized more than once - this should not happen"
)
})?;
anyhow::Ok(())
}
.await;
if let Err(e) = init {
error!(
"FATAL: Failed to initialize database::Client | {e:#} | Exiting..."
);
std::process::exit(1)
}
}
pub fn jwt_client() -> &'static JwtClient {
@@ -52,8 +67,10 @@ pub fn jwt_client() -> &'static JwtClient {
JWT_CLIENT.get_or_init(|| match JwtClient::new(core_config()) {
Ok(client) => client,
Err(e) => {
error!("failed to initialialize JwtClient | {e:#}");
panic!("Exiting");
error!(
"FATAL: Failed to initialialize JwtClient | {e:#} | Exiting..."
);
std::process::exit(1)
}
})
}
@@ -207,3 +224,19 @@ pub fn all_resources_cache() -> &'static ArcSwap<AllResourcesById> {
OnceLock::new();
ALL_RESOURCES.get_or_init(Default::default)
}
pub fn auth_rate_limiter() -> &'static RateLimiter {
static AUTH_RATE_LIMITER: OnceLock<Arc<RateLimiter>> =
OnceLock::new();
AUTH_RATE_LIMITER.get_or_init(|| {
let config = core_config();
if config.auth_rate_limit_disabled {
warn!("Auth rate limiting is disabled")
}
RateLimiter::new(
config.auth_rate_limit_disabled,
config.auth_rate_limit_max_attempts as usize,
config.auth_rate_limit_window_seconds,
)
})
}

View File

@@ -186,7 +186,7 @@ impl ToToml for Swarm {
.servers
.get(server_id)
.map(|s| s.name.clone())
.unwrap_or(String::new()),
.unwrap_or_default(),
);
}
}

View File

@@ -1,113 +0,0 @@
use crate::{
auth::{auth_api_key_check_enabled, auth_jwt_check_enabled},
helpers::query::get_user,
};
use anyhow::anyhow;
use axum::{
Router,
extract::ws::{self, WebSocket},
routing::get,
};
use futures_util::SinkExt;
use komodo_client::{entities::user::User, ws::WsLoginMessage};
mod terminal;
mod update;
pub fn router() -> Router {
Router::new()
// Periphery facing
.route("/periphery", get(crate::connection::server::handler))
// User facing
.route("/update", get(update::handler))
.route("/terminal", get(terminal::handler))
}
async fn user_ws_login(
mut socket: WebSocket,
) -> Option<(WebSocket, User)> {
let login_msg = match socket.recv().await {
Some(Ok(ws::Message::Text(login_msg))) => {
LoginMessage::Ok(login_msg.to_string())
}
Some(Ok(msg)) => {
LoginMessage::Err(format!("invalid login message: {msg:?}"))
}
Some(Err(e)) => {
LoginMessage::Err(format!("failed to get login message: {e:?}"))
}
None => {
LoginMessage::Err("failed to get login message".to_string())
}
};
let login_msg = match login_msg {
LoginMessage::Ok(login_msg) => login_msg,
LoginMessage::Err(msg) => {
let _ = socket.send(ws::Message::text(msg)).await;
let _ = socket.close().await;
return None;
}
};
match WsLoginMessage::from_json_str(&login_msg) {
// Login using a jwt
Ok(WsLoginMessage::Jwt { jwt }) => {
match auth_jwt_check_enabled(&jwt).await {
Ok(user) => {
let _ = socket.send(ws::Message::text("LOGGED_IN")).await;
Some((socket, user))
}
Err(e) => {
let _ = socket
.send(ws::Message::text(format!(
"failed to authenticate user using jwt | {e:#}"
)))
.await;
let _ = socket.close().await;
None
}
}
}
// login using api keys
Ok(WsLoginMessage::ApiKeys { key, secret }) => {
match auth_api_key_check_enabled(&key, &secret).await {
Ok(user) => {
let _ = socket.send(ws::Message::text("LOGGED_IN")).await;
Some((socket, user))
}
Err(e) => {
let _ = socket
.send(ws::Message::text(format!(
"failed to authenticate user using api keys | {e:#}"
)))
.await;
let _ = socket.close().await;
None
}
}
}
Err(e) => {
let _ = socket
.send(ws::Message::text(format!(
"failed to parse login message: {e:#}"
)))
.await;
let _ = socket.close().await;
None
}
}
}
enum LoginMessage {
/// The text message
Ok(String),
/// The err message
Err(String),
}
async fn check_user_valid(user_id: &str) -> anyhow::Result<User> {
let user = get_user(user_id).await?;
if !user.enabled {
return Err(anyhow!("user not enabled"));
}
Ok(user)
}

View File

@@ -1,6 +1,6 @@
## All in one, multi stage compile + runtime Docker build for your architecture.
FROM rust:1.90.0-trixie AS builder
FROM rust:1.91.1-trixie AS builder
RUN cargo install cargo-strip
WORKDIR /builder

View File

@@ -101,13 +101,26 @@ impl Resolve<super::Args> for GetSwarmServiceLog {
no_resolve,
details,
} = self;
let timestamps =
timestamps.then_some(" --timestamps").unwrap_or_default();
let no_task_ids =
no_task_ids.then_some(" --no-task-ids").unwrap_or_default();
let no_resolve =
no_resolve.then_some(" --no-resolve").unwrap_or_default();
let details = details.then_some(" --details").unwrap_or_default();
let timestamps = if timestamps {
" --timestamps"
} else {
Default::default()
};
let no_task_ids = if no_task_ids {
" --no-task-ids"
} else {
Default::default()
};
let no_resolve = if no_resolve {
" --no-resolve"
} else {
Default::default()
};
let details = if details {
" --details"
} else {
Default::default()
};
let command = format!(
"docker service logs --tail {tail}{timestamps}{no_task_ids}{no_resolve}{details} {service}",
);
@@ -134,13 +147,26 @@ impl Resolve<super::Args> for GetSwarmServiceLogSearch {
no_resolve,
details,
} = self;
let timestamps =
timestamps.then_some(" --timestamps").unwrap_or_default();
let no_task_ids =
no_task_ids.then_some(" --no-task-ids").unwrap_or_default();
let no_resolve =
no_resolve.then_some(" --no-resolve").unwrap_or_default();
let details = details.then_some(" --details").unwrap_or_default();
let timestamps = if timestamps {
" --timestamps"
} else {
Default::default()
};
let no_task_ids = if no_task_ids {
" --no-task-ids"
} else {
Default::default()
};
let no_resolve = if no_resolve {
" --no-resolve"
} else {
Default::default()
};
let details = if details {
" --details"
} else {
Default::default()
};
let grep = format_log_grep(&terms, combinator, invert);
let command = format!(
"docker service logs --tail 5000{timestamps}{no_task_ids}{no_resolve}{details} {service} 2>&1 | {grep}",

View File

@@ -14,9 +14,9 @@ pub async fn list_swarm_configs()
.await;
if !res.success {
return Err(anyhow!("{}", res.combined()).context(format!(
"Failed to list swarm configs using 'docker config ls'"
)));
return Err(anyhow!("{}", res.combined()).context(
"Failed to list swarm configs using 'docker config ls'",
));
}
// The output is in JSONL, need to convert to standard JSON vec.

View File

@@ -166,6 +166,8 @@ pub struct Env {
/// Override `local_auth`
pub komodo_local_auth: Option<bool>,
/// Override `min_password_length`
pub komodo_min_password_length: Option<u16>,
/// Override `init_admin_username`
pub komodo_init_admin_username: Option<String>,
/// Override `init_admin_username` from file
@@ -218,6 +220,18 @@ pub struct Env {
/// Override `github_oauth.secret` from file
pub komodo_github_oauth_secret_file: Option<PathBuf>,
/// Override `auth_rate_limit_disabled`
pub komodo_auth_rate_limit_disabled: Option<bool>,
/// Override `auth_rate_limit_max_attempts`
pub komodo_auth_rate_limit_max_attempts: Option<u16>,
/// Override `auth_rate_limit_window_seconds`
pub komodo_auth_rate_limit_window_seconds: Option<u64>,
/// Override `cors_allowed_origins`
pub komodo_cors_allowed_origins: Option<Vec<String>>,
/// Override `cors_allow_credentials`
pub komodo_cors_allow_credentials: Option<bool>,
/// Override `database.uri`
#[serde(alias = "komodo_mongo_uri")]
pub komodo_database_uri: Option<String>,
@@ -405,14 +419,20 @@ pub struct CoreConfig {
// ================
// = Auth / Login =
// ================
/// enable login with local auth
/// Enable login with local auth
#[serde(default)]
pub local_auth: bool,
/// Configure a minimum password length.
/// Default: 1
#[serde(default = "default_min_password_length")]
pub min_password_length: u16,
/// Upon fresh launch, initalize an Admin user with this username.
/// If this is not provided, no initial user will be created.
#[serde(skip_serializing_if = "Option::is_none")]
pub init_admin_username: Option<String>,
/// Upon fresh launch, initalize an Admin user with this password.
/// Default: `changeme`
#[serde(default = "default_init_admin_password")]
@@ -511,6 +531,35 @@ pub struct CoreConfig {
#[serde(default)]
pub github_oauth: OauthCredentials,
// =================
// = Rate Limiting =
// =================
/// Disable the auth rate limiter.
#[serde(default)]
pub auth_rate_limit_disabled: bool,
/// Set the max allowed attempts per IP
#[serde(default = "default_auth_rate_limit_max_attempts")]
pub auth_rate_limit_max_attempts: u16,
#[serde(default = "default_auth_rate_limit_window_seconds")]
pub auth_rate_limit_window_seconds: u64,
// =======
// = CORS =
// =======
/// List of CORS allowed origins.
/// If empty, allows all origins (`*`).
/// Production setups should configure this explicitly.
/// Example: `["https://komodo.example.com", "https://app.example.com"]`.
#[serde(default)]
pub cors_allowed_origins: Vec<String>,
/// Tell CORS to allow credentials in requests.
/// Used if needed for authentication proxy.
#[serde(default)]
pub cors_allow_credentials: bool,
// ============
// = Webhooks =
// ============
@@ -681,10 +730,22 @@ fn default_jwt_ttl() -> Timelength {
Timelength::OneDay
}
fn default_min_password_length() -> u16 {
1
}
fn default_init_admin_password() -> String {
String::from("changeme")
}
fn default_auth_rate_limit_max_attempts() -> u16 {
5
}
fn default_auth_rate_limit_window_seconds() -> u64 {
15
}
fn default_sync_directory() -> PathBuf {
PathBuf::from("/syncs")
}
@@ -739,6 +800,7 @@ impl Default for CoreConfig {
frontend_path: default_frontend_path(),
database: Default::default(),
local_auth: Default::default(),
min_password_length: default_min_password_length(),
init_admin_username: Default::default(),
init_admin_password: default_init_admin_password(),
transparent_mode: Default::default(),
@@ -757,6 +819,13 @@ impl Default for CoreConfig {
oidc_additional_audiences: Default::default(),
google_oauth: Default::default(),
github_oauth: Default::default(),
auth_rate_limit_disabled: Default::default(),
auth_rate_limit_max_attempts:
default_auth_rate_limit_max_attempts(),
auth_rate_limit_window_seconds:
default_auth_rate_limit_window_seconds(),
cors_allowed_origins: Default::default(),
cors_allow_credentials: Default::default(),
webhook_secret: Default::default(),
webhook_base_url: Default::default(),
logging: Default::default(),
@@ -824,6 +893,7 @@ impl CoreConfig {
disable_non_admin_create: config.disable_non_admin_create,
lock_login_credentials_for: config.lock_login_credentials_for,
local_auth: config.local_auth,
min_password_length: config.min_password_length,
init_admin_username: config
.init_admin_username
.map(|u| empty_or_redacted(&u)),
@@ -853,6 +923,13 @@ impl CoreConfig {
id: empty_or_redacted(&config.github_oauth.id),
secret: empty_or_redacted(&config.github_oauth.id),
},
auth_rate_limit_disabled: config.auth_rate_limit_disabled,
auth_rate_limit_max_attempts: config
.auth_rate_limit_max_attempts,
auth_rate_limit_window_seconds: config
.auth_rate_limit_window_seconds,
cors_allowed_origins: config.cors_allowed_origins,
cors_allow_credentials: config.cors_allow_credentials,
webhook_secret: empty_or_redacted(&config.webhook_secret),
webhook_base_url: config.webhook_base_url,
database: config.database.sanitized(),

View File

@@ -52,7 +52,9 @@ pub struct SwarmInfo {}
pub type _PartialSwarmConfig = PartialSwarmConfig;
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Builder, Partial)]
#[derive(
Debug, Clone, Default, Serialize, Deserialize, Builder, Partial,
)]
#[partial_derive(Serialize, Deserialize, Debug, Clone, Default)]
#[partial(skip_serializing_none, from, diff)]
pub struct SwarmConfig {
@@ -74,15 +76,6 @@ pub struct SwarmConfig {
pub links: Vec<String>,
}
impl Default for SwarmConfig {
fn default() -> Self {
Self {
server_ids: Default::default(),
links: Default::default(),
}
}
}
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default)]
pub struct SwarmActionState {}

View File

@@ -146,6 +146,11 @@ internet_interface = ""
## Default: false
local_auth = false
## Configure a minimum password length.
## Env: KOMODO_MIN_PASSWORD_LENGTH
## Default: 1
min_password_length = 1
## Initialize the first admin user when starting up Komodo for the first time.
## Env: KOMODO_INIT_ADMIN_USERNAME or KOMODO_INIT_ADMIN_USERNAME_FILE
## Default: None
@@ -294,6 +299,48 @@ github_oauth.id = ""
## Required if github_oauth is enabled.
github_oauth.secret = ""
######################
# AUTH RATE LIMITING #
######################
## By default, all authenticated requests have a global failure rate limiter
## by client IP address (X-FORWARDED-FOR, X-REAL-IP headers).
## If clients try too many calls which fail to authenticate,
## they will be temporarily blocked from making further attempts,
## mitigating brute force attacks.
## Disable the auth rate limiting.
## Env: KOMODO_AUTH_RATE_LIMIT_DISABLED
## Default: false
auth_rate_limit_disabled = false
## Configure the max attempts allowed within the given 'window_seconds'.
## Env: KOMODO_AUTH_RATE_LIMIT_MAX_ATTEMPTS
## Default: 5
auth_rate_limit_max_attempts = 5
## Set the rate limiting window in seconds.
## Env: KOMODO_AUTH_RATE_LIMIT_WINDOW_SECONDS
## Default: 15
auth_rate_limit_window_seconds = 15
########
# CORS #
########
## Specifically set list of CORS allowed origins.
## If empty, allows all origins (`*`).
## Production setups should configure this explicitly.
## Env: KOMODO_CORS_ALLOWED_ORIGINS
## Default: empty
cors_allowed_origins = []
## Tell CORS to allow credentials in requests.
## Set true only if needed for authentication proxy.
## Env: KOMODO_CORS_ALLOW_CREDENTIALS
## Default: false
cors_allow_credentials = false
##################
# POLL INTERVALS #
##################

View File

@@ -1,4 +1,4 @@
FROM rust:1.90.0-bullseye as builder
FROM rust:1.91.1-bullseye as builder
WORKDIR /builder
COPY . .

View File

@@ -1,4 +1,4 @@
FROM rust:1.90.0-bullseye as builder
FROM rust:1.91.1-bullseye as builder
WORKDIR /builder
COPY . .

View File

@@ -57,6 +57,7 @@
"@types/react": "19.1.6",
"@types/react-dom": "19.1.5",
"@types/sanitize-html": "2.16.0",
"@types/shell-quote": "^1.7.5",
"@typescript-eslint/eslint-plugin": "8.33.0",
"@typescript-eslint/parser": "8.33.0",
"@vitejs/plugin-react": "4.5.0",

View File

@@ -456,7 +456,7 @@ const ProviderDialog = ({
: provider
) as Types.GitProvider[] | Types.DockerRegistry[],
});
const remove_account = (account_index) =>
const remove_account = (account_index: number) =>
set({
[arr_field]: providers.map(
(provider: Types.GitProvider | Types.DockerRegistry, provider_index) =>
@@ -495,7 +495,7 @@ const ProviderDialog = ({
: provider
) as Types.GitProvider[] | Types.DockerRegistry[],
});
const remove_organization = (organization_index) =>
const remove_organization = (organization_index: number) =>
set({
[arr_field]: providers.map(
(provider: Types.DockerRegistry, provider_index) =>

View File

@@ -552,9 +552,8 @@ const Stage = ({
execution: {
type,
params:
TARGET_COMPONENTS[
type as Types.Execution["type"]
].params,
TARGET_COMPONENTS[type as MinExecutionType]
.params,
},
} as Types.EnabledExecution)
: item
@@ -575,7 +574,8 @@ const Stage = ({
index,
},
}) => {
const Component = TARGET_COMPONENTS[type].Component;
const Component =
TARGET_COMPONENTS[type as MinExecutionType].Component;
return (
<Component
disabled={disabled}

View File

@@ -36,7 +36,11 @@ export const Prune = ({
? "pruning_system"
: "";
const pending = isPending || action_state?.[pruningKey];
const pending =
isPending ||
(pruningKey && action_state?.[pruningKey]
? action_state?.[pruningKey]
: undefined);
if (type === "Images" || type === "Networks" || type === "Buildx") {
return (

View File

@@ -164,43 +164,6 @@ export const useUserReset = () => {
};
};
export const useRead = <
T extends Types.ReadRequest["type"],
R extends Extract<Types.ReadRequest, { type: T }>,
P extends R["params"],
C extends Omit<
UseQueryOptions<
ReadResponses[R["type"]],
unknown,
ReadResponses[R["type"]],
(T | P)[]
>,
"queryFn" | "queryKey"
>,
>(
type: T,
params: P,
config?: C
) => {
const hasJwt = !!LOGIN_TOKENS.jwt();
return useQuery({
queryKey: [type, params],
queryFn: () => komodo_client().read<T, R>(type, params),
enabled: hasJwt && config?.enabled !== false,
...config,
});
};
export const useInvalidate = () => {
const qc = useQueryClient();
return <
Type extends Types.ReadRequest["type"],
Params extends Extract<Types.ReadRequest, { type: Type }>["params"],
>(
...keys: Array<[Type] | [Type, Params]>
) => keys.forEach((key) => qc.invalidateQueries({ queryKey: key }));
};
export const useManageUser = <
T extends Types.UserRequest["type"],
R extends Extract<Types.UserRequest, { type: T }>,
@@ -238,6 +201,80 @@ export const useManageUser = <
});
};
export const useAuth = <
T extends Types.AuthRequest["type"],
R extends Extract<Types.AuthRequest, { type: T }>,
P extends R["params"],
C extends Omit<
UseMutationOptions<AuthResponses[T], unknown, P, unknown>,
"mutationKey" | "mutationFn"
>,
>(
type: T,
config?: C
) => {
const { toast } = useToast();
return useMutation({
mutationKey: [type],
mutationFn: (params: P) => komodo_client().auth<T, R>(type, params),
onError: (e: { result: { error?: string; trace?: string[] } }, v, c) => {
console.log("Auth error:", e);
const msg = e.result.error ?? "Unknown error. See console.";
const detail = e.result?.trace
?.map((msg) => msg[0].toUpperCase() + msg.slice(1))
.join(" | ");
let msg_log = msg ? msg[0].toUpperCase() + msg.slice(1) + " | " : "";
if (detail) {
msg_log += detail + " | ";
}
toast({
title: `Auth request ${type} failed`,
description: `${msg_log}See console for details`,
variant: "destructive",
});
config?.onError && config.onError(e, v, c);
},
...config,
});
};
export const useRead = <
T extends Types.ReadRequest["type"],
R extends Extract<Types.ReadRequest, { type: T }>,
P extends R["params"],
C extends Omit<
UseQueryOptions<
ReadResponses[R["type"]],
unknown,
ReadResponses[R["type"]],
(T | P)[]
>,
"queryFn" | "queryKey"
>,
>(
type: T,
params: P,
config?: C
) => {
const hasJwt = !!LOGIN_TOKENS.jwt();
return useQuery({
queryKey: [type, params],
queryFn: () => komodo_client().read<T, R>(type, params),
enabled: hasJwt && config?.enabled !== false,
...config,
});
};
export const useInvalidate = () => {
const qc = useQueryClient();
return <
Type extends Types.ReadRequest["type"],
Params extends Extract<Types.ReadRequest, { type: Type }>["params"],
>(
...keys: Array<[Type] | [Type, Params]>
) => keys.forEach((key) => qc.invalidateQueries({ queryKey: key }));
};
export const useWrite = <
T extends Types.WriteRequest["type"],
R extends Extract<Types.WriteRequest, { type: T }>,
@@ -312,43 +349,6 @@ export const useExecute = <
});
};
export const useAuth = <
T extends Types.AuthRequest["type"],
R extends Extract<Types.AuthRequest, { type: T }>,
P extends R["params"],
C extends Omit<
UseMutationOptions<AuthResponses[T], unknown, P, unknown>,
"mutationKey" | "mutationFn"
>,
>(
type: T,
config?: C
) => {
const { toast } = useToast();
return useMutation({
mutationKey: [type],
mutationFn: (params: P) => komodo_client().auth<T, R>(type, params),
onError: (e: { result: { error?: string; trace?: string[] } }, v, c) => {
console.log("Auth error:", e);
const msg = e.result.error ?? "Unknown error. See console.";
const detail = e.result?.trace
?.map((msg) => msg[0].toUpperCase() + msg.slice(1))
.join(" | ");
let msg_log = msg ? msg[0].toUpperCase() + msg.slice(1) + " | " : "";
if (detail) {
msg_log += detail + " | ";
}
toast({
title: `Auth request ${type} failed`,
description: `${msg_log}See console for details`,
variant: "destructive",
});
config?.onError && config.onError(e, v, c);
},
...config,
});
};
// ============== UTILITY ==============
export const useResourceParamType = () => {
@@ -507,9 +507,8 @@ export const useKeyListener = (listenKey: string, onPress: () => void) => {
useEffect(() => {
const keydown = (e: KeyboardEvent) => {
// This will ignore Shift + listenKey if it is sent from input / textarea
const target = e.target as any;
if (target.matches("input") || target.matches("textarea")) return;
const target = e.target as HTMLElement | null;
if (target?.matches("input") || target?.matches("textarea")) return;
if (e.key === listenKey) {
e.preventDefault();
onPress();
@@ -524,9 +523,8 @@ export const useShiftKeyListener = (listenKey: string, onPress: () => void) => {
useEffect(() => {
const keydown = (e: KeyboardEvent) => {
// This will ignore Shift + listenKey if it is sent from input / textarea
const target = e.target as any;
if (target.matches("input") || target.matches("textarea")) return;
const target = e.target as HTMLElement | null;
if (target?.matches("input") || target?.matches("textarea")) return;
if (e.shiftKey && e.key === listenKey) {
e.preventDefault();
onPress();

View File

@@ -45,7 +45,7 @@ export async function init_monaco() {
typeRoots: ["index.d.ts"],
allowTopLevelAwait: true,
moduleDetection: "force",
} as monaco.languages.typescript.CompilerOptions & ExtraOptions);
} as monaco.languages.typescript.CompilerOptions & Partial<ExtraOptions>);
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
diagnosticCodesToIgnore: [

View File

@@ -101,7 +101,7 @@ export default function AlertsPage() {
</SelectItem>
<SelectSeparator />
{Object.keys(ALERT_TYPES_BY_RESOURCE).map((type) => {
const Icon = ResourceComponents[type].Icon;
const Icon = ResourceComponents[type as UsableResource].Icon;
return (
<SelectItem key={type} value={type}>
<div className="flex items-center gap-2">

View File

@@ -20,7 +20,6 @@ import { ThemeToggle } from "@ui/theme";
import { KOMODO_BASE_URL } from "@main";
import { KeyRound, X } from "lucide-react";
import { cn } from "@lib/utils";
import { useToast } from "@ui/use-toast";
import { Types } from "komodo_client";
type OauthProvider = "Github" | "Google" | "OIDC";
@@ -39,7 +38,6 @@ const login_with_oauth = (provider: OauthProvider) => {
export default function Login() {
const options = useLoginOptions().data;
const userInvalidate = useUserInvalidate();
const { toast } = useToast();
const formRef = useRef<HTMLFormElement>(null);
// If signing in another user, need to redirect away from /login manually
@@ -60,42 +58,10 @@ export default function Login() {
"SignUpLocalUser",
{
onSuccess,
onError: (e: any) => {
const message = e?.response?.data?.error as string | undefined;
if (message) {
toast({
title: `Failed to sign up user. '${message}'`,
variant: "destructive",
});
console.error(e);
} else {
toast({
title: "Failed to sign up user. See console log for details.",
variant: "destructive",
});
console.error(e);
}
},
}
);
const { mutate: login, isPending: loginPending } = useAuth("LoginLocalUser", {
onSuccess,
onError: (e: any) => {
const message = e?.response?.data?.error as string | undefined;
if (message) {
toast({
title: `Failed to login user. '${message}'`,
variant: "destructive",
});
console.error(e);
} else {
toast({
title: "Failed to login user. See console log for details.",
variant: "destructive",
});
console.error(e);
}
},
});
const getFormCredentials = () => {
@@ -111,12 +77,12 @@ export default function Login() {
if (!creds) return;
login(creds);
};
const handleSubmit = (e: any) => {
e.preventDefault();
handleLogin();
};
const handleSignUp = () => {
const creds = getFormCredentials();
if (!creds) return;
@@ -188,11 +154,7 @@ export default function Login() {
</div>
</CardHeader>
{options?.local && (
<form
ref={formRef}
onSubmit={handleSubmit}
autoComplete="on"
>
<form ref={formRef} onSubmit={handleSubmit} autoComplete="on">
<CardContent className="flex flex-col justify-center w-full gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username</Label>

View File

@@ -1,19 +1,28 @@
export const Json = ({ json }: any) => {
type JsonValue =
| string
| number
| boolean
| null
| undefined
| JsonValue[]
| { [key: string]: JsonValue };
export const Json = ({ json }: { json: JsonValue }) => {
if (!json) {
return <p>null</p>;
}
const type = typeof json;
if (type === "function") {
return <p>??function??</p>;
}
// null case
if (type === "undefined") {
return <p>null</p>;
}
if (type === "function") {
return <p>??function??</p>;
}
// base cases
if (
type === "bigint" ||
@@ -22,27 +31,34 @@ export const Json = ({ json }: any) => {
type === "string" ||
type === "symbol"
) {
return <p>{json}</p>;
return <p>{String(json)}</p>;
}
// Type is object or array
if (Array.isArray(json)) {
return (
<div className="flex flex-col gap-2">
{(json as any[]).map((json) => (
<Json json={json} />
{json.map((json, index) => (
<Json key={index} json={json} />
))}
</div>
);
}
return (
<div className="flex flex-col gap-2">
{Object.keys(json).map((key) => (
<div className="flex gap-2">
<p>{key}</p>: <Json json={json[key]} />
</div>
))}
</div>
);
if (type === "object") {
const obj = json as {
[key: string]: JsonValue;
};
return (
<div className="flex flex-col gap-2">
{Object.keys(obj).map((key) => (
<div key={key} className="flex gap-2">
<p>{key}</p>: <Json json={obj[key]} />
</div>
))}
</div>
);
}
return <p>null</p>;
};

View File

@@ -21,7 +21,7 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noImplicitAny": false,
"noImplicitAny": true,
/* Paths */
"baseUrl": "./src",
"paths": {

View File

@@ -1201,6 +1201,11 @@
resolved "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz"
integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==
"@types/shell-quote@^1.7.5":
version "1.7.5"
resolved "https://registry.yarnpkg.com/@types/shell-quote/-/shell-quote-1.7.5.tgz#6db4704742d307cd6d604e124e3ad6cd5ed943f3"
integrity sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==
"@typescript-eslint/eslint-plugin@8.33.0":
version "8.33.0"
resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.0.tgz"

33
lib/cache/src/lib.rs vendored
View File

@@ -105,6 +105,29 @@ impl<K: PartialEq + Eq + Hash + std::fmt::Debug + Clone, T: Clone>
pub async fn remove(&self, key: &K) -> Option<T> {
self.0.write().await.remove(key)
}
///Retains only the elements specified by the predicate.
///
/// In other words, remove all pairs (k, v) for which f(&k, &mut v) returns false. The elements are visited in unsorted (and unspecified) order.
pub async fn retain(&self, retain: impl FnMut(&K, &mut T) -> bool) {
self.0.write().await.retain(retain);
}
pub async fn get_or_insert_with(
&self,
key: &K,
default: impl FnOnce() -> T,
) -> T {
let mut lock = self.0.write().await;
match lock.get(key).cloned() {
Some(item) => item,
None => {
let item: T = default();
lock.insert(key.clone(), item.clone());
item
}
}
}
}
impl<
@@ -113,15 +136,7 @@ impl<
> CloneCache<K, T>
{
pub async fn get_or_insert_default(&self, key: &K) -> T {
let mut lock = self.0.write().await;
match lock.get(key).cloned() {
Some(item) => item,
None => {
let item: T = Default::default();
lock.insert(key.clone(), item.clone());
item
}
}
self.get_or_insert_with(key, T::default).await
}
}

18
lib/rate_limit/Cargo.toml Normal file
View File

@@ -0,0 +1,18 @@
[package]
name = "rate_limit"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
[dependencies]
# local
cache.workspace = true
# mogh
serror.workspace = true
#
anyhow.workspace = true
tokio.workspace = true
axum.workspace = true

215
lib/rate_limit/src/lib.rs Normal file
View File

@@ -0,0 +1,215 @@
use std::{
net::IpAddr,
sync::Arc,
time::{Duration, Instant},
};
use anyhow::anyhow;
use axum::http::{HeaderMap, StatusCode};
use cache::CloneCache;
use serror::{AddStatusCode, AddStatusCodeError};
use tokio::sync::RwLock;
/// Trait to extend fallible futures with stateful
/// rate limiting.
pub trait WithFailureRateLimit<R>
where
Self: Future<Output = serror::Result<R>> + Sized,
{
/// Ensure the given IP 'ip' is
/// not violating the givin 'limiter' rate limit rules
/// before executing this fallible future.
///
/// If the rules are violated, will return `429 Too Many Requests`.
///
/// If the rate limiting rules are not violated, the
/// future will be executed, and if it fails then the
/// attempt time will be recorded for rate limit,
/// and original error returned.
///
/// The end result rate limits failing requests,
/// while succeeding requests are not rate late limited.
fn with_failure_rate_limit_using_ip(
self,
limiter: &RateLimiter,
ip: &IpAddr,
) -> impl Future<Output = serror::Result<R>> {
async {
if limiter.disabled {
return self.await;
}
// Only locks if entry at key does not exist yet.
let attempts = limiter.attempts.get_or_insert_default(ip).await;
// RwLock allows multiple readers, minimizing locking effect.
let read = attempts.read().await;
let now = Instant::now();
let window_start = now - limiter.window;
let (first, count) =
read.iter().filter(|&&time| time > window_start).fold(
(Option::<Instant>::None, 0),
|(first, count), &time| {
(Some(first.unwrap_or(time)), count + 1)
},
);
// Drop the read lock immediately
drop(read);
// Don't allow future to be executed if rate limiter violated
if count >= limiter.max_attempts {
// Use this opportunity to take write lock and clear the attempts cache
attempts.write().await.retain(|&time| time > window_start);
return Err(
anyhow!(
"Too many attempts | Try again in {:.0?}",
limiter.window
- first.map(|first| now - first).unwrap_or_default(),
)
.status_code(StatusCode::TOO_MANY_REQUESTS),
);
}
match self.await {
// The succeeding branch has no write locks
// after the initial attempt array initializes.
Ok(res) => Ok(res),
Err(mut e) => {
// Failing branch takes exclusive write lock.
let mut write = attempts.write().await;
// Use this opportunity to clear the attempts cache
write.retain(|&time| time > window_start);
// Always push after failed attempts, eg failed api key check.
write.push(now);
// Add 1 to count because it doesn't include this attempt.
let remaining_attempts = limiter.max_attempts - (count + 1);
// Return original error with remaining attempts shown
e.error = anyhow!(
"{:#} | You have {remaining_attempts} attempts remaining",
e.error,
);
Err(e)
}
}
}
}
fn with_failure_rate_limit_using_headers(
self,
limiter: &RateLimiter,
headers: &HeaderMap,
fallback: Option<IpAddr>,
) -> impl Future<Output = serror::Result<R>> {
async move {
// Can skip header ip extraction if disabled
if limiter.disabled {
return self.await;
}
let ip = get_ip_from_headers(headers, fallback)?;
self.with_failure_rate_limit_using_ip(limiter, &ip).await
}
}
}
impl<F, R> WithFailureRateLimit<R> for F where
F: Future<Output = serror::Result<R>> + Sized
{
}
type RateLimiterMapEntry = Arc<RwLock<Vec<Instant>>>;
pub struct RateLimiter {
attempts: CloneCache<IpAddr, RateLimiterMapEntry>,
disabled: bool,
max_attempts: usize,
window: Duration,
}
impl RateLimiter {
/// Create a new rate limiter. Also spawns tokio task
/// to cleanup stale keys (ones which haven't been accessed in 15+ minutes).
///
/// # Arguments
///
/// * `disabled` - Whether rate limiter is disabled
/// * `max_attempts` - Maximum number of attempts allowed in given window
/// * `window_seconds` - Time window in seconds
pub fn new(
disabled: bool,
max_attempts: usize,
window_seconds: u64,
) -> Arc<Self> {
let limiter = Arc::new(Self {
attempts: CloneCache::default(),
disabled,
max_attempts,
window: Duration::from_secs(window_seconds),
});
if !disabled {
spawn_cleanup_task(limiter.clone());
}
limiter
}
}
/// Task to run every 15 mins and clear off
/// the best guess of stale entries. Note that
/// repeatedly succeeding calls from IP will end up with
/// "empty" attempts array, and will be cleared off when this runs.
/// The impact on performance should be negligible until very large scale.
fn spawn_cleanup_task(limiter: Arc<RateLimiter>) {
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(60));
loop {
interval.tick().await;
let remove_before =
Instant::now() - Duration::from_secs(15 * 60);
limiter
.attempts
.retain(|_, attempts| {
let Ok(attempts) = attempts.try_read() else {
// Retain any locked attempts, they are being actively used and not stale.
return true;
};
let Some(&last) = attempts.last() else {
// Remove any empty attempts arrays
return false;
};
last > remove_before
})
.await;
}
});
}
pub fn get_ip_from_headers(
headers: &HeaderMap,
fallback: Option<IpAddr>,
) -> serror::Result<IpAddr> {
// Check X-Forwarded-For header (first IP in chain)
if let Some(forwarded) = headers.get("x-forwarded-for")
&& let Ok(forwarded_str) = forwarded.to_str()
&& let Some(ip) = forwarded_str.split(',').next()
{
return ip.trim().parse().status_code(StatusCode::UNAUTHORIZED);
}
// Check X-Real-IP header
if let Some(real_ip) = headers.get("x-real-ip")
&& let Ok(ip) = real_ip.to_str()
{
return ip.trim().parse().status_code(StatusCode::UNAUTHORIZED);
}
if let Some(fallback) = fallback {
return Ok(fallback);
}
Err(
anyhow!("'x-forwarded-for' and 'x-real-ip' headers are both missing, and no fallback ip could be extracted from the request.")
.status_code(StatusCode::UNAUTHORIZED),
)
}

View File

@@ -0,0 +1,14 @@
[package]
name = "validations"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
[dependencies]
anyhow.workspace = true
regex.workspace = true
bson.workspace = true
url.workspace = true

175
lib/validations/src/lib.rs Normal file
View File

@@ -0,0 +1,175 @@
//! # Input Validation Module
//!
//! This module provides validation functions for user inputs to prevent
//! invalid data from entering the system and improve security.
use std::{str::FromStr as _, sync::OnceLock};
use anyhow::{Context, anyhow};
use bson::oid::ObjectId;
use regex::Regex;
/// Options to validate input strings to have certain properties.
/// This ensures only valid data can enter the system.
///
/// ## Usage
///
/// ```
/// StringValidator::default()
/// .min_length(1)
/// .max_length(100)
/// .matches(StringValidatorMatches::Username)
/// .validate("admin@example.com")?
/// ```
#[derive(Default)]
pub struct StringValidator {
/// Specify the minimum length of string.
/// Setting `0` will effectively skip this validation.
pub min_length: usize,
/// Specify max length of string, or None to allow arbitrary length.
pub max_length: Option<usize>,
/// Skip the control character check.
/// Most values should not contain these by default.
pub skip_control_check: bool,
/// Specify a pattern to validate the string contents.
pub matches: Option<StringValidatorMatches>,
}
impl StringValidator {
/// Returns Ok if input passes validations, otherwise includes
/// error with failure reason.
pub fn validate(&self, input: &str) -> anyhow::Result<()> {
let len = input.len();
if len < self.min_length {
return Err(anyhow!(
"Input too short. Must be at least {} characters.",
self.min_length
));
}
if let Some(max_length) = self.max_length
&& len > max_length
{
return Err(anyhow!(
"Input too long. Must be at most {max_length} characters."
));
}
if !self.skip_control_check {
validate_no_control_chars(input)?;
}
if let Some(matches) = &self.matches {
matches.validate(input)?
}
Ok(())
}
pub fn min_length(mut self, min_length: usize) -> StringValidator {
self.min_length = min_length;
self
}
pub fn max_length(
mut self,
max_length: impl Into<Option<usize>>,
) -> StringValidator {
self.max_length = max_length.into();
self
}
pub fn skip_control_check(mut self) -> StringValidator {
self.skip_control_check = true;
self
}
pub fn matches(
mut self,
matches: impl Into<Option<StringValidatorMatches>>,
) -> StringValidator {
self.matches = matches.into();
self
}
}
pub enum StringValidatorMatches {
/// - alphanumeric characters
/// - underscores
/// - hyphens
/// - dots
/// - @
/// - No Object Ids
Username,
/// - alphanumeric characters
/// - underscores
VariableName,
/// - http or https URL.
HttpUrl,
}
impl StringValidatorMatches {
/// Returns Ok if input passes validations, otherwise includes
/// error with failure reason.
fn validate(&self, input: &str) -> anyhow::Result<()> {
let validate = || match self {
StringValidatorMatches::Username => {
static USERNAME_REGEX: OnceLock<Regex> = OnceLock::new();
let regex = USERNAME_REGEX.get_or_init(|| {
Regex::new(r"^[a-zA-Z0-9._@-]+$")
.expect("Failed to initialize username regex")
});
if !regex.is_match(input) {
return Err(anyhow!(
"Only alphanumeric characters, underscores, hyphens, dots, and @ are allowed"
));
}
if ObjectId::from_str(input).is_ok() {
return Err(anyhow!("Cannot be valid ObjectId"));
}
Ok(())
}
StringValidatorMatches::VariableName => {
static VARIABLE_NAME_REGEX: OnceLock<Regex> = OnceLock::new();
let regex = VARIABLE_NAME_REGEX.get_or_init(|| {
Regex::new(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
.expect("Failed to initialize variable name regex")
});
if regex.is_match(input) {
Ok(())
} else {
Err(anyhow!(
"Only alphanumeric characters and underscores are allowed"
))
}
}
StringValidatorMatches::HttpUrl => {
if !input.starts_with("http://")
&& !input.starts_with("https://")
{
return Err(anyhow!(
"Input must start with http:// or https://"
));
}
url::Url::parse(input)
.context("Failed to parse input as URL")
.map(|_| ())
}
};
validate().context("Invalid characters in input")
}
}
fn validate_no_control_chars(input: &str) -> anyhow::Result<()> {
for (index, char) in input.chars().enumerate() {
if char.is_control() {
return Err(anyhow!(
"Control character at index {index}. Input: \"{input}\""
));
}
}
Ok(())
}