From f9a342bba86ceaeeff1772a28c1b7c42206ec4a2 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Fri, 13 Nov 2026 02:27:58 +0100 Subject: [PATCH] (GH-1495) Experimental SSH subsystem for DSC --- Cargo.lock | 3 + configurations/ssh/.project.data.json | 9 + configurations/ssh/ssh_subsystem.dsc.yaml | 32 + data.build.json | 13 + dsc/Cargo.toml | 3 + dsc/locales/en-us.toml | 31 + dsc/src/args.rs | 2 + dsc/src/main.rs | 14 +- dsc/src/remote.rs | 908 ++++++++++++++++++++++ dsc/src/server/invoke_dsc_config.rs | 72 +- dsc/src/server/mcp_server.rs | 8 +- dsc/src/util.rs | 1 + dsc/tests/other/dsc_remote.tests.ps1 | 265 +++++++ dsc/tests/other/dsc_server.tests.ps1 | 6 + examples/ssh-subsystem-azure/.gitignore | 2 + 15 files changed, 1352 insertions(+), 17 deletions(-) create mode 100644 configurations/ssh/.project.data.json create mode 100644 configurations/ssh/ssh_subsystem.dsc.yaml create mode 100644 dsc/src/remote.rs create mode 100644 dsc/tests/other/dsc_remote.tests.ps1 create mode 100644 examples/ssh-subsystem-azure/.gitignore diff --git a/Cargo.lock b/Cargo.lock index 5d5522668..3e25ea67e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -839,6 +839,8 @@ dependencies = [ "tracing", "tracing-indicatif", "tracing-subscriber", + "url", + "urlencoding", ] [[package]] @@ -2923,6 +2925,7 @@ dependencies = [ "serde_json", "thiserror 2.0.21", "tokio", + "tokio-stream", "tokio-util", "tracing", "url", diff --git a/configurations/ssh/.project.data.json b/configurations/ssh/.project.data.json new file mode 100644 index 000000000..0cfb7d5eb --- /dev/null +++ b/configurations/ssh/.project.data.json @@ -0,0 +1,9 @@ +{ + "Name": "configurations/ssh", + "Kind": "Configuration", + "CopyFiles": { + "All": [ + "ssh_subsystem.dsc.yaml" + ] + } +} diff --git a/configurations/ssh/ssh_subsystem.dsc.yaml b/configurations/ssh/ssh_subsystem.dsc.yaml new file mode 100644 index 000000000..23e3b97a5 --- /dev/null +++ b/configurations/ssh/ssh_subsystem.dsc.yaml @@ -0,0 +1,32 @@ +# Registers DSC as an SSH subsystem so that `dsc --target ssh:// config ...` can reach +# this machine. Apply it from an elevated session, then restart the SSH service: +# +# dsc config set --file ssh_subsystem.dsc.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +metadata: + Microsoft.DSC: + securityContext: elevated +parameters: + dscPath: + type: string + defaultValue: "[tryWhich('dsc')]" + metadata: + description: Full path of the dsc executable that sshd starts. + traceLevel: + type: string + defaultValue: warn + allowedValues: + - error + - warn + - info + metadata: + description: Highest trace level the target sends to remote clients. +resources: +- name: DSC subsystem + type: Microsoft.OpenSSH.SSHD/Subsystem + properties: + subsystem: + name: dsc + # The path is quoted because sshd fails to start a subsystem whose unquoted path has a space. + value: >- + [concat('"', parameters('dscPath'), '" --trace-format json --trace-level ', parameters('traceLevel'), ' server')] diff --git a/data.build.json b/data.build.json index 470b076a3..32bb721e9 100644 --- a/data.build.json +++ b/data.build.json @@ -36,6 +36,7 @@ "sshd_config.dsc.resource.json", "sshd-subsystem.dsc.resource.json", "sshd-subsystemList.dsc.resource.json", + "ssh_subsystem.dsc.yaml", "y2j" ], "macOS": [ @@ -70,6 +71,7 @@ "sshd_config.dsc.resource.json", "sshd-subsystem.dsc.resource.json", "sshd-subsystemList.dsc.resource.json", + "ssh_subsystem.dsc.yaml", "y2j" ], "Windows": [ @@ -117,6 +119,7 @@ "sshd_config.dsc.resource.json", "sshd-subsystem.dsc.resource.json", "sshd-subsystemList.dsc.resource.json", + "ssh_subsystem.dsc.yaml", "windowspowershell.dsc.resource.json", "WindowsPowerShell_adapter.dsc.resource.json", "windowsupdate.dsc.resource.json", @@ -194,6 +197,16 @@ ] } }, + { + "Name": "configurations/ssh", + "Kind": "Configuration", + "RelativePath": "configurations/ssh", + "CopyFiles": { + "All": [ + "ssh_subsystem.dsc.yaml" + ] + } + }, { "Name": "dism_dsc", "Kind": "Resource", diff --git a/dsc/Cargo.toml b/dsc/Cargo.toml index f65519c84..b2196deb2 100644 --- a/dsc/Cargo.toml +++ b/dsc/Cargo.toml @@ -14,6 +14,7 @@ jsonschema = { workspace = true } path-absolutize = { workspace = true } regex = { workspace = true } rmcp = { workspace = true, features = [ + "client", "server", "macros", "transport-io", @@ -35,6 +36,8 @@ tokio-util = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } tracing-indicatif = { workspace = true } +url = { workspace = true } +urlencoding = { workspace = true } # workspace crate dependencies dsc-lib = { workspace = true } dsc-lib-telemetry = { workspace = true } diff --git a/dsc/locales/en-us.toml b/dsc/locales/en-us.toml index 94c4eba8f..7dd738fd2 100644 --- a/dsc/locales/en-us.toml +++ b/dsc/locales/en-us.toml @@ -41,6 +41,7 @@ version = "The version requirement for the resource, like '^1.2' or '>1.2.3, <1. serverAbout = "Use DSC as a server over JSON-RPC (useful as MCP server)" bicepAbout = "Use DSC as a Bicep server over gRPC" ignoreSettingsFile = "Ignore the settings file when running the command" +target = "Run the configuration command on another machine, given as 'ssh://[user@]host[:port]'" [main] failedToSpawnMain = "Failed to spawn dsc main thread: %{error}" @@ -65,6 +66,36 @@ Press any key to close this window""" failedToStartServer = "Failed to start server: %{error}" failedToGetSystem = "Failed to get system information" +[remote] +targetNotUri = "The value is not a URI" +targetUnsupportedScheme = "The scheme '%{scheme}' is not supported, the supported scheme is '%{supported}'" +targetMissingHost = "The URI does not name a host" +targetInvalidHost = "The host '%{host}' is not a valid host name, address or SSH host alias" +targetInvalidUser = "The user '%{user}' is not a valid user name" +targetPasswordNotAllowed = "The URI must not contain a password" +targetUnexpectedComponent = "The URI must not contain a path, query or fragment" +unsupportedCommand = "The '--target' option is only supported with 'config get', 'config set', 'config test' and 'config export', and not together with '--system-root'" +invalidTarget = "Invalid target '%{target}': %{error}" +noDocument = "A configuration document is required, use '--input' or '--file'" +includeResolvesOnTarget = "The configuration uses '%{resource}'; file paths in it are resolved on '%{target}', not on this machine" +requestTooLarge = "The configuration and parameters are %{size} bytes, which is more than the %{limit} bytes a DSC server accepts" +failedToCreateRuntime = "Failed to create async runtime: %{error}" +connecting = "Connecting to '%{target}'" +startingSsh = "Starting '%{executable}' with arguments: %{args}" +failedToStartSsh = "Failed to start the SSH client '%{executable}': %{error}" +missingSshPipes = "The SSH client was started without piped standard streams" +sshOutput = "ssh: %{line}" +trace = "%{host}: %{message}" +invalidMinimumVersion = "The minimum server version is not a valid semantic version: %{error}" +serverNotIdentified = "the server did not identify itself" +serverTooOld = "the server reports '%{name} %{version}', but 'dsc %{minimum}' or newer is required" +serverVersion = "Remote DSC version is %{version}" +connectionFailed = "Could not connect to '%{target}': %{detail}" +handshakeTimeout = "no response to the initialize request within %{seconds} seconds" +incompatibleServer = "Cannot use the DSC server on '%{target}': %{reason}" +toolFailed = "The remote operation failed: %{detail}" +missingResult = "The remote server returned a response without a result" + [resolve] processingInclude = "Processing Include input" invalidInclude = "Failed to deserialize Include input" diff --git a/dsc/src/args.rs b/dsc/src/args.rs index f95a8c50b..381b2b181 100644 --- a/dsc/src/args.rs +++ b/dsc/src/args.rs @@ -70,6 +70,8 @@ pub struct Args { pub progress_format: Option, #[clap(long, help = t!("args.ignoreSettingsFile").to_string(), num_args=0..=1, default_missing_value="true", require_equals = true)] pub ignore_settings_file: Option, + #[clap(long, value_name = "URI", help = t!("args.target").to_string())] + pub target: Option, } impl From<&Args> for dsc_lib::settings::sources::CommandLineData { diff --git a/dsc/src/main.rs b/dsc/src/main.rs index 7104e139c..7e8cbe946 100644 --- a/dsc/src/main.rs +++ b/dsc/src/main.rs @@ -19,6 +19,7 @@ use crossterm::event; use std::env; pub mod args; +pub mod remote; pub mod resolve; pub mod resource_command; pub mod server; @@ -112,6 +113,13 @@ fn dsc_main() -> Result<(), ExitCode> { let progress_format = args.progress_format.unwrap_or( ProgressFormat::Default ); + // Validate the remote target and the command before reading any input, so that an + // unsupported combination never opens a connection. + let remote_target = match args.target.as_deref() { + Some(target) => Some(remote::resolve_target(target, &args.subcommand)?), + None => None, + }; + match args.subcommand { SubCommand::Completer { shell } => { info!("{} {:?}", t!("main.generatingCompleter"), shell); @@ -144,7 +152,11 @@ fn dsc_main() -> Result<(), ExitCode> { (None, None) => None, }; - subcommand::config(&subcommand, &merged_parameters, system_root.as_ref(), &as_group, &as_assert, &as_include, progress_format)?; + if let Some(target) = &remote_target { + remote::config(target, &subcommand, merged_parameters.as_deref())?; + } else { + subcommand::config(&subcommand, &merged_parameters, system_root.as_ref(), &as_group, &as_assert, &as_include, progress_format)?; + } }, SubCommand::Action { subcommand } => { subcommand::action(&subcommand, progress_format)?; diff --git a/dsc/src/remote.rs b/dsc/src/remote.rs new file mode 100644 index 000000000..f21564a2d --- /dev/null +++ b/dsc/src/remote.rs @@ -0,0 +1,908 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Run configuration operations on another machine. +//! +//! The remote endpoint is a `dsc server` process that the target's SSH daemon starts as the +//! `dsc` subsystem. This module reads the input locally, starts the system `ssh` client, speaks +//! MCP to the server over the client's stdin and stdout, and prints the result the same way a +//! local run does. + +use crate::args::{ConfigSubCommand, OutputFormat, SubCommand}; +use crate::server::invoke_dsc_config::EXIT_CODE_DATA_KEY; +use crate::server::mcp_server::SERVER_NAME; +use crate::util::{ + get_input, write_object, EXIT_DSC_ERROR, EXIT_INVALID_ARGS, EXIT_INVALID_INPUT, + EXIT_JSON_ERROR, EXIT_REMOTE_FAILED, +}; +use dsc_lib::{dscresources::command_resource::TraceLevel, types::SemanticVersion}; +use rmcp::{ + model::{ + CallToolRequestParams, CallToolResult, ClientCapabilities, ClientConfig, Implementation, + ServerPeerInfo, + }, + service::RoleClient, + transport::async_rw::AsyncRwTransport, + ServiceError, ServiceExt, +}; +use rust_i18n::t; +use serde::Deserialize; +use serde_json::{Map, Value}; +use std::{ + ffi::OsString, + fmt::{self, Display, Formatter}, + process::{ExitCode, Stdio}, + str::FromStr, + sync::{Arc, Mutex, OnceLock}, + time::Duration, +}; +use thiserror::Error; +use tokio::{ + io::{AsyncBufReadExt, BufReader}, + process::{Child, ChildStderr, Command}, + task::JoinHandle, + time::timeout, +}; +use tracing::{debug, error, info, trace, warn}; +use url::{Host, Url}; + +/// The only URI scheme a target can use in this version. +const SSH_SCHEME: &str = "ssh"; +/// The SSH subsystem that starts `dsc server` on the target. +const SUBSYSTEM_NAME: &str = "dsc"; +/// The server tool that runs a configuration operation. +const CONFIG_TOOL_NAME: &str = "invoke_dsc_config"; +/// The resource type whose file paths resolve on the target instead of on the client. +const INCLUDE_RESOURCE_TYPE: &str = "Microsoft.DSC/Include"; +/// The first version of DSC whose server reports its identity in the handshake. Older servers +/// silently ignore arguments they don't know, so `--what-if` would apply changes for real. +const MINIMUM_SERVER_VERSION: &str = "3.4.0-preview.3"; +/// The environment variable that replaces the `ssh` executable found on `PATH`. +const SSH_EXECUTABLE_ENV_VAR: &str = "DSC_SSH_EXECUTABLE"; +/// How long the target has to answer the MCP `initialize` request after `ssh` is started. +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60); +/// How long `ssh` has to exit after the connection is closed before it's terminated. +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10); +/// The largest message `dsc server` reads. It drops a longer one without answering. +const MAX_REQUEST_BYTES: usize = 16 * 1024 * 1024; +/// The largest response the client accepts. +const MAX_RESPONSE_BYTES: usize = 64 * 1024 * 1024; +/// How many lines of `ssh`'s own stderr output are kept for a connection error message. +const MAX_SSH_STDERR_LINES: usize = 20; + +/// A machine that runs `dsc server` as an SSH subsystem. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteTarget { + user: Option, + host: String, + port: Option, +} + +/// The reasons a `--target` value is rejected. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum RemoteTargetError { + #[error("{t}: {0}", t = t!("remote.targetNotUri"))] + NotUri(String), + #[error("{t}", t = t!("remote.targetUnsupportedScheme", scheme = .0, supported = SSH_SCHEME))] + UnsupportedScheme(String), + #[error("{t}", t = t!("remote.targetMissingHost"))] + MissingHost, + #[error("{t}", t = t!("remote.targetInvalidHost", host = .0))] + InvalidHost(String), + #[error("{t}", t = t!("remote.targetInvalidUser", user = .0))] + InvalidUser(String), + #[error("{t}", t = t!("remote.targetPasswordNotAllowed"))] + PasswordNotAllowed, + #[error("{t}", t = t!("remote.targetUnexpectedComponent"))] + UnexpectedComponent, +} + +impl FromStr for RemoteTarget { + type Err = RemoteTargetError; + + /// Parse a target of the form `ssh://[@][:]`. + /// + /// # Errors + /// + /// Returns an error when the value isn't a URI, uses another scheme, carries a password, + /// path, query or fragment, or names a user or host that could be read as an `ssh` option. + fn from_str(value: &str) -> Result { + let url = Url::parse(value).map_err(|err| RemoteTargetError::NotUri(err.to_string()))?; + if url.scheme() != SSH_SCHEME { + return Err(RemoteTargetError::UnsupportedScheme(url.scheme().to_string())); + } + if url.password().is_some() { + return Err(RemoteTargetError::PasswordNotAllowed); + } + if !matches!(url.path(), "" | "/") || url.query().is_some() || url.fragment().is_some() { + return Err(RemoteTargetError::UnexpectedComponent); + } + + let host = match url.host() { + Some(Host::Domain(name)) => { + if !is_valid_host_name(name) { + return Err(RemoteTargetError::InvalidHost(name.to_string())); + } + name.to_string() + }, + Some(Host::Ipv4(address)) => address.to_string(), + // `ssh` takes an IPv6 address without the brackets a URI requires. + Some(Host::Ipv6(address)) => address.to_string(), + None => return Err(RemoteTargetError::MissingHost), + }; + + let user = match url.username() { + "" => None, + encoded => { + let Ok(user) = urlencoding::decode(encoded) else { + return Err(RemoteTargetError::InvalidUser(encoded.to_string())); + }; + if !is_valid_user_name(&user) { + return Err(RemoteTargetError::InvalidUser(user.into_owned())); + } + Some(user.into_owned()) + }, + }; + + Ok(Self { user, host, port: url.port() }) + } +} + +impl Display for RemoteTarget { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{SSH_SCHEME}://")?; + if let Some(user) = &self.user { + write!(f, "{user}@")?; + } + if self.host.contains(':') { + write!(f, "[{}]", self.host)?; + } else { + write!(f, "{}", self.host)?; + } + if let Some(port) = self.port { + write!(f, ":{port}")?; + } + Ok(()) + } +} + +impl RemoteTarget { + /// Build the arguments for the `ssh` client. + /// + /// The host always follows `--`, so a target can never add an option to the command line. + /// + /// # Returns + /// + /// The arguments that request the `dsc` subsystem on this target. + fn ssh_args(&self) -> Vec { + let mut args = vec!["-s".to_string()]; + if let Some(port) = self.port { + args.push("-p".to_string()); + args.push(port.to_string()); + } + if let Some(user) = &self.user { + args.push("-l".to_string()); + args.push(user.clone()); + } + args.push("--".to_string()); + args.push(self.host.clone()); + args.push(SUBSYSTEM_NAME.to_string()); + args + } +} + +/// Check that a host is a DNS name or an SSH configuration alias. +/// +/// # Arguments +/// +/// * `name` - The host part of the target URI. +/// +/// # Returns +/// +/// `true` when the name starts with a letter, digit or underscore and contains only letters, +/// digits, `.`, `-` and `_`. +fn is_valid_host_name(name: &str) -> bool { + let starts_safely = name.chars().next().is_some_and(|c| c.is_ascii_alphanumeric() || c == '_'); + starts_safely && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) +} + +/// Check that a user name can't be read as an `ssh` option. +/// +/// # Arguments +/// +/// * `name` - The decoded user part of the target URI. +/// +/// # Returns +/// +/// `true` when the name isn't empty, doesn't start with `-`, and has no whitespace or control +/// characters. +fn is_valid_user_name(name: &str) -> bool { + !name.is_empty() + && !name.starts_with('-') + && !name.chars().any(|c| c.is_whitespace() || c.is_control()) +} + +/// Validate a `--target` value together with the command it's used with. +/// +/// # Arguments +/// +/// * `target` - The value of the `--target` option. +/// * `subcommand` - The subcommand the user invoked. +/// +/// # Errors +/// +/// Returns the invalid arguments exit code when the command can't run remotely or the target +/// isn't valid. The reason is logged as an error. +pub fn resolve_target(target: &str, subcommand: &SubCommand) -> Result { + if !supports_remote(subcommand) { + error!("{}", t!("remote.unsupportedCommand")); + return Err(ExitCode::from(EXIT_INVALID_ARGS)); + } + + match target.parse::() { + Ok(target) => Ok(target), + Err(err) => { + error!("{}", t!("remote.invalidTarget", target = target, error = err)); + Err(ExitCode::from(EXIT_INVALID_ARGS)) + } + } +} + +/// Determine whether a command can run against a remote target. +/// +/// Only `config get`, `set`, `test` and `export` are supported, and only without the options +/// that depend on the local machine or that DSC uses internally. +/// +/// # Arguments +/// +/// * `subcommand` - The subcommand the user invoked. +/// +/// # Returns +/// +/// `true` when the command can be sent to a remote server. +fn supports_remote(subcommand: &SubCommand) -> bool { + let SubCommand::Config { subcommand, system_root, as_group, as_assert, as_include, .. } = subcommand else { + return false; + }; + if system_root.is_some() || *as_group || *as_assert || *as_include { + return false; + } + + match subcommand { + ConfigSubCommand::Get { .. } | ConfigSubCommand::Set { .. } | ConfigSubCommand::Export { .. } => true, + ConfigSubCommand::Test { as_get, as_config, .. } => !*as_get && !*as_config, + ConfigSubCommand::Validate { .. } | ConfigSubCommand::Resolve { .. } => false, + } +} + +/// A configuration operation the server can run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RemoteOperation { + Get, + Set, + Test, + Export, +} + +impl RemoteOperation { + /// The value of the tool's `operation` argument. + fn as_str(self) -> &'static str { + match self { + RemoteOperation::Get => "get", + RemoteOperation::Set => "set", + RemoteOperation::Test => "test", + RemoteOperation::Export => "export", + } + } +} + +/// A remote operation that didn't produce a result. +#[derive(Debug, PartialEq, Eq)] +struct RemoteFailure { + /// The exit code `dsc` returns for the failure. + exit_code: u8, + /// The message logged for the failure. + message: String, +} + +/// Run a `dsc config` operation on a remote target and print its result. +/// +/// The document and parameters are read on this machine, exactly as for a local run, and sent +/// inline. Nothing has to exist on the target except `dsc`. +/// +/// # Arguments +/// +/// * `target` - The machine to run the operation on. +/// * `subcommand` - The `config` subcommand with its input and output options. +/// * `parameters` - The merged parameters from `--parameters` and `--parameters-file`. +/// +/// # Errors +/// +/// Returns the exit code for the failure: the usual codes for input errors and for failures the +/// target reports, and the remote failure code when the target can't be reached or is too old. +pub fn config(target: &RemoteTarget, subcommand: &ConfigSubCommand, parameters: Option<&str>) -> Result<(), ExitCode> { + let (operation, input, file, output_format, what_if) = match subcommand { + ConfigSubCommand::Get { input, file, output_format } => (RemoteOperation::Get, input, file, output_format, false), + ConfigSubCommand::Set { input, file, output_format, what_if } => (RemoteOperation::Set, input, file, output_format, *what_if), + ConfigSubCommand::Test { input, file, output_format, .. } => (RemoteOperation::Test, input, file, output_format, false), + ConfigSubCommand::Export { input, file, output_format } => (RemoteOperation::Export, input, file, output_format, false), + ConfigSubCommand::Validate { .. } | ConfigSubCommand::Resolve { .. } => { + error!("{}", t!("remote.unsupportedCommand")); + return Err(ExitCode::from(EXIT_INVALID_ARGS)); + } + }; + + let document = get_input(input.as_ref(), file.as_ref())?; + if document.is_empty() { + error!("{}", t!("remote.noDocument")); + return Err(ExitCode::from(EXIT_INVALID_INPUT)); + } + let parameters = match parameters { + Some(parameters) => Some(normalize_parameters(parameters)?), + None => None, + }; + + if serde_json::from_str::(&document).is_ok_and(|value| contains_include(&value)) { + warn!("{}", t!("remote.includeResolvesOnTarget", resource = INCLUDE_RESOURCE_TYPE, target = target)); + } + + let arguments = build_arguments(operation, document, parameters, what_if); + let request_size = serde_json::to_string(&arguments).map_or(0, |json| json.len()); + if request_size >= MAX_REQUEST_BYTES { + error!("{}", t!("remote.requestTooLarge", size = request_size, limit = MAX_REQUEST_BYTES)); + return Err(ExitCode::from(EXIT_INVALID_INPUT)); + } + + let runtime = match tokio::runtime::Runtime::new() { + Ok(runtime) => runtime, + Err(err) => { + error!("{}", t!("remote.failedToCreateRuntime", error = err)); + return Err(ExitCode::from(EXIT_DSC_ERROR)); + } + }; + + info!("{}", t!("remote.connecting", target = target)); + match runtime.block_on(invoke_config_tool(target, arguments)) { + Ok(result) => write_result(operation, &result, output_format.as_ref()), + Err(failure) => { + error!("{}", failure.message); + Err(ExitCode::from(failure.exit_code)) + } + } +} + +/// Convert parameters given as JSON or YAML into JSON text. +/// +/// # Arguments +/// +/// * `parameters` - The parameters as the user provided them. +/// +/// # Errors +/// +/// Returns the same exit codes a local run uses when the parameters can't be parsed. +fn normalize_parameters(parameters: &str) -> Result { + let value: Value = match serde_json::from_str(parameters) { + Ok(json) => json, + Err(_) => match serde_yaml::from_str::(parameters) { + Ok(yaml) => match serde_json::to_value(yaml) { + Ok(json) => json, + Err(err) => { + error!("{}: {err}", t!("subcommand.failedConvertJson")); + return Err(ExitCode::from(EXIT_DSC_ERROR)); + } + }, + Err(err) => { + error!("{}: {err}", t!("subcommand.invalidParameters")); + return Err(ExitCode::from(EXIT_INVALID_INPUT)); + } + }, + }; + + Ok(value.to_string()) +} + +/// Find out whether a document uses the include resource anywhere, including in nested groups. +/// +/// # Arguments +/// +/// * `value` - The configuration document, or a part of it. +/// +/// # Returns +/// +/// `true` when any object in the document has the include resource's type. +fn contains_include(value: &Value) -> bool { + match value { + Value::Object(map) => { + map.get("type") + .and_then(Value::as_str) + .is_some_and(|resource_type| resource_type.eq_ignore_ascii_case(INCLUDE_RESOURCE_TYPE)) + || map.values().any(contains_include) + }, + Value::Array(items) => items.iter().any(contains_include), + _ => false, + } +} + +/// Build the arguments for the configuration tool. +/// +/// # Arguments +/// +/// * `operation` - The operation to run. +/// * `document` - The configuration document as JSON text. +/// * `parameters` - The parameters as JSON text, when there are any. +/// * `what_if` - Whether a set should only be simulated. +/// +/// # Returns +/// +/// The tool arguments. `parameters` and `what_if` are left out when they aren't used. +fn build_arguments(operation: RemoteOperation, document: String, parameters: Option, what_if: bool) -> Map { + let mut arguments = Map::new(); + arguments.insert("operation".to_string(), Value::String(operation.as_str().to_string())); + arguments.insert("configuration".to_string(), Value::String(document)); + if let Some(parameters) = parameters { + arguments.insert("parameters".to_string(), Value::String(parameters)); + } + if what_if { + arguments.insert("what_if".to_string(), Value::Bool(true)); + } + arguments +} + +/// The `ssh` child process and the task that reads its stderr. +struct SshProcess { + child: Child, + stderr_task: JoinHandle<()>, + /// Lines on stderr that aren't traces from the remote `dsc`, so they come from `ssh`. + ssh_lines: Arc>>, +} + +impl SshProcess { + /// Start `ssh` for a target with its standard streams connected to pipes. + /// + /// # Arguments + /// + /// * `target` - The machine to connect to. + /// + /// # Errors + /// + /// Returns a remote failure when the `ssh` executable can't be started. + fn spawn(target: &RemoteTarget) -> Result<(Self, tokio::process::ChildStdin, tokio::process::ChildStdout), RemoteFailure> { + let executable = ssh_executable(); + let args = target.ssh_args(); + debug!("{}", t!("remote.startingSsh", executable = executable.to_string_lossy(), args = args.join(" "))); + + let mut command = Command::new(&executable); + command + .args(&args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let mut child = command.spawn().map_err(|err| RemoteFailure { + exit_code: EXIT_REMOTE_FAILED, + message: t!("remote.failedToStartSsh", executable = executable.to_string_lossy(), error = err).to_string(), + })?; + + let (Some(stdin), Some(stdout), Some(stderr)) = (child.stdin.take(), child.stdout.take(), child.stderr.take()) else { + unreachable!("{}", t!("remote.missingSshPipes")); + }; + + let ssh_lines = Arc::new(Mutex::new(Vec::new())); + let stderr_task = tokio::spawn(forward_stderr(stderr, target.host.clone(), Arc::clone(&ssh_lines))); + Ok((Self { child, stderr_task, ssh_lines }, stdin, stdout)) + } + + /// Wait for `ssh` to exit and collect what it wrote on stderr itself. + /// + /// # Arguments + /// + /// * `terminate` - Whether to end the process immediately instead of waiting for it to exit + /// after its stdin was closed. + /// + /// # Returns + /// + /// The stderr lines that weren't traces from the remote `dsc`. + async fn finish(mut self, terminate: bool) -> Vec { + if terminate || timeout(SHUTDOWN_TIMEOUT, self.child.wait()).await.is_err() { + let _ = self.child.kill().await; + } + // A process further down the chain can keep the pipe open, so don't wait forever. + if timeout(SHUTDOWN_TIMEOUT, &mut self.stderr_task).await.is_err() { + self.stderr_task.abort(); + } + self.ssh_lines.lock().map(|lines| lines.clone()).unwrap_or_default() + } +} + +/// Get the `ssh` executable to start. +/// +/// # Returns +/// +/// The value of the `DSC_SSH_EXECUTABLE` environment variable when it's set, otherwise `ssh`, +/// which is resolved from `PATH`. +fn ssh_executable() -> OsString { + match std::env::var_os(SSH_EXECUTABLE_ENV_VAR) { + Some(value) if !value.is_empty() => value, + _ => OsString::from("ssh"), + } +} + +/// Read the `ssh` child's stderr until it closes. +/// +/// Traces from the remote `dsc` are emitted through the local tracing configuration. Any other +/// line comes from `ssh` itself and is kept for a connection error message. +/// +/// # Arguments +/// +/// * `stderr` - The child's stderr pipe. +/// * `host` - The target host, used to attribute remote traces. +/// * `ssh_lines` - Receives the lines that aren't remote traces. +async fn forward_stderr(stderr: ChildStderr, host: String, ssh_lines: Arc>>) { + let mut reader = BufReader::new(stderr); + let mut buffer = Vec::new(); + loop { + buffer.clear(); + match reader.read_until(b'\n', &mut buffer).await { + Ok(0) | Err(_) => break, + Ok(_) => {}, + } + let text = String::from_utf8_lossy(&buffer); + let line = text.trim(); + if line.is_empty() || log_remote_trace(&host, line) { + continue; + } + + debug!("{}", t!("remote.sshOutput", line = line)); + if let Ok(mut lines) = ssh_lines.lock() { + if lines.len() == MAX_SSH_STDERR_LINES { + lines.remove(0); + } + lines.push(line.to_string()); + } + } +} + +/// A trace line written by `dsc --trace-format json`. +#[derive(Deserialize)] +struct RemoteTrace { + level: TraceLevel, + fields: Map, +} + +/// Emit a stderr line as a local trace when it's a trace from the remote `dsc`. +/// +/// # Arguments +/// +/// * `host` - The target host, prefixed to the message. +/// * `line` - A line from the `ssh` child's stderr. +/// +/// # Returns +/// +/// `true` when the line was a remote trace and has been emitted. +fn log_remote_trace(host: &str, line: &str) -> bool { + let Ok(remote_trace) = serde_json::from_str::(line) else { + return false; + }; + + // Traces that DSC relays from a resource carry the text in `trace_message`. + let text = ["message", "trace_message"] + .iter() + .find_map(|key| remote_trace.fields.get(*key).and_then(Value::as_str)) + .map_or_else(|| Value::Object(remote_trace.fields.clone()).to_string(), str::to_string); + let message = t!("remote.trace", host = host, message = text); + match remote_trace.level { + TraceLevel::Error => error!("{message}"), + TraceLevel::Warn => warn!("{message}"), + TraceLevel::Info => info!("{message}"), + TraceLevel::Debug => debug!("{message}"), + TraceLevel::Trace => trace!("{message}"), + } + true +} + +/// Get the lowest server version this client talks to. +/// +/// # Panics +/// +/// Panics if the built-in minimum version isn't a valid semantic version, which a unit test +/// rules out. +fn minimum_server_version() -> &'static SemanticVersion { + static VERSION: OnceLock = OnceLock::new(); + VERSION.get_or_init(|| match SemanticVersion::parse(MINIMUM_SERVER_VERSION) { + Ok(version) => version, + Err(err) => unreachable!("{}", t!("remote.invalidMinimumVersion", error = err)), + }) +} + +/// Check that the peer is a DSC server this client can safely send operations to. +/// +/// # Arguments +/// +/// * `peer` - What the server reported about itself in the `initialize` response. +/// +/// # Errors +/// +/// Returns a description of the mismatch when the server isn't DSC or is older than the +/// minimum version. +fn check_server(peer: Option<&ServerPeerInfo>) -> Result<(), String> { + let Some(server) = peer.and_then(|peer| peer.server_info.as_ref()) else { + return Err(t!("remote.serverNotIdentified").to_string()); + }; + + let minimum = minimum_server_version(); + if server.name != SERVER_NAME { + return Err(t!("remote.serverTooOld", name = server.name, version = server.version, minimum = minimum).to_string()); + } + match SemanticVersion::parse(&server.version) { + Ok(version) if version >= *minimum => { + debug!("{}", t!("remote.serverVersion", version = version)); + Ok(()) + }, + _ => Err(t!("remote.serverTooOld", name = server.name, version = server.version, minimum = minimum).to_string()), + } +} + +/// Describe a connection problem, including what `ssh` reported. +/// +/// # Arguments +/// +/// * `target` - The machine that couldn't be reached. +/// * `detail` - What the client observed. +/// * `ssh_lines` - What `ssh` wrote on stderr. +/// +/// # Returns +/// +/// A failure with the remote failure exit code. +fn connection_failure(target: &RemoteTarget, detail: &str, ssh_lines: &[String]) -> RemoteFailure { + let message = if ssh_lines.is_empty() { + t!("remote.connectionFailed", target = target, detail = detail) + } else { + t!("remote.connectionFailed", target = target, detail = ssh_lines.join("; ")) + }; + RemoteFailure { exit_code: EXIT_REMOTE_FAILED, message: message.to_string() } +} + +/// Connect to a target, run the configuration tool once and disconnect. +/// +/// # Arguments +/// +/// * `target` - The machine to run the operation on. +/// * `arguments` - The arguments for the configuration tool. +/// +/// # Errors +/// +/// Returns a failure with the exit code to use when the connection, the handshake, the version +/// check or the operation fails. +async fn invoke_config_tool(target: &RemoteTarget, arguments: Map) -> Result { + let (process, stdin, stdout) = SshProcess::spawn(target)?; + + let transport = AsyncRwTransport::::new(stdout, stdin).with_max_line_length(MAX_RESPONSE_BYTES); + let client = ClientConfig::new( + ClientCapabilities::default(), + Implementation::new(SERVER_NAME, env!("CARGO_PKG_VERSION")), + ); + let service = match timeout(HANDSHAKE_TIMEOUT, client.serve(transport)).await { + Ok(Ok(service)) => service, + Ok(Err(err)) => { + let ssh_lines = process.finish(true).await; + return Err(connection_failure(target, &err.to_string(), &ssh_lines)); + }, + Err(_) => { + let ssh_lines = process.finish(true).await; + let detail = t!("remote.handshakeTimeout", seconds = HANDSHAKE_TIMEOUT.as_secs()); + return Err(connection_failure(target, &detail, &ssh_lines)); + }, + }; + + if let Err(reason) = check_server(service.peer_info().as_deref()) { + let _ = service.cancel().await; + process.finish(false).await; + return Err(RemoteFailure { + exit_code: EXIT_REMOTE_FAILED, + message: t!("remote.incompatibleServer", target = target, reason = reason).to_string(), + }); + } + + let response = service.call_tool(CallToolRequestParams::new(CONFIG_TOOL_NAME).with_arguments(arguments)).await; + let _ = service.cancel().await; + let ssh_lines = process.finish(false).await; + + match response { + Ok(result) => tool_result(result), + Err(ServiceError::McpError(err)) => Err(RemoteFailure { + exit_code: tool_error_exit_code(err.data.as_ref()), + message: err.message.to_string(), + }), + Err(err) => Err(connection_failure(target, &err.to_string(), &ssh_lines)), + } +} + +/// Get the exit code for a tool error. +/// +/// # Arguments +/// +/// * `data` - The `data` member of the JSON-RPC error. +/// +/// # Returns +/// +/// The exit code the server put in the error data, or the general DSC error code when the data +/// has none. +fn tool_error_exit_code(data: Option<&Value>) -> u8 { + data.and_then(|data| data.get(EXIT_CODE_DATA_KEY)) + .and_then(Value::as_u64) + .and_then(|code| u8::try_from(code).ok()) + .filter(|code| *code != 0) + .unwrap_or(EXIT_DSC_ERROR) +} + +/// Extract the operation result from a tool response. +/// +/// # Arguments +/// +/// * `result` - The response to the tool call. +/// +/// # Errors +/// +/// Returns a failure when the tool flagged an error or the response has no structured result. +fn tool_result(result: CallToolResult) -> Result { + if result.is_error == Some(true) { + // Read the text blocks through their JSON form, which is stable across content types. + let text = serde_json::to_value(&result.content) + .ok() + .and_then(|content| content.as_array().map(|blocks| { + blocks.iter() + .filter_map(|block| block.get("text").and_then(Value::as_str)) + .collect::>() + .join("; ") + })) + .unwrap_or_default(); + return Err(RemoteFailure { exit_code: EXIT_DSC_ERROR, message: t!("remote.toolFailed", detail = text).to_string() }); + } + + match result.structured_content { + Some(Value::Object(mut content)) if content.contains_key("result") => { + Ok(content.remove("result").unwrap_or(Value::Null)) + }, + _ => Err(RemoteFailure { exit_code: EXIT_DSC_ERROR, message: t!("remote.missingResult").to_string() }), + } +} + +/// Print the result of a remote operation the way the local command does. +/// +/// # Arguments +/// +/// * `operation` - The operation that produced the result. +/// * `result` - The result object the target returned. +/// * `format` - The requested output format. +/// +/// # Errors +/// +/// Returns the DSC error exit code when the result reports errors, after printing it. +fn write_result(operation: RemoteOperation, result: &Value, format: Option<&OutputFormat>) -> Result<(), ExitCode> { + // A local `config export` prints only the exported configuration. + let output = if operation == RemoteOperation::Export { + result.get("result").unwrap_or(result) + } else { + result + }; + let json = match serde_json::to_string(output) { + Ok(json) => json, + Err(err) => { + error!("JSON: {err}"); + return Err(ExitCode::from(EXIT_JSON_ERROR)); + } + }; + write_object(&json, format, false)?; + + if result.get("hadErrors").and_then(Value::as_bool).unwrap_or(false) { + if operation == RemoteOperation::Export { + for message in result.get("messages").and_then(Value::as_array).into_iter().flatten() { + let level = message.get("level").and_then(Value::as_str).unwrap_or_default(); + let text = message.get("message").and_then(Value::as_str).unwrap_or_default(); + error!("{level} {} {text}", t!("subcommand.message")); + } + } + return Err(ExitCode::from(EXIT_DSC_ERROR)); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn target_with_user_and_port_parses() { + let target: RemoteTarget = "ssh://admin@web01:2222".parse().unwrap(); + assert_eq!(target.ssh_args(), ["-s", "-p", "2222", "-l", "admin", "--", "web01", "dsc"]); + assert_eq!(target.to_string(), "ssh://admin@web01:2222"); + } + + #[test] + fn target_with_host_only_uses_ssh_defaults() { + let target: RemoteTarget = "ssh://web01.contoso.com".parse().unwrap(); + assert_eq!(target.ssh_args(), ["-s", "--", "web01.contoso.com", "dsc"]); + } + + #[test] + fn ipv6_target_loses_brackets_for_ssh() { + let target: RemoteTarget = "ssh://[::1]:22".parse().unwrap(); + assert_eq!(target.ssh_args(), ["-s", "-p", "22", "--", "::1", "dsc"]); + assert_eq!(target.to_string(), "ssh://[::1]:22"); + } + + #[test] + fn encoded_user_is_decoded() { + let target: RemoteTarget = "ssh://CONTOSO%5Cadmin@web01".parse().unwrap(); + assert_eq!(target.ssh_args(), ["-s", "-l", "CONTOSO\\admin", "--", "web01", "dsc"]); + } + + #[test] + fn host_that_looks_like_an_option_is_rejected() { + let result = "ssh://-oProxyCommand=calc".parse::(); + assert_eq!(result, Err(RemoteTargetError::InvalidHost("-oProxyCommand=calc".to_string()))); + } + + #[test] + fn user_that_looks_like_an_option_is_rejected() { + let result = "ssh://-oProxyCommand=calc@web01".parse::(); + assert_eq!(result, Err(RemoteTargetError::InvalidUser("-oProxyCommand=calc".to_string()))); + } + + #[test] + fn other_schemes_are_rejected() { + let result = "https://web01".parse::(); + assert_eq!(result, Err(RemoteTargetError::UnsupportedScheme("https".to_string()))); + } + + #[test] + fn bare_host_is_not_a_uri() { + assert!(matches!("web01".parse::(), Err(RemoteTargetError::NotUri(_)))); + } + + #[test] + fn password_path_query_and_fragment_are_rejected() { + assert_eq!("ssh://admin:secret@web01".parse::(), Err(RemoteTargetError::PasswordNotAllowed)); + assert_eq!("ssh://web01/etc".parse::(), Err(RemoteTargetError::UnexpectedComponent)); + assert_eq!("ssh://web01?x=1".parse::(), Err(RemoteTargetError::UnexpectedComponent)); + assert_eq!("ssh://web01#x".parse::(), Err(RemoteTargetError::UnexpectedComponent)); + } + + #[test] + fn minimum_server_version_is_valid() { + assert_eq!(minimum_server_version().to_string(), MINIMUM_SERVER_VERSION); + } + + #[test] + fn exit_code_comes_from_error_data() { + assert_eq!(tool_error_exit_code(Some(&json!({ "exitCode": 4 }))), 4); + assert_eq!(tool_error_exit_code(Some(&json!({ "exitCode": 0 }))), EXIT_DSC_ERROR); + assert_eq!(tool_error_exit_code(Some(&json!({ "exitCode": 4096 }))), EXIT_DSC_ERROR); + assert_eq!(tool_error_exit_code(None), EXIT_DSC_ERROR); + } + + #[test] + fn include_is_found_in_nested_groups() { + let document = json!({ + "resources": [{ + "name": "group", + "type": "Microsoft.DSC/Group", + "properties": { "resources": [{ "name": "inc", "type": "microsoft.dsc/include" }] } + }] + }); + assert!(contains_include(&document)); + assert!(!contains_include(&json!({ "resources": [{ "name": "a", "type": "Microsoft.DSC.Debug/Echo" }] }))); + } + + #[test] + fn optional_arguments_are_left_out() { + let arguments = build_arguments(RemoteOperation::Get, "{}".to_string(), None, false); + assert_eq!(Value::Object(arguments), json!({ "operation": "get", "configuration": "{}" })); + + let arguments = build_arguments(RemoteOperation::Set, "{}".to_string(), Some("{}".to_string()), true); + assert_eq!(Value::Object(arguments), json!({ "operation": "set", "configuration": "{}", "parameters": "{}", "what_if": true })); + } +} diff --git a/dsc/src/server/invoke_dsc_config.rs b/dsc/src/server/invoke_dsc_config.rs index 2237e0086..5e531d089 100644 --- a/dsc/src/server/invoke_dsc_config.rs +++ b/dsc/src/server/invoke_dsc_config.rs @@ -2,7 +2,10 @@ // Licensed under the MIT License. use crate::server::mcp_server::McpServer; -use crate::util::current_dsc_version; +use crate::util::{ + current_dsc_version, EXIT_DSC_ERROR, EXIT_INVALID_ARGS, EXIT_INVALID_INPUT, EXIT_JSON_ERROR, + EXIT_VALIDATION_FAILED, +}; use dsc_lib::{ configure::{ config_doc::{Configuration, ExecutionKind}, @@ -12,14 +15,19 @@ use dsc_lib::{ }, Configurator, }, + dscerror::DscError, progress::ProgressFormat, }; use rmcp::{handler::server::wrapper::Parameters, tool, tool_router, ErrorData as McpError, Json}; use rust_i18n::t; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; use tokio::task; +/// The member of a tool error's `data` object that carries the equivalent CLI exit code. +pub const EXIT_CODE_DATA_KEY: &str = "exitCode"; + #[derive(Deserialize, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum ConfigOperation { @@ -60,6 +68,40 @@ pub struct InvokeDscConfigRequest { pub what_if: Option, } +/// Build the `data` member of a tool error. +/// +/// The data tells a client which exit code the `dsc` CLI returns when the same failure happens +/// in a local run, so a remote client can exit with the same code. +/// +/// # Arguments +/// +/// * `exit_code` - The exit code the CLI uses for the failure. +/// +/// # Returns +/// +/// The JSON object to attach to the error. +fn exit_code_data(exit_code: u8) -> Option { + Some(json!({ EXIT_CODE_DATA_KEY: exit_code })) +} + +/// Convert a failed configuration operation into a tool error. +/// +/// # Arguments +/// +/// * `operation` - The operation that failed. +/// * `error` - The error returned by the configurator. +/// +/// # Returns +/// +/// An internal error carrying the exit code that `dsc config ` returns for `error`. +fn operation_error(operation: &ConfigOperation, error: &DscError) -> McpError { + let exit_code = match (operation, error) { + (ConfigOperation::Test, DscError::Schema(_)) => EXIT_VALIDATION_FAILED, + _ => EXIT_DSC_ERROR, + }; + McpError::internal_error(error.to_string(), exit_code_data(exit_code)) +} + #[tool_router(router = invoke_dsc_config_router, vis = "pub")] impl McpServer { #[tool( @@ -92,7 +134,7 @@ impl McpServer { "{}: {e}", t!("server.invoke_dsc_config.invalidConfiguration") ), - None, + exit_code_data(EXIT_DSC_ERROR), )) } }, @@ -102,7 +144,7 @@ impl McpServer { "{}: {e}", t!("server.invoke_dsc_config.failedConvertJson") ), - None, + exit_code_data(EXIT_INVALID_INPUT), )) } }, @@ -112,7 +154,7 @@ impl McpServer { "{}: {e}", t!("server.invoke_dsc_config.invalidConfiguration") ), - None, + exit_code_data(EXIT_INVALID_INPUT), )) } }; @@ -122,21 +164,21 @@ impl McpServer { Err(e) => { return Err(McpError::internal_error( format!("{}: {e}", t!("server.invoke_dsc_config.failedSerialize")), - None, + exit_code_data(EXIT_JSON_ERROR), )) } }; let mut configurator = match Configurator::new_with_dsc_version(&config_json, ProgressFormat::None, current_dsc_version()) { Ok(configurator) => configurator, - Err(e) => return Err(McpError::internal_error(e.to_string(), None)), + Err(e) => return Err(McpError::internal_error(e.to_string(), exit_code_data(EXIT_DSC_ERROR))), }; if what_if.unwrap_or(false) { if !matches!(operation, ConfigOperation::Set) { return Err(McpError::invalid_params( t!("server.invoke_dsc_config.whatIfOnlySet"), - None, + exit_code_data(EXIT_INVALID_ARGS), )); } configurator.context.execution_type = ExecutionKind::WhatIf; @@ -152,7 +194,7 @@ impl McpServer { "{}: {e}", t!("server.invoke_dsc_config.failedConvertJson") ), - None, + exit_code_data(EXIT_DSC_ERROR), )) } }, @@ -162,7 +204,7 @@ impl McpServer { "{}: {e}", t!("server.invoke_dsc_config.invalidParameters") ), - None, + exit_code_data(EXIT_INVALID_INPUT), )) } }; @@ -184,7 +226,7 @@ impl McpServer { if let Err(e) = configurator.set_context(parameters_value.as_ref()) { return Err(McpError::invalid_request( format!("{}: {e}", t!("server.invoke_dsc_config.failedSetParameters")), - None, + exit_code_data(EXIT_INVALID_INPUT), )); } @@ -192,35 +234,35 @@ impl McpServer { ConfigOperation::Get => { let result = match configurator.invoke_get() { Ok(res) => res, - Err(e) => return Err(McpError::internal_error(e.to_string(), None)), + Err(e) => return Err(operation_error(&operation, &e)), }; Ok(ConfigOperationResult::GetResult(Box::new(result))) } ConfigOperation::Set => { let result = match configurator.invoke_set(false) { Ok(res) => res, - Err(e) => return Err(McpError::internal_error(e.to_string(), None)), + Err(e) => return Err(operation_error(&operation, &e)), }; Ok(ConfigOperationResult::SetResult(Box::new(result))) } ConfigOperation::Test => { let result = match configurator.invoke_test() { Ok(res) => res, - Err(e) => return Err(McpError::internal_error(e.to_string(), None)), + Err(e) => return Err(operation_error(&operation, &e)), }; Ok(ConfigOperationResult::TestResult(Box::new(result))) } ConfigOperation::Export => { let result = match configurator.invoke_export() { Ok(res) => res, - Err(e) => return Err(McpError::internal_error(e.to_string(), None)), + Err(e) => return Err(operation_error(&operation, &e)), }; Ok(ConfigOperationResult::ExportResult(Box::new(result))) } } }) .await - .map_err(|e| McpError::internal_error(e.to_string(), None))??; + .map_err(|e| McpError::internal_error(e.to_string(), exit_code_data(EXIT_DSC_ERROR)))??; Ok(Json(InvokeDscConfigResponse { result })) } diff --git a/dsc/src/server/mcp_server.rs b/dsc/src/server/mcp_server.rs index b254314b9..156dbedec 100644 --- a/dsc/src/server/mcp_server.rs +++ b/dsc/src/server/mcp_server.rs @@ -4,13 +4,16 @@ use rmcp::{ ErrorData as McpError, handler::server::tool::ToolRouter, - model::{InitializeResult, InitializeRequestParams, ServerCapabilities, ServerConfig}, + model::{Implementation, InitializeResult, InitializeRequestParams, ServerCapabilities, ServerConfig}, service::{RequestContext, RoleServer}, ServerHandler, tool_handler, }; use rust_i18n::t; +/// The name the server reports for itself in the `initialize` result. +pub const SERVER_NAME: &str = "dsc"; + #[derive(Debug, Clone)] pub struct McpServer { tool_router: ToolRouter @@ -51,6 +54,9 @@ impl ServerHandler for McpServer { .build() ); info.instructions = Some(t!("server.mod.instructions").to_string()); + // Report DSC itself instead of the MCP library, so a client can check which version of + // DSC it's talking to before it sends an operation. + info.server_info = Implementation::new(SERVER_NAME, env!("CARGO_PKG_VERSION")); info } diff --git a/dsc/src/util.rs b/dsc/src/util.rs index 874cdc4a0..8e99a2af0 100644 --- a/dsc/src/util.rs +++ b/dsc/src/util.rs @@ -72,6 +72,7 @@ pub const EXIT_DSC_RESOURCE_NOT_FOUND: u8 = 7; pub const EXIT_DSC_ASSERTION_FAILED: u8 = 8; pub const EXIT_SERVER_FAILED: u8 = 9; pub const EXIT_BICEP_FAILED: u8 = 10; +pub const EXIT_REMOTE_FAILED: u8 = 11; pub const DSC_CONFIG_ROOT: &str = "DSC_CONFIG_ROOT"; diff --git a/dsc/tests/other/dsc_remote.tests.ps1 b/dsc/tests/other/dsc_remote.tests.ps1 new file mode 100644 index 000000000..4ed654385 --- /dev/null +++ b/dsc/tests/other/dsc_remote.tests.ps1 @@ -0,0 +1,265 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# These tests cover `dsc --target ssh://...`. No SSH daemon is needed: the DSC_SSH_EXECUTABLE +# environment variable points the client at a script that takes the place of `ssh` and connects +# the client's stdin and stdout to a local process, which is what `ssh -s dsc` does. + +Describe 'Tests for remote configuration over SSH' { + BeforeAll { + $script:fakeDir = Join-Path $TestDrive 'fakessh' + $null = New-Item -ItemType Directory -Path $fakeDir + + # Creates a stand-in for the ssh client. It records its arguments in '.args.txt' + # and then runs the given commands. + function New-FakeSsh { + param( + [string]$Name, + [string[]]$WindowsCommands, + [string[]]$UnixCommands + ) + + if ($IsWindows) { + $path = Join-Path $fakeDir "$Name.cmd" + $lines = @('@echo off', "echo %*> `"$fakeDir\$Name.args.txt`"") + $WindowsCommands + Set-Content -Path $path -Value ($lines -join "`r`n") + } else { + $path = Join-Path $fakeDir $Name + $lines = @('#!/bin/sh', "echo `"`$*`" > '$fakeDir/$Name.args.txt'") + $UnixCommands + Set-Content -Path $path -Value ($lines -join "`n") + chmod +x $path + } + $path + } + + $serverCommand = 'dsc --trace-format json --trace-level warn server' + $script:workingSsh = New-FakeSsh -Name 'working' -WindowsCommands $serverCommand -UnixCommands $serverCommand + + $refusedMessage = 'ssh: connect to host web01 port 22: Connection refused' + $script:refusedSsh = New-FakeSsh -Name 'refused' ` + -WindowsCommands "echo $refusedMessage 1>&2", 'exit /b 255' ` + -UnixCommands "echo '$refusedMessage' >&2", 'exit 255' + + # Answers the MCP handshake the way a DSC server from before the identity change does. + $oldServerScript = Join-Path $fakeDir 'old-server.ps1' + Set-Content -Path $oldServerScript -Value @' +$request = [Console]::In.ReadLine() | ConvertFrom-Json +$response = @{ + jsonrpc = '2.0' + id = $request.id + result = @{ + protocolVersion = $request.params.protocolVersion + capabilities = @{ tools = @{} } + serverInfo = @{ name = 'rmcp'; version = '3.8.0' } + } +} +[Console]::Out.WriteLine(($response | ConvertTo-Json -Compress -Depth 5)) +[Console]::Out.Flush() +while ($null -ne [Console]::In.ReadLine()) { } +'@ + $oldServerCommand = "pwsh -NoProfile -NoLogo -File `"$oldServerScript`"" + $script:oldServerSsh = New-FakeSsh -Name 'old' -WindowsCommands $oldServerCommand -UnixCommands $oldServerCommand + + $script:echoConfig = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + greeting: + type: string + defaultValue: hello +resources: +- name: echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parameters('greeting')]" +'@ + $script:errorFile = Join-Path $TestDrive 'stderr.txt' + } + + BeforeEach { + $env:DSC_SSH_EXECUTABLE = $workingSsh + } + + AfterAll { + $env:DSC_SSH_EXECUTABLE = $null + } + + Context 'Running operations' { + It 'Runs config get on the target and requests the dsc subsystem' { + $out = dsc --target ssh://admin@web01:2222 config get -i $echoConfig 2> $errorFile | ConvertFrom-Json -Depth 20 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $errorFile -Raw) + $out.hadErrors | Should -BeFalse + $out.results[0].type | Should -BeExactly 'Microsoft.DSC.Debug/Echo' + $out.results[0].result.actualState.output | Should -BeExactly 'hello' + $out.metadata.'Microsoft.DSC'.operation | Should -BeExactly 'get' + (Get-Content (Join-Path $fakeDir 'working.args.txt')).Trim() | Should -BeExactly '-s -p 2222 -l admin -- web01 dsc' + } + + It 'Leaves port and user to the ssh client when the target has none' { + $null = dsc --target ssh://web01 config get -i $echoConfig 2> $errorFile + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $errorFile -Raw) + (Get-Content (Join-Path $fakeDir 'working.args.txt')).Trim() | Should -BeExactly '-s -- web01 dsc' + } + + It 'Sends parameters from the command line and from a file' { + $parametersFile = Join-Path $TestDrive 'remote.parameters.yaml' + Set-Content -Path $parametersFile -Value "parameters:`n greeting: from file" + + $out = dsc --target ssh://web01 config --parameters-file $parametersFile get -i $echoConfig 2> $errorFile | ConvertFrom-Json -Depth 20 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $errorFile -Raw) + $out.results[0].result.actualState.output | Should -BeExactly 'from file' + + $inline = '{"parameters":{"greeting":"from inline"}}' + $out = dsc --target ssh://web01 config --parameters-file $parametersFile --parameters $inline get -i $echoConfig 2> $errorFile | ConvertFrom-Json -Depth 20 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $errorFile -Raw) + $out.results[0].result.actualState.output | Should -BeExactly 'from inline' + } + + It 'Reads the document from a file and from STDIN on the client' { + $configFile = Join-Path $TestDrive 'remote.dsc.yaml' + Set-Content -Path $configFile -Value $echoConfig + + $out = dsc --target ssh://web01 config test -f $configFile 2> $errorFile | ConvertFrom-Json -Depth 20 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $errorFile -Raw) + $out.results[0].result.inDesiredState | Should -BeTrue + + $out = $echoConfig | dsc --target ssh://web01 config test -f - 2> $errorFile | ConvertFrom-Json -Depth 20 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $errorFile -Raw) + $out.results[0].result.inDesiredState | Should -BeTrue + } + + It 'Runs config set as what-if when asked' { + $out = dsc --target ssh://web01 config set --what-if -i $echoConfig 2> $errorFile | ConvertFrom-Json -Depth 20 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $errorFile -Raw) + $out.metadata.'Microsoft.DSC'.executionType | Should -BeExactly 'whatIf' + + $out = dsc --target ssh://web01 config set -i $echoConfig 2> $errorFile | ConvertFrom-Json -Depth 20 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $errorFile -Raw) + $out.metadata.'Microsoft.DSC'.executionType | Should -BeExactly 'actual' + } + + It 'Prints the exported configuration like a local export' { + $exportConfig = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: os + type: Microsoft/OSInfo +'@ + $remote = dsc --target ssh://web01 config export -i $exportConfig 2> $errorFile | ConvertFrom-Json -Depth 20 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $errorFile -Raw) + $local = dsc config export -i $exportConfig 2> $null | ConvertFrom-Json -Depth 20 + + $remote.resources[0].type | Should -BeExactly 'Microsoft/OSInfo' + $remote.PSObject.Properties.Name | Should -Be $local.PSObject.Properties.Name + } + + It 'Honors the output format' { + $out = dsc --target ssh://web01 config get -i $echoConfig -o yaml 2> $errorFile + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $errorFile -Raw) + ($out -join "`n") | Should -Match '(?m)^hadErrors: false$' + } + + It 'Returns the exit code of a local run when the target rejects the parameters' { + $parameters = '{"parameters":{"notDefined":"value"}}' + $null = dsc --target ssh://web01 config --parameters $parameters get -i $echoConfig 2> $errorFile + $LASTEXITCODE | Should -Be 4 + $remoteError = Get-Content $errorFile -Raw + + $null = dsc config --parameters $parameters get -i $echoConfig 2> $errorFile + $LASTEXITCODE | Should -Be 4 + $remoteError | Should -Match "notDefined" + } + + It 'Warns that included files are resolved on the target' { + $includeConfig = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: included + type: Microsoft.DSC/Include + properties: + configurationContent: | + $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: echo + type: Microsoft.DSC.Debug/Echo + properties: + output: included +'@ + $null = dsc -l warn -t plaintext --target ssh://web01 config get -i $includeConfig 2> $errorFile + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $errorFile -Raw) + Get-Content $errorFile -Raw | Should -Match "Microsoft.DSC/Include.*resolved on 'ssh://web01'" + } + } + + Context 'Rejecting commands and targets before connecting' { + BeforeEach { + Remove-Item -Path (Join-Path $fakeDir 'working.args.txt') -ErrorAction Ignore + } + + It 'Rejects with --target' -TestCases @( + @{ name = 'resource list'; arguments = @('resource', 'list') } + @{ name = 'extension list'; arguments = @('extension', 'list') } + @{ name = 'function list'; arguments = @('function', 'list') } + @{ name = 'schema'; arguments = @('schema', '--type', 'configuration') } + @{ name = 'server'; arguments = @('server') } + @{ name = 'config with --system-root'; arguments = @('config', '--system-root', '.', 'get', '-i', '{}') } + @{ name = 'config validate'; arguments = @('config', 'validate', '-i', '{}') } + ) { + param($arguments) + + $null = dsc --target ssh://web01 @arguments 2> $errorFile + $LASTEXITCODE | Should -Be 1 + Get-Content $errorFile -Raw | Should -Match "'--target' option is only supported" + Join-Path $fakeDir 'working.args.txt' | Should -Not -Exist + } + + It 'Rejects the target ' -TestCases @( + @{ target = 'web01'; reason = 'not a URI' } + @{ target = 'https://web01'; reason = "scheme 'https' is not supported" } + @{ target = 'ssh://-oProxyCommand=calc'; reason = 'not a valid host' } + @{ target = 'ssh://-oProxyCommand=calc@web01'; reason = 'not a valid user' } + @{ target = 'ssh://admin:secret@web01'; reason = 'must not contain a password' } + @{ target = 'ssh://web01/etc/dsc'; reason = 'must not contain a path' } + ) { + param($target, $reason) + + $null = dsc --target $target config get -i $echoConfig 2> $errorFile + $LASTEXITCODE | Should -Be 1 + Get-Content $errorFile -Raw | Should -Match ([regex]::Escape($reason)) + Join-Path $fakeDir 'working.args.txt' | Should -Not -Exist + } + } + + Context 'Connection failures' { + It 'Reports what ssh wrote when the connection fails' { + $env:DSC_SSH_EXECUTABLE = $refusedSsh + $null = dsc --target ssh://web01 config get -i $echoConfig 2> $errorFile + $LASTEXITCODE | Should -Be 11 + Get-Content $errorFile -Raw | Should -Match "Could not connect to 'ssh://web01'.*Connection refused" + } + + It 'Fails when the ssh client cannot be started' { + $env:DSC_SSH_EXECUTABLE = Join-Path $fakeDir 'does-not-exist' + $null = dsc --target ssh://web01 config get -i $echoConfig 2> $errorFile + $LASTEXITCODE | Should -Be 11 + Get-Content $errorFile -Raw | Should -Match 'Failed to start the SSH client' + } + + It 'Refuses a server that does not identify itself as a supported DSC version' { + $env:DSC_SSH_EXECUTABLE = $oldServerSsh + $null = dsc --target ssh://web01 config set --what-if -i $echoConfig 2> $errorFile + $LASTEXITCODE | Should -Be 11 + Get-Content $errorFile -Raw | Should -Match "the server reports 'rmcp 3.8.0'" + } + } + + Context 'Remote traces' { + It 'Emits traces from the target with the host as prefix' { + # The server logs a warning when a tool call fails; an undefined parameter makes the + # call fail without needing any resource. + $parameters = '{"parameters":{"notDefined":"value"}}' + $null = dsc -l warn -t plaintext --target ssh://web01 config --parameters $parameters get -i $echoConfig 2> $errorFile + $LASTEXITCODE | Should -Be 4 + Get-Content $errorFile -Raw | Should -Match 'WARN web01: ' + } + } +} diff --git a/dsc/tests/other/dsc_server.tests.ps1 b/dsc/tests/other/dsc_server.tests.ps1 index 857044957..4d7e28709 100644 --- a/dsc/tests/other/dsc_server.tests.ps1 +++ b/dsc/tests/other/dsc_server.tests.ps1 @@ -47,6 +47,9 @@ Describe 'Tests for DSC server' { $response.id | Should -Be 1 $response.result.capabilities.tools | Should -Not -Be $null $response.result.instructions | Should -Not -BeNullOrEmpty + # the server identifies itself as DSC so that clients can check its version + $response.result.serverInfo.name | Should -BeExactly 'dsc' + $response.result.serverInfo.version | Should -BeExactly (dsc --version).Split(' ')[1] $notifyInitialized = @{ jsonrpc = "2.0" @@ -926,6 +929,8 @@ resources: $response.id | Should -Be 17 $response.error.code | Should -Be -32600 $response.error.message | Should -Match 'Invalid configuration' + # the error carries the exit code the CLI returns for the same failure + $response.error.data.exitCode | Should -Be 2 } It 'Calling invoke_dsc_config with invalid parameters returns error' { @@ -961,6 +966,7 @@ resources: $response.id | Should -Be 18 $response.error.code | Should -Be -32600 $response.error.message | Should -Match 'Invalid parameters' + $response.error.data.exitCode | Should -Be 4 } It 'Calling show_dsc_schema works' { diff --git a/examples/ssh-subsystem-azure/.gitignore b/examples/ssh-subsystem-azure/.gitignore new file mode 100644 index 000000000..d65e7d914 --- /dev/null +++ b/examples/ssh-subsystem-azure/.gitignore @@ -0,0 +1,2 @@ +# Created by deploy.ps1: the demo SSH key pair, the dsc package and the connection details. +.demo/