sb-vault 0.1.0

S&B Vault // Zero-Trust Desktop Suite & Secret Engine
use std::collections::HashMap;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Mutex;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use tauri::State;

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TunnelProfile {
    pub id: String,
    pub server_name: String,
    pub name: String,
    pub local_port: u16,
    pub remote_host: String,
    pub remote_port: u16,
    pub is_active: bool,
    pub pid: Option<u32>,
    pub error_message: Option<String>,
    pub created_at: String,
    #[serde(default)]
    pub allow_lan_pivot: Option<bool>,
}

#[derive(Debug, Serialize, Deserialize, Default)]
pub struct TunnelsConfig {
    pub tunnels: Vec<TunnelProfile>,
}

pub struct TunnelState {
    pub profiles: Mutex<Vec<TunnelProfile>>,
    pub processes: Mutex<HashMap<String, tokio::process::Child>>,
}

impl Default for TunnelState {
    fn default() -> Self {
        let loaded = load_tunnels_from_disk();
        Self {
            profiles: Mutex::new(loaded),
            processes: Mutex::new(HashMap::new()),
        }
    }
}

pub fn get_tunnels_file_path() -> PathBuf {
    crate::servers::get_sb_ssh_dir().join("tunnels.json")
}

pub fn load_tunnels_from_disk() -> Vec<TunnelProfile> {
    let path = get_tunnels_file_path();
    if !path.exists() {
        return Vec::new();
    }
    match std::fs::read_to_string(&path) {
        Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
        Err(_) => Vec::new(),
    }
}

pub fn save_tunnels_to_disk(tunnels: &[TunnelProfile]) -> Result<(), String> {
    let path = get_tunnels_file_path();
    let json = serde_json::to_string_pretty(tunnels).map_err(|e| e.to_string())?;
    let mut file = OpenOptions::new()
        .write(true)
        .create(true)
        .truncate(true)
        .open(&path)
        .map_err(|e| e.to_string())?;
    file.write_all(json.as_bytes()).map_err(|e| e.to_string())?;
    Ok(())
}

fn resolve_sb_ssh_bin() -> Result<PathBuf, String> {
    which::which("sb-ssh")
        .or_else(|_| {
            dirs::home_dir().map(|h| {
                h.join(".cargo")
                    .join("bin")
                    .join(if cfg!(windows) { "sb-ssh.exe" } else { "sb-ssh" })
            })
            .filter(|p| p.exists())
            .ok_or("Nicht gefunden")
        })
        .or_else(|_| {
            std::env::current_exe().ok().and_then(|mut exe| {
                exe.pop();
                let candidate = exe.join(if cfg!(windows) { "sb-ssh.exe" } else { "sb-ssh" });
                if candidate.exists() {
                    Some(candidate)
                } else {
                    None
                }
            }).ok_or("Nicht gefunden")
        })
        .map_err(|_| "sb-ssh Binary wurde im System nicht gefunden (weder in PATH noch ~/.cargo/bin)".to_string())
}

#[tauri::command]
pub fn get_tunnels(state: State<'_, TunnelState>) -> Result<Vec<TunnelProfile>, String> {
    let mut profiles = state.profiles.lock().map_err(|e| e.to_string())?;
    let mut procs = state.processes.lock().map_err(|e| e.to_string())?;

    for p in profiles.iter_mut() {
        if p.is_active {
            if let Some(child) = procs.get_mut(&p.id) {
                match child.try_wait() {
                    Ok(Some(status)) => {
                        p.is_active = false;
                        p.pid = None;
                        p.error_message = Some(format!("Tunnel beendet ({})", status));
                    }
                    Ok(None) => {}
                    Err(e) => {
                        p.is_active = false;
                        p.pid = None;
                        p.error_message = Some(format!("Prozessfehler: {}", e));
                    }
                }
            } else {
                p.is_active = false;
                p.pid = None;
            }
        }
    }

    Ok(profiles.clone())
}

