daylight-gui 0.1.0

Daylight GUI - embedded web server with privacy observation interface
//! Daylight GUI  -  embedded web server for the privacy observer interface.
//!
//! Launches a local HTTP server, opens the browser, and provides a
//! JSON API that the frontend calls. The HTML/CSS/JS is embedded in
//! the binary at compile time  -  no external files needed.
//!
//! Usage: `daylight gui` or just `daylight` (GUI is the default).

use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};

use daylight_core::{Config, Engine, ObserveTarget};
use serde_json::json;

/// The HTML interface, embedded at compile time.
const INDEX_HTML: &str = include_str!("../static/index.html");

/// Shared application state.
struct AppState {
    engine: Engine,
    handle: Option<daylight_core::ObservationHandle>,
}

fn main() {
    tracing_subscriber::fmt::init();

    // Bind to a random available port.
    let listener = match TcpListener::bind("127.0.0.1:0") {
        Ok(l) => l,
        Err(e) => {
            eprintln!("Failed to start server: {e}");
            std::process::exit(1);
        }
    };

    let port = match listener.local_addr() {
        Ok(addr) => addr.port(),
        Err(e) => {
            eprintln!("Failed to get server address: {e}");
            std::process::exit(1);
        }
    };

    let url = format!("http://127.0.0.1:{port}");
    eprintln!("Daylight GUI running at {url}");

    // Open the browser.
    if let Err(e) = open_browser(&url) {
        eprintln!("Could not open browser: {e}");
        eprintln!("Open manually: {url}");
    }

    let state = Arc::new(Mutex::new(AppState {
        engine: Engine::new(),
        handle: None,
    }));

    // Accept connections.
    for stream in listener.incoming() {
        match stream {
            Ok(stream) => {
                let state = Arc::clone(&state);
                std::thread::spawn(move || {
                    if let Err(e) = handle_connection(stream, &state) {
                        tracing::debug!("connection error: {e}");
                    }
                });
            }
            Err(e) => {
                tracing::debug!("accept error: {e}");
            }
        }
    }
}

/// Handles a single HTTP connection.
fn handle_connection(
    mut stream: TcpStream,
    state: &Arc<Mutex<AppState>>,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut reader = BufReader::new(stream.try_clone()?);
    let mut request_line = String::new();
    reader.read_line(&mut request_line)?;

    let parts: Vec<&str> = request_line.trim().split_whitespace().collect();
    if parts.len() < 2 {
        return Ok(());
    }

    let method = parts[0];
    let path = parts[1];

    // Read all headers.
    let mut content_length: usize = 0;
    loop {
        let mut line = String::new();
        reader.read_line(&mut line)?;
        if line.trim().is_empty() {
            break;
        }
        if let Some(rest) = line.to_lowercase().strip_prefix("content-length:") {
            content_length = rest.trim().parse().unwrap_or(0);
        }
    }

    // Read body if present.
    const MAX_BODY: usize = 1 * 1024 * 1024; // 1 MiB cap to prevent OOM DoS
    if content_length > MAX_BODY {
        respond_json(&mut stream, 413, &json!({"error": "request body too large"}))?;
        return Ok(());
    }
    let body = if content_length > 0 {
        let mut buf = vec![0u8; content_length];
        reader.read_exact(&mut buf)?;
        String::from_utf8_lossy(&buf).to_string()
    } else {
        String::new()
    };

    // Route.
    match (method, path) {
        ("GET", "/") | ("GET", "/index.html") => {
            respond_html(&mut stream, INDEX_HTML)?;
        }
        ("GET", "/api/processes") => {
            handle_processes(&mut stream, state)?;
        }
        ("GET", "/api/browser-tabs") => {
            handle_browser_tabs(&mut stream, state)?;
        }
        ("POST", "/api/start") => {
            handle_start(&mut stream, state, &body)?;
        }
        ("POST", "/api/stop") => {
            handle_stop(&mut stream, state)?;
        }
        ("GET", "/api/status") => {
            handle_status(&mut stream, state)?;
        }
        _ => {
            respond_json(&mut stream, 404, &json!({"error": "not found"}))?;
        }
    }

    Ok(())
}

/// List running processes.
fn handle_processes(
    stream: &mut TcpStream,
    state: &Arc<Mutex<AppState>>,
) -> Result<(), Box<dyn std::error::Error>> {
    let state = state.lock().map_err(|e| format!("lock: {e}"))?;
    match state.engine.list_processes() {
        Ok(processes) => {
            // Return top 50.
            let top: Vec<_> = processes.into_iter().take(50).collect();
            respond_json(stream, 200, &serde_json::to_value(top)?)?;
        }
        Err(e) => {
            respond_json(stream, 500, &json!({"error": e.to_string()}))?;
        }
    }
    Ok(())
}

