From 3d3d6a4111dbb3e038c385045aa31a86beffaabb Mon Sep 17 00:00:00 2001 From: Eugene Date: Sun, 22 Jun 2025 23:53:07 +0200 Subject: [PATCH 01/21] wip --- Cargo.lock | 339 +++++++++++- Cargo.toml | 3 + justfile | 2 +- warpgate-admin/Cargo.toml | 1 + .../src/api/certificate_credentials.rs | 251 +++++++++ warpgate-admin/src/api/mod.rs | 5 + warpgate-common/src/auth/cred.rs | 28 +- warpgate-common/src/config/defaults.rs | 5 + warpgate-common/src/config/mod.rs | 51 ++ warpgate-common/src/config/target.rs | 53 ++ warpgate-core/src/recordings/mod.rs | 2 +- warpgate-db-entities/Cargo.toml | 1 + .../src/CertificateCredential.rs | 69 +++ warpgate-db-entities/src/Recording.rs | 2 + warpgate-db-entities/src/Target.rs | 3 + warpgate-db-entities/src/User.rs | 13 +- warpgate-db-entities/src/lib.rs | 1 + warpgate-db-migrations/src/lib.rs | 2 + .../src/m00003_create_recording.rs | 2 + .../src/m00019_certificate_credentials.rs | 78 +++ warpgate-protocol-http/Cargo.toml | 1 + warpgate-protocol-http/src/api/auth.rs | 5 + warpgate-protocol-http/src/api/credentials.rs | 168 +++++- warpgate-protocol-kubernetes/Cargo.toml | 29 + warpgate-protocol-kubernetes/src/client.rs | 54 ++ warpgate-protocol-kubernetes/src/lib.rs | 59 ++ warpgate-protocol-kubernetes/src/recording.rs | 88 +++ warpgate-protocol-kubernetes/src/server.rs | 517 ++++++++++++++++++ warpgate-protocol-ssh/src/server/session.rs | 4 + .../src/admin/AuthPolicyEditor.svelte | 9 +- .../admin/CertificateCredentialModal.svelte | 121 ++++ .../src/admin/CredentialEditor.svelte | 70 ++- .../src/admin/config/CreateTarget.svelte | 46 +- .../src/admin/config/CreateTicket.svelte | 9 +- .../src/admin/config/targets/Target.svelte | 41 +- .../src/admin/lib/openapi-schema.json | 432 ++++++++++++++- .../src/common/ConnectionInstructions.svelte | 23 +- warpgate-web/src/common/protocols.ts | 72 +++ .../src/gateway/CredentialManager.svelte | 89 ++- .../src/gateway/lib/openapi-schema.json | 143 ++++- warpgate/Cargo.toml | 1 + warpgate/src/commands/run.rs | 10 + warpgate/src/commands/test_target.rs | 3 + warpgate/src/protocols.rs | 4 + 44 files changed, 2854 insertions(+), 55 deletions(-) create mode 100644 warpgate-admin/src/api/certificate_credentials.rs create mode 100644 warpgate-db-entities/src/CertificateCredential.rs create mode 100644 warpgate-db-migrations/src/m00019_certificate_credentials.rs create mode 100644 warpgate-protocol-kubernetes/Cargo.toml create mode 100644 warpgate-protocol-kubernetes/src/client.rs create mode 100644 warpgate-protocol-kubernetes/src/lib.rs create mode 100644 warpgate-protocol-kubernetes/src/recording.rs create mode 100644 warpgate-protocol-kubernetes/src/server.rs create mode 100644 warpgate-web/src/admin/CertificateCredentialModal.svelte diff --git a/Cargo.lock b/Cargo.lock index f8632f0e9..b4c469972 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -428,7 +428,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 1.1.0", "shlex", "syn 2.0.101", "which", @@ -816,6 +816,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1494,9 +1504,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -1780,6 +1792,26 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-http-proxy" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ad4b0a1e37510028bc4ba81d0e38d239c39671b0f0ce9e02dfa93a8133f7c08" +dependencies = [ + "bytes", + "futures-util", + "headers", + "http", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls-native-certs 0.7.3", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-rustls" version = "0.27.6" @@ -1789,12 +1821,14 @@ dependencies = [ "http", "hyper", "hyper-util", + "log", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.1", "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", + "webpki-roots 1.0.0", ] [[package]] @@ -2137,6 +2171,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonpath-rust" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d8fe85bd70ff715f31ce8c739194b423d79811a19602115d611a3ec85d6200" +dependencies = [ + "lazy_static", + "once_cell", + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "jsonwebtoken" version = "9.3.1" @@ -2152,6 +2201,19 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "k8s-openapi" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8847402328d8301354c94d605481f25a6bdc1ed65471fd96af8eca71141b13" +dependencies = [ + "base64 0.22.1", + "chrono", + "serde", + "serde-value", + "serde_json", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -2172,6 +2234,71 @@ dependencies = [ "libc", ] +[[package]] +name = "kube" +version = "0.96.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efffeb3df0bd4ef3e5d65044573499c0e4889b988070b08c50b25b1329289a1f" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", +] + +[[package]] +name = "kube-client" +version = "0.96.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf471ece8ff8d24735ce78dac4d091e9fcb8d74811aeb6b75de4d1c3f5de0f1" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "either", + "futures", + "home", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-http-proxy", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem", + "rustls", + "rustls-pemfile", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tower 0.5.2", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "0.96.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42346d30bb34d1d7adc5c549b691bce7aa3a1e60254e68fab7e2d7b26fe3d77" +dependencies = [ + "chrono", + "form_urlencoded", + "http", + "k8s-openapi", + "serde", + "serde-value", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "lazy-regex" version = "3.4.1" @@ -2837,6 +2964,50 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "pest" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" +dependencies = [ + "memchr", + "thiserror 2.0.12", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb056d9e8ea77922845ec74a1c4e8fb17e7c218cc4fc11a15c5d25e189aa40bc" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e404e638f781eb3202dc82db6760c8ae8a1eeef7fb3fa8264b2ef280504966" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "pest_meta" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd1101f170f5903fde0914f899bb503d9ff5271d7ba76bbb70bea63690cc0d5" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "pgvector" version = "0.4.1" @@ -3298,6 +3469,60 @@ dependencies = [ "serde", ] +[[package]] +name = "quinn" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3bd15a6f2967aef83887dcb9fec0014580467e33720d073560cf015a5683012" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls", + "socket2", + "thiserror 2.0.12", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b820744eb4dc9b57a3398183639c511b5a26d2ed702cedd3febaa1393caa22cc" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "rand 0.9.1", + "ring", + "rustc-hash 2.1.1", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.12", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "541d0f57c6ec747a90738a52741d3221f7960e8ac2f0ff4b1a63680e033b4ab5" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.40" @@ -3505,8 +3730,9 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "quinn", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.1", "rustls-pki-types", "serde", "serde_json", @@ -3523,6 +3749,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots 1.0.0", ] [[package]] @@ -3778,6 +4005,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3822,12 +4055,26 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + [[package]] name = "rustls-native-certs" version = "0.8.1" @@ -3837,7 +4084,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.2.0", ] [[package]] @@ -3855,6 +4102,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] @@ -4138,6 +4386,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.9.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.2.0" @@ -4145,7 +4415,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" dependencies = [ "bitflags 2.9.1", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4933,7 +5203,7 @@ dependencies = [ "futures-util", "log", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.1", "rustls-pki-types", "tokio", "tokio-rustls", @@ -5046,8 +5316,10 @@ dependencies = [ "pin-project-lite", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -5056,16 +5328,19 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ + "base64 0.22.1", "bitflags 2.9.1", "bytes", "futures-util", "http", "http-body", "iri-string", + "mime", "pin-project-lite", "tower 0.5.2", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -5162,6 +5437,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uncased" version = "0.9.10" @@ -5363,6 +5644,7 @@ dependencies = [ "warpgate-core", "warpgate-db-entities", "warpgate-protocol-http", + "warpgate-protocol-kubernetes", "warpgate-protocol-mysql", "warpgate-protocol-postgres", "warpgate-protocol-ssh", @@ -5384,6 +5666,7 @@ dependencies = [ "regex", "russh", "rust-embed", + "rustls-pemfile", "sea-orm", "serde", "serde_json", @@ -5420,7 +5703,7 @@ dependencies = [ "rand_core 0.6.4", "russh", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.1", "rustls-pemfile", "schemars", "sea-orm", @@ -5502,6 +5785,7 @@ dependencies = [ "chrono", "poem-openapi", "sea-orm", + "secrecy", "serde", "serde_json", "sqlx", @@ -5543,6 +5827,7 @@ dependencies = [ "poem-openapi", "regex", "reqwest", + "rustls-pemfile", "sea-orm", "serde", "serde_json", @@ -5559,6 +5844,34 @@ dependencies = [ "warpgate-web", ] +[[package]] +name = "warpgate-protocol-kubernetes" +version = "0.14.1" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "futures", + "http", + "k8s-openapi", + "kube", + "poem", + "reqwest", + "sea-orm", + "secrecy", + "serde", + "serde_json", + "thiserror 2.0.12", + "tokio", + "tracing", + "url", + "uuid", + "warpgate-common", + "warpgate-core", + "warpgate-db-entities", +] + [[package]] name = "warpgate-protocol-mysql" version = "0.14.1" @@ -5598,7 +5911,7 @@ dependencies = [ "pgwire", "rsasl", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.1", "rustls-pemfile", "thiserror 2.0.12", "tokio", @@ -5778,6 +6091,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki" version = "0.22.4" diff --git a/Cargo.toml b/Cargo.toml index cab42e343..c15de99d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "warpgate-db-entities", "warpgate-database-protocols", "warpgate-protocol-http", + "warpgate-protocol-kubernetes", "warpgate-protocol-mysql", "warpgate-protocol-postgres", "warpgate-protocol-ssh", @@ -62,6 +63,8 @@ rand_chacha = { version = "0.3", default-features = false } rand_core = { version = "0.6", features = ["std"], default-features = false } dialoguer = { version = "0.11", default-features = false, features = ["editor", "password"] } tokio = { version = "1.20", features = ["tracing", "signal", "macros", "rt-multi-thread", "io-util"], default-features = false } +kube = { version = "0.96", features = ["client", "rustls-tls"], default-features = false } +k8s-openapi = { version = "0.23", features = ["latest"], default-features = false } [profile.release] lto = true diff --git a/justfile b/justfile index 196f8d259..f4f7b144c 100644 --- a/justfile +++ b/justfile @@ -1,4 +1,4 @@ -projects := "warpgate warpgate-admin warpgate-common warpgate-db-entities warpgate-db-migrations warpgate-database-protocols warpgate-protocol-ssh warpgate-protocol-mysql warpgate-protocol-postgres warpgate-protocol-http warpgate-core warpgate-sso" +projects := "warpgate warpgate-admin warpgate-common warpgate-db-entities warpgate-db-migrations warpgate-database-protocols warpgate-protocol-ssh warpgate-protocol-mysql warpgate-protocol-postgres warpgate-protocol-kubernetes warpgate-protocol-http warpgate-core warpgate-sso" run $RUST_BACKTRACE='1' *ARGS='run': cargo run --all-features -- --config config.yaml {{ARGS}} diff --git a/warpgate-admin/Cargo.toml b/warpgate-admin/Cargo.toml index 86ddcac5d..b573ffd72 100644 --- a/warpgate-admin/Cargo.toml +++ b/warpgate-admin/Cargo.toml @@ -14,6 +14,7 @@ hex = { version = "0.4", default-features = false } mime_guess = { version = "2.0", default-features = false } poem.workspace = true poem-openapi.workspace = true +rustls-pemfile.workspace = true russh.workspace = true rust-embed = { version = "8.3", default-features = false } sea-orm.workspace = true diff --git a/warpgate-admin/src/api/certificate_credentials.rs b/warpgate-admin/src/api/certificate_credentials.rs new file mode 100644 index 000000000..ee0d0c7c9 --- /dev/null +++ b/warpgate-admin/src/api/certificate_credentials.rs @@ -0,0 +1,251 @@ +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use poem::web::Data; +use poem_openapi::param::Path; +use poem_openapi::payload::Json; +use poem_openapi::{ApiResponse, Object, OpenApi}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, ModelTrait, QueryFilter, + Set, +}; +use tokio::sync::Mutex; +use uuid::Uuid; +use warpgate_common::{UserCertificateCredential, WarpgateError, Secret}; +use warpgate_db_entities::CertificateCredential; + +use super::AnySecurityScheme; + +fn validate_certificate_pem(cert: &str) -> Result<(), WarpgateError> { + // Check if it looks like a PEM certificate + let cert = cert.trim(); + if !cert.starts_with("-----BEGIN CERTIFICATE-----") { + return Err(WarpgateError::Other( + "Certificate must be in PEM format and start with '-----BEGIN CERTIFICATE-----'".into() + )); + } + if !cert.ends_with("-----END CERTIFICATE-----") { + return Err(WarpgateError::Other( + "Certificate must be in PEM format and end with '-----END CERTIFICATE-----'".into() + )); + } + + // Try to parse the certificate using rustls-pemfile + use rustls_pemfile::Item; + let mut reader = std::io::Cursor::new(cert.as_bytes()); + match rustls_pemfile::read_one(&mut reader) { + Ok(Some(Item::X509Certificate(_))) => Ok(()), + Ok(Some(_)) => Err(WarpgateError::Other( + "PEM file does not contain a certificate".into() + )), + Ok(None) => Err(WarpgateError::Other( + "No valid PEM items found in certificate".into() + )), + Err(_) => Err(WarpgateError::Other( + "Invalid PEM certificate format".into() + )), + } +} + +fn abbreviate_certificate(cert: &str) -> String { + // Extract the subject or first few lines of the certificate for display + if let Some(first_line) = cert.lines().next() { + if first_line.len() > 50 { + format!("{}...", &first_line[..47]) + } else { + first_line.to_string() + } + } else { + "Invalid certificate".to_string() + } +} + +#[derive(Object)] +struct ExistingCertificateCredential { + id: Uuid, + label: String, + date_added: Option>, + last_used: Option>, + abbreviated: String, +} + +#[derive(Object)] +struct NewCertificateCredential { + label: String, + certificate: String, +} + +impl From for ExistingCertificateCredential { + fn from(credential: CertificateCredential::Model) -> Self { + Self { + id: credential.id, + date_added: credential.date_added, + last_used: credential.last_used, + label: credential.label, + abbreviated: abbreviate_certificate(&credential.certificate), + } + } +} + +impl From<&NewCertificateCredential> for UserCertificateCredential { + fn from(credential: &NewCertificateCredential) -> Self { + Self { + certificate: Secret::new(credential.certificate.clone()), + } + } +} + +#[derive(ApiResponse)] +enum GetCertificateCredentialsResponse { + #[oai(status = 200)] + Ok(Json>), +} + +#[derive(ApiResponse)] +enum CreateCertificateCredentialResponse { + #[oai(status = 201)] + Created(Json), +} + +#[derive(ApiResponse)] +enum UpdateCertificateCredentialResponse { + #[oai(status = 200)] + Updated(Json), + #[oai(status = 404)] + NotFound, +} + +pub struct ListApi; + +#[OpenApi] +impl ListApi { + #[oai( + path = "/users/:user_id/credentials/certificates", + method = "get", + operation_id = "get_certificate_credentials" + )] + async fn api_get_all( + &self, + db: Data<&Arc>>, + user_id: Path, + _auth: AnySecurityScheme, + ) -> Result { + let db = db.lock().await; + + let objects = CertificateCredential::Entity::find() + .filter(CertificateCredential::Column::UserId.eq(*user_id)) + .all(&*db) + .await?; + + Ok(GetCertificateCredentialsResponse::Ok(Json( + objects.into_iter().map(Into::into).collect(), + ))) + } + + #[oai( + path = "/users/:user_id/credentials/certificates", + method = "post", + operation_id = "create_certificate_credential" + )] + async fn api_create( + &self, + db: Data<&Arc>>, + body: Json, + user_id: Path, + _auth: AnySecurityScheme, + ) -> Result { + // Validate the certificate PEM format + validate_certificate_pem(&body.certificate)?; + + let db = db.lock().await; + + let object = CertificateCredential::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(*user_id), + date_added: Set(Some(Utc::now())), + last_used: Set(None), + label: Set(body.label.clone()), + ..CertificateCredential::ActiveModel::from(UserCertificateCredential::from(&*body)) + } + .insert(&*db) + .await + .map_err(WarpgateError::from)?; + + Ok(CreateCertificateCredentialResponse::Created(Json( + object.into(), + ))) + } +} + +#[derive(ApiResponse)] +enum DeleteCredentialResponse { + #[oai(status = 204)] + Deleted, + #[oai(status = 404)] + NotFound, +} + +pub struct DetailApi; + +#[OpenApi] +impl DetailApi { + #[oai( + path = "/users/:user_id/credentials/certificates/:id", + method = "put", + operation_id = "update_certificate_credential" + )] + async fn api_update( + &self, + db: Data<&Arc>>, + body: Json, + user_id: Path, + id: Path, + _auth: AnySecurityScheme, + ) -> Result { + let db = db.lock().await; + + let model = CertificateCredential::ActiveModel { + id: Set(id.0), + user_id: Set(*user_id), + date_added: Set(Some(Utc::now())), + label: Set(body.label.clone()), + ..<_>::from(UserCertificateCredential::from(&*body)) + } + .update(&*db) + .await; + + match model { + Ok(model) => Ok(UpdateCertificateCredentialResponse::Updated(Json( + model.into(), + ))), + Err(DbErr::RecordNotFound(_)) => Ok(UpdateCertificateCredentialResponse::NotFound), + Err(e) => Err(e.into()), + } + } + + #[oai( + path = "/users/:user_id/credentials/certificates/:id", + method = "delete", + operation_id = "delete_certificate_credential" + )] + async fn api_delete( + &self, + db: Data<&Arc>>, + user_id: Path, + id: Path, + _auth: AnySecurityScheme, + ) -> Result { + let db = db.lock().await; + + let Some(model) = CertificateCredential::Entity::find_by_id(id.0) + .filter(CertificateCredential::Column::UserId.eq(*user_id)) + .one(&*db) + .await? + else { + return Ok(DeleteCredentialResponse::NotFound); + }; + + model.delete(&*db).await?; + Ok(DeleteCredentialResponse::Deleted) + } +} diff --git a/warpgate-admin/src/api/mod.rs b/warpgate-admin/src/api/mod.rs index f25c84e3c..f4182fabf 100644 --- a/warpgate-admin/src/api/mod.rs +++ b/warpgate-admin/src/api/mod.rs @@ -1,6 +1,7 @@ use poem_openapi::auth::ApiKey; use poem_openapi::{OpenApi, SecurityScheme}; +mod certificate_credentials; mod known_hosts_detail; mod known_hosts_list; mod logs; @@ -58,6 +59,10 @@ pub fn get() -> impl OpenApi { public_key_credentials::ListApi, public_key_credentials::DetailApi, ), + ( + certificate_credentials::ListApi, + certificate_credentials::DetailApi, + ), (otp_credentials::ListApi, otp_credentials::DetailApi), parameters::Api, ssh_connection_test::Api, diff --git a/warpgate-common/src/auth/cred.rs b/warpgate-common/src/auth/cred.rs index d59636efe..3a32e2eda 100644 --- a/warpgate-common/src/auth/cred.rs +++ b/warpgate-common/src/auth/cred.rs @@ -3,7 +3,7 @@ use poem_openapi::Enum; use russh::keys::Algorithm; use serde::{Deserialize, Serialize}; -use crate::Secret; +use crate::{Secret, UserCertificateCredential}; #[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Hash, Enum)] pub enum CredentialKind { @@ -11,6 +11,8 @@ pub enum CredentialKind { Password, #[serde(rename = "publickey")] PublicKey, + #[serde(rename = "certificate")] + Certificate, #[serde(rename = "otp")] Totp, #[serde(rename = "sso")] @@ -27,6 +29,9 @@ pub enum AuthCredential { kind: Algorithm, public_key_bytes: Bytes, }, + Certificate { + certificate: Secret, + }, Sso { provider: String, email: String, @@ -39,6 +44,7 @@ impl AuthCredential { match self { Self::Password { .. } => CredentialKind::Password, Self::PublicKey { .. } => CredentialKind::PublicKey, + Self::Certificate { .. } => CredentialKind::Certificate, Self::Otp { .. } => CredentialKind::Totp, Self::Sso { .. } => CredentialKind::Sso, Self::WebUserApproval => CredentialKind::WebUserApproval, @@ -49,9 +55,29 @@ impl AuthCredential { match self { Self::Password { .. } => "password".to_string(), Self::PublicKey { .. } => "public key".to_string(), + Self::Certificate { .. } => "client certificate".to_string(), Self::Otp { .. } => "one-time password".to_string(), Self::Sso { provider, .. } => format!("SSO ({provider})"), Self::WebUserApproval => "in-browser auth".to_string(), } } } + +impl From for AuthCredential { + fn from(cred: UserCertificateCredential) -> Self { + AuthCredential::Certificate { + certificate: cred.certificate, + } + } +} + +impl From for Option { + fn from(cred: AuthCredential) -> Self { + match cred { + AuthCredential::Certificate { certificate } => Some(UserCertificateCredential { + certificate, + }), + _ => None, + } + } +} diff --git a/warpgate-common/src/config/defaults.rs b/warpgate-common/src/config/defaults.rs index 9d24532ac..499675124 100644 --- a/warpgate-common/src/config/defaults.rs +++ b/warpgate-common/src/config/defaults.rs @@ -54,6 +54,11 @@ pub(crate) fn _default_postgres_listen() -> ListenEndpoint { ListenEndpoint::from(SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), 55432)) } +#[inline] +pub(crate) fn _default_kubernetes_listen() -> ListenEndpoint { + ListenEndpoint::from(SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), 8443)) +} + #[inline] pub(crate) fn _default_retention() -> Duration { Duration::from_secs(60 * 60 * 24 * 7) diff --git a/warpgate-common/src/config/mod.rs b/warpgate-common/src/config/mod.rs index 933fbb49d..9c90bb4fe 100644 --- a/warpgate-common/src/config/mod.rs +++ b/warpgate-common/src/config/mod.rs @@ -30,6 +30,8 @@ pub enum UserAuthCredential { Password(UserPasswordCredential), #[serde(rename = "publickey")] PublicKey(UserPublicKeyCredential), + #[serde(rename = "certificate")] + Certificate(UserCertificateCredential), #[serde(rename = "otp")] Totp(UserTotpCredential), #[serde(rename = "sso")] @@ -53,6 +55,12 @@ impl UserPasswordCredential { pub struct UserPublicKeyCredential { pub key: Secret, } + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)] +pub struct UserCertificateCredential { + pub certificate: Secret, +} + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)] pub struct UserTotpCredential { #[serde(with = "crate::helpers::serde_base64_secret")] @@ -69,6 +77,7 @@ impl UserAuthCredential { match self { Self::Password(_) => CredentialKind::Password, Self::PublicKey(_) => CredentialKind::PublicKey, + Self::Certificate(_) => CredentialKind::Certificate, Self::Totp(_) => CredentialKind::Totp, Self::Sso(_) => CredentialKind::Sso, } @@ -80,6 +89,8 @@ pub struct UserRequireCredentialsPolicy { #[serde(skip_serializing_if = "Option::is_none")] pub http: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub kubernetes: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub ssh: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub mysql: Option>, @@ -303,6 +314,42 @@ impl MySqlConfig { } } +#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)] +pub struct KubernetesConfig { + #[serde(default = "_default_false")] + pub enable: bool, + + #[serde(default = "_default_kubernetes_listen")] + pub listen: ListenEndpoint, + + #[serde(default)] + pub external_port: Option, + + #[serde(default)] + pub certificate: String, + + #[serde(default)] + pub key: String, +} + +impl Default for KubernetesConfig { + fn default() -> Self { + KubernetesConfig { + enable: false, + listen: _default_kubernetes_listen(), + external_port: None, + certificate: "".to_owned(), + key: "".to_owned(), + } + } +} + +impl KubernetesConfig { + pub fn external_port(&self) -> u16 { + self.external_port.unwrap_or(self.listen.port()) + } +} + #[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)] pub struct PostgresConfig { #[serde(default = "_default_false")] @@ -397,6 +444,9 @@ pub struct WarpgateConfigStore { #[serde(default)] pub http: HttpConfig, + #[serde(default)] + pub kubernetes: KubernetesConfig, + #[serde(default)] pub mysql: MySqlConfig, @@ -416,6 +466,7 @@ impl Default for WarpgateConfigStore { database_url: _default_database_url(), ssh: <_>::default(), http: <_>::default(), + kubernetes: <_>::default(), mysql: <_>::default(), postgres: <_>::default(), log: <_>::default(), diff --git a/warpgate-common/src/config/target.rs b/warpgate-common/src/config/target.rs index c8d4bd61e..3375f9e5d 100644 --- a/warpgate-common/src/config/target.rs +++ b/warpgate-common/src/config/target.rs @@ -7,6 +7,21 @@ use uuid::Uuid; use super::defaults::*; use crate::Secret; +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)] +pub struct KubernetesTargetCertificateAuth { + pub certificate: Secret, + pub private_key: Secret, +} + +impl Default for KubernetesTargetCertificateAuth { + fn default() -> Self { + Self { + certificate: Secret::new(String::new()), + private_key: Secret::new(String::new()), + } + } +} + #[derive(Debug, Deserialize, Serialize, Clone, Object)] pub struct TargetSSHOptions { pub host: String, @@ -128,6 +143,42 @@ pub struct TargetPostgresOptions { #[derive(Debug, Deserialize, Serialize, Clone, Object, Default)] pub struct TargetWebAdminOptions {} +#[derive(Debug, Deserialize, Serialize, Clone, Object)] +pub struct TargetKubernetesOptions { + #[serde(default = "_default_empty_string")] + pub cluster_url: String, + + #[serde(default = "_default_empty_string")] + pub namespace: String, + + #[serde(default)] + pub tls: Tls, + + #[serde(default)] + pub auth: KubernetesTargetAuth, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Union)] +#[serde(untagged)] +#[oai(discriminator_name = "kind", one_of)] +pub enum KubernetesTargetAuth { + #[serde(rename = "token")] + Token(KubernetesTargetTokenAuth), + #[serde(rename = "certificate")] + Certificate(KubernetesTargetCertificateAuth), +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)] +pub struct KubernetesTargetTokenAuth { + pub token: Secret, +} + +impl Default for KubernetesTargetAuth { + fn default() -> Self { + KubernetesTargetAuth::Certificate(KubernetesTargetCertificateAuth::default()) + } +} + #[derive(Debug, Deserialize, Serialize, Clone, Object)] pub struct Target { #[serde(default)] @@ -147,6 +198,8 @@ pub enum TargetOptions { Ssh(TargetSSHOptions), #[serde(rename = "http")] Http(TargetHTTPOptions), + #[serde(rename = "kubernetes")] + Kubernetes(TargetKubernetesOptions), #[serde(rename = "mysql")] MySql(TargetMySqlOptions), #[serde(rename = "postgres")] diff --git a/warpgate-core/src/recordings/mod.rs b/warpgate-core/src/recordings/mod.rs index aab093413..3a91b0fff 100644 --- a/warpgate-core/src/recordings/mod.rs +++ b/warpgate-core/src/recordings/mod.rs @@ -15,7 +15,7 @@ mod traffic; mod writer; pub use terminal::*; pub use traffic::*; -use writer::RecordingWriter; +pub use writer::RecordingWriter; #[derive(thiserror::Error, Debug)] pub enum Error { diff --git a/warpgate-db-entities/Cargo.toml b/warpgate-db-entities/Cargo.toml index 9096dc870..7fbad9cf3 100644 --- a/warpgate-db-entities/Cargo.toml +++ b/warpgate-db-entities/Cargo.toml @@ -19,3 +19,4 @@ serde.workspace = true serde_json.workspace = true uuid = { version = "1.3", features = ["v4", "serde"], default-features = false } warpgate-common = { version = "*", path = "../warpgate-common", default-features = false } +secrecy = "0.10" diff --git a/warpgate-db-entities/src/CertificateCredential.rs b/warpgate-db-entities/src/CertificateCredential.rs new file mode 100644 index 000000000..0594b374c --- /dev/null +++ b/warpgate-db-entities/src/CertificateCredential.rs @@ -0,0 +1,69 @@ +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use sea_orm::sea_query::ForeignKeyAction; +use sea_orm::Set; +use secrecy::ExposeSecret; +use serde::Serialize; +use uuid::Uuid; +use warpgate_common::{UserAuthCredential, UserCertificateCredential}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)] +#[sea_orm(table_name = "credentials_certificate")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub user_id: Uuid, + pub label: String, + pub date_added: Option>, + pub last_used: Option>, + #[sea_orm(column_type = "Text")] + pub certificate: String, +} + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation { + User, +} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Self::User => Entity::belongs_to(super::User::Entity) + .from(Column::UserId) + .to(super::User::Column::Id) + .on_delete(ForeignKeyAction::Cascade) + .into(), + } + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} + +impl From for UserCertificateCredential { + fn from(credential: Model) -> Self { + UserCertificateCredential { + certificate: credential.certificate.into(), + } + } +} + +impl From for UserAuthCredential { + fn from(model: Model) -> Self { + Self::Certificate(model.into()) + } +} + +impl From for ActiveModel { + fn from(credential: UserCertificateCredential) -> Self { + Self { + certificate: Set(credential.certificate.expose_secret().clone()), + ..Default::default() + } + } +} diff --git a/warpgate-db-entities/src/Recording.rs b/warpgate-db-entities/src/Recording.rs index 6c6c58e72..cc4de45f3 100644 --- a/warpgate-db-entities/src/Recording.rs +++ b/warpgate-db-entities/src/Recording.rs @@ -12,6 +12,8 @@ pub enum RecordingKind { Terminal, #[sea_orm(string_value = "traffic")] Traffic, + #[sea_orm(string_value = "kubernetes")] + Kubernetes, } #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)] diff --git a/warpgate-db-entities/src/Target.rs b/warpgate-db-entities/src/Target.rs index bb899c393..5932fb8f9 100644 --- a/warpgate-db-entities/src/Target.rs +++ b/warpgate-db-entities/src/Target.rs @@ -9,6 +9,8 @@ use warpgate_common::{Target, TargetOptions}; pub enum TargetKind { #[sea_orm(string_value = "http")] Http, + #[sea_orm(string_value = "kubernetes")] + Kubernetes, #[sea_orm(string_value = "mysql")] MySql, #[sea_orm(string_value = "ssh")] @@ -23,6 +25,7 @@ impl From<&TargetOptions> for TargetKind { fn from(options: &TargetOptions) -> Self { match options { TargetOptions::Http(_) => Self::Http, + TargetOptions::Kubernetes(_) => Self::Kubernetes, TargetOptions::MySql(_) => Self::MySql, TargetOptions::Postgres(_) => Self::Postgres, TargetOptions::Ssh(_) => Self::Ssh, diff --git a/warpgate-db-entities/src/User.rs b/warpgate-db-entities/src/User.rs index c9f9b3031..0f360733b 100644 --- a/warpgate-db-entities/src/User.rs +++ b/warpgate-db-entities/src/User.rs @@ -5,7 +5,7 @@ use serde::Serialize; use uuid::Uuid; use warpgate_common::{User, UserDetails, WarpgateError}; -use crate::{OtpCredential, PasswordCredential, PublicKeyCredential, Role, SsoCredential}; +use crate::{CertificateCredential, OtpCredential, PasswordCredential, PublicKeyCredential, Role, SsoCredential}; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)] #[sea_orm(table_name = "users")] @@ -47,6 +47,12 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::CertificateCredentials.def() + } +} + impl Related for Entity { fn to() -> RelationDef { Relation::SsoCredentials.def() @@ -65,6 +71,7 @@ pub enum Relation { OtpCredentials, PasswordCredentials, PublicKeyCredentials, + CertificateCredentials, SsoCredentials, ApiTokens, } @@ -84,6 +91,10 @@ impl RelationTrait for Relation { .from(Column::Id) .to(super::PublicKeyCredential::Column::UserId) .into(), + Self::CertificateCredentials => Entity::has_many(super::CertificateCredential::Entity) + .from(Column::Id) + .to(super::CertificateCredential::Column::UserId) + .into(), Self::SsoCredentials => Entity::has_many(super::SsoCredential::Entity) .from(Column::Id) .to(super::SsoCredential::Column::UserId) diff --git a/warpgate-db-entities/src/lib.rs b/warpgate-db-entities/src/lib.rs index b0ca9ce28..700023661 100644 --- a/warpgate-db-entities/src/lib.rs +++ b/warpgate-db-entities/src/lib.rs @@ -1,6 +1,7 @@ #![allow(non_snake_case)] pub mod ApiToken; +pub mod CertificateCredential; pub mod KnownHost; pub mod LogEntry; pub mod OtpCredential; diff --git a/warpgate-db-migrations/src/lib.rs b/warpgate-db-migrations/src/lib.rs index e1a14178b..c2dfe1bd4 100644 --- a/warpgate-db-migrations/src/lib.rs +++ b/warpgate-db-migrations/src/lib.rs @@ -20,6 +20,7 @@ mod m00015_fix_public_key_dates; mod m00016_fix_public_key_length; mod m00017_descriptions; mod m00018_ticket_description; +mod m00019_certificate_credentials; pub struct Migrator; @@ -45,6 +46,7 @@ impl MigratorTrait for Migrator { Box::new(m00016_fix_public_key_length::Migration), Box::new(m00017_descriptions::Migration), Box::new(m00018_ticket_description::Migration), + Box::new(m00019_certificate_credentials::Migration), ] } } diff --git a/warpgate-db-migrations/src/m00003_create_recording.rs b/warpgate-db-migrations/src/m00003_create_recording.rs index e6b3a505d..4e039510e 100644 --- a/warpgate-db-migrations/src/m00003_create_recording.rs +++ b/warpgate-db-migrations/src/m00003_create_recording.rs @@ -14,6 +14,8 @@ pub mod recording { Terminal, #[sea_orm(string_value = "traffic")] Traffic, + #[sea_orm(string_value = "kubernetes")] + Kubernetes, } #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] diff --git a/warpgate-db-migrations/src/m00019_certificate_credentials.rs b/warpgate-db-migrations/src/m00019_certificate_credentials.rs new file mode 100644 index 000000000..f979d9381 --- /dev/null +++ b/warpgate-db-migrations/src/m00019_certificate_credentials.rs @@ -0,0 +1,78 @@ +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use sea_orm::sea_query::ForeignKeyAction; +use sea_orm::Schema; +use sea_orm_migration::prelude::*; +use serde::Serialize; +use uuid::Uuid; + +use super::m00008_users::user as User; + +pub mod certificate_credential { + use super::*; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)] + #[sea_orm(table_name = "credentials_certificate")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub user_id: Uuid, + pub label: String, + pub date_added: Option>, + pub last_used: Option>, + #[sea_orm(column_type = "Text")] + pub certificate: String, + } + + #[derive(Copy, Clone, Debug, EnumIter)] + pub enum Relation { + User, + } + + impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Self::User => Entity::belongs_to(super::User::Entity) + .from(Column::UserId) + .to(super::User::Column::Id) + .on_delete(ForeignKeyAction::Cascade) + .into(), + } + } + } + + impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } + } + + impl ActiveModelBehavior for ActiveModel {} +} + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let builder = manager.get_database_backend(); + let schema = Schema::new(builder); + manager + .create_table(schema.create_table_from_entity(certificate_credential::Entity)) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(certificate_credential::Entity) + .to_owned(), + ) + .await?; + Ok(()) + } +} diff --git a/warpgate-protocol-http/Cargo.toml b/warpgate-protocol-http/Cargo.toml index 9609d69ae..95339acb3 100644 --- a/warpgate-protocol-http/Cargo.toml +++ b/warpgate-protocol-http/Cargo.toml @@ -22,6 +22,7 @@ reqwest = { version = "0.12", features = [ "stream", "gzip", ], default-features = false } +rustls-pemfile.workspace = true sea-orm.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/warpgate-protocol-http/src/api/auth.rs b/warpgate-protocol-http/src/api/auth.rs index da17c17f1..9f531e518 100644 --- a/warpgate-protocol-http/src/api/auth.rs +++ b/warpgate-protocol-http/src/api/auth.rs @@ -118,6 +118,11 @@ impl From for ApiAuthState { Some(CredentialKind::Sso) => ApiAuthState::SsoNeeded, Some(CredentialKind::WebUserApproval) => ApiAuthState::WebUserApprovalNeeded, Some(CredentialKind::PublicKey) => ApiAuthState::PublicKeyNeeded, + Some(CredentialKind::Certificate) => { + // Certificate authentication is not supported for HTTP protocol + // This credential type is primarily for Kubernetes + ApiAuthState::Failed + } None => ApiAuthState::Failed, } } diff --git a/warpgate-protocol-http/src/api/credentials.rs b/warpgate-protocol-http/src/api/credentials.rs index 9e02165d7..74f0b1154 100644 --- a/warpgate-protocol-http/src/api/credentials.rs +++ b/warpgate-protocol-http/src/api/credentials.rs @@ -9,11 +9,42 @@ use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilte use uuid::Uuid; use warpgate_common::{User, UserPasswordCredential, UserRequireCredentialsPolicy, WarpgateError}; use warpgate_core::Services; -use warpgate_db_entities::{self as entities, Parameters, PasswordCredential, PublicKeyCredential}; +use warpgate_db_entities::{self as entities, Parameters, PasswordCredential, PublicKeyCredential, CertificateCredential}; use super::common::get_user; use crate::common::{endpoint_auth, RequestAuthorization}; +fn validate_certificate_pem(cert: &str) -> Result<(), WarpgateError> { + // Check if it looks like a PEM certificate + let cert = cert.trim(); + if !cert.starts_with("-----BEGIN CERTIFICATE-----") { + return Err(WarpgateError::Other( + "Certificate must be in PEM format and start with '-----BEGIN CERTIFICATE-----'".into() + )); + } + if !cert.ends_with("-----END CERTIFICATE-----") { + return Err(WarpgateError::Other( + "Certificate must be in PEM format and end with '-----END CERTIFICATE-----'".into() + )); + } + + // Try to parse the certificate using rustls-pemfile + use rustls_pemfile::Item; + let mut reader = std::io::Cursor::new(cert.as_bytes()); + match rustls_pemfile::read_one(&mut reader) { + Ok(Some(Item::X509Certificate(_))) => Ok(()), + Ok(Some(_)) => Err(WarpgateError::Other( + "PEM file does not contain a certificate".into() + )), + Ok(None) => Err(WarpgateError::Other( + "No valid PEM items found in certificate".into() + )), + Err(_) => Err(WarpgateError::Other( + "Invalid PEM certificate format".into() + )), + } +} + pub struct Api; #[derive(Enum)] @@ -58,6 +89,7 @@ pub struct CredentialsState { password: PasswordState, otp: Vec, public_keys: Vec, + certificates: Vec, sso: Vec, credential_policy: UserRequireCredentialsPolicy, } @@ -151,6 +183,64 @@ enum CreateOtpCredentialResponse { Unauthorized, } +#[derive(Object)] +struct NewCertificateCredential { + label: String, + certificate: String, +} + +#[derive(Object)] +struct ExistingCertificateCredential { + id: Uuid, + label: String, + date_added: Option>, + last_used: Option>, + abbreviated: String, +} + +fn abbreviate_certificate(cert: &str) -> String { + // Extract the subject or first few lines of the certificate for display + if let Some(first_line) = cert.lines().next() { + if first_line.len() > 50 { + format!("{}...", &first_line[..47]) + } else { + first_line.to_string() + } + } else { + "Invalid certificate".to_string() + } +} + +impl From for ExistingCertificateCredential { + fn from(credential: entities::CertificateCredential::Model) -> Self { + Self { + id: credential.id, + label: credential.label, + date_added: credential.date_added, + last_used: credential.last_used, + abbreviated: abbreviate_certificate(&credential.certificate), + } + } +} + +#[derive(ApiResponse)] +enum CreateCertificateCredentialResponse { + #[oai(status = 201)] + Created(Json), + #[oai(status = 401)] + Unauthorized, +} + +#[derive(ApiResponse)] +enum DeleteCertificateCredentialResponse { + #[oai(status = 200)] + Ok, + #[oai(status = 401)] + Unauthorized, + #[oai(status = 404)] + NotFound, +} + pub fn parameters_based_auth(e: E) -> impl Endpoint { e.around(|ep, req| async move { let services = Data::<&Services>::from_request_without_body(&req).await?; @@ -205,6 +295,12 @@ impl Api { .find_related(entities::PublicKeyCredential::Entity) .all(&*db) .await?; + + let cert_creds = user_model + .find_related(entities::CertificateCredential::Entity) + .all(&*db) + .await?; + Ok(CredentialsStateResponse::Ok(Json(CredentialsState { password: match password_creds.len() { 0 => PasswordState::Unset, @@ -213,6 +309,7 @@ impl Api { }, otp: otp_creds.into_iter().map(Into::into).collect(), public_keys: pk_creds.into_iter().map(Into::into).collect(), + certificates: cert_creds.into_iter().map(Into::into).collect(), sso: sso_creds.into_iter().map(Into::into).collect(), credential_policy: user.credential_policy.unwrap_or_default(), }))) @@ -404,4 +501,73 @@ impl Api { model.delete(&*db).await?; Ok(DeleteCredentialResponse::Deleted) } + + #[oai( + path = "/profile/credentials/certificates", + method = "post", + operation_id = "add_my_certificate", + transform = "parameters_based_auth" + )] + async fn api_create_certificate( + &self, + auth: Data<&RequestAuthorization>, + services: Data<&Services>, + body: Json, + ) -> Result { + // Validate the certificate PEM format + validate_certificate_pem(&body.certificate)?; + + let db = services.db.lock().await; + + let Some(user_model) = get_user(&auth, &db).await? else { + return Ok(CreateCertificateCredentialResponse::Unauthorized); + }; + + let object = CertificateCredential::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user_model.id), + date_added: Set(Some(Utc::now())), + last_used: Set(None), + label: Set(body.label.clone()), + certificate: Set(body.certificate.clone()), + } + .insert(&*db) + .await + .map_err(WarpgateError::from)?; + + Ok(CreateCertificateCredentialResponse::Created(Json( + object.into(), + ))) + } + + #[oai( + path = "/profile/credentials/certificates/:id", + method = "delete", + operation_id = "delete_my_certificate", + transform = "parameters_based_auth" + )] + async fn api_delete_certificate( + &self, + auth: Data<&RequestAuthorization>, + services: Data<&Services>, + id: Path, + ) -> Result { + let db = services.db.lock().await; + + let Some(user_model) = get_user(&auth, &db).await? else { + return Ok(DeleteCertificateCredentialResponse::Unauthorized); + }; + + let Some(model) = user_model + .find_related(entities::CertificateCredential::Entity) + .filter(entities::CertificateCredential::Column::Id.eq(id.0)) + .one(&*db) + .await? + else { + return Ok(DeleteCertificateCredentialResponse::NotFound); + }; + + model.delete(&*db).await?; + Ok(DeleteCertificateCredentialResponse::Ok) + } } diff --git a/warpgate-protocol-kubernetes/Cargo.toml b/warpgate-protocol-kubernetes/Cargo.toml new file mode 100644 index 000000000..4e3d2bafa --- /dev/null +++ b/warpgate-protocol-kubernetes/Cargo.toml @@ -0,0 +1,29 @@ +[package] +edition = "2021" +license = "Apache-2.0" +name = "warpgate-protocol-kubernetes" +version = "0.14.1" + +[dependencies] +anyhow = { version = "1.0", features = ["std"], default-features = false } +async-trait = { version = "0.1", default-features = false } +bytes.workspace = true +futures.workspace = true +kube.workspace = true +k8s-openapi.workspace = true +serde = { version = "1.0", features = ["derive"], default-features = false } +serde_json = { version = "1.0", default-features = false } +thiserror.workspace = true +tokio.workspace = true +tracing.workspace = true +uuid = { version = "1.3", features = ["v4"], default-features = false } +warpgate-common = { version = "*", path = "../warpgate-common", default-features = false } +warpgate-core = { version = "*", path = "../warpgate-core", default-features = false } +warpgate-db-entities = { version = "*", path = "../warpgate-db-entities", default-features = false } +poem.workspace = true +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +url = { version = "2.0", default-features = false } +http = { version = "1.0", default-features = false } +secrecy = { version = "0.10", default-features = false } +sea-orm.workspace = true +chrono = { version = "0.4", default-features = false, features = ["serde"] } diff --git a/warpgate-protocol-kubernetes/src/client.rs b/warpgate-protocol-kubernetes/src/client.rs new file mode 100644 index 000000000..35c293172 --- /dev/null +++ b/warpgate-protocol-kubernetes/src/client.rs @@ -0,0 +1,54 @@ +use anyhow::Result; +use kube::{Client, Config}; +use warpgate_common::{KubernetesTargetAuth, TargetKubernetesOptions, TlsMode}; + +pub async fn test_connection(options: &TargetKubernetesOptions) -> Result<()> { + let config = create_kube_config(options).await?; + let client = Client::try_from(config)?; + + // Test basic connectivity by listing namespaces + let api: kube::Api = kube::Api::all(client); + let _namespaces = api.list(&Default::default()).await?; + + Ok(()) +} + +pub async fn create_kube_config(options: &TargetKubernetesOptions) -> Result { + let mut config = Config::infer().await.unwrap_or_else(|_| { + // If infer fails, create a basic config + Config::new(options.cluster_url.parse().unwrap()) + }); + + // Set the cluster URL + config.cluster_url = options.cluster_url.parse()?; + + // Configure TLS verification + match options.tls.mode { + TlsMode::Disabled => { + config.accept_invalid_certs = true; + } + TlsMode::Preferred => { + if !options.tls.verify { + config.accept_invalid_certs = true; + } + } + TlsMode::Required => { + if !options.tls.verify { + config.accept_invalid_certs = true; + } + } + } + + // Configure authentication + match &options.auth { + KubernetesTargetAuth::Token(auth) => { + config.auth_info.token = Some(secrecy::SecretBox::new(auth.token.expose_secret().clone().into())); + } + KubernetesTargetAuth::Certificate(_) => { + // Certificate-based auth will be handled by user credentials + // For target testing, we'll try without auth first + } + } + + Ok(config) +} diff --git a/warpgate-protocol-kubernetes/src/lib.rs b/warpgate-protocol-kubernetes/src/lib.rs new file mode 100644 index 000000000..3946cc4ad --- /dev/null +++ b/warpgate-protocol-kubernetes/src/lib.rs @@ -0,0 +1,59 @@ +mod client; +mod recording; +mod server; + +use anyhow::Result; +pub use client::*; +pub use recording::*; +pub use server::run_server; +use warpgate_common::{ + ListenEndpoint, ProtocolName, Target, TargetKubernetesOptions, TargetOptions, +}; +use warpgate_core::{ProtocolServer, Services, TargetTestError}; + +pub static PROTOCOL_NAME: ProtocolName = "Kubernetes"; + +#[derive(Clone)] +pub struct KubernetesProtocolServer { + services: Services, +} + +impl KubernetesProtocolServer { + pub async fn new(services: &Services) -> Result { + Ok(Self { + services: services.clone(), + }) + } +} + +impl ProtocolServer for KubernetesProtocolServer { + async fn run(self, address: ListenEndpoint) -> Result<()> { + run_server(self.services, address).await + } + + async fn test_target(&self, target: Target) -> Result<(), TargetTestError> { + let TargetOptions::Kubernetes(options) = &target.options else { + return Err(TargetTestError::Misconfigured( + "Not a Kubernetes target".to_string(), + )); + }; + + test_kubernetes_target(options.clone()).await + } +} + +async fn test_kubernetes_target(options: TargetKubernetesOptions) -> Result<(), TargetTestError> { + // Test connection to Kubernetes cluster + match client::test_connection(&options).await { + Ok(_) => Ok(()), + Err(e) => { + if e.to_string().contains("authentication") || e.to_string().contains("Unauthorized") { + Err(TargetTestError::AuthenticationError) + } else if e.to_string().contains("connection") || e.to_string().contains("unreachable") { + Err(TargetTestError::Unreachable) + } else { + Err(TargetTestError::ConnectionError(e.to_string())) + } + } + } +} diff --git a/warpgate-protocol-kubernetes/src/recording.rs b/warpgate-protocol-kubernetes/src/recording.rs new file mode 100644 index 000000000..4cb41431c --- /dev/null +++ b/warpgate-protocol-kubernetes/src/recording.rs @@ -0,0 +1,88 @@ +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use tokio::time::Instant; +use warpgate_core::recordings::{RecordingWriter, Recorder}; +use warpgate_db_entities::Recording::RecordingKind; + +#[derive(Serialize, Deserialize, Debug)] +pub struct KubernetesRecordingItem { + pub time: f32, + pub request_method: String, + pub request_path: String, + pub request_headers: std::collections::HashMap, + #[serde(with = "warpgate_common::helpers::serde_base64")] + pub request_body: Bytes, + pub response_status: Option, + pub response_body: Option>, +} + +pub struct KubernetesRecorder { + writer: RecordingWriter, + started_at: Instant, +} + +impl KubernetesRecorder { + fn get_time(&self) -> f32 { + self.started_at.elapsed().as_secs_f32() + } + + async fn write_item(&mut self, item: &KubernetesRecordingItem) -> Result<(), warpgate_core::recordings::Error> { + let mut serialized_item = serde_json::to_vec(&item).map_err(warpgate_core::recordings::Error::Serialization)?; + serialized_item.push(b'\n'); + self.writer.write(&serialized_item).await?; + Ok(()) + } + + pub async fn record_request( + &mut self, + method: &str, + path: &str, + headers: std::collections::HashMap, + body: &[u8], + ) -> Result<(), warpgate_core::recordings::Error> { + self.write_item(&KubernetesRecordingItem { + time: self.get_time(), + request_method: method.to_string(), + request_path: path.to_string(), + request_headers: headers, + request_body: Bytes::from(body.to_vec()), + response_status: None, + response_body: None, + }) + .await + } + + pub async fn record_response( + &mut self, + method: &str, + path: &str, + headers: std::collections::HashMap, + request_body: &[u8], + status: u16, + response_body: &[u8], + ) -> Result<(), warpgate_core::recordings::Error> { + self.write_item(&KubernetesRecordingItem { + time: self.get_time(), + request_method: method.to_string(), + request_path: path.to_string(), + request_headers: headers, + request_body: Bytes::from(request_body.to_vec()), + response_status: Some(status), + response_body: Some(response_body.to_vec()), + }) + .await + } +} + +impl Recorder for KubernetesRecorder { + fn kind() -> RecordingKind { + RecordingKind::Kubernetes + } + + fn new(writer: RecordingWriter) -> Self { + KubernetesRecorder { + writer, + started_at: Instant::now(), + } + } +} diff --git a/warpgate-protocol-kubernetes/src/server.rs b/warpgate-protocol-kubernetes/src/server.rs new file mode 100644 index 000000000..aef58c67a --- /dev/null +++ b/warpgate-protocol-kubernetes/src/server.rs @@ -0,0 +1,517 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use futures::{SinkExt, StreamExt}; +use poem::listener::{Listener, RustlsConfig}; +use poem::web::websocket::{Message, WebSocket}; +use poem::web::{Data, Path}; +use poem::{get, handler, Body, EndpointExt, IntoResponse, Request, Response, Route, Server}; +use sea_orm::{ActiveModelTrait, Set}; +use secrecy::ExposeSecret; +use tokio::sync::Mutex; +use tracing::*; +use warpgate_common::{ + ListenEndpoint, SessionId, Target, TargetKubernetesOptions, TargetOptions, + TlsCertificateAndPrivateKey, TlsCertificateBundle, TlsPrivateKey, +}; +use warpgate_core::recordings::SessionRecordings; +use warpgate_core::{AuthStateStore, ConfigProvider, Services, State}; +use warpgate_db_entities::Session; + +use crate::client::create_kube_config; +use crate::recording::KubernetesRecorder; + +#[derive(Debug)] +struct AuthenticatedTarget { + target: Target, + auth_user: Option, +} + +pub async fn run_server(services: Services, address: ListenEndpoint) -> Result<()> { + let state = services.state.clone(); + let auth_state_store = services.auth_state_store.clone(); + let recordings = services.recordings.clone(); + + let app = Route::new() + .at("/:target_name/*path", handle_api_request) + .at("/:target_name/ws", get(handle_websocket)) + .with(poem::middleware::Cors::new()) + .data(state) + .data(auth_state_store) + .data(recordings) + .data(services.clone()) + .before(|req: Request| async move { + info!("Received Kubernetes API request: {}", req.uri()); + Ok(req) + }); + + info!(?address, "Kubernetes protocol listening"); + + let certificate_and_key = { + let config = services.config.lock().await; + let certificate_path = config + .paths_relative_to + .join(&config.store.kubernetes.certificate); + let key_path = config.paths_relative_to.join(&config.store.kubernetes.key); + + TlsCertificateAndPrivateKey { + certificate: TlsCertificateBundle::from_file(&certificate_path) + .await + .with_context(|| { + format!("reading TLS private key from '{}'", key_path.display()) + })?, + private_key: TlsPrivateKey::from_file(&key_path).await.with_context(|| { + format!( + "reading TLS certificate from '{}'", + certificate_path.display() + ) + })?, + } + }; + + Server::new( + address + .poem_listener() + .await? + .rustls(RustlsConfig::new().fallback(certificate_and_key.into())), + ) + .run(app) + .await + .context("Kubernetes server error")?; + + Ok(()) +} + +#[handler] +async fn handle_api_request( + req: &Request, + Path((target_name, path)): Path<(String, String)>, + body: Body, + state: Data<&Arc>>, + _auth_state_store: Data<&Arc>>, + recordings: Data<&Arc>>, + services: Data<&Services>, +) -> Result { + debug!( + target_name = target_name, + path_param = ?path, + full_uri = %req.uri(), + "Handling Kubernetes API request" + ); + + let target_info = authenticate_and_get_target(req, &target_name, &state, &services).await?; + + let TargetOptions::Kubernetes(k8s_options) = &target_info.target.options else { + return Err(poem::Error::from_string( + "Invalid target type", + poem::http::StatusCode::BAD_REQUEST, + )); + }; + + let client = + create_authenticated_client(k8s_options, &target_info.auth_user, &services).await?; + + info!( + "Target Kubernetes options: cluster_url={}, auth={:?}", + k8s_options.cluster_url, + match &k8s_options.auth { + warpgate_common::KubernetesTargetAuth::Token(_) => "Token", + warpgate_common::KubernetesTargetAuth::Certificate(_) => "Certificate", + } + ); + + let method = req.method().as_str(); + + // Extract the API path by removing the target name prefix from the original URI + let original_path = req.uri().path(); + let api_path = if let Some(stripped) = original_path.strip_prefix(&format!("/{}/", target_name)) + { + format!("/{}", stripped) + } else if original_path == format!("/{}", target_name) { + "/".to_string() + } else { + // Fallback to the path parameter method + format!("/{}", path) + }; + + let query = req.uri().query().unwrap_or(""); + + // Construct the full URL to the Kubernetes API server (without target prefix) + let full_url = if query.is_empty() { + format!("{}{}", k8s_options.cluster_url, api_path) + } else { + format!("{}{}?{}", k8s_options.cluster_url, api_path, query) + }; + + debug!( + target_name = target_name, + original_path = original_path, + api_path = api_path, + cluster_url = k8s_options.cluster_url, + full_url = full_url, + "Constructing upstream Kubernetes API URL" + ); + + // Extract headers + let mut headers = HashMap::new(); + for (name, value) in req.headers() { + if let Ok(value_str) = value.to_str() { + headers.insert(name.to_string(), value_str.to_string()); + } + } + + // Get request body + let body_bytes = body.into_bytes().await.map_err(|e| { + poem::Error::from_string( + format!("Failed to read body: {}", e), + poem::http::StatusCode::BAD_REQUEST, + ) + })?; + + // Record the request if recording is enabled + let mut recorder_opt = { + // Check if recording is enabled in the config + let config = services.config.lock().await; + if config.store.recordings.enable { + drop(config); + + // For Kubernetes protocol, we'll create a temporary session since each request is independent + // In the future, this could be improved to group related requests by user/target + let session_id = uuid::Uuid::new_v4(); + + // First create a minimal session record in the database to satisfy foreign key constraints + if let Err(e) = create_temporary_session(&session_id, &target_info, &services).await { + warn!("Failed to create temporary session for recording: {}", e); + None + } else { + match start_recording(&session_id, &recordings).await { + Ok(recorder) => Some(recorder), + Err(e) => { + warn!("Failed to start recording: {}", e); + None + } + } + } + } else { + None + } + }; + + if let Some(ref mut recorder) = recorder_opt { + if let Err(e) = recorder + .record_request(method, &full_url, headers.clone(), &body_bytes) + .await + { + warn!("Failed to record Kubernetes request: {}", e); + } + } + + // Forward request to Kubernetes API + let mut request_builder = client.request( + http::Method::from_bytes(method.as_bytes()).map_err(|e| { + poem::Error::from_string( + format!("Invalid method: {}", e), + poem::http::StatusCode::BAD_REQUEST, + ) + })?, + &full_url, + ); + + // Add headers (excluding authorization, host, and content-length as they'll be set by reqwest) + let mut upstream_headers = HashMap::new(); + for (name, value) in &headers { + let header_name_lower = name.to_lowercase(); + if ![ + "host", + "content-length", + "connection", + "transfer-encoding", + "authorization", + ] + .contains(&header_name_lower.as_str()) + { + if let (Ok(header_name), Ok(header_value)) = ( + http::HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(value), + ) { + request_builder = request_builder.header(header_name, header_value); + upstream_headers.insert(name.clone(), value.clone()); + } + } else { + debug!(header = name, "Filtering out header from upstream request"); + } + } + + debug!( + filtered_headers = ?upstream_headers, + "Headers being sent to upstream Kubernetes API" + ); + + if !body_bytes.is_empty() { + request_builder = request_builder.body(body_bytes.to_vec()); + } + + // Debug logging for upstream request + debug!( + method = method, + url = %full_url, + headers = ?headers, + body_size = body_bytes.len(), + "Sending request to upstream Kubernetes API" + ); + + let response = request_builder.send().await.map_err(|e| { + warn!( + method = method, + url = %full_url, + error = %e, + "Kubernetes API request failed" + ); + poem::Error::from_string( + format!("Kubernetes API error: {}", e), + poem::http::StatusCode::BAD_GATEWAY, + ) + })?; + + let status = response.status(); + let response_headers = response.headers().clone(); + + debug!( + method = method, + url = %full_url, + status = %status, + response_headers = ?response_headers, + "Received response from upstream Kubernetes API" + ); + + let response_body = response.bytes().await.map_err(|e| { + poem::Error::from_string( + format!("Failed to read response: {}", e), + poem::http::StatusCode::BAD_GATEWAY, + ) + })?; + + // Record the response + if let Some(ref mut recorder) = recorder_opt { + if let Err(e) = recorder + .record_response( + method, + &full_url, + headers, + &body_bytes, + status.as_u16(), + &response_body, + ) + .await + { + warn!("Failed to record Kubernetes response: {}", e); + } + } + + let mut poem_response = Response::builder().status(status); + + // Copy response headers + for (name, value) in response_headers.iter() { + if let Ok(poem_name) = poem::http::HeaderName::from_bytes(name.as_str().as_bytes()) { + if let Ok(poem_value) = poem::http::HeaderValue::from_bytes(value.as_bytes()) { + poem_response = poem_response.header(poem_name, poem_value); + } + } + } + + Ok(poem_response.body(response_body.to_vec())) +} + +#[handler] +async fn handle_websocket( + Path(_target_name): Path, + ws: WebSocket, + _state: Data<&Arc>>, + _auth_state_store: Data<&Arc>>, +) -> impl IntoResponse { + ws.on_upgrade(|socket| async move { + let (mut sink, mut stream) = socket.split(); + + while let Some(msg) = stream.next().await { + match msg { + Ok(Message::Text(text)) => { + // Echo back for now - in a real implementation, this would + // establish a WebSocket connection to the Kubernetes API + if sink.send(Message::Text(text)).await.is_err() { + break; + } + } + Ok(Message::Binary(data)) => { + if sink.send(Message::Binary(data)).await.is_err() { + break; + } + } + Ok(Message::Close(_)) => break, + Err(_) => break, + _ => {} + } + } + }) +} + +async fn authenticate_and_get_target( + req: &Request, + target_name: &str, + _state: &Arc>, + services: &Services, +) -> Result { + // Check for Bearer token authentication (API tokens) + if let Some(auth_header) = req.headers().get("authorization") { + if let Ok(auth_str) = auth_header.to_str() { + if let Some(token) = auth_str.strip_prefix("Bearer ") { + let mut config_provider = services.config_provider.lock().await; + if let Ok(Some(user)) = config_provider.validate_api_token(token).await { + // Look up the specific target by name from the URL + let targets = config_provider.list_targets().await.map_err(|e| { + poem::Error::from_string( + format!("Failed to list targets: {}", e), + poem::http::StatusCode::INTERNAL_SERVER_ERROR, + ) + })?; + + // Find the target with the specified name + for target in targets { + if target.name == target_name + && matches!(target.options, TargetOptions::Kubernetes(_)) + { + if config_provider + .authorize_target(&user.username, &target.name) + .await + .unwrap_or(false) + { + return Ok(AuthenticatedTarget { + target, + auth_user: Some(user.username), + }); + } else { + return Err(poem::Error::from_string( + format!("Access denied to target: {}", target_name), + poem::http::StatusCode::FORBIDDEN, + )); + } + } + } + + return Err(poem::Error::from_string( + format!("Kubernetes target not found: {}", target_name), + poem::http::StatusCode::NOT_FOUND, + )); + } + } + } + } + + // Check for certificate authentication + // This would be handled by TLS client certificate verification at the HTTP layer + // For now, return unauthorized if no valid authentication found + Err(poem::Error::from_string( + "Unauthorized", + poem::http::StatusCode::UNAUTHORIZED, + )) +} + +async fn create_authenticated_client( + k8s_options: &TargetKubernetesOptions, + _auth_user: &Option, + _services: &Services, +) -> Result { + debug!( + server_url = ?k8s_options.cluster_url, + auth_kind = ?k8s_options.auth, + tls_config = ?k8s_options.tls, + "Creating authenticated Kubernetes client" + ); + + let config = create_kube_config(k8s_options).await.map_err(|e| { + warn!(error = %e, "Failed to create kube config"); + poem::Error::from_string( + format!("Kubernetes config error: {}", e), + poem::http::StatusCode::BAD_REQUEST, + ) + })?; + + // Create HTTP client with the configuration + let mut client_builder = reqwest::Client::builder(); + + if config.accept_invalid_certs { + client_builder = client_builder.danger_accept_invalid_certs(true); + } + + // TODO: Add certificate authentication support + // For certificate auth, we would: + // 1. Look up the user's certificate credentials from the database + // 2. Configure the HTTP client with the certificate and private key + + if let Some(token) = &config.auth_info.token { + info!( + "Setting Kubernetes auth token: {}...", + &token.expose_secret()[..std::cmp::min(10, token.expose_secret().len())] + ); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token.expose_secret())) + .map_err(|e| { + poem::Error::from_string( + format!("Invalid token: {}", e), + poem::http::StatusCode::BAD_REQUEST, + ) + })?, + ); + client_builder = client_builder.default_headers(headers); + } else { + warn!("No Kubernetes auth token configured for target"); + } + + client_builder.build().map_err(|e| { + poem::Error::from_string( + format!("Failed to create HTTP client: {}", e), + poem::http::StatusCode::INTERNAL_SERVER_ERROR, + ) + }) +} + +async fn create_temporary_session( + session_id: &SessionId, + target_info: &AuthenticatedTarget, + services: &Services, +) -> Result<(), Box> { + use chrono::Utc; + + let session_model = Session::ActiveModel { + id: Set(*session_id), + username: Set(target_info.auth_user.clone()), + target_snapshot: Set(Some(serde_json::to_string(&target_info.target)?)), + remote_address: Set("kubernetes-api".to_string()), // For API requests, we don't have a real remote address + started: Set(Utc::now()), + protocol: Set("kubernetes".to_string()), + ..Default::default() + }; + + let db = services.db.lock().await; + session_model.insert(&*db).await?; + + Ok(()) +} + +async fn start_recording( + session_id: &SessionId, + recordings: &Arc>, +) -> Result { + let mut recordings = recordings.lock().await; + recordings + .start::(session_id, "kubernetes-api".to_string()) + .await + .map_err(|e| { + poem::Error::from_string( + format!("Recording error: {}", e), + poem::http::StatusCode::INTERNAL_SERVER_ERROR, + ) + }) +} diff --git a/warpgate-protocol-ssh/src/server/session.rs b/warpgate-protocol-ssh/src/server/session.rs index 084173546..43a7b0710 100644 --- a/warpgate-protocol-ssh/src/server/session.rs +++ b/warpgate-protocol-ssh/src/server/session.rs @@ -1499,6 +1499,10 @@ impl ServerSession { CredentialKind::WebUserApproval => m.push(MethodKind::KeyboardInteractive), CredentialKind::PublicKey => m.push(MethodKind::PublicKey), CredentialKind::Sso => m.push(MethodKind::KeyboardInteractive), + CredentialKind::Certificate => { + // Certificate authentication is not supported for SSH protocol + // This credential type is primarily for Kubernetes + } } } if m.contains(&MethodKind::KeyboardInteractive) { diff --git a/warpgate-web/src/admin/AuthPolicyEditor.svelte b/warpgate-web/src/admin/AuthPolicyEditor.svelte index aff4d9f6e..9bad210a0 100644 --- a/warpgate-web/src/admin/AuthPolicyEditor.svelte +++ b/warpgate-web/src/admin/AuthPolicyEditor.svelte @@ -5,7 +5,7 @@ import type { ExistingCredential } from './CredentialEditor.svelte' import Fa from 'svelte-fa' import { faInfoCircle } from '@fortawesome/free-solid-svg-icons' -type ProtocolID = 'http' | 'ssh' | 'mysql' | 'postgres' +type ProtocolID = 'http' | 'ssh' | 'mysql' | 'postgres' | 'kubernetes' interface Props { value: UserRequireCredentialsPolicy @@ -24,6 +24,7 @@ let { const labels = { Password: 'Password', PublicKey: 'Key', + Certificate: 'Certificate', Totp: 'OTP', Sso: 'SSO', WebUserApproval: 'In-browser auth', @@ -39,6 +40,12 @@ const tips: Record> = { http: new Map(), mysql: new Map(), ssh: new Map(), + kubernetes: new Map([ + [ + [CredentialKind.WebUserApproval, true], + 'Users will need to log in to the Warpgate UI to see the 2FA auth prompt for Kubernetes access.', + ], + ]), } let activeTips: string[] = $derived.by(() => { diff --git a/warpgate-web/src/admin/CertificateCredentialModal.svelte b/warpgate-web/src/admin/CertificateCredentialModal.svelte new file mode 100644 index 000000000..436548f93 --- /dev/null +++ b/warpgate-web/src/admin/CertificateCredentialModal.svelte @@ -0,0 +1,121 @@ + + + { + if (instance) { + label = instance.label + // Note: we can't populate the certificate field as it's not returned by the API for security + } + field?.focus() +}}> +
{ + _save() + e.preventDefault() + }}> + + + + + + + + {#if certificate && !CERT_REGEX.test(certificate.trim())} +
+ Certificate must be in PEM format (-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----) +
+ {/if} +
+ + + + + +
+
diff --git a/warpgate-web/src/admin/CredentialEditor.svelte b/warpgate-web/src/admin/CredentialEditor.svelte index a4370cdf5..7dc96b0cf 100644 --- a/warpgate-web/src/admin/CredentialEditor.svelte +++ b/warpgate-web/src/admin/CredentialEditor.svelte @@ -3,17 +3,19 @@ { kind: typeof CredentialKind.Password } & ExistingPasswordCredential | { kind: typeof CredentialKind.Sso } & ExistingSsoCredential | { kind: typeof CredentialKind.PublicKey } & ExistingPublicKeyCredential + | { kind: typeof CredentialKind.Certificate } & ExistingCertificateCredential | { kind: typeof CredentialKind.Totp } & ExistingOtpCredential diff --git a/warpgate-web/src/admin/config/CreateTicket.svelte b/warpgate-web/src/admin/config/CreateTicket.svelte index 8c3cce7e8..22a8bb882 100644 --- a/warpgate-web/src/admin/config/CreateTicket.svelte +++ b/warpgate-web/src/admin/config/CreateTicket.svelte @@ -71,7 +71,14 @@ async function create () { {#if selectedTarget && selectedUser} - {#if target.options.kind === 'Ssh' || target.options.kind === 'MySql' || target.options.kind === 'Postgres'} + {#if target.options.kind === 'Ssh' || target.options.kind === 'MySql' || target.options.kind === 'Postgres' || target.options.kind === 'Kubernetes'} {#snippet children(users)} @@ -107,6 +107,7 @@ Http: TargetKind.Http, MySql: TargetKind.MySql, Postgres: TargetKind.Postgres, + Kubernetes: TargetKind.Kubernetes, }[target.options.kind ?? '']} targetExternalHost={target.options.kind === 'Http' ? target.options.externalHost : undefined} /> @@ -139,6 +140,9 @@ {#if target.options.kind === 'Http'} HTTP target {/if} + {#if target.options.kind === 'Kubernetes'} + Kubernetes target + {/if} {#if target.options.kind === 'WebAdmin'} This web admin interface {/if} @@ -204,6 +208,41 @@ {/if} + {#if target.options.kind === 'Kubernetes'} + + + + + + + + +
Authentication
+ + + + + {#if target.options.auth.kind === 'Certificate'} + + + + + + + {/if} + + {#if target.options.auth.kind === 'Token'} + + + + {/if} + + + {/if} +

Allow access for roles

{#snippet children(roles)} diff --git a/warpgate-web/src/admin/lib/openapi-schema.json b/warpgate-web/src/admin/lib/openapi-schema.json index 86d5fce14..4e72c0184 100644 --- a/warpgate-web/src/admin/lib/openapi-schema.json +++ b/warpgate-web/src/admin/lib/openapi-schema.json @@ -2,7 +2,7 @@ "openapi": "3.0.0", "info": { "title": "Warpgate Web Admin", - "version": "v0.13.2-49-ge91a4cf-modified" + "version": "v0.14.1-8-g2ef3553-modified" }, "servers": [ { @@ -2074,6 +2074,198 @@ "operationId": "delete_public_key_credential" } }, + "/users/{user_id}/credentials/certificates": { + "get": { + "parameters": [ + { + "name": "user_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "in": "path", + "required": true, + "deprecated": false, + "explode": true + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json; charset=utf-8": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExistingCertificateCredential" + } + } + } + } + } + }, + "security": [ + { + "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] + } + ], + "operationId": "get_certificate_credentials" + }, + "post": { + "parameters": [ + { + "name": "user_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "in": "path", + "required": true, + "deprecated": false, + "explode": true + } + ], + "requestBody": { + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewCertificateCredential" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ExistingCertificateCredential" + } + } + } + } + }, + "security": [ + { + "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] + } + ], + "operationId": "create_certificate_credential" + } + }, + "/users/{user_id}/credentials/certificates/{id}": { + "put": { + "parameters": [ + { + "name": "user_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "in": "path", + "required": true, + "deprecated": false, + "explode": true + }, + { + "name": "id", + "schema": { + "type": "string", + "format": "uuid" + }, + "in": "path", + "required": true, + "deprecated": false, + "explode": true + } + ], + "requestBody": { + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewCertificateCredential" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ExistingCertificateCredential" + } + } + } + }, + "404": { + "description": "" + } + }, + "security": [ + { + "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] + } + ], + "operationId": "update_certificate_credential" + }, + "delete": { + "parameters": [ + { + "name": "user_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "in": "path", + "required": true, + "deprecated": false, + "explode": true + }, + { + "name": "id", + "schema": { + "type": "string", + "format": "uuid" + }, + "in": "path", + "required": true, + "deprecated": false, + "explode": true + } + ], + "responses": { + "204": { + "description": "" + }, + "404": { + "description": "" + } + }, + "security": [ + { + "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] + } + ], + "operationId": "delete_certificate_credential" + } + }, "/users/{user_id}/credentials/otp": { "get": { "parameters": [ @@ -2307,6 +2499,7 @@ "schemas": { "AddSshKnownHostRequest": { "type": "object", + "title": "AddSshKnownHostRequest", "required": [ "host", "port", @@ -2331,6 +2524,7 @@ }, "CheckSshHostKeyRequest": { "type": "object", + "title": "CheckSshHostKeyRequest", "required": [ "host", "port" @@ -2347,6 +2541,7 @@ }, "CheckSshHostKeyResponseBody": { "type": "object", + "title": "CheckSshHostKeyResponseBody", "required": [ "remote_key_type", "remote_key_base64" @@ -2362,6 +2557,7 @@ }, "CreateTicketRequest": { "type": "object", + "title": "CreateTicketRequest", "required": [ "username", "target_name" @@ -2388,6 +2584,7 @@ }, "CreateUserRequest": { "type": "object", + "title": "CreateUserRequest", "required": [ "username" ], @@ -2405,13 +2602,44 @@ "enum": [ "Password", "PublicKey", + "Certificate", "Totp", "Sso", "WebUserApproval" ] }, + "ExistingCertificateCredential": { + "type": "object", + "title": "ExistingCertificateCredential", + "required": [ + "id", + "label", + "abbreviated" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "label": { + "type": "string" + }, + "date_added": { + "type": "string", + "format": "date-time" + }, + "last_used": { + "type": "string", + "format": "date-time" + }, + "abbreviated": { + "type": "string" + } + } + }, "ExistingOtpCredential": { "type": "object", + "title": "ExistingOtpCredential", "required": [ "id" ], @@ -2424,6 +2652,7 @@ }, "ExistingPasswordCredential": { "type": "object", + "title": "ExistingPasswordCredential", "required": [ "id" ], @@ -2436,6 +2665,7 @@ }, "ExistingPublicKeyCredential": { "type": "object", + "title": "ExistingPublicKeyCredential", "required": [ "id", "label", @@ -2464,6 +2694,7 @@ }, "ExistingSsoCredential": { "type": "object", + "title": "ExistingSsoCredential", "required": [ "id", "email" @@ -2483,6 +2714,7 @@ }, "GetLogsRequest": { "type": "object", + "title": "GetLogsRequest", "properties": { "before": { "type": "string", @@ -2508,8 +2740,99 @@ } } }, + "KubernetesTargetAuth": { + "type": "object", + "oneOf": [ + { + "$ref": "#/components/schemas/KubernetesTargetAuth_KubernetesTargetTokenAuth" + }, + { + "$ref": "#/components/schemas/KubernetesTargetAuth_KubernetesTargetCertificateAuth" + } + ], + "discriminator": { + "propertyName": "kind", + "mapping": { + "Token": "#/components/schemas/KubernetesTargetAuth_KubernetesTargetTokenAuth", + "Certificate": "#/components/schemas/KubernetesTargetAuth_KubernetesTargetCertificateAuth" + } + } + }, + "KubernetesTargetAuth_KubernetesTargetCertificateAuth": { + "allOf": [ + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "Certificate" + ], + "example": "Certificate" + } + } + }, + { + "$ref": "#/components/schemas/KubernetesTargetCertificateAuth" + } + ] + }, + "KubernetesTargetAuth_KubernetesTargetTokenAuth": { + "allOf": [ + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "Token" + ], + "example": "Token" + } + } + }, + { + "$ref": "#/components/schemas/KubernetesTargetTokenAuth" + } + ] + }, + "KubernetesTargetCertificateAuth": { + "type": "object", + "title": "KubernetesTargetCertificateAuth", + "required": [ + "certificate", + "private_key" + ], + "properties": { + "certificate": { + "type": "string" + }, + "private_key": { + "type": "string" + } + } + }, + "KubernetesTargetTokenAuth": { + "type": "object", + "title": "KubernetesTargetTokenAuth", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string" + } + } + }, "LogEntry": { "type": "object", + "title": "LogEntry", "required": [ "id", "text", @@ -2539,8 +2862,25 @@ } } }, + "NewCertificateCredential": { + "type": "object", + "title": "NewCertificateCredential", + "required": [ + "label", + "certificate" + ], + "properties": { + "label": { + "type": "string" + }, + "certificate": { + "type": "string" + } + } + }, "NewOtpCredential": { "type": "object", + "title": "NewOtpCredential", "required": [ "secret_key" ], @@ -2556,6 +2896,7 @@ }, "NewPasswordCredential": { "type": "object", + "title": "NewPasswordCredential", "required": [ "password" ], @@ -2567,6 +2908,7 @@ }, "NewPublicKeyCredential": { "type": "object", + "title": "NewPublicKeyCredential", "required": [ "label", "openssh_public_key" @@ -2582,6 +2924,7 @@ }, "NewSsoCredential": { "type": "object", + "title": "NewSsoCredential", "required": [ "email" ], @@ -2596,6 +2939,7 @@ }, "PaginatedResponse_SessionSnapshot": { "type": "object", + "title": "PaginatedResponse_SessionSnapshot", "required": [ "items", "offset", @@ -2620,6 +2964,7 @@ }, "ParameterUpdate": { "type": "object", + "title": "ParameterUpdate", "properties": { "allow_own_credential_management": { "type": "boolean" @@ -2628,6 +2973,7 @@ }, "ParameterValues": { "type": "object", + "title": "ParameterValues", "required": [ "allow_own_credential_management" ], @@ -2639,6 +2985,7 @@ }, "Recording": { "type": "object", + "title": "Recording", "required": [ "id", "name", @@ -2675,11 +3022,13 @@ "type": "string", "enum": [ "Terminal", - "Traffic" + "Traffic", + "Kubernetes" ] }, "Role": { "type": "object", + "title": "Role", "required": [ "id", "name", @@ -2700,6 +3049,7 @@ }, "RoleDataRequest": { "type": "object", + "title": "RoleDataRequest", "required": [ "name" ], @@ -2714,6 +3064,7 @@ }, "SSHKey": { "type": "object", + "title": "SSHKey", "required": [ "kind", "public_key_base64" @@ -2729,6 +3080,7 @@ }, "SSHKnownHost": { "type": "object", + "title": "SSHKnownHost", "required": [ "id", "host", @@ -2820,6 +3172,7 @@ }, "SessionSnapshot": { "type": "object", + "title": "SessionSnapshot", "required": [ "id", "started", @@ -2855,6 +3208,7 @@ }, "SshTargetPasswordAuth": { "type": "object", + "title": "SshTargetPasswordAuth", "required": [ "password" ], @@ -2865,10 +3219,12 @@ } }, "SshTargetPublicKeyAuth": { - "type": "object" + "type": "object", + "title": "SshTargetPublicKeyAuth" }, "Target": { "type": "object", + "title": "Target", "required": [ "id", "name", @@ -2900,6 +3256,7 @@ }, "TargetDataRequest": { "type": "object", + "title": "TargetDataRequest", "required": [ "name", "options" @@ -2918,6 +3275,7 @@ }, "TargetHTTPOptions": { "type": "object", + "title": "TargetHTTPOptions", "required": [ "url", "tls" @@ -2940,8 +3298,33 @@ } } }, + "TargetKubernetesOptions": { + "type": "object", + "title": "TargetKubernetesOptions", + "required": [ + "cluster_url", + "namespace", + "tls", + "auth" + ], + "properties": { + "cluster_url": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "tls": { + "$ref": "#/components/schemas/Tls" + }, + "auth": { + "$ref": "#/components/schemas/KubernetesTargetAuth" + } + } + }, "TargetMySqlOptions": { "type": "object", + "title": "TargetMySqlOptions", "required": [ "host", "port", @@ -2976,6 +3359,9 @@ { "$ref": "#/components/schemas/TargetOptions_TargetHTTPOptions" }, + { + "$ref": "#/components/schemas/TargetOptions_TargetKubernetesOptions" + }, { "$ref": "#/components/schemas/TargetOptions_TargetMySqlOptions" }, @@ -2991,6 +3377,7 @@ "mapping": { "Ssh": "#/components/schemas/TargetOptions_TargetSSHOptions", "Http": "#/components/schemas/TargetOptions_TargetHTTPOptions", + "Kubernetes": "#/components/schemas/TargetOptions_TargetKubernetesOptions", "MySql": "#/components/schemas/TargetOptions_TargetMySqlOptions", "Postgres": "#/components/schemas/TargetOptions_TargetPostgresOptions", "WebAdmin": "#/components/schemas/TargetOptions_TargetWebAdminOptions" @@ -3019,6 +3406,28 @@ } ] }, + "TargetOptions_TargetKubernetesOptions": { + "allOf": [ + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "Kubernetes" + ], + "example": "Kubernetes" + } + } + }, + { + "$ref": "#/components/schemas/TargetKubernetesOptions" + } + ] + }, "TargetOptions_TargetMySqlOptions": { "allOf": [ { @@ -3109,6 +3518,7 @@ }, "TargetPostgresOptions": { "type": "object", + "title": "TargetPostgresOptions", "required": [ "host", "port", @@ -3136,6 +3546,7 @@ }, "TargetSSHOptions": { "type": "object", + "title": "TargetSSHOptions", "required": [ "host", "port", @@ -3162,10 +3573,12 @@ } }, "TargetWebAdminOptions": { - "type": "object" + "type": "object", + "title": "TargetWebAdminOptions" }, "Ticket": { "type": "object", + "title": "Ticket", "required": [ "id", "username", @@ -3203,6 +3616,7 @@ }, "TicketAndSecret": { "type": "object", + "title": "TicketAndSecret", "required": [ "ticket", "secret" @@ -3218,6 +3632,7 @@ }, "Tls": { "type": "object", + "title": "Tls", "required": [ "mode", "verify" @@ -3241,6 +3656,7 @@ }, "User": { "type": "object", + "title": "User", "required": [ "id", "username", @@ -3264,6 +3680,7 @@ }, "UserDataRequest": { "type": "object", + "title": "UserDataRequest", "required": [ "username" ], @@ -3281,6 +3698,7 @@ }, "UserRequireCredentialsPolicy": { "type": "object", + "title": "UserRequireCredentialsPolicy", "properties": { "http": { "type": "array", @@ -3288,6 +3706,12 @@ "$ref": "#/components/schemas/CredentialKind" } }, + "kubernetes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CredentialKind" + } + }, "ssh": { "type": "array", "items": { diff --git a/warpgate-web/src/common/ConnectionInstructions.svelte b/warpgate-web/src/common/ConnectionInstructions.svelte index b9d87d61e..2b00844af 100644 --- a/warpgate-web/src/common/ConnectionInstructions.svelte +++ b/warpgate-web/src/common/ConnectionInstructions.svelte @@ -2,7 +2,7 @@ import { FormGroup } from '@sveltestrap/sveltestrap' import { TargetKind } from 'gateway/lib/api' import { serverInfo } from 'gateway/lib/store' - import { makeExampleSSHCommand, makeSSHUsername, makeExampleMySQLCommand, makeExampleMySQLURI, makeMySQLUsername, makeTargetURL, makeExamplePostgreSQLCommand, makePostgreSQLUsername, makeExamplePostgreSQLURI } from 'common/protocols' + import { makeExampleSSHCommand, makeSSHUsername, makeExampleMySQLCommand, makeExampleMySQLURI, makeMySQLUsername, makeTargetURL, makeExamplePostgreSQLCommand, makePostgreSQLUsername, makeExamplePostgreSQLURI, makeKubeconfig, makeExampleKubectlCommand } from 'common/protocols' import CopyButton from 'common/CopyButton.svelte' import Alert from './sveltestrap-s5-ports/Alert.svelte' @@ -39,6 +39,8 @@ let examplePostgreSQLURI = $derived(makeExamplePostgreSQLURI(opts)) let targetURL = $derived(targetName ? makeTargetURL(opts) : '') let authHeader = $derived(`Authorization: Warpgate ${ticketSecret}`) + let kubeconfig = $derived(makeKubeconfig(opts)) + let exampleKubectlCommand = $derived(makeExampleKubectlCommand(opts)) {#if targetKind === TargetKind.Ssh} @@ -109,3 +111,22 @@ Make sure you've set your client to require TLS and allowed cleartext password authentication. {/if} + +{#if targetKind === TargetKind.Kubernetes} + + + + + + + + + + + + Save the kubeconfig above to a file (e.g., warpgate-kubeconfig.yaml) and use it with kubectl. + {#if !ticketSecret} + You'll need to replace the placeholder certificate and key data with your actual credentials. + {/if} + +{/if} diff --git a/warpgate-web/src/common/protocols.ts b/warpgate-web/src/common/protocols.ts index 93f66fe37..e1beca272 100644 --- a/warpgate-web/src/common/protocols.ts +++ b/warpgate-web/src/common/protocols.ts @@ -65,8 +65,80 @@ export const possibleCredentials: Record> = { http: new Set([CredentialKind.Password, CredentialKind.Totp, CredentialKind.Sso]), mysql: new Set([CredentialKind.Password]), postgres: new Set([CredentialKind.Password, CredentialKind.WebUserApproval]), + kubernetes: new Set([CredentialKind.Certificate, CredentialKind.WebUserApproval]), } export function abbreviatePublicKey (key: string): string { return key.slice(0, 16) + '...' + key.slice(-8) } + +export function makeKubernetesContext (opt: ConnectionOptions): string { + if (opt.ticketSecret) { + return `ticket-${opt.ticketSecret}` + } + return `${opt.username ?? 'username'}:${opt.targetName ?? 'target'}` +} + +export function makeKubernetesNamespace (opt: ConnectionOptions): string { + return 'default' +} + +export function makeKubernetesClusterUrl (opt: ConnectionOptions): string { + const baseUrl = `https://${opt.serverInfo?.externalHost ?? 'warpgate-host'}:${opt.serverInfo?.ports.kubernetes ?? 'warpgate-kubernetes-port'}` + return `${baseUrl}/${opt.targetName ?? 'target'}` +} + +export function makeKubeconfig (opt: ConnectionOptions): string { + const clusterUrl = makeKubernetesClusterUrl(opt) + const context = makeKubernetesContext(opt) + const namespace = makeKubernetesNamespace(opt) + + if (opt.ticketSecret) { + // Token-based authentication using API ticket + return `apiVersion: v1 +kind: Config +clusters: +- cluster: + server: ${clusterUrl} + insecure-skip-tls-verify: true + name: warpgate-${opt.targetName ?? 'target'} +contexts: +- context: + cluster: warpgate-${opt.targetName ?? 'target'} + namespace: ${namespace} + user: ${context} + name: ${context} +current-context: ${context} +users: +- name: ${context} + user: + token: ${opt.ticketSecret} +` + } else { + // Certificate-based authentication + return `apiVersion: v1 +kind: Config +clusters: +- cluster: + server: ${clusterUrl} + insecure-skip-tls-verify: true + name: warpgate-${opt.targetName ?? 'target'} +contexts: +- context: + cluster: warpgate-${opt.targetName ?? 'target'} + namespace: ${namespace} + user: ${context} + name: ${context} +current-context: ${context} +users: +- name: ${context} + user: + client-certificate-data: + client-key-data: +` + } +} + +export function makeExampleKubectlCommand (opt: ConnectionOptions): string { + return shellEscape(['kubectl', '--kubeconfig', 'warpgate-kubeconfig.yaml', 'get', 'pods']) +} diff --git a/warpgate-web/src/gateway/CredentialManager.svelte b/warpgate-web/src/gateway/CredentialManager.svelte index d2e3f4a0a..878ccc3d5 100644 --- a/warpgate-web/src/gateway/CredentialManager.svelte +++ b/warpgate-web/src/gateway/CredentialManager.svelte @@ -1,10 +1,11 @@ @@ -78,9 +95,9 @@ {/if} - { changingPassword = true e.preventDefault() @@ -94,7 +111,7 @@ {#if creds.password === PasswordState.MultipleSet} Reset password {/if} - + @@ -107,21 +124,21 @@
- {#each creds.otp as credential} + {#each creds.otp as credential (credential.id)}
OTP device { deleteOtp(credential) e.preventDefault() @@ -142,14 +159,14 @@
- {#each creds.publicKeys as credential} + {#each creds.publicKeys as credential (credential.id)}
@@ -160,7 +177,7 @@ { deletePublicKey(credential) e.preventDefault() @@ -178,13 +195,52 @@ {/if} + + +
+ {#each creds.certificates as credential (credential.id)} +
+ +
+
{credential.label}
+ {credential.abbreviated} +
+ + + { + deleteCertificate(credential) + e.preventDefault() + }} + > + Delete + +
+ {/each} +
+ + {#if creds.certificates.length === 0 && creds.credentialPolicy.kubernetes?.includes(CredentialKind.Certificate)} + + Your credential policy requires using a certificate for authentication. Without one, you won't be able to log in. + + {/if} + {#if creds.sso.length > 0}

Single sign-on

- {#each creds.sso as credential} + {#each creds.sso as credential (credential.email)}
@@ -220,6 +276,13 @@ /> {/if} +{#if creatingCertificateCredential} + +{/if} + diff --git a/warpgate-web/src/theme/_theme.scss b/warpgate-web/src/theme/_theme.scss index f6a691805..1f28f56f0 100644 --- a/warpgate-web/src/theme/_theme.scss +++ b/warpgate-web/src/theme/_theme.scss @@ -39,7 +39,7 @@ // @import "bootstrap/scss/images"; @import "bootstrap/scss/containers"; @import "bootstrap/scss/grid"; -// @import "bootstrap/scss/tables"; +@import "bootstrap/scss/tables"; @import "bootstrap/scss/forms"; @import "bootstrap/scss/buttons"; @import "bootstrap/scss/transitions"; diff --git a/warpgate-web/src/theme/vars.common.scss b/warpgate-web/src/theme/vars.common.scss index db646708e..4f898ab00 100644 --- a/warpgate-web/src/theme/vars.common.scss +++ b/warpgate-web/src/theme/vars.common.scss @@ -37,3 +37,6 @@ $badge-padding-x: .85em; $alert-border-width: 0; $alert-border-scale: -30%; + +$table-color: var(--bs-body-color); +$table-bg: var(--bs-body-bg); From ca1755f985d18412cee8329f3c513adbce8c6d93 Mon Sep 17 00:00:00 2001 From: Eugene Date: Fri, 16 Jan 2026 11:46:52 +0100 Subject: [PATCH 21/21] lint --- warpgate-web/src/admin/AuthPolicyEditor.svelte | 4 ++-- warpgate-web/src/admin/Session.svelte | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/warpgate-web/src/admin/AuthPolicyEditor.svelte b/warpgate-web/src/admin/AuthPolicyEditor.svelte index 00ad113b9..84de26f47 100644 --- a/warpgate-web/src/admin/AuthPolicyEditor.svelte +++ b/warpgate-web/src/admin/AuthPolicyEditor.svelte @@ -3,6 +3,7 @@ import { Input } from '@sveltestrap/sveltestrap' import { CredentialKind, type UserRequireCredentialsPolicy } from './lib/api' import type { ExistingCredential } from './CredentialEditor.svelte' import InfoBox from 'common/InfoBox.svelte' +import { SvelteSet } from 'svelte/reactivity' type ProtocolID = 'http' | 'ssh' | 'mysql' | 'postgres' | 'kubernetes' @@ -58,8 +59,7 @@ let activeTips: string[] = $derived.by(() => { }) const validCredentials = $derived.by(() => { - let vc = new Set() - vc = new Set(existingCredentials.map(x => x.kind as CredentialKind)) + let vc = new SvelteSet(existingCredentials.map(x => x.kind as CredentialKind)) vc.add(CredentialKind.WebUserApproval) return vc }) diff --git a/warpgate-web/src/admin/Session.svelte b/warpgate-web/src/admin/Session.svelte index 44d1a25e0..635a09500 100644 --- a/warpgate-web/src/admin/Session.svelte +++ b/warpgate-web/src/admin/Session.svelte @@ -15,7 +15,7 @@ import Badge from 'common/sveltestrap-s5-ports/Badge.svelte' import { faArrowRight } from '@fortawesome/free-solid-svg-icons' import Tooltip from 'common/sveltestrap-s5-ports/Tooltip.svelte' - import { PROTOCOL_PROPERTIES } from 'common/protocols'; + import { PROTOCOL_PROPERTIES } from 'common/protocols' interface Props { params: { id: string }