From 382494951c4999e15805be026a0f6b6f9130a082 Mon Sep 17 00:00:00 2001
From: Aditya
Date: Sat, 4 Jul 2026 00:04:14 +0530
Subject: [PATCH 1/6] feat(security): implement setup key verification and
document v1.1.0 changes
---
CHANGELOG.md | 40 +++++++++++++++++
README.md | 3 +-
backend/src/lib.rs | 5 +++
backend/src/main.rs | 7 +++
backend/src/setup.rs | 86 ++++++++++++++++++++++++++++++++++++
backend/tests/integration.rs | 24 +++++++++-
6 files changed, 163 insertions(+), 2 deletions(-)
create mode 100644 CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e7cc323
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,40 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [1.1.0] - 2026-07-04
+
+### Added
+- **Setup Key Verification**: Implemented backend validation for the console-printed Setup Key (via new endpoints `/api/setup/check-token` and `/api/setup/verify-setup-token`) and cookie-based authorization to secure the Setup Wizard against unauthorized remote access.
+- **Startup Console**: Added descriptive instructions and use-case details about the generated setup key in the startup console banner.
+- **Documentation**: Documented the Setup Key generation and its verification purposes in the main README.md file.
+
+## [1.0.1] - 2026-07-03
+
+### Fixed
+- **CI/CD Pipeline**: GitHub Actions build and linker failures on macOS and Linux runners.
+- **Linux Platform**: Added missing `libxdo-dev` dependency on build environments to fix input compilation/linking.
+- **macOS Platform**: Fixed key mapping error by conditionalizing `F21`–`F24` keys, which are not supported by Enigo on macOS.
+- **macOS Platform**: Silenced compiler warning for unused `brightness` variable during macOS-specific display operations.
+- **Frontend Code Quality**: Resolved all `oxlint` accessibility and linting warnings:
+ - Cleaned up unused imports and variables across multiple components.
+ - Linked form controls to labels for better screen reader compatibility.
+ - Added missing `aria-label` properties to toggle elements.
+ - Normalized React Hook dependency arrays.
+- **Backend Code Quality**: Removed unused imports (`std::io`, `std::ffi::c_void`), cleaned up unused `mut` specifiers, and resolved `Arc` borrow-after-move error in `main.rs`.
+
+## [1.0.0] - 2026-07-03
+
+### Added
+- **Core Architecture**: Lightweight remote system administration agent utilizing a Rust backend and embedded React/TypeScript single-page app (SPA).
+- **System Telemetry**: Real-time telemetry monitoring including CPU load, RAM usage, network interface speeds, disk usage, and battery capacity.
+- **File Manager**: Directory navigation, file downloads, secure path validation blocklists, and streaming uploads supporting files up to 500MB.
+- **Terminal Emulator**: Remote interactive terminal interface leveraging PTY and `xterm.js`.
+- **Script Engine**: Secure execution of PowerShell (Windows) and Bash (macOS/Linux) scripts with customizable execution timeouts.
+- **Power Management**: Support for remote Shutdown, Restart, Sleep, Hibernate, Sign Out, Lock, and Switch User actions with a 5-second grace period cancel queue.
+- **Cloudflare Tunnels**: Integrated `cloudflared` tunnel creation for secure remote access without port forwarding.
+- **System Tray Widget**: Native system tray application displaying active status, quick tunnel actions, and port configurations.
+- **Security & MFA**: Cryptographic password hashing (Argon2id), mandatory TOTP multi-factor authentication setup, recovery code generation, and automated lockout policies.
diff --git a/README.md b/README.md
index 60583bb..6de0d72 100644
--- a/README.md
+++ b/README.md
@@ -139,7 +139,8 @@ On first run, the backend:
2. Stores the JWT signing key in your OS keychain (Credential Manager / Keychain / Secret Service)
3. Downloads the correct `cloudflared` binary for your platform with SHA256 verification
4. Binds to `localhost:3939` (falls back to random port)
-5. Opens your browser to the setup wizard
+5. Generates a one-time **Setup Key** printed to the console window. This key acts as a security token to verify physical/owner access to the host machine.
+6. Opens your browser to the first-run Setup Wizard, where you must enter this Setup Key to unlock configuration steps (admin credentials, TOTP, and remote access relay).
### Production Build
diff --git a/backend/src/lib.rs b/backend/src/lib.rs
index 3e0c408..eae7c17 100644
--- a/backend/src/lib.rs
+++ b/backend/src/lib.rs
@@ -572,6 +572,11 @@ pub fn build_router(state: AppState) -> Router {
.route("/ws", get(ws::ws_handler))
.route("/api/telemetry/history", get(history_handler))
.route("/api/setup/status", get(setup::setup_status_handler))
+ .route("/api/setup/check-token", get(setup::check_token_handler))
+ .route(
+ "/api/setup/verify-setup-token",
+ post(setup::verify_setup_token_handler),
+ )
.route("/api/setup/password", post(setup::api_password_handler))
.route("/api/setup/totp", post(setup::api_totp_handler))
.route(
diff --git a/backend/src/main.rs b/backend/src/main.rs
index 91c53e2..9295828 100644
--- a/backend/src/main.rs
+++ b/backend/src/main.rs
@@ -418,6 +418,7 @@ async fn main() {
println!(" Manage → System tray icon");
println!(" Setup key → {}", setup_token);
println!(" ──────────────────────────────────────────────────");
+ println!(" (Use the setup key to authorize the first-run configuration wizard)");
let is_headless = cfg!(target_os = "linux") && std::env::var("DISPLAY").is_err();
if is_headless {
@@ -425,10 +426,16 @@ async fn main() {
println!(" Headless mode — no display detected.");
println!(" Use SSH port forwarding to reach the dashboard:");
println!(" ssh -L {}:127.0.0.1:{} user@host", port, port);
+ println!();
+ println!(" Note: Keep the Setup key above handy. When you open the");
+ println!(" dashboard, you must enter it to unlock the setup wizard.");
} else {
println!();
println!(" Press Enter to open the dashboard.");
println!(" Close this window at any time — SysDeck keeps running.");
+ println!();
+ println!(" Note: Copy the Setup key above before proceeding. It is");
+ println!(" required to authorize the first-run configuration wizard.");
}
println!();
// ────────────────────────────────────────────────────────────────────────
diff --git a/backend/src/setup.rs b/backend/src/setup.rs
index cdb7b57..584361c 100644
--- a/backend/src/setup.rs
+++ b/backend/src/setup.rs
@@ -69,6 +69,11 @@ pub struct ProgressQuery {
pub token: String,
}
+#[derive(Deserialize)]
+pub struct VerifySetupTokenRequest {
+ pub token: String,
+}
+
#[derive(Serialize)]
pub(crate) struct SetupStatus {
is_setup_complete: bool,
@@ -83,12 +88,93 @@ pub(crate) async fn setup_status_handler(State(state): State) -> Json<
Json(SetupStatus { is_setup_complete })
}
+pub(crate) async fn check_token_handler(State(state): State) -> Json {
+ let needs_setup = {
+ let conn = state.db.lock().await;
+ db::is_setup_complete(&conn).map(|c| !c).unwrap_or(true)
+ };
+ Json(serde_json::json!({ "token_required": needs_setup }))
+}
+
+pub(crate) async fn verify_setup_token_handler(
+ State(state): State,
+ Json(body): Json,
+) -> Response {
+ if body.token == *state.setup_token {
+ use sha2::{Sha256, Digest};
+ let mut hasher = Sha256::new();
+ hasher.update(state.setup_token.as_bytes());
+ let hash_hex = hasher.finalize().iter().map(|b| format!("{:02x}", b)).collect::();
+
+ let cookie_val = format!(
+ "setup_authorized={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=3600",
+ hash_hex
+ );
+
+ Response::builder()
+ .header(axum::http::header::SET_COOKIE, cookie_val)
+ .body(axum::body::Body::from(
+ serde_json::json!({ "success": true }).to_string()
+ ))
+ .unwrap()
+ } else {
+ Response::builder()
+ .status(StatusCode::BAD_REQUEST)
+ .body(axum::body::Body::from(
+ serde_json::json!({ "success": false, "error": "Invalid setup token" }).to_string()
+ ))
+ .unwrap()
+ }
+}
+
// --- JSON API Handlers ---
pub async fn api_password_handler(
State(state): State,
+ headers: axum::http::HeaderMap,
Json(body): Json,
) -> Response {
+ let needs_setup = {
+ let conn = state.db.lock().await;
+ db::is_setup_complete(&conn).map(|c| !c).unwrap_or(true)
+ };
+
+ if needs_setup {
+ let is_authorized = headers
+ .get(axum::http::header::COOKIE)
+ .and_then(|c| c.to_str().ok())
+ .and_then(|c_str| {
+ c_str.split(';').find_map(|cookie| {
+ let parts: Vec<&str> = cookie.trim().split('=').collect();
+ if parts.len() == 2 && parts[0] == "setup_authorized" {
+ Some(parts[1])
+ } else {
+ None
+ }
+ })
+ })
+ .map(|cookie_val| {
+ use sha2::{Sha256, Digest};
+ let mut hasher = Sha256::new();
+ hasher.update(state.setup_token.as_bytes());
+ let expected_hash = hasher.finalize().iter().map(|b| format!("{:02x}", b)).collect::();
+ cookie_val == expected_hash
+ })
+ .unwrap_or(false);
+
+ if !is_authorized {
+ tracing::warn!(handler = "api_password_handler", "unauthorized setup attempt");
+ return (
+ StatusCode::UNAUTHORIZED,
+ Json(serde_json::json!({
+ "success": false,
+ "error": "Unauthorized: Setup token verification required"
+ })),
+ )
+ .into_response();
+ }
+ }
+
if body.password.len() < 8 {
tracing::warn!(handler = "api_password_handler", "password too short");
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"success": false, "error": "Password must be at least 8 characters"}))).into_response();
diff --git a/backend/tests/integration.rs b/backend/tests/integration.rs
index a12f5dc..6e3bf22 100644
--- a/backend/tests/integration.rs
+++ b/backend/tests/integration.rs
@@ -14,10 +14,21 @@ use tower::ServiceExt;
async fn test_setup_json_api_full_flow() {
let (mut router, _state) = test_app();
- // Step 1: Set password
+ // Verify setup token to get setup_authorized cookie
let resp = post_json(
+ &mut router,
+ "/api/setup/verify-setup-token",
+ json!({"token": "test-setup-token-123"}),
+ )
+ .await;
+ assert_eq!(resp.status(), StatusCode::OK);
+ let cookie = resp.headers().get(axum::http::header::SET_COOKIE).unwrap().to_str().unwrap().to_string();
+
+ // Step 1: Set password
+ let resp = authed_post_json(
&mut router,
"/api/setup/password",
+ &cookie,
json!({"password": "MySecureP@ss1"}),
)
.await;
@@ -84,9 +95,20 @@ async fn test_setup_invalid_token_returns_error() {
#[tokio::test]
async fn test_setup_weak_password_rejected() {
let (mut router, _state) = test_app();
+ // Verify setup token to get setup_authorized cookie
let resp = post_json(
+ &mut router,
+ "/api/setup/verify-setup-token",
+ json!({"token": "test-setup-token-123"}),
+ )
+ .await;
+ assert_eq!(resp.status(), StatusCode::OK);
+ let cookie = resp.headers().get(axum::http::header::SET_COOKIE).unwrap().to_str().unwrap().to_string();
+
+ let resp = authed_post_json(
&mut router,
"/api/setup/password",
+ &cookie,
json!({"password": "weak"}),
)
.await;
From aabcf8e44e41bc144cf56c9eb29ec28af5fb23b9 Mon Sep 17 00:00:00 2001
From: Aditya
Date: Sat, 4 Jul 2026 00:09:55 +0530
Subject: [PATCH 2/6] ci: trigger CI checks on dev branch and format codebase
---
.github/workflows/ci.yml | 4 ++--
backend/src/setup.rs | 27 +++++++++++++++++++--------
backend/tests/integration.rs | 16 ++++++++++++++--
3 files changed, 35 insertions(+), 12 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 94d1b15..fd0e78f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,9 +2,9 @@ name: CI Checks
on:
push:
- branches: [ main ]
+ branches: [ main, dev ]
pull_request:
- branches: [ main ]
+ branches: [ main, dev ]
env:
CARGO_TERM_COLOR: always
diff --git a/backend/src/setup.rs b/backend/src/setup.rs
index 584361c..b14cf6b 100644
--- a/backend/src/setup.rs
+++ b/backend/src/setup.rs
@@ -101,27 +101,31 @@ pub(crate) async fn verify_setup_token_handler(
Json(body): Json,
) -> Response {
if body.token == *state.setup_token {
- use sha2::{Sha256, Digest};
+ use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(state.setup_token.as_bytes());
- let hash_hex = hasher.finalize().iter().map(|b| format!("{:02x}", b)).collect::();
+ let hash_hex = hasher
+ .finalize()
+ .iter()
+ .map(|b| format!("{:02x}", b))
+ .collect::();
let cookie_val = format!(
"setup_authorized={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=3600",
hash_hex
);
-
+
Response::builder()
.header(axum::http::header::SET_COOKIE, cookie_val)
.body(axum::body::Body::from(
- serde_json::json!({ "success": true }).to_string()
+ serde_json::json!({ "success": true }).to_string(),
))
.unwrap()
} else {
Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(axum::body::Body::from(
- serde_json::json!({ "success": false, "error": "Invalid setup token" }).to_string()
+ serde_json::json!({ "success": false, "error": "Invalid setup token" }).to_string(),
))
.unwrap()
}
@@ -154,16 +158,23 @@ pub async fn api_password_handler(
})
})
.map(|cookie_val| {
- use sha2::{Sha256, Digest};
+ use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(state.setup_token.as_bytes());
- let expected_hash = hasher.finalize().iter().map(|b| format!("{:02x}", b)).collect::();
+ let expected_hash = hasher
+ .finalize()
+ .iter()
+ .map(|b| format!("{:02x}", b))
+ .collect::();
cookie_val == expected_hash
})
.unwrap_or(false);
if !is_authorized {
- tracing::warn!(handler = "api_password_handler", "unauthorized setup attempt");
+ tracing::warn!(
+ handler = "api_password_handler",
+ "unauthorized setup attempt"
+ );
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
diff --git a/backend/tests/integration.rs b/backend/tests/integration.rs
index 6e3bf22..badfa3c 100644
--- a/backend/tests/integration.rs
+++ b/backend/tests/integration.rs
@@ -22,7 +22,13 @@ async fn test_setup_json_api_full_flow() {
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
- let cookie = resp.headers().get(axum::http::header::SET_COOKIE).unwrap().to_str().unwrap().to_string();
+ let cookie = resp
+ .headers()
+ .get(axum::http::header::SET_COOKIE)
+ .unwrap()
+ .to_str()
+ .unwrap()
+ .to_string();
// Step 1: Set password
let resp = authed_post_json(
@@ -103,7 +109,13 @@ async fn test_setup_weak_password_rejected() {
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
- let cookie = resp.headers().get(axum::http::header::SET_COOKIE).unwrap().to_str().unwrap().to_string();
+ let cookie = resp
+ .headers()
+ .get(axum::http::header::SET_COOKIE)
+ .unwrap()
+ .to_str()
+ .unwrap()
+ .to_string();
let resp = authed_post_json(
&mut router,
From aec10d0604ab80c00e0f9fde7b62913c6fda3e5a Mon Sep 17 00:00:00 2001
From: Aditya
Date: Sat, 4 Jul 2026 00:21:22 +0530
Subject: [PATCH 3/6] fix(windows): resolve empty console window flashing on
startup and polling
---
CHANGELOG.md | 3 +++
backend/src/hardware.rs | 7 +++---
backend/src/lib.rs | 25 ++++++++++++++++---
backend/src/network.rs | 5 ++--
backend/src/power.rs | 54 ++++++++++++++++------------------------
backend/src/process.rs | 2 +-
backend/src/script.rs | 15 ++++++-----
backend/src/telemetry.rs | 2 +-
backend/src/tunnel.rs | 3 +--
9 files changed, 62 insertions(+), 54 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7cc323..f412327 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Startup Console**: Added descriptive instructions and use-case details about the generated setup key in the startup console banner.
- **Documentation**: Documented the Setup Key generation and its verification purposes in the main README.md file.
+### Fixed
+- **Windows Console Flashing**: Resolved an issue where multiple empty Command Prompt/PowerShell terminal windows flashed on the screen on Windows during startup and periodic background operations (telemetry WMI polling, network/hardware status checks, registry queries, and Cloudflare tunnel spawning) by forcing child processes to spawn silently with the `CREATE_NO_WINDOW` process creation flag.
+
## [1.0.1] - 2026-07-03
### Fixed
diff --git a/backend/src/hardware.rs b/backend/src/hardware.rs
index d0e7004..8cbe263 100644
--- a/backend/src/hardware.rs
+++ b/backend/src/hardware.rs
@@ -2,7 +2,6 @@ use axum::extract::State;
use axum::response::{IntoResponse, Json};
use serde::{Deserialize, Serialize};
use serde_json::json;
-use std::process::Command;
use std::sync::atomic::Ordering;
use std::time::Duration;
@@ -82,7 +81,7 @@ pub struct ScheduledPowerRequest {
#[allow(dead_code)]
fn run_cmd(cmd: &str, args: &[&str]) -> Result {
- let output = Command::new(cmd)
+ let output = crate::new_command(cmd)
.args(args)
.output()
.map_err(|e| format!("Failed to execute {}: {}", cmd, e))?;
@@ -99,7 +98,7 @@ fn run_powershell(script: &str) -> Result {
use std::io::Write;
use std::process::Stdio;
- let mut child = Command::new("powershell")
+ let mut child = crate::new_command("powershell")
.args(["-NoProfile", "-Command", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
@@ -1130,7 +1129,7 @@ pub async fn schedule_power_handler(
crate::power::PowerAction::Restart => "/r",
_ => "/s",
};
- let _ = Command::new("shutdown")
+ let _ = crate::new_command("shutdown")
.args([flag, "/f", "/t", "1"])
.spawn();
} else {
diff --git a/backend/src/lib.rs b/backend/src/lib.rs
index eae7c17..6b60fcb 100644
--- a/backend/src/lib.rs
+++ b/backend/src/lib.rs
@@ -79,6 +79,25 @@ pub struct AppState {
pub setup_token: Arc,
}
+pub fn new_command>(program: S) -> std::process::Command {
+ let mut cmd = std::process::Command::new(program);
+ #[cfg(target_os = "windows")]
+ {
+ use std::os::windows::process::CommandExt;
+ cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
+ }
+ cmd
+}
+
+pub fn new_tokio_command>(program: S) -> tokio::process::Command {
+ let mut cmd = tokio::process::Command::new(program);
+ #[cfg(target_os = "windows")]
+ {
+ cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
+ }
+ cmd
+}
+
pub fn get_data_dir() -> PathBuf {
#[cfg(target_os = "windows")]
{
@@ -212,7 +231,7 @@ pub async fn find_available_port() -> (u16, tokio::net::TcpListener) {
fn is_startup_enabled() -> bool {
#[cfg(target_os = "windows")]
{
- std::process::Command::new("reg")
+ new_command("reg")
.args([
"query",
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run",
@@ -234,7 +253,7 @@ fn set_startup(enabled: bool) {
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
if enabled {
- let _ = std::process::Command::new("reg")
+ let _ = new_command("reg")
.args([
"add",
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run",
@@ -248,7 +267,7 @@ fn set_startup(enabled: bool) {
])
.output();
} else {
- let _ = std::process::Command::new("reg")
+ let _ = new_command("reg")
.args([
"delete",
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run",
diff --git a/backend/src/network.rs b/backend/src/network.rs
index bde8b82..8eef1c8 100644
--- a/backend/src/network.rs
+++ b/backend/src/network.rs
@@ -1,7 +1,6 @@
use axum::response::{IntoResponse, Json};
use serde::{Deserialize, Serialize};
use serde_json::json;
-use std::process::Command;
use tracing;
#[derive(Serialize, Debug)]
@@ -51,7 +50,7 @@ fn default_wifi_security() -> String {
}
fn run_cmd(cmd: &str, args: &[&str]) -> Result {
- let output = Command::new(cmd)
+ let output = crate::new_command(cmd)
.args(args)
.output()
.map_err(|e| format!("Failed to execute {}: {}", cmd, e))?;
@@ -66,7 +65,7 @@ fn run_cmd(cmd: &str, args: &[&str]) -> Result {
fn run_powershell(script: &str) -> Result {
use std::io::Write;
use std::process::Stdio;
- let mut child = Command::new("powershell")
+ let mut child = crate::new_command("powershell")
.args(["-NoProfile", "-Command", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
diff --git a/backend/src/power.rs b/backend/src/power.rs
index 239bdb0..39c9f9d 100644
--- a/backend/src/power.rs
+++ b/backend/src/power.rs
@@ -31,73 +31,63 @@ impl SystemCommands for RealOs {
#[cfg(target_os = "windows")]
match action {
PowerAction::Shutdown => {
- let _ = std::process::Command::new("shutdown")
+ let _ = crate::new_command("shutdown")
.args(["/s", "/t", "1"])
.spawn();
}
PowerAction::Restart => {
- let _ = std::process::Command::new("shutdown")
+ let _ = crate::new_command("shutdown")
.args(["/r", "/t", "1"])
.spawn();
}
PowerAction::Sleep => {
- let _ = std::process::Command::new("rundll32.exe")
+ let _ = crate::new_command("rundll32.exe")
.args(["powrprof.dll,SetSuspendState", "0", "1", "0"])
.spawn();
}
PowerAction::Hibernate => {
- let _ = std::process::Command::new("rundll32.exe")
+ let _ = crate::new_command("rundll32.exe")
.args(["powrprof.dll,SetSuspendState", "1", "1", "0"])
.spawn();
}
PowerAction::SignOut => {
- let _ = std::process::Command::new("shutdown").args(["/l"]).spawn();
+ let _ = crate::new_command("shutdown").args(["/l"]).spawn();
}
PowerAction::Lock => {
- let _ = std::process::Command::new("rundll32.exe")
+ let _ = crate::new_command("rundll32.exe")
.args(["user32.dll,LockWorkStation"])
.spawn();
}
PowerAction::SwitchUser => {
// Switch to the login screen to allow another user to log in
- let _ = std::process::Command::new("tsdiscon.exe").spawn();
+ let _ = crate::new_command("tsdiscon.exe").spawn();
}
}
#[cfg(target_os = "linux")]
match action {
PowerAction::Shutdown => {
- let _ = std::process::Command::new("systemctl")
- .arg("poweroff")
- .spawn();
+ let _ = crate::new_command("systemctl").arg("poweroff").spawn();
}
PowerAction::Restart => {
- let _ = std::process::Command::new("systemctl")
- .arg("reboot")
- .spawn();
+ let _ = crate::new_command("systemctl").arg("reboot").spawn();
}
PowerAction::Sleep => {
- let _ = std::process::Command::new("systemctl")
- .arg("suspend")
- .spawn();
+ let _ = crate::new_command("systemctl").arg("suspend").spawn();
}
PowerAction::Hibernate => {
- let _ = std::process::Command::new("systemctl")
- .arg("hibernate")
- .spawn();
+ let _ = crate::new_command("systemctl").arg("hibernate").spawn();
}
PowerAction::SignOut => {
- let _ = std::process::Command::new("loginctl")
+ let _ = crate::new_command("loginctl")
.args(["terminate-session", "self"])
.spawn();
}
PowerAction::Lock => {
- let _ = std::process::Command::new("loginctl")
- .arg("lock-session")
- .spawn();
+ let _ = crate::new_command("loginctl").arg("lock-session").spawn();
}
PowerAction::SwitchUser => {
// Switch user via display manager
- let _ = std::process::Command::new("dm-tool")
+ let _ = crate::new_command("dm-tool")
.arg("switch-to-greeter")
.spawn();
}
@@ -105,41 +95,41 @@ impl SystemCommands for RealOs {
#[cfg(target_os = "macos")]
match action {
PowerAction::Shutdown => {
- let _ = std::process::Command::new("osascript")
+ let _ = crate::new_command("osascript")
.args(["-e", "tell application \"System Events\" to shut down"])
.spawn();
}
PowerAction::Restart => {
- let _ = std::process::Command::new("osascript")
+ let _ = crate::new_command("osascript")
.args(["-e", "tell application \"System Events\" to restart"])
.spawn();
}
PowerAction::Sleep => {
- let _ = std::process::Command::new("osascript")
+ let _ = crate::new_command("osascript")
.args(["-e", "tell application \"System Events\" to sleep"])
.spawn();
}
PowerAction::Hibernate => {
// macOS: enable hibernate mode then sleep
- let _ = std::process::Command::new("pmset")
+ let _ = crate::new_command("pmset")
.args(["-a", "hibernatemode", "25"])
.spawn();
- let _ = std::process::Command::new("osascript")
+ let _ = crate::new_command("osascript")
.args(["-e", "tell application \"System Events\" to sleep"])
.spawn();
}
PowerAction::SignOut => {
- let _ = std::process::Command::new("osascript")
+ let _ = crate::new_command("osascript")
.args(["-e", "tell application \"System Events\" to log out"])
.spawn();
}
PowerAction::Lock => {
- let _ = std::process::Command::new("/System/Library/CoreServices/Menu Extras/User.menu/Contents/Resources/CGSession")
+ let _ = crate::new_command("/System/Library/CoreServices/Menu Extras/User.menu/Contents/Resources/CGSession")
.arg("-suspend")
.spawn();
}
PowerAction::SwitchUser => {
- let _ = std::process::Command::new("/System/Library/CoreServices/Menu Extras/User.menu/Contents/Resources/CGSession")
+ let _ = crate::new_command("/System/Library/CoreServices/Menu Extras/User.menu/Contents/Resources/CGSession")
.arg("-suspend")
.spawn();
}
diff --git a/backend/src/process.rs b/backend/src/process.rs
index 1e8ca26..f31a12f 100644
--- a/backend/src/process.rs
+++ b/backend/src/process.rs
@@ -48,7 +48,7 @@ pub async fn kill_handler(Json(body): Json) -> impl IntoResponse {
#[cfg(target_os = "windows")]
{
let pid = body.pid;
- match std::process::Command::new("taskkill")
+ match crate::new_command("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.output()
{
diff --git a/backend/src/script.rs b/backend/src/script.rs
index 70ddbc0..5e12a88 100644
--- a/backend/src/script.rs
+++ b/backend/src/script.rs
@@ -10,7 +10,6 @@ use axum::response::{IntoResponse, Json, Response};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::io::AsyncBufReadExt;
-use tokio::process::Command;
use tokio::sync::broadcast;
use tokio::sync::Mutex;
use uuid::Uuid;
@@ -19,7 +18,7 @@ fn kill_process_tree(pid: Option) {
let Some(pid) = pid else { return };
#[cfg(target_os = "windows")]
{
- let _ = std::process::Command::new("taskkill")
+ let _ = crate::new_command("taskkill")
.args(["/T", "/F", "/PID", &pid.to_string()])
.spawn()
.map(|mut c| {
@@ -28,10 +27,10 @@ fn kill_process_tree(pid: Option) {
}
#[cfg(unix)]
{
- let _ = std::process::Command::new("pkill")
+ let _ = crate::new_command("pkill")
.args(["-P", &pid.to_string()])
.spawn();
- let _ = std::process::Command::new("kill")
+ let _ = crate::new_command("kill")
.args(["-9", &pid.to_string()])
.spawn();
}
@@ -223,7 +222,7 @@ async fn run_script(
"$OutputEncoding = [System.Text.Encoding]::UTF8; {}",
content
);
- Command::new("powershell")
+ crate::new_tokio_command("powershell")
.args(["-NoProfile", "-Command", &ps_content])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
@@ -232,7 +231,7 @@ async fn run_script(
#[cfg(target_os = "windows")]
{
let content = content.replace("\r\n", " & ").replace('\n', " & ");
- Command::new("cmd")
+ crate::new_tokio_command("cmd")
.args(["/C", &content])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
@@ -250,7 +249,7 @@ async fn run_script(
#[cfg(target_os = "windows")]
{
let content = content.replace("\r\n", " & ").replace('\n', " & ");
- Command::new("cmd")
+ crate::new_tokio_command("cmd")
.args(["/C", &content])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
@@ -258,7 +257,7 @@ async fn run_script(
}
#[cfg(not(target_os = "windows"))]
{
- Command::new("bash")
+ crate::new_tokio_command("bash")
.args(["-c", content])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
diff --git a/backend/src/telemetry.rs b/backend/src/telemetry.rs
index d3b71e1..6bc8d66 100644
--- a/backend/src/telemetry.rs
+++ b/backend/src/telemetry.rs
@@ -60,7 +60,7 @@ fn get_wmi_temperatures() -> (Option, Option) {
let mut gpu = None;
for (source, cmd) in &queries {
tracing::debug!(source, "querying WMI thermal zones");
- let output = match std::process::Command::new("powershell")
+ let output = match crate::new_command("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", cmd])
.output()
{
diff --git a/backend/src/tunnel.rs b/backend/src/tunnel.rs
index 721d0c4..83c8cb4 100644
--- a/backend/src/tunnel.rs
+++ b/backend/src/tunnel.rs
@@ -9,7 +9,6 @@ use serde::Serialize;
use sha2::{Digest, Sha256};
use tokio::fs;
use tokio::io::{AsyncBufReadExt, BufReader};
-use tokio::process::Command;
use tokio::sync::{broadcast, Mutex, RwLock};
use crate::AppState;
@@ -260,7 +259,7 @@ async fn run_tunnel_loop(
None => return,
};
- let mut child = match Command::new(&state.exe_path)
+ let mut child = match crate::new_tokio_command(&state.exe_path)
.args([
"tunnel",
"--no-autoupdate",
From 48386da883591634194a2f08abb0897b086fc6d7 Mon Sep 17 00:00:00 2001
From: Aditya
Date: Sat, 4 Jul 2026 00:51:12 +0530
Subject: [PATCH 4/6] feat: implement Zero-Friction Portable Workflow (removed
setup key, added welcome wizard step, auto-start tunnel on setup complete)
---
CHANGELOG.md | 8 +-
README.md | 2 +-
backend/src/lib.rs | 6 --
backend/src/main.rs | 77 ++++++-----------
backend/src/setup.rs | 108 ++++--------------------
backend/tests/common/mod.rs | 1 -
backend/tests/integration.rs | 36 +-------
frontend/src/pages/Setup.tsx | 155 ++++++++++++++++-------------------
8 files changed, 120 insertions(+), 273 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f412327..113b4ca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,12 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [1.1.0] - 2026-07-04
+## [1.1.0] - Unreleased
### Added
-- **Setup Key Verification**: Implemented backend validation for the console-printed Setup Key (via new endpoints `/api/setup/check-token` and `/api/setup/verify-setup-token`) and cookie-based authorization to secure the Setup Wizard against unauthorized remote access.
-- **Startup Console**: Added descriptive instructions and use-case details about the generated setup key in the startup console banner.
-- **Documentation**: Documented the Setup Key generation and its verification purposes in the main README.md file.
+- **Zero-Friction Portable Workflow**: Redesigned the onboarding process by completely removing the setup key requirements. Added a welcoming onboarding screen (Step 0) in the browser before configuring the system.
+- **Cloudflare Tunnel Auto-Start**: Configured the Cloudflare tunnel to dynamically start immediately upon completing the setup wizard if the user opts in, removing any need for a server restart.
+- **User-Friendly Startup Console**: Updated the server CLI banner on launch to show a clean box-drawing layout directing the user to press Enter to instantly minimize the window to system tray and open their default browser.
### Fixed
- **Windows Console Flashing**: Resolved an issue where multiple empty Command Prompt/PowerShell terminal windows flashed on the screen on Windows during startup and periodic background operations (telemetry WMI polling, network/hardware status checks, registry queries, and Cloudflare tunnel spawning) by forcing child processes to spawn silently with the `CREATE_NO_WINDOW` process creation flag.
diff --git a/README.md b/README.md
index 6de0d72..a7b119b 100644
--- a/README.md
+++ b/README.md
@@ -265,7 +265,7 @@ sysdeck/
| POST | `/api/setup/password` | Set initial password (step 1) |
| POST | `/api/setup/totp` | Generate TOTP secret (step 2) |
| POST | `/api/setup/verify-totp` | Verify TOTP code (step 3) |
-| POST | `/api/setup/recovery-codes` | Generate recovery codes (step 4) |
+| POST | `/api/setup/relay` | Set Cloudflare relay opt-in (step 4) |
| POST | `/api/setup/finish` | Complete setup |
### Dashboard & Telemetry
diff --git a/backend/src/lib.rs b/backend/src/lib.rs
index 6b60fcb..0c42d05 100644
--- a/backend/src/lib.rs
+++ b/backend/src/lib.rs
@@ -76,7 +76,6 @@ pub struct AppState {
pub terminal_state: Arc,
pub tunnel_state: Arc,
pub port: u16,
- pub setup_token: Arc,
}
pub fn new_command>(program: S) -> std::process::Command {
@@ -591,11 +590,6 @@ pub fn build_router(state: AppState) -> Router {
.route("/ws", get(ws::ws_handler))
.route("/api/telemetry/history", get(history_handler))
.route("/api/setup/status", get(setup::setup_status_handler))
- .route("/api/setup/check-token", get(setup::check_token_handler))
- .route(
- "/api/setup/verify-setup-token",
- post(setup::verify_setup_token_handler),
- )
.route("/api/setup/password", post(setup::api_password_handler))
.route("/api/setup/totp", post(setup::api_totp_handler))
.route(
diff --git a/backend/src/main.rs b/backend/src/main.rs
index 9295828..435f05b 100644
--- a/backend/src/main.rs
+++ b/backend/src/main.rs
@@ -5,7 +5,6 @@
use std::sync::Arc;
-use rand::Rng;
use tokio::sync::{broadcast, oneshot, Mutex};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
@@ -202,14 +201,6 @@ async fn main() {
let jwt_key = Arc::new(auth::load_or_create_jwt_key().expect("Failed to load JWT signing key"));
println!(" ✓ JWT signing key loaded");
- // Generate setup token for headless/remote setup
- let setup_token: String = rand::thread_rng()
- .sample_iter(&rand::distributions::Alphanumeric)
- .take(16)
- .map(char::from)
- .collect();
- let setup_token = Arc::new(setup_token);
-
let (port, listener) = find_available_port().await;
println!(" ✓ Server bound to localhost:{}", port);
@@ -348,7 +339,6 @@ async fn main() {
terminal_state,
tunnel_state: tunnel_state.clone(),
port,
- setup_token: setup_token.clone(),
};
let app = build_router(app_state.clone());
@@ -411,56 +401,37 @@ async fn main() {
server_ready_rx.await.ok();
- // ── Startup banner (complete) ────────────────────────────────────────────
+ // ── Startup banner ────────────────────────────────────────────
println!();
- println!(" ──────────────────────────────────────────────────");
- println!(" Dashboard → http://localhost:{}", port);
- println!(" Manage → System tray icon");
- println!(" Setup key → {}", setup_token);
- println!(" ──────────────────────────────────────────────────");
- println!(" (Use the setup key to authorize the first-run configuration wizard)");
-
- let is_headless = cfg!(target_os = "linux") && std::env::var("DISPLAY").is_err();
- if is_headless {
- println!();
- println!(" Headless mode — no display detected.");
- println!(" Use SSH port forwarding to reach the dashboard:");
- println!(" ssh -L {}:127.0.0.1:{} user@host", port, port);
- println!();
- println!(" Note: Keep the Setup key above handy. When you open the");
- println!(" dashboard, you must enter it to unlock the setup wizard.");
- } else {
- println!();
- println!(" Press Enter to open the dashboard.");
- println!(" Close this window at any time — SysDeck keeps running.");
- println!();
- println!(" Note: Copy the Setup key above before proceeding. It is");
- println!(" required to authorize the first-run configuration wizard.");
- }
+ println!("┌────────────────────────────────────────────────────────┐");
+ println!("│ ⚡ SysDeck is running! │");
+ println!("├────────────────────────────────────────────────────────┤");
+ println!("│ The app is now active in your system tray. │");
+ println!("│ │");
+ println!("│ Press [Enter] to open the setup wizard in your browser│");
+ println!("│ Press [Ctrl+C] to stop the app │");
+ println!("└────────────────────────────────────────────────────────┘");
println!();
- // ────────────────────────────────────────────────────────────────────────
// Spawn a blocking task to wait for Enter, then open the browser and
// detach the splash console. On Windows, if the user closes the window
// first (CTRL_CLOSE_EVENT), FreeConsole() closes stdin, read_line()
// returns EOF, and open_dashboard_once() is a no-op (already opened).
- if !is_headless {
- tokio::task::spawn_blocking(move || {
- let mut buf = String::new();
- // Returns Ok(0) on EOF (stdin closed by FreeConsole) or Ok(n) on Enter.
- let _ = std::io::stdin().read_line(&mut buf);
-
- #[cfg(windows)]
- {
- detach_splash_console();
- open_dashboard_once();
- }
- #[cfg(not(windows))]
- {
- let _ = open::that(format!("http://localhost:{}", port));
- }
- });
- }
+ tokio::task::spawn_blocking(move || {
+ let mut buf = String::new();
+ // Returns Ok(0) on EOF (stdin closed by FreeConsole) or Ok(n) on Enter.
+ let _ = std::io::stdin().read_line(&mut buf);
+
+ #[cfg(windows)]
+ {
+ detach_splash_console();
+ open_dashboard_once();
+ }
+ #[cfg(not(windows))]
+ {
+ let _ = open::that(format!("http://localhost:{}", port));
+ }
+ });
server_handle.await.expect("Server task panicked");
diff --git a/backend/src/setup.rs b/backend/src/setup.rs
index b14cf6b..72806ac 100644
--- a/backend/src/setup.rs
+++ b/backend/src/setup.rs
@@ -69,11 +69,6 @@ pub struct ProgressQuery {
pub token: String,
}
-#[derive(Deserialize)]
-pub struct VerifySetupTokenRequest {
- pub token: String,
-}
-
#[derive(Serialize)]
pub(crate) struct SetupStatus {
is_setup_complete: bool,
@@ -88,54 +83,10 @@ pub(crate) async fn setup_status_handler(State(state): State) -> Json<
Json(SetupStatus { is_setup_complete })
}
-pub(crate) async fn check_token_handler(State(state): State) -> Json {
- let needs_setup = {
- let conn = state.db.lock().await;
- db::is_setup_complete(&conn).map(|c| !c).unwrap_or(true)
- };
- Json(serde_json::json!({ "token_required": needs_setup }))
-}
-
-pub(crate) async fn verify_setup_token_handler(
- State(state): State,
- Json(body): Json,
-) -> Response {
- if body.token == *state.setup_token {
- use sha2::{Digest, Sha256};
- let mut hasher = Sha256::new();
- hasher.update(state.setup_token.as_bytes());
- let hash_hex = hasher
- .finalize()
- .iter()
- .map(|b| format!("{:02x}", b))
- .collect::();
-
- let cookie_val = format!(
- "setup_authorized={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=3600",
- hash_hex
- );
-
- Response::builder()
- .header(axum::http::header::SET_COOKIE, cookie_val)
- .body(axum::body::Body::from(
- serde_json::json!({ "success": true }).to_string(),
- ))
- .unwrap()
- } else {
- Response::builder()
- .status(StatusCode::BAD_REQUEST)
- .body(axum::body::Body::from(
- serde_json::json!({ "success": false, "error": "Invalid setup token" }).to_string(),
- ))
- .unwrap()
- }
-}
-
// --- JSON API Handlers ---
pub async fn api_password_handler(
State(state): State,
- headers: axum::http::HeaderMap,
Json(body): Json,
) -> Response {
let needs_setup = {
@@ -143,47 +94,16 @@ pub async fn api_password_handler(
db::is_setup_complete(&conn).map(|c| !c).unwrap_or(true)
};
- if needs_setup {
- let is_authorized = headers
- .get(axum::http::header::COOKIE)
- .and_then(|c| c.to_str().ok())
- .and_then(|c_str| {
- c_str.split(';').find_map(|cookie| {
- let parts: Vec<&str> = cookie.trim().split('=').collect();
- if parts.len() == 2 && parts[0] == "setup_authorized" {
- Some(parts[1])
- } else {
- None
- }
- })
- })
- .map(|cookie_val| {
- use sha2::{Digest, Sha256};
- let mut hasher = Sha256::new();
- hasher.update(state.setup_token.as_bytes());
- let expected_hash = hasher
- .finalize()
- .iter()
- .map(|b| format!("{:02x}", b))
- .collect::();
- cookie_val == expected_hash
- })
- .unwrap_or(false);
-
- if !is_authorized {
- tracing::warn!(
- handler = "api_password_handler",
- "unauthorized setup attempt"
- );
- return (
- StatusCode::UNAUTHORIZED,
- Json(serde_json::json!({
- "success": false,
- "error": "Unauthorized: Setup token verification required"
- })),
- )
- .into_response();
- }
+ if !needs_setup {
+ tracing::warn!(handler = "api_password_handler", "setup already complete");
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({
+ "success": false,
+ "error": "Setup is already completed"
+ })),
+ )
+ .into_response();
}
if body.password.len() < 8 {
@@ -389,6 +309,14 @@ pub async fn api_finish_handler(
);
let _ = db::wal_checkpoint(&conn);
drop(conn);
+
+ if flow.relay_opt_in {
+ let ts = state.tunnel_state.clone();
+ tokio::spawn(async move {
+ let _ = crate::tunnel::TunnelState::start(ts).await;
+ });
+ }
+
tracing::info!(handler = "api_finish_handler", "setup complete");
Json(serde_json::json!({"success": true})).into_response()
}
diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs
index 411b02b..73b332a 100644
--- a/backend/tests/common/mod.rs
+++ b/backend/tests/common/mod.rs
@@ -90,7 +90,6 @@ fn test_app_inner(
terminal_state: Arc::new(TerminalState::default()),
tunnel_state: Arc::new(tunnel_state),
port: 3939,
- setup_token: Arc::new("test-setup-token-123".to_string()),
};
let router = sysdeck_agent::build_router(app_state.clone());
diff --git a/backend/tests/integration.rs b/backend/tests/integration.rs
index badfa3c..a12f5dc 100644
--- a/backend/tests/integration.rs
+++ b/backend/tests/integration.rs
@@ -14,27 +14,10 @@ use tower::ServiceExt;
async fn test_setup_json_api_full_flow() {
let (mut router, _state) = test_app();
- // Verify setup token to get setup_authorized cookie
- let resp = post_json(
- &mut router,
- "/api/setup/verify-setup-token",
- json!({"token": "test-setup-token-123"}),
- )
- .await;
- assert_eq!(resp.status(), StatusCode::OK);
- let cookie = resp
- .headers()
- .get(axum::http::header::SET_COOKIE)
- .unwrap()
- .to_str()
- .unwrap()
- .to_string();
-
// Step 1: Set password
- let resp = authed_post_json(
+ let resp = post_json(
&mut router,
"/api/setup/password",
- &cookie,
json!({"password": "MySecureP@ss1"}),
)
.await;
@@ -101,26 +84,9 @@ async fn test_setup_invalid_token_returns_error() {
#[tokio::test]
async fn test_setup_weak_password_rejected() {
let (mut router, _state) = test_app();
- // Verify setup token to get setup_authorized cookie
let resp = post_json(
- &mut router,
- "/api/setup/verify-setup-token",
- json!({"token": "test-setup-token-123"}),
- )
- .await;
- assert_eq!(resp.status(), StatusCode::OK);
- let cookie = resp
- .headers()
- .get(axum::http::header::SET_COOKIE)
- .unwrap()
- .to_str()
- .unwrap()
- .to_string();
-
- let resp = authed_post_json(
&mut router,
"/api/setup/password",
- &cookie,
json!({"password": "weak"}),
)
.await;
diff --git a/frontend/src/pages/Setup.tsx b/frontend/src/pages/Setup.tsx
index 67e93fb..38d46dd 100644
--- a/frontend/src/pages/Setup.tsx
+++ b/frontend/src/pages/Setup.tsx
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { useNavigate } from "react-router-dom"
-import { Check, Copy, Download, Eye, EyeOff, ShieldAlert } from "lucide-react"
+import { Check, Copy, Download, Eye, EyeOff } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
@@ -347,6 +347,11 @@ function StepRelay({ onComplete }: { onComplete: (enabled: boolean) => void }) {
Optionally expose this agent to the internet via a Cloudflare Quick Tunnel.
This allows remote access from outside your local network without port forwarding.
+
+
+ *Note: Because this uses a free, zero-signup tunnel, your remote URL will change every time the app restarts. We are working on adding persistent domain support soon!
+
+
+ You can turn on the Cloudflare Relay to access this PC from anywhere in the world.
+
+
+ *Note: Because this uses a free, zero-signup tunnel, your remote URL will change every time the app restarts. We are working on adding persistent domain support soon!
+
+