feat(launcher): one-click client arming + modular preflight/services
Add a GUI "Arm client" button that reproduces client_arm.sh in a single pkexec batch: kernel.yama.ptrace_scope=0, DNAT of EA's hardcoded redirector IP to the OpenFUT server (+ MASQUERADE reply path), and /etc/hosts rewrites for every dead EA hostname (removing foreign shadow lines first, so glibc's first-match resolution can't land on a stale loopback entry). All steps are idempotent (delete-then-add) and injection-safe: config values are charset- validated and rejected on a surprising character, never shell-escaped. arm() returns the concrete change list, which the button logs line-by-line and echoes as an inline pass/fail status on the pre-launch tab (no tab jump, no reuse of the local-services toast). This necessarily lands the surrounding launcher modularization the arm feature is built on, extracted from the former monolithic app.rs/process.rs: - preflight: advisory pre-launch checks (ptrace, redirector DNAT, hostnames, backend reachability) that colour rows but never block Launch - local_services: launcher-owned LSX/autopatch child processes - game_launch, account_sync, health, netcheck helpers - openfut-common: dependency-free shared server-destination/port mapping, used by both the launcher and (separately) openfut_hook.dll openfut-hook RE changes are intentionally left uncommitted (separate concern). fmt + clippy -D warnings clean; 46 tests pass.
This commit is contained in:
+133
@@ -0,0 +1,133 @@
|
||||
//! Read-only health monitoring of the (remote) OpenFUT server.
|
||||
//!
|
||||
//! The launcher no longer *controls* the servers — they run elsewhere (e.g. in
|
||||
//! Docker on the server host). This module polls the configured server in a
|
||||
//! background thread and exposes a snapshot the UI can render. It never starts,
|
||||
//! stops, or assumes anything about how the server is hosted; it only asks
|
||||
//! "can the FIFA client reach it right now?".
|
||||
|
||||
use std::{
|
||||
net::{TcpStream, ToSocketAddrs},
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// A snapshot of the last health probe, rendered by the dashboard.
|
||||
#[derive(Clone)]
|
||||
pub struct HealthState {
|
||||
/// None = not yet checked / no target; Some(true/false) = reachable or not.
|
||||
pub reachable: Option<bool>,
|
||||
pub detail: String,
|
||||
pub last_checked: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Default for HealthState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
reachable: None,
|
||||
detail: "No server configured.".into(),
|
||||
last_checked: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Background poller. Holds a shared target (host, port) the UI can update when
|
||||
/// the user changes the server address, and a shared state the UI reads.
|
||||
pub struct HealthMonitor {
|
||||
pub state: Arc<Mutex<HealthState>>,
|
||||
target: Arc<Mutex<Option<(String, u16)>>>,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl HealthMonitor {
|
||||
pub fn new() -> Self {
|
||||
let state = Arc::new(Mutex::new(HealthState::default()));
|
||||
let target: Arc<Mutex<Option<(String, u16)>>> = Arc::new(Mutex::new(None));
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
|
||||
let t_state = Arc::clone(&state);
|
||||
let t_target = Arc::clone(&target);
|
||||
let t_running = Arc::clone(&running);
|
||||
thread::spawn(move || {
|
||||
while t_running.load(Ordering::Relaxed) {
|
||||
let target = t_target.lock().unwrap().clone();
|
||||
match target {
|
||||
None => {
|
||||
*t_state.lock().unwrap() = HealthState::default();
|
||||
}
|
||||
Some((host, port)) => {
|
||||
let snapshot = probe(&host, port);
|
||||
*t_state.lock().unwrap() = snapshot;
|
||||
}
|
||||
}
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
state,
|
||||
target,
|
||||
running,
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the monitor at a new server address (host + bridge port). Passing
|
||||
/// None (e.g. no server configured) puts it back into the idle state.
|
||||
pub fn set_target(&self, target: Option<(String, u16)>) {
|
||||
*self.target.lock().unwrap() = target;
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> HealthState {
|
||||
self.state.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HealthMonitor {
|
||||
fn drop(&mut self) {
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single reachability probe: DNS-resolve host:port and attempt a bounded TCP
|
||||
/// connect. A successful connect proves the FIFA client can reach the bridge.
|
||||
fn probe(host: &str, port: u16) -> HealthState {
|
||||
let now = Some(Instant::now());
|
||||
let addrs = match (host, port).to_socket_addrs() {
|
||||
Ok(a) => a.collect::<Vec<_>>(),
|
||||
Err(e) => {
|
||||
return HealthState {
|
||||
reachable: Some(false),
|
||||
detail: format!("Cannot resolve {host}: {e}"),
|
||||
last_checked: now,
|
||||
};
|
||||
}
|
||||
};
|
||||
if addrs.is_empty() {
|
||||
return HealthState {
|
||||
reachable: Some(false),
|
||||
detail: format!("{host} resolved to no addresses"),
|
||||
last_checked: now,
|
||||
};
|
||||
}
|
||||
for addr in &addrs {
|
||||
if TcpStream::connect_timeout(addr, CONNECT_TIMEOUT).is_ok() {
|
||||
return HealthState {
|
||||
reachable: Some(true),
|
||||
detail: format!("Reachable at {addr}"),
|
||||
last_checked: now,
|
||||
};
|
||||
}
|
||||
}
|
||||
HealthState {
|
||||
reachable: Some(false),
|
||||
detail: format!("{host}:{port} not reachable"),
|
||||
last_checked: now,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user