feat: inital support for .RDP files (#862)

This is paving the way for .rdp file support.

Issue: ARC-339
Issue: ARC-355
This commit is contained in:
Benoît Cortier
2025-07-08 13:10:04 -04:00
committed by GitHub
parent 14e245d73e
commit c710909a3c
17 changed files with 657 additions and 8 deletions
+12
View File
@@ -123,6 +123,14 @@ RDCleanPath PDU structure used by IronRDP web client and Devolutions Gateway.
Lightweight and `no_std`-compatible generic `Error` and `Report` types.
The `Error` type wraps a custom consumer-defined type for domain-specific details (such as `PduErrorKind`).
#### [`crates/ironrdp-propertyset`](./crates/ironrdp-propertyset)
The main type is `PropertySet`, a key-value store for configuration options.
#### [`crates/ironrdp-rdpfile`](./crates/ironrdp-rdpfile)
Loader and writer for the .RDPfile format.
### Extra Tier
Higher level libraries and binaries built on top of the core tier.
@@ -188,6 +196,10 @@ Web-based frontend using `Svelte` and `Material` frameworks.
Native CLIPRDR backend implementations.
#### [`crates/ironrdp-cfg`](./crates/ironrdp-cfg)
IronRDP-related utilities for ironrdp-propertyset.
### Internal Tier
Crates that are only used inside the IronRDP project, not meant to be published.
Generated
+26
View File
@@ -2422,6 +2422,13 @@ dependencies = [
"tracing",
]
[[package]]
name = "ironrdp-cfg"
version = "0.1.0"
dependencies = [
"ironrdp-propertyset",
]
[[package]]
name = "ironrdp-client"
version = "0.1.0"
@@ -2431,10 +2438,13 @@ dependencies = [
"futures-util",
"inquire",
"ironrdp",
"ironrdp-cfg",
"ironrdp-cliprdr-native",
"ironrdp-core",
"ironrdp-dvc-pipe-proxy",
"ironrdp-propertyset",
"ironrdp-rdcleanpath",
"ironrdp-rdpfile",
"ironrdp-rdpsnd-native",
"ironrdp-tls",
"ironrdp-tokio",
@@ -2635,6 +2645,13 @@ dependencies = [
name = "ironrdp-pdu-generators"
version = "0.0.0"
[[package]]
name = "ironrdp-propertyset"
version = "0.1.0"
dependencies = [
"tracing",
]
[[package]]
name = "ironrdp-rdcleanpath"
version = "0.1.3"
@@ -2666,6 +2683,13 @@ dependencies = [
"tracing",
]
[[package]]
name = "ironrdp-rdpfile"
version = "0.1.0"
dependencies = [
"ironrdp-propertyset",
]
[[package]]
name = "ironrdp-rdpsnd"
version = "0.5.0"
@@ -2764,7 +2788,9 @@ dependencies = [
"ironrdp-graphics",
"ironrdp-input",
"ironrdp-pdu",
"ironrdp-propertyset",
"ironrdp-rdcleanpath",
"ironrdp-rdpfile",
"ironrdp-rdpsnd",
"ironrdp-session",
"lazy_static",
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "ironrdp-cfg"
version = "0.1.0"
readme = "README.md"
description = "IronRDP utilities for ironrdp-cfgstore"
publish = false # TODO: publish
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true
[lib]
doctest = false
test = false
[dependencies]
ironrdp-propertyset = { path = "../ironrdp-propertyset", version = "0.1" } # public
[lints]
workspace = true
+7
View File
@@ -0,0 +1,7 @@
# IronRDP Configuration
IronRDP-related utilities for ironrdp-propertyset.
This crate is part of the [IronRDP] project.
[IronRDP]: https://github.com/Devolutions/IronRDP
+63
View File
@@ -0,0 +1,63 @@
// QUESTION: consider auto-generating this file based on a reference file?
// https://gist.github.com/awakecoding/838c7fe2ed3a6208e3ca5d8af25363f6
use ironrdp_propertyset::PropertySet;
pub trait PropertySetExt {
fn full_address(&self) -> Option<&str>;
fn server_port(&self) -> Option<i64>;
fn alternate_full_address(&self) -> Option<&str>;
fn gateway_hostname(&self) -> Option<&str>;
fn remote_application_name(&self) -> Option<&str>;
fn remote_application_program(&self) -> Option<&str>;
fn kdc_proxy_url(&self) -> Option<&str>;
fn username(&self) -> Option<&str>;
/// Target RDP server password - use for testing only
fn clear_text_password(&self) -> Option<&str>;
}
impl PropertySetExt for PropertySet {
fn full_address(&self) -> Option<&str> {
self.get::<&str>("full address")
}
fn server_port(&self) -> Option<i64> {
self.get::<i64>("server port")
}
fn alternate_full_address(&self) -> Option<&str> {
self.get::<&str>("alternate full address")
}
fn gateway_hostname(&self) -> Option<&str> {
self.get::<&str>("gatewayhostname")
}
fn remote_application_name(&self) -> Option<&str> {
self.get::<&str>("remoteapplicationname")
}
fn remote_application_program(&self) -> Option<&str> {
self.get::<&str>("remoteapplicationprogram")
}
fn kdc_proxy_url(&self) -> Option<&str> {
self.get::<&str>("kdcproxyurl")
}
fn username(&self) -> Option<&str> {
self.get::<&str>("username")
}
fn clear_text_password(&self) -> Option<&str> {
self.get::<&str>("ClearTextPassword")
}
}
+3
View File
@@ -49,6 +49,9 @@ ironrdp-tls = { path = "../ironrdp-tls", version = "0.1" }
ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.6", features = ["reqwest"] }
ironrdp-rdcleanpath.path = "../ironrdp-rdcleanpath"
ironrdp-dvc-pipe-proxy.path = "../ironrdp-dvc-pipe-proxy"
ironrdp-propertyset.path = "../ironrdp-propertyset"
ironrdp-rdpfile.path = "../ironrdp-rdpfile"
ironrdp-cfg.path = "../ironrdp-cfg"
# Windowing and rendering
winit = { version = "0.30", features = ["rwh_06"] }
+42 -8
View File
@@ -8,6 +8,7 @@ use clap::Parser;
use ironrdp::connector::{self, Credentials};
use ironrdp::pdu::rdp::capability_sets::{client_codecs_capabilities, MajorPlatformType};
use ironrdp::pdu::rdp::client_info::PerformanceFlags;
use std::path::PathBuf;
use tap::prelude::*;
use url::Url;
@@ -176,13 +177,17 @@ impl FromStr for DvcProxyInfo {
#[clap(author = "Devolutions", about = "Devolutions-IronRDP client")]
#[clap(version, long_about = None)]
struct Args {
/// An address on which the client will connect.
destination: Option<Destination>,
/// Path to a .rdp file to read the configuration from.
#[clap(long)]
rdp_file: Option<PathBuf>,
/// A file with IronRDP client logs
#[clap(short, long)]
log_file: Option<String>,
/// An address on which the client will connect.
destination: Option<Destination>,
/// A target RDP server user name
#[clap(short, long)]
username: Option<String>,
@@ -273,21 +278,46 @@ struct Args {
#[clap(long, num_args = 1.., value_delimiter = ',')]
codecs: Vec<String>,
/// Add DVC channel named pipe proxy.
/// the format is <name>=<pipe>
/// e.g. `ChannelName=PipeName` where `ChannelName` is the name of the channel,
/// and `PipeName` is the name of the named pipe to connect to (without OS-specific prefix),
/// e.g. PipeName will automatically be prefixed with `\\.\pipe\` on Windows.
/// Add DVC channel named pipe proxy
///
/// The format is `<name>=<pipe>`, e.g., `ChannelName=PipeName` where `ChannelName` is the name of the channel,
/// and `PipeName` is the name of the named pipe to connect to (without OS-specific prefix).
/// `<pipe>` will automatically be prefixed with `\\.\pipe\` on Windows.
#[clap(long)]
dvc_proxy: Vec<DvcProxyInfo>,
}
impl Config {
pub fn parse_args() -> anyhow::Result<Self> {
use ironrdp_cfg::PropertySetExt as _;
let args = Args::parse();
let mut properties = ironrdp_propertyset::PropertySet::new();
if let Some(rdp_file) = args.rdp_file {
let input =
std::fs::read_to_string(&rdp_file).with_context(|| format!("failed to read {}", rdp_file.display()))?;
if let Err(errors) = ironrdp_rdpfile::load(&mut properties, &input) {
for e in errors {
#[expect(clippy::print_stderr)]
{
eprintln!("Error when reading {}: {e}", rdp_file.display())
}
}
}
}
let destination = if let Some(destination) = args.destination {
destination
} else if let Some(destination) = properties.full_address() {
if let Some(port) = properties.server_port() {
format!("{destination}:{port}").parse()
} else {
destination.parse()
}
.context("invalid destination")?
} else {
inquire::Text::new("Server address:")
.prompt()
@@ -297,12 +327,16 @@ impl Config {
let username = if let Some(username) = args.username {
username
} else if let Some(username) = properties.username() {
username.to_owned()
} else {
inquire::Text::new("Username:").prompt().context("Username prompt")?
};
let password = if let Some(password) = args.password {
password
} else if let Some(password) = properties.clear_text_password() {
password.to_owned()
} else {
inquire::Password::new("Password:")
.without_confirmation()
+1
View File
@@ -4,6 +4,7 @@
#[cfg(feature = "alloc")]
extern crate alloc;
#[cfg(feature = "alloc")]
use alloc::boxed::Box;
use core::fmt;
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "ironrdp-propertyset"
version = "0.1.0"
readme = "README.md"
description = "A key-value store for configuration options"
publish = false # TODO: publish
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true
[lib]
doctest = false
test = false
[dependencies]
tracing = { version = "0.1", features = ["log"] }
[lints]
workspace = true
+7
View File
@@ -0,0 +1,7 @@
# IronRDP PropertySet
The main type is `PropertySet`, a key-value store for configuration options.
This crate is part of the [IronRDP] project.
[IronRDP]: https://github.com/Devolutions/IronRDP
+171
View File
@@ -0,0 +1,171 @@
#![doc = include_str!("../README.md")]
#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")]
#![no_std]
extern crate alloc;
#[macro_use]
extern crate tracing;
use core::fmt::{self, Display};
use alloc::borrow::Cow;
use alloc::collections::BTreeMap;
use alloc::string::String;
pub type Key = Cow<'static, str>;
/// Key-value store for configuration keys.
#[derive(Clone, Default, PartialEq, Eq)]
pub struct PropertySet {
inner: BTreeMap<Key, Value>,
}
impl PropertySet {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, key: impl Into<Key>, value: impl Into<Value>) -> Option<Value> {
let (key, value) = (key.into(), value.into());
debug!("PropertySet::insert({key}, {value})");
self.inner.insert(key, value)
}
pub fn remove(&mut self, key: &str) -> Option<Value> {
let value = self.inner.remove(key);
match &value {
Some(value) => debug!("PropertySet::remove({key}) = {value}"),
None => debug!("PropertySet::remove({key}) = None"),
}
value
}
pub fn get<'a, V: ExtractFrom<&'a Value>>(&'a self, key: &str) -> Option<V> {
let value = self.inner.get(key);
match &value {
Some(value) => debug!("PropertySet::get({key}) = {value}"),
None => debug!("PropertySet::get({key}) = None"),
}
value.and_then(|val| V::extract_from(val, private::Token))
}
pub fn iter(&self) -> impl Iterator<Item = (&Key, &Value)> {
self.inner.iter()
}
}
impl IntoIterator for PropertySet {
type Item = (Key, Value);
type IntoIter = alloc::collections::btree_map::IntoIter<Key, Value>;
fn into_iter(self) -> Self::IntoIter {
self.inner.into_iter()
}
}
impl fmt::Debug for PropertySet {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&self.inner, f)
}
}
macro_rules! impl_from {
($from:ty => $enum:ident :: $variant:ident) => {
impl From<$from> for $enum {
fn from(value: $from) -> Self {
Self::$variant(value.into())
}
}
};
}
macro_rules! impl_extract_from {
(ref $enum:ident :: as_int => $to:ty) => {
impl ExtractFrom<&$enum> for $to {
fn extract_from(value: &$enum, _token: private::Token) -> Option<Self> {
value.as_int().and_then(|v| v.try_into().ok())
}
}
};
}
pub trait ExtractFrom<Value>: Sized {
fn extract_from(value: Value, _token: private::Token) -> Option<Self>;
}
/// Represents a value of any type of the .RDP file format.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Value {
/// Numerical value.
Int(i64),
/// String value.
Str(String),
}
impl Value {
pub fn as_str(&self) -> Option<&str> {
if let Self::Str(value) = self {
Some(value.as_str())
} else {
None
}
}
pub fn as_int(&self) -> Option<i64> {
if let Self::Int(value) = self {
Some(*value)
} else {
None
}
}
}
impl Display for Value {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Value::Int(value) => write!(f, "{value}"),
Value::Str(value) => write!(f, "\"{value}\""),
}
}
}
impl_from!(String => Value::Str);
impl_from!(&str => Value::Str);
impl_from!(u8 => Value::Int);
impl_from!(u16 => Value::Int);
impl_from!(u32 => Value::Int);
impl_from!(i8 => Value::Int);
impl_from!(i16 => Value::Int);
impl_from!(i32 => Value::Int);
impl_from!(i64 => Value::Int);
impl_from!(bool => Value::Int);
impl_extract_from!(ref Value::as_int => u8);
impl_extract_from!(ref Value::as_int => u16);
impl_extract_from!(ref Value::as_int => u32);
impl_extract_from!(ref Value::as_int => i8);
impl_extract_from!(ref Value::as_int => i16);
impl_extract_from!(ref Value::as_int => i32);
impl_extract_from!(ref Value::as_int => i64);
impl<'a> ExtractFrom<&'a Value> for &'a str {
fn extract_from(value: &'a Value, _token: private::Token) -> Option<Self> {
value.as_str()
}
}
impl ExtractFrom<&Value> for bool {
fn extract_from(value: &Value, _token: private::Token) -> Option<Self> {
value.as_int().map(|value| value != 0)
}
}
mod private {
pub struct Token;
}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "ironrdp-rdpfile"
version = "0.1.0"
readme = "README.md"
description = "Parser and writer for .RDP file format"
publish = false # TODO: publish
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true
[lib]
doctest = false
test = false
[dependencies]
ironrdp-propertyset = { path = "../ironrdp-propertyset", version = "0.1" } # public
[lints]
workspace = true
+7
View File
@@ -0,0 +1,7 @@
# IronRDP .RDP file
Loader and writer for the .RDPfile format.
This crate is part of the [IronRDP] project.
[IronRDP]: https://github.com/Devolutions/IronRDP
+126
View File
@@ -0,0 +1,126 @@
#![doc = include_str!("../README.md")]
#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")]
#![no_std]
extern crate alloc;
use core::fmt;
use alloc::borrow::ToOwned;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use ironrdp_propertyset::{PropertySet, Value};
#[derive(Debug, Clone)]
pub enum ErrorKind {
UnknownType { ty: String },
InvalidValue { ty: String, value: String },
MalformedLine { line: String },
}
#[derive(Debug, Clone)]
pub struct Error {
pub kind: ErrorKind,
pub line: usize,
}
impl core::error::Error for Error {}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let line_number = self.line;
match &self.kind {
ErrorKind::UnknownType { ty } => write!(f, "unknown type at line {line_number} ({ty})"),
ErrorKind::InvalidValue { ty, value } => {
write!(f, "invalid value at line {line_number} for type {ty} ({value})")
}
ErrorKind::MalformedLine { line } => write!(f, "malformed line at line {line_number} ({line})"),
}
}
}
pub fn load(properties: &mut PropertySet, input: &str) -> Result<(), Vec<Error>> {
let mut errors = Vec::new();
for (idx, line) in input.lines().enumerate() {
let mut split = line.splitn(3, ':');
if let (Some(key), Some(ty), Some(value)) = (split.next(), split.next(), split.next()) {
match ty {
"i" => {
if let Ok(value) = value.parse::<i64>() {
properties.insert(key.to_owned(), value);
} else {
errors.push(Error {
kind: ErrorKind::InvalidValue {
ty: ty.to_owned(),
value: value.to_owned(),
},
line: idx,
});
}
}
"s" => {
properties.insert(key.to_owned(), value);
}
_ => {
errors.push(Error {
kind: ErrorKind::UnknownType { ty: ty.to_owned() },
line: idx,
});
}
}
} else {
errors.push(Error {
kind: ErrorKind::MalformedLine { line: line.to_owned() },
line: idx,
})
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
pub struct ParseResult {
pub properties: PropertySet,
pub errors: Vec<Error>,
}
pub fn parse(input: &str) -> ParseResult {
let mut properties = PropertySet::new();
let errors = match load(&mut properties, input) {
Ok(()) => Vec::new(),
Err(errors) => errors,
};
ParseResult { properties, errors }
}
pub fn write(properties: &PropertySet) -> String {
let mut buf = String::new();
for (key, value) in properties.iter() {
buf.push_str(key);
match value {
Value::Int(value) => {
buf.push_str(":i:");
buf.push_str(&value.to_string());
}
Value::Str(value) => {
buf.push_str(":s:");
buf.push_str(value);
}
}
buf.push('\n');
}
buf
}
+2
View File
@@ -45,6 +45,8 @@ ironrdp-input.path = "../ironrdp-input"
ironrdp-rdcleanpath.path = "../ironrdp-rdcleanpath"
ironrdp-rdpsnd.path = "../ironrdp-rdpsnd"
ironrdp-session.path = "../ironrdp-session"
ironrdp-propertyset.path = "../ironrdp-propertyset"
ironrdp-rdpfile.path = "../ironrdp-rdpfile"
png = "0.17"
pretty_assertions = "1.4"
proptest.workspace = true
@@ -19,6 +19,7 @@ mod graphics;
mod input;
mod pcb;
mod pdu;
mod propertyset;
mod rdcleanpath;
mod rdpsnd;
mod server;
@@ -0,0 +1,120 @@
use expect_test::expect;
use ironrdp_rdpfile::ParseResult;
const RDP_FILE_SAMPLE: &str = r#"remoteapplicationmode:i:0
server port:i:3389
promptcredentialonce:i:1
full address:s:192.168.56.101
alternate shell:s:|explorer
remoteapplicationname:s:|explorer
alternate full address:s:some.alternateaddress.ninja
username:s:David
ClearTextPassword:s:Devolutions123!
MalformedLine:s
UnknownType:z:10293"#;
const RDP_FILE_SAMPLE_2: &str = r#"remoteapplicationmode:i:50
server port:i:4000
full address:s:192.168.56.2"#;
#[test]
fn parse_file() {
let ParseResult { mut properties, errors } = ironrdp_rdpfile::parse(RDP_FILE_SAMPLE);
expect![[r#"
{
"ClearTextPassword": Str(
"Devolutions123!",
),
"alternate full address": Str(
"some.alternateaddress.ninja",
),
"alternate shell": Str(
"|explorer",
),
"full address": Str(
"192.168.56.101",
),
"promptcredentialonce": Int(
1,
),
"remoteapplicationmode": Int(
0,
),
"remoteapplicationname": Str(
"|explorer",
),
"server port": Int(
3389,
),
"username": Str(
"David",
),
}
"#]]
.assert_debug_eq(&properties);
expect![[r#"
[
Error {
kind: MalformedLine {
line: "MalformedLine:s",
},
line: 9,
},
Error {
kind: UnknownType {
ty: "z",
},
line: 10,
},
]
"#]]
.assert_debug_eq(&errors);
// Verify the `get` operation.
assert_eq!(properties.get::<bool>("remoteapplicationmode"), Some(false));
assert_eq!(properties.get::<bool>("promptcredentialonce"), Some(true));
assert_eq!(properties.get::<bool>("absentproperty"), None);
assert_eq!(properties.get::<i64>("server port"), Some(3389));
assert_eq!(properties.get::<&str>("full address"), Some("192.168.56.101"));
// Merge another file.
ironrdp_rdpfile::load(&mut properties, RDP_FILE_SAMPLE_2).expect("valid rdp file format");
expect![[r#"
{
"ClearTextPassword": Str(
"Devolutions123!",
),
"alternate full address": Str(
"some.alternateaddress.ninja",
),
"alternate shell": Str(
"|explorer",
),
"full address": Str(
"192.168.56.2",
),
"promptcredentialonce": Int(
1,
),
"remoteapplicationmode": Int(
50,
),
"remoteapplicationname": Str(
"|explorer",
),
"server port": Int(
4000,
),
"username": Str(
"David",
),
}
"#]]
.assert_debug_eq(&properties);
// Anything that is not 0 is considered to be 'true'.
assert_eq!(properties.get::<bool>("remoteapplicationmode"), Some(true));
}