#[tauri::command]
pub fn save_tunnel(
    mut profile: TunnelProfile,
    state: State<'_, TunnelState>,
) -> Result<Vec<TunnelProfile>, String> {
    let mut profiles = state.profiles.lock().map_err(|e| e.to_string())?;
    if profile.id.is_empty() {
        profile.id = format!("tun-{}", Utc::now().timestamp_millis());
        profile.created_at = Utc::now().to_rfc3339();
        profiles.push(profile);
    } else if let Some(pos) = profiles.iter().position(|p| p.id == profile.id) {
        profile.is_active = profiles[pos].is_active;
        profile.pid = profiles[pos].pid;
        profiles[pos] = profile;
    } else {
        profile.created_at = Utc::now().to_rfc3339();
        profiles.push(profile);
    }

    save_tunnels_to_disk(&profiles)?;
    Ok(profiles.clone())
}

#[tauri::command]
pub async fn delete_tunnel(
    id: String,
    state: State<'_, TunnelState>,
) -> Result<Vec<TunnelProfile>, String> {
    let maybe_child = {
        let mut procs = state.processes.lock().map_err(|e| e.to_string())?;
        procs.remove(&id)
    };
    if let Some(mut child) = maybe_child {
        let _ = child.kill().await;
    }

    let mut profiles = state.profiles.lock().map_err(|e| e.to_string())?;
    profiles.retain(|p| p.id != id);
    save_tunnels_to_disk(&profiles)?;
    Ok(profiles.clone())
}

use std::net::{IpAddr, ToSocketAddrs};

pub fn is_rfc1918_or_link_local(ip: &IpAddr) -> bool {
    match ip {
        IpAddr::V4(v4) => {
            let octets = v4.octets();
            if octets[0] == 10 { return true; }
            if octets[0] == 172 && (16..=31).contains(&octets[1]) { return true; }
            if octets[0] == 192 && octets[1] == 168 { return true; }
            if octets[0] == 169 && octets[1] == 254 { return true; }
            if octets[0] == 100 && (64..=127).contains(&octets[1]) { return true; }
            false
        }
        IpAddr::V6(v6) => {
            let segments = v6.segments();
            if (segments[0] & 0xffc0) == 0xfe80 { return true; }
            if (segments[0] & 0xfe00) == 0xfc00 { return true; }
            false
        }
    }
}

pub fn is_lan_pivoting_target(remote_host: &str) -> bool {
    let host_trim = remote_host.trim();
    let lower = host_trim.to_ascii_lowercase();
    if lower == "localhost" || lower == "127.0.0.1" || lower == "::1" {
        return false;
    }
    if let Ok(ip) = host_trim.parse::<IpAddr>() {
        if ip.is_loopback() { return false; }
        return is_rfc1918_or_link_local(&ip);
    }
    let probe = format!("{}:80", host_trim);
    if let Ok(addrs) = probe.to_socket_addrs() {
        for addr in addrs {
            let ip = addr.ip();
            if !ip.is_loopback() && is_rfc1918_or_link_local(&ip) {
                return true;
            }
        }
    }
    false
}

