NetDesk 1.5.0 — source release

This commit is contained in:
NetDesk committed 2026-10-18 02:02:46 +00:00
commit 40318aafdd
227 files changed
+80798

No files matched your search

+97
View File
@@ -0,0 +1,97 @@
[package]
name = "netdesk-cli"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description = "Headless NetDesk native client — connects to a host and verifies the pipeline (dev/test harness)."
[[bin]]
name = "netdesk"
path = "src/main.rs"
# End-to-end feature test harness: connects to the live host and verifies every
# Session feature + the read-only host_ctl backend. See src/bin/e2e.rs.
[[bin]]
name = "netdesk-e2e"
path = "src/bin/e2e.rs"
# TCP tunnel client: forward a local port through the relay to a port on the
# remote host (SSH/VNC/RDP/DB over NetDesk). Portal-authed.
[[bin]]
name = "netdesk-tunnel"
path = "src/bin/tunnel.rs"
# LAN discovery scanner: find NetDesk hosts on the local network.
[[bin]]
name = "netdesk-lan-scan"
path = "src/bin/lan_scan.rs"
# Recursive folder download: pull a whole remote folder tree to a local dir.
[[bin]]
name = "netdesk-getdir"
path = "src/bin/getdir.rs"
# Interactive terminal: run commands on the remote host's shell (ConPTY).
[[bin]]
name = "netdesk-term"
path = "src/bin/term.rs"
# Voice send-test: capture this PC's mic and stream it to the host over
# CHAN_VOICE (drives the exact native-viewer voice path, headlessly).
[[bin]]
name = "netdesk-voice-send"
path = "src/bin/voice_send.rs"
# FPS probe: count received video frames per 1-second bucket (observes the
# host's startup FPS ramp end to end).
[[bin]]
name = "netdesk-fps-probe"
path = "src/bin/fps_probe.rs"
# Privacy-mode integration test: toggle privacy + verify sys.res and that the
# capture keeps delivering frames through the blackout.
[[bin]]
name = "netdesk-privacy-test"
path = "src/bin/privacy_test.rs"
# Direct-IP connect test: dial a host's IP:port directly (no relay) over
# DirectTcp and verify the E2E session + video.
[[bin]]
name = "netdesk-direct"
path = "src/bin/direct_connect.rs"
[dependencies]
netdesk-core = { path = "../core" }
# e2e decodes received Opus audio packets to measure real per-source energy
# (RMS/peak) — proving system + mic audio actually transmit, not just that
# packets arrive.
netdesk-opus = { path = "../opus" }
tokio = { workspace = true }
anyhow = { workspace = true }
# e2e.rs parses sys.res / session.info JSON and pulls in the viewer's host_ctl
# module verbatim (via #[path]); host_ctl needs serde derive, serde_json, a
# blocking HTTP client (ureq) and the clipboard crate it references.
serde = { workspace = true }
serde_json = { workspace = true }
ureq = { version = "2.10", features = ["json"] }
arboard = "3"
# netdesk-tunnel client: portal-login (ureq) then a raw tunnel WebSocket to
# wss://server/tunnel/<hid>, piping a local TcpListener through it.
tokio-tungstenite = { workspace = true }
futures-util = { workspace = true }
# e2e.rs verifies REAL OS-level input injection on the host (which, for the live
# test, runs on this same machine): GetCursorPos confirms the mouse actually
# moved, GetAsyncKeyState confirms a key actually registered. DPI-aware so the
# readings share the host's physical pixel space.
[target.'cfg(windows)'.dependencies]
# the e2e bin #[path]-includes viewer/src/host_ctl.rs, which drives the in-process
# native host engine for the HOSTCTL checks.
netdesk-host = { path = "../host" }
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_HiDpi",
] }
+80
View File
@@ -0,0 +1,80 @@
//! NetDesk direct-IP connect test — dials a host's IP:port directly (no relay),
//! runs the same challenge/auth + E2E over a raw TCP socket (DirectTcp), and
//! verifies video decodes. Proves encrypted direct-IP access. Developed by
//! Akronlogic.
//!
//! Usage: netdesk-direct <ip:port> <host_id> <password> [secs]
use netdesk_core::signaling::connect_with;
use netdesk_core::transport::DirectTcp;
use netdesk_core::{ConnectConfig, SessionEvent};
use std::time::{Duration, Instant};
use tokio::net::TcpStream;
use tokio::sync::mpsc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let a: Vec<String> = std::env::args().collect();
if a.len() < 4 {
eprintln!("usage: netdesk-direct <ip:port> <host_id> <password> [secs]");
std::process::exit(2);
}
let addr = a[1].clone();
let secs: u64 = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(6);
eprintln!("[direct] dialing {addr} …");
let stream = TcpStream::connect(&addr)
.await
.map_err(|e| anyhow::anyhow!("tcp connect {addr}: {e}"))?;
let transport = DirectTcp::new(stream);
let cfg = ConnectConfig {
host_id: a[2].clone(),
password: a[3].clone(),
ws_url: String::new(), // unused: the transport is provided directly
totp: String::new(),
takeover: false,
};
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
let session = connect_with(cfg, tx, Box::new(transport))
.await
.map_err(|e| anyhow::anyhow!("connect_with: {e}"))?;
eprintln!("[direct] connected — E2E handshake completed over raw TCP; waiting for video …");
let mut frames = 0u32;
let mut e2e = false;
let deadline = Instant::now() + Duration::from_secs(secs + 4);
loop {
if Instant::now() >= deadline {
break;
}
match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await {
Ok(Some(SessionEvent::Connected { host_name, e2e: ee, w, h })) => {
e2e = ee;
eprintln!("[direct] session.accept host='{host_name}' {w}x{h} e2e={ee}");
}
Ok(Some(SessionEvent::Video { .. })) => {
frames += 1;
if frames >= 5 {
break;
}
}
Ok(Some(SessionEvent::Denied { reason })) => {
eprintln!("[direct] denied: {reason}");
std::process::exit(3);
}
Ok(Some(SessionEvent::Ended { .. })) | Ok(None) => break,
Ok(Some(_)) => {}
Err(_) => {}
}
}
session.close();
if frames > 0 {
println!("DIRECT_E2E_PASS frames={frames} e2e={e2e} addr={addr}");
Ok(())
} else {
println!("DIRECT_E2E_FAIL frames=0 e2e={e2e}");
std::process::exit(4);
}
}
File diff suppressed because it is too large. Load diff
+73
View File
@@ -0,0 +1,73 @@
//! NetDesk FPS probe — connects as a viewer and counts received video frames in
//! 1-second buckets, so the host's startup FPS ramp (qos::startup_fps: begins at
//! INIT_FPS, climbs to target over RAMP_MS) is observable end to end: the first
//! buckets should be ~INIT_FPS and climb to the target. Developed by Akronlogic.
//!
//! Usage: netdesk-fps-probe <host_id> <password> [ws_url] [secs]
use netdesk_core::{connect, ConnectConfig, SessionEvent};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let a: Vec<String> = std::env::args().collect();
if a.len() < 3 {
eprintln!("usage: netdesk-fps-probe <host_id> <password> [ws_url] [secs]");
std::process::exit(2);
}
let secs: usize = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(6);
let cfg = ConnectConfig {
host_id: a[1].clone(),
password: a[2].clone(),
ws_url: a.get(3).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()),
totp: String::new(),
takeover: false,
};
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
eprintln!("[fps-probe] connecting to host {} …", cfg.host_id);
let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
// Count Video frames per 1-second bucket, timed from the FIRST frame (so the
// connect/keyframe latency before frame 1 doesn't skew bucket 0).
let mut buckets: Vec<u32> = vec![0; secs + 2];
let mut first: Option<Instant> = None;
let hard_deadline = Instant::now() + Duration::from_secs(secs as u64 + 6);
loop {
if Instant::now() >= hard_deadline {
break;
}
match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await {
Ok(Some(SessionEvent::Video { .. })) => {
let t0 = *first.get_or_insert_with(Instant::now);
let b = t0.elapsed().as_secs() as usize;
if b < buckets.len() {
buckets[b] += 1;
}
if t0.elapsed().as_secs() as usize >= secs {
break;
}
}
Ok(Some(SessionEvent::Denied { reason })) => {
eprintln!("[fps-probe] denied: {reason}");
std::process::exit(3);
}
Ok(Some(SessionEvent::Ended { .. })) | Ok(None) => break,
Ok(Some(_)) => {}
Err(_) => {} // 500ms idle tick; keep waiting
}
}
session.close();
if first.is_none() {
println!("FPS_BUCKETS no-frames");
std::process::exit(4);
}
print!("FPS_BUCKETS");
for (i, c) in buckets.iter().take(secs + 1).enumerate() {
print!(" t{i}s={c}");
}
println!();
Ok(())
}
+125
View File
@@ -0,0 +1,125 @@
//! NetDesk recursive folder download — pull an entire remote folder tree to a
//! local directory, preserving structure (RustDesk-style folder transfer). It
//! asks the host for a recursive manifest (`file.walk`) then downloads each file
//! into `<local_dir>/<relative path>`. Developed by Akronlogic.
//!
//! Usage:
//! netdesk-getdir <host_id> <password> <remote_dir> <local_dir> [ws_url] [totp]
use netdesk_core::{connect, ConnectConfig, SessionEvent};
use std::path::PathBuf;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::timeout;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let a: Vec<String> = std::env::args().collect();
if a.len() < 5 {
eprintln!("usage: netdesk-getdir <host_id> <password> <remote_dir> <local_dir> [ws_url] [totp]");
std::process::exit(2);
}
let cfg = ConnectConfig {
host_id: a[1].clone(),
password: a[2].clone(),
ws_url: a.get(5).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()),
totp: a.get(6).cloned().unwrap_or_default(),
takeover: false,
};
let remote_dir = a[3].trim_end_matches(['\\', '/']).to_string();
let local_dir = PathBuf::from(&a[4]);
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
eprintln!("[getdir] connecting to host {} …", cfg.host_id);
let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
eprintln!("[getdir] connected; requesting manifest of {remote_dir}");
session.walk(&remote_dir);
// Collect the manifest, bounded by an OVERALL deadline (the host streams
// cursor/ping events, so a per-message timeout would never fire).
let manifest = timeout(Duration::from_secs(45), async {
loop {
match rx.recv().await {
Some(SessionEvent::FileWalk { files, truncated, .. }) => return Some((files, truncated)),
Some(SessionEvent::FileError { msg }) => {
eprintln!("[getdir] host error: {msg}");
return None;
}
Some(_) => continue,
None => return None,
}
}
})
.await;
let (files, truncated) = match manifest {
Ok(Some(x)) => x,
_ => return Err(anyhow::anyhow!("no manifest from host (timed out)")),
};
eprintln!(
"[getdir] {} file(s){}; downloading to {}",
files.len(),
if truncated { " (TRUNCATED — hit the host's cap)" } else { "" },
local_dir.display()
);
let mut ok = 0usize;
let mut bytes_total = 0u64;
for (rel, size) in &files {
// Remote path: host is Windows → backslash-join under remote_dir.
let remote_full = format!("{}\\{}", remote_dir, rel.replace('/', "\\"));
let fid = session.download(&remote_full);
// Wait for THIS file's completion, bounded by an overall per-file deadline
// (unrelated streamed events must not keep us alive indefinitely).
let done_path = timeout(Duration::from_secs(90), async {
loop {
match rx.recv().await {
Some(SessionEvent::FileDownloadDone { fid: f, path, .. }) if f == fid => {
return Some(path)
}
Some(SessionEvent::FileError { msg }) => {
eprintln!("[getdir] skip {rel}: {msg}");
return None;
}
Some(_) => continue, // progress / cursor / ping — ignore
None => return None,
}
}
})
.await
.unwrap_or_else(|_| {
eprintln!("[getdir] timeout on {rel}");
None
});
if let Some(tmp) = done_path {
// Move the downloaded temp file into <local_dir>/<rel>.
let target = local_dir.join(PathBuf::from(rel.replace('/', std::path::MAIN_SEPARATOR_STR)));
if let Some(parent) = target.parent() {
let _ = std::fs::create_dir_all(parent);
}
// rename across drives can fail → fall back to copy+remove.
if std::fs::rename(&tmp, &target).is_err() {
if std::fs::copy(&tmp, &target).is_ok() {
let _ = std::fs::remove_file(&tmp);
} else {
eprintln!("[getdir] could not place {rel}");
continue;
}
}
ok += 1;
bytes_total += size;
}
}
session.close();
println!(
"[getdir] done: {}/{} files, {} bytes -> {}",
ok,
files.len(),
bytes_total,
local_dir.display()
);
if ok != files.len() {
std::process::exit(1);
}
Ok(())
}
+33
View File
@@ -0,0 +1,33 @@
//! NetDesk LAN scan — discover NetDesk hosts on the local network by UDP
//! broadcast (the viewer end of the host's discovery responder). Prints each
//! host's id + name so you can connect by id. Developed by Akronlogic.
//!
//! Usage: netdesk-lan-scan [timeout_ms] (default 800ms)
use netdesk_core::lan;
use std::time::Duration;
fn main() {
let ms: u64 = std::env::args()
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(800);
eprintln!("[lan] scanning for NetDesk hosts on udp/{} for {ms}ms…", lan::LAN_PORT);
match lan::scan(&lan::broadcast_targets(), Duration::from_millis(ms)) {
Ok(hosts) if hosts.is_empty() => {
println!("no NetDesk hosts found on this LAN");
}
Ok(hosts) => {
println!("found {} host(s):", hosts.len());
for h in hosts {
// addr.port() is 0 until direct-IP connect ships; the id is what
// a viewer uses to connect through the relay today.
println!(" id={} name=\"{}\" ip={}", h.id, h.name, h.addr.ip());
}
}
Err(e) => {
eprintln!("[lan] scan failed: {e}");
std::process::exit(1);
}
}
}
+95
View File
@@ -0,0 +1,95 @@
//! Privacy-mode integration test. Connects to a host, confirms video is
//! streaming, toggles privacy ON (expects sys.res {kind:"privacy", on:true} and
//! that frames keep flowing — i.e. the capture survives the blackout), then
//! toggles it OFF (on:false). Developed by Akronlogic.
//!
//! Usage: netdesk-privacy-test <host_id> <password> [ws_url]
use netdesk_core::{connect, ConnectConfig, SessionEvent};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
type Rx = mpsc::UnboundedReceiver<SessionEvent>;
async fn wait_for_frame(rx: &mut Rx, frames: &mut u32, secs: u64) -> anyhow::Result<()> {
let start = *frames;
let deadline = Instant::now() + Duration::from_secs(secs);
while Instant::now() < deadline {
match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await {
Ok(Some(SessionEvent::Video { .. })) => {
*frames += 1;
if *frames > start {
return Ok(());
}
}
Ok(Some(SessionEvent::Denied { reason })) => anyhow::bail!("denied: {reason}"),
Ok(Some(SessionEvent::Ended { .. })) | Ok(None) => anyhow::bail!("session ended"),
Ok(Some(_)) => {}
Err(_) => {}
}
}
anyhow::bail!("no video frame within {secs}s")
}
async fn wait_privacy(rx: &mut Rx, frames: &mut u32, secs: u64) -> anyhow::Result<bool> {
let deadline = Instant::now() + Duration::from_secs(secs);
while Instant::now() < deadline {
match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await {
Ok(Some(SessionEvent::Video { .. })) => *frames += 1,
Ok(Some(SessionEvent::SysResult { json })) => {
let v: serde_json::Value = serde_json::from_str(&json).unwrap_or_default();
if v.get("kind").and_then(|k| k.as_str()) == Some("privacy") {
return Ok(v.get("on").and_then(|o| o.as_bool()).unwrap_or(false));
}
}
Ok(Some(SessionEvent::Denied { reason })) => anyhow::bail!("denied: {reason}"),
Ok(Some(SessionEvent::Ended { .. })) | Ok(None) => anyhow::bail!("session ended"),
Ok(Some(_)) => {}
Err(_) => {}
}
}
anyhow::bail!("no privacy sys.res within {secs}s")
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let a: Vec<String> = std::env::args().collect();
if a.len() < 3 {
eprintln!("usage: netdesk-privacy-test <host_id> <password> [ws_url]");
std::process::exit(2);
}
let cfg = ConnectConfig {
host_id: a[1].clone(),
password: a[2].clone(),
ws_url: a.get(3).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()),
totp: String::new(),
takeover: false,
};
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
eprintln!("[privacy-test] connecting to host {} …", cfg.host_id);
let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
let mut frames = 0u32;
wait_for_frame(&mut rx, &mut frames, 8).await?;
eprintln!("[privacy-test] streaming confirmed; toggling privacy ON");
session.privacy();
let on1 = wait_privacy(&mut rx, &mut frames, 8).await?;
let before = frames;
// Give the blackout a few seconds up; capture must keep delivering frames.
let flowed = wait_for_frame(&mut rx, &mut frames, 5).await.is_ok();
eprintln!("[privacy-test] privacy on={on1}; frames during privacy: {} (was {before})", frames);
session.privacy();
let on2 = wait_privacy(&mut rx, &mut frames, 8).await?;
eprintln!("[privacy-test] privacy on={on2} (toggled off)");
session.close();
if on1 && !on2 && flowed {
println!("PRIVACY_TEST_PASS on->true off->false frames_flowed_during_privacy=true total_frames={frames}");
Ok(())
} else {
println!("PRIVACY_TEST_FAIL on1={on1} on2={on2} flowed={flowed}");
std::process::exit(3);
}
}
+64
View File
@@ -0,0 +1,64 @@
//! Tiny viewer that connects to a host and sends ONE tap (mouse down+up) at
//! TAP_X,TAP_Y (in the host's capture pixel space). Used to verify Android-host
//! input injection on BlueStacks: send a tap, then screencap to confirm effect.
//! E2E_HOST_ID=.. E2E_PASSWORD=.. TAP_X=.. TAP_Y=.. tap-probe
use netdesk_core::{connect, ConnectConfig, SessionEvent};
use std::time::Duration;
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
let id = std::env::var("E2E_HOST_ID").expect("E2E_HOST_ID");
let pw = std::env::var("E2E_PASSWORD").expect("E2E_PASSWORD");
let x: i32 = std::env::var("TAP_X").unwrap_or_else(|_| "480".into()).parse().unwrap();
let y: i32 = std::env::var("TAP_Y").unwrap_or_else(|_| "800".into()).parse().unwrap();
let swipe = std::env::var("SWIPE").ok(); // "x2,y2" -> drag instead of tap
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
let cfg = ConnectConfig {
ws_url: "wss://netdesk.space/ws".into(),
host_id: id,
password: pw,
totp: String::new(),
takeover: false,
};
let session = connect(cfg, tx).await.expect("connect");
// drain until Connected
let mut connected = false;
for _ in 0..50 {
if let Ok(Some(ev)) = tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
if matches!(ev, SessionEvent::Connected { .. }) {
connected = true;
break;
}
}
}
if !connected {
println!("TAP_PROBE_FAIL not connected");
return;
}
tokio::time::sleep(Duration::from_millis(400)).await;
// press
session.mouse(x, y, 1, 0.0, 0.0);
if let Some(s) = swipe {
// drag: move with button held to (x2,y2), then release
let parts: Vec<i32> = s.split(',').filter_map(|v| v.trim().parse().ok()).collect();
let (x2, y2) = (parts[0], parts[1]);
for i in 1..=8 {
let ix = x + (x2 - x) * i / 8;
let iy = y + (y2 - y) * i / 8;
tokio::time::sleep(Duration::from_millis(25)).await;
session.mouse(ix, iy, 1, 0.0, 0.0);
}
tokio::time::sleep(Duration::from_millis(40)).await;
session.mouse(x2, y2, 0, 0.0, 0.0);
println!("SWIPE_SENT {x},{y} -> {x2},{y2}");
} else {
tokio::time::sleep(Duration::from_millis(80)).await;
session.mouse(x, y, 0, 0.0, 0.0); // release = tap
println!("TAP_SENT {x},{y}");
}
tokio::time::sleep(Duration::from_millis(600)).await;
session.close();
}
+77
View File
@@ -0,0 +1,77 @@
//! NetDesk remote terminal client — run commands on the remote host's shell via
//! the ConPTY terminal channel and stream the output back. Developed by Akronlogic.
//!
//! Usage:
//! netdesk-term <host_id> <password> [ws_url] [totp]
//! Input: set ND_TERM_CMD="<command>" to run one command, or pipe commands on
//! stdin (e.g. `echo dir | netdesk-term <id> <pw>`). Output streams for a few
//! seconds, then it exits. (A full raw-TTY interactive mode is a follow-on.)
use netdesk_core::{connect, ConnectConfig, SessionEvent};
use std::io::{Read as _, Write as _};
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::timeout;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let a: Vec<String> = std::env::args().collect();
if a.len() < 3 {
eprintln!("usage: netdesk-term <host_id> <password> [ws_url] [totp] (ND_TERM_CMD=cmd, or pipe stdin)");
std::process::exit(2);
}
let cfg = ConnectConfig {
host_id: a[1].clone(),
password: a[2].clone(),
ws_url: a.get(3).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()),
totp: a.get(4).cloned().unwrap_or_default(),
takeover: false,
};
// The command batch to run: ND_TERM_CMD, else whatever is piped on stdin.
let input = if let Ok(cmd) = std::env::var("ND_TERM_CMD") {
format!("{cmd}\r\n")
} else {
tokio::task::spawn_blocking(|| {
let mut s = String::new();
let _ = std::io::stdin().read_to_string(&mut s);
// normalize bare \n to \r\n for the shell
s.replace("\r\n", "\n").replace('\n', "\r\n")
})
.await
.unwrap_or_default()
};
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
eprintln!("[term] connecting to host {} …", cfg.host_id);
let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
let tid = 1u32;
session.term_open(tid);
eprintln!("[term] shell opened (tid {tid})");
tokio::time::sleep(Duration::from_millis(700)).await; // let the shell print its prompt
if !input.trim().is_empty() {
session.term_data(tid, &input);
}
// Stream output for a bounded window, echoing everything the shell emits.
let _ = timeout(Duration::from_secs(6), async {
loop {
match rx.recv().await {
Some(SessionEvent::TermData { data, .. }) => {
print!("{data}");
let _ = std::io::stdout().flush();
}
Some(SessionEvent::TermExit { .. }) | Some(SessionEvent::Ended { .. }) => break,
Some(_) => continue,
None => break,
}
}
})
.await;
session.term_close(tid);
session.close();
println!();
Ok(())
}
+210
View File
@@ -0,0 +1,210 @@
//! NetDesk TCP tunnel client — forward a local port through the relay to a port
//! on the remote host (SSH/VNC/RDP/DB over NetDesk), the viewer end of the
//! `/tunnel/<hid>` server endpoint the host now implements.
//!
//! Each accepted local connection opens its own `wss://server/tunnel/<hid>`
//! WebSocket (portal-authed via the `ndportal` cookie), sends `{"t":"open",...}`
//! and then pipes raw bytes both ways. The server asks the host to open the TCP
//! connection and relays the stream.
//!
//! Usage:
//! netdesk-tunnel <host_id> <local_port> <remote_port> [remote_host] \
//! [wss://server/] [portal_password]
//! Env fallbacks: ND_SERVER (https/wss base), ND_PORTAL_PASSWORD.
//!
//! Example — reach the remote host's RDP:
//! netdesk-tunnel 123456789 13389 3389
//! mstsc /v:127.0.0.1:13389
//!
//! Developed by Akronlogic.
use anyhow::{anyhow, Context, Result};
use futures_util::{SinkExt, StreamExt};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::{ClientRequestBuilder, Message};
fn env_or(args: &[String], idx: usize, env: &str, default: &str) -> String {
args.get(idx)
.cloned()
.or_else(|| std::env::var(env).ok())
.unwrap_or_else(|| default.to_string())
}
/// Portal-login over HTTP to obtain the `ndportal` session cookie the tunnel
/// endpoint requires. Returns the raw cookie value (`ndportal=…`).
fn portal_cookie(http_base: &str, password: &str) -> Result<String> {
// The server replies 303 with Set-Cookie: ndportal=…; don't follow the
// redirect so we can read the cookie off the response.
let agent = ureq::builder().redirects(0).build();
let resp = agent
.post(&format!("{http_base}/portal/login"))
.send_form(&[("password", password)]);
let resp = match resp {
Ok(r) => r,
Err(ureq::Error::Status(_code, r)) => r, // 303 is an "error" status to ureq
Err(e) => return Err(anyhow!("portal login request failed: {e}")),
};
let set_cookie = resp
.header("set-cookie")
.ok_or_else(|| anyhow!("portal login did not set a cookie (wrong password?)"))?;
// "ndportal=XYZ; HttpOnly; …" -> "ndportal=XYZ"
let cookie = set_cookie
.split(';')
.next()
.unwrap_or("")
.trim()
.to_string();
if !cookie.starts_with("ndportal=") {
return Err(anyhow!("unexpected portal cookie: {cookie}"));
}
Ok(cookie)
}
#[tokio::main]
async fn main() -> Result<()> {
// `--rdp` forwards to the remote host's RDP port (3389) and launches the
// local Windows RDP client at the forwarded port once it's up — RustDesk's
// "New RDP" workflow. It makes remote_port optional (defaults to 3389).
let mut raw: Vec<String> = std::env::args().collect();
let rdp = raw.iter().any(|a| a == "--rdp");
raw.retain(|a| a != "--rdp");
let args = raw;
if args.len() < 3 || (!rdp && args.len() < 4) {
eprintln!(
"usage: netdesk-tunnel <host_id> <local_port> <remote_port> \
[remote_host] [server] [portal_password]\n\
or: netdesk-tunnel --rdp <host_id> <local_port> [remote_host] [server] [portal_password]"
);
std::process::exit(2);
}
let host_id = args[1].clone();
let local_port: u16 = args[2].parse().context("local_port")?;
// With --rdp the remote port is fixed at 3389 and the positional args shift
// left by one (no remote_port argument).
let (remote_port, shift) = if rdp {
(3389u16, 0usize)
} else {
(args[3].parse().context("remote_port")?, 1usize)
};
let remote_host = env_or(&args, 3 + shift, "ND_TUNNEL_RHOST", "127.0.0.1");
let server = env_or(&args, 4 + shift, "ND_SERVER", "wss://netdesk.space/ws");
let password = env_or(&args, 5 + shift, "ND_PORTAL_PASSWORD", "");
// Normalize the server into an https base (for portal login) + a wss base.
let https_base = server
.replace("wss://", "https://")
.replace("ws://", "http://")
.trim_end_matches("/ws")
.trim_end_matches('/')
.to_string();
let wss_base = https_base
.replace("https://", "wss://")
.replace("http://", "ws://");
let tunnel_url = format!("{wss_base}/tunnel/{host_id}");
if password.is_empty() {
return Err(anyhow!(
"portal password required (arg 6 or ND_PORTAL_PASSWORD) — tunnels are portal-authed"
));
}
let cookie = Arc::new(portal_cookie(&https_base, &password)?);
eprintln!("[tunnel] portal authed; forwarding 127.0.0.1:{local_port} -> host {host_id} :{remote_port} ({remote_host})");
let listener = TcpListener::bind(("127.0.0.1", local_port))
.await
.with_context(|| format!("bind 127.0.0.1:{local_port}"))?;
eprintln!("[tunnel] listening on 127.0.0.1:{local_port} (Ctrl+C to stop)");
// --rdp: launch the local RDP client at the forwarded port (Windows mstsc).
if rdp {
#[cfg(windows)]
{
let target = format!("/v:127.0.0.1:{local_port}");
match std::process::Command::new("mstsc").arg(&target).spawn() {
Ok(_) => eprintln!("[tunnel] launched mstsc {target}"),
Err(e) => eprintln!("[tunnel] could not launch mstsc ({e}); connect manually to 127.0.0.1:{local_port}"),
}
}
#[cfg(not(windows))]
eprintln!("[tunnel] --rdp: connect your RDP client to 127.0.0.1:{local_port}");
}
loop {
let (sock, peer) = listener.accept().await?;
let cookie = cookie.clone();
let url = tunnel_url.clone();
let rhost = remote_host.clone();
tokio::spawn(async move {
if let Err(e) = handle_conn(sock, &url, &cookie, &rhost, remote_port).await {
eprintln!("[tunnel] {peer}: {e}");
}
});
}
}
/// Pipe one accepted local TCP connection through its own tunnel WebSocket.
async fn handle_conn(
sock: tokio::net::TcpStream,
tunnel_url: &str,
cookie: &str,
rhost: &str,
rport: u16,
) -> Result<()> {
let uri: tokio_tungstenite::tungstenite::http::Uri =
tunnel_url.parse().context("tunnel uri")?;
let req = ClientRequestBuilder::new(uri).with_header("Cookie", cookie.to_string());
let (ws, _resp) = connect_async(req).await.context("tunnel ws connect")?;
let (mut ws_tx, mut ws_rx) = ws.split();
// Ask the host to open the remote TCP connection.
ws_tx
.send(Message::Text(
serde_json::json!({"t":"open","host":rhost,"port":rport}).to_string(),
))
.await?;
let (mut rd, mut wr) = sock.into_split();
// local socket -> ws (binary)
let up = async move {
let mut buf = vec![0u8; 32 * 1024];
loop {
match rd.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
if ws_tx.send(Message::Binary(buf[..n].to_vec())).await.is_err() {
break;
}
}
}
}
let _ = ws_tx.close().await;
};
// ws -> local socket; tunnel.ok/err/closed are control text.
let down = async move {
while let Some(msg) = ws_rx.next().await {
match msg {
Ok(Message::Binary(b)) => {
if wr.write_all(&b).await.is_err() {
break;
}
}
Ok(Message::Text(t)) => {
if t.contains("tunnel.err") || t.contains("tunnel.closed") {
break;
}
}
Ok(Message::Close(_)) | Err(_) => break,
_ => {}
}
}
let _ = wr.shutdown().await;
};
tokio::join!(up, down);
Ok(())
}
+81
View File
@@ -0,0 +1,81 @@
//! NetDesk voice send-test — drives the EXACT path the native viewer's voice
//! button uses (`netdesk_host::audio::run_audio` with source = microphone, and
//! `Session::voice` per Opus packet), headlessly, so we can prove viewer->host
//! two-way voice end to end against a scratch host. Developed by Akronlogic.
//!
//! Usage: netdesk-voice-send <host_id> <password> [ws_url] [secs]
//! Watch the HOST log for `voice: rx N pkts from viewer mic, recent peak X`.
use netdesk_core::{connect, ConnectConfig, SessionEvent};
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::mpsc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let a: Vec<String> = std::env::args().collect();
if a.len() < 3 {
eprintln!("usage: netdesk-voice-send <host_id> <password> [ws_url] [secs]");
std::process::exit(2);
}
let secs: u64 = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(6);
let cfg = ConnectConfig {
host_id: a[1].clone(),
password: a[2].clone(),
ws_url: a.get(3).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()),
totp: String::new(),
takeover: false,
};
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
eprintln!("[voice-send] connecting to host {} …", cfg.host_id);
let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
eprintln!("[voice-send] connected; capturing microphone and streaming CHAN_VOICE for {secs}s");
// Mirror viewer::toggle_voice exactly: share the Session behind a Mutex and
// feed each captured Opus packet to Session::voice from the capture thread.
let sess = Arc::new(Mutex::new(session));
let stop = Arc::new(AtomicBool::new(false));
let sent = Arc::new(AtomicUsize::new(0));
#[cfg(windows)]
{
let s2 = sess.clone();
let st2 = stop.clone();
let n2 = sent.clone();
std::thread::spawn(move || {
let src = Arc::new(AtomicU8::new(2)); // 2 = microphone (default capture device)
netdesk_host::audio::run_audio(st2, || true, src, move |opus| {
s2.lock().unwrap().voice(&opus);
n2.fetch_add(1, Ordering::Relaxed);
});
});
}
#[cfg(not(windows))]
{
eprintln!("[voice-send] microphone capture is Windows-only");
}
// Drain session events (ignore them) while the capture thread streams.
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
loop {
tokio::select! {
_ = tokio::time::sleep_until(deadline) => break,
ev = rx.recv() => match ev {
Some(SessionEvent::Ended { .. }) | None => break,
Some(_) => {}
}
}
}
stop.store(true, Ordering::Relaxed);
let n = sent.load(Ordering::Relaxed);
sess.lock().unwrap().close();
println!("VOICE_SEND_DONE sent={n} packets");
if n == 0 {
eprintln!("[voice-send] WARNING: 0 packets — no microphone/capture device?");
std::process::exit(3);
}
Ok(())
}
+192
View File
@@ -0,0 +1,192 @@
//! NetDesk native CLI — a headless end-to-end test harness for the Rust core.
//! Connects to a host through the real signaling server, completes the E2E
//! handshake, and streams video, reporting fps / keyframes / bitrate. This is
//! how the core is tested on CI and during the build-out, before the UI exists.
//!
//! Usage:
//! netdesk <host-id> <password> [ws-url] [seconds]
//! Example:
//! netdesk 123456789 'secret' wss://netdesk.space/ws 10
use netdesk_core::decode::best_decoder_for;
use netdesk_core::{connect, ConnectConfig, SessionEvent};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("usage: netdesk <host-id> <password> [ws-url] [seconds]");
std::process::exit(2);
}
let host_id = args[1].clone();
let password = args[2].clone();
let ws_url = args
.get(3)
.cloned()
.unwrap_or_else(|| "wss://netdesk.space/ws".to_string());
let secs: u64 = args.get(4).and_then(|s| s.parse().ok()).unwrap_or(10);
println!("NetDesk native core v{} (by {})", netdesk_core::VERSION, netdesk_core::VENDOR);
println!("connecting to host {host_id} via {ws_url} ...");
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
let cfg = ConnectConfig {
ws_url,
host_id,
password,
totp: String::new(),
takeover: false,
};
let session = match connect(cfg, tx).await {
Ok(s) => s,
Err(e) => {
eprintln!("connect failed: {e}");
std::process::exit(1);
}
};
// ask for an immediate keyframe so we start decoding fast
session.request_keyframe();
// Report the monitors the host advertised (this is what drives the viewer's
// monitor-switch UI). With `switch` as the 5th arg, flip to another monitor
// mid-session to prove monitor.switch works end to end.
let mons: Vec<_> = session.monitors().to_vec();
let cur = session.current_monitor();
println!(
" monitors: {} [{}] current={cur}",
mons.len(),
mons.iter()
.map(|m| format!("#{} {}x{}", m.i, m.w, m.h))
.collect::<Vec<_>>()
.join(", ")
);
let do_switch = args.get(5).map(|s| s == "switch").unwrap_or(false);
let mut switched = false;
let start = Instant::now();
let mut frames = 0u64;
let mut keyframes = 0u64;
let mut bytes = 0u64;
let mut connected = false;
let mut last_report = Instant::now();
// Decode the stream to prove the pixels come through. Codec-aware: follows
// the host's per-frame codec tag (H.264 / VP9), swapping on a keyframe.
let mut cur_codec: u8 = 0;
let mut decoder = best_decoder_for(cur_codec);
let mut decoded = 0u64;
let mut audio_pkts = 0u64;
let mut audio_bytes = 0u64;
let mut last_decoded_dim = (0usize, 0usize);
let mut last_nonblack = 0u64; // non-black pixels in the last decoded frame
loop {
let remaining = Duration::from_secs(secs).checked_sub(start.elapsed());
let timeout = match remaining {
Some(r) if !r.is_zero() => r,
_ => break,
};
match tokio::time::timeout(timeout, rx.recv()).await {
Ok(Some(ev)) => match ev {
SessionEvent::Connecting => println!(" . connecting"),
SessionEvent::Challenged { e2e } => println!(" . challenged (e2e={e2e})"),
SessionEvent::Connected { host_name, e2e, w, h } => {
connected = true;
println!(" ✓ CONNECTED to \"{host_name}\" {w}x{h} e2e={e2e}");
}
SessionEvent::Video { keyframe, au, nals, codec } => {
frames += 1;
bytes += au.len() as u64;
if keyframe {
keyframes += 1;
}
// Live monitor-switch test: after a few frames, flip to a
// different monitor and watch the stream follow.
if do_switch && !switched && frames == 30 && mons.len() > 1 {
let other = mons.iter().map(|m| m.i).find(|&i| i != cur).unwrap_or(cur);
println!(" --> monitor.switch to #{other}");
session.monitor_switch(other);
switched = true;
}
if codec != cur_codec && keyframe {
println!(" codec switch -> {}", if codec == 1 { "VP9" } else { "H.264" });
decoder = best_decoder_for(codec);
cur_codec = codec;
}
if codec == cur_codec {
match decoder.decode(&au) {
Ok(Some(frame)) => {
decoded += 1;
last_decoded_dim = (frame.w, frame.h);
last_nonblack =
frame.rgb.chunks_exact(3).filter(|p| p[0] | p[1] | p[2] != 0).count() as u64;
}
Ok(None) => {}
Err(e) => {
if decoded == 0 && frames <= 3 {
eprintln!(" decode error: {e}");
}
}
}
}
if last_report.elapsed() >= Duration::from_secs(1) {
let secs_f = start.elapsed().as_secs_f64().max(0.001);
println!(
" {:>4} rx | {:>4} decoded | {:>3} key | {:>5.1} fps | {:>6.2} Mbps | last {}x{} ({} NAL, {} lit px)",
frames,
decoded,
keyframes,
decoded as f64 / secs_f,
(bytes as f64 * 8.0) / secs_f / 1_000_000.0,
last_decoded_dim.0,
last_decoded_dim.1,
nals,
last_nonblack
);
last_report = Instant::now();
}
}
SessionEvent::Audio { data } => {
audio_pkts += 1;
audio_bytes += data.len() as u64;
}
SessionEvent::Denied { reason } => {
eprintln!(" ✗ DENIED: {reason}");
std::process::exit(1);
}
SessionEvent::Ended { reason } => {
println!(" session ended: {reason}");
break;
}
// New control-protocol events are not exercised by this probe.
_ => {}
},
Ok(None) => break,
Err(_) => break, // overall timeout
}
}
session.close();
let secs_f = start.elapsed().as_secs_f64().max(0.001);
println!("\n==== RESULT ====");
println!("connected : {connected}");
println!("audio : {audio_pkts} Opus pkts ({audio_bytes} B)");
println!("frames rx : {frames} ({:.1} fps)", frames as f64 / secs_f);
println!("decoded : {decoded} frames (last {}x{}, {} lit px)", last_decoded_dim.0, last_decoded_dim.1, last_nonblack);
println!("keyframes : {keyframes}");
println!("video : {:.2} Mbps avg", (bytes as f64 * 8.0) / secs_f / 1_000_000.0);
if connected && decoded > 0 && last_nonblack > 0 {
println!("STATUS : OK — native core decoded live H.264 to real pixels ({}x{})", last_decoded_dim.0, last_decoded_dim.1);
} else if connected && frames > 0 {
println!("STATUS : stream received but not decoded (decoder issue)");
} else if connected {
println!("STATUS : CONNECTED but no frames (host idle? request keyframe?)");
} else {
println!("STATUS : FAILED to establish a session");
std::process::exit(1);
}
Ok(())
}