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
+50 -1
View File
@@ -2,7 +2,7 @@
//! host-key policy, line endings, forward direction, protocol) can't hold invalid values in Rust,
//! so the Go checks for those are enforced by the types instead.
use crate::models::{GitHubConfig, IrcConfig, Profile, Protocol, RawConfig, SerialConfig, SshConfig, TelnetConfig, VncConfig};
use crate::models::{GitHubConfig, IrcConfig, Profile, Protocol, RawConfig, RdpConfig, SerialConfig, SshConfig, TelnetConfig, VncConfig};
use serde::Serialize;
use std::fmt;
@@ -100,6 +100,13 @@ pub fn validate_profile(p: &Profile) -> Result<(), ValidationErrors> {
None => errs.add("vnc", "VNC configuration is required for VNC protocol"),
Some(c) => errs.merge(validate_vnc_config(&p.host, c)),
},
Protocol::Rdp => {
host_port(&mut errs, &p.host, p.port);
match &p.rdp {
None => errs.add("rdp", "RDP configuration is required for RDP protocol"),
Some(c) => errs.merge(validate_rdp_config(c)),
}
}
Protocol::Irc => {
host_port(&mut errs, &p.host, p.port);
match &p.irc {
@@ -310,6 +317,32 @@ pub fn validate_vnc_config(profile_host: &str, cfg: &VncConfig) -> Result<(), Va
errs.into_result()
}
pub fn validate_rdp_config(cfg: &RdpConfig) -> Result<(), ValidationErrors> {
let mut errs = ValidationErrors::default();
for (field, v) in [("rdp.width", cfg.width), ("rdp.height", cfg.height)] {
if v != 0 && !(200..=8192).contains(&v) {
errs.add(field, "desktop size must be 0 (fit the tab) or 200–8192 pixels");
}
}
match cfg.proxy_type.as_str() {
"" | "none" => {}
"socks5" | "http" => {
errs.check("rdp.proxyHost", validate_host(&cfg.proxy_host));
if cfg.proxy_port != 0 {
errs.check("rdp.proxyPort", validate_port(cfg.proxy_port));
}
}
t => errs.add("rdp.proxyType", format!("unsupported proxy type {t:?}")),
}
if cfg.ssh_tunnel {
if !matches!(cfg.proxy_type.as_str(), "" | "none") {
errs.add("rdp.proxyType", "use either a proxy or an SSH tunnel, not both");
}
ssh_tunnel(&mut errs, "rdp", &cfg.ssh_host, cfg.ssh_port, &cfg.ssh_username, &cfg.ssh_auth_method);
}
errs.into_result()
}
fn ssh_tunnel(errs: &mut ValidationErrors, p: &str, host: &str, port: i64, user: &str, auth: &str) {
errs.check(&format!("{p}.sshHost"), validate_host(host));
errs.check(&format!("{p}.sshPort"), validate_port(port));
@@ -399,6 +432,22 @@ fn is_valid_channel(ch: &str) -> bool {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rdp_profiles() {
let mut p = Profile::new(Protocol::Rdp);
p.host = "win.example".into();
p.normalize();
assert!(validate_profile(&p).is_ok(), "{:?}", validate_profile(&p).err().map(|e| e.to_string()));
let c = p.rdp.as_mut().unwrap();
c.width = 100;
c.proxy_type = "socks5".into();
c.ssh_tunnel = true;
let msg = validate_profile(&p).unwrap_err().to_string();
for want in ["rdp.width", "rdp.proxyHost", "rdp.sshHost", "not both"] {
assert!(msg.contains(want), "{want}: {msg}");
}
}
use crate::models::{JumpHost, PayloadPreset};
#[test]