diff --git a/rust/app/src/main.rs b/rust/app/src/main.rs index 9bc3c0f..1388432 100644 --- a/rust/app/src/main.rs +++ b/rust/app/src/main.rs @@ -175,15 +175,14 @@ fn init_logging(data_dir: &std::path::Path) { /// files, and on Linux the process is non-dumpable (also blocks ptrace attach by other processes /// of the same user). Debug builds skip this so they can still be debugged. fn harden_process() { - if cfg!(debug_assertions) { - return; - } #[cfg(unix)] - // SAFETY: plain syscalls on this process with valid arguments; failure is harmless. - unsafe { - let none = libc::rlimit { rlim_cur: 0, rlim_max: 0 }; - libc::setrlimit(libc::RLIMIT_CORE, &none); - #[cfg(target_os = "linux")] - libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0); + if !cfg!(debug_assertions) { + // SAFETY: plain syscalls on this process with valid arguments; failure is harmless. + unsafe { + let none = libc::rlimit { rlim_cur: 0, rlim_max: 0 }; + libc::setrlimit(libc::RLIMIT_CORE, &none); + #[cfg(target_os = "linux")] + libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0); + } } } diff --git a/rust/core/src/clients/local.rs b/rust/core/src/clients/local.rs index 992d344..10dcc2f 100644 --- a/rust/core/src/clients/local.rs +++ b/rust/core/src/clients/local.rs @@ -171,6 +171,9 @@ impl LocalShell { } } + /// macOS has no cheap cwd lookup (it's an `lsof` run), so the directory is sampled after Enter: + /// the first output after Enter is usually the shell echoing the command, before a `cd` has + /// run, so sample shortly afterwards and again once the command has had time to finish. fn capture_cwd_in_background(self: &Arc) { let Some(pid) = self.state.lock().0.as_ref().and_then(|r| r.pid) else { return }; if self.capturing.swap(true, Ordering::SeqCst) { @@ -178,9 +181,12 @@ impl LocalShell { } let me = self.clone(); std::thread::spawn(move || { - // A failed lookup (the shell already exited) keeps the last known directory. - if let Some(dir) = process_cwd(pid).filter(|d| d.is_dir()) { - me.state.lock().1 = dir; + for delay in [150, 850] { + std::thread::sleep(std::time::Duration::from_millis(delay)); + // A failed lookup (the shell already exited) keeps the last known directory. + if let Some(dir) = process_cwd(pid).filter(|d| d.is_dir()) { + me.state.lock().1 = dir; + } } me.capturing.store(false, Ordering::SeqCst); }); @@ -223,6 +229,11 @@ mod tests { std::thread::sleep(Duration::from_millis(20)); } sh.resize(100, 30).unwrap(); + // macOS samples the directory shortly after a command runs (Linux reads it live). + while sh.working_directory() != std::path::Path::new("/") { + assert!(t.elapsed() < Duration::from_secs(5), "cwd still {:?}", sh.working_directory()); + std::thread::sleep(Duration::from_millis(20)); + } sh.write(b"exit\n").unwrap(); while *closed.lock() == 0 { assert!(t.elapsed() < Duration::from_secs(5)); diff --git a/rust/core/src/clients/rdp.rs b/rust/core/src/clients/rdp.rs index 8f8bee1..9e1faac 100644 --- a/rust/core/src/clients/rdp.rs +++ b/rust/core/src/clients/rdp.rs @@ -205,6 +205,7 @@ impl Client { } if self.cfg.shared_folder.is_some() || self.cfg.printer.is_some() { let mut devices = super::rdp_devices::Devices::default(); + #[cfg_attr(not(unix), allow(unused_mut))] let mut drives = vec![]; #[cfg(unix)] if let Some((folder, name)) = &self.cfg.shared_folder { diff --git a/rust/core/src/clients/rdp_devices.rs b/rust/core/src/clients/rdp_devices.rs index 0b1433d..e4fc0bb 100644 --- a/rust/core/src/clients/rdp_devices.rs +++ b/rust/core/src/clients/rdp_devices.rs @@ -14,9 +14,11 @@ use ironrdp::core::impl_as_any; use ironrdp::pdu::PduResult; use ironrdp::rdpdr::RdpdrBackend; use ironrdp::rdpdr::pdu::RdpdrPdu; +#[cfg(unix)] +use ironrdp::rdpdr::pdu::efs::FileInformationClass; use ironrdp::rdpdr::pdu::efs::{ ClientDriveQueryDirectoryResponse, ClientDriveSetInformationResponse, DeviceCloseResponse, DeviceControlRequest, DeviceCreateResponse, DeviceIoResponse, - DeviceWriteResponse, FileInformationClass, Information, NtStatus, PrinterIoRequest, ServerDeviceAnnounceResponse, ServerDriveIoRequest, + DeviceWriteResponse, Information, NtStatus, PrinterIoRequest, ServerDeviceAnnounceResponse, ServerDriveIoRequest, }; use ironrdp::rdpdr::pdu::esc::{ScardCall, ScardIoCtlCode}; use ironrdp::svc::SvcMessage; @@ -24,7 +26,9 @@ use ironrdp::svc::SvcMessage; use ironrdp_rdpdr_native::backend::NixRdpdrBackend; use std::collections::HashMap; use std::io::Write; -use std::path::{Path, PathBuf}; +#[cfg(unix)] +use std::path::Path; +use std::path::PathBuf; /// A print job may not grow past this (a hostile server could otherwise fill the disk). const MAX_PRINT_JOB: u64 = 256 << 20; @@ -32,6 +36,7 @@ const MAX_PRINT_JOB: u64 = 256 << 20; const MAX_OPEN_PRINT_JOBS: usize = 4; /// Largest drive read served in one request. The server picks the length (a u32), and the /// native backend allocates it before reading; Windows clients never ask for more than ~1 MiB. +#[cfg(unix)] const MAX_DRIVE_READ: u32 = 8 << 20; /// Called with the file a finished print job was saved to. @@ -151,7 +156,7 @@ impl RdpdrBackend for Devices { if let Some(d) = &mut self.drive { return d.handle(req); } - ConfinedDrive::refuse(req) + refuse_drive_io(req) } fn handle_printer_io_request(&mut self, req: PrinterIoRequest) -> PduResult> { @@ -164,14 +169,35 @@ impl RdpdrBackend for Devices { } } +/// Answers a drive request with a refusal (no shared folder, or a path outside it). +fn refuse_drive_io(req: ServerDriveIoRequest) -> PduResult> { + tracing::warn!("RDP server asked for a path outside the shared folder; refused"); + let pdu = match req { + ServerDriveIoRequest::ServerCreateDriveRequest(r) => RdpdrPdu::DeviceCreateResponse(DeviceCreateResponse { + device_io_reply: DeviceIoResponse::new(r.device_io_request, NtStatus::ACCESS_DENIED), + file_id: 0, + information: Information::empty(), + }), + ServerDriveIoRequest::ServerDriveQueryDirectoryRequest(r) => RdpdrPdu::ClientDriveQueryDirectoryResponse(ClientDriveQueryDirectoryResponse { + device_io_reply: DeviceIoResponse::new(r.device_io_request, NtStatus::NO_MORE_FILES), + buffer: None, + }), + ServerDriveIoRequest::ServerDriveSetInformationRequest(r) => RdpdrPdu::ClientDriveSetInformationResponse( + ClientDriveSetInformationResponse::new(&r, NtStatus::ACCESS_DENIED).map_err(|e| ironrdp::pdu::encode_err!(e))?, + ), + _ => return Ok(vec![]), + }; + Ok(vec![SvcMessage::from(pdu)]) +} + +#[cfg(unix)] #[derive(Debug)] struct ConfinedDrive { - #[cfg(unix)] inner: NixRdpdrBackend, - #[cfg_attr(not(unix), allow(dead_code))] base: PathBuf, } +#[cfg(unix)] impl ConfinedDrive { pub fn new(folder: &Path) -> std::io::Result { let base = folder.canonicalize()?; @@ -211,28 +237,9 @@ impl ConfinedDrive { } } } - - fn refuse(req: ServerDriveIoRequest) -> PduResult> { - tracing::warn!("RDP server asked for a path outside the shared folder; refused"); - let pdu = match req { - ServerDriveIoRequest::ServerCreateDriveRequest(r) => RdpdrPdu::DeviceCreateResponse(DeviceCreateResponse { - device_io_reply: DeviceIoResponse::new(r.device_io_request, NtStatus::ACCESS_DENIED), - file_id: 0, - information: Information::empty(), - }), - ServerDriveIoRequest::ServerDriveQueryDirectoryRequest(r) => RdpdrPdu::ClientDriveQueryDirectoryResponse(ClientDriveQueryDirectoryResponse { - device_io_reply: DeviceIoResponse::new(r.device_io_request, NtStatus::NO_MORE_FILES), - buffer: None, - }), - ServerDriveIoRequest::ServerDriveSetInformationRequest(r) => RdpdrPdu::ClientDriveSetInformationResponse( - ClientDriveSetInformationResponse::new(&r, NtStatus::ACCESS_DENIED).map_err(|e| ironrdp::pdu::encode_err!(e))?, - ), - _ => return Ok(vec![]), - }; - Ok(vec![SvcMessage::from(pdu)]) - } } +#[cfg(unix)] impl ConfinedDrive { #[cfg(unix)] fn handle(&mut self, mut req: ServerDriveIoRequest) -> PduResult> { @@ -250,7 +257,7 @@ impl ConfinedDrive { // Everything else names a file by an id that a checked create handed out. _ => true, }; - if ok { self.inner.handle_drive_io_request(req) } else { Self::refuse(req) } + if ok { self.inner.handle_drive_io_request(req) } else { refuse_drive_io(req) } } } @@ -314,6 +321,7 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + #[cfg(unix)] #[test] fn server_paths_stay_inside_the_share() { let root = std::env::temp_dir().join(format!("nexus-drive-{}", std::process::id())); diff --git a/rust/core/src/clients/serial.rs b/rust/core/src/clients/serial.rs index 3cae2ac..d2a4c9b 100644 --- a/rust/core/src/clients/serial.rs +++ b/rust/core/src/clients/serial.rs @@ -206,6 +206,7 @@ fn open_error(path: &str, e: &serialport::Error) -> crate::Error { #[cfg(test)] mod tests { use super::*; + #[cfg(unix)] use std::sync::mpsc; #[test] diff --git a/rust/packaging/linux/nexus-terminal.desktop b/rust/packaging/linux/nexus-terminal.desktop index f902934..6c3996f 100644 --- a/rust/packaging/linux/nexus-terminal.desktop +++ b/rust/packaging/linux/nexus-terminal.desktop @@ -2,10 +2,10 @@ Type=Application Name=Nexus Terminal GenericName=Terminal and Remote Connections -Comment=SSH, Telnet, RAW TCP, Serial, VNC, IRC and GitHub sessions in one window +Comment=SSH, Telnet, RAW TCP, Serial, VNC, RDP, IRC and GitHub sessions in one window Exec=nexus-terminal Icon=nexus-terminal Terminal=false Categories=System;TerminalEmulator;Network;RemoteAccess; -Keywords=ssh;telnet;serial;sftp;vnc;irc;terminal; +Keywords=ssh;telnet;serial;sftp;vnc;rdp;irc;terminal; StartupWMClass=nexus-terminal diff --git a/rust/packaging/repo.sh b/rust/packaging/repo.sh new file mode 100755 index 0000000..518877c --- /dev/null +++ b/rust/packaging/repo.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (C) 2026 Andy Dixon +# +# Publish a Nexus Terminal release as the `nexusterminal` package (.deb, .rpm and Arch) in the +# signed apt, dnf and pacman repositories served at https://repo.dixon.cx. +# +# packaging/repo.sh # the newest tag +# packaging/repo.sh v3.0.2 # a given tag +# +# Unlike the command-line tools' copies of this script, nothing is built here: Nexus is a GUI app +# linked against the platform's windowing stack, so the packages wrap the official binaries that +# the Release Builds workflow made for the tag (native glibc builds on amd64 and arm64). They're +# downloaded from the GitHub release and checked against its SHA256SUMS first. +# +# The publish half is the same as in ../../../mapsize/packaging/repo.sh and friends: every index +# is rebuilt over all packages in the repository, so other projects' packages are kept. +# +# Needs: gh (or curl), unzip, curl (for the pinned nfpm), docker, gpg, apt-ftparchive, flock. +# REPO_DIR where the repository lives (default below) +# REPO_GNUPGHOME GnuPG home holding the signing key (default below) +# ARCHS package architectures (default: amd64 arm64) +set -euo pipefail + +# ---- project --------------------------------------------------------------- +PACKAGE=nexusterminal +SUMMARY="Native desktop client for SSH, SFTP, Telnet, Serial, VNC, RDP, IRC and GitHub" +HOMEPAGE=https://dixon.cx/nexus +GITHUB=andydixon/nexus +LICENSE=AGPL-3.0-only +ARCHS=${ARCHS:-"amd64 arm64"} +# The binaries come from ubuntu-22.04 runners: glibc 2.35. Windowing, GL and audio libraries are +# loaded at run time, so they're recommended rather than hard dependencies (any desktop has them). +DEB_DEPENDS='"libc6 (>= 2.35)", "libgcc-s1", "zlib1g"' +DEB_RECOMMENDS='"libxkbcommon0", "libwayland-client0", "libx11-6", "libxcursor1", "libxrandr2", "libxi6", "libgl1", "libegl1", "libasound2", "xdg-desktop-portal"' +RPM_DEPENDS='"glibc >= 2.35", "libgcc", "zlib"' +RPM_RECOMMENDS='"libxkbcommon", "libwayland-client", "libX11", "libXcursor", "libXrandr", "libXi", "mesa-libGL", "mesa-libEGL", "alsa-lib", "xdg-desktop-portal"' +ARCH_DEPENDS='"glibc", "gcc-libs", "zlib", "libxkbcommon", "libglvnd"' +ARCH_OPTDEPENDS='"wayland: Wayland sessions", "libx11: X11 sessions", "libxcursor: X11 sessions", "libxrandr: X11 sessions", "libxi: X11 sessions", "alsa-lib: RDP audio", "xdg-desktop-portal: file dialogs"' +# ---------------------------------------------------------------------------- + +REPO_DIR=${REPO_DIR:-/home/andy/domains/repo.dixon.cx/public_html} +REPO_GNUPGHOME=${REPO_GNUPGHOME:-$HOME/.config/repo.dixon.cx/gnupg} +SUITE=stable +COMPONENT=main +ARCH_REPO=dixon # the pacman repository name: [dixon] +HERE=$(cd "$(dirname "$0")" && pwd) + +say() { echo "repo: $*" >&2; } +die() { say "$*"; exit 1; } + +for tool in gh unzip curl docker gpg apt-ftparchive flock sha256sum; do + command -v $tool >/dev/null || die "needs $tool" +done +[[ -d $REPO_DIR ]] || die "$REPO_DIR does not exist" +export GNUPGHOME=$REPO_GNUPGHOME +KEY=$(gpg --list-secret-keys --with-colons 2>/dev/null | awk -F: '/^fpr/{print $10; exit}') +[[ -n $KEY ]] || die "no signing key in $GNUPGHOME" + +cd "$(git -C "$HERE" rev-parse --show-toplevel)" +TAG=${1:-$(git describe --tags --abbrev=0)} +VERSION=${TAG#v} + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT +gpg --batch --armor --export-secret-keys "$KEY" >"$WORK/signing.asc" + +# The pinned nfpm, as in the other projects (checksummed download, cached under target/tools). +NFPM_VERSION=2.41.1 +case $(uname -m) in +x86_64) nfpm_arch=x86_64 nfpm_sum=b3cf95aa6dabed836d09ad7f0c190a13c74c5b1304db60846f0f702ee407f430 ;; +aarch64) nfpm_arch=arm64 nfpm_sum=17350a838c8e2c422c6e573ed379b18424565d2de8a2b1cb1b20211976124eb5 ;; +*) die "unsupported host $(uname -m)" ;; +esac +NFPM_DIR=$PWD/rust/target/tools/nfpm-$NFPM_VERSION +if [[ ! -x $NFPM_DIR/nfpm ]]; then + mkdir -p "$NFPM_DIR" + curl -fsSL -o "$NFPM_DIR/nfpm.tar.gz" "https://github.com/goreleaser/nfpm/releases/download/v$NFPM_VERSION/nfpm_${NFPM_VERSION}_Linux_$nfpm_arch.tar.gz" + echo "$nfpm_sum $NFPM_DIR/nfpm.tar.gz" | sha256sum -c --quiet + tar -xzf "$NFPM_DIR/nfpm.tar.gz" -C "$NFPM_DIR" nfpm + rm -f "$NFPM_DIR/nfpm.tar.gz" +fi +NFPM=$NFPM_DIR/nfpm + +# ---- fetch and verify the release binaries --------------------------------- +say "fetching $TAG from github.com/$GITHUB" +mkdir -p "$WORK/dl" +patterns=(--pattern SHA256SUMS) +for arch in $ARCHS; do patterns+=(--pattern "NexusTerminal-linux-$arch.zip"); done +gh release download "$TAG" -R "$GITHUB" -D "$WORK/dl" "${patterns[@]}" || die "release $TAG has no Linux assets yet" +(cd "$WORK/dl" && grep -E "NexusTerminal-linux-($(tr ' ' '|' <<<"$ARCHS"))\.zip" SHA256SUMS | sha256sum -c --quiet) || die "checksum mismatch" + +# ---- package ----------------------------------------------------------------- +say "packaging $PACKAGE $VERSION for $ARCHS" +mkdir -p "$WORK/out" +for arch in $ARCHS; do + src=$WORK/src-$arch + mkdir -p "$src" + unzip -q "$WORK/dl/NexusTerminal-linux-$arch.zip" -d "$src" + [[ -x $src/nexus-terminal ]] || die "no nexus-terminal binary in the $arch zip" + cp "$src/nexus-terminal.desktop" "$src/$PACKAGE.desktop" + cat >"$WORK/nfpm-$arch.yaml" <<-EOF + name: $PACKAGE + arch: $arch + platform: linux + version: $VERSION + release: 1 + section: net + maintainer: Andy Dixon + description: | + $SUMMARY. + Tabs, split panes, SFTP, port forwards, jump hosts, session recording and an + encrypted credential vault, in one native app written in Rust. + homepage: $HOMEPAGE + license: $LICENSE + contents: + - src: $src/nexus-terminal + dst: /usr/bin/nexus-terminal + file_info: {mode: 0755} + - src: /usr/bin/nexus-terminal + dst: /usr/bin/$PACKAGE + type: symlink + - src: $src/$PACKAGE.desktop + dst: /usr/share/applications/$PACKAGE.desktop + file_info: {mode: 0644} + - src: $src/app/assets/icon/icon-256.png + dst: /usr/share/icons/hicolor/256x256/apps/nexus-terminal.png + file_info: {mode: 0644} + - src: $src/app/assets/icon/icon-512.png + dst: /usr/share/icons/hicolor/512x512/apps/nexus-terminal.png + file_info: {mode: 0644} + - src: $src/LICENSE + dst: /usr/share/licenses/$PACKAGE/LICENSE + file_info: {mode: 0644} + - src: $PWD/README.md + dst: /usr/share/doc/$PACKAGE/README.md + file_info: {mode: 0644} + overrides: + deb: + depends: [$DEB_DEPENDS] + recommends: [$DEB_RECOMMENDS] + rpm: + depends: [$RPM_DEPENDS] + recommends: [$RPM_RECOMMENDS] + archlinux: + depends: [$ARCH_DEPENDS] + archlinux: + packager: Andy Dixon + optdepends: [$ARCH_OPTDEPENDS] + rpm: + signature: + key_file: $WORK/signing.asc + EOF + for fmt in deb rpm archlinux; do + "$NFPM" pkg -f "$WORK/nfpm-$arch.yaml" -p $fmt -t "$WORK/out/" >/dev/null + done +done + +# ---- publish (shared with the other projects' repo.sh) ----------------------- +exec 9>"$REPO_DIR/../.publish.lock" +flock 9 +say "publishing to $REPO_DIR" +gpg --batch --armor --export "$KEY" >"$REPO_DIR/dixon.asc" +gpg --batch --export "$KEY" >"$REPO_DIR/dixon.gpg" +sign() { gpg --batch --yes --local-user "$KEY" "$@"; } + +pool=$REPO_DIR/apt/pool/$COMPONENT/${PACKAGE:0:1}/$PACKAGE +mkdir -p "$pool" +cp "$WORK"/out/*.deb "$pool/" +( + cd "$REPO_DIR/apt" + dists=dists/.$SUITE.new + rm -rf "$dists" + debarchs=$(find pool -name '*.deb' | sed -E 's/.*_([^_]+)\.deb$/\1/' | sort -u | tr '\n' ' ') + for a in $debarchs; do + d=$dists/$COMPONENT/binary-$a + mkdir -p "$d" + apt-ftparchive --arch "$a" packages pool >"$d/Packages" + gzip -9kn "$d/Packages" + done + apt-ftparchive \ + -o APT::FTPArchive::Release::Origin=repo.dixon.cx \ + -o APT::FTPArchive::Release::Label=repo.dixon.cx \ + -o APT::FTPArchive::Release::Suite=$SUITE \ + -o APT::FTPArchive::Release::Codename=$SUITE \ + -o APT::FTPArchive::Release::Components=$COMPONENT \ + -o "APT::FTPArchive::Release::Architectures=${debarchs% }" \ + release "$dists" >"$WORK/Release" + mv "$WORK/Release" "$dists/Release" + sign --clearsign -o "$dists/InRelease" "$dists/Release" + sign --armor --detach-sign -o "$dists/Release.gpg" "$dists/Release" + rm -rf "dists/.$SUITE.old" + [[ -d dists/$SUITE ]] && mv "dists/$SUITE" "dists/.$SUITE.old" + mv "$dists" "dists/$SUITE" + rm -rf "dists/.$SUITE.old" +) + +mkdir -p "$REPO_DIR/rpm" +cp "$WORK"/out/*.rpm "$REPO_DIR/rpm/" +docker image inspect repo-tools:fedora >/dev/null 2>&1 || + docker build -q -t repo-tools:fedora - >/dev/null <<-'EOF' + FROM fedora:42 + RUN dnf -y install createrepo_c && dnf clean all + EOF +docker run --rm --user "$(id -u):$(id -g)" -v "$REPO_DIR/rpm:/repo" repo-tools:fedora \ + createrepo_c --quiet --update --general-compress-type=gz /repo +sign --armor --detach-sign -o "$REPO_DIR/rpm/repodata/repomd.xml.asc" "$REPO_DIR/rpm/repodata/repomd.xml" + +added= +for pkg in "$WORK"/out/*.pkg.tar.zst; do + a=$(basename "$pkg" .pkg.tar.zst) + a=${a##*-} + mkdir -p "$REPO_DIR/arch/$a" + cp "$pkg" "$REPO_DIR/arch/$a/" + sign --detach-sign -o "$REPO_DIR/arch/$a/$(basename "$pkg").sig" "$pkg" + added+=" $a" +done +for a in $(tr ' ' '\n' <<<"$added" | sort -u); do + dir=$REPO_DIR/arch/$a + rm -f "$dir/$ARCH_REPO".{db,files}{,.tar.gz}{,.sig} + docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$dir:/repo" -w /repo archlinux:latest \ + sh -c "repo-add --quiet --prevent-downgrade $ARCH_REPO.db.tar.gz *.pkg.tar.zst" 2>&1 | + { grep -v "newer version\|^$" >&2 || true; } + for db in $ARCH_REPO.db $ARCH_REPO.files; do + sign --detach-sign -o "$dir/$db.tar.gz.sig" "$dir/$db.tar.gz" + ln -sfn "$db.tar.gz.sig" "$dir/$db.sig" + done + rm -f "$dir"/*.old "$dir"/*.old.sig +done + +say "published $PACKAGE $VERSION: $(cd "$WORK/out" && ls | wc -l) packages"