#[tauri::command]
pub async fn start_tunnel(
    id: String,
    vault_state: State<'_, crate::vault::VaultState>,
    tunnel_state: State<'_, TunnelState>,
) -> Result<TunnelProfile, String> {
    crate::vault::ensure_unlocked(&vault_state)?;

    let profile = {
        let profiles = tunnel_state.profiles.lock().map_err(|e| e.to_string())?;
        profiles
            .iter()
            .find(|p| p.id == id)
            .cloned()
            .ok_or_else(|| format!("Tunnel mit ID '{}' nicht gefunden", id))?
    };

    let allow_lan = profile.allow_lan_pivot.unwrap_or(false);
    if !allow_lan && is_lan_pivoting_target(&profile.remote_host) {
        return Err(format!(
            "Anti-LAN Pivoting Firewall: Ziel '{}' liegt in einem privaten Subnetz (RFC 1918 / Link-Local). Pivoting ist standardmäßig gesperrt. Bitte 'allow_lan_pivot' im Profil aktivieren.",
            profile.remote_host
        ));
    }

    let sb_ssh = resolve_sb_ssh_bin()?;
    let forward_arg = format!(
        "{}:{}:{}",
        profile.local_port, profile.remote_host, profile.remote_port
    );

    #[cfg(windows)]
    let mut cmd = tokio::process::Command::new(sb_ssh);
    #[cfg(windows)]
    {
        const CREATE_NO_WINDOW: u32 = 0x08000000;
        cmd.creation_flags(CREATE_NO_WINDOW);
    }
    #[cfg(unix)]
    let mut cmd = tokio::process::Command::new(sb_ssh);

    let mut args = vec![profile.server_name.clone(), "-L".to_string(), forward_arg];
    if allow_lan {
        args.push("--allow-lan-pivot".to_string());
    }
    cmd.args(&args);
    cmd.stdout(std::process::Stdio::piped());
    cmd.stderr(std::process::Stdio::piped());

    let child = cmd
        .spawn()
        .map_err(|e| format!("Fehler beim Starten des Tunnel-Prozesses: {}", e))?;

    let child_pid = child.id();

    {
        let mut procs = tunnel_state.processes.lock().map_err(|e| e.to_string())?;
        procs.insert(id.clone(), child);
    }

    tokio::time::sleep(std::time::Duration::from_millis(500)).await;

    let mut procs = tunnel_state.processes.lock().map_err(|e| e.to_string())?;
    let mut profiles = tunnel_state.profiles.lock().map_err(|e| e.to_string())?;

    if let Some(child_ref) = procs.get_mut(&id) {
        match child_ref.try_wait() {
            Ok(Some(status)) => {
                procs.remove(&id);
                let err_msg = format!("Tunnel konnte nicht etabliert werden (Exit-Code: {})", status);
                if let Some(p) = profiles.iter_mut().find(|p| p.id == id) {
                    p.is_active = false;
                    p.pid = None;
                    p.error_message = Some(err_msg.clone());
                }
                return Err(err_msg);
            }
            Ok(None) => {
                if let Some(p) = profiles.iter_mut().find(|p| p.id == id) {
                    p.is_active = true;
                    p.pid = child_pid;
                    p.error_message = None;
                    return Ok(p.clone());
                }
            }
            Err(e) => {
                procs.remove(&id);
                let err_msg = format!("Prozess-Prüfung fehlgeschlagen: {}", e);
                return Err(err_msg);
            }
        }
    }

    Err("Unerwarteter Fehler beim Tunnelstart".to_string())
}

#[tauri::command]
pub async fn stop_tunnel(
    id: String,
    tunnel_state: State<'_, TunnelState>,
) -> Result<TunnelProfile, String> {
    let maybe_child = {
        let mut procs = tunnel_state.processes.lock().map_err(|e| e.to_string())?;
        procs.remove(&id)
    };
    if let Some(mut child) = maybe_child {
        let _ = child.kill().await;
    }

    let mut profiles = tunnel_state.profiles.lock().map_err(|e| e.to_string())?;
    if let Some(p) = profiles.iter_mut().find(|p| p.id == id) {
        p.is_active = false;
        p.pid = None;
        p.error_message = None;
        return Ok(p.clone());
    }

    Err(format!("Tunnel '{}' nicht gefunden", id))
}

pub async fn stop_all_active_tunnels(tunnel_state: &TunnelState) {
    let children: Vec<tokio::process::Child> = {
        let mut procs = match tunnel_state.processes.lock() {
            Ok(guard) => guard,
            Err(_) => return,
        };
        procs.drain().map(|(_, c)| c).collect()
    };

    for mut child in children {
        let _ = child.kill().await;
    }

    if let Ok(mut profiles) = tunnel_state.profiles.lock() {
        for p in profiles.iter_mut() {
            p.is_active = false;
            p.pid = None;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_tunnel_profile_serialization() {
        let profile = TunnelProfile {
            id: "tun-123".to_string(),
            server_name: "prod-db".to_string(),
            name: "PostgreSQL".to_string(),
            local_port: 5433,
            remote_host: "127.0.0.1".to_string(),
            remote_port: 5432,
            is_active: false,
            pid: None,
            error_message: None,
            created_at: "2026-09-09T22:00:00Z".to_string(),
        };

        let json = serde_json::to_string(&profile).expect("serialize");
        let parsed: TunnelProfile = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(parsed.local_port, 5433);
        assert_eq!(parsed.remote_port, 5432);
    }
}