- Logo oficial (sabia.png) e conjunto de ícones do app desktop. - Interface em português e inglês: tradutor por observação do DOM, seletor de idioma e checagem de cobertura de tradução no CI. - CI/CD com caches (rust-cache, .deb do Tauri em cache) e pipeline de release multiplataforma (CLI em 5 alvos + app desktop via tauri-action), templates de issue/PR. - Suíte de testes de integração do sabia-core (scan e2e, handshake/crack 4-way e PMKID, pcap, trilha de auditoria) — 46 testes verdes. - Documentação completa pt/en: README, ROADMAP, SECURITY, CONTRIBUTING, CHANGELOG e docs/ (cli, interface, arquitetura), com enquadramento de uso autorizado. - Correções de clippy (-D warnings) e formatação em toda a árvore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
253 lines
7.4 KiB
Rust
253 lines
7.4 KiB
Rust
//! Controle de modo (managed/monitor), canal e leitura das capacidades do
|
|
//! rádio via `iw`.
|
|
//!
|
|
//! É uma ponte deliberada: o protocolo real é nl80211 por netlink, e trocar
|
|
//! este módulo por uma implementação nativa (sem depender dos binários `iw` e
|
|
//! `ip`) é o próximo passo natural. Por ora `iw`/`ip` são universais em
|
|
//! Linux, testados, e evitam reimplementar genl/nl80211 antes de validar o
|
|
//! pipeline com hardware real.
|
|
|
|
use crate::error::{Result, SabiaError};
|
|
use crate::iface::Mode;
|
|
use crate::radiotap::{band_for_freq, Band};
|
|
use std::process::Command;
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct ChannelInfo {
|
|
pub channel: u32,
|
|
pub freq_mhz: u16,
|
|
pub band: Band,
|
|
pub disabled: bool,
|
|
pub max_power_dbm: Option<f32>,
|
|
}
|
|
|
|
fn run(cmd: &str, args: &[&str]) -> Result<String> {
|
|
let out = Command::new(cmd)
|
|
.args(args)
|
|
.output()
|
|
.map_err(|e| SabiaError::CommandFailed {
|
|
cmd: format!("{cmd} {}", args.join(" ")),
|
|
detail: e.to_string(),
|
|
})?;
|
|
if !out.status.success() {
|
|
let detail = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
|
let detail = if detail.is_empty() {
|
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
|
} else {
|
|
detail
|
|
};
|
|
if detail.contains("Operation not permitted")
|
|
|| detail.contains("Insufficient privileges")
|
|
|| detail.contains("nl80211 not found")
|
|
{
|
|
return Err(SabiaError::PermissionDenied);
|
|
}
|
|
return Err(SabiaError::CommandFailed {
|
|
cmd: format!("{cmd} {}", args.join(" ")),
|
|
detail,
|
|
});
|
|
}
|
|
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
|
}
|
|
|
|
pub fn command_exists(name: &str) -> bool {
|
|
std::env::var_os("PATH")
|
|
.map(|paths| {
|
|
std::env::split_paths(&paths).any(|dir| {
|
|
let p = dir.join(name);
|
|
p.is_file()
|
|
})
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
pub fn is_root() -> bool {
|
|
#[cfg(unix)]
|
|
{
|
|
unsafe { libc::geteuid() == 0 }
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
false
|
|
}
|
|
}
|
|
|
|
pub fn iw_dev_type(iface: &str) -> Option<Mode> {
|
|
let out = run("iw", &["dev", iface, "info"]).ok()?;
|
|
for line in out.lines() {
|
|
let line = line.trim();
|
|
if let Some(t) = line.strip_prefix("type ") {
|
|
return Some(match t.trim() {
|
|
"monitor" => Mode::Monitor,
|
|
"managed" => Mode::Managed,
|
|
_ => Mode::Unknown,
|
|
});
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn phy_for_iface(iface: &str) -> Result<String> {
|
|
let out = run("iw", &["dev", iface, "info"])?;
|
|
out.lines()
|
|
.find_map(|l| l.trim().strip_prefix("wiphy "))
|
|
.map(|n| format!("phy{n}"))
|
|
.ok_or_else(|| SabiaError::CommandFailed {
|
|
cmd: "iw dev info".into(),
|
|
detail: "wiphy não encontrado na saída".into(),
|
|
})
|
|
}
|
|
|
|
/// Lista canais suportados pelo phy da interface.
|
|
pub fn channels(iface: &str) -> Result<Vec<ChannelInfo>> {
|
|
let phy = phy_for_iface(iface)?;
|
|
let info = run("iw", &["phy", &phy, "info"])?;
|
|
Ok(parse_phy_channels(&info))
|
|
}
|
|
|
|
pub fn supported_channels(iface: &str) -> Result<Vec<u32>> {
|
|
Ok(channels(iface)?
|
|
.into_iter()
|
|
.filter(|c| !c.disabled)
|
|
.map(|c| c.channel)
|
|
.collect())
|
|
}
|
|
|
|
/// Canais a percorrer. `band` aceita `"2.4"`, `"5"`, `"6"`, `"all"`/`None`.
|
|
pub fn channel_plan(iface: &str, band: Option<&str>) -> Result<Vec<u32>> {
|
|
let want = match band.map(|b| b.trim().to_ascii_lowercase()) {
|
|
None => None,
|
|
Some(b) if b.is_empty() || b == "all" || b == "auto" => None,
|
|
Some(b) => Some(match b.as_str() {
|
|
"2.4" | "2.4ghz" | "2g" => Band::B2G4,
|
|
"5" | "5ghz" | "5g" => Band::B5,
|
|
"6" | "6ghz" | "6g" => Band::B6,
|
|
other => {
|
|
return Err(SabiaError::InvalidInput(format!(
|
|
"banda `{other}` inválida (use 2.4, 5, 6 ou all)"
|
|
)))
|
|
}
|
|
}),
|
|
};
|
|
let mut v: Vec<u32> = channels(iface)?
|
|
.into_iter()
|
|
.filter(|c| !c.disabled)
|
|
.filter(|c| want.map_or(true, |w| c.band == w))
|
|
.map(|c| c.channel)
|
|
.collect();
|
|
v.sort_unstable();
|
|
v.dedup();
|
|
Ok(v)
|
|
}
|
|
|
|
pub fn monitor_supported(iface: &str) -> bool {
|
|
let Ok(phy) = phy_for_iface(iface) else {
|
|
return false;
|
|
};
|
|
let Ok(info) = run("iw", &["phy", &phy, "info"]) else {
|
|
return false;
|
|
};
|
|
info.lines().any(|l| l.trim() == "* monitor")
|
|
}
|
|
|
|
pub fn driver_name(iface: &str) -> Option<String> {
|
|
let path = std::path::Path::new("/sys/class/net")
|
|
.join(iface)
|
|
.join("device/driver");
|
|
std::fs::read_link(path)
|
|
.ok()
|
|
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
|
|
}
|
|
|
|
/// Põe a interface em modo monitor. Requer `CAP_NET_ADMIN` (normalmente root).
|
|
pub fn set_monitor_mode(iface: &str, enable: bool) -> Result<()> {
|
|
run("ip", &["link", "set", iface, "down"])?;
|
|
let target = if enable { "monitor" } else { "managed" };
|
|
run("iw", &["dev", iface, "set", "type", target])?;
|
|
run("ip", &["link", "set", iface, "up"])?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Troca o canal atual da interface (precisa já estar em modo monitor).
|
|
pub fn set_channel(iface: &str, channel: u32) -> Result<()> {
|
|
run(
|
|
"iw",
|
|
&["dev", iface, "set", "channel", &channel.to_string()],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn parse_phy_channels(text: &str) -> Vec<ChannelInfo> {
|
|
let mut out = Vec::new();
|
|
for line in text.lines() {
|
|
let line = line.trim();
|
|
let Some(rest) = line.strip_prefix("* ") else {
|
|
continue;
|
|
};
|
|
let Some(mhz_at) = rest.find(" MHz") else {
|
|
continue;
|
|
};
|
|
let Ok(freq) = rest[..mhz_at].trim().parse::<f32>() else {
|
|
continue;
|
|
};
|
|
let Some(open) = rest.find('[') else {
|
|
continue;
|
|
};
|
|
let Some(close_rel) = rest[open..].find(']') else {
|
|
continue;
|
|
};
|
|
let Ok(channel) = rest[open + 1..open + close_rel].parse::<u32>() else {
|
|
continue;
|
|
};
|
|
let freq = freq.round() as u16;
|
|
let disabled = rest.contains("(disabled)") || rest.contains("no IR");
|
|
let max_power_dbm = rest
|
|
.rfind('(')
|
|
.and_then(|p| rest[p + 1..].strip_suffix(" dBm)"))
|
|
.and_then(|v| v.trim().parse::<f32>().ok());
|
|
out.push(ChannelInfo {
|
|
channel,
|
|
freq_mhz: freq,
|
|
band: band_for_freq(freq),
|
|
disabled,
|
|
max_power_dbm,
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const SAMPLE: &str = "\
|
|
Wiphy phy0
|
|
\tBand 1:
|
|
\t\tFrequencies:
|
|
\t\t\t* 2412.0 MHz [1] (20.0 dBm)
|
|
\t\t\t* 2437.0 MHz [6] (20.0 dBm)
|
|
\t\t\t* 2484.0 MHz [14] (disabled)
|
|
\tBand 2:
|
|
\t\tFrequencies:
|
|
\t\t\t* 5180.0 MHz [36] (23.0 dBm)
|
|
\t\t\t* 5200.0 MHz [40] (23.0 dBm)
|
|
\tBand 4:
|
|
\t\tFrequencies:
|
|
\t\t\t* 5955.0 MHz [1] (12.0 dBm)
|
|
";
|
|
|
|
#[test]
|
|
fn parses_iw_phy_output() {
|
|
let ch = parse_phy_channels(SAMPLE);
|
|
assert_eq!(ch.len(), 6);
|
|
assert_eq!(ch[0].channel, 1);
|
|
assert_eq!(ch[0].freq_mhz, 2412);
|
|
assert_eq!(ch[0].band, Band::B2G4);
|
|
assert_eq!(ch[0].max_power_dbm, Some(20.0));
|
|
assert!(ch[2].disabled);
|
|
assert_eq!(ch[3].band, Band::B5);
|
|
assert_eq!(ch[5].band, Band::B6);
|
|
assert_eq!(ch[5].channel, 1);
|
|
}
|
|
}
|