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
+3 -3
View File
@@ -1,17 +1,17 @@
# Nexus Terminal
**A native desktop client for SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub and local terminals — with secure credential handling and a focused, multi-session workflow.**
**A native desktop client for SSH, Telnet, RAW TCP, Serial, VNC, RDP, IRC, GitHub and local terminals — with secure credential handling and a focused, multi-session workflow.**
Nexus Terminal helps you manage many connections at once, keep terminal work organised, and move files with confidence. It is a single Rust application with no webview, so it starts quickly and stays responsive under heavy terminal output.
## Highlights
- SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub and local terminal sessions in one app
- SSH, Telnet, RAW TCP, Serial, VNC, RDP, IRC, GitHub and local terminal sessions in one app
- Tabbed sessions with split panes, pinning and drag-to-reorder
- SFTP browser with drag-and-drop uploads and a permission-aware directory picker
- SSH jump hosts, SOCKS5/HTTP proxies, local and remote port forwards, agent forwarding and TOTP auto-fill
- Serial consoles with full line settings (baud, data bits, parity, stop bits, flow control)
- Native VNC viewer, optionally tunnelled over SSH
- Native VNC and RDP viewers; RDP with NLA, certificate pinning, and SOCKS5/HTTP proxy or SSH tunnel routes
- Disconnected overlay with one-click reconnect, and auto-reconnect with backoff
- Broadcast input, snippets, trigger rules and terminal search
- Session recording (asciicast v2) and playback
+13 -4
View File
@@ -1,6 +1,6 @@
# Nexus Terminal Overview
Nexus Terminal is a native desktop connection workspace for people who manage servers, network services, serial devices, remote desktops, chat networks and repositories every day. It brings SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub and local shell sessions into one focused app with secure credential handling, multi-session organisation, file transfer, automation and recording built in.
Nexus Terminal is a native desktop connection workspace for people who manage servers, network services, serial devices, remote desktops, chat networks and repositories every day. It brings SSH, Telnet, RAW TCP, Serial, VNC, RDP, IRC, GitHub and local shell sessions into one focused app with secure credential handling, multi-session organisation, file transfer, automation and recording built in.
It is a single Rust application drawn with egui: there is no webview, no bundled browser engine and no runtime to install. Terminal output goes straight from the network into the terminal emulator, so large outputs stay responsive.
@@ -25,7 +25,7 @@ Use Nexus Terminal when you need to:
- **Telnet** for legacy devices and services.
- **RAW TCP** for direct socket interaction, protocol testing and device troubleshooting.
- **Serial** for USB-serial adapters, console cables, development boards and network gear.
- **VNC** for remote desktop access, with a native viewer.
- **VNC** and **RDP** for remote desktop access, with native viewers.
- **IRC** for chat networks, with rooms, users and an optional SSH tunnel.
- **GitHub** for repository browsing and download workflows.
- **Local terminal** for opening a shell on the local machine.
@@ -140,6 +140,15 @@ Use Nexus Terminal when you need to:
- Clear hints for permission-denied and busy ports.
- Works with everything a terminal session supports: recording, triggers, broadcast, snippets, find and split panes.
### RDP capabilities
- Native RDP viewer (IronRDP) with Network Level Authentication (CredSSP/NTLM) and optional TLS-only logon for servers such as xrdp.
- Server certificate pinned by fingerprint in Known Hosts, checked before credentials are sent.
- Direct, SOCKS5, HTTP CONNECT or SSH-tunnelled connections.
- Scale-to-fit or 1:1, a fixed resolution or one that follows the tab (Display Control).
- Keyboard (scancodes for keys and shortcuts, Unicode for text), mouse and wheel; Ctrl+Alt+Del; type the clipboard into the session.
- Password storage modes and domain logon.
### VNC capabilities
- Native VNC viewer inside the Nexus Terminal workspace (RFB 3.3–3.8; Raw, CopyRect, Hextile, ZRLE and Tight encodings).
@@ -227,7 +236,7 @@ Nexus Terminal: one native desktop workspace for SSH, files, sockets, serial con
### Suggested subheadline
Manage SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub and local shell sessions from a fast tabbed interface with secure credentials, SFTP file transfer, split panes, snippets, recordings and automation built in.
Manage SSH, Telnet, RAW TCP, Serial, VNC, RDP, IRC, GitHub and local shell sessions from a fast tabbed interface with secure credentials, SFTP file transfer, split panes, snippets, recordings and automation built in.
### Short product description
@@ -235,7 +244,7 @@ Nexus Terminal helps developers, operators, support engineers, network and hardw
### Product page feature bullets
- All-in-one connection workspace for SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub and local shells.
- All-in-one connection workspace for SSH, Telnet, RAW TCP, Serial, VNC, RDP, IRC, GitHub and local shells.
- Native and fast: no webview, with a terminal that stays responsive under heavy output.
- Multi-session interface with tabs, pinned sessions, split panes, reconnect and broadcast input.
- Rich SSH support including agent auth and forwarding, TOTP, jump hosts, proxies, port forwarding, startup commands, login scripts and host key policies.
+5 -1
View File
@@ -1,6 +1,6 @@
# Nexus Terminal Quick Start
Nexus Terminal is a native desktop client for SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub and local terminal sessions. This page is a shareable quick start for everyday use.
Nexus Terminal is a native desktop client for SSH, Telnet, RAW TCP, Serial, VNC, RDP, IRC, GitHub and local terminal sessions. This page is a shareable quick start for everyday use.
## Get connected in minutes
@@ -32,6 +32,10 @@ Create a **Serial** profile, pick the device from the list (click refresh after
Create a **VNC** profile for remote desktop sessions. Use the SSH tunnel option when possible, especially outside trusted networks. Direct VNC is available for isolated or trusted networks, but classic VNC traffic is not encrypted, so Nexus Terminal asks before connecting without a tunnel.
## RDP remote desktops
Create an **RDP** profile with the host, your username and, if needed, a domain. Windows servers use Network Level Authentication by default; for xrdp and other servers without it, tick **Allow servers without NLA**. The first connection shows the server certificate's fingerprint to trust. Use the **Proxy** or **SSH Tunnel** sections to reach desktops behind a jump host or proxy.
## File transfers on SSH
Click the **folder** icon on an SSH tab to open the SFTP browser. You can browse, upload, download, rename and delete files, create folders and bookmark locations.
+13
View File
@@ -162,6 +162,19 @@ The viewer's toolbar shows the connection state and desktop size, and has button
Use SSH tunnelling for VNC whenever possible. If a VNC profile has no tunnel, Nexus Terminal warns that the traffic is unencrypted and offers **Connect Anyway**, **Edit Profile** or **Cancel**.
### RDP
- **RDP**: domain (optional), password storage mode, **Certificate Policy** and **Allow servers without NLA**. The username, host and port (3389) are the profile's own.
- **Display**: **Scale to Fit**, a fixed **Width** and **Height** (0 × 0 fits the tab) and **Resize With Tab**.
- **Proxy**: **SOCKS5** or **HTTP CONNECT**, with an optional username and password.
- **SSH Tunnel**: carry RDP through SSH, as for VNC. Use either a proxy or a tunnel, not both.
Nexus Terminal logs on with Network Level Authentication (CredSSP, NTLM), so your password is checked before a session is set up. Servers without NLA, such as xrdp, need **Allow servers without NLA**; the password is then passed to the server's own logon. Kerberos and smart cards are not supported.
RDP servers usually present self-signed certificates, so the certificate is pinned like an SSH host key: its SHA-256 fingerprint is shown the first time and stored in **Known Hosts**, and a changed certificate offers Reject, Accept Once or Accept & Update. The check happens before any credentials are sent.
The viewer works like the VNC one: click the desktop to give it keyboard focus, **Ctrl+Alt+Del** sends the key combination, and **Paste Clipboard** types your clipboard's text into the remote desktop (clipboard sharing is not supported yet). With **Resize With Tab**, the remote desktop follows the tab's size on servers that support it (Windows); otherwise the picture is scaled.
### IRC
- **IRC**: nick, real name, **Use TLS** (port 6697 for most TLS networks) and an optional server password.
+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]