mirror of
https://github.com/moghtech/komodo.git
synced 2026-03-16 05:30:51 -05:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2d9fa931e | ||
|
|
1621043a21 | ||
|
|
6fa5acd1e3 | ||
|
|
45d6b21a61 | ||
|
|
33bd744052 | ||
|
|
967629545f | ||
|
|
581ef4f7da | ||
|
|
ffc125e288 | ||
|
|
23430cc3cb | ||
|
|
d44be15c14 | ||
|
|
afeb4ac526 | ||
|
|
3e3639231d | ||
|
|
dd3fb92eb8 | ||
|
|
30929838d2 | ||
|
|
8b0c2299be | ||
|
|
3164cdf5fe | ||
|
|
4a50b780a6 |
127
Cargo.lock
generated
127
Cargo.lock
generated
@@ -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"
|
||||
|
||||
22
Cargo.toml
22
Cargo.toml
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<()> {
|
||||
@@ -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<()> {
|
||||
4
bin/core/src/api/listener/integrations/mod.rs
Normal file
4
bin/core/src/api/listener/integrations/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod github;
|
||||
pub mod gitlab;
|
||||
|
||||
use super::{ExtractBranch, VerifySecret};
|
||||
@@ -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<()>;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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 {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
101
bin/core/src/api/ws/mod.rs
Normal 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)
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
85
bin/core/src/helpers/validations.rs
Normal file
85
bin/core/src/helpers/validations.rs
Normal 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")
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod github;
|
||||
pub mod gitlab;
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:#}"
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ impl ToToml for Swarm {
|
||||
.servers
|
||||
.get(server_id)
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or(String::new()),
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 #
|
||||
##################
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.90.0-bullseye as builder
|
||||
FROM rust:1.91.1-bullseye as builder
|
||||
WORKDIR /builder
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.90.0-bullseye as builder
|
||||
FROM rust:1.91.1-bullseye as builder
|
||||
WORKDIR /builder
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitAny": false,
|
||||
"noImplicitAny": true,
|
||||
/* Paths */
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
|
||||
@@ -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
33
lib/cache/src/lib.rs
vendored
@@ -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
18
lib/rate_limit/Cargo.toml
Normal 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
215
lib/rate_limit/src/lib.rs
Normal 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),
|
||||
)
|
||||
}
|
||||
14
lib/validations/Cargo.toml
Normal file
14
lib/validations/Cargo.toml
Normal 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
175
lib/validations/src/lib.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user