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
+4 -4
View File
@@ -259,7 +259,7 @@ impl App {
(Protocol::Raw, Some(p)) => View::Raw(Box::new(crate::raw_view::RawView::new(&info.id, p))),
(Protocol::Irc, Some(p)) => View::Irc(Box::new(crate::irc_view::IrcView::new(&info.id, p))),
(Protocol::Github, Some(p)) => View::Github(Box::new(crate::github_view::GithubView::new(&info.id, &p.display_name))),
(Protocol::Vnc, Some(p)) => View::Vnc(Box::new(crate::vnc_view::VncView::new(&info.id, p))),
(Protocol::Vnc | Protocol::Rdp, Some(p)) => View::Desktop(Box::new(crate::desktop_view::DesktopView::new(&info.id, p))),
(_, p) => {
let (scroll, size, cursor) = match p {
Some(p) => (p.terminal_scrollback, p.terminal_font_size, p.terminal_cursor_style.clone()),
@@ -522,12 +522,12 @@ impl App {
Event::HostKeyVerify { host, key_type, fingerprint, stored, reply, .. } => {
self.prompts.push_back(Prompt::HostKey { host, key_type, fingerprint, stored, reply });
}
Event::VncReady { session } => {
if let Some(View::Vnc(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
Event::DesktopReady { session } => {
if let Some(View::Desktop(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
v.on_ready();
}
}
Event::VncFrame { .. } => {}
Event::DesktopFrame { .. } => {}
Event::VncClipboard { text, .. } => self.remote_clipboard(&text),
}
}
@@ -1,14 +1,16 @@
//! VNC viewer: the remote framebuffer as a texture, with mouse, keyboard and clipboard input.
//! Toolbar after the web build's `vnc.js` (status, Ctrl+Alt+Del, clipboard), plus refresh and a
//! scale toggle.
//! Remote desktop viewer for VNC and RDP: the remote framebuffer as a texture, with mouse,
//! keyboard and clipboard input. Input is expressed in X keysyms and VNC pointer masks (VNC's own
//! terms); `Remote` translates them for RDP. Toolbar after the web build's `vnc.js` (status,
//! Ctrl+Alt+Del, clipboard), plus refresh and a scale toggle.
use crate::state::Shared;
use crate::theme::{self, Tokens};
use crate::widgets;
use egui::{Event, Key, Modifiers, Pos2, Rect, RichText, Sense, TextureHandle, Ui, vec2};
use egui_phosphor::regular as icon;
use nexus_core::clients::rdp::{self, MouseButton, MousePosition, Operation, Scancode, WheelRotations};
use nexus_core::clients::vnc::{self, keysym_for};
use nexus_core::models::Profile;
use nexus_core::models::{Profile, Protocol};
use std::sync::Arc;
const XK_SHIFT: u32 = 0xffe1;
@@ -19,12 +21,121 @@ const XK_DELETE: u32 = 0xffff;
/// Wheel distance (points) per remote scroll notch for pixel-precise wheels.
const NOTCH: f32 = 40.0;
pub struct VncView {
/// The connected client a view drives.
#[derive(Clone)]
enum Remote {
Vnc(Arc<vnc::Client>),
Rdp(Arc<rdp::Client>),
}
impl Remote {
fn framebuffer(&self) -> Arc<parking_lot::Mutex<vnc::Framebuffer>> {
match self {
Remote::Vnc(c) => c.framebuffer(),
Remote::Rdp(c) => c.framebuffer(),
}
}
/// `held` is the modifier keysyms down on the remote side (for RDP shortcuts).
fn key(&self, sym: u32, down: bool, held: &[u32]) {
match self {
Remote::Vnc(c) => {
let _ = c.key(sym, down);
}
Remote::Rdp(c) => {
if let Some(op) = rdp_key(sym, down, held.iter().any(|&k| matches!(k, XK_CONTROL | XK_ALT | XK_SUPER))) {
c.input(vec![op]);
}
}
}
}
/// Bits 0–2 left, middle, right; 3/4 a wheel notch up/down (VNC's pointer mask).
fn pointer(&self, x: u16, y: u16, mask: u8) {
match self {
Remote::Vnc(c) => {
let _ = c.pointer(x, y, mask);
}
Remote::Rdp(c) => {
let mut ops = vec![Operation::MouseMove(MousePosition { x, y })];
for (bit, b) in [(0, MouseButton::Left), (1, MouseButton::Middle), (2, MouseButton::Right)] {
ops.push(if mask & 1 << bit != 0 { Operation::MouseButtonPressed(b) } else { Operation::MouseButtonReleased(b) });
}
for (bit, units) in [(3, 120), (4, -120)] {
if mask & 1 << bit != 0 {
ops.push(Operation::WheelRotations(WheelRotations { is_vertical: true, rotation_units: units }));
}
}
c.input(ops);
}
}
}
}
/// An X keysym as an RDP key operation: named keys (and letters/digits pressed with Ctrl, Alt or
/// Win, so shortcuts work) as US-layout scancodes, everything else as a Unicode key.
fn rdp_key(sym: u32, down: bool, chord: bool) -> Option<Operation> {
let sc = |ext: bool, code: u8| {
Some(if down { Operation::KeyPressed(Scancode::from_u8(ext, code)) } else { Operation::KeyReleased(Scancode::from_u8(ext, code)) })
};
let named = match sym {
0xff1b => (false, 0x01),
0xff08 => (false, 0x0e),
0xff09 => (false, 0x0f),
0xff0d => (false, 0x1c),
0xffe1 => (false, 0x2a),
0xffe2 => (false, 0x36),
0xffe3 => (false, 0x1d),
0xffe4 => (true, 0x1d),
0xffe9 => (false, 0x38),
0xffea | 0xfe03 => (true, 0x38),
0xffeb => (true, 0x5b),
0xffec => (true, 0x5c),
0xffe5 => (false, 0x3a),
0xff50 => (true, 0x47),
0xff51 => (true, 0x4b),
0xff52 => (true, 0x48),
0xff53 => (true, 0x4d),
0xff54 => (true, 0x50),
0xff55 => (true, 0x49),
0xff56 => (true, 0x51),
0xff57 => (true, 0x4f),
0xff63 => (true, 0x52),
0xffff => (true, 0x53),
f @ 0xffbe..=0xffc7 => (false, 0x3b + (f - 0xffbe) as u8),
0xffc8 => (false, 0x57),
0xffc9 => (false, 0x58),
_ => (false, 0),
};
if named.1 != 0 {
return sc(named.0, named.1);
}
let ch = char::from_u32(if sym >= 0x0100_0000 { sym - 0x0100_0000 } else { sym })?;
if chord {
const LETTERS: &[u8; 26] = b"\x1e\x30\x2e\x20\x12\x21\x22\x23\x17\x24\x25\x26\x32\x31\x18\x19\x10\x13\x1f\x14\x16\x2f\x11\x2d\x15\x2c";
let lower = ch.to_ascii_lowercase();
match lower {
'a'..='z' => return sc(false, LETTERS[(lower as u8 - b'a') as usize]),
'1'..='9' => return sc(false, lower as u8 - b'1' + 0x02),
'0' => return sc(false, 0x0b),
' ' => return sc(false, 0x39),
_ => {}
}
}
Some(if down { Operation::UnicodeKeyPressed(ch) } else { Operation::UnicodeKeyReleased(ch) })
}
pub struct DesktopView {
pub session: String,
protocol: Protocol,
view_only: bool,
scale_to_fit: bool,
/// RDP: ask the server to match the tab's size.
dynamic_resolution: bool,
/// Last tab size reported (pixels) and when it changed, for debounced RDP resizes.
reported: ([u32; 2], [u32; 2], f64),
ready: bool,
client: Option<Arc<vnc::Client>>,
client: Option<Remote>,
texture: Option<TextureHandle>,
fb_size: [usize; 2],
name: String,
@@ -105,13 +216,20 @@ fn to_remote(p: Pos2, img: Rect, fb: [usize; 2]) -> (u16, u16) {
(x as u16, y as u16)
}
impl VncView {
pub fn new(session: &str, profile: &Profile) -> VncView {
let cfg = profile.vnc.clone().unwrap_or_default();
VncView {
impl DesktopView {
pub fn new(session: &str, profile: &Profile) -> DesktopView {
let (view_only, scale_to_fit, dynamic_resolution) = match (&profile.vnc, &profile.rdp) {
(Some(v), _) if profile.protocol == Protocol::Vnc => (v.view_only, v.scale_to_fit, false),
(_, Some(r)) => (false, r.scale_to_fit, r.dynamic_resolution && r.width == 0),
_ => (false, true, false),
};
DesktopView {
session: session.into(),
view_only: cfg.view_only,
scale_to_fit: cfg.scale_to_fit,
protocol: profile.protocol,
view_only,
scale_to_fit,
dynamic_resolution,
reported: ([0, 0], [0, 0], 0.0),
ready: false,
client: None,
texture: None,
@@ -125,7 +243,7 @@ impl VncView {
}
}
/// Called on `Event::VncReady` (the client is connected; fetch it via `s.svc.sessions.vnc(id)`).
/// Called on `Event::DesktopReady` (the client is connected; the view fetches it next frame).
pub fn on_ready(&mut self) {
self.ready = true;
self.client = None;
@@ -133,7 +251,7 @@ impl VncView {
fn key(&self, sym: u32, down: bool) {
if let Some(c) = &self.client {
let _ = c.key(sym, down);
c.key(sym, down, &self.held);
}
}
@@ -161,23 +279,57 @@ impl VncView {
if self.buttons != 0
&& let Some(c) = &self.client
{
let _ = c.pointer(self.last_pos.0, self.last_pos.1, 0);
c.pointer(self.last_pos.0, self.last_pos.1, 0);
}
self.buttons = 0;
}
/// VNC: sets the remote clipboard. RDP (no clipboard channel yet): types the text.
fn paste(&self, s: &mut Shared, text: &str) {
if let Some(c) = &self.client
&& let Err(e) = c.send_clipboard(text)
{
s.error(format!("Clipboard: {e}"));
match &self.client {
Some(Remote::Vnc(c)) => {
if let Err(e) = c.send_clipboard(text) {
s.error(format!("Clipboard: {e}"));
}
}
Some(Remote::Rdp(c)) => c.input(
text.chars()
.filter(|c| !c.is_control() || *c == '\n')
.flat_map(|ch| [Operation::UnicodeKeyPressed(ch), Operation::UnicodeKeyReleased(ch)])
.collect(),
),
None => {}
}
}
/// RDP with dynamic resolution: report the tab's pixel size once it has settled.
fn report_size(&mut self, ui: &Ui, s: &Shared, area: Rect) {
if self.protocol != Protocol::Rdp || !self.dynamic_resolution {
return;
}
let ppp = ui.ctx().pixels_per_point();
let size = [(area.width() * ppp) as u32 & !1, (area.height() * ppp) as u32];
let now = ui.input(|i| i.time);
if size != self.reported.1 {
self.reported.1 = size;
self.reported.2 = now;
}
// ponytail: 0.4 s debounce; each resize is a server-side desktop reconfiguration.
if size != self.reported.0 && now - self.reported.2 > 0.4 && size[0] >= 200 && size[1] >= 200 {
self.reported.0 = size;
let _ = s.svc.sessions.resize(&self.session, size[0].min(8192) as u16, size[1].min(8192) as u16);
} else if size != self.reported.0 {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(450));
}
}
pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) {
if self.ready && self.client.is_none() {
self.client = s.svc.sessions.vnc(&self.session).ok();
if let Some(c) = &self.client {
self.client = match self.protocol {
Protocol::Rdp => s.svc.sessions.rdp(&self.session).ok().map(Remote::Rdp),
_ => s.svc.sessions.vnc(&self.session).ok().map(Remote::Vnc),
};
if let Some(Remote::Vnc(c)) = &self.client {
self.name = c.desktop_name();
}
}
@@ -186,6 +338,7 @@ impl VncView {
let t = Tokens::get(ui.ctx());
let area = ui.available_rect_before_wrap();
self.report_size(ui, s, area);
ui.painter().rect_filled(area, 0.0, t.canvas);
let Some(tex) = self.texture.clone().filter(|_| self.ready) else {
ui.allocate_rect(area, Sense::hover());
@@ -206,7 +359,7 @@ impl VncView {
self.draw(ui, s, &tex, img);
} else {
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(area));
egui::ScrollArea::both().id_salt(("vnc", &self.session)).auto_shrink([false, false]).show(&mut c, |ui| {
egui::ScrollArea::both().id_salt(("desktop", &self.session)).auto_shrink([false, false]).show(&mut c, |ui| {
let (img, _) = ui.allocate_exact_size(size, Sense::hover());
self.draw(ui, s, &tex, img);
});
@@ -230,7 +383,7 @@ impl VncView {
self.fb_size = size;
match &mut self.texture {
Some(t) => t.set(img, egui::TextureOptions::LINEAR),
None => self.texture = Some(ui.ctx().load_texture(format!("vnc-{}", self.session), img, egui::TextureOptions::LINEAR)),
None => self.texture = Some(ui.ctx().load_texture(format!("desktop-{}", self.session), img, egui::TextureOptions::LINEAR)),
}
}
@@ -262,16 +415,18 @@ impl VncView {
if widgets::icon_button(ui, g, tip).clicked() {
self.scale_to_fit = !self.scale_to_fit;
}
if widgets::icon_button(ui, icon::ARROW_CLOCKWISE, "Refresh the screen").clicked()
&& let Some(c) = &self.client
if let Some(Remote::Vnc(c)) = &self.client
&& widgets::icon_button(ui, icon::ARROW_CLOCKWISE, "Refresh the screen").clicked()
{
let _ = c.request_full_update();
}
if !self.view_only {
if widgets::button(ui, icon::CLIPBOARD_TEXT, "Paste Clipboard")
.on_hover_text("Send the local clipboard to the remote desktop")
.clicked()
{
let tip = if self.protocol == Protocol::Rdp {
"Type the local clipboard's text into the remote desktop"
} else {
"Send the local clipboard to the remote desktop"
};
if widgets::button(ui, icon::CLIPBOARD_TEXT, "Paste Clipboard").on_hover_text(tip).clicked() {
match arboard::Clipboard::new().and_then(|mut c| c.get_text()) {
Ok(text) if !text.is_empty() => {
self.paste(s, &text);
@@ -292,7 +447,7 @@ impl VncView {
fn draw(&mut self, ui: &mut Ui, s: &mut Shared, tex: &TextureHandle, img: Rect) {
ui.painter().image(tex.id(), img, Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), egui::Color32::WHITE);
let id = egui::Id::new(("vnc-input", &self.session));
let id = egui::Id::new(("desktop-input", &self.session));
let resp = ui.interact(img, id, Sense::click_and_drag());
if resp.clicked() || resp.drag_started() || resp.secondary_clicked() || resp.middle_clicked() {
resp.request_focus();
@@ -328,7 +483,7 @@ impl VncView {
let buttons = if resp.hovered() || resp.dragged() || self.buttons != 0 { button_mask(left, middle, right) } else { 0 };
let at = to_remote(pos, img, self.fb_size);
if at != self.last_pos || buttons != self.buttons {
let _ = c.pointer(at.0, at.1, buttons);
c.pointer(at.0, at.1, buttons);
self.last_pos = at;
self.buttons = buttons;
}
@@ -336,8 +491,8 @@ impl VncView {
self.wheel += scroll;
while self.wheel.abs() >= 1.0 {
let bit = if self.wheel > 0.0 { 1 << 3 } else { 1 << 4 };
let _ = c.pointer(at.0, at.1, buttons | bit);
let _ = c.pointer(at.0, at.1, buttons);
c.pointer(at.0, at.1, buttons | bit);
c.pointer(at.0, at.1, buttons);
self.wheel -= self.wheel.signum();
}
}
@@ -394,6 +549,20 @@ mod tests {
assert_eq!(modifier_syms(Modifiers::CTRL | Modifiers::SHIFT), vec![XK_SHIFT, XK_CONTROL]);
}
#[test]
fn keysyms_map_to_rdp_input() {
let sc = |ext, code| format!("{:?}", Some(Operation::KeyPressed(Scancode::from_u8(ext, code))));
let k = |sym, down, chord| format!("{:?}", rdp_key(sym, down, chord));
assert_eq!(k(0xff0d, true, false), sc(false, 0x1c), "Enter");
assert_eq!(k(0xff51, true, false), sc(true, 0x4b), "Left is extended");
assert_eq!(k(0xffc9, true, false), sc(false, 0x58), "F12");
assert_eq!(k(XK_DELETE, true, false), sc(true, 0x53));
assert_eq!(k(u32::from(b'c'), true, false), format!("{:?}", Some(Operation::UnicodeKeyPressed('c'))), "typed text is Unicode");
assert_eq!(k(u32::from(b'c'), true, true), sc(false, 0x2e), "Ctrl+C sends the C scancode");
assert_eq!(k(u32::from(b'1'), false, true), format!("{:?}", Some(Operation::KeyReleased(Scancode::from_u8(false, 0x02)))));
assert_eq!(k(0x0100_20ac, true, false), format!("{:?}", Some(Operation::UnicodeKeyPressed('€'))));
}
#[test]
fn pointer_mapping() {
assert_eq!(button_mask(true, false, true), 0b101);
+1 -1
View File
@@ -4,6 +4,7 @@
mod app;
mod chrome;
mod desktop_view;
mod forwards;
mod github_view;
mod irc_view;
@@ -25,7 +26,6 @@ mod theme;
mod transfer;
mod triggers;
mod views;
mod vnc_view;
mod widgets;
use nexus_core::config::AppConfig;
+67
View File
@@ -156,6 +156,9 @@ impl ProfileEditor {
if proto != Protocol::Vnc {
p.vnc = None;
}
if proto != Protocol::Rdp {
p.rdp = None;
}
if proto != Protocol::Irc {
p.irc = None;
}
@@ -223,6 +226,7 @@ impl ProfileEditor {
Protocol::Raw => self.raw(ui, &errs),
Protocol::Github => self.github(ui, &errs),
Protocol::Vnc => self.vnc(ui, &errs),
Protocol::Rdp => self.rdp(ui, &errs),
Protocol::Irc => self.irc(ui, &errs),
Protocol::Serial => self.serial(ui, &errs),
Protocol::Local => {}
@@ -678,6 +682,67 @@ impl ProfileEditor {
});
}
fn rdp(&mut self, ui: &mut Ui, errs: &[ValidationError]) {
let Self { p, keys, key_checks, secrets, .. } = self;
let c = p.rdp.get_or_insert_with(RdpConfig::defaults);
section(ui, "RDP", |ui| {
text(ui, "Domain", &mut c.domain, "Optional (e.g. CORP)");
mode(ui, "Password Storage", &mut c.secret_mode);
if c.secret_mode == SecretMode::Saved {
secret(ui, "Password", secrets, SecretKind::RdpPassword, "Leave blank to keep the saved password");
}
let opts: Vec<_> = HostKeyPolicy::ALL.iter().map(|k| (*k, k.label())).collect();
combo(ui, "Certificate Policy", &mut c.host_key_policy, &opts);
check(
ui,
"Allow servers without NLA",
&mut c.allow_tls_only,
"Plain TLS logon, e.g. xrdp. Less secure: the session is set up before you authenticate.",
);
});
section(ui, "Display", |ui| {
check(ui, "Scale to Fit", &mut c.scale_to_fit, "");
num(ui, "Width (0 = fit tab)", &mut c.width, 0..=8192);
err(ui, errs, "rdp.width");
num(ui, "Height (0 = fit tab)", &mut c.height, 0..=8192);
err(ui, errs, "rdp.height");
if c.width == 0 {
check(ui, "Resize With Tab", &mut c.dynamic_resolution, "Ask the server to match the tab's size as it changes");
}
});
section(ui, "Proxy", |ui| {
combo_s(ui, "Proxy Type", &mut c.proxy_type, &[("none", "None (direct connection)"), ("socks5", "SOCKS5"), ("http", "HTTP CONNECT")]);
err(ui, errs, "rdp.proxyType");
if c.proxy_type != "none" {
text(ui, "Proxy Host", &mut c.proxy_host, "proxy.example.com");
err(ui, errs, "rdp.proxyHost");
num(ui, "Proxy Port", &mut c.proxy_port, 0..=65535);
text(ui, "Proxy Username", &mut c.proxy_username, "Optional");
secret(ui, "Proxy Password", secrets, SecretKind::ProxyPassword, "Optional; leave blank to keep the saved password");
}
});
section(ui, "SSH Tunnel", |ui| {
tunnel(
ui,
"rdp",
Tunnel {
on: &mut c.ssh_tunnel,
host: &mut c.ssh_host,
port: &mut c.ssh_port,
user: &mut c.ssh_username,
auth: &mut c.ssh_auth_method,
key: &mut c.ssh_private_key,
agent: &mut c.ssh_agent_auth,
mode: &mut c.ssh_secret_mode,
},
keys,
key_checks,
secrets,
errs,
);
});
}
fn irc(&mut self, ui: &mut Ui, errs: &[ValidationError]) {
let Self { p, keys, key_checks, secrets, rooms, .. } = self;
let port = &mut p.port;
@@ -1171,6 +1236,8 @@ fn tunnel(ui: &mut Ui, kind: &str, c: Tunnel, keys: &[String], checks: &mut KeyC
let (l, k) = match (kind, c.auth.as_str()) {
("vnc", "password") => ("SSH Password", SecretKind::VncSshPassword),
("vnc", _) => ("SSH Key Passphrase", SecretKind::VncSshPassphrase),
("rdp", "password") => ("SSH Password", SecretKind::RdpSshPassword),
("rdp", _) => ("SSH Key Passphrase", SecretKind::RdpSshPassphrase),
(_, "password") => ("SSH Password", SecretKind::IrcSshPassword),
_ => ("SSH Key Passphrase", SecretKind::IrcSshPassphrase),
};
+3 -3
View File
@@ -9,7 +9,7 @@ use egui_phosphor::regular as icon;
use nexus_core::clients::serial;
use nexus_core::models::{Profile, Protocol};
const PROTOCOLS: [Protocol; 5] = [Protocol::Ssh, Protocol::Telnet, Protocol::Raw, Protocol::Irc, Protocol::Serial];
const PROTOCOLS: [Protocol; 6] = [Protocol::Ssh, Protocol::Rdp, Protocol::Telnet, Protocol::Raw, Protocol::Irc, Protocol::Serial];
pub struct QuickConnect {
open: bool,
@@ -67,7 +67,7 @@ impl QuickConnect {
if self.protocol != Protocol::Serial {
p.port = self.port;
}
if matches!(self.protocol, Protocol::Ssh | Protocol::Irc) {
if matches!(self.protocol, Protocol::Ssh | Protocol::Rdp | Protocol::Irc) {
p.username = self.username.trim().into();
}
if let Some(c) = &mut p.irc
@@ -134,7 +134,7 @@ impl QuickConnect {
label(ui, "Port");
ui.add(DragValue::new(&mut self.port).range(1..=65535).speed(0.25));
}
if matches!(self.protocol, Protocol::Ssh | Protocol::Irc) {
if matches!(self.protocol, Protocol::Ssh | Protocol::Rdp | Protocol::Irc) {
label(ui, "Username");
ui.add(TextEdit::singleline(&mut self.username).hint_text("root").desired_width(f32::INFINITY));
}
+1 -1
View File
@@ -339,7 +339,7 @@ pub fn pane(app: &mut App, ui: &mut Ui, id: &str, rect: Rect) {
View::Raw(v) => v.ui(ui, &mut app.s),
View::Irc(v) => v.ui(ui, &mut app.s),
View::Github(v) => v.ui(ui, &mut app.s),
View::Vnc(v) => v.ui(ui, &mut app.s),
View::Desktop(v) => v.ui(ui, &mut app.s),
}
let state = app.s.session_state(id).unwrap_or(pane.info.state);
let local_exited = pane.local_exited;
+5 -2
View File
@@ -1,17 +1,18 @@
//! How a session is drawn. Terminal-style protocols share `TermView`; the rest have their own.
use crate::desktop_view::DesktopView;
use crate::github_view::GithubView;
use crate::irc_view::IrcView;
use crate::raw_view::RawView;
use crate::terminal::TermView;
use crate::vnc_view::VncView;
pub enum View {
Terminal(Box<TermView>),
Raw(Box<RawView>),
Irc(Box<IrcView>),
Github(Box<GithubView>),
Vnc(Box<VncView>),
/// VNC or RDP.
Desktop(Box<DesktopView>),
}
/// Protocol badge colour (the web build's --protocol-* tokens).
@@ -24,6 +25,7 @@ pub fn protocol_color(p: nexus_core::models::Protocol) -> egui::Color32 {
Raw => rgb(0xab, 0x47, 0xbc),
Github => rgb(0x7c, 0x8a, 0xff),
Vnc => rgb(0xe8, 0x5d, 0x4a),
Rdp => rgb(0x26, 0xa6, 0x9a),
Irc => rgb(0xd8, 0xb2, 0x4c),
Local => rgb(0x8b, 0xc3, 0x4a),
Serial => rgb(0x4f, 0xa3, 0xe0),
@@ -39,6 +41,7 @@ pub fn protocol_badge(p: nexus_core::models::Protocol) -> &'static str {
Raw => "RAW",
Github => "GitHub",
Vnc => "VNC",
Rdp => "RDP",
Irc => "IRC",
Local => "LOCAL",
Serial => "SERIAL",
+56
View File
@@ -0,0 +1,56 @@
-- Migration 008: RDP protocol support
-- Adds an rdp_config column and drops the protocol CHECK constraint: protocol ids are validated
-- in code (Protocol::from_id), and each new protocol needed a table rebuild only to extend it.
CREATE TABLE IF NOT EXISTS profiles_new (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT DEFAULT '',
protocol TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT DEFAULT '',
tags TEXT DEFAULT '[]',
folder TEXT DEFAULT '',
environment TEXT DEFAULT '',
notes TEXT DEFAULT '',
favourite INTEGER DEFAULT 0,
last_connected TEXT,
connect_timeout INTEGER DEFAULT 30,
read_timeout INTEGER DEFAULT 0,
terminal_font_family TEXT DEFAULT '',
terminal_font_size INTEGER DEFAULT 14,
terminal_scrollback INTEGER DEFAULT 10000,
terminal_cursor_style TEXT DEFAULT 'block',
ssh_config TEXT,
telnet_config TEXT,
raw_config TEXT,
github_config TEXT,
vnc_config TEXT,
irc_config TEXT,
serial_config TEXT,
rdp_config TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT OR IGNORE INTO profiles_new (
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, rdp_config, created_at,
updated_at
)
SELECT
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, NULL, created_at,
updated_at
FROM profiles;
DROP TABLE profiles;
ALTER TABLE profiles_new RENAME TO profiles;
INSERT INTO schema_version (version, applied_at) VALUES (8, datetime('now'));
+10 -6
View File
@@ -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
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() {
+10 -1
View File
@@ -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
View File
@@ -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),
+17 -7
View File
@@ -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"]);
+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]