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:
@@ -209,6 +209,8 @@ impl Client {
|
||||
}
|
||||
.build();
|
||||
let mut input = Database::new();
|
||||
// A resize waits here until the Display Control channel is open (it opens after logon).
|
||||
let mut want_size: Option<(u16, u16)> = None;
|
||||
loop {
|
||||
let mut outputs = Vec::new();
|
||||
loop {
|
||||
@@ -219,12 +221,7 @@ impl Client {
|
||||
outputs.extend(stage.process_fastpath_input(&mut image, &events)?);
|
||||
}
|
||||
}
|
||||
Ok(Input::Resize(w, h)) => {
|
||||
let (w, h) = ironrdp::displaycontrol::pdu::MonitorLayoutEntry::adjust_display_size(w.into(), h.into());
|
||||
if let Some(frame) = stage.encode_resize(w, h, None, None) {
|
||||
outputs.push(ActiveStageOutput::ResponseFrame(frame?));
|
||||
}
|
||||
}
|
||||
Ok(Input::Resize(w, h)) => want_size = Some((w, h)).filter(|&s| s != (image.width(), image.height())),
|
||||
Ok(Input::Close) | Err(mpsc::TryRecvError::Disconnected) => {
|
||||
for o in stage.graceful_shutdown()? {
|
||||
if let ActiveStageOutput::ResponseFrame(f) = o {
|
||||
@@ -236,6 +233,13 @@ impl Client {
|
||||
Err(mpsc::TryRecvError::Empty) => break,
|
||||
}
|
||||
}
|
||||
if let Some((w, h)) = want_size {
|
||||
let (w, h) = ironrdp::displaycontrol::pdu::MonitorLayoutEntry::adjust_display_size(w.into(), h.into());
|
||||
if let Some(frame) = stage.encode_resize(w, h, None, None) {
|
||||
outputs.push(ActiveStageOutput::ResponseFrame(frame?));
|
||||
want_size = None;
|
||||
}
|
||||
}
|
||||
match framed.read_pdu() {
|
||||
Ok((action, payload)) => outputs.extend(stage.process(&mut image, action, &payload)?),
|
||||
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {}
|
||||
|
||||
+72
-1
@@ -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() {
|
||||
|
||||
@@ -31,12 +31,15 @@ pub enum SecretKind {
|
||||
IrcSshPassword,
|
||||
IrcSshPassphrase,
|
||||
ProxyPassword,
|
||||
RdpPassword,
|
||||
RdpSshPassword,
|
||||
RdpSshPassphrase,
|
||||
}
|
||||
|
||||
impl SecretKind {
|
||||
/// Every kind, so deleting a profile can purge them all from the keychain. A new variant
|
||||
/// must be added here as well as to `as_str`.
|
||||
pub const ALL: [SecretKind; 12] = [
|
||||
pub const ALL: [SecretKind; 15] = [
|
||||
Self::Password,
|
||||
Self::Passphrase,
|
||||
Self::Totp,
|
||||
@@ -49,6 +52,9 @@ impl SecretKind {
|
||||
Self::IrcSshPassword,
|
||||
Self::IrcSshPassphrase,
|
||||
Self::ProxyPassword,
|
||||
Self::RdpPassword,
|
||||
Self::RdpSshPassword,
|
||||
Self::RdpSshPassphrase,
|
||||
];
|
||||
|
||||
/// The storage name, shared with the Go app.
|
||||
@@ -66,6 +72,9 @@ impl SecretKind {
|
||||
Self::IrcSshPassword => "irc-ssh-password",
|
||||
Self::IrcSshPassphrase => "irc-ssh-passphrase",
|
||||
Self::ProxyPassword => "proxy-password",
|
||||
Self::RdpPassword => "rdp-password",
|
||||
Self::RdpSshPassword => "rdp-ssh-password",
|
||||
Self::RdpSshPassphrase => "rdp-ssh-passphrase",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+130
-7
@@ -109,10 +109,10 @@ pub enum Event {
|
||||
stored: Option<String>,
|
||||
reply: mpsc::Sender<HostKeyDecision>,
|
||||
},
|
||||
VncReady {
|
||||
DesktopReady {
|
||||
session: String,
|
||||
},
|
||||
VncFrame {
|
||||
DesktopFrame {
|
||||
session: String,
|
||||
},
|
||||
/// The remote desktop's clipboard changed.
|
||||
@@ -154,6 +154,7 @@ enum Client {
|
||||
Github(Arc<github::Client>, Arc<AtomicBool>),
|
||||
Irc(Arc<irc::Client>, Option<Arc<SshClient>>),
|
||||
Vnc(Arc<clients::vnc::Client>, Option<Arc<SshClient>>),
|
||||
Rdp(Arc<clients::rdp::Client>, Option<Arc<SshClient>>),
|
||||
}
|
||||
|
||||
impl Client {
|
||||
@@ -178,6 +179,12 @@ impl Client {
|
||||
t.close();
|
||||
}
|
||||
}
|
||||
Client::Rdp(c, tunnel) => {
|
||||
c.close();
|
||||
if let Some(t) = tunnel {
|
||||
t.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,7 +243,10 @@ fn attempt_failed(state: &SessionSecrets, error: &str) {
|
||||
/// host-key failure).
|
||||
pub fn is_auth_error(msg: &str) -> bool {
|
||||
let m = msg.to_lowercase();
|
||||
["unable to authenticate", "authentication failed", "permission denied", "no supported methods"].iter().any(|p| m.contains(p))
|
||||
// The last two are CredSSP's (RDP with NLA) wording for a rejected password.
|
||||
["unable to authenticate", "authentication failed", "permission denied", "no supported methods", "logondenied", "logon failure"]
|
||||
.iter()
|
||||
.any(|p| m.contains(p))
|
||||
}
|
||||
|
||||
pub struct Manager {
|
||||
@@ -427,6 +437,7 @@ impl Manager {
|
||||
Protocol::Github => self.connect_github(id, epoch, p, prompt),
|
||||
Protocol::Irc => self.connect_irc(id, epoch, p, prompt),
|
||||
Protocol::Vnc => self.connect_vnc(id, epoch, p, prompt),
|
||||
Protocol::Rdp => self.connect_rdp(id, epoch, p, prompt),
|
||||
Protocol::Local => err("unsupported protocol \"local\"".into()),
|
||||
}
|
||||
}
|
||||
@@ -669,8 +680,11 @@ impl Manager {
|
||||
c.auth_methods = vec![if method.is_empty() { "publickey".into() } else { method.into() }];
|
||||
c.private_key_path = key.into();
|
||||
c.agent_auth = agent;
|
||||
let (pw_kind, pp_kind) =
|
||||
if kind == "vnc" { (SecretKind::VncSshPassword, SecretKind::VncSshPassphrase) } else { (SecretKind::IrcSshPassword, SecretKind::IrcSshPassphrase) };
|
||||
let (pw_kind, pp_kind) = match kind {
|
||||
"vnc" => (SecretKind::VncSshPassword, SecretKind::VncSshPassphrase),
|
||||
"rdp" => (SecretKind::RdpSshPassword, SecretKind::RdpSshPassphrase),
|
||||
_ => (SecretKind::IrcSshPassword, SecretKind::IrcSshPassphrase),
|
||||
};
|
||||
let password = (method == "password").then(|| self.secret_fn(id, profile_id, pw_kind, mode, prompt.clone()));
|
||||
let passphrase = self.secret_fn(id, profile_id, pp_kind, mode, prompt);
|
||||
let t = SshClient::new(&tp, Callbacks::noop());
|
||||
@@ -865,12 +879,12 @@ impl Manager {
|
||||
password,
|
||||
tunnel.clone(),
|
||||
cb,
|
||||
Arc::new(move || me.emit(Event::VncFrame { session: sid.clone() })),
|
||||
Arc::new(move || me.emit(Event::DesktopFrame { session: sid.clone() })),
|
||||
clip,
|
||||
);
|
||||
match c {
|
||||
Ok(c) => {
|
||||
self.emit(Event::VncReady { session: id.into() });
|
||||
self.emit(Event::DesktopReady { session: id.into() });
|
||||
Ok(Client::Vnc(c, tunnel))
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -882,6 +896,103 @@ impl Manager {
|
||||
}
|
||||
}
|
||||
|
||||
fn connect_rdp(self: &Arc<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
|
||||
let rc = p.rdp.clone().unwrap_or_else(RdpConfig::defaults);
|
||||
let port = if p.port == 0 { DEFAULT_RDP_PORT } else { p.port as u16 };
|
||||
let tunnel = if rc.ssh_tunnel {
|
||||
Some(self.tunnel(
|
||||
id,
|
||||
&p.id,
|
||||
&rc.ssh_host,
|
||||
rc.ssh_port,
|
||||
&rc.ssh_username,
|
||||
&rc.ssh_auth_method,
|
||||
&rc.ssh_private_key,
|
||||
rc.ssh_agent_auth,
|
||||
rc.ssh_secret_mode,
|
||||
"rdp",
|
||||
prompt.clone(),
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let r = self.rdp_client(id, epoch, p, &rc, port, tunnel.clone(), prompt);
|
||||
match r {
|
||||
Ok(c) => {
|
||||
self.emit(Event::DesktopReady { session: id.into() });
|
||||
Ok(Client::Rdp(c, tunnel))
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(t) = tunnel {
|
||||
t.close();
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn rdp_client(
|
||||
self: &Arc<Self>,
|
||||
id: &str,
|
||||
epoch: u64,
|
||||
p: &Profile,
|
||||
rc: &RdpConfig,
|
||||
port: u16,
|
||||
tunnel: Option<Arc<SshClient>>,
|
||||
prompt: Prompt,
|
||||
) -> Result<Arc<clients::rdp::Client>> {
|
||||
let timeout = Duration::from_secs(p.connect_timeout.max(1) as u64);
|
||||
let host = p.host.clone();
|
||||
let dial: clients::rdp::Dial = match tunnel {
|
||||
Some(t) => {
|
||||
let h = host.clone();
|
||||
Arc::new(move || t.open_tunnel(&h, port))
|
||||
}
|
||||
None => {
|
||||
let proxy = clients::proxy::Proxy {
|
||||
kind: rc.proxy_type.clone(),
|
||||
host: rc.proxy_host.clone(),
|
||||
port: rc.proxy_port as u16,
|
||||
username: rc.proxy_username.clone(),
|
||||
password: self.secrets.get(&SecretKind::ProxyPassword.key(&p.id)).unwrap_or_default(),
|
||||
};
|
||||
let h = host.clone();
|
||||
Arc::new(move || proxy.connect(&h, port, timeout))
|
||||
}
|
||||
};
|
||||
let check = self.host_key_check(id, rc.host_key_policy);
|
||||
let h = host.clone();
|
||||
let check_cert: clients::rdp::CertCheck = Arc::new(move |fp: &str| {
|
||||
check(&HostKeyInfo { host: h.clone(), port, key_type: "rdp-tls".into(), fingerprint: fp.into(), public_key_b64: String::new() })
|
||||
});
|
||||
// A 0×0 configured size follows the tab, which reports its pixel size like a terminal.
|
||||
let (width, height) = match (rc.width, rc.height) {
|
||||
(w, h) if w > 0 && h > 0 => (w as u16, h as u16),
|
||||
_ => self.with(id, |s| s.pending_resize).ok().flatten().unwrap_or((1280, 800)),
|
||||
};
|
||||
let password = self.secret_fn(id, &p.id, SecretKind::RdpPassword, rc.secret_mode, prompt)()?;
|
||||
let cb = self.terminal_callbacks(id, epoch);
|
||||
let (me, sid) = (self.clone(), id.to_string());
|
||||
let c = clients::rdp::Client::new(clients::rdp::Config {
|
||||
host,
|
||||
username: p.username.clone(),
|
||||
domain: rc.domain.clone(),
|
||||
password,
|
||||
width,
|
||||
height,
|
||||
allow_tls_only: rc.allow_tls_only,
|
||||
dial,
|
||||
check_cert,
|
||||
connect_timeout: timeout,
|
||||
on_frame: Arc::new(move || me.emit(Event::DesktopFrame { session: sid.clone() })),
|
||||
on_error: cb.on_error,
|
||||
on_close: cb.on_close,
|
||||
});
|
||||
c.connect()?;
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
// --- local shell --------------------------------------------------------------------
|
||||
|
||||
pub fn connect_local(self: &Arc<Self>, shell: &str) -> String {
|
||||
@@ -1187,6 +1298,11 @@ impl Manager {
|
||||
Client::Ssh(c) => c.resize(cols as u32, rows as u32),
|
||||
Client::Telnet(c) => c.resize(cols, rows),
|
||||
Client::Local(c) => c.resize(cols, rows),
|
||||
// For a remote desktop the "size" is the tab's size in pixels.
|
||||
Client::Rdp(c, _) => {
|
||||
c.resize(cols, rows);
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -1289,6 +1405,13 @@ impl Manager {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rdp(&self, id: &str) -> Result<Arc<clients::rdp::Client>> {
|
||||
match self.client(id)? {
|
||||
Client::Rdp(c, _) => Ok(c),
|
||||
_ => err(format!("sessions: session {id:?} is not an RDP connection")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn vnc(&self, id: &str) -> Result<Arc<clients::vnc::Client>> {
|
||||
match self.client(id)? {
|
||||
Client::Vnc(c, _) => Ok(c),
|
||||
|
||||
@@ -16,13 +16,14 @@ const MIGRATIONS: &[(i64, &str)] = &[
|
||||
(5, include_str!("../migrations/005_irc_protocol.sql")),
|
||||
(6, include_str!("../migrations/006_irc_channel_state.sql")),
|
||||
(7, include_str!("../migrations/007_serial_protocol.sql")),
|
||||
(8, include_str!("../migrations/008_rdp_protocol.sql")),
|
||||
];
|
||||
|
||||
const PROFILE_COLS: &str = "id, display_name, description, protocol, host, port, username,
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config, serial_config,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config, serial_config, rdp_config,
|
||||
created_at, updated_at";
|
||||
|
||||
/// Formats like Go's `time.RFC3339` (whole seconds, `Z` for UTC).
|
||||
@@ -81,8 +82,9 @@ fn scan_profile(r: &Row) -> rusqlite::Result<Profile> {
|
||||
vnc: parse_col(r.get(23)?),
|
||||
irc: parse_col(r.get(24)?),
|
||||
serial: parse_col(r.get(25)?),
|
||||
created_at: parse_time(&r.get::<_, String>(26)?),
|
||||
updated_at: parse_time(&r.get::<_, String>(27)?),
|
||||
rdp: parse_col(r.get(26)?),
|
||||
created_at: parse_time(&r.get::<_, String>(27)?),
|
||||
updated_at: parse_time(&r.get::<_, String>(28)?),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -136,7 +138,7 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn profile_params(p: &Profile) -> [Box<dyn rusqlite::ToSql>; 25] {
|
||||
fn profile_params(p: &Profile) -> [Box<dyn rusqlite::ToSql>; 26] {
|
||||
[
|
||||
Box::new(p.display_name.clone()),
|
||||
Box::new(p.description.clone()),
|
||||
@@ -163,6 +165,7 @@ impl Store {
|
||||
Box::new(json_col(&p.vnc)),
|
||||
Box::new(json_col(&p.irc)),
|
||||
Box::new(json_col(&p.serial)),
|
||||
Box::new(json_col(&p.rdp)),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -171,7 +174,7 @@ impl Store {
|
||||
args.extend(Self::profile_params(p));
|
||||
args.push(Box::new(fmt_time(&p.created_at)));
|
||||
args.push(Box::new(fmt_time(&p.updated_at)));
|
||||
let sql = format!("INSERT INTO profiles ({PROFILE_COLS}) VALUES ({})", vec!["?"; 28].join(","));
|
||||
let sql = format!("INSERT INTO profiles ({PROFILE_COLS}) VALUES ({})", vec!["?"; 29].join(","));
|
||||
self.db
|
||||
.lock()
|
||||
.execute(&sql, rusqlite::params_from_iter(args.iter().map(|b| b.as_ref())))
|
||||
@@ -191,7 +194,7 @@ impl Store {
|
||||
tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?,
|
||||
connect_timeout=?, read_timeout=?,
|
||||
terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?,
|
||||
ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?, irc_config=?, serial_config=?,
|
||||
ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?, irc_config=?, serial_config=?, rdp_config=?,
|
||||
updated_at=? WHERE id=?",
|
||||
rusqlite::params_from_iter(args.iter().map(|b| b.as_ref())),
|
||||
)
|
||||
@@ -482,11 +485,18 @@ mod tests {
|
||||
assert_eq!(s.get_profile(&ssh.id).unwrap(), ssh);
|
||||
s.update_profile(&ssh).unwrap();
|
||||
assert_eq!(s.get_profile(&ssh.id).unwrap(), ssh, "updates are lossless");
|
||||
let mut rdp = Profile::new(Protocol::Rdp);
|
||||
rdp.host = "win".into();
|
||||
rdp.username = "me".into();
|
||||
rdp.rdp.as_mut().unwrap().domain = "CORP".into();
|
||||
rdp.normalize();
|
||||
s.create_profile(&rdp).unwrap();
|
||||
assert_eq!(s.get_profile(&rdp.id).unwrap(), rdp.clone().with_secs());
|
||||
let mut serial = Profile::new(Protocol::Serial);
|
||||
serial.host = "/dev/ttyUSB0".into();
|
||||
serial.normalize();
|
||||
s.create_profile(&serial).unwrap();
|
||||
assert_eq!(s.list_profiles().unwrap().len(), 2);
|
||||
assert_eq!(s.list_profiles().unwrap().len(), 3);
|
||||
|
||||
s.save_irc_last_rooms(&ssh.id, &["#rust".into(), "#nexus".into()]).unwrap();
|
||||
assert_eq!(s.irc_last_rooms(&ssh.id).unwrap(), ["#rust", "#nexus"]);
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user