421 lines
12 KiB
Rust
421 lines
12 KiB
Rust
//! OSTP Client CLI - Stealth VPN Client for Windows
|
|
//!
|
|
//! Usage:
|
|
//! ostp-client connect --server 1.2.3.4:8443 --psk <hex-key>
|
|
//! ostp-client --config %APPDATA%/ostp/client.json
|
|
|
|
use anyhow::{Context, Result};
|
|
use clap::{Parser, Subcommand};
|
|
use console::{style, Emoji};
|
|
use dialoguer::{Confirm, Input, Select};
|
|
use ostp::{ClientConfig, OstpClient};
|
|
use ostp_guard::error_codes;
|
|
use std::net::SocketAddr;
|
|
use std::path::PathBuf;
|
|
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
|
|
|
static ROCKET: Emoji<'_, '_> = Emoji("🚀 ", "");
|
|
static LOCK: Emoji<'_, '_> = Emoji("🔒 ", "");
|
|
static CHECK: Emoji<'_, '_> = Emoji("✅ ", "[OK] ");
|
|
static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "[!] ");
|
|
static GLOBE: Emoji<'_, '_> = Emoji("🌍 ", "");
|
|
|
|
#[derive(Parser)]
|
|
#[command(name = "ostp-client")]
|
|
#[command(author = "Ospab Team")]
|
|
#[command(version)]
|
|
#[command(about = "OSTP Stealth VPN Client", long_about = None)]
|
|
struct Cli {
|
|
#[command(subcommand)]
|
|
command: Option<Commands>,
|
|
|
|
/// Path to config file (JSON)
|
|
#[arg(short, long)]
|
|
config: Option<PathBuf>,
|
|
|
|
/// Log level (trace, debug, info, warn, error)
|
|
#[arg(long, default_value = "info")]
|
|
log_level: String,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Commands {
|
|
/// Connect to an OSTP server
|
|
Connect {
|
|
/// Server address (e.g., vpn.example.com:8443)
|
|
#[arg(short, long)]
|
|
server: SocketAddr,
|
|
|
|
/// Pre-shared key in hex format
|
|
#[arg(short, long, env = "OSTP_PSK")]
|
|
psk: String,
|
|
|
|
/// Country code for SNI mimicry (RU, US, DE, NO, CN)
|
|
#[arg(short = 'c', long, default_value = "US")]
|
|
country: String,
|
|
},
|
|
/// Interactive setup wizard
|
|
Setup,
|
|
/// Test connection to server
|
|
Test {
|
|
/// Server address
|
|
#[arg(short, long)]
|
|
server: SocketAddr,
|
|
|
|
/// Pre-shared key in hex format
|
|
#[arg(short, long)]
|
|
psk: String,
|
|
},
|
|
/// Show saved profiles
|
|
Profiles,
|
|
}
|
|
|
|
#[derive(serde::Deserialize, serde::Serialize, Clone)]
|
|
#[allow(dead_code)]
|
|
struct ClientConfigFile {
|
|
server: String,
|
|
psk: String,
|
|
country: Option<String>,
|
|
log_level: Option<String>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize, serde::Serialize, Default)]
|
|
struct ProfilesFile {
|
|
profiles: Vec<Profile>,
|
|
default_profile: Option<String>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize, serde::Serialize, Clone)]
|
|
struct Profile {
|
|
name: String,
|
|
server: String,
|
|
psk: String,
|
|
country: String,
|
|
}
|
|
|
|
fn setup_logging(level: &str) {
|
|
let filter = EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| EnvFilter::new(level));
|
|
|
|
tracing_subscriber::registry()
|
|
.with(fmt::layer()
|
|
.with_target(false)
|
|
.with_thread_ids(false)
|
|
.without_time())
|
|
.with(filter)
|
|
.init();
|
|
}
|
|
|
|
fn parse_psk(hex_str: &str) -> Result<[u8; 32]> {
|
|
let bytes = hex::decode(hex_str.trim())
|
|
.context("Invalid hex string for PSK")?;
|
|
|
|
if bytes.len() != 32 {
|
|
anyhow::bail!("PSK must be exactly 32 bytes (64 hex characters), got {}", bytes.len());
|
|
}
|
|
|
|
let mut psk = [0u8; 32];
|
|
psk.copy_from_slice(&bytes);
|
|
Ok(psk)
|
|
}
|
|
|
|
fn get_config_dir() -> PathBuf {
|
|
#[cfg(windows)]
|
|
{
|
|
std::env::var("APPDATA")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|_| PathBuf::from("."))
|
|
.join("ostp")
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
std::env::var("HOME")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|_| PathBuf::from("."))
|
|
.join(".config")
|
|
.join("ostp")
|
|
}
|
|
}
|
|
|
|
fn print_banner() {
|
|
println!();
|
|
println!("{}", style("╔════════════════════════════════════════════════════════╗").cyan());
|
|
println!("{}", style("║ OSTP Stealth VPN Client ║").cyan());
|
|
println!("{}", style("║ ║").cyan());
|
|
println!("{}", style("║ Invisible by Default • Contextual Mimicry ║").cyan());
|
|
println!("{}", style("╚════════════════════════════════════════════════════════╝").cyan());
|
|
println!();
|
|
}
|
|
|
|
async fn interactive_setup() -> Result<()> {
|
|
print_banner();
|
|
|
|
println!("{}Welcome to OSTP Client Setup Wizard\n", ROCKET);
|
|
|
|
let profile_name: String = Input::new()
|
|
.with_prompt("Profile name")
|
|
.default("default".into())
|
|
.interact_text()?;
|
|
|
|
let server: String = Input::new()
|
|
.with_prompt("Server address (host:port)")
|
|
.interact_text()?;
|
|
|
|
let psk: String = Input::new()
|
|
.with_prompt("Pre-shared key (hex)")
|
|
.interact_text()?;
|
|
|
|
// Validate PSK
|
|
parse_psk(&psk)?;
|
|
|
|
let countries = vec!["US", "RU", "DE", "NO", "CN", "Custom"];
|
|
let country_idx = Select::new()
|
|
.with_prompt("Select country for SNI mimicry")
|
|
.items(&countries)
|
|
.default(0)
|
|
.interact()?;
|
|
|
|
let country = if country_idx == countries.len() - 1 {
|
|
Input::new()
|
|
.with_prompt("Enter custom country code")
|
|
.interact_text()?
|
|
} else {
|
|
countries[country_idx].to_string()
|
|
};
|
|
|
|
let profile = Profile {
|
|
name: profile_name.clone(),
|
|
server,
|
|
psk,
|
|
country,
|
|
};
|
|
|
|
// Save profile
|
|
let config_dir = get_config_dir();
|
|
std::fs::create_dir_all(&config_dir)?;
|
|
|
|
let profiles_path = config_dir.join("profiles.json");
|
|
let mut profiles: ProfilesFile = if profiles_path.exists() {
|
|
let content = std::fs::read_to_string(&profiles_path)?;
|
|
serde_json::from_str(&content).unwrap_or_default()
|
|
} else {
|
|
ProfilesFile::default()
|
|
};
|
|
|
|
// Remove existing profile with same name
|
|
profiles.profiles.retain(|p| p.name != profile_name);
|
|
profiles.profiles.push(profile);
|
|
|
|
if profiles.default_profile.is_none() {
|
|
profiles.default_profile = Some(profile_name.clone());
|
|
}
|
|
|
|
std::fs::write(&profiles_path, serde_json::to_string_pretty(&profiles)?)?;
|
|
|
|
println!();
|
|
println!("{}Profile '{}' saved to {:?}", CHECK, style(&profile_name).green(), profiles_path);
|
|
|
|
if Confirm::new()
|
|
.with_prompt("Connect now?")
|
|
.default(true)
|
|
.interact()?
|
|
{
|
|
let profile = profiles.profiles.iter().find(|p| p.name == profile_name).unwrap();
|
|
connect_to_server(&profile.server, &profile.psk, &profile.country).await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn connect_to_server(server: &str, psk_hex: &str, country: &str) -> Result<()> {
|
|
let server_addr: SocketAddr = server.parse()
|
|
.context("Invalid server address")?;
|
|
let psk = parse_psk(psk_hex)?;
|
|
|
|
println!();
|
|
println!("{}Connecting to {}", GLOBE, style(server).yellow());
|
|
println!("{}Country mimicry: {}", LOCK, style(country).green());
|
|
println!();
|
|
|
|
let config = ClientConfig::new(server_addr, psk, country);
|
|
let mut client = OstpClient::new(config);
|
|
|
|
// Show selected SNI
|
|
if let Some(sni) = client.suggested_sni() {
|
|
println!(" SNI target: {}", style(sni).dim());
|
|
}
|
|
|
|
println!(" Initiating handshake...");
|
|
|
|
match client.connect().await {
|
|
Ok(_stream) => {
|
|
println!();
|
|
println!("{}Connection established!", CHECK);
|
|
println!();
|
|
println!("{}VPN tunnel is active. Press Ctrl+C to disconnect.", style("INFO").blue());
|
|
|
|
// Keep connection alive
|
|
loop {
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
println!();
|
|
println!("{}Connection failed: {}", WARN, style(&e).red());
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn test_connection(server: SocketAddr, psk_hex: &str) -> Result<()> {
|
|
let psk = parse_psk(psk_hex)?;
|
|
|
|
println!();
|
|
println!("Testing connection to {}...", style(server).yellow());
|
|
|
|
let config = ClientConfig::new(server, psk, "US");
|
|
let mut client = OstpClient::new(config);
|
|
|
|
let start = std::time::Instant::now();
|
|
|
|
match client.connect().await {
|
|
Ok(_) => {
|
|
let elapsed = start.elapsed();
|
|
println!("{}Handshake successful! ({}ms)", CHECK, elapsed.as_millis());
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
println!("{}Handshake failed: {}", WARN, e);
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn show_profiles() -> Result<()> {
|
|
let profiles_path = get_config_dir().join("profiles.json");
|
|
|
|
if !profiles_path.exists() {
|
|
println!("No profiles found. Run 'ostp-client setup' to create one.");
|
|
return Ok(());
|
|
}
|
|
|
|
let content = std::fs::read_to_string(&profiles_path)?;
|
|
let profiles: ProfilesFile = serde_json::from_str(&content)?;
|
|
|
|
println!();
|
|
println!("{}", style("Saved Profiles:").bold());
|
|
println!();
|
|
|
|
for profile in &profiles.profiles {
|
|
let is_default = profiles.default_profile.as_ref() == Some(&profile.name);
|
|
let marker = if is_default { " (default)" } else { "" };
|
|
|
|
println!(" {} {}{}",
|
|
style("●").green(),
|
|
style(&profile.name).bold(),
|
|
style(marker).dim()
|
|
);
|
|
println!(" Server: {}", profile.server);
|
|
println!(" Country: {}", profile.country);
|
|
println!();
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
// ============================================
|
|
// SECURITY CHECK - Must pass before anything else
|
|
// ============================================
|
|
#[cfg(not(debug_assertions))]
|
|
{
|
|
if !security_init() {
|
|
// Silent exit with generic error
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
let cli = Cli::parse();
|
|
|
|
match cli.command {
|
|
Some(Commands::Setup) => {
|
|
interactive_setup().await?;
|
|
}
|
|
Some(Commands::Connect { server, psk, country }) => {
|
|
setup_logging(&cli.log_level);
|
|
print_banner();
|
|
connect_to_server(&server.to_string(), &psk, &country).await?;
|
|
}
|
|
Some(Commands::Test { server, psk }) => {
|
|
setup_logging(&cli.log_level);
|
|
test_connection(server, &psk).await?;
|
|
}
|
|
Some(Commands::Profiles) => {
|
|
show_profiles()?;
|
|
}
|
|
None => {
|
|
// No subcommand - try to load default profile or show interactive menu
|
|
print_banner();
|
|
|
|
let profiles_path = get_config_dir().join("profiles.json");
|
|
|
|
if profiles_path.exists() {
|
|
let content = std::fs::read_to_string(&profiles_path)?;
|
|
let profiles: ProfilesFile = serde_json::from_str(&content)?;
|
|
|
|
if !profiles.profiles.is_empty() {
|
|
let choices: Vec<&str> = profiles.profiles.iter()
|
|
.map(|p| p.name.as_str())
|
|
.chain(std::iter::once("Setup new profile"))
|
|
.collect();
|
|
|
|
let selection = Select::new()
|
|
.with_prompt("Select profile to connect")
|
|
.items(&choices)
|
|
.default(0)
|
|
.interact()?;
|
|
|
|
if selection == choices.len() - 1 {
|
|
interactive_setup().await?;
|
|
} else {
|
|
setup_logging(&cli.log_level);
|
|
let profile = &profiles.profiles[selection];
|
|
connect_to_server(&profile.server, &profile.psk, &profile.country).await?;
|
|
}
|
|
} else {
|
|
interactive_setup().await?;
|
|
}
|
|
} else {
|
|
interactive_setup().await?;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Security initialization - runs all protection checks
|
|
fn security_init() -> bool {
|
|
// Check for debuggers and VMs
|
|
if !ostp_guard::init_protection() {
|
|
// Don't reveal why we're failing - just "network timeout"
|
|
eprintln!("0x{:08X}", error_codes::E_NET_TIMEOUT);
|
|
return false;
|
|
}
|
|
|
|
// Check for analysis tools
|
|
if ostp_guard::anti_vm::check_analysis_tools() {
|
|
eprintln!("0x{:08X}", error_codes::E_NET_TIMEOUT);
|
|
return false;
|
|
}
|
|
|
|
// Start background monitor in release builds
|
|
#[cfg(not(debug_assertions))]
|
|
{
|
|
ostp_guard::anti_debug::start_background_monitor();
|
|
}
|
|
|
|
true
|
|
}
|