feat(desktop): adiciona Raven Service e corrige UAC
- Implementa Windows Service (raven-service) para operacoes privilegiadas - Comunicacao via Named Pipes sem necessidade de UAC adicional - Adiciona single-instance para evitar multiplos icones na bandeja - Corrige todos os warnings do clippy (rustdesk, lib, usb_control, agent) - Remove fallback de elevacao para evitar UAC desnecessario - USB Policy e RustDesk provisioning agora usam o servico quando disponivel 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
caa6c53b2b
commit
c4664ab1c7
16 changed files with 4209 additions and 143 deletions
1931
apps/desktop/service/Cargo.lock
generated
Normal file
1931
apps/desktop/service/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
70
apps/desktop/service/Cargo.toml
Normal file
70
apps/desktop/service/Cargo.toml
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
[package]
|
||||
name = "raven-service"
|
||||
version = "0.1.0"
|
||||
description = "Raven Windows Service - Executa operacoes privilegiadas para o Raven Desktop"
|
||||
authors = ["Esdras Renan"]
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "raven-service"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# Windows Service
|
||||
windows-service = "0.7"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util", "net", "signal"] }
|
||||
|
||||
# IPC via Named Pipes
|
||||
interprocess = { version = "2", features = ["tokio"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Windows Registry
|
||||
winreg = "0.55"
|
||||
|
||||
# Error handling
|
||||
thiserror = "1.0"
|
||||
|
||||
# HTTP client (para RustDesk)
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"], default-features = false }
|
||||
|
||||
# Date/time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Crypto (para RustDesk ID)
|
||||
sha2 = "0.10"
|
||||
|
||||
# UUID para request IDs
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
# Parking lot para locks
|
||||
parking_lot = "0.12"
|
||||
|
||||
# Once cell para singletons
|
||||
once_cell = "1.19"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
"Win32_System_Services",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_IO",
|
||||
"Win32_System_SystemServices",
|
||||
"Win32_Storage_FileSystem",
|
||||
] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
290
apps/desktop/service/src/ipc.rs
Normal file
290
apps/desktop/service/src/ipc.rs
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
//! Modulo IPC - Servidor de Named Pipes
|
||||
//!
|
||||
//! Implementa comunicacao entre o Raven UI e o Raven Service
|
||||
//! usando Named Pipes do Windows com protocolo JSON-RPC simplificado.
|
||||
|
||||
use crate::{rustdesk, usb_policy};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use thiserror::Error;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum IpcError {
|
||||
#[error("Erro de IO: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Erro de serializacao: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
/// Requisicao JSON-RPC simplificada
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Request {
|
||||
pub id: String,
|
||||
pub method: String,
|
||||
#[serde(default)]
|
||||
pub params: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Resposta JSON-RPC simplificada
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Response {
|
||||
pub id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<ErrorResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn success(id: String, result: serde_json::Value) -> Self {
|
||||
Self {
|
||||
id,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(id: String, code: i32, message: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
result: None,
|
||||
error: Some(ErrorResponse { code, message }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inicia o servidor de Named Pipes
|
||||
pub async fn run_server(pipe_name: &str) -> Result<(), IpcError> {
|
||||
info!("Iniciando servidor IPC em: {}", pipe_name);
|
||||
|
||||
loop {
|
||||
match accept_connection(pipe_name).await {
|
||||
Ok(()) => {
|
||||
debug!("Conexao processada com sucesso");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Erro ao processar conexao: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Aceita uma conexao e processa requisicoes
|
||||
async fn accept_connection(pipe_name: &str) -> Result<(), IpcError> {
|
||||
use windows::Win32::Foundation::INVALID_HANDLE_VALUE;
|
||||
use windows::Win32::Security::{
|
||||
InitializeSecurityDescriptor, SetSecurityDescriptorDacl,
|
||||
PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, SECURITY_DESCRIPTOR,
|
||||
};
|
||||
use windows::Win32::Storage::FileSystem::PIPE_ACCESS_DUPLEX;
|
||||
use windows::Win32::System::Pipes::{
|
||||
ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe,
|
||||
PIPE_READMODE_MESSAGE, PIPE_TYPE_MESSAGE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT,
|
||||
};
|
||||
use windows::Win32::System::SystemServices::SECURITY_DESCRIPTOR_REVISION;
|
||||
use windows::core::PCWSTR;
|
||||
|
||||
// Cria o named pipe com seguranca que permite acesso a todos os usuarios
|
||||
let pipe_name_wide: Vec<u16> = pipe_name.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
|
||||
// Cria security descriptor com DACL nulo (permite acesso a todos)
|
||||
let mut sd = SECURITY_DESCRIPTOR::default();
|
||||
unsafe {
|
||||
let sd_ptr = PSECURITY_DESCRIPTOR(&mut sd as *mut _ as *mut _);
|
||||
let _ = InitializeSecurityDescriptor(sd_ptr, SECURITY_DESCRIPTOR_REVISION);
|
||||
// DACL nulo = acesso irrestrito
|
||||
let _ = SetSecurityDescriptorDacl(sd_ptr, true, None, false);
|
||||
}
|
||||
|
||||
let sa = SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: &mut sd as *mut _ as *mut _,
|
||||
bInheritHandle: false.into(),
|
||||
};
|
||||
|
||||
let pipe_handle = unsafe {
|
||||
CreateNamedPipeW(
|
||||
PCWSTR::from_raw(pipe_name_wide.as_ptr()),
|
||||
PIPE_ACCESS_DUPLEX,
|
||||
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
|
||||
PIPE_UNLIMITED_INSTANCES,
|
||||
4096, // out buffer
|
||||
4096, // in buffer
|
||||
0, // default timeout
|
||||
Some(&sa), // seguranca permissiva
|
||||
)
|
||||
};
|
||||
|
||||
// Verifica se o handle e valido
|
||||
if pipe_handle == INVALID_HANDLE_VALUE {
|
||||
return Err(IpcError::Io(std::io::Error::last_os_error()));
|
||||
}
|
||||
|
||||
// Aguarda conexao de um cliente
|
||||
info!("Aguardando conexao de cliente...");
|
||||
let connect_result = unsafe {
|
||||
ConnectNamedPipe(pipe_handle, None)
|
||||
};
|
||||
|
||||
if let Err(e) = connect_result {
|
||||
// ERROR_PIPE_CONNECTED (535) significa que o cliente ja estava conectado
|
||||
// o que e aceitavel
|
||||
let error_code = e.code().0 as u32;
|
||||
if error_code != 535 {
|
||||
warn!("Erro ao aguardar conexao: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Cliente conectado");
|
||||
|
||||
// Processa requisicoes do cliente
|
||||
let result = process_client(pipe_handle);
|
||||
|
||||
// Desconecta o cliente
|
||||
unsafe {
|
||||
let _ = DisconnectNamedPipe(pipe_handle);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Processa requisicoes de um cliente conectado
|
||||
fn process_client(pipe_handle: windows::Win32::Foundation::HANDLE) -> Result<(), IpcError> {
|
||||
use std::os::windows::io::{FromRawHandle, RawHandle};
|
||||
use std::fs::File;
|
||||
|
||||
// Cria File handle a partir do pipe
|
||||
let raw_handle = pipe_handle.0 as RawHandle;
|
||||
let file = unsafe { File::from_raw_handle(raw_handle) };
|
||||
|
||||
let reader = BufReader::new(file.try_clone()?);
|
||||
let mut writer = file;
|
||||
|
||||
// Le linhas (cada linha e uma requisicao JSON)
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
info!("Cliente desconectou");
|
||||
break;
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
debug!("Requisicao recebida: {}", line);
|
||||
|
||||
// Parse da requisicao
|
||||
let response = match serde_json::from_str::<Request>(&line) {
|
||||
Ok(request) => handle_request(request),
|
||||
Err(e) => Response::error(
|
||||
"unknown".to_string(),
|
||||
-32700,
|
||||
format!("Parse error: {}", e),
|
||||
),
|
||||
};
|
||||
|
||||
// Serializa e envia resposta
|
||||
let response_json = serde_json::to_string(&response)?;
|
||||
debug!("Resposta: {}", response_json);
|
||||
|
||||
writeln!(writer, "{}", response_json)?;
|
||||
writer.flush()?;
|
||||
}
|
||||
|
||||
// IMPORTANTE: Nao fechar o handle aqui, pois DisconnectNamedPipe precisa dele
|
||||
std::mem::forget(writer);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Processa uma requisicao e retorna a resposta
|
||||
fn handle_request(request: Request) -> Response {
|
||||
info!("Processando metodo: {}", request.method);
|
||||
|
||||
match request.method.as_str() {
|
||||
"health_check" => handle_health_check(request.id),
|
||||
"apply_usb_policy" => handle_apply_usb_policy(request.id, request.params),
|
||||
"get_usb_policy" => handle_get_usb_policy(request.id),
|
||||
"provision_rustdesk" => handle_provision_rustdesk(request.id, request.params),
|
||||
"get_rustdesk_status" => handle_get_rustdesk_status(request.id),
|
||||
_ => Response::error(
|
||||
request.id,
|
||||
-32601,
|
||||
format!("Metodo nao encontrado: {}", request.method),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Handlers de Requisicoes
|
||||
// =============================================================================
|
||||
|
||||
fn handle_health_check(id: String) -> Response {
|
||||
Response::success(
|
||||
id,
|
||||
serde_json::json!({
|
||||
"status": "ok",
|
||||
"service": "RavenService",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"timestamp": chrono::Utc::now().timestamp_millis()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn handle_apply_usb_policy(id: String, params: serde_json::Value) -> Response {
|
||||
let policy = match params.get("policy").and_then(|p| p.as_str()) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Response::error(id, -32602, "Parametro 'policy' e obrigatorio".to_string())
|
||||
}
|
||||
};
|
||||
|
||||
match usb_policy::apply_policy(policy) {
|
||||
Ok(result) => Response::success(id, serde_json::to_value(result).unwrap()),
|
||||
Err(e) => Response::error(id, -32000, format!("Erro ao aplicar politica: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_get_usb_policy(id: String) -> Response {
|
||||
match usb_policy::get_current_policy() {
|
||||
Ok(policy) => Response::success(
|
||||
id,
|
||||
serde_json::json!({
|
||||
"policy": policy
|
||||
}),
|
||||
),
|
||||
Err(e) => Response::error(id, -32000, format!("Erro ao obter politica: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_provision_rustdesk(id: String, params: serde_json::Value) -> Response {
|
||||
let config_string = params.get("config").and_then(|c| c.as_str()).map(String::from);
|
||||
let password = params.get("password").and_then(|p| p.as_str()).map(String::from);
|
||||
let machine_id = params.get("machineId").and_then(|m| m.as_str()).map(String::from);
|
||||
|
||||
match rustdesk::ensure_rustdesk(config_string.as_deref(), password.as_deref(), machine_id.as_deref()) {
|
||||
Ok(result) => Response::success(id, serde_json::to_value(result).unwrap()),
|
||||
Err(e) => Response::error(id, -32000, format!("Erro ao provisionar RustDesk: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_get_rustdesk_status(id: String) -> Response {
|
||||
match rustdesk::get_status() {
|
||||
Ok(status) => Response::success(id, serde_json::to_value(status).unwrap()),
|
||||
Err(e) => Response::error(id, -32000, format!("Erro ao obter status: {}", e)),
|
||||
}
|
||||
}
|
||||
268
apps/desktop/service/src/main.rs
Normal file
268
apps/desktop/service/src/main.rs
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
//! Raven Service - Servico Windows para operacoes privilegiadas
|
||||
//!
|
||||
//! Este servico roda como LocalSystem e executa operacoes que requerem
|
||||
//! privilegios de administrador, como:
|
||||
//! - Aplicar politicas de USB
|
||||
//! - Provisionar e configurar RustDesk
|
||||
//! - Modificar chaves de registro em HKEY_LOCAL_MACHINE
|
||||
//!
|
||||
//! O app Raven UI comunica com este servico via Named Pipes.
|
||||
|
||||
mod ipc;
|
||||
mod rustdesk;
|
||||
mod usb_policy;
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::time::Duration;
|
||||
use tracing::{error, info};
|
||||
use windows_service::{
|
||||
define_windows_service,
|
||||
service::{
|
||||
ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
|
||||
ServiceType,
|
||||
},
|
||||
service_control_handler::{self, ServiceControlHandlerResult},
|
||||
service_dispatcher,
|
||||
};
|
||||
|
||||
const SERVICE_NAME: &str = "RavenService";
|
||||
const SERVICE_DISPLAY_NAME: &str = "Raven Desktop Service";
|
||||
const SERVICE_DESCRIPTION: &str = "Servico do Raven Desktop para operacoes privilegiadas (USB, RustDesk)";
|
||||
const PIPE_NAME: &str = r"\\.\pipe\RavenService";
|
||||
|
||||
define_windows_service!(ffi_service_main, service_main);
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Configura logging
|
||||
init_logging();
|
||||
|
||||
// Verifica argumentos de linha de comando
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
if args.len() > 1 {
|
||||
match args[1].as_str() {
|
||||
"install" => {
|
||||
install_service()?;
|
||||
return Ok(());
|
||||
}
|
||||
"uninstall" => {
|
||||
uninstall_service()?;
|
||||
return Ok(());
|
||||
}
|
||||
"run" => {
|
||||
// Modo de teste: roda sem registrar como servico
|
||||
info!("Executando em modo de teste (nao como servico)");
|
||||
run_standalone()?;
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Inicia como servico Windows
|
||||
info!("Iniciando Raven Service...");
|
||||
service_dispatcher::start(SERVICE_NAME, ffi_service_main)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_logging() {
|
||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||
|
||||
// Tenta criar diretorio de logs
|
||||
let log_dir = std::env::var("PROGRAMDATA")
|
||||
.map(|p| std::path::PathBuf::from(p).join("RavenService").join("logs"))
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("C:\\ProgramData\\RavenService\\logs"));
|
||||
|
||||
let _ = std::fs::create_dir_all(&log_dir);
|
||||
|
||||
// Arquivo de log
|
||||
let log_file = log_dir.join("service.log");
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_file)
|
||||
.ok();
|
||||
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
|
||||
if let Some(file) = file {
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(fmt::layer().with_writer(file).with_ansi(false))
|
||||
.init();
|
||||
} else {
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(fmt::layer())
|
||||
.init();
|
||||
}
|
||||
}
|
||||
|
||||
fn service_main(arguments: Vec<OsString>) {
|
||||
if let Err(e) = run_service(arguments) {
|
||||
error!("Erro ao executar servico: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_service(_arguments: Vec<OsString>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
info!("Servico iniciando...");
|
||||
|
||||
// Canal para shutdown
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let shutdown_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(shutdown_tx)));
|
||||
|
||||
// Registra handler de controle do servico
|
||||
let shutdown_tx_clone = shutdown_tx.clone();
|
||||
let status_handle = service_control_handler::register(SERVICE_NAME, move |control| {
|
||||
match control {
|
||||
ServiceControl::Stop | ServiceControl::Shutdown => {
|
||||
info!("Recebido comando de parada");
|
||||
if let Ok(mut guard) = shutdown_tx_clone.lock() {
|
||||
if let Some(tx) = guard.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
ServiceControlHandlerResult::NoError
|
||||
}
|
||||
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
|
||||
_ => ServiceControlHandlerResult::NotImplemented,
|
||||
}
|
||||
})?;
|
||||
|
||||
// Atualiza status para Running
|
||||
status_handle.set_service_status(ServiceStatus {
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
current_state: ServiceState::Running,
|
||||
controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
|
||||
exit_code: ServiceExitCode::Win32(0),
|
||||
checkpoint: 0,
|
||||
wait_hint: Duration::default(),
|
||||
process_id: None,
|
||||
})?;
|
||||
|
||||
info!("Servico em execucao, aguardando conexoes...");
|
||||
|
||||
// Cria runtime Tokio
|
||||
let runtime = tokio::runtime::Runtime::new()?;
|
||||
|
||||
// Executa servidor IPC
|
||||
runtime.block_on(async {
|
||||
tokio::select! {
|
||||
result = ipc::run_server(PIPE_NAME) => {
|
||||
if let Err(e) = result {
|
||||
error!("Erro no servidor IPC: {}", e);
|
||||
}
|
||||
}
|
||||
_ = async {
|
||||
let _ = shutdown_rx.await;
|
||||
} => {
|
||||
info!("Shutdown solicitado");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Atualiza status para Stopped
|
||||
status_handle.set_service_status(ServiceStatus {
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
current_state: ServiceState::Stopped,
|
||||
controls_accepted: ServiceControlAccept::empty(),
|
||||
exit_code: ServiceExitCode::Win32(0),
|
||||
checkpoint: 0,
|
||||
wait_hint: Duration::default(),
|
||||
process_id: None,
|
||||
})?;
|
||||
|
||||
info!("Servico parado");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_standalone() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let runtime = tokio::runtime::Runtime::new()?;
|
||||
|
||||
runtime.block_on(async {
|
||||
info!("Servidor IPC iniciando em modo standalone...");
|
||||
|
||||
tokio::select! {
|
||||
result = ipc::run_server(PIPE_NAME) => {
|
||||
if let Err(e) = result {
|
||||
error!("Erro no servidor IPC: {}", e);
|
||||
}
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
info!("Ctrl+C recebido, encerrando...");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_service() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use windows_service::{
|
||||
service::{ServiceAccess, ServiceErrorControl, ServiceInfo, ServiceStartType},
|
||||
service_manager::{ServiceManager, ServiceManagerAccess},
|
||||
};
|
||||
|
||||
info!("Instalando servico...");
|
||||
|
||||
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CREATE_SERVICE)?;
|
||||
|
||||
let exe_path = std::env::current_exe()?;
|
||||
|
||||
let service_info = ServiceInfo {
|
||||
name: OsString::from(SERVICE_NAME),
|
||||
display_name: OsString::from(SERVICE_DISPLAY_NAME),
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
start_type: ServiceStartType::AutoStart,
|
||||
error_control: ServiceErrorControl::Normal,
|
||||
executable_path: exe_path,
|
||||
launch_arguments: vec![],
|
||||
dependencies: vec![],
|
||||
account_name: None, // LocalSystem
|
||||
account_password: None,
|
||||
};
|
||||
|
||||
let service = manager.create_service(&service_info, ServiceAccess::CHANGE_CONFIG)?;
|
||||
|
||||
// Define descricao
|
||||
service.set_description(SERVICE_DESCRIPTION)?;
|
||||
|
||||
info!("Servico instalado com sucesso: {}", SERVICE_NAME);
|
||||
println!("Servico '{}' instalado com sucesso!", SERVICE_DISPLAY_NAME);
|
||||
println!("Para iniciar: sc start {}", SERVICE_NAME);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn uninstall_service() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use windows_service::{
|
||||
service::ServiceAccess,
|
||||
service_manager::{ServiceManager, ServiceManagerAccess},
|
||||
};
|
||||
|
||||
info!("Desinstalando servico...");
|
||||
|
||||
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
|
||||
|
||||
let service = manager.open_service(
|
||||
SERVICE_NAME,
|
||||
ServiceAccess::STOP | ServiceAccess::DELETE | ServiceAccess::QUERY_STATUS,
|
||||
)?;
|
||||
|
||||
// Tenta parar o servico primeiro
|
||||
let status = service.query_status()?;
|
||||
if status.current_state != ServiceState::Stopped {
|
||||
info!("Parando servico...");
|
||||
let _ = service.stop();
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
|
||||
// Remove o servico
|
||||
service.delete()?;
|
||||
|
||||
info!("Servico desinstalado com sucesso");
|
||||
println!("Servico '{}' removido com sucesso!", SERVICE_DISPLAY_NAME);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
846
apps/desktop/service/src/rustdesk.rs
Normal file
846
apps/desktop/service/src/rustdesk.rs
Normal file
|
|
@ -0,0 +1,846 @@
|
|||
//! Modulo RustDesk - Provisionamento e gerenciamento do RustDesk
|
||||
//!
|
||||
//! Gerencia a instalacao, configuracao e provisionamento do RustDesk.
|
||||
//! Como o servico roda como LocalSystem, nao precisa de elevacao.
|
||||
|
||||
use chrono::Utc;
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::Mutex;
|
||||
use reqwest::blocking::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::env;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
const RELEASES_API: &str = "https://api.github.com/repos/rustdesk/rustdesk/releases/latest";
|
||||
const USER_AGENT: &str = "RavenService/1.0";
|
||||
const SERVER_HOST: &str = "rust.rever.com.br";
|
||||
const SERVER_KEY: &str = "0mxocQKmK6GvTZQYKgjrG9tlNkKOqf81gKgqwAmnZuI=";
|
||||
const DEFAULT_PASSWORD: &str = "FMQ9MA>e73r.FI<b*34Vmx_8P";
|
||||
const SERVICE_NAME: &str = "RustDesk";
|
||||
const CACHE_DIR_NAME: &str = "Rever\\RustDeskCache";
|
||||
const LOCAL_SERVICE_CONFIG: &str = r"C:\Windows\ServiceProfiles\LocalService\AppData\Roaming\RustDesk\config";
|
||||
const LOCAL_SYSTEM_CONFIG: &str = r"C:\Windows\System32\config\systemprofile\AppData\Roaming\RustDesk\config";
|
||||
const SECURITY_VERIFICATION_VALUE: &str = "use-permanent-password";
|
||||
const SECURITY_APPROVE_MODE_VALUE: &str = "password";
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
static PROVISION_MUTEX: Lazy<Mutex<()>> = 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 nao encontrado para Windows x86_64")]
|
||||
AssetMissing,
|
||||
|
||||
#[error("Falha ao executar comando {command}: status {status:?}")]
|
||||
CommandFailed { command: String, status: Option<i32> },
|
||||
|
||||
#[error("Falha ao detectar ID do RustDesk")]
|
||||
MissingId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RustdeskResult {
|
||||
pub id: String,
|
||||
pub password: String,
|
||||
pub installed_version: Option<String>,
|
||||
pub updated: bool,
|
||||
pub last_provisioned_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RustdeskStatus {
|
||||
pub installed: bool,
|
||||
pub running: bool,
|
||||
pub id: Option<String>,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReleaseAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReleaseResponse {
|
||||
tag_name: String,
|
||||
assets: Vec<ReleaseAsset>,
|
||||
}
|
||||
|
||||
/// Provisiona o RustDesk
|
||||
pub fn ensure_rustdesk(
|
||||
config_string: Option<&str>,
|
||||
password_override: Option<&str>,
|
||||
machine_id: Option<&str>,
|
||||
) -> Result<RustdeskResult, RustdeskError> {
|
||||
let _guard = PROVISION_MUTEX.lock();
|
||||
info!("Iniciando provisionamento do RustDesk");
|
||||
|
||||
// Prepara ACLs dos diretorios de servico
|
||||
if let Err(e) = ensure_service_profiles_writable() {
|
||||
warn!("Aviso ao preparar ACL: {}", e);
|
||||
}
|
||||
|
||||
// Le ID existente antes de qualquer limpeza
|
||||
let preserved_remote_id = read_remote_id_from_profiles();
|
||||
if let Some(ref id) = preserved_remote_id {
|
||||
info!("ID existente preservado: {}", id);
|
||||
}
|
||||
|
||||
let exe_path = detect_executable_path();
|
||||
let (installed_version, freshly_installed) = ensure_installed(&exe_path)?;
|
||||
|
||||
info!(
|
||||
"RustDesk {}: {}",
|
||||
if freshly_installed { "instalado" } else { "ja presente" },
|
||||
exe_path.display()
|
||||
);
|
||||
|
||||
// Para processos existentes
|
||||
let _ = stop_rustdesk_processes();
|
||||
|
||||
// Limpa perfis apenas se instalacao fresca
|
||||
if freshly_installed {
|
||||
let _ = purge_existing_rustdesk_profiles();
|
||||
}
|
||||
|
||||
// Aplica configuracao
|
||||
if let Some(config) = config_string.filter(|c| !c.trim().is_empty()) {
|
||||
if let Err(e) = run_with_args(&exe_path, &["--config", config]) {
|
||||
warn!("Falha ao aplicar config inline: {}", e);
|
||||
}
|
||||
} else {
|
||||
let config_path = write_config_files()?;
|
||||
if let Err(e) = apply_config(&exe_path, &config_path) {
|
||||
warn!("Falha ao aplicar config via CLI: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Define senha
|
||||
let password = password_override
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_PASSWORD.to_string());
|
||||
|
||||
if let Err(e) = set_password(&exe_path, &password) {
|
||||
warn!("Falha ao definir senha: {}", e);
|
||||
} else {
|
||||
let _ = ensure_password_files(&password);
|
||||
let _ = propagate_password_profile();
|
||||
}
|
||||
|
||||
// Define ID customizado
|
||||
let custom_id = if let Some(ref existing_id) = preserved_remote_id {
|
||||
if !freshly_installed {
|
||||
Some(existing_id.clone())
|
||||
} else {
|
||||
define_custom_id(&exe_path, machine_id)
|
||||
}
|
||||
} else {
|
||||
define_custom_id(&exe_path, machine_id)
|
||||
};
|
||||
|
||||
// Inicia servico
|
||||
if let Err(e) = ensure_service_running(&exe_path) {
|
||||
warn!("Falha ao iniciar servico: {}", e);
|
||||
}
|
||||
|
||||
// Obtem ID final
|
||||
let final_id = match query_id_with_retries(&exe_path, 5) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
read_remote_id_from_profiles()
|
||||
.or_else(|| custom_id.clone())
|
||||
.ok_or(RustdeskError::MissingId)?
|
||||
}
|
||||
};
|
||||
|
||||
// Garante ID em todos os arquivos
|
||||
ensure_remote_id_files(&final_id);
|
||||
|
||||
let version = query_version(&exe_path).ok().or(installed_version);
|
||||
let last_provisioned_at = Utc::now().timestamp_millis();
|
||||
|
||||
info!("Provisionamento concluido. ID: {}, Versao: {:?}", final_id, version);
|
||||
|
||||
Ok(RustdeskResult {
|
||||
id: final_id,
|
||||
password,
|
||||
installed_version: version,
|
||||
updated: freshly_installed,
|
||||
last_provisioned_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Retorna status do RustDesk
|
||||
pub fn get_status() -> Result<RustdeskStatus, RustdeskError> {
|
||||
let exe_path = detect_executable_path();
|
||||
let installed = exe_path.exists();
|
||||
|
||||
let running = if installed {
|
||||
query_service_state().map(|s| s == "running").unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let id = if installed {
|
||||
query_id(&exe_path).ok().or_else(read_remote_id_from_profiles)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let version = if installed {
|
||||
query_version(&exe_path).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(RustdeskStatus {
|
||||
installed,
|
||||
running,
|
||||
id,
|
||||
version,
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Funcoes Auxiliares
|
||||
// =============================================================================
|
||||
|
||||
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<String>, 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));
|
||||
}
|
||||
|
||||
info!("Baixando RustDesk: {}", asset.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 = hidden_command(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 program_data_config_dir() -> PathBuf {
|
||||
PathBuf::from(env::var("PROGRAMDATA").unwrap_or_else(|_| "C:/ProgramData".to_string()))
|
||||
.join("RustDesk")
|
||||
.join("config")
|
||||
}
|
||||
|
||||
/// Retorna todos os diretorios AppData\Roaming\RustDesk\config de usuarios do sistema
|
||||
/// Como o servico roda como LocalSystem, precisamos enumerar os profiles de usuarios
|
||||
fn all_user_appdata_config_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
// Enumera C:\Users\*\AppData\Roaming\RustDesk\config
|
||||
let users_dir = Path::new("C:\\Users");
|
||||
if let Ok(entries) = fs::read_dir(users_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
// Ignora pastas de sistema
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if name == "Public" || name == "Default" || name == "Default User" || name == "All Users" {
|
||||
continue;
|
||||
}
|
||||
let rustdesk_config = path.join("AppData").join("Roaming").join("RustDesk").join("config");
|
||||
// Verifica se o diretorio pai existe (usuario real)
|
||||
if path.join("AppData").join("Roaming").exists() {
|
||||
dirs.push(rustdesk_config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tambem tenta o APPDATA do ambiente (pode ser util em alguns casos)
|
||||
if let Ok(appdata) = env::var("APPDATA") {
|
||||
let path = Path::new(&appdata).join("RustDesk").join("config");
|
||||
if !dirs.contains(&path) {
|
||||
dirs.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
fn service_profile_dirs() -> Vec<PathBuf> {
|
||||
vec![
|
||||
PathBuf::from(LOCAL_SERVICE_CONFIG),
|
||||
PathBuf::from(LOCAL_SYSTEM_CONFIG),
|
||||
]
|
||||
}
|
||||
|
||||
fn remote_id_directories() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
dirs.push(program_data_config_dir());
|
||||
dirs.extend(service_profile_dirs());
|
||||
dirs.extend(all_user_appdata_config_dirs());
|
||||
dirs
|
||||
}
|
||||
|
||||
fn write_config_files() -> Result<PathBuf, RustdeskError> {
|
||||
let config_contents = format!(
|
||||
r#"[options]
|
||||
key = "{key}"
|
||||
relay-server = "{host}"
|
||||
custom-rendezvous-server = "{host}"
|
||||
api-server = "https://{host}"
|
||||
verification-method = "{verification}"
|
||||
approve-mode = "{approve}"
|
||||
"#,
|
||||
host = SERVER_HOST,
|
||||
key = SERVER_KEY,
|
||||
verification = SECURITY_VERIFICATION_VALUE,
|
||||
approve = SECURITY_APPROVE_MODE_VALUE,
|
||||
);
|
||||
|
||||
let main_path = program_data_config_dir().join("RustDesk2.toml");
|
||||
write_file(&main_path, &config_contents)?;
|
||||
|
||||
for service_dir in service_profile_dirs() {
|
||||
let service_profile = service_dir.join("RustDesk2.toml");
|
||||
let _ = write_file(&service_profile, &config_contents);
|
||||
}
|
||||
|
||||
Ok(main_path)
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, contents: &str) -> Result<(), io::Error> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(path)?;
|
||||
file.write_all(contents.as_bytes())
|
||||
}
|
||||
|
||||
fn apply_config(exe_path: &Path, config_path: &Path) -> Result<(), RustdeskError> {
|
||||
run_with_args(exe_path, &["--import-config", &config_path.to_string_lossy()])
|
||||
}
|
||||
|
||||
fn set_password(exe_path: &Path, secret: &str) -> Result<(), RustdeskError> {
|
||||
run_with_args(exe_path, &["--password", secret])
|
||||
}
|
||||
|
||||
fn define_custom_id(exe_path: &Path, machine_id: Option<&str>) -> Option<String> {
|
||||
let value = machine_id.and_then(|raw| {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() { None } else { Some(trimmed) }
|
||||
})?;
|
||||
|
||||
let custom_id = derive_numeric_id(value);
|
||||
if run_with_args(exe_path, &["--set-id", &custom_id]).is_ok() {
|
||||
info!("ID deterministico definido: {}", custom_id);
|
||||
Some(custom_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
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(exe_path: &Path) -> Result<(), RustdeskError> {
|
||||
ensure_service_installed(exe_path)?;
|
||||
let _ = run_sc(&["config", SERVICE_NAME, "start=", "auto"]);
|
||||
let _ = run_sc(&["start", SERVICE_NAME]);
|
||||
remove_rustdesk_autorun_artifacts();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_service_installed(exe_path: &Path) -> Result<(), RustdeskError> {
|
||||
if run_sc(&["query", SERVICE_NAME]).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
run_with_args(exe_path, &["--install-service"])
|
||||
}
|
||||
|
||||
fn stop_rustdesk_processes() -> Result<(), RustdeskError> {
|
||||
let _ = run_sc(&["stop", SERVICE_NAME]);
|
||||
thread::sleep(Duration::from_secs(2));
|
||||
|
||||
let status = hidden_command("taskkill")
|
||||
.args(["/F", "/T", "/IM", "rustdesk.exe"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()?;
|
||||
|
||||
if status.success() || matches!(status.code(), Some(128)) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(RustdeskError::CommandFailed {
|
||||
command: "taskkill".into(),
|
||||
status: status.code(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn purge_existing_rustdesk_profiles() -> Result<(), String> {
|
||||
let files = [
|
||||
"RustDesk.toml",
|
||||
"RustDesk_local.toml",
|
||||
"RustDesk2.toml",
|
||||
"password",
|
||||
"passwd",
|
||||
"passwd.txt",
|
||||
];
|
||||
|
||||
for dir in remote_id_directories() {
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
for name in files {
|
||||
let path = dir.join(name);
|
||||
if path.exists() {
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_password_files(secret: &str) -> Result<(), String> {
|
||||
for dir in remote_id_directories() {
|
||||
let password_path = dir.join("RustDesk.toml");
|
||||
let _ = write_toml_kv(&password_path, "password", secret);
|
||||
|
||||
let local_path = dir.join("RustDesk_local.toml");
|
||||
let _ = write_toml_kv(&local_path, "verification-method", SECURITY_VERIFICATION_VALUE);
|
||||
let _ = write_toml_kv(&local_path, "approve-mode", SECURITY_APPROVE_MODE_VALUE);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn propagate_password_profile() -> io::Result<bool> {
|
||||
// Encontra um diretorio de usuario que tenha arquivos de config
|
||||
let user_dirs = all_user_appdata_config_dirs();
|
||||
let src_dir = user_dirs.iter().find(|d| d.join("RustDesk.toml").exists());
|
||||
|
||||
let Some(src_dir) = src_dir else {
|
||||
// Se nenhum usuario tem config, usa ProgramData como fonte
|
||||
let pd = program_data_config_dir();
|
||||
if !pd.join("RustDesk.toml").exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
return propagate_from_dir(&pd);
|
||||
};
|
||||
|
||||
propagate_from_dir(src_dir)
|
||||
}
|
||||
|
||||
fn propagate_from_dir(src_dir: &Path) -> io::Result<bool> {
|
||||
let propagation_files = ["RustDesk.toml", "RustDesk_local.toml", "RustDesk2.toml"];
|
||||
let mut propagated = false;
|
||||
|
||||
for filename in propagation_files {
|
||||
let src_path = src_dir.join(filename);
|
||||
if !src_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for dest_root in remote_id_directories() {
|
||||
if dest_root == src_dir {
|
||||
continue; // Nao copiar para si mesmo
|
||||
}
|
||||
let target_path = dest_root.join(filename);
|
||||
if copy_overwrite(&src_path, &target_path).is_ok() {
|
||||
propagated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(propagated)
|
||||
}
|
||||
|
||||
fn ensure_remote_id_files(id: &str) {
|
||||
for dir in remote_id_directories() {
|
||||
let path = dir.join("RustDesk_local.toml");
|
||||
let _ = write_remote_id_value(&path, id);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_remote_id_value(path: &Path, id: &str) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let replacement = format!("remote_id = '{}'\n", id);
|
||||
if let Ok(existing) = fs::read_to_string(path) {
|
||||
let mut replaced = false;
|
||||
let mut buffer = String::with_capacity(existing.len() + replacement.len());
|
||||
for line in existing.lines() {
|
||||
if line.trim_start().starts_with("remote_id") {
|
||||
buffer.push_str(&replacement);
|
||||
replaced = true;
|
||||
} else {
|
||||
buffer.push_str(line);
|
||||
buffer.push('\n');
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
buffer.push_str(&replacement);
|
||||
}
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(path)?;
|
||||
file.write_all(buffer.as_bytes())
|
||||
} else {
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(path)?;
|
||||
file.write_all(replacement.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
fn write_toml_kv(path: &Path, key: &str, value: &str) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let sanitized = value.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let replacement = format!("{key} = \"{sanitized}\"\n");
|
||||
let existing = fs::read_to_string(path).unwrap_or_default();
|
||||
let mut replaced = false;
|
||||
let mut buffer = String::with_capacity(existing.len() + replacement.len());
|
||||
for line in existing.lines() {
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.starts_with(&format!("{key} ")) || trimmed.starts_with(&format!("{key}=")) {
|
||||
buffer.push_str(&replacement);
|
||||
replaced = true;
|
||||
} else {
|
||||
buffer.push_str(line);
|
||||
buffer.push('\n');
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
buffer.push_str(&replacement);
|
||||
}
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(path)?;
|
||||
file.write_all(buffer.as_bytes())
|
||||
}
|
||||
|
||||
fn read_remote_id_from_profiles() -> Option<String> {
|
||||
for dir in remote_id_directories() {
|
||||
for candidate in [dir.join("RustDesk_local.toml"), dir.join("RustDesk.toml")] {
|
||||
if let Some(id) = read_remote_id_file(&candidate) {
|
||||
if !id.is_empty() {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_remote_id_file(path: &Path) -> Option<String> {
|
||||
let content = fs::read_to_string(path).ok()?;
|
||||
for line in content.lines() {
|
||||
if let Some(value) = parse_assignment(line, "remote_id") {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_assignment(line: &str, key: &str) -> Option<String> {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.starts_with(key) {
|
||||
return None;
|
||||
}
|
||||
let (_, rhs) = trimmed.split_once('=')?;
|
||||
let value = rhs.trim().trim_matches(|c| c == '\'' || c == '"');
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn query_id_with_retries(exe_path: &Path, attempts: usize) -> Result<String, RustdeskError> {
|
||||
for attempt in 0..attempts {
|
||||
match query_id(exe_path) {
|
||||
Ok(value) if !value.trim().is_empty() => return Ok(value),
|
||||
_ => {}
|
||||
}
|
||||
if attempt + 1 < attempts {
|
||||
thread::sleep(Duration::from_millis(800));
|
||||
}
|
||||
}
|
||||
Err(RustdeskError::MissingId)
|
||||
}
|
||||
|
||||
fn query_id(exe_path: &Path) -> Result<String, RustdeskError> {
|
||||
let output = hidden_command(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<String, RustdeskError> {
|
||||
let output = hidden_command(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 query_service_state() -> Option<String> {
|
||||
let output = hidden_command("sc")
|
||||
.args(["query", SERVICE_NAME])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
let lower = line.to_lowercase();
|
||||
if lower.contains("running") {
|
||||
return Some("running".to_string());
|
||||
}
|
||||
if lower.contains("stopped") {
|
||||
return Some("stopped".to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn run_sc(args: &[&str]) -> Result<(), RustdeskError> {
|
||||
let status = hidden_command("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 run_with_args(exe_path: &Path, args: &[&str]) -> Result<(), RustdeskError> {
|
||||
let status = hidden_command(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(())
|
||||
}
|
||||
|
||||
fn remove_rustdesk_autorun_artifacts() {
|
||||
// Remove atalhos de inicializacao automatica
|
||||
let mut startup_paths: Vec<PathBuf> = Vec::new();
|
||||
if let Ok(appdata) = env::var("APPDATA") {
|
||||
startup_paths.push(
|
||||
Path::new(&appdata)
|
||||
.join("Microsoft\\Windows\\Start Menu\\Programs\\Startup\\RustDesk.lnk"),
|
||||
);
|
||||
}
|
||||
startup_paths.push(PathBuf::from(
|
||||
r"C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup\RustDesk.lnk",
|
||||
));
|
||||
|
||||
for path in startup_paths {
|
||||
if path.exists() {
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove entradas de registro
|
||||
for hive in ["HKCU", "HKLM"] {
|
||||
let reg_path = format!(r"{}\Software\Microsoft\Windows\CurrentVersion\Run", hive);
|
||||
let _ = hidden_command("reg")
|
||||
.args(["delete", ®_path, "/v", "RustDesk", "/f"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_service_profiles_writable() -> Result<(), String> {
|
||||
for dir in service_profile_dirs() {
|
||||
if !can_write_dir(&dir) {
|
||||
fix_profile_acl(&dir)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn can_write_dir(dir: &Path) -> bool {
|
||||
if fs::create_dir_all(dir).is_err() {
|
||||
return false;
|
||||
}
|
||||
let probe = dir.join(".raven_acl_probe");
|
||||
match OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&probe)
|
||||
{
|
||||
Ok(mut file) => {
|
||||
if file.write_all(b"ok").is_err() {
|
||||
let _ = fs::remove_file(&probe);
|
||||
return false;
|
||||
}
|
||||
let _ = fs::remove_file(&probe);
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn fix_profile_acl(target: &Path) -> Result<(), String> {
|
||||
let target_str = target.display().to_string();
|
||||
|
||||
// Como ja estamos rodando como LocalSystem, podemos usar takeown/icacls diretamente
|
||||
let _ = hidden_command("takeown")
|
||||
.args(["/F", &target_str, "/R", "/D", "Y"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
|
||||
let status = hidden_command("icacls")
|
||||
.args([
|
||||
&target_str,
|
||||
"/grant",
|
||||
"*S-1-5-32-544:(OI)(CI)F",
|
||||
"*S-1-5-19:(OI)(CI)F",
|
||||
"*S-1-5-32-545:(OI)(CI)M",
|
||||
"/T",
|
||||
"/C",
|
||||
"/Q",
|
||||
])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map_err(|e| format!("Erro ao executar icacls: {}", e))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("icacls retornou codigo {}", status.code().unwrap_or(-1)))
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_overwrite(src: &Path, dst: &Path) -> io::Result<()> {
|
||||
if let Some(parent) = dst.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
if dst.is_dir() {
|
||||
fs::remove_dir_all(dst)?;
|
||||
} else if dst.exists() {
|
||||
fs::remove_file(dst)?;
|
||||
}
|
||||
fs::copy(src, dst)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hidden_command(program: impl AsRef<OsStr>) -> Command {
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
cmd
|
||||
}
|
||||
259
apps/desktop/service/src/usb_policy.rs
Normal file
259
apps/desktop/service/src/usb_policy.rs
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
//! Modulo USB Policy - Controle de dispositivos USB
|
||||
//!
|
||||
//! Implementa o controle de armazenamento USB no Windows.
|
||||
//! Como o servico roda como LocalSystem, nao precisa de elevacao.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io;
|
||||
use thiserror::Error;
|
||||
use tracing::{error, info, warn};
|
||||
use winreg::enums::*;
|
||||
use winreg::RegKey;
|
||||
|
||||
// GUID para Removable Storage Devices (Disk)
|
||||
const REMOVABLE_STORAGE_GUID: &str = "{53f56307-b6bf-11d0-94f2-00a0c91efb8b}";
|
||||
|
||||
// Chaves de registro
|
||||
const REMOVABLE_STORAGE_PATH: &str = r"Software\Policies\Microsoft\Windows\RemovableStorageDevices";
|
||||
const USBSTOR_PATH: &str = r"SYSTEM\CurrentControlSet\Services\USBSTOR";
|
||||
const STORAGE_POLICY_PATH: &str = r"SYSTEM\CurrentControlSet\Control\StorageDevicePolicies";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum UsbPolicy {
|
||||
Allow,
|
||||
BlockAll,
|
||||
Readonly,
|
||||
}
|
||||
|
||||
impl UsbPolicy {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_uppercase().as_str() {
|
||||
"ALLOW" => Some(Self::Allow),
|
||||
"BLOCK_ALL" => Some(Self::BlockAll),
|
||||
"READONLY" => Some(Self::Readonly),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Allow => "ALLOW",
|
||||
Self::BlockAll => "BLOCK_ALL",
|
||||
Self::Readonly => "READONLY",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsbPolicyResult {
|
||||
pub success: bool,
|
||||
pub policy: String,
|
||||
pub error: Option<String>,
|
||||
pub applied_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum UsbControlError {
|
||||
#[error("Politica USB invalida: {0}")]
|
||||
InvalidPolicy(String),
|
||||
|
||||
#[error("Erro de registro do Windows: {0}")]
|
||||
RegistryError(String),
|
||||
|
||||
#[error("Permissao negada")]
|
||||
PermissionDenied,
|
||||
|
||||
#[error("Erro de I/O: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
}
|
||||
|
||||
/// Aplica uma politica de USB
|
||||
pub fn apply_policy(policy_str: &str) -> Result<UsbPolicyResult, UsbControlError> {
|
||||
let policy = UsbPolicy::from_str(policy_str)
|
||||
.ok_or_else(|| UsbControlError::InvalidPolicy(policy_str.to_string()))?;
|
||||
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
info!("Aplicando politica USB: {:?}", policy);
|
||||
|
||||
// 1. Aplicar Removable Storage Policy
|
||||
apply_removable_storage_policy(policy)?;
|
||||
|
||||
// 2. Aplicar USBSTOR
|
||||
apply_usbstor_policy(policy)?;
|
||||
|
||||
// 3. Aplicar WriteProtect se necessario
|
||||
if policy == UsbPolicy::Readonly {
|
||||
apply_write_protect(true)?;
|
||||
} else {
|
||||
apply_write_protect(false)?;
|
||||
}
|
||||
|
||||
// 4. Atualizar Group Policy (opcional)
|
||||
if let Err(e) = refresh_group_policy() {
|
||||
warn!("Falha ao atualizar group policy: {}", e);
|
||||
}
|
||||
|
||||
info!("Politica USB aplicada com sucesso: {:?}", policy);
|
||||
|
||||
Ok(UsbPolicyResult {
|
||||
success: true,
|
||||
policy: policy.as_str().to_string(),
|
||||
error: None,
|
||||
applied_at: Some(now),
|
||||
})
|
||||
}
|
||||
|
||||
/// Retorna a politica USB atual
|
||||
pub fn get_current_policy() -> Result<String, UsbControlError> {
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
|
||||
// Verifica Removable Storage Policy primeiro
|
||||
let full_path = format!(r"{}\{}", REMOVABLE_STORAGE_PATH, REMOVABLE_STORAGE_GUID);
|
||||
|
||||
if let Ok(key) = hklm.open_subkey_with_flags(&full_path, KEY_READ) {
|
||||
let deny_read: u32 = key.get_value("Deny_Read").unwrap_or(0);
|
||||
let deny_write: u32 = key.get_value("Deny_Write").unwrap_or(0);
|
||||
|
||||
if deny_read == 1 && deny_write == 1 {
|
||||
return Ok("BLOCK_ALL".to_string());
|
||||
}
|
||||
|
||||
if deny_read == 0 && deny_write == 1 {
|
||||
return Ok("READONLY".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Verifica USBSTOR como fallback
|
||||
if let Ok(key) = hklm.open_subkey_with_flags(USBSTOR_PATH, KEY_READ) {
|
||||
let start: u32 = key.get_value("Start").unwrap_or(3);
|
||||
if start == 4 {
|
||||
return Ok("BLOCK_ALL".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok("ALLOW".to_string())
|
||||
}
|
||||
|
||||
fn apply_removable_storage_policy(policy: UsbPolicy) -> Result<(), UsbControlError> {
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
let full_path = format!(r"{}\{}", REMOVABLE_STORAGE_PATH, REMOVABLE_STORAGE_GUID);
|
||||
|
||||
match policy {
|
||||
UsbPolicy::Allow => {
|
||||
// Tenta remover as restricoes, se existirem
|
||||
if let Ok(key) = hklm.open_subkey_with_flags(&full_path, KEY_ALL_ACCESS) {
|
||||
let _ = key.delete_value("Deny_Read");
|
||||
let _ = key.delete_value("Deny_Write");
|
||||
let _ = key.delete_value("Deny_Execute");
|
||||
}
|
||||
// Tenta remover a chave inteira se estiver vazia
|
||||
let _ = hklm.delete_subkey(&full_path);
|
||||
}
|
||||
UsbPolicy::BlockAll => {
|
||||
let (key, _) = hklm
|
||||
.create_subkey(&full_path)
|
||||
.map_err(map_winreg_error)?;
|
||||
|
||||
key.set_value("Deny_Read", &1u32)
|
||||
.map_err(map_winreg_error)?;
|
||||
key.set_value("Deny_Write", &1u32)
|
||||
.map_err(map_winreg_error)?;
|
||||
key.set_value("Deny_Execute", &1u32)
|
||||
.map_err(map_winreg_error)?;
|
||||
}
|
||||
UsbPolicy::Readonly => {
|
||||
let (key, _) = hklm
|
||||
.create_subkey(&full_path)
|
||||
.map_err(map_winreg_error)?;
|
||||
|
||||
// Permite leitura, bloqueia escrita
|
||||
key.set_value("Deny_Read", &0u32)
|
||||
.map_err(map_winreg_error)?;
|
||||
key.set_value("Deny_Write", &1u32)
|
||||
.map_err(map_winreg_error)?;
|
||||
key.set_value("Deny_Execute", &0u32)
|
||||
.map_err(map_winreg_error)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_usbstor_policy(policy: UsbPolicy) -> Result<(), UsbControlError> {
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
|
||||
let key = hklm
|
||||
.open_subkey_with_flags(USBSTOR_PATH, KEY_ALL_ACCESS)
|
||||
.map_err(map_winreg_error)?;
|
||||
|
||||
match policy {
|
||||
UsbPolicy::Allow => {
|
||||
// Start = 3 habilita o driver
|
||||
key.set_value("Start", &3u32)
|
||||
.map_err(map_winreg_error)?;
|
||||
}
|
||||
UsbPolicy::BlockAll => {
|
||||
// Start = 4 desabilita o driver
|
||||
key.set_value("Start", &4u32)
|
||||
.map_err(map_winreg_error)?;
|
||||
}
|
||||
UsbPolicy::Readonly => {
|
||||
// Readonly mantem driver ativo
|
||||
key.set_value("Start", &3u32)
|
||||
.map_err(map_winreg_error)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_write_protect(enable: bool) -> Result<(), UsbControlError> {
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
|
||||
if enable {
|
||||
let (key, _) = hklm
|
||||
.create_subkey(STORAGE_POLICY_PATH)
|
||||
.map_err(map_winreg_error)?;
|
||||
|
||||
key.set_value("WriteProtect", &1u32)
|
||||
.map_err(map_winreg_error)?;
|
||||
} else if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) {
|
||||
let _ = key.set_value("WriteProtect", &0u32);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refresh_group_policy() -> Result<(), UsbControlError> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Command;
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
let output = Command::new("gpupdate")
|
||||
.args(["/target:computer", "/force"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.map_err(UsbControlError::Io)?;
|
||||
|
||||
if !output.status.success() {
|
||||
warn!(
|
||||
"gpupdate retornou erro: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map_winreg_error(error: io::Error) -> UsbControlError {
|
||||
if let Some(code) = error.raw_os_error() {
|
||||
if code == 5 {
|
||||
return UsbControlError::PermissionDenied;
|
||||
}
|
||||
}
|
||||
UsbControlError::RegistryError(error.to_string())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue