diff --git a/src-tauri/src/commands/controllers.rs b/src-tauri/src/commands/controllers.rs new file mode 100644 index 0000000..2cae415 --- /dev/null +++ b/src-tauri/src/commands/controllers.rs @@ -0,0 +1,147 @@ +use ssh2::Session; +use std::{ + io::Read, + net::{TcpStream, ToSocketAddrs}, + time::Duration, +}; + +fn run_vps_command( + password: &str, + command: &str, +) -> Result { + let address = "146.59.230.190:22"; + + let socket_address = address + .to_socket_addrs() + .map_err(|error| { + format!( + "failed to resolve VPS address: {}", + error + ) + })? + .next() + .ok_or_else(|| { + format!( + "failed to resolve VPS address {}", + address + ) + })?; + + let tcp = TcpStream::connect_timeout( + &socket_address, + Duration::from_secs(8), + ) + .map_err(|error| { + format!( + "failed to connect to VPS SSH: {}", + error + ) + })?; + + let _ = tcp.set_read_timeout(Some( + Duration::from_secs(30), + )); + + let _ = tcp.set_write_timeout(Some( + Duration::from_secs(30), + )); + + let mut session = Session::new() + .map_err(|error| { + format!( + "failed to create VPS SSH session: {}", + error + ) + })?; + + session.set_tcp_stream(tcp); + + session.handshake().map_err(|error| { + format!( + "VPS SSH handshake failed: {}", + error + ) + })?; + + session + .userauth_password("lr-vpn", password) + .map_err(|error| { + format!( + "VPS SSH authentication failed: {}", + error + ) + })?; + + if !session.authenticated() { + return Err( + "VPS SSH authentication failed" + .into(), + ); + } + + let mut channel = session + .channel_session() + .map_err(|error| { + format!( + "failed to open VPS SSH channel: {}", + error + ) + })?; + + channel + .exec(command) + .map_err(|error| { + format!( + "failed to execute VPS command: {}", + error + ) + })?; + + let mut stdout = String::new(); + + channel + .read_to_string(&mut stdout) + .map_err(|error| { + format!( + "failed to read VPS command output: {}", + error + ) + })?; + + channel.wait_close().ok(); + + let exit_status = channel + .exit_status() + .map_err(|error| { + format!( + "failed to read VPS command exit status: {}", + error + ) + })?; + + if exit_status != 0 { + return Err(format!( + "VPS command failed with exit code {}:\n{}", + exit_status, + stdout + )); + } + + Ok(stdout) +} + +#[tauri::command] +pub async fn list_controller_clients( + password: String, +) -> Result { + if password.trim().is_empty() { + return Err( + "VPS password is required".into(), + ); + } + + run_vps_command( + &password, + "sudo /usr/local/sbin/lr-controllers-list", + ) +} \ No newline at end of file diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 21d8fcc..44fe54a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,3 +2,4 @@ pub mod files; pub mod network; pub mod router; pub mod ssh; +pub mod controllers; \ No newline at end of file diff --git a/src-tauri/src/commands/router.rs b/src-tauri/src/commands/router.rs index 70299ea..6b98f86 100644 --- a/src-tauri/src/commands/router.rs +++ b/src-tauri/src/commands/router.rs @@ -2,17 +2,25 @@ use ssh2::Session; use std::process::Command; use std::{ fs::File, - io::{ Read, Write }, - net::{ TcpStream, ToSocketAddrs }, - path::Path, + io::{Read, Write}, + net::{TcpStream, ToSocketAddrs}, + path::{Path, PathBuf}, thread, time::Duration, }; +use tauri::{AppHandle, Manager}; fn delay() { thread::sleep(Duration::from_millis(350)); } +fn resource_path(app: &AppHandle, relative_path: &str) -> Result { + app.path() + .resource_dir() + .map_err(|error| format!("failed to resolve resource directory: {}", error)) + .map(|resource_dir| resource_dir.join("resources").join(relative_path)) +} + #[tauri::command] pub async fn detect_router(ip: String) -> Result { delay(); @@ -64,9 +72,12 @@ fn run_system_ssh(ip: &str, command: &str) -> Result { return Ok(format!("{}{}", stdout, stderr)); } - Err( - format!("system ssh failed with status {:?}:\n{}\n{}", output.status.code(), stderr, stdout) - ) + Err(format!( + "system ssh failed with status {:?}:\n{}\n{}", + output.status.code(), + stderr, + stdout + )) } fn open_router_session(ip: &str, password: &str) -> Result { @@ -78,20 +89,20 @@ fn open_router_session(ip: &str, password: &str) -> Result { .next() .ok_or_else(|| format!("failed to resolve router address {}", address))?; - let tcp = TcpStream::connect_timeout(&socket_address, Duration::from_secs(8)).map_err(|error| - format!("failed to connect to SSH on {}: {}", ip, error) - )?; + let tcp = TcpStream::connect_timeout(&socket_address, Duration::from_secs(8)) + .map_err(|error| format!("failed to connect to SSH on {}: {}", ip, error))?; let _ = tcp.set_read_timeout(Some(Duration::from_secs(30))); let _ = tcp.set_write_timeout(Some(Duration::from_secs(30))); - let mut session = Session::new().map_err(|error| - format!("failed to create SSH session: {}", error) - )?; + let mut session = + Session::new().map_err(|error| format!("failed to create SSH session: {}", error))?; session.set_tcp_stream(tcp); - session.handshake().map_err(|error| format!("SSH handshake failed for {}: {}", ip, error))?; + session + .handshake() + .map_err(|error| format!("SSH handshake failed for {}: {}", ip, error))?; authenticate_router(&session, password).map_err(|error| format!("root@{}: {}", ip, error))?; @@ -102,11 +113,10 @@ fn scp_file_from_disk( session: &Session, local_path: &str, remote_path: &str, - mode: i32 + mode: i32, ) -> Result<(), String> { - let mut local_file = File::open(local_path).map_err(|error| - format!("failed to open {}: {}", local_path, error) - )?; + let mut local_file = + File::open(local_path).map_err(|error| format!("failed to open {}: {}", local_path, error))?; let file_size = local_file .metadata() @@ -135,12 +145,7 @@ fn scp_file_from_disk( Ok(()) } -fn scp_string( - session: &Session, - content: &str, - remote_path: &str, - mode: i32 -) -> Result<(), String> { +fn scp_string(session: &Session, content: &str, remote_path: &str, mode: i32) -> Result<(), String> { let bytes = content.as_bytes(); let mut remote_file = session @@ -260,9 +265,15 @@ pub async fn verify_router(ip: String) -> Result { } #[tauri::command] -pub async fn upload_firmware_to_router(ip: String, password: String) -> Result { - let local_firmware_path = - "resources/firmware/openwrt-23.05.5-zbt-we826-16m-litoral-golden-sysupgrade.bin"; +pub async fn upload_firmware_to_router( + app: AppHandle, + ip: String, + password: String, +) -> Result { + let local_firmware_path = resource_path( + &app, + "firmware/openwrt-23.05.5-zbt-we826-16m-litoral-golden-sysupgrade.bin", + )?; let remote_firmware_path = "/tmp/firmware.bin"; @@ -270,16 +281,11 @@ pub async fn upload_firmware_to_router(ip: String, password: String) -> Result Result Result - Ok(format!("sysupgrade command submitted on {} using system ssh. {}", ip, output)), + Ok(output) => Ok(format!( + "sysupgrade command submitted on {} using system ssh. {}", + ip, output + )), Err(error) => { let lowered = error.to_lowercase(); - if - lowered.contains("commencing upgrade") || - lowered.contains("closing all shell sessions") || - lowered.contains("connection failed") || - lowered.contains("connection reset") || - lowered.contains("broken pipe") || - lowered.contains("closed") || - lowered.contains("disconnect") || - lowered.contains("sysupgrade") + if lowered.contains("commencing upgrade") + || lowered.contains("closing all shell sessions") + || lowered.contains("connection failed") + || lowered.contains("connection reset") + || lowered.contains("broken pipe") + || lowered.contains("closed") + || lowered.contains("disconnect") + || lowered.contains("sysupgrade") { - Ok(format!("sysupgrade started on {}; SSH disconnected as expected.", ip)) + Ok(format!( + "sysupgrade started on {}; SSH disconnected as expected.", + ip + )) } else { Err(error) } @@ -422,40 +409,15 @@ pub async fn flash_router_sysupgrade(ip: String, password: String) -> Result Result Resul if password.trim().is_empty() { match run_system_ssh(&ip, "ubus call system board") { Ok(output) => { - return Ok( - format!( - "Router reconnected after flash on attempt {}/{} using system ssh.\n{}", - attempt, - max_attempts, - output - ) - ); + return Ok(format!( + "Router reconnected after flash on attempt {}/{} using system ssh.\n{}", + attempt, max_attempts, output + )); } Err(_) => { thread::sleep(wait_between_attempts); @@ -505,93 +466,24 @@ pub async fn reconnect_router_after_flash(ip: String, password: String) -> Resul } } - let address = format!("{}:22", ip); - - let socket_address = match address.to_socket_addrs() { - Ok(mut addresses) => - match addresses.next() { - Some(socket_address) => socket_address, - None => { - thread::sleep(wait_between_attempts); - continue; - } + match open_router_session(&ip, &password) { + Ok(session) => match run_ssh_command(&session, "ubus call system board") { + Ok(output) => { + return Ok(format!( + "Router reconnected after flash on attempt {}/{}.\n{}", + attempt, max_attempts, output + )); } - Err(_) => { - thread::sleep(wait_between_attempts); - continue; - } - }; - - let tcp = match TcpStream::connect_timeout(&socket_address, Duration::from_secs(4)) { - Ok(tcp) => tcp, - Err(_) => { - thread::sleep(wait_between_attempts); - continue; - } - }; - - let _ = tcp.set_read_timeout(Some(Duration::from_secs(8))); - let _ = tcp.set_write_timeout(Some(Duration::from_secs(8))); - - let mut session = match Session::new() { - Ok(session) => session, - Err(_) => { - thread::sleep(wait_between_attempts); - continue; - } - }; - - session.set_tcp_stream(tcp); - - if session.handshake().is_err() { - thread::sleep(wait_between_attempts); - continue; + Err(_) => thread::sleep(wait_between_attempts), + }, + Err(_) => thread::sleep(wait_between_attempts), } - - if authenticate_router(&session, &password).is_err() { - thread::sleep(wait_between_attempts); - continue; - } - - let mut channel = match session.channel_session() { - Ok(channel) => channel, - Err(_) => { - thread::sleep(wait_between_attempts); - continue; - } - }; - - if channel.exec("ubus call system board").is_err() { - thread::sleep(wait_between_attempts); - continue; - } - - let mut output = String::new(); - - if channel.read_to_string(&mut output).is_err() { - thread::sleep(wait_between_attempts); - continue; - } - - let _ = channel.wait_close(); - - return Ok( - format!( - "Router reconnected after flash on attempt {}/{}.\n{}", - attempt, - max_attempts, - output - ) - ); } - Err( - format!( - "Router did not become reachable over SSH at {} after {} attempts. Replug Ethernet and retry.", - ip, - max_attempts - ) - ) + Err(format!( + "Router did not become reachable over SSH at {} after {} attempts. Replug Ethernet and retry.", + ip, max_attempts + )) } #[tauri::command] @@ -604,66 +496,28 @@ pub async fn check_router_after_flash(ip: String, password: String) -> Result Result { if ip.trim().is_empty() { return Err("router IP is required".into()); } - let local_script_path = "resources/provisioning/provision.sh"; + let local_script_path = resource_path(&app, "provisioning/provision.sh")?; let remote_script_path = "/tmp/provision.sh"; let remote_env_path = "/tmp/router.env"; if password.trim().is_empty() { let script_target = format!("root@{}:{}", ip, remote_script_path); + let local_script_path_string = local_script_path.to_string_lossy().to_string(); let script_upload = Command::new("scp") .args([ @@ -676,37 +530,41 @@ pub async fn upload_provisioning_bundle( "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=NUL", - local_script_path, + &local_script_path_string, &script_target, ]) .output() .map_err(|error| format!("failed to run scp for provision.sh: {}", error))?; if !script_upload.status.success() { - return Err( - format!( - "failed to upload provision.sh:\n{}\n{}", - String::from_utf8_lossy(&script_upload.stderr), - String::from_utf8_lossy(&script_upload.stdout) - ) - ); + return Err(format!( + "failed to upload provision.sh:\n{}\n{}", + String::from_utf8_lossy(&script_upload.stderr), + String::from_utf8_lossy(&script_upload.stdout) + )); } let env_command = format!( "cat > {} <<'EOF'\n{}\nEOF\nchmod +x {}", - remote_env_path, - env_content, - remote_script_path + remote_env_path, env_content, remote_script_path ); run_system_ssh(&ip, &env_command)?; - return Ok(format!("uploaded provision.sh and router.env to {} using system ssh/scp", ip)); + return Ok(format!( + "uploaded provision.sh and router.env to {} using system ssh/scp", + ip + )); } let session = open_router_session(&ip, &password)?; - scp_file_from_disk(&session, local_script_path, remote_script_path, 0o755)?; + scp_file_from_disk( + &session, + local_script_path.to_string_lossy().as_ref(), + remote_script_path, + 0o755, + )?; scp_string(&session, &env_content, remote_env_path, 0o600)?; @@ -716,16 +574,21 @@ pub async fn upload_provisioning_bundle( } #[tauri::command] -pub async fn upload_udp2raw_setup_script(ip: String, password: String) -> Result { +pub async fn upload_udp2raw_setup_script( + app: AppHandle, + ip: String, + password: String, +) -> Result { if ip.trim().is_empty() { return Err("router IP is required".into()); } - let local_script_path = "resources/udp2raw/setup_udp2raw.sh"; + let local_script_path = resource_path(&app, "udp2raw/setup_udp2raw.sh")?; let remote_script_path = "/tmp/setup_udp2raw.sh"; if password.trim().is_empty() { let target = format!("root@{}:{}", ip, remote_script_path); + let local_script_path_string = local_script_path.to_string_lossy().to_string(); let output = Command::new("scp") .args([ @@ -738,20 +601,18 @@ pub async fn upload_udp2raw_setup_script(ip: String, password: String) -> Result "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=NUL", - local_script_path, + &local_script_path_string, &target, ]) .output() .map_err(|error| format!("failed to run scp for setup_udp2raw.sh: {}", error))?; if !output.status.success() { - return Err( - format!( - "failed to upload setup_udp2raw.sh:\n{}\n{}", - String::from_utf8_lossy(&output.stderr), - String::from_utf8_lossy(&output.stdout) - ) - ); + return Err(format!( + "failed to upload setup_udp2raw.sh:\n{}\n{}", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + )); } run_system_ssh(&ip, "chmod +x /tmp/setup_udp2raw.sh")?; @@ -761,7 +622,12 @@ pub async fn upload_udp2raw_setup_script(ip: String, password: String) -> Result let session = open_router_session(&ip, &password)?; - scp_file_from_disk(&session, local_script_path, remote_script_path, 0o755)?; + scp_file_from_disk( + &session, + local_script_path.to_string_lossy().as_ref(), + remote_script_path, + 0o755, + )?; run_ssh_command(&session, "chmod +x /tmp/setup_udp2raw.sh")?; @@ -791,8 +657,7 @@ pub async fn check_udp2raw_router_status(ip: String, password: String) -> Result return Err("router IP is required".into()); } - let command = - r#" + let command = r#" echo "== udp2raw binary ==" if command -v udp2raw >/dev/null 2>&1; then command -v udp2raw @@ -899,12 +764,7 @@ pub async fn test_udp2raw_tunnel(ip: String, password: String) -> Result { - last_error = format!( - "SSH attempt {}/5 failed: {}", - attempt, - error - ); - + last_error = format!("SSH attempt {}/5 failed: {}", attempt, error); thread::sleep(Duration::from_secs(2)); } } @@ -914,16 +774,21 @@ pub async fn test_udp2raw_tunnel(ip: String, password: String) -> Result Result { +pub async fn upload_udp2raw_binary( + app: AppHandle, + ip: String, + password: String, +) -> Result { if ip.trim().is_empty() { return Err("router IP is required".into()); } - let local_binary_path = "resources/udp2raw/udp2raw"; + let local_binary_path = resource_path(&app, "udp2raw/udp2raw")?; let remote_binary_path = "/usr/bin/udp2raw"; if password.trim().is_empty() { let target = format!("root@{}:{}", ip, remote_binary_path); + let local_binary_path_string = local_binary_path.to_string_lossy().to_string(); let output = Command::new("scp") .args([ @@ -936,25 +801,23 @@ pub async fn upload_udp2raw_binary(ip: String, password: String) -> Result/dev/null 2>&1 || true" + "chmod +x /usr/bin/udp2raw && /usr/bin/udp2raw --help >/dev/null 2>&1 || true", )?; return Ok("uploaded udp2raw binary to /usr/bin/udp2raw".into()); @@ -962,9 +825,17 @@ pub async fn upload_udp2raw_binary(ip: String, password: String) -> Result; } + if (active === 'Controladores') { + return ; + } + if (active === 'Provisionamento') { return ; } diff --git a/src/components/controllers/ControllersRoute.tsx b/src/components/controllers/ControllersRoute.tsx new file mode 100644 index 0000000..8ccdc73 --- /dev/null +++ b/src/components/controllers/ControllersRoute.tsx @@ -0,0 +1,1211 @@ +import { useEffect, useMemo, useRef, useState, Fragment } from 'react'; +import { + Monitor, + Pencil, + Plus, + RefreshCw, + Save, + Search, + Server, + ShieldCheck, + Trash2, + X, +} from 'lucide-react'; + +import { Card } from '@/components/ui/Card'; +import { Select } from '@/components/ui/Select'; +import { vpsApi } from '@/services/vpsApi'; + +import type { ControllerClient } from '@/services/vpsApi'; + +const scrollClass = + '[scrollbar-width:thin] [scrollbar-color:rgba(59,130,246,0.55)_transparent] [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-blue-500/45 hover:[&::-webkit-scrollbar-thumb]:bg-blue-500/65'; + +const controllerFilterOptions: { + value: 'all' | 'with' | 'without'; + label: string; +}[] = [ + { value: 'all', label: 'Todos' }, + { value: 'with', label: 'Com controladores' }, + { value: 'without', label: 'Sem controladores' }, + ]; + +const pageSizeOptions: { + value: '10' | '25' | '50'; + label: string; +}[] = [ + { value: '10', label: '10 por página' }, + { value: '25', label: '25 por página' }, + { value: '50', label: '50 por página' }, + ]; + +export function ControllersRoute() { + const [search, setSearch] = useState(''); + const [clients, setClients] = useState([]); + const [selectedId, setSelectedId] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState<'10' | '25' | '50'>('10'); + const [controllerFilter, setControllerFilter] = + useState<'all' | 'with' | 'without'>('all'); + + const [editingClient, setEditingClient] = + useState(null); + + async function loadClients() { + try { + setLoading(true); + setError(''); + + const response = await vpsApi.listControllerClients(); + + setClients(response.clients); + setSelectedId((current) => current || response.clients[0]?.id || ''); + } catch (err) { + setError(String(err)); + setClients([]); + setSelectedId(''); + } finally { + setLoading(false); + } + } + + useEffect(() => { + loadClients(); + }, []); + + const filteredClients = useMemo(() => { + return clients.filter((client) => { + const matchesSearch = client.name + .toLowerCase() + .includes(search.toLowerCase()); + + const matchesControllerFilter = + controllerFilter === 'all' || + (controllerFilter === 'with' && client.controller_count > 0) || + (controllerFilter === 'without' && client.controller_count === 0); + + return matchesSearch && matchesControllerFilter; + }); + }, [clients, search, controllerFilter]); + + useEffect(() => { + setPage(1); + }, [search, controllerFilter, pageSize]); + + const pageSizeNumber = Number(pageSize); + + const totalPages = Math.max( + 1, + Math.ceil(filteredClients.length / pageSizeNumber), + ); + + const pagedClients = useMemo(() => { + const start = (page - 1) * pageSizeNumber; + return filteredClients.slice(start, start + pageSizeNumber); + }, [filteredClients, page, pageSizeNumber]); + + const selectedClient = + filteredClients.find((client) => client.id === selectedId) ?? + filteredClients[0]; + + return ( +
+
+
+

+ Controladores +

+ +

+ Gestão de clientes, controladores, ficheiros VPS e regras do router. +

+
+ + +
+ + {error && ( +
+ Erro ao carregar clientes: {error} +
+ )} + +
+ +
+
+

+ Clientes +

+ +

+ Ficheiros em /var/litoral_regas_app/maquinas +

+
+ +
+ {filteredClients.length} / {clients.length} clientes +
+
+ +
+ + +
+ + + + setSearch(event.target.value) + } + placeholder="Pesquisar cliente..." + className="w-full bg-transparent text-sm text-white outline-none placeholder:text-slate-600" + /> +
+
+ +
+
+ + + +
+
+ +
+ {loading && clients.length === 0 && ( +
+ A carregar clientes da VPS... +
+ )} + + {!loading && filteredClients.length === 0 && ( +
+ Nenhum cliente encontrado. +
+ )} + + {pagedClients.map((client) => { + const active = selectedClient?.id === client.id; + + return ( + + ); + })} +
+ +
+ + Página {page} de {totalPages} ·{' '} + {filteredClients.length} resultados + + +
+ + + +
+
+
+ + + {selectedClient ? ( +
+
+
+

+ Cliente selecionado +

+ +

+ {selectedClient.name} +

+ +

+ {selectedClient.path} +

+ +

+ Última alteração:{' '} + {selectedClient.modified_at ?? '—'} +

+
+ + +
+ +
+ } + label="Controladores" + value={String( + selectedClient.controller_count, + )} + /> + + } + label="Ficheiro VPS" + value={selectedClient.file} + /> +
+ +
+

+ Próximas ações +

+ +
+

• Abrir modal de edição do cliente.

+

+ • Listar controladores do ficheiro .txt. +

+

+ • Detetar router associado pelo IP + WireGuard. +

+

+ • Adicionar, atualizar ou remover DNAT no + router. +

+

+ • Atualizar entrada correspondente no + ficheiro VPS. +

+
+
+
+ ) : ( +
+ Nenhum cliente selecionado. +
+ )} +
+
+ + {editingClient && ( + setEditingClient(null)} + /> + )} +
+ ); +} + +function MiniStat({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: string; +}) { + return ( +
+
+ {icon} +
+ +

+ {label} +

+ +

+ {value} +

+
+ ); +} + +type EditableController = { + id: string; + name: string; + routerVpnIp: string; + externalPort: string; + password: string; +}; + +function EditClientModal({ + client, + onClose, +}: { + client: ControllerClient; + onClose: () => void; +}) { + const [clientPassword, setClientPassword] = useState(''); + const [controllers, setControllers] = useState([]); + const [loadingClientFile, setLoadingClientFile] = useState(true); + const [clientFileError, setClientFileError] = useState(''); + + useEffect(() => { + let cancelled = false; + + async function loadClientFile() { + try { + setLoadingClientFile(true); + setClientFileError(''); + + const response = await vpsApi.readControllerClient(client.id); + + if (cancelled) return; + + const parsed = parseClientTxt(response.content); + + setClientPassword(parsed.password); + setControllers(parsed.controllers); + } catch (err) { + if (cancelled) return; + + setClientFileError(String(err)); + setClientPassword(''); + setControllers([]); + } finally { + if (!cancelled) { + setLoadingClientFile(false); + } + } + } + + loadClientFile(); + + return () => { + cancelled = true; + }; + }, [client.id]); + + const controllerErrors = useMemo( + () => controllers.map(validateController), + [controllers], + ); + + const clientPasswordError = useMemo(() => { + if (loadingClientFile) return ''; + if (!clientPassword.trim()) return 'A palavra-passe é obrigatória.'; + + if (hasTxtSeparator(clientPassword)) { + return 'A palavra-passe não pode conter "xxx" ou quebras de linha.'; + } + + return ''; + }, [clientPassword, loadingClientFile]); + + const formIsValid = + !loadingClientFile && + !clientFileError && + !clientPasswordError && + controllers.length > 0 && + controllerErrors.every((errors) => !hasErrors(errors)); + + const txtPreview = useMemo(() => { + const lines = [ + clientPassword, + ...controllers.map( + (controller) => + `${controller.name}xxx${controller.routerVpnIp}xxx${controller.externalPort}xxx${controller.password}`, + ), + ]; + + return `${lines.join('\n')}\n`; + }, [clientPassword, controllers]); + + function updateController( + id: string, + patch: Partial, + ) { + setControllers((current) => + current.map((controller) => + controller.id === id + ? { ...controller, ...patch } + : controller, + ), + ); + } + + function addController() { + setControllers((current) => [ + ...current, + { + id: crypto.randomUUID(), + name: `Controlador${current.length + 1}`, + routerVpnIp: '', + externalPort: getNextAvailablePort(current), + password: '111111', + }, + ]); + } + + function removeController(id: string) { + setControllers((current) => + current.filter((controller) => controller.id !== id), + ); + } + + return ( +
+
+
+
+

+ Editar Cliente +

+ +

+ {client.name} +

+ +

+ {client.path} +

+
+ + +
+ + {loadingClientFile ? ( +
+ A carregar ficheiro do cliente... +
+ ) : clientFileError ? ( +
+ Erro ao carregar ficheiro do cliente: {clientFileError} +
+ ) : ( +
+
+
+

+ Dados do Cliente +

+ +
+ +
+
+ +
+
+
+

+ Controladores +

+ +

+ Entradas carregadas do ficheiro real do cliente. +

+
+ + +
+ +
+ {controllers.map((controller, index) => ( +
+
+

+ Controlador #{index + 1} +

+ + +
+ +
+ + updateController(controller.id, { + name: value.replace( + /[^A-Za-z0-9_-]/g, + '', + ), + }) + } + /> + + + updateController(controller.id, { + routerVpnIp: value, + }) + } + /> + + + updateController(controller.id, { + externalPort: value, + }) + } + /> + + + updateController(controller.id, { + password: value + .replace(/\D/g, '') + .slice(0, 6), + }) + } + /> +
+
+ ))} +
+
+
+ +
+
+
+
+

+ Preview .txt +

+ +

+ Conteúdo exato a guardar no ficheiro. +

+
+ + + {controllers.length} entradas + +
+ +
+                                    {txtPreview}
+                                
+
+
+
+ )} + +
+

+ Dados carregados diretamente do ficheiro do cliente. +

+ +
+ + + +
+
+
+
+ ); +} + +function parseClientTxt(content: string): { + password: string; + controllers: EditableController[]; +} { + const lines = content + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .split('\n') + .filter((line) => line.length > 0); + + const password = lines[0] ?? ''; + + const controllers = lines.slice(1).map((line, index) => { + const [name = '', routerVpnIp = '', externalPort = '', password = ''] = + line.split('xxx'); + + return { + id: crypto.randomUUID(), + name, + routerVpnIp, + externalPort, + password, + }; + }); + + return { + password, + controllers, + }; +} + +function Field({ + label, + value, + onChange, + error, + inputMode, + maxLength, + type = 'text', + min, + max, + step, + hideLabel = false, + placeholder, + autoFocus = false +}: { + label: string; + value: string; + onChange: (value: string) => void; + error?: string; + inputMode?: React.HTMLAttributes['inputMode']; + maxLength?: number; + type?: React.HTMLInputTypeAttribute; + min?: number; + max?: number; + step?: number; + hideLabel?: boolean; + placeholder?: string; + autoFocus?: boolean; +}) { + return ( +
+ {!hideLabel && ( + + )} + +
+ onChange(event.target.value)} + className={`w-full rounded-xl border bg-slate-950 px-3 py-3 pr-12 font-mono text-sm text-white outline-none transition placeholder:text-slate-600 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${error + ? 'border-red-500/60 focus:border-red-400' + : 'border-white/10 focus:border-blue-500/40' + }`} + /> + + {type === 'number' && ( +
+ + + +
+ )} +
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} + +function IpField({ + label, + value, + onChange, + error, + hideLabel = false, + autoFocusFirstOctet = false, + autoFocusOctetIndex = 0, +}: { + label: string; + value: string; + onChange: (value: string) => void; + error?: string; + hideLabel?: boolean; + autoFocusFirstOctet?: boolean; + autoFocusOctetIndex?: number; +}) { + const inputRefs = useRef>([]); + + useEffect(() => { + if (!autoFocusFirstOctet) return; + + requestAnimationFrame(() => { + const input = inputRefs.current[autoFocusOctetIndex]; + + input?.focus(); + input?.setSelectionRange(input.value.length, input.value.length); + }); + }, [autoFocusFirstOctet, autoFocusOctetIndex]); + + const parts = value.split('.'); + const octets = Array.from({ length: 4 }, (_, index) => parts[index] ?? ''); + + function updateOctet(index: number, rawValue: string) { + const nextValue = rawValue.replace(/\D/g, '').slice(0, 3); + + if (nextValue && Number(nextValue) > 255) return; + + const nextOctets = [...octets]; + nextOctets[index] = nextValue; + + onChange(nextOctets.join('.')); + } + + function handleKeyDown( + index: number, + event: React.KeyboardEvent, + ) { + if (event.key === '.') { + event.preventDefault(); + + inputRefs.current[index + 1]?.focus(); + + return; + } + + if (event.key !== 'Backspace') return; + + const current = octets[index]; + + if (current.length > 0) { + const nextOctets = [...octets]; + nextOctets[index] = current.slice(0, -1); + + onChange(nextOctets.join('.')); + event.preventDefault(); + + return; + } + + if (index > 0) { + event.preventDefault(); + inputRefs.current[index - 1]?.focus(); + } + } + + return ( +
+ {!hideLabel && ( + + )} + +
+ {octets.map((octet, index) => ( + + { + inputRefs.current[index] = element; + }} + value={octet} + inputMode="numeric" + maxLength={3} + onChange={(event) => + updateOctet(index, event.target.value) + } + onKeyDown={(event) => + handleKeyDown(index, event) + } + className="w-full min-w-0 bg-transparent px-1 py-1 text-center outline-none" + /> + + {index < 3 && ( + + . + + )} + + ))} +
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} + +type FieldErrors = Partial>; + +const IPV4_REGEX = + /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; + +const HOSTNAME_REGEX = + /^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/; + +function isValidRouterHost(value: string) { + const trimmed = value.trim(); + + return IPV4_REGEX.test(trimmed) || HOSTNAME_REGEX.test(trimmed); +} + +const INVALID_CONTROLLER_NAME_REGEX = /(xxx|\r|\n)/i; + +function hasTxtSeparator(value: string) { + return value.includes('xxx') || value.includes('\n') || value.includes('\r'); +} + +function validatePort(value: string) { + const port = Number(value); + + if (!value.trim()) { + return 'A porta externa é obrigatória.'; + } + + if (!/^\d+$/.test(value)) { + return 'A porta deve conter apenas números.'; + } + + if (port < 5900 || port > 5999) { + return 'A porta deve estar entre 5900 e 5999.'; + } + + return ''; +} + +function getNextAvailablePort(controllers: EditableController[]) { + const usedPorts = new Set( + controllers + .map((controller) => Number(controller.externalPort)) + .filter((port) => Number.isInteger(port) && port >= 5900), + ); + + let port = 5900; + + while (usedPorts.has(port)) { + port += 1; + } + + return String(port); +} + +function validateController(controller: EditableController): FieldErrors { + const errors: FieldErrors = {}; + + if (!controller.name.trim()) { + errors.name = 'O nome é obrigatório.'; + } else if (INVALID_CONTROLLER_NAME_REGEX.test(controller.name)) { + errors.name = + 'O nome não pode conter "xxx" ou quebras de linha.'; + } + + if (!controller.routerVpnIp.trim()) { + errors.routerVpnIp = 'O router/host é obrigatório.'; + } else if (!isValidRouterHost(controller.routerVpnIp)) { + errors.routerVpnIp = 'Introduz um IPv4 ou hostname válido.'; + } + + const portError = validatePort(controller.externalPort); + if (portError) errors.externalPort = portError; + + if (!/^\d{6}$/.test(controller.password)) { + errors.password = 'O PIN/password deve ter exatamente 6 dígitos.'; + } + + return errors; +} + +function hasErrors(errors: FieldErrors) { + return Object.values(errors).some(Boolean); +} + +type RouterFieldMode = 'host' | 'ip'; + +function SmartRouterField({ + label, + value, + onChange, + error, +}: { + label: string; + value: string; + onChange: (value: string) => void; + error?: string; +}) { + const [mode, setMode] = useState(() => + IPV4_REGEX.test(value.trim()) ? 'ip' : 'host', + ); + + const [focusOctetIndex, setFocusOctetIndex] = useState(0); + const [isFocused, setIsFocused] = useState(false); + + useEffect(() => { + if (!value.trim()) { + setMode('host'); + setFocusOctetIndex(0); + } + }, [value]); + + function handleHostChange(nextValue: string) { + const shouldSwitchToIp = + /^[\d.]+$/.test(nextValue) && /\d+\.$/.test(nextValue); + + if (shouldSwitchToIp) { + setFocusOctetIndex( + Math.min(nextValue.split('.').length - 1, 3), + ); + + setMode('ip'); + } + + onChange(nextValue); + } + + function handleIpChange(nextValue: string) { + if (!nextValue.replace(/\./g, '').trim()) { + onChange(''); + return; + } + + onChange(nextValue); + } + return ( +
setIsFocused(true)} + onBlur={() => { + setIsFocused(false); + + if (!value.trim()) { + setMode('host'); + setFocusOctetIndex(0); + } + }} + > +
+ + + + {mode === 'ip' ? 'Auto · IPv4' : 'Auto · Host'} + +
+ + {mode === 'ip' ? ( + + ) : ( + + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 785a366..d3a885e 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -5,12 +5,14 @@ import { RadioTower, Settings, Wrench, + Router } from 'lucide-react'; import logoIcon from '@/assets/logo-icon.png'; const items = [ ['Painel', Gauge], + ['Controladores', Router], ['Configuração UDP2RAW', RadioTower], ['Provisionamento', Wrench], ['Registos de Atividade', FileClock], @@ -57,18 +59,16 @@ export function Sidebar({ key={label} type="button" onClick={() => onSelect(label)} - className={`group flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-left text-sm font-semibold transition-all duration-200 ${ - isActive + className={`group flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-left text-sm font-semibold transition-all duration-200 ${isActive ? 'border border-blue-500/20 bg-blue-500/15 text-white shadow-[0_0_25px_rgba(59,130,246,0.12)]' : 'border border-transparent text-slate-300 hover:border-white/5 hover:bg-white/[0.035] hover:text-white' - }`} + }`} >
diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index fac75f2..03c82ec 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -1,9 +1,9 @@ import type { AppSettings } from '@/types/api'; -const defaults: AppSettings = { - backendUrl: 'http://146.59.230.190:8080', - apiKey: - 'b8184608fcab419da2ce9a220ee6017daaff637b627e43ebac31b6b7c3344349', +const defaults: AppSettings = { // FOR DEBUG AND DEV PURPOSES ONLY ==========================> + backendUrl: 'http://localhost:8080',// FOR DEBUG AND DEV PURPOSES ONLY ==========================> + apiKey:// FOR DEBUG AND DEV PURPOSES ONLY ==========================> + 'dev-api-key',// FOR DEBUG AND DEV PURPOSES ONLY ==========================> }; const SETTINGS_KEY = diff --git a/src/services/vpsApi.ts b/src/services/vpsApi.ts index a53e98b..18e04bd 100644 --- a/src/services/vpsApi.ts +++ b/src/services/vpsApi.ts @@ -5,21 +5,32 @@ import type { VpsHealth, } from '@/types/api'; +export type ControllerClient = { + id: string; + name: string; + file: string; + path: string; + controller_count: number; + modified_at: string; +}; + +export type ControllerClientsResponse = { + clients: ControllerClient[]; +}; + +export type ControllerClientFileResponse = { + content: string; +}; + export const vpsApi = { health: () => - apiRequest( - '/api/vps/health', - ), + apiRequest('/api/vps/health'), networkTraffic: () => - apiRequest( - '/api/vps/network-traffic', - ), + apiRequest('/api/vps/network-traffic'), udp2rawTraffic: () => - apiRequest( - '/api/vps/udp2raw-traffic', - ), + apiRequest('/api/vps/udp2raw-traffic'), rollbackLastBackup: () => apiRequest<{ restored: boolean }>( @@ -28,4 +39,16 @@ export const vpsApi = { method: 'POST', }, ), + + // ALL clients + listControllerClients: () => + apiRequest( + '/api/vps/controllers/clients', + ), + + // ONE client's .txt content + readControllerClient: (clientId: string) => + apiRequest( + `/api/vps/controllers/clients/${encodeURIComponent(clientId)}`, + ), }; \ No newline at end of file