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);
}
}