/// List browser tabs.
fn handle_browser_tabs(
    stream: &mut TcpStream,
    state: &Arc<Mutex<AppState>>,
) -> Result<(), Box<dyn std::error::Error>> {
    let state = state.lock().map_err(|e| format!("lock: {e}"))?;
    match state.engine.discover_browser_tabs(9222) {
        Ok(tabs) => {
            respond_json(stream, 200, &serde_json::to_value(tabs)?)?;
        }
        Err(e) => {
            respond_json(stream, 200, &json!({"error": e.to_string()}))?;
        }
    }
    Ok(())
}

/// Start observation.
fn handle_start(
    stream: &mut TcpStream,
    state: &Arc<Mutex<AppState>>,
    body: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let target: ObserveTarget = serde_json::from_str(body).map_err(|e| format!("parse: {e}"))?;

    let mut state = state.lock().map_err(|e| format!("lock: {e}"))?;

    if state.handle.is_some() {
        respond_json(stream, 400, &json!({"error": "observation already running"}))?;
        return Ok(());
    }

    let config = Config {
        classify: true,
        ..Config::default()
    };

    match state.engine.start(target, config) {
        Ok(handle) => {
            state.handle = Some(handle);
            respond_json(stream, 200, &json!({"status": "started"}))?;
        }
        Err(e) => {
            respond_json(stream, 500, &json!({"error": e.to_string()}))?;
        }
    }

    Ok(())
}

/// Stop observation and return the report.
fn handle_stop(
    stream: &mut TcpStream,
    state: &Arc<Mutex<AppState>>,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut state = state.lock().map_err(|e| format!("lock: {e}"))?;

    let handle = match state.handle.take() {
        Some(h) => h,
        None => {
            respond_json(stream, 400, &json!({"error": "no observation running"}))?;
            return Ok(());
        }
    };

    match handle.stop() {
        Ok(session) => {
            let report = state.engine.render_text(&session);
            let result = json!({
                "status": "stopped",
                "report": report,
                "event_count": session.events.len(),
            });
            respond_json(stream, 200, &result)?;
        }
        Err(e) => {
            respond_json(stream, 500, &json!({"error": e.to_string()}))?;
        }
    }

    Ok(())
}

/// Get current observation status and events.
fn handle_status(
    stream: &mut TcpStream,
    state: &Arc<Mutex<AppState>>,
) -> Result<(), Box<dyn std::error::Error>> {
    let state = state.lock().map_err(|e| format!("lock: {e}"))?;

    match &state.handle {
        Some(handle) => {
            let events = handle.events_snapshot();
            let running = handle.is_running();

            // Serialize events for the UI.
            let event_summaries: Vec<serde_json::Value> = events
                .iter()
                .map(|e| {
                    let dest_summary = e.destination.summary();
                    let hostname = match &e.destination {
                        daylight_types::Destination::Network { hostname, .. } => hostname.clone(),
                        _ => None,
                    };

                    json!({
                        "sequence": e.sequence,
                        "timestamp": e.timestamp.to_rfc3339(),
                        "kind": e.kind.syscall_name(),
                        "destination": {
                            "address": dest_summary,
                            "hostname": hostname,
                        },
                        "classifications": e.classifications.iter().map(|c| {
                            json!({
                                "category": c.category.as_str(),
                                "rule": c.rule_name,
                            })
                        }).collect::<Vec<_>>(),
                    })
                })
                .collect();

            respond_json(stream, 200, &json!({
                "running": running,
                "event_count": events.len(),
                "events": event_summaries,
            }))?;
        }
        None => {
            respond_json(stream, 200, &json!({
                "running": false,
                "event_count": 0,
                "events": [],
            }))?;
        }
    }

    Ok(())
}

/// Sends an HTML response.
fn respond_html(stream: &mut TcpStream, body: &str) -> Result<(), std::io::Error> {
    let response = format!(
        "HTTP/1.1 200 OK\r\n\
         Content-Type: text/html; charset=utf-8\r\n\
         Content-Length: {}\r\n\
         Connection: close\r\n\
         \r\n\
         {body}",
        body.len()
    );
    stream.write_all(response.as_bytes())
}

/// Sends a JSON response.
fn respond_json(
    stream: &mut TcpStream,
    status: u16,
    body: &serde_json::Value,
) -> Result<(), std::io::Error> {
    let body_str = body.to_string();
    let status_text = match status {
        200 => "OK",
        400 => "Bad Request",
        404 => "Not Found",
        500 => "Internal Server Error",
        _ => "OK",
    };
    let response = format!(
        "HTTP/1.1 {status} {status_text}\r\n\
         Content-Type: application/json\r\n\
         Content-Length: {}\r\n\
         Connection: close\r\n\
         Access-Control-Allow-Origin: *\r\n\
         \r\n\
         {body_str}",
        body_str.len()
    );
    stream.write_all(response.as_bytes())
}

/// Opens the default browser to the given URL.
fn open_browser(url: &str) -> Result<(), std::io::Error> {
    std::process::Command::new("xdg-open")
        .arg(url)
        .spawn()
        .map(|_| ())
}