From c84bcf30f6f7605aee916624bf9730275c3878e4 Mon Sep 17 00:00:00 2001 From: Corentin ARNOULD Date: Fri, 3 Apr 2026 17:40:53 +0200 Subject: [PATCH 1/5] Add target SSH certificate authentication --- warpgate-common/src/config/target.rs | 7 +- warpgate-db-entities/src/Target.rs | 2 + warpgate-protocol-ssh/src/client/mod.rs | 157 ++++++++++++------ warpgate-protocol-ssh/src/keys.rs | 41 ++++- warpgate-web/src/admin/config/SSHKeys.svelte | 12 ++ .../admin/config/targets/ssh/Options.svelte | 3 +- .../src/admin/lib/openapi-schema.json | 34 +++- .../src/gateway/lib/openapi-schema.json | 2 +- 8 files changed, 200 insertions(+), 58 deletions(-) diff --git a/warpgate-common/src/config/target.rs b/warpgate-common/src/config/target.rs index 299547866..7ce6c7245 100644 --- a/warpgate-common/src/config/target.rs +++ b/warpgate-common/src/config/target.rs @@ -40,13 +40,15 @@ pub struct TargetSSHOptions { } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Union)] -#[serde(untagged)] +#[serde(tag = "kind")] #[oai(discriminator_name = "kind", one_of)] pub enum SSHTargetAuth { #[serde(rename = "password")] Password(SshTargetPasswordAuth), #[serde(rename = "publickey")] PublicKey(SshTargetPublicKeyAuth), + #[serde(rename = "certificate")] + Certificate(SshTargetCertificateAuth), } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)] @@ -57,6 +59,9 @@ pub struct SshTargetPasswordAuth { #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object, Default)] pub struct SshTargetPublicKeyAuth {} +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object, Default)] +pub struct SshTargetCertificateAuth {} + impl Default for SSHTargetAuth { fn default() -> Self { Self::PublicKey(SshTargetPublicKeyAuth::default()) diff --git a/warpgate-db-entities/src/Target.rs b/warpgate-db-entities/src/Target.rs index 1fcea5c11..f5488d5c6 100644 --- a/warpgate-db-entities/src/Target.rs +++ b/warpgate-db-entities/src/Target.rs @@ -38,6 +38,8 @@ pub enum SshAuthKind { Password, #[sea_orm(string_value = "publickey")] PublicKey, + #[sea_orm(string_value = "certificate")] + Certificate, } #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)] diff --git a/warpgate-protocol-ssh/src/client/mod.rs b/warpgate-protocol-ssh/src/client/mod.rs index eb41db7ee..847aea669 100644 --- a/warpgate-protocol-ssh/src/client/mod.rs +++ b/warpgate-protocol-ssh/src/client/mod.rs @@ -29,7 +29,10 @@ use warpgate_core::Services; use self::handler::ClientHandlerEvent; use super::{ChannelOperation, DirectTCPIPParams}; use crate::client::handler::ClientHandlerError; -use crate::{load_keys, ForwardedStreamlocalParams, ForwardedTcpIpParams}; +use crate::{ + gen_target_user_cert, generate_private, load_keys, ForwardedStreamlocalParams, + ForwardedTcpIpParams, +}; #[derive(Debug, thiserror::Error)] pub enum ConnectionError { @@ -418,6 +421,102 @@ impl RemoteClient { Ok(false) } + fn get_insecure_preferred() -> Preferred { + Preferred { + kex: Cow::Borrowed(&[ + kex::MLKEM768X25519_SHA256, + kex::CURVE25519, + kex::CURVE25519_PRE_RFC_8731, + kex::ECDH_SHA2_NISTP256, + kex::ECDH_SHA2_NISTP384, + kex::ECDH_SHA2_NISTP521, + kex::DH_G16_SHA512, + kex::DH_G14_SHA256, // non-default + kex::DH_GEX_SHA256, + kex::DH_G1_SHA1, // non-default + kex::EXTENSION_SUPPORT_AS_CLIENT, + kex::EXTENSION_SUPPORT_AS_SERVER, + kex::EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT, + kex::EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER, + ]), + key: Cow::Borrowed(&[ + russh::keys::Algorithm::Ed25519, + russh::keys::Algorithm::Ecdsa { + curve: russh::keys::EcdsaCurve::NistP256, + }, + russh::keys::Algorithm::Ecdsa { + curve: russh::keys::EcdsaCurve::NistP384, + }, + russh::keys::Algorithm::Ecdsa { + curve: russh::keys::EcdsaCurve::NistP521, + }, + russh::keys::Algorithm::Rsa { + hash: Some(russh::keys::HashAlg::Sha256), + }, + russh::keys::Algorithm::Rsa { + hash: Some(russh::keys::HashAlg::Sha512), + }, + russh::keys::Algorithm::Rsa { hash: None }, + ]), + cipher: Cow::Borrowed(&[ + russh::cipher::CHACHA20_POLY1305, + russh::cipher::AES_256_GCM, + russh::cipher::AES_256_CTR, + russh::cipher::AES_256_CBC, + russh::cipher::AES_192_CTR, + russh::cipher::AES_192_CBC, + russh::cipher::AES_128_CTR, + russh::cipher::AES_128_CBC, + russh::cipher::TRIPLE_DES_CBC, + ]), + ..<_>::default() + } + } + + async fn connect_certificate( + &self, + ssh_options: &TargetSSHOptions, + session: &mut Handle, + ) -> Result { + // #[allow(clippy::explicit_auto_deref)] + let keys = load_keys( + &*self.services.config.lock().await, + &self.services.global_params, + "client", + )?; + for key in keys.into_iter() { + // Generate a certificate signed by private key + let client_key = generate_private(key.algorithm()).map_err(russh::Error::from)?; + let key_str = client_key + .public_key() + .to_openssh() + .map_err(russh::Error::from)?; + let certificate = + gen_target_user_cert(&ssh_options.username, &PublicKey::from(&client_key), &key)?; + + let response = session + .authenticate_openssh_cert( + ssh_options.username.clone(), + Arc::new(client_key), + certificate, + ) + .await?; + + if self + ._handle_auth_result(session, ssh_options.username.clone(), response) + .await + .unwrap_or(false) + { + debug!( + username=&ssh_options.username[..], + key=%key_str, + "Authenticated with certificate"); + return Ok(true); + } + } + Ok(false) + } + async fn connect(&mut self, ssh_options: TargetSSHOptions) -> Result<(), ConnectionError> { let address_str = format!("{}:{}", ssh_options.host, ssh_options.port); let address = match address_str @@ -435,55 +534,7 @@ impl RemoteClient { info!(?address, username = &ssh_options.username[..], "Connecting"); let algos = if ssh_options.allow_insecure_algos.unwrap_or(false) { - Preferred { - kex: Cow::Borrowed(&[ - kex::MLKEM768X25519_SHA256, - kex::CURVE25519, - kex::CURVE25519_PRE_RFC_8731, - kex::ECDH_SHA2_NISTP256, - kex::ECDH_SHA2_NISTP384, - kex::ECDH_SHA2_NISTP521, - kex::DH_G16_SHA512, - kex::DH_G14_SHA256, // non-default - kex::DH_GEX_SHA256, - kex::DH_G1_SHA1, // non-default - kex::EXTENSION_SUPPORT_AS_CLIENT, - kex::EXTENSION_SUPPORT_AS_SERVER, - kex::EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT, - kex::EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER, - ]), - key: Cow::Borrowed(&[ - russh::keys::Algorithm::Ed25519, - russh::keys::Algorithm::Ecdsa { - curve: russh::keys::EcdsaCurve::NistP256, - }, - russh::keys::Algorithm::Ecdsa { - curve: russh::keys::EcdsaCurve::NistP384, - }, - russh::keys::Algorithm::Ecdsa { - curve: russh::keys::EcdsaCurve::NistP521, - }, - russh::keys::Algorithm::Rsa { - hash: Some(russh::keys::HashAlg::Sha256), - }, - russh::keys::Algorithm::Rsa { - hash: Some(russh::keys::HashAlg::Sha512), - }, - russh::keys::Algorithm::Rsa { hash: None }, - ]), - cipher: Cow::Borrowed(&[ - russh::cipher::CHACHA20_POLY1305, - russh::cipher::AES_256_GCM, - russh::cipher::AES_256_CTR, - russh::cipher::AES_256_CBC, - russh::cipher::AES_192_CTR, - russh::cipher::AES_192_CBC, - russh::cipher::AES_128_CTR, - russh::cipher::AES_128_CBC, - russh::cipher::TRIPLE_DES_CBC, - ]), - ..<_>::default() - } + Self::get_insecure_preferred() } else { Preferred::default() }; @@ -621,6 +672,12 @@ impl RemoteClient { auth_error_msg = Some("Public key authentication was rejected by the SSH target".into()); } } + SSHTargetAuth::Certificate(_) => { + auth_result = self.connect_certificate(&ssh_options, &mut session).await?; + if !auth_result { + auth_error_msg = Some("Certificate authentication was rejected by the SSH target".into()); + } + } } if !auth_result { diff --git a/warpgate-protocol-ssh/src/keys.rs b/warpgate-protocol-ssh/src/keys.rs index 99c045b3c..324a9f5b6 100644 --- a/warpgate-protocol-ssh/src/keys.rs +++ b/warpgate-protocol-ssh/src/keys.rs @@ -1,8 +1,10 @@ use std::fs::{create_dir_all, File}; use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; -use russh::keys::{encode_pkcs8_pem, load_secret_key, HashAlg, PrivateKey}; +use russh::keys::ssh_key::certificate; +use russh::keys::{encode_pkcs8_pem, load_secret_key, Certificate, HashAlg, PrivateKey, PublicKey}; use tracing::*; use warpgate_common::helpers::fs::{secure_directory, secure_file}; use warpgate_common::helpers::rng::get_crypto_rng; @@ -14,6 +16,10 @@ fn get_keys_path(config: &WarpgateConfig, params: &GlobalParams) -> PathBuf { path } +pub fn generate_private(algo: russh::keys::Algorithm) -> Result { + Ok(PrivateKey::random(&mut get_crypto_rng(), algo)?) +} + pub fn generate_keys(config: &WarpgateConfig, params: &GlobalParams, prefix: &str) -> Result<()> { let path = get_keys_path(config, params); create_dir_all(&path)?; @@ -33,8 +39,7 @@ pub fn generate_keys(config: &WarpgateConfig, params: &GlobalParams, prefix: &st let key_path = path.join(name); if !key_path.exists() { info!("Generating {prefix} key ({algo:?})"); - let key = PrivateKey::random(&mut get_crypto_rng(), algo) - .context("Failed to generate key")?; + let key = generate_private(algo).context("Failed to generate key")?; let f = File::create(&key_path)?; encode_pkcs8_pem(&key, f)?; } @@ -57,3 +62,33 @@ pub fn load_keys( load_secret_key(path.join(format!("{prefix}-rsa")), None)?, ]) } + +pub fn gen_target_user_cert( + user: &str, + public_key: &PublicKey, + signing_key: &PrivateKey, +) -> Result { + // Certificate are used only for authentication + // Thus don't need a long validity period + let valid_after = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| russh::keys::Error::SshKey(e.into()))? + .as_secs(); + let valid_before = valid_after + 60; + + let mut cert_builder = certificate::Builder::new_with_random_nonce( + &mut get_crypto_rng(), + public_key, + valid_after, + valid_before, + )?; + + cert_builder.cert_type(certificate::CertType::User)?; + cert_builder.valid_principal(user)?; + cert_builder.extension("permit-agent-forwarding", "")?; + cert_builder.extension("permit-port-forwarding", "")?; + cert_builder.extension("permit-pty", "")?; + cert_builder.extension("permit-X11-forwarding", "")?; + + Ok(cert_builder.sign(signing_key)?) +} diff --git a/warpgate-web/src/admin/config/SSHKeys.svelte b/warpgate-web/src/admin/config/SSHKeys.svelte index b4f55108b..eb5a0a11d 100644 --- a/warpgate-web/src/admin/config/SSHKeys.svelte +++ b/warpgate-web/src/admin/config/SSHKeys.svelte @@ -49,6 +49,18 @@ {/each} +

Warpgate's own Certificate Authority

+ Add theses keys to the file referrenced by your targets' TrustedUserCAKeys +
+ {#each ownKeys as key (key)} +
+
{key.kind} {key.publicKeyBase64}
+
+ +
+
+ {/each} +
{/if}
diff --git a/warpgate-web/src/admin/config/targets/ssh/Options.svelte b/warpgate-web/src/admin/config/targets/ssh/Options.svelte index 1ab476c28..4ecca4d54 100644 --- a/warpgate-web/src/admin/config/targets/ssh/Options.svelte +++ b/warpgate-web/src/admin/config/targets/ssh/Options.svelte @@ -62,9 +62,10 @@ - {#if options.auth.kind === 'PublicKey'} + {#if ['PublicKey', 'Certificate'].some(e => e === options.auth.kind)} Date: Wed, 8 Apr 2026 14:43:28 +0200 Subject: [PATCH 2/5] Target Cert Auth - Test & Migration --- tests/conftest.py | 13 +- tests/images/ssh-server/Dockerfile | 1 + tests/pyproject.toml | 1 + tests/test_ssh_target_auth.py | 188 ++++++++++++++++++ warpgate-db-migrations/src/lib.rs | 2 + .../src/m00037_target_ssh_cert.rs | 115 +++++++++++ .../src/admin/lib/openapi-schema.json | 2 +- .../src/gateway/lib/openapi-schema.json | 2 +- 8 files changed, 321 insertions(+), 3 deletions(-) create mode 100644 tests/test_ssh_target_auth.py create mode 100644 warpgate-db-migrations/src/m00037_target_ssh_cert.rs diff --git a/tests/conftest.py b/tests/conftest.py index b5a207e7d..7fb0626d4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -120,18 +120,21 @@ def stop(self): pass p.kill() - def start_ssh_server(self, trusted_keys=[], extra_config=""): + def start_ssh_server(self, trusted_keys=[], extra_config="", trusted_ca=[]): port = alloc_port() data_dir = self.ctx.tmpdir / f"sshd-{uuid.uuid4()}" data_dir.mkdir(parents=True) authorized_keys_path = data_dir / "authorized_keys" authorized_keys_path.write_text("\n".join(trusted_keys)) config_path = data_dir / "sshd_config" + ssh_ca = data_dir / "trusted_ca" + ssh_ca.write_text("\n".join(trusted_ca)) config_path.write_text( dedent( f"""\ Port 22 AuthorizedKeysFile {authorized_keys_path} + TrustedUserCAKeys {ssh_ca} AllowAgentForwarding yes AllowTcpForwarding yes GatewayPorts yes @@ -147,9 +150,11 @@ def start_ssh_server(self, trusted_keys=[], extra_config=""): """ ) ) + data_dir.chmod(0o700) authorized_keys_path.chmod(0o600) config_path.chmod(0o600) + ssh_ca.chmod(0o600) self.start( [ @@ -162,6 +167,8 @@ def start_ssh_server(self, trusted_keys=[], extra_config=""): f"{data_dir}:{data_dir}", "-v", f"{os.getcwd()}/ssh-keys:/ssh-keys", + "--security-opt", + "label=disable", "warpgate-e2e-ssh-server", "-f", str(config_path), @@ -234,6 +241,8 @@ def start_k3s(self) -> K3sInstance: "--name", container_name, "--privileged", + "--security-opt", + "label=disable", "-p", f"{port}:6443", image, @@ -563,6 +572,8 @@ def start_oidc_server( f"IDENTITY_RESOURCES_INLINE={identity_resources}", "-e", "CLIENTS_CONFIGURATION_PATH=/tmp/config/clients-config.json", + "--security-opt", + "label=disable", "-v", f"{oidc_data_dir}:/tmp/config:ro", "ghcr.io/soluto/oidc-server-mock:0.10.1", diff --git a/tests/images/ssh-server/Dockerfile b/tests/images/ssh-server/Dockerfile index 22db23c4f..acc741fe9 100644 --- a/tests/images/ssh-server/Dockerfile +++ b/tests/images/ssh-server/Dockerfile @@ -1,4 +1,5 @@ FROM alpine:3.14@sha256:0f2d5c38dd7a4f4f733e688e3a6733cb5ab1ac6e3cb4603a5dd564e5bfb80eed RUN apk add openssh curl +RUN adduser -D foo && echo "foo:bar" | chpasswd RUN passwd -u root ENTRYPOINT ["/usr/sbin/sshd", "-De"] diff --git a/tests/pyproject.toml b/tests/pyproject.toml index 182f1e924..c341b90f1 100644 --- a/tests/pyproject.toml +++ b/tests/pyproject.toml @@ -3,6 +3,7 @@ name = "tests" version = "0.1.0" description = "" authors = ["Your Name "] +package-mode = false [tool.poetry.dependencies] python = "^3.10" diff --git a/tests/test_ssh_target_auth.py b/tests/test_ssh_target_auth.py new file mode 100644 index 000000000..085f76de5 --- /dev/null +++ b/tests/test_ssh_target_auth.py @@ -0,0 +1,188 @@ +from pathlib import Path +from uuid import uuid4 + +from .api_client import admin_client, sdk +from .conftest import ProcessManager, WarpgateProcess +from .util import wait_port + + +class Test: + def test_password( + self, + processes: ProcessManager, + wg_c_ed25519_pubkey: Path, + timeout, + shared_wg: WarpgateProcess, + ): + ssh_port = processes.start_ssh_server() + + wait_port(ssh_port) + + url = f"https://localhost:{shared_wg.http_port}" + with admin_client(url) as api: + role = api.create_role( + sdk.RoleDataRequest(name=f"role-{uuid4()}"), + ) + user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) + api.create_public_key_credential( + user.id, + sdk.NewPublicKeyCredential( + label="Public Key", + openssh_public_key=open("ssh-keys/id_ed25519.pub").read().strip() + ), + ) + api.add_user_role(user.id, role.id) + ssh_target = api.create_target( + sdk.TargetDataRequest( + name=f"ssh-{uuid4()}", + options=sdk.TargetOptions( + sdk.TargetOptionsTargetSSHOptions( + kind="Ssh", + host="localhost", + port=ssh_port, + username="foo", + auth=sdk.SSHTargetAuth( + sdk.SSHTargetAuthSshTargetPasswordAuth( + kind="Password", + password="bar" + ) + ), + ) + ), + ) + ) + api.add_target_role(ssh_target.id, role.id) + + ssh_client = processes.start_ssh_client( + f"{user.username}:{ssh_target.name}@localhost", + "-p", + str(shared_wg.ssh_port), + "-o", + "IdentityFile=ssh-keys/id_ed25519", + "-o", + "PreferredAuthentications=publickey", + "ls", + "/bin/sh", + ) + assert ssh_client.communicate(timeout=timeout)[0] == b"/bin/sh\n" + assert ssh_client.returncode == 0 + + def test_certificate( + self, + processes: ProcessManager, + wg_c_ed25519_pubkey: Path, + timeout, + shared_wg: WarpgateProcess, + ): + ssh_port = processes.start_ssh_server( + trusted_ca=[wg_c_ed25519_pubkey.read_text()] + ) + + wait_port(ssh_port) + + url = f"https://localhost:{shared_wg.http_port}" + with admin_client(url) as api: + role = api.create_role( + sdk.RoleDataRequest(name=f"role-{uuid4()}"), + ) + user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) + api.create_public_key_credential( + user.id, + sdk.NewPublicKeyCredential( + label="Public Key", + openssh_public_key=open("ssh-keys/id_ed25519.pub").read().strip() + ), + ) + api.add_user_role(user.id, role.id) + ssh_target = api.create_target( + sdk.TargetDataRequest( + name=f"ssh-{uuid4()}", + options=sdk.TargetOptions( + sdk.TargetOptionsTargetSSHOptions( + kind="Ssh", + host="localhost", + port=ssh_port, + username="root", + auth=sdk.SSHTargetAuth( + sdk.SSHTargetAuthSshTargetCertificateAuth( + kind="Certificate" + ) + ), + ) + ), + ) + ) + api.add_target_role(ssh_target.id, role.id) + + ssh_client = processes.start_ssh_client( + f"{user.username}:{ssh_target.name}@localhost", + "-p", + str(shared_wg.ssh_port), + "-o", + "IdentityFile=ssh-keys/id_ed25519", + "-o", + "PreferredAuthentications=publickey", + "ls", + "/bin/sh", + ) + assert ssh_client.communicate(timeout=timeout)[0] == b"/bin/sh\n" + assert ssh_client.returncode == 0 + + def test_none( + self, + processes: ProcessManager, + wg_c_ed25519_pubkey: Path, + timeout, + shared_wg: WarpgateProcess, + ): + ssh_port = processes.start_ssh_server() + + wait_port(ssh_port) + + url = f"https://localhost:{shared_wg.http_port}" + with admin_client(url) as api: + role = api.create_role( + sdk.RoleDataRequest(name=f"role-{uuid4()}"), + ) + user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) + api.create_public_key_credential( + user.id, + sdk.NewPublicKeyCredential( + label="Public Key", + openssh_public_key=open("ssh-keys/id_ed25519.pub").read().strip() + ), + ) + api.add_user_role(user.id, role.id) + ssh_target = api.create_target( + sdk.TargetDataRequest( + name=f"ssh-{uuid4()}", + options=sdk.TargetOptions( + sdk.TargetOptionsTargetSSHOptions( + kind="Ssh", + host="localhost", + port=ssh_port, + username="root", + auth=sdk.SSHTargetAuth( + sdk.SSHTargetAuthSshTargetPublicKeyAuth( + kind="PublicKey" + ) + ), + ) + ), + ) + ) + api.add_target_role(ssh_target.id, role.id) + + ssh_client = processes.start_ssh_client( + f"{user.username}:{ssh_target.name}@localhost", + "-p", + str(shared_wg.ssh_port), + "-o", + "IdentityFile=ssh-keys/id_ed25519", + "-o", + "PreferredAuthentications=publickey", + "ls", + "/bin/sh", + ) + assert ssh_client.communicate(timeout=timeout)[0] == b"" + assert ssh_client.returncode != 0 diff --git a/warpgate-db-migrations/src/lib.rs b/warpgate-db-migrations/src/lib.rs index 07cf924df..900ec5a79 100644 --- a/warpgate-db-migrations/src/lib.rs +++ b/warpgate-db-migrations/src/lib.rs @@ -38,6 +38,7 @@ mod m00033_add_log_target; mod m00034_add_log_related_fields; mod m00035_ticket_user_target_id; mod m00036_user_role_expiry_history; +mod m00037_target_ssh_cert; pub struct Migrator; @@ -81,6 +82,7 @@ impl MigratorTrait for Migrator { Box::new(m00034_add_log_related_fields::Migration), Box::new(m00035_ticket_user_target_id::Migration), Box::new(m00036_user_role_expiry_history::Migration), + Box::new(m00037_target_ssh_cert::Migration), ] } } diff --git a/warpgate-db-migrations/src/m00037_target_ssh_cert.rs b/warpgate-db-migrations/src/m00037_target_ssh_cert.rs new file mode 100644 index 000000000..60aeba2c2 --- /dev/null +++ b/warpgate-db-migrations/src/m00037_target_ssh_cert.rs @@ -0,0 +1,115 @@ +use sea_orm::prelude::*; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, JsonValue, QueryFilter, Set}; +use sea_orm_migration::prelude::*; + +use crate::m00007_targets_and_roles::target; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m00033_target_ssh_cert" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let conn = manager.get_connection(); + + // Find SSH targets + let ssh_targets = target::Entity::find() + .filter(target::Column::Kind.eq(target::TargetKind::Ssh)) + .all(conn) + .await?; + + println!("--- migration"); + println!("{:?}", ssh_targets); + + for target in ssh_targets { + let mut options = target + .options + .get("ssh") + .unwrap_or(Default::default()) + .clone(); + + if let Some(auth) = options.get_mut("auth") { + println!("--- auth found"); + if auth.get("kind").is_some() { + // Already migrated + println!("--- already migrated"); + continue; + } else if let Some(password) = auth.get("password").cloned() { + println!("--- found password"); + *auth = serde_json::json!({ + "kind": "password", + "password": password, + }); + } else { + println!("--- found pubkey"); + *auth = serde_json::json!({ + "kind": "publickey", + }); + } + + let mut target: target::ActiveModel = target.into(); + let options = serde_json::json!({"ssh": options}); + target.options = Set(options); + println!("--- updated "); + target.update(conn).await?; + } + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let conn = manager.get_connection(); + + // Find SSH targets + let ssh_targets = target::Entity::find() + .filter(target::Column::Kind.eq(target::TargetKind::Ssh)) + .all(conn) + .await?; + + // Check if there is a target authentified by a certificate + let has_certificates = ssh_targets + .clone() + .into_iter() + .filter_map(|t| t.options.get("ssh").cloned()) + .filter_map(|t| t.get("auth").cloned()) + .any(|t| { + let value = JsonValue::String("certificate".to_string()); + t.get("kind") == Some(&value) + }); + + if has_certificates { + // At least one target is using certificate auth + // reversing would fallback to pubkey, which would not work + panic!("This migration cannot be reversed"); + } + + for target in ssh_targets { + let mut options = target + .options + .get("ssh") + .unwrap_or(Default::default()) + .clone(); + + if let Some(auth) = options.get_mut("auth") { + if let Some(password) = auth.get("password").cloned() { + *auth = serde_json::json!({ + "password": password, + }); + } else { + *auth = serde_json::json!({}); + } + + let mut target: target::ActiveModel = target.into(); + let options = serde_json::json!({"ssh": options}); + target.options = Set(options); + target.update(conn).await?; + } + } + Ok(()) + } +} diff --git a/warpgate-web/src/admin/lib/openapi-schema.json b/warpgate-web/src/admin/lib/openapi-schema.json index 8f8197b9c..94e948f23 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.22.0-beta.4-18-g75b5fc29-modified" + "version": "v0.22.0-beta.5-4-g080601c-modified" }, "servers": [ { diff --git a/warpgate-web/src/gateway/lib/openapi-schema.json b/warpgate-web/src/gateway/lib/openapi-schema.json index 0e2f82796..cd45b80b9 100644 --- a/warpgate-web/src/gateway/lib/openapi-schema.json +++ b/warpgate-web/src/gateway/lib/openapi-schema.json @@ -2,7 +2,7 @@ "openapi": "3.0.0", "info": { "title": "Warpgate HTTP proxy", - "version": "v0.22.0-beta.4-18-g75b5fc29-modified" + "version": "v0.22.0-beta.5-4-g080601c-modified" }, "servers": [ { From 67ca00efc9a2d6f4ad1745b737d00f74be505a2e Mon Sep 17 00:00:00 2001 From: Eugene Date: Tue, 21 Apr 2026 16:58:56 -0700 Subject: [PATCH 3/5] cleanup --- config-schema.json | 7 +- tests/conftest.py | 6 - tests/pyproject.toml | 1 - tests/test_ssh_target_auth.py | 208 ++++++++---------- warpgate-common/src/config/defaults.rs | 4 + warpgate-common/src/config/mod.rs | 10 +- .../src/m00037_target_ssh_cert.rs | 20 +- warpgate-protocol-ssh/src/client/mod.rs | 21 +- warpgate-protocol-ssh/src/keys.rs | 13 +- warpgate-web/src/admin/config/SSHKeys.svelte | 4 +- .../admin/config/targets/ssh/Options.svelte | 4 +- 11 files changed, 132 insertions(+), 166 deletions(-) diff --git a/config-schema.json b/config-schema.json index 1f1d0f2f1..9a3da85ff 100644 --- a/config-schema.json +++ b/config-schema.json @@ -88,7 +88,8 @@ "inactivity_timeout": "5m", "keepalive_interval": null, "keys": "./data/keys", - "listen": "[::]:2222" + "listen": "[::]:2222", + "temporary_client_certificate_validity": "5m" } }, "sso_providers": { @@ -416,6 +417,10 @@ "listen": { "$ref": "#/$defs/ListenEndpoint", "default": "[::]:2222" + }, + "temporary_client_certificate_validity": { + "type": "string", + "default": "5m" } } }, diff --git a/tests/conftest.py b/tests/conftest.py index 7fb0626d4..acda3fc93 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -167,8 +167,6 @@ def start_ssh_server(self, trusted_keys=[], extra_config="", trusted_ca=[]): f"{data_dir}:{data_dir}", "-v", f"{os.getcwd()}/ssh-keys:/ssh-keys", - "--security-opt", - "label=disable", "warpgate-e2e-ssh-server", "-f", str(config_path), @@ -241,8 +239,6 @@ def start_k3s(self) -> K3sInstance: "--name", container_name, "--privileged", - "--security-opt", - "label=disable", "-p", f"{port}:6443", image, @@ -572,8 +568,6 @@ def start_oidc_server( f"IDENTITY_RESOURCES_INLINE={identity_resources}", "-e", "CLIENTS_CONFIGURATION_PATH=/tmp/config/clients-config.json", - "--security-opt", - "label=disable", "-v", f"{oidc_data_dir}:/tmp/config:ro", "ghcr.io/soluto/oidc-server-mock:0.10.1", diff --git a/tests/pyproject.toml b/tests/pyproject.toml index c341b90f1..182f1e924 100644 --- a/tests/pyproject.toml +++ b/tests/pyproject.toml @@ -3,7 +3,6 @@ name = "tests" version = "0.1.0" description = "" authors = ["Your Name "] -package-mode = false [tool.poetry.dependencies] python = "^3.10" diff --git a/tests/test_ssh_target_auth.py b/tests/test_ssh_target_auth.py index 085f76de5..f79f49c80 100644 --- a/tests/test_ssh_target_auth.py +++ b/tests/test_ssh_target_auth.py @@ -5,12 +5,65 @@ from .conftest import ProcessManager, WarpgateProcess from .util import wait_port +USER_PUBLIC_KEY_PATH = Path("ssh-keys/id_ed25519.pub") +USER_PRIVATE_KEY_PATH = "ssh-keys/id_ed25519" + class Test: + @staticmethod + def _create_user_role_and_target(api, ssh_port, username: str, auth): + role = api.create_role( + sdk.RoleDataRequest(name=f"role-{uuid4()}"), + ) + user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) + api.create_public_key_credential( + user.id, + sdk.NewPublicKeyCredential( + label="Public Key", + openssh_public_key=USER_PUBLIC_KEY_PATH.read_text().strip(), + ), + ) + api.add_user_role(user.id, role.id) + ssh_target = api.create_target( + sdk.TargetDataRequest( + name=f"ssh-{uuid4()}", + options=sdk.TargetOptions( + sdk.TargetOptionsTargetSSHOptions( + kind="Ssh", + host="localhost", + port=ssh_port, + username=username, + auth=sdk.SSHTargetAuth(auth), + ) + ), + ) + ) + api.add_target_role(ssh_target.id, role.id) + return user, ssh_target + + @staticmethod + def _run_ssh_ls( + processes: ProcessManager, + shared_wg: WarpgateProcess, + user, + ssh_target, + ): + ssh_client = processes.start_ssh_client( + f"{user.username}:{ssh_target.name}@localhost", + "-p", + str(shared_wg.ssh_port), + "-o", + f"IdentityFile={USER_PRIVATE_KEY_PATH}", + "-o", + "PreferredAuthentications=publickey", + "ls", + "/bin/sh", + ) + return ssh_client + def test_password( self, processes: ProcessManager, - wg_c_ed25519_pubkey: Path, timeout, shared_wg: WarpgateProcess, ): @@ -20,49 +73,21 @@ def test_password( url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: - role = api.create_role( - sdk.RoleDataRequest(name=f"role-{uuid4()}"), - ) - user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) - api.create_public_key_credential( - user.id, - sdk.NewPublicKeyCredential( - label="Public Key", - openssh_public_key=open("ssh-keys/id_ed25519.pub").read().strip() + user, ssh_target = self._create_user_role_and_target( + api, + ssh_port, + username="foo", + auth=sdk.SSHTargetAuthSshTargetPasswordAuth( + kind="Password", + password="bar", ), ) - api.add_user_role(user.id, role.id) - ssh_target = api.create_target( - sdk.TargetDataRequest( - name=f"ssh-{uuid4()}", - options=sdk.TargetOptions( - sdk.TargetOptionsTargetSSHOptions( - kind="Ssh", - host="localhost", - port=ssh_port, - username="foo", - auth=sdk.SSHTargetAuth( - sdk.SSHTargetAuthSshTargetPasswordAuth( - kind="Password", - password="bar" - ) - ), - ) - ), - ) - ) - api.add_target_role(ssh_target.id, role.id) - ssh_client = processes.start_ssh_client( - f"{user.username}:{ssh_target.name}@localhost", - "-p", - str(shared_wg.ssh_port), - "-o", - "IdentityFile=ssh-keys/id_ed25519", - "-o", - "PreferredAuthentications=publickey", - "ls", - "/bin/sh", + ssh_client = self._run_ssh_ls( + processes, + shared_wg, + user, + ssh_target, ) assert ssh_client.communicate(timeout=timeout)[0] == b"/bin/sh\n" assert ssh_client.returncode == 0 @@ -82,48 +107,18 @@ def test_certificate( url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: - role = api.create_role( - sdk.RoleDataRequest(name=f"role-{uuid4()}"), + user, ssh_target = self._create_user_role_and_target( + api, + ssh_port, + username="root", + auth=sdk.SSHTargetAuthSshTargetCertificateAuth(kind="Certificate"), ) - user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) - api.create_public_key_credential( - user.id, - sdk.NewPublicKeyCredential( - label="Public Key", - openssh_public_key=open("ssh-keys/id_ed25519.pub").read().strip() - ), - ) - api.add_user_role(user.id, role.id) - ssh_target = api.create_target( - sdk.TargetDataRequest( - name=f"ssh-{uuid4()}", - options=sdk.TargetOptions( - sdk.TargetOptionsTargetSSHOptions( - kind="Ssh", - host="localhost", - port=ssh_port, - username="root", - auth=sdk.SSHTargetAuth( - sdk.SSHTargetAuthSshTargetCertificateAuth( - kind="Certificate" - ) - ), - ) - ), - ) - ) - api.add_target_role(ssh_target.id, role.id) - ssh_client = processes.start_ssh_client( - f"{user.username}:{ssh_target.name}@localhost", - "-p", - str(shared_wg.ssh_port), - "-o", - "IdentityFile=ssh-keys/id_ed25519", - "-o", - "PreferredAuthentications=publickey", - "ls", - "/bin/sh", + ssh_client = self._run_ssh_ls( + processes, + shared_wg, + user, + ssh_target, ) assert ssh_client.communicate(timeout=timeout)[0] == b"/bin/sh\n" assert ssh_client.returncode == 0 @@ -131,7 +126,6 @@ def test_certificate( def test_none( self, processes: ProcessManager, - wg_c_ed25519_pubkey: Path, timeout, shared_wg: WarpgateProcess, ): @@ -141,48 +135,18 @@ def test_none( url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: - role = api.create_role( - sdk.RoleDataRequest(name=f"role-{uuid4()}"), - ) - user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) - api.create_public_key_credential( - user.id, - sdk.NewPublicKeyCredential( - label="Public Key", - openssh_public_key=open("ssh-keys/id_ed25519.pub").read().strip() - ), + user, ssh_target = self._create_user_role_and_target( + api, + ssh_port, + username="root", + auth=sdk.SSHTargetAuthSshTargetPublicKeyAuth(kind="PublicKey"), ) - api.add_user_role(user.id, role.id) - ssh_target = api.create_target( - sdk.TargetDataRequest( - name=f"ssh-{uuid4()}", - options=sdk.TargetOptions( - sdk.TargetOptionsTargetSSHOptions( - kind="Ssh", - host="localhost", - port=ssh_port, - username="root", - auth=sdk.SSHTargetAuth( - sdk.SSHTargetAuthSshTargetPublicKeyAuth( - kind="PublicKey" - ) - ), - ) - ), - ) - ) - api.add_target_role(ssh_target.id, role.id) - ssh_client = processes.start_ssh_client( - f"{user.username}:{ssh_target.name}@localhost", - "-p", - str(shared_wg.ssh_port), - "-o", - "IdentityFile=ssh-keys/id_ed25519", - "-o", - "PreferredAuthentications=publickey", - "ls", - "/bin/sh", + ssh_client = self._run_ssh_ls( + processes, + shared_wg, + user, + ssh_target, ) assert ssh_client.communicate(timeout=timeout)[0] == b"" assert ssh_client.returncode != 0 diff --git a/warpgate-common/src/config/defaults.rs b/warpgate-common/src/config/defaults.rs index f8c1cbd27..908a02ce5 100644 --- a/warpgate-common/src/config/defaults.rs +++ b/warpgate-common/src/config/defaults.rs @@ -96,6 +96,10 @@ pub const fn _default_ssh_inactivity_timeout() -> Duration { Duration::from_secs(60 * 5) } +pub const fn _default_temporary_client_certificate_validity() -> Duration { + Duration::from_secs(60) +} + #[allow(clippy::unnecessary_wraps)] pub fn _default_postgres_idle_timeout_str() -> Option { Some("10m".to_string()) diff --git a/warpgate-common/src/config/mod.rs b/warpgate-common/src/config/mod.rs index 4e793e394..48eac1e33 100644 --- a/warpgate-common/src/config/mod.rs +++ b/warpgate-common/src/config/mod.rs @@ -10,7 +10,7 @@ use defaults::{ _default_http_listen, _default_kubernetes_listen, _default_mysql_listen, _default_postgres_listen, _default_recordings_path, _default_retention, _default_session_max_age, _default_ssh_inactivity_timeout, _default_ssh_keys_path, - _default_ssh_listen, + _default_ssh_listen, _default_temporary_client_certificate_validity, }; use poem::http::uri; use poem_openapi::{Object, Union}; @@ -308,6 +308,13 @@ pub struct SshConfig { #[serde(default)] pub keepalive_interval: Option, + + #[serde( + default = "_default_temporary_client_certificate_validity", + with = "humantime_serde" + )] + #[schemars(with = "String")] + pub temporary_client_certificate_validity: Duration, } impl Default for SshConfig { @@ -321,6 +328,7 @@ impl Default for SshConfig { external_host: None, inactivity_timeout: _default_ssh_inactivity_timeout(), keepalive_interval: None, + temporary_client_certificate_validity: _default_temporary_client_certificate_validity(), } } } diff --git a/warpgate-db-migrations/src/m00037_target_ssh_cert.rs b/warpgate-db-migrations/src/m00037_target_ssh_cert.rs index 60aeba2c2..14cc8c8a4 100644 --- a/warpgate-db-migrations/src/m00037_target_ssh_cert.rs +++ b/warpgate-db-migrations/src/m00037_target_ssh_cert.rs @@ -8,7 +8,7 @@ pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { - "m00033_target_ssh_cert" + "m00037_target_ssh_cert" } } @@ -23,9 +23,6 @@ impl MigrationTrait for Migration { .all(conn) .await?; - println!("--- migration"); - println!("{:?}", ssh_targets); - for target in ssh_targets { let mut options = target .options @@ -34,19 +31,14 @@ impl MigrationTrait for Migration { .clone(); if let Some(auth) = options.get_mut("auth") { - println!("--- auth found"); if auth.get("kind").is_some() { - // Already migrated - println!("--- already migrated"); continue; } else if let Some(password) = auth.get("password").cloned() { - println!("--- found password"); *auth = serde_json::json!({ "kind": "password", "password": password, }); } else { - println!("--- found pubkey"); *auth = serde_json::json!({ "kind": "publickey", }); @@ -55,7 +47,6 @@ impl MigrationTrait for Migration { let mut target: target::ActiveModel = target.into(); let options = serde_json::json!({"ssh": options}); target.options = Set(options); - println!("--- updated "); target.update(conn).await?; } } @@ -71,12 +62,11 @@ impl MigrationTrait for Migration { .all(conn) .await?; - // Check if there is a target authentified by a certificate + // Check if there is a target authenticated by a certificate let has_certificates = ssh_targets - .clone() - .into_iter() - .filter_map(|t| t.options.get("ssh").cloned()) - .filter_map(|t| t.get("auth").cloned()) + .iter() + .filter_map(|t| t.options.get("ssh")) + .filter_map(|t| t.get("auth")) .any(|t| { let value = JsonValue::String("certificate".to_string()); t.get("kind") == Some(&value) diff --git a/warpgate-protocol-ssh/src/client/mod.rs b/warpgate-protocol-ssh/src/client/mod.rs index 847aea669..a290a6464 100644 --- a/warpgate-protocol-ssh/src/client/mod.rs +++ b/warpgate-protocol-ssh/src/client/mod.rs @@ -30,7 +30,7 @@ use self::handler::ClientHandlerEvent; use super::{ChannelOperation, DirectTCPIPParams}; use crate::client::handler::ClientHandlerError; use crate::{ - gen_target_user_cert, generate_private, load_keys, ForwardedStreamlocalParams, + issue_temporary_client_certificate, generate_private, load_keys, ForwardedStreamlocalParams, ForwardedTcpIpParams, }; @@ -478,21 +478,22 @@ impl RemoteClient { ssh_options: &TargetSSHOptions, session: &mut Handle, ) -> Result { - // #[allow(clippy::explicit_auto_deref)] - let keys = load_keys( - &*self.services.config.lock().await, - &self.services.global_params, - "client", - )?; - for key in keys.into_iter() { + let config = self.services.config.lock().await.clone(); + let cert_validity = config.store.ssh.temporary_client_certificate_validity; + let keys = load_keys(&config, &self.services.global_params, "client")?; + for key in keys { // Generate a certificate signed by private key let client_key = generate_private(key.algorithm()).map_err(russh::Error::from)?; let key_str = client_key .public_key() .to_openssh() .map_err(russh::Error::from)?; - let certificate = - gen_target_user_cert(&ssh_options.username, &PublicKey::from(&client_key), &key)?; + let certificate = issue_temporary_client_certificate( + &ssh_options.username, + &PublicKey::from(&client_key), + &key, + cert_validity, + )?; let response = session .authenticate_openssh_cert( diff --git a/warpgate-protocol-ssh/src/keys.rs b/warpgate-protocol-ssh/src/keys.rs index 324a9f5b6..c981775e0 100644 --- a/warpgate-protocol-ssh/src/keys.rs +++ b/warpgate-protocol-ssh/src/keys.rs @@ -1,6 +1,6 @@ use std::fs::{create_dir_all, File}; use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; use russh::keys::ssh_key::certificate; @@ -63,18 +63,19 @@ pub fn load_keys( ]) } -pub fn gen_target_user_cert( +pub fn issue_temporary_client_certificate( user: &str, public_key: &PublicKey, signing_key: &PrivateKey, + validity: Duration, ) -> Result { - // Certificate are used only for authentication - // Thus don't need a long validity period - let valid_after = SystemTime::now() + // Backdate slightly to tolerate modest clock skew between Warpgate and targets. + let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|e| russh::keys::Error::SshKey(e.into()))? .as_secs(); - let valid_before = valid_after + 60; + let valid_after = now.saturating_sub(30); + let valid_before = now.saturating_add(validity.as_secs().max(1)); let mut cert_builder = certificate::Builder::new_with_random_nonce( &mut get_crypto_rng(), diff --git a/warpgate-web/src/admin/config/SSHKeys.svelte b/warpgate-web/src/admin/config/SSHKeys.svelte index eb5a0a11d..857ce0aea 100644 --- a/warpgate-web/src/admin/config/SSHKeys.svelte +++ b/warpgate-web/src/admin/config/SSHKeys.svelte @@ -49,8 +49,8 @@ {/each} -

Warpgate's own Certificate Authority

- Add theses keys to the file referrenced by your targets' TrustedUserCAKeys +

Warpgate's SSH CA keys

+ Add these keys to the file referenced by your targets' TrustedUserCAKeys
{#each ownKeys as key (key)}
diff --git a/warpgate-web/src/admin/config/targets/ssh/Options.svelte b/warpgate-web/src/admin/config/targets/ssh/Options.svelte index 4ecca4d54..0c7c629f6 100644 --- a/warpgate-web/src/admin/config/targets/ssh/Options.svelte +++ b/warpgate-web/src/admin/config/targets/ssh/Options.svelte @@ -62,10 +62,10 @@ - {#if ['PublicKey', 'Certificate'].some(e => e === options.auth.kind)} + {#if ['PublicKey', 'Certificate'].includes(options.auth.kind)} Date: Tue, 21 Apr 2026 17:14:19 -0700 Subject: [PATCH 4/5] rename migration --- ...{m00037_target_ssh_cert.rs => m00042_target_ssh_cert.rs} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename warpgate-db-migrations/src/{m00037_target_ssh_cert.rs => m00042_target_ssh_cert.rs} (95%) diff --git a/warpgate-db-migrations/src/m00037_target_ssh_cert.rs b/warpgate-db-migrations/src/m00042_target_ssh_cert.rs similarity index 95% rename from warpgate-db-migrations/src/m00037_target_ssh_cert.rs rename to warpgate-db-migrations/src/m00042_target_ssh_cert.rs index 14cc8c8a4..580440a6a 100644 --- a/warpgate-db-migrations/src/m00037_target_ssh_cert.rs +++ b/warpgate-db-migrations/src/m00042_target_ssh_cert.rs @@ -8,7 +8,7 @@ pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { - "m00037_target_ssh_cert" + "m00042_target_ssh_cert" } } @@ -27,7 +27,7 @@ impl MigrationTrait for Migration { let mut options = target .options .get("ssh") - .unwrap_or(Default::default()) + .unwrap_or_default() .clone(); if let Some(auth) = options.get_mut("auth") { @@ -75,7 +75,7 @@ impl MigrationTrait for Migration { if has_certificates { // At least one target is using certificate auth // reversing would fallback to pubkey, which would not work - panic!("This migration cannot be reversed"); + assert!(!has_certificates, "This migration cannot be reversed"); } for target in ssh_targets { From 6c69648980b194a23d238dd3302149583b073398 Mon Sep 17 00:00:00 2001 From: Eugene Date: Wed, 22 Apr 2026 11:50:24 -0700 Subject: [PATCH 5/5] Update config-schema.json --- config-schema.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config-schema.json b/config-schema.json index 9a3da85ff..72e1bedfe 100644 --- a/config-schema.json +++ b/config-schema.json @@ -89,7 +89,7 @@ "keepalive_interval": null, "keys": "./data/keys", "listen": "[::]:2222", - "temporary_client_certificate_validity": "5m" + "temporary_client_certificate_validity": "1m" } }, "sso_providers": { @@ -420,7 +420,7 @@ }, "temporary_client_certificate_validity": { "type": "string", - "default": "5m" + "default": "1m" } } },