feat: RDP profiles, sessions and viewer

- Protocol::Rdp with RdpConfig (domain, password mode, certificate
  policy, TLS-only fallback, fixed or tab-sized resolution, SOCKS5/HTTP
  proxy, SSH tunnel); migration 008 adds rdp_config and drops the
  protocol CHECK constraint so new protocols no longer need a rebuild.
- Session manager: connect_rdp dials directly, via the shared proxy
  module (proxy password from the secret store) or an SSH tunnel; the
  server certificate goes through the known-hosts check (key type
  rdp-tls); a tab-sized desktop uses the tab's reported pixel size, and
  resizes wait until the server opens Display Control. Rejected NLA
  passwords count as auth failures, so a saved one is asked again.
- The VNC viewer becomes DesktopView for both protocols; a small adapter
  turns its keysyms and pointer masks into RDP scancodes, Unicode keys,
  buttons and wheel events. For RDP, Paste Clipboard types the text.
- Profile editor RDP section, Quick Connect, badges, docs.

Verified against xrdp in the app: logon, desktop, input, and connections
through SOCKS5 and HTTP CONNECT relays. Not verified here: Windows/NLA
servers, the SSH tunnel route, and Display Control resizing (xrdp
0.9.24 never opened the channel).
This commit is contained in:
Andy Dixon
2026-10-12 11:14:39 +01:00
parent 50c0104d57
commit d25746bb14
18 changed files with 662 additions and 75 deletions
+72 -1
View File
@@ -10,6 +10,7 @@ pub const DEFAULT_TELNET_PORT: u16 = 23;
pub const DEFAULT_RAW_PORT: u16 = 23;
pub const DEFAULT_GITHUB_PORT: u16 = 443;
pub const DEFAULT_VNC_PORT: u16 = 5900;
pub const DEFAULT_RDP_PORT: u16 = 3389;
pub const DEFAULT_IRC_PORT: u16 = 6667;
pub const DEFAULT_IRC_TLS_PORT: u16 = 6697;
pub const DEFAULT_CONNECT_TIMEOUT: i64 = 30;
@@ -52,13 +53,15 @@ pub enum Protocol {
Raw,
Github,
Vnc,
Rdp,
Irc,
Serial,
Local,
}
impl Protocol {
pub const SAVED: [Protocol; 7] = [Protocol::Ssh, Protocol::Telnet, Protocol::Raw, Protocol::Github, Protocol::Vnc, Protocol::Irc, Protocol::Serial];
pub const SAVED: [Protocol; 8] =
[Protocol::Ssh, Protocol::Telnet, Protocol::Raw, Protocol::Github, Protocol::Vnc, Protocol::Rdp, Protocol::Irc, Protocol::Serial];
pub fn id(self) -> &'static str {
match self {
Protocol::Ssh => "ssh",
@@ -66,6 +69,7 @@ impl Protocol {
Protocol::Raw => "raw",
Protocol::Github => "github",
Protocol::Vnc => "vnc",
Protocol::Rdp => "rdp",
Protocol::Irc => "irc",
Protocol::Serial => "serial",
Protocol::Local => "local",
@@ -81,6 +85,7 @@ impl Protocol {
Protocol::Raw => "RAW TCP",
Protocol::Github => "GitHub",
Protocol::Vnc => "VNC",
Protocol::Rdp => "RDP",
Protocol::Irc => "IRC",
Protocol::Serial => "Serial",
Protocol::Local => "Local",
@@ -237,6 +242,8 @@ pub struct Profile {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vnc: Option<VncConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rdp: Option<RdpConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub irc: Option<IrcConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub serial: Option<SerialConfig>,
@@ -387,6 +394,53 @@ pub struct VncConfig {
pub ssh_agent_auth: bool,
}
/// RDP connection settings; host, port and username are the profile's own.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct RdpConfig {
pub domain: String,
pub secret_mode: SecretMode,
/// Initial desktop size; 0×0 follows the tab's size.
pub width: i64,
pub height: i64,
pub scale_to_fit: bool,
/// Resize the remote desktop with the tab (Display Control); otherwise scale the image.
pub dynamic_resolution: bool,
/// Allow plain TLS logon for servers without Network Level Authentication (e.g. xrdp).
#[serde(rename = "allowTLSOnly")]
pub allow_tls_only: bool,
/// Trust policy for the server's TLS certificate (pinned like an SSH host key).
#[serde(deserialize_with = "lenient")]
pub host_key_policy: HostKeyPolicy,
/// "none", "socks5" or "http"; the password is a stored secret.
pub proxy_type: String,
pub proxy_host: String,
pub proxy_port: i64,
pub proxy_username: String,
pub ssh_tunnel: bool,
pub ssh_host: String,
pub ssh_port: i64,
pub ssh_username: String,
pub ssh_auth_method: String,
pub ssh_secret_mode: SecretMode,
#[serde(rename = "sshPrivateKeyPath")]
pub ssh_private_key: String,
pub ssh_agent_auth: bool,
}
impl RdpConfig {
pub fn defaults() -> Self {
RdpConfig {
scale_to_fit: true,
dynamic_resolution: true,
proxy_type: "none".into(),
ssh_port: DEFAULT_SSH_PORT.into(),
ssh_auth_method: "publickey".into(),
..Default::default()
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct IrcConfig {
@@ -667,6 +721,7 @@ impl Profile {
raw: None,
github: None,
vnc: None,
rdp: None,
irc: None,
serial: None,
};
@@ -692,6 +747,10 @@ impl Profile {
p.port = DEFAULT_VNC_PORT.into();
p.vnc = Some(VncConfig::defaults());
}
Protocol::Rdp => {
p.port = DEFAULT_RDP_PORT.into();
p.rdp = Some(RdpConfig::defaults());
}
Protocol::Irc => {
p.port = DEFAULT_IRC_PORT.into();
p.irc = Some(IrcConfig::defaults());
@@ -801,6 +860,18 @@ impl Profile {
c.ssh_auth_method = "publickey".into();
}
}
Protocol::Rdp => {
let c = self.rdp.get_or_insert_with(RdpConfig::defaults);
if c.ssh_port == 0 {
c.ssh_port = DEFAULT_SSH_PORT.into();
}
if c.ssh_auth_method.is_empty() {
c.ssh_auth_method = "publickey".into();
}
if c.proxy_type.is_empty() {
c.proxy_type = "none".into();
}
}
Protocol::Irc => {
let c = self.irc.get_or_insert_with(IrcConfig::defaults);
if c.nick.is_empty() {