From 331af972bc1d1e09e1ff3c0a029d276d113819f1 Mon Sep 17 00:00:00 2001 From: Eugene Date: Tue, 3 Jun 2025 00:37:25 +0200 Subject: [PATCH] fixed #1356 - generate config schema (#1357) --- .github/workflows/build.yml | 26 +- Cargo.lock | 59 +++ Cargo.toml | 1 + config-schema.json | 459 +++++++++++++++++++ justfile | 3 + warpgate-common/Cargo.toml | 5 + warpgate-common/src/config/mod.rs | 35 +- warpgate-common/src/config_schema.rs | 7 + warpgate-common/src/types/listen_endpoint.rs | 3 +- warpgate-core/src/services.rs | 17 +- warpgate-sso/Cargo.toml | 1 + warpgate-sso/src/config.rs | 14 +- warpgate/Cargo.toml | 1 + warpgate/src/commands/setup.rs | 5 + 14 files changed, 597 insertions(+), 39 deletions(-) create mode 100644 config-schema.json create mode 100644 warpgate-common/src/config_schema.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 37410721..12a17a0f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,7 +5,7 @@ permissions: on: [push, pull_request] jobs: - Build: + build: strategy: matrix: include: @@ -128,3 +128,27 @@ jobs: generate_release_notes: true files: dist/* token: ${{ secrets.GITHUB_TOKEN }} + + config-schema: + name: Config schema check + runs-on: ubuntu-24.04 + + steps: + - name: Setup + run: | + sudo apt update + sudo apt install --no-install-recommends -y libssl-dev pkg-config + + - uses: actions/checkout@v2 + with: + submodules: recursive + + - name: Install tools + run: | + cargo install just + + - name: Ensure there are no changes in config schema + run: | + mkdir warpgate-web/dist + just config-schema + git diff --exit-code config-schema.json diff --git a/Cargo.lock b/Cargo.lock index f1532fb2..75fd8d6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3470,6 +3470,26 @@ dependencies = [ "bitflags 2.8.0", ] +[[package]] +name = "ref-cast" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "regex" version = "1.11.1" @@ -3972,6 +3992,31 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5016d94c77c6d32f0b8e08b781f7dc8a90c2007d4e77472cc2807bc10a8438fe" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.98", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -4230,6 +4275,17 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "serde_json" version = "1.0.138" @@ -5396,6 +5452,7 @@ dependencies = [ "notify", "rcgen", "rustls 0.23.22", + "schemars", "sd-notify", "sea-orm", "serde_json", @@ -5469,6 +5526,7 @@ dependencies = [ "rustls 0.23.22", "rustls-native-certs", "rustls-pemfile 1.0.4", + "schemars", "sea-orm", "serde", "serde_json", @@ -5690,6 +5748,7 @@ dependencies = [ "jsonwebtoken", "once_cell", "openidconnect", + "schemars", "serde", "serde_json", "thiserror 1.0.69", diff --git a/Cargo.toml b/Cargo.toml index 1fb94b8a..d78a4462 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ poem = { version = "3.1", features = [ password-hash = { version = "0.4", features = ["std"] } delegate = "0.13" tracing = "0.1" +schemars = "0.9.0" [profile.release] lto = true diff --git a/config-schema.json b/config-schema.json new file mode 100644 index 00000000..218edad2 --- /dev/null +++ b/config-schema.json @@ -0,0 +1,459 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "WarpgateConfigStore", + "type": "object", + "properties": { + "database_url": { + "type": "string", + "default": "sqlite:data/db" + }, + "external_host": { + "type": [ + "string", + "null" + ], + "default": null + }, + "http": { + "$ref": "#/$defs/HttpConfig", + "default": { + "certificate": "", + "cookie_max_age": "1day", + "enable": false, + "external_port": null, + "key": "", + "listen": "[::]:8888", + "session_max_age": "30m", + "trust_x_forwarded_headers": false + } + }, + "log": { + "$ref": "#/$defs/LogConfig", + "default": { + "retention": "7days", + "send_to": null + } + }, + "mysql": { + "$ref": "#/$defs/MySqlConfig", + "default": { + "certificate": "", + "enable": false, + "external_port": null, + "key": "", + "listen": "[::]:33306" + } + }, + "postgres": { + "$ref": "#/$defs/PostgresConfig", + "default": { + "certificate": "", + "enable": false, + "external_port": null, + "key": "", + "listen": "[::]:55432" + } + }, + "recordings": { + "$ref": "#/$defs/RecordingsConfig", + "default": { + "enable": false, + "path": "./data/recordings" + } + }, + "ssh": { + "$ref": "#/$defs/SshConfig", + "default": { + "enable": false, + "external_port": null, + "host_key_verification": "prompt", + "inactivity_timeout": "5m", + "keepalive_interval": null, + "keys": "./data/keys", + "listen": "[::]:2222" + } + }, + "sso_providers": { + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/SsoProviderConfig" + } + } + }, + "$defs": { + "Duration": { + "type": "object", + "properties": { + "nanos": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "secs": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "secs", + "nanos" + ] + }, + "HttpConfig": { + "type": "object", + "properties": { + "certificate": { + "type": "string", + "default": "" + }, + "cookie_max_age": { + "type": "string", + "default": "1day" + }, + "enable": { + "type": "boolean", + "default": false + }, + "external_port": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "default": null, + "maximum": 65535, + "minimum": 0 + }, + "key": { + "type": "string", + "default": "" + }, + "listen": { + "$ref": "#/$defs/ListenEndpoint", + "default": "[::]:8888" + }, + "session_max_age": { + "type": "string", + "default": "30m" + }, + "trust_x_forwarded_headers": { + "type": "boolean", + "default": false + } + } + }, + "ListenEndpoint": { + "type": "string" + }, + "LogConfig": { + "type": "object", + "properties": { + "retention": { + "type": "string", + "default": "7days" + }, + "send_to": { + "type": [ + "string", + "null" + ], + "default": null + } + } + }, + "MySqlConfig": { + "type": "object", + "properties": { + "certificate": { + "type": "string", + "default": "" + }, + "enable": { + "type": "boolean", + "default": false + }, + "external_port": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "default": null, + "maximum": 65535, + "minimum": 0 + }, + "key": { + "type": "string", + "default": "" + }, + "listen": { + "$ref": "#/$defs/ListenEndpoint", + "default": "[::]:33306" + } + } + }, + "PostgresConfig": { + "type": "object", + "properties": { + "certificate": { + "type": "string", + "default": "" + }, + "enable": { + "type": "boolean", + "default": false + }, + "external_port": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "default": null, + "maximum": 65535, + "minimum": 0 + }, + "key": { + "type": "string", + "default": "" + }, + "listen": { + "$ref": "#/$defs/ListenEndpoint", + "default": "[::]:55432" + } + } + }, + "RecordingsConfig": { + "type": "object", + "properties": { + "enable": { + "type": "boolean", + "default": false + }, + "path": { + "type": "string", + "default": "./data/recordings" + } + } + }, + "SshConfig": { + "type": "object", + "properties": { + "enable": { + "type": "boolean", + "default": false + }, + "external_port": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "default": null, + "maximum": 65535, + "minimum": 0 + }, + "host_key_verification": { + "$ref": "#/$defs/SshHostKeyVerificationMode", + "default": "prompt" + }, + "inactivity_timeout": { + "type": "string", + "default": "5m" + }, + "keepalive_interval": { + "anyOf": [ + { + "$ref": "#/$defs/Duration" + }, + { + "type": "null" + } + ], + "default": null + }, + "keys": { + "type": "string", + "default": "./data/keys" + }, + "listen": { + "$ref": "#/$defs/ListenEndpoint", + "default": "[::]:2222" + } + } + }, + "SshHostKeyVerificationMode": { + "type": "string", + "enum": [ + "prompt", + "auto_accept", + "auto_reject" + ] + }, + "SsoInternalProviderConfig": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "google" + }, + "client_id": { + "type": "string" + }, + "client_secret": { + "type": "string" + } + }, + "required": [ + "type", + "client_id", + "client_secret" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "apple" + }, + "client_id": { + "type": "string" + }, + "client_secret": { + "type": "string" + }, + "key_id": { + "type": "string" + }, + "team_id": { + "type": "string" + } + }, + "required": [ + "type", + "client_id", + "client_secret", + "key_id", + "team_id" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "azure" + }, + "client_id": { + "type": "string" + }, + "client_secret": { + "type": "string" + }, + "tenant": { + "type": "string" + } + }, + "required": [ + "type", + "client_id", + "client_secret", + "tenant" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "custom" + }, + "additional_trusted_audiences": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "client_id": { + "type": "string" + }, + "client_secret": { + "type": "string" + }, + "issuer_url": { + "type": "string" + }, + "role_mappings": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "trust_unknown_audiences": { + "type": "boolean", + "default": false + } + }, + "required": [ + "type", + "client_id", + "client_secret", + "issuer_url", + "scopes" + ] + } + ] + }, + "SsoProviderConfig": { + "type": "object", + "properties": { + "auto_create_users": { + "type": "boolean", + "default": false + }, + "label": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "provider": { + "$ref": "#/$defs/SsoInternalProviderConfig" + }, + "return_domain_whitelist": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "provider" + ] + } + } +} diff --git a/justfile b/justfile index e3334d25..196f8d25 100644 --- a/justfile +++ b/justfile @@ -36,6 +36,9 @@ openapi-all: openapi: cd warpgate-web && npm run openapi:client:admin && npm run openapi:client:gateway +config-schema: + cargo run -p warpgate-common --bin config-schema > config-schema.json + cleanup: (fix "--allow-dirty") (clippy "--fix" "--allow-dirty") fmt svelte-check lint udeps: diff --git a/warpgate-common/Cargo.toml b/warpgate-common/Cargo.toml index 9defd348..a81e86b6 100644 --- a/warpgate-common/Cargo.toml +++ b/warpgate-common/Cargo.toml @@ -4,6 +4,10 @@ license = "Apache-2.0" name = "warpgate-common" version = "0.14.0" +[[bin]] +name = "config-schema" +path = "src/config_schema.rs" + [dependencies] anyhow = "1.0" argon2 = "0.5" @@ -45,3 +49,4 @@ rustls-pemfile = "1.0" webpki = "0.22" tokio-stream.workspace = true git-version = "0.3.9" +schemars.workspace = true diff --git a/warpgate-common/src/config/mod.rs b/warpgate-common/src/config/mod.rs index 18c0dc46..933fbb49 100644 --- a/warpgate-common/src/config/mod.rs +++ b/warpgate-common/src/config/mod.rs @@ -8,6 +8,7 @@ use std::time::Duration; use defaults::*; use poem::http::uri; use poem_openapi::{Object, Union}; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; pub use target::*; use tracing::warn; @@ -160,7 +161,7 @@ pub struct Role { pub description: String, } -#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq, Copy)] +#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq, Copy, JsonSchema)] pub enum SshHostKeyVerificationMode { #[serde(rename = "prompt")] #[default] @@ -171,7 +172,7 @@ pub enum SshHostKeyVerificationMode { AutoReject, } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)] pub struct SshConfig { #[serde(default = "_default_false")] pub enable: bool, @@ -189,6 +190,7 @@ pub struct SshConfig { pub host_key_verification: SshHostKeyVerificationMode, #[serde(default = "_default_ssh_inactivity_timeout", with = "humantime_serde")] + #[schemars(with = "String")] pub inactivity_timeout: Duration, #[serde(default)] @@ -215,7 +217,7 @@ impl SshConfig { } } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)] pub struct HttpConfig { #[serde(default = "_default_false")] pub enable: bool, @@ -236,9 +238,11 @@ pub struct HttpConfig { pub trust_x_forwarded_headers: bool, #[serde(default = "_default_session_max_age", with = "humantime_serde")] + #[schemars(with = "String")] pub session_max_age: Duration, #[serde(default = "_default_cookie_max_age", with = "humantime_serde")] + #[schemars(with = "String")] pub cookie_max_age: Duration, } @@ -263,7 +267,7 @@ impl HttpConfig { } } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)] pub struct MySqlConfig { #[serde(default = "_default_false")] pub enable: bool, @@ -299,7 +303,7 @@ impl MySqlConfig { } } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)] pub struct PostgresConfig { #[serde(default = "_default_false")] pub enable: bool, @@ -335,7 +339,7 @@ impl PostgresConfig { } } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)] pub struct RecordingsConfig { #[serde(default = "_default_false")] pub enable: bool, @@ -353,9 +357,10 @@ impl Default for RecordingsConfig { } } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)] pub struct LogConfig { #[serde(default = "_default_retention", with = "humantime_serde")] + #[schemars(with = "String")] pub retention: Duration, #[serde(default)] @@ -371,16 +376,7 @@ impl Default for LogConfig { } } -#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Default)] -pub enum ConfigProviderKind { - #[serde(rename = "file")] - File, - #[serde(rename = "database")] - #[default] - Database, -} - -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)] pub struct WarpgateConfigStore { #[serde(default)] pub sso_providers: Vec, @@ -392,6 +388,7 @@ pub struct WarpgateConfigStore { pub external_host: Option, #[serde(default = "_default_database_url")] + #[schemars(with = "String")] pub database_url: Secret, #[serde(default)] @@ -408,9 +405,6 @@ pub struct WarpgateConfigStore { #[serde(default)] pub log: LogConfig, - - #[serde(default)] - pub config_provider: ConfigProviderKind, } impl Default for WarpgateConfigStore { @@ -425,7 +419,6 @@ impl Default for WarpgateConfigStore { mysql: <_>::default(), postgres: <_>::default(), log: <_>::default(), - config_provider: <_>::default(), } } } diff --git a/warpgate-common/src/config_schema.rs b/warpgate-common/src/config_schema.rs new file mode 100644 index 00000000..b5b10a31 --- /dev/null +++ b/warpgate-common/src/config_schema.rs @@ -0,0 +1,7 @@ +use schemars::schema_for; + +#[allow(clippy::unwrap_used)] +fn main() { + let schema = schema_for!(warpgate_common::WarpgateConfigStore); + println!("{}", serde_json::to_string_pretty(&schema).unwrap()); +} diff --git a/warpgate-common/src/types/listen_endpoint.rs b/warpgate-common/src/types/listen_endpoint.rs index 45662eb1..9e8b2de1 100644 --- a/warpgate-common/src/types/listen_endpoint.rs +++ b/warpgate-common/src/types/listen_endpoint.rs @@ -5,13 +5,14 @@ use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs}; use futures::stream::{iter, FuturesUnordered}; use futures::{Stream, StreamExt, TryStreamExt}; use poem::listener::Listener; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tokio::net::{TcpListener, TcpStream}; use tokio_stream::wrappers::TcpListenerStream; use crate::WarpgateError; -#[derive(Clone)] +#[derive(Clone, JsonSchema)] pub struct ListenEndpoint(SocketAddr); impl ListenEndpoint { diff --git a/warpgate-core/src/services.rs b/warpgate-core/src/services.rs index 9d30f0d0..a1d3974d 100644 --- a/warpgate-core/src/services.rs +++ b/warpgate-core/src/services.rs @@ -4,21 +4,19 @@ use std::time::Duration; use anyhow::Result; use sea_orm::DatabaseConnection; use tokio::sync::Mutex; -use warpgate_common::{ConfigProviderKind, WarpgateConfig}; +use warpgate_common::WarpgateConfig; use crate::db::{connect_to_db, populate_db}; use crate::recordings::SessionRecordings; use crate::{AuthStateStore, ConfigProviderEnum, DatabaseConfigProvider, State}; -type ConfigProviderArc = Arc>; - #[derive(Clone)] pub struct Services { pub db: Arc>, pub recordings: Arc>, pub config: Arc>, pub state: Arc>, - pub config_provider: ConfigProviderArc, + pub config_provider: Arc>, pub auth_state_store: Arc>, pub admin_token: Arc>>, } @@ -32,18 +30,9 @@ impl Services { let recordings = SessionRecordings::new(db.clone(), &config)?; let recordings = Arc::new(Mutex::new(recordings)); - let provider = config.store.config_provider.clone(); let config = Arc::new(Mutex::new(config)); - let config_provider = match provider { - ConfigProviderKind::File => { - anyhow::bail!("File based config provider in no longer supported"); - } - ConfigProviderKind::Database => { - Arc::new(Mutex::new(DatabaseConfigProvider::new(&db).await.into())) - as ConfigProviderArc - } - }; + let config_provider = Arc::new(Mutex::new(DatabaseConfigProvider::new(&db).await.into())); let auth_state_store = Arc::new(Mutex::new(AuthStateStore::new(config_provider.clone()))); diff --git a/warpgate-sso/Cargo.toml b/warpgate-sso/Cargo.toml index 73ef0999..0ed003b7 100644 --- a/warpgate-sso/Cargo.toml +++ b/warpgate-sso/Cargo.toml @@ -19,3 +19,4 @@ once_cell = "1.17" jsonwebtoken = "9" data-encoding.workspace = true futures.workspace = true +schemars.workspace = true diff --git a/warpgate-sso/src/config.rs b/warpgate-sso/src/config.rs index fec8243b..f49d8ab1 100644 --- a/warpgate-sso/src/config.rs +++ b/warpgate-sso/src/config.rs @@ -4,6 +4,7 @@ use std::time::SystemTime; use data_encoding::BASE64; use once_cell::sync::Lazy; use openidconnect::{AuthType, ClientId, ClientSecret, IssuerUrl}; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::SsoError; @@ -16,7 +17,7 @@ pub static GOOGLE_ISSUER_URL: Lazy = pub static APPLE_ISSUER_URL: Lazy = Lazy::new(|| IssuerUrl::new("https://appleid.apple.com".to_string()).unwrap()); -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct SsoProviderConfig { pub name: String, pub label: Option, @@ -34,31 +35,40 @@ impl SsoProviderConfig { } } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(tag = "type")] pub enum SsoInternalProviderConfig { #[serde(rename = "google")] Google { + #[schemars(with = "String")] client_id: ClientId, + #[schemars(with = "String")] client_secret: ClientSecret, }, #[serde(rename = "apple")] Apple { + #[schemars(with = "String")] client_id: ClientId, + #[schemars(with = "String")] client_secret: ClientSecret, key_id: String, team_id: String, }, #[serde(rename = "azure")] Azure { + #[schemars(with = "String")] client_id: ClientId, + #[schemars(with = "String")] client_secret: ClientSecret, tenant: String, }, #[serde(rename = "custom")] Custom { + #[schemars(with = "String")] client_id: ClientId, + #[schemars(with = "String")] client_secret: ClientSecret, + #[schemars(with = "String")] issuer_url: IssuerUrl, scopes: Vec, role_mappings: Option>, diff --git a/warpgate/Cargo.toml b/warpgate/Cargo.toml index 54537501..31eab6a6 100644 --- a/warpgate/Cargo.toml +++ b/warpgate/Cargo.toml @@ -39,6 +39,7 @@ warpgate-protocol-http = { version = "*", path = "../warpgate-protocol-http" } warpgate-protocol-mysql = { version = "*", path = "../warpgate-protocol-mysql" } warpgate-protocol-postgres = { version = "*", path = "../warpgate-protocol-postgres" } warpgate-protocol-ssh = { version = "*", path = "../warpgate-protocol-ssh" } +schemars.workspace = true [target.'cfg(target_os = "linux")'.dependencies] sd-notify = "0.4" diff --git a/warpgate/src/commands/setup.rs b/warpgate/src/commands/setup.rs index b05bdeb4..241b690d 100644 --- a/warpgate/src/commands/setup.rs +++ b/warpgate/src/commands/setup.rs @@ -293,6 +293,11 @@ pub(crate) async fn command(cli: &crate::Cli) -> Result<()> { let yaml = serde_yaml::to_string(&store)?; println!("{yaml}"); + let yaml = format!( + "# Config generated in version {version}\n# yaml-language-server: $schema=https://raw.githubusercontent.com/warp-tech/warpgate/refs/heads/main/config-schema.json\n\n{yaml}", + version = warpgate_version() + ); + File::create(&cli.config)?.write_all(yaml.as_bytes())?; info!("Saved into {}", cli.config.display());