Files
Linux-Hello/linux-hello-cli/src/main.rs
2026-01-30 09:44:12 +01:00

709 lines
23 KiB
Rust

//! Linux Hello CLI
//!
//! Command-line interface for enrollment, testing, and diagnostics.
use clap::{Parser, Subcommand};
use linux_hello_common::{Config, Result};
use tracing::{info, warn, Level};
use tracing_subscriber::FmtSubscriber;
#[derive(Parser)]
#[command(name = "linux-hello")]
#[command(author, version, about = "Linux Hello facial authentication", long_about = None)]
struct Cli {
/// Enable verbose output
#[arg(short, long)]
verbose: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Capture frames from IR camera (for testing)
Capture {
/// Output directory for captured frames
#[arg(short, long, default_value = ".")]
output: String,
/// Number of frames to capture
#[arg(short, long, default_value = "5")]
count: u32,
/// Camera device path (auto-detect if not specified)
#[arg(short, long)]
device: Option<String>,
},
/// Test face detection on camera or image
Detect {
/// Image file to process (use camera if not specified)
#[arg(short, long)]
image: Option<String>,
/// Show detection confidence scores
#[arg(long)]
scores: bool,
/// Save annotated output image
#[arg(short, long)]
output: Option<String>,
},
/// Show system status
Status {
/// Include camera diagnostics
#[arg(long)]
camera: bool,
/// Include daemon status
#[arg(long)]
daemon: bool,
},
/// Enroll a face model
Enroll {
/// Label for this enrollment
#[arg(short, long, default_value = "default")]
label: String,
},
/// List enrolled face models
List,
/// Remove enrolled face models
Remove {
/// Label to remove (or --all for all)
label: Option<String>,
/// Remove all enrolled models
#[arg(long)]
all: bool,
},
/// Test authentication
Test {
/// Show detailed confidence scores
#[arg(long)]
verbose: bool,
/// Save debug information
#[arg(long)]
debug: bool,
},
/// Show or modify configuration
Config {
/// Show config as JSON
#[arg(long)]
json: bool,
/// Set a configuration value (key=value)
#[arg(long)]
set: Option<String>,
},
}
/// Get the real username (handles sudo case)
fn get_real_username() -> String {
// Try SUDO_USER first (set when running with sudo)
// This is the most reliable way to get the real user when using sudo
std::env::var("SUDO_USER")
.or_else(|_| std::env::var("USER"))
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| {
// Final fallback: try to get from whoami command
std::process::Command::new("whoami")
.output()
.ok()
.and_then(|output| String::from_utf8(output.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
})
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
// Initialize logging
let log_level = if cli.verbose {
Level::DEBUG
} else {
Level::INFO
};
FmtSubscriber::builder()
.with_max_level(log_level)
.with_target(false)
.init();
// Load configuration
let config = Config::load_or_default();
match cli.command {
Commands::Capture {
output,
count,
device,
} => cmd_capture(&config, &output, count, device.as_deref()).await,
Commands::Detect {
image,
scores,
output,
} => cmd_detect(&config, image.as_deref(), scores, output.as_deref()).await,
Commands::Status { camera, daemon } => cmd_status(&config, camera, daemon).await,
Commands::Enroll { label } => cmd_enroll(&config, &label).await,
Commands::List => cmd_list(&config).await,
Commands::Remove { label, all } => cmd_remove(&config, label.as_deref(), all).await,
Commands::Test { verbose, debug } => cmd_test(&config, verbose, debug).await,
Commands::Config { json, set } => cmd_config(&config, json, set.as_deref()).await,
}
}
async fn cmd_capture(
_config: &Config,
output: &str,
count: u32,
device: Option<&str>,
) -> Result<()> {
info!("Capturing {} frames to {}", count, output);
#[cfg(target_os = "linux")]
use linux_hello_daemon::camera::{enumerate_cameras, Camera};
#[cfg(not(target_os = "linux"))]
use linux_hello_daemon::camera::{enumerate_cameras, Camera};
use linux_hello_daemon::PixelFormat;
// Find camera
let device_path = match device {
Some(d) => d.to_string(),
None => {
let cameras = enumerate_cameras()?;
let ir_cam = cameras.iter().find(|c| c.is_ir);
let cam = ir_cam.or(cameras.first());
match cam {
Some(c) => {
info!("Using camera: {}", c);
c.device_path.clone()
}
None => {
return Err(linux_hello_common::Error::NoCameraFound);
}
}
}
};
// Open camera and capture
let mut camera = Camera::open(&device_path)?;
info!("Camera opened, resolution: {:?}", camera.resolution());
// Create output directory if it doesn't exist
std::fs::create_dir_all(output)?;
for i in 0..count {
let frame = camera.capture_frame()?;
match frame.format {
PixelFormat::Grey => {
let filename = format!("{}/frame_{:03}.png", output, i);
if let Some(img) = image::GrayImage::from_raw(frame.width, frame.height, frame.data)
{
img.save(&filename).map_err(|e| {
linux_hello_common::Error::Io(std::io::Error::new(
std::io::ErrorKind::Other,
e,
))
})?;
info!("Saved frame {} (Permissions: Grey)", filename);
}
}
PixelFormat::Yuyv => {
// Convert YUYV to grayscale (extract Y channel)
let filename = format!("{}/frame_{:03}.png", output, i);
let mut gray_data = Vec::with_capacity((frame.width * frame.height) as usize);
for chunk in frame.data.chunks_exact(2) {
gray_data.push(chunk[0]); // Y component
}
if let Some(img) = image::GrayImage::from_raw(frame.width, frame.height, gray_data)
{
img.save(&filename).map_err(|e| {
linux_hello_common::Error::Io(std::io::Error::new(
std::io::ErrorKind::Other,
e,
))
})?;
info!(
"Saved frame {} (converted from YUYV to grayscale)",
filename
);
}
}
PixelFormat::Mjpeg => {
// Decode MJPEG and save as PNG
let filename = format!("{}/frame_{:03}.png", output, i);
match image::load_from_memory(&frame.data) {
Ok(img) => {
let gray = img.to_luma8();
gray.save(&filename).map_err(|e| {
linux_hello_common::Error::Io(std::io::Error::new(
std::io::ErrorKind::Other,
e,
))
})?;
info!("Saved frame {} (decoded from MJPEG)", filename);
}
Err(e) => {
warn!("Failed to decode MJPEG frame {}: {}", i, e);
}
}
}
_ => {
let filename = format!("{}/frame_{:03}.raw", output, i);
std::fs::write(&filename, &frame.data)?;
info!(
"Saved frame {} ({} bytes - Unknown format)",
filename,
frame.data.len()
);
}
}
}
info!("Capture complete");
Ok(())
}
async fn cmd_detect(
_config: &Config,
image: Option<&str>,
show_scores: bool,
output: Option<&str>,
) -> Result<()> {
match image {
Some(path) => {
info!("Detecting faces in: {}", path);
// Load image
let img = image::open(path)
.map_err(|e| linux_hello_common::Error::Detection(e.to_string()))?;
let gray = img.to_luma8();
let (width, height) = gray.dimensions();
// Run simple detection (placeholder)
use linux_hello_daemon::detection::detect_face_simple;
let detection = detect_face_simple(gray.as_raw(), width, height);
match detection {
Some(det) => {
let (x, y, w, h) = det.to_pixels(width, height);
println!("Face detected:");
println!(" Position: ({}, {})", x, y);
println!(" Size: {}x{}", w, h);
if show_scores {
println!(" Confidence: {:.2}%", det.confidence * 100.0);
}
if let Some(out_path) = output {
info!("Saving annotated image to: {}", out_path);
// Convert to RGB for drawing
let mut rgb_img = img.to_rgb8();
// Draw bounding box (red color)
draw_bounding_box(&mut rgb_img, x, y, w, h, [255, 0, 0]);
rgb_img.save(out_path).map_err(|e| {
linux_hello_common::Error::Io(std::io::Error::new(
std::io::ErrorKind::Other,
e,
))
})?;
println!("Annotated image saved to: {}", out_path);
}
}
None => {
println!("No face detected");
if let Some(out_path) = output {
// Save original image without annotations
img.save(out_path).map_err(|e| {
linux_hello_common::Error::Io(std::io::Error::new(
std::io::ErrorKind::Other,
e,
))
})?;
println!("Image saved to: {} (no face detected)", out_path);
}
}
}
}
None => {
println!("Live camera detection not yet implemented");
println!("Use --image to detect in an image file");
}
}
Ok(())
}
/// Draw a bounding box on an RGB image
fn draw_bounding_box(img: &mut image::RgbImage, x: u32, y: u32, w: u32, h: u32, color: [u8; 3]) {
let (img_width, img_height) = img.dimensions();
let thickness = 2u32;
// Clamp coordinates to image bounds
let x1 = x.min(img_width.saturating_sub(1));
let y1 = y.min(img_height.saturating_sub(1));
let x2 = (x + w).min(img_width.saturating_sub(1));
let y2 = (y + h).min(img_height.saturating_sub(1));
// Draw horizontal lines (top and bottom)
for t in 0..thickness {
// Top line
let top_y = y1.saturating_add(t).min(img_height.saturating_sub(1));
for px in x1..=x2 {
img.put_pixel(px, top_y, image::Rgb(color));
}
// Bottom line
let bottom_y = y2.saturating_sub(t);
if bottom_y >= y1 {
for px in x1..=x2 {
img.put_pixel(px, bottom_y, image::Rgb(color));
}
}
}
// Draw vertical lines (left and right)
for t in 0..thickness {
// Left line
let left_x = x1.saturating_add(t).min(img_width.saturating_sub(1));
for py in y1..=y2 {
img.put_pixel(left_x, py, image::Rgb(color));
}
// Right line
let right_x = x2.saturating_sub(t);
if right_x >= x1 {
for py in y1..=y2 {
img.put_pixel(right_x, py, image::Rgb(color));
}
}
}
}
async fn cmd_status(config: &Config, show_camera: bool, show_daemon: bool) -> Result<()> {
println!("Linux Hello Status");
println!("==================");
if show_camera || (!show_camera && !show_daemon) {
println!("\nCamera:");
#[cfg(target_os = "linux")]
{
use linux_hello_daemon::camera::enumerate_cameras;
match enumerate_cameras() {
Ok(cameras) => {
if cameras.is_empty() {
println!(" No cameras found");
} else {
for cam in cameras {
println!(" - {}", cam);
if !cam.resolutions.is_empty() {
println!(
" Resolutions: {:?}",
cam.resolutions.iter().take(3).collect::<Vec<_>>()
);
}
}
}
}
Err(e) => {
println!(" Error: {}", e);
}
}
}
#[cfg(not(target_os = "linux"))]
println!(" Camera detection requires Linux");
}
if show_daemon || (!show_camera && !show_daemon) {
println!("\nDaemon:");
// TODO: Check daemon via D-Bus
println!(" Status: Unknown (D-Bus not implemented)");
}
println!("\nConfiguration:");
println!(" Detection model: {}", config.detection.model);
println!(
" Anti-spoofing: {}",
if config.anti_spoofing.enabled {
"enabled"
} else {
"disabled"
}
);
println!(
" TPM: {}",
if config.tpm.enabled {
"enabled"
} else {
"disabled"
}
);
Ok(())
}
async fn cmd_enroll(config: &Config, label: &str) -> Result<()> {
use linux_hello_daemon::auth::AuthService;
info!("Starting enrollment with label: {}", label);
// Get real user (handles sudo)
let user = get_real_username();
println!("Enrolling user: {}", user);
println!("Label: {}", label);
println!("Please look at the camera...");
// Create auth service
let auth_service = AuthService::new(config.clone());
auth_service.initialize()?;
// Enroll with 5 frames
match auth_service.enroll(&user, label, 5).await {
Ok(()) => {
println!("✓ Enrollment successful!");
Ok(())
}
Err(e) => {
eprintln!("✗ Enrollment failed: {}", e);
Err(e)
}
}
}
async fn cmd_list(_config: &Config) -> Result<()> {
use linux_hello_common::TemplateStore;
let store = TemplateStore::new(TemplateStore::default_path());
let users = store.list_users()?;
if users.is_empty() {
println!("No enrolled users");
return Ok(());
}
println!("Enrolled users:");
for user in users {
let templates = store.list_templates(&user)?;
if templates.is_empty() {
println!(" {}: (no templates)", user);
} else {
println!(" {}:", user);
for label in templates {
if let Ok(template) = store.load(&user, &label) {
println!(
" - {} (enrolled: {}, frames: {})",
label, template.enrolled_at, template.frame_count
);
}
}
}
}
Ok(())
}
async fn cmd_remove(_config: &Config, label: Option<&str>, all: bool) -> Result<()> {
use linux_hello_common::TemplateStore;
let user = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".to_string());
let store = TemplateStore::new(TemplateStore::default_path());
if all {
store.remove_all(&user)?;
println!("Removed all templates for user: {}", user);
} else if let Some(l) = label {
store.remove(&user, l)?;
println!("Removed template '{}' for user: {}", l, user);
} else {
println!("Specify a label or --all");
return Err(linux_hello_common::Error::Config(
"No label specified".to_string(),
));
}
Ok(())
}
async fn cmd_test(config: &Config, verbose: bool, _debug: bool) -> Result<()> {
use linux_hello_daemon::auth::AuthService;
// Get real user (handles sudo)
let user = get_real_username();
println!("Testing authentication for user: {}", user);
println!("Please look at the camera...");
// Create auth service
let auth_service = AuthService::new(config.clone());
auth_service.initialize()?;
match auth_service.authenticate(&user).await {
Ok(true) => {
println!("✓ Authentication successful!");
if verbose {
println!(" User: {}", user);
}
Ok(())
}
Ok(false) => {
println!("✗ Authentication failed");
Err(linux_hello_common::Error::AuthenticationFailed)
}
Err(e) => {
eprintln!("✗ Authentication error: {}", e);
Err(e)
}
}
}
async fn cmd_config(config: &Config, json: bool, set: Option<&str>) -> Result<()> {
if let Some(kv) = set {
// Parse key=value
let parts: Vec<&str> = kv.splitn(2, '=').collect();
if parts.len() != 2 {
return Err(linux_hello_common::Error::Config(
"Invalid format. Use key=value".to_string(),
));
}
let key = parts[0].trim();
let value = parts[1].trim();
let mut new_config = config.clone();
// Update configuration based on key
match key {
"general.log_level" => new_config.general.log_level = value.to_string(),
"general.timeout_seconds" => {
new_config.general.timeout_seconds = value.parse().map_err(|_| {
linux_hello_common::Error::Config("Invalid timeout value".to_string())
})?;
}
"camera.device" => new_config.camera.device = value.to_string(),
"camera.ir_emitter" => new_config.camera.ir_emitter = value.to_string(),
"camera.fps" => {
new_config.camera.fps = value.parse().map_err(|_| {
linux_hello_common::Error::Config("Invalid fps value".to_string())
})?;
}
"detection.model" => new_config.detection.model = value.to_string(),
"detection.min_face_size" => {
new_config.detection.min_face_size = value.parse().map_err(|_| {
linux_hello_common::Error::Config("Invalid min_face_size value".to_string())
})?;
}
"detection.confidence_threshold" => {
new_config.detection.confidence_threshold = value.parse().map_err(|_| {
linux_hello_common::Error::Config(
"Invalid confidence_threshold value".to_string(),
)
})?;
}
"embedding.model" => new_config.embedding.model = value.to_string(),
"embedding.distance_threshold" => {
new_config.embedding.distance_threshold = value.parse().map_err(|_| {
linux_hello_common::Error::Config(
"Invalid distance_threshold value".to_string(),
)
})?;
}
"anti_spoofing.enabled" => {
new_config.anti_spoofing.enabled = value.parse().map_err(|_| {
linux_hello_common::Error::Config("Invalid boolean value".to_string())
})?;
}
"anti_spoofing.depth_check" => {
new_config.anti_spoofing.depth_check = value.parse().map_err(|_| {
linux_hello_common::Error::Config("Invalid boolean value".to_string())
})?;
}
"anti_spoofing.liveness_model" => {
new_config.anti_spoofing.liveness_model = value.parse().map_err(|_| {
linux_hello_common::Error::Config("Invalid boolean value".to_string())
})?;
}
"anti_spoofing.min_score" => {
new_config.anti_spoofing.min_score = value.parse().map_err(|_| {
linux_hello_common::Error::Config("Invalid min_score value".to_string())
})?;
}
"tpm.enabled" => {
new_config.tpm.enabled = value.parse().map_err(|_| {
linux_hello_common::Error::Config("Invalid boolean value".to_string())
})?;
}
"tpm.pcr_binding" => {
new_config.tpm.pcr_binding = value.parse().map_err(|_| {
linux_hello_common::Error::Config("Invalid boolean value".to_string())
})?;
}
_ => {
return Err(linux_hello_common::Error::Config(format!(
"Unknown config key: {}. Valid keys: general.log_level, general.timeout_seconds, camera.device, camera.ir_emitter, camera.fps, detection.model, detection.min_face_size, detection.confidence_threshold, embedding.model, embedding.distance_threshold, anti_spoofing.enabled, anti_spoofing.depth_check, anti_spoofing.liveness_model, anti_spoofing.min_score, tpm.enabled, tpm.pcr_binding",
key
)));
}
}
// Save configuration
let config_path = "/etc/linux-hello/config.toml";
match new_config.save(config_path) {
Ok(()) => println!("Configuration saved to {}", config_path),
Err(_) => {
// Try user config as fallback
let user_config_path = dirs_config_path();
std::fs::create_dir_all(user_config_path.parent().unwrap())?;
new_config.save(&user_config_path)?;
println!(
"Configuration saved to {} (no write access to {})",
user_config_path.display(),
config_path
);
}
}
return Ok(());
}
if json {
let json = serde_json::to_string_pretty(config)
.map_err(|e| linux_hello_common::Error::Serialization(e.to_string()))?;
println!("{}", json);
} else {
let toml = toml::to_string_pretty(config)
.map_err(|e| linux_hello_common::Error::Serialization(e.to_string()))?;
println!("{}", toml);
}
Ok(())
}
/// Get user-specific config path
fn dirs_config_path() -> std::path::PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
std::path::PathBuf::from(home).join(".config/linux-hello/config.toml")
}