diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 8d5dee5..6a7b76f 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -27,7 +27,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] } get_if_addrs = "0.5" -reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"], default-features = false } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } once_cell = "1.19" thiserror = "1.0" @@ -35,3 +35,4 @@ chrono = { version = "0.4", features = ["serde"] } parking_lot = "0.12" hostname = "0.4" base64 = "0.22" +sha2 = "0.10" diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index c20a616..7aaafcd 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,8 +1,19 @@ mod agent; +#[cfg(target_os = "windows")] +mod rustdesk; use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile}; use tauri_plugin_store::Builder as StorePluginBuilder; +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RustdeskProvisioningResult { + pub id: String, + pub password: String, + pub installed_version: Option, + pub updated: bool, +} + #[tauri::command] fn collect_machine_profile() -> Result { collect_profile().map_err(|error| error.to_string()) @@ -38,6 +49,26 @@ fn open_devtools(window: tauri::WebviewWindow) -> Result<(), String> { Ok(()) } +#[tauri::command] +async fn provision_rustdesk(machine_id: Option) -> Result { + let machine_id = machine_id + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "Informe o identificador da máquina para provisionar o RustDesk.".to_string())?; + tauri::async_runtime::spawn_blocking(move || run_rustdesk_provision(machine_id)) + .await + .map_err(|error| error.to_string())? +} + +#[cfg(target_os = "windows")] +fn run_rustdesk_provision(machine_id: String) -> Result { + rustdesk::provision(&machine_id).map_err(|error| error.to_string()) +} + +#[cfg(not(target_os = "windows"))] +fn run_rustdesk_provision(_machine_id: String) -> Result { + Err("Provisionamento automático do RustDesk está disponível apenas no Windows.".to_string()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -51,7 +82,8 @@ pub fn run() { collect_machine_inventory, start_machine_agent, stop_machine_agent, - open_devtools + open_devtools, + provision_rustdesk ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/apps/desktop/src-tauri/src/rustdesk.rs b/apps/desktop/src-tauri/src/rustdesk.rs new file mode 100644 index 0000000..ea5b477 --- /dev/null +++ b/apps/desktop/src-tauri/src/rustdesk.rs @@ -0,0 +1,270 @@ +#![cfg(target_os = "windows")] + +use crate::RustdeskProvisioningResult; +use once_cell::sync::Lazy; +use parking_lot::Mutex; +use reqwest::blocking::Client; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::env; +use std::fs::{self, File}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::Duration; +use thiserror::Error; + +const RELEASES_API: &str = "https://api.github.com/repos/rustdesk/rustdesk/releases/latest"; +const USER_AGENT: &str = "RavenDesktop/1.0"; +const SERVER_HOST: &str = "rust.rever.com.br"; +const SERVER_KEY: &str = "0mxocQKmK6GvTZQYKgjrG9tlNkKOqf81gKgqwAmnZuI="; +const DEFAULT_PASSWORD: &str = "FMQ9MA>e73r.FI> = Lazy::new(|| Mutex::new(())); + +#[derive(Debug, Error)] +pub enum RustdeskError { + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + #[error("I/O error: {0}")] + Io(#[from] io::Error), + #[error("Release asset não encontrado para Windows x86_64")] + AssetMissing, + #[error("Falha ao executar comando {command}: status {status:?}")] + CommandFailed { command: String, status: Option }, + #[error("Falha ao detectar ID do RustDesk")] + MissingId, +} + +#[derive(Debug, Deserialize)] +struct ReleaseAsset { + name: String, + browser_download_url: String, +} + +#[derive(Debug, Deserialize)] +struct ReleaseResponse { + tag_name: String, + assets: Vec, +} + +pub fn provision(machine_id: &str) -> Result { + let _guard = PROVISION_MUTEX.lock(); + let exe_path = detect_executable_path(); + let (installed_version, freshly_installed) = ensure_installed(&exe_path)?; + let config_path = write_config_files()?; + apply_config(&exe_path, &config_path)?; + set_password(&exe_path)?; + let custom_id = set_custom_id(&exe_path, machine_id)?; + ensure_service_running()?; + let reported_id = query_id(&exe_path).unwrap_or(custom_id.clone()); + let version = query_version(&exe_path).ok().or(installed_version); + + Ok(RustdeskProvisioningResult { + id: reported_id, + password: DEFAULT_PASSWORD.to_string(), + installed_version: version, + updated: freshly_installed, + }) +} + +fn detect_executable_path() -> PathBuf { + let program_files = env::var("PROGRAMFILES").unwrap_or_else(|_| "C:/Program Files".to_string()); + Path::new(&program_files).join("RustDesk").join("rustdesk.exe") +} + +fn ensure_installed(exe_path: &Path) -> Result<(Option, bool), RustdeskError> { + if exe_path.exists() { + return Ok((None, false)); + } + + let cache_root = PathBuf::from(env::var("PROGRAMDATA").unwrap_or_else(|_| "C:/ProgramData".to_string())) + .join(CACHE_DIR_NAME); + fs::create_dir_all(&cache_root)?; + + let (installer_path, version_tag) = download_latest_installer(&cache_root)?; + run_installer(&installer_path)?; + thread::sleep(Duration::from_secs(20)); + + Ok((Some(version_tag), true)) +} + +fn download_latest_installer(cache_root: &Path) -> Result<(PathBuf, String), RustdeskError> { + let client = Client::builder() + .user_agent(USER_AGENT) + .timeout(Duration::from_secs(60)) + .build()?; + let release: ReleaseResponse = client.get(RELEASES_API).send()?.error_for_status()?.json()?; + let asset = release + .assets + .iter() + .find(|a| a.name.ends_with("x86_64.exe")) + .ok_or(RustdeskError::AssetMissing)?; + let target_path = cache_root.join(&asset.name); + if target_path.exists() { + return Ok((target_path, release.tag_name)); + } + + let mut response = client.get(&asset.browser_download_url).send()?.error_for_status()?; + let mut output = File::create(&target_path)?; + response.copy_to(&mut output)?; + Ok((target_path, release.tag_name)) +} + +fn run_installer(installer_path: &Path) -> Result<(), RustdeskError> { + let status = Command::new(installer_path) + .arg("--silent-install") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + if !status.success() { + return Err(RustdeskError::CommandFailed { + command: format!("{} --silent-install", installer_path.display()), + status: status.code(), + }); + } + Ok(()) +} + +fn write_config_files() -> Result { + let config_contents = build_config_contents(); + let program_data = PathBuf::from(env::var("PROGRAMDATA").unwrap_or_else(|_| "C:/ProgramData".to_string())); + let main_path = program_data.join("RustDesk").join("config").join("RustDesk2.toml"); + write_file(&main_path, &config_contents)?; + + let service_profile = PathBuf::from(r"C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Roaming\\RustDesk\\config\\RustDesk2.toml"); + write_file(&service_profile, &config_contents).ok(); + + Ok(main_path) +} + +fn write_file(path: &Path, contents: &str) -> Result<(), io::Error> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, contents) +} + +fn build_config_contents() -> String { + format!( + r#"rendezvous_server = "{host}" +relay_server = "{host}" +api_server = "" +serial = 0 + +[options] +key = "{key}" +relay-server = "{host}" +custom-rendezvous-server = "{host}" +"#, + host = SERVER_HOST, + key = SERVER_KEY + ) +} + +fn apply_config(exe_path: &Path, config_path: &Path) -> Result<(), RustdeskError> { + let status = Command::new(exe_path) + .arg("--import-config") + .arg(config_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + if !status.success() { + return Err(RustdeskError::CommandFailed { + command: format!("{} --import-config {}", exe_path.display(), config_path.display()), + status: status.code(), + }); + } + Ok(()) +} + +fn set_password(exe_path: &Path) -> Result<(), RustdeskError> { + run_with_args(exe_path, &["--password", DEFAULT_PASSWORD]) +} + +fn set_custom_id(exe_path: &Path, machine_id: &str) -> Result { + let custom_id = derive_numeric_id(machine_id); + run_with_args(exe_path, &["--set-id", &custom_id])?; + Ok(custom_id) +} + +fn derive_numeric_id(machine_id: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(machine_id.as_bytes()); + let hash = hasher.finalize(); + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&hash[..8]); + let value = u64::from_le_bytes(bytes); + let num = (value % 900_000_000) + 100_000_000; + format!("{:09}", num) +} + +fn ensure_service_running() -> Result<(), RustdeskError> { + let _ = run_sc(&["stop", SERVICE_NAME]); + thread::sleep(Duration::from_secs(2)); + let _ = run_sc(&["config", SERVICE_NAME, &format!("start= {}", "auto")]); + run_sc(&["start", SERVICE_NAME]) +} + +fn run_sc(args: &[&str]) -> Result<(), RustdeskError> { + let status = Command::new("sc") + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + if !status.success() { + return Err(RustdeskError::CommandFailed { + command: format!("sc {}", args.join(" ")), + status: status.code(), + }); + } + Ok(()) +} + +fn query_id(exe_path: &Path) -> Result { + let output = Command::new(exe_path) + .arg("--get-id") + .output()?; + if !output.status.success() { + return Err(RustdeskError::CommandFailed { + command: format!("{} --get-id", exe_path.display()), + status: output.status.code(), + }); + } + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout.is_empty() { + return Err(RustdeskError::MissingId); + } + Ok(stdout) +} + +fn query_version(exe_path: &Path) -> Result { + let output = Command::new(exe_path) + .arg("--version") + .output()?; + if !output.status.success() { + return Err(RustdeskError::CommandFailed { + command: format!("{} --version", exe_path.display()), + status: output.status.code(), + }); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn run_with_args(exe_path: &Path, args: &[&str]) -> Result<(), RustdeskError> { + let status = Command::new(exe_path) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + if !status.success() { + return Err(RustdeskError::CommandFailed { + command: format!("{} {}", exe_path.display(), args.join(" ")), + status: status.code(), + }); + } + Ok(()) +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index e4bf0b4..fb178fa 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -76,6 +76,20 @@ type AgentConfig = { heartbeatIntervalSec?: number | null } +type RustdeskProvisioningResult = { + id: string + password: string + installedVersion?: string | null + updated: boolean +} + +type RustdeskInfo = RustdeskProvisioningResult & { + lastProvisionedAt: number + lastSyncedAt?: number | null +} + +const RUSTDESK_STORE_KEY = "rustdesk" + declare global { interface ImportMetaEnv { readonly VITE_APP_URL?: string @@ -128,6 +142,15 @@ async function writeConfig(store: Store, cfg: AgentConfig): Promise { await store.save() } +async function readRustdeskInfo(store: Store): Promise { + return (await store.get(RUSTDESK_STORE_KEY)) ?? null +} + +async function writeRustdeskInfo(store: Store, info: RustdeskInfo): Promise { + await store.set(RUSTDESK_STORE_KEY, info) + await store.save() +} + function bytes(n?: number) { if (!n || !Number.isFinite(n)) return "—" const u = ["B","KB","MB","GB","TB"] @@ -191,6 +214,9 @@ function App() { const [tokenValidationTick, setTokenValidationTick] = useState(0) const [, setIsValidatingToken] = useState(false) const tokenVerifiedRef = useRef(false) + const [rustdeskInfo, setRustdeskInfo] = useState(null) + const [isRustdeskProvisioning, setIsRustdeskProvisioning] = useState(false) + const rustdeskBootstrapRef = useRef(false) const [provisioningCode, setProvisioningCode] = useState("") const [validatedCompany, setValidatedCompany] = useState<{ id: string; name: string; slug: string; tenantId: string } | null>(null) @@ -219,6 +245,8 @@ function App() { setToken(t) const cfg = await readConfig(s) setConfig(cfg) + const existingRustdesk = await readRustdeskInfo(s) + setRustdeskInfo(existingRustdesk) if (cfg?.collaboratorEmail) setCollabEmail(cfg.collaboratorEmail) if (cfg?.collaboratorName) setCollabName(cfg.collaboratorName) if (cfg?.companyName) setCompanyName(cfg.companyName) @@ -426,14 +454,94 @@ function App() { } }, [provisioningCode]) - const resolvedAppUrl = useMemo(() => { - if (!config?.appUrl) return appUrl - const normalized = normalizeUrl(config.appUrl, appUrl) - if (import.meta.env.MODE === "production" && normalized.includes("localhost")) { - return appUrl +const resolvedAppUrl = useMemo(() => { + if (!config?.appUrl) return appUrl + const normalized = normalizeUrl(config.appUrl, appUrl) + if (import.meta.env.MODE === "production" && normalized.includes("localhost")) { + return appUrl + } + return normalized +}, [config?.appUrl]) + +const syncRustdeskAccess = useCallback( + async (machineToken: string, info: RustdeskInfo) => { + if (!store || !machineToken) return + try { + const response = await fetch(`${apiBaseUrl}/api/machines/remote-access`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + machineToken, + provider: "RustDesk", + identifier: info.id, + url: `rustdesk://${info.id}`, + password: info.password, + notes: info.installedVersion ? `RustDesk ${info.installedVersion}` : undefined, + }), + }) + if (!response.ok) { + const text = await response.text() + throw new Error(text.slice(0, 300) || "Falha ao registrar acesso remoto") + } + const nextInfo: RustdeskInfo = { ...info, lastSyncedAt: Date.now() } + await writeRustdeskInfo(store, nextInfo) + setRustdeskInfo(nextInfo) + } catch (error) { + console.error("Falha ao sincronizar acesso remoto com a plataforma", error) } - return normalized - }, [config?.appUrl]) + }, + [store] +) + +const provisionRustdesk = useCallback( + async (machineId: string, machineToken: string): Promise => { + if (!store || !machineId) return null + setIsRustdeskProvisioning(true) + try { + const result = await invoke("provision_rustdesk", { machineId }) + const info: RustdeskInfo = { + ...result, + lastProvisionedAt: Date.now(), + lastSyncedAt: null, + } + await writeRustdeskInfo(store, info) + setRustdeskInfo(info) + if (machineToken) { + await syncRustdeskAccess(machineToken, info) + } + return info + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (message.toLowerCase().includes("apenas no windows")) { + console.info("Provisionamento do RustDesk ignorado (plataforma não suportada)") + } else { + console.error("Falha ao provisionar RustDesk", error) + } + return null + } finally { + setIsRustdeskProvisioning(false) + } + }, + [store, syncRustdeskAccess] +) + +useEffect(() => { + if (!store || !config?.machineId || !token) return + if (!rustdeskInfo && !isRustdeskProvisioning && !rustdeskBootstrapRef.current) { + rustdeskBootstrapRef.current = true + provisionRustdesk(config.machineId, token).finally(() => { + rustdeskBootstrapRef.current = false + }) + return + } + if (rustdeskInfo && !isRustdeskProvisioning) { + const lastSync = rustdeskInfo.lastSyncedAt ?? 0 + const needsSync = Date.now() - lastSync > 7 * 24 * 60 * 60 * 1000 + if (needsSync) { + syncRustdeskAccess(token, rustdeskInfo) + } + } +}, [store, config?.machineId, token, rustdeskInfo, provisionRustdesk, syncRustdeskAccess, isRustdeskProvisioning]) async function register() { if (!profile) return @@ -524,6 +632,8 @@ function App() { setToken(data.machineToken) setCompanyName(validatedCompany.name) + await provisionRustdesk(data.machineId, data.machineToken) + await invoke("start_machine_agent", { baseUrl: apiBaseUrl, token: data.machineToken, @@ -885,6 +995,9 @@ function App() { ) : null}
+ {isRustdeskProvisioning ? ( +

Preparando cliente de acesso remoto (RustDesk)...

+ ) : null}
) : ( diff --git a/convex/machines.ts b/convex/machines.ts index 4f0a460..b03ef0f 100644 --- a/convex/machines.ts +++ b/convex/machines.ts @@ -2261,6 +2261,87 @@ export const updateRemoteAccess = mutation({ }, }) +export const upsertRemoteAccessViaToken = mutation({ + args: { + machineToken: v.string(), + provider: v.string(), + identifier: v.string(), + url: v.optional(v.string()), + username: v.optional(v.string()), + password: v.optional(v.string()), + notes: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const { machine } = await getActiveToken(ctx, args.machineToken) + const trimmedProvider = args.provider.trim() + const trimmedIdentifier = args.identifier.trim() + if (!trimmedProvider || !trimmedIdentifier) { + throw new ConvexError("Informe provedor e identificador do acesso remoto.") + } + + let normalizedUrl: string | null = null + if (args.url) { + const trimmedUrl = args.url.trim() + if (trimmedUrl) { + const isValidScheme = /^https?:\/\//i.test(trimmedUrl) || /^rustdesk:\/\//i.test(trimmedUrl) + if (!isValidScheme) { + throw new ConvexError("Informe uma URL iniciando com http://, https:// ou rustdesk://.") + } + try { + new URL(trimmedUrl.replace(/^rustdesk:\/\//i, "https://")) + } catch { + throw new ConvexError("Informe uma URL válida para o acesso remoto.") + } + normalizedUrl = trimmedUrl + } + } + + const cleanedUsername = args.username?.trim() ? args.username.trim() : null + const cleanedPassword = args.password?.trim() ? args.password.trim() : null + const cleanedNotes = args.notes?.trim() ? args.notes.trim() : null + const timestamp = Date.now() + const existingEntries = normalizeRemoteAccessList(machine.remoteAccess) + const existingIndex = existingEntries.findIndex( + (entry) => + entry.provider.toLowerCase() === trimmedProvider.toLowerCase() && + entry.identifier.toLowerCase() === trimmedIdentifier.toLowerCase() + ) + + const entryId = existingIndex >= 0 ? existingEntries[existingIndex].id : createRemoteAccessId() + const updatedEntry: RemoteAccessEntry = { + id: entryId, + provider: trimmedProvider, + identifier: trimmedIdentifier, + url: normalizedUrl, + username: cleanedUsername, + password: cleanedPassword, + notes: cleanedNotes, + lastVerifiedAt: timestamp, + metadata: { + provider: trimmedProvider, + identifier: trimmedIdentifier, + url: normalizedUrl, + username: cleanedUsername, + password: cleanedPassword, + notes: cleanedNotes, + lastVerifiedAt: timestamp, + }, + } + + const nextEntries = + existingIndex >= 0 + ? existingEntries.map((entry, index) => (index === existingIndex ? updatedEntry : entry)) + : [...existingEntries, updatedEntry] + + await ctx.db.patch(machine._id, { + remoteAccess: nextEntries, + updatedAt: timestamp, + }) + + return { remoteAccess: nextEntries } + }, +}) + export const remove = mutation({ args: { machineId: v.id("machines"), diff --git a/src/app/api/machines/remote-access/route.ts b/src/app/api/machines/remote-access/route.ts new file mode 100644 index 0000000..41d37fe --- /dev/null +++ b/src/app/api/machines/remote-access/route.ts @@ -0,0 +1,59 @@ +import { z } from "zod" + +import { api } from "@/convex/_generated/api" +import { createCorsPreflight, jsonWithCors } from "@/server/cors" +import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" + +const schema = z.object({ + machineToken: z.string().min(1), + provider: z.string().min(1), + identifier: z.string().min(1), + url: z.string().optional(), + username: z.string().optional(), + password: z.string().optional(), + notes: z.string().optional(), +}) + +const METHODS = "POST, OPTIONS" + +export async function OPTIONS(request: Request) { + return createCorsPreflight(request.headers.get("origin"), METHODS) +} + +export async function POST(request: Request) { + const origin = request.headers.get("origin") + if (request.method !== "POST") { + return jsonWithCors({ error: "Método não permitido" }, 405, origin, METHODS) + } + + let client + try { + client = createConvexClient() + } catch (error) { + if (error instanceof ConvexConfigurationError) { + return jsonWithCors({ error: error.message }, 500, origin, METHODS) + } + throw error + } + + let payload + try { + payload = schema.parse(await request.json()) + } catch (error) { + return jsonWithCors( + { error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, + 400, + origin, + METHODS + ) + } + + try { + const response = await client.mutation(api.devices.upsertRemoteAccessViaToken, payload) + return jsonWithCors({ ok: true, remoteAccess: response?.remoteAccess ?? null }, 200, origin, METHODS) + } catch (error) { + console.error("[machines.remote-access:token] Falha ao registrar acesso remoto", error) + const details = error instanceof Error ? error.message : String(error) + return jsonWithCors({ error: "Falha ao registrar acesso remoto", details }, 500, origin, METHODS) + } +}