use crate::client::util::{color_green, color_red, color_yellow};
use std::io::BufRead;
use crate::cli::cli_args::*;
use crate::client::client::IskraClient;
use crate::error::error::IskraError;
use crate::client::util::clamp_timeout;
use std::time::Instant;
use tokio::net::TcpStream;
use url::Url;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::io::{stdin as tokio_stdin, stdout as tokio_stdout};
use std::sync::{Arc, atomic::{AtomicUsize, Ordering}};
use chrono::Local;
pub async fn run_cli() -> Result<(), IskraError> {
let args: Vec<_> = std::env::args().collect();
if args.iter().any(|a| a == "--help" || a == "-h") || args.len() == 1 {
crate::cli::cli_args::print_iskra_help();
return Ok(());
}
let cli = match parse_iskra_cli_args(std::env::args_os()) {
Ok(cli) => cli,
Err(e) => {
eprintln!("\x1b[1;31mIskra CLI parse error:\x1b[0m {e}\n");
crate::cli::cli_args::print_iskra_help();
return Err(IskraError::Other(format!("CLI parse error: {e}")));
}
};
let timeout = clamp_timeout(cli.timeout);
let client = if cli.no_decompress {
IskraClient::new_with_timeout_and_decompression(std::time::Duration::from_secs(timeout), false)?
} else {
IskraClient::new_with_timeout(std::time::Duration::from_secs(timeout))?
};
let verbose = cli.verbose;
let quiet = cli.quiet;
let fail_on_error = cli.fail;
match &cli.subcommand {
IskraSubcommand::Burst { input, output_dir, concurrency, throttle, retries, backoff, summary } => {
use std::fs::File;
use std::io::{self, BufReader};
use crate::burst::{BurstRequest, BurstOptions, BurstEngine};
let reader: Box<dyn BufRead> = if input == "-" {
Box::new(BufReader::new(io::stdin()))
} else {
Box::new(BufReader::new(File::open(input).map_err(|e| IskraError::Other(format!("{e}")))?))
};
let requests = BurstRequest::parse_batch(reader)
.map_err(|e| {
println!("Iskra Error: Burst input parse error: {e}");
IskraError::Other(format!("Burst input parse error: {e}"))
})?;
if requests.is_empty() {
return Err(IskraError::Other("No burst requests found in input".into()));
}
let options = BurstOptions {
concurrency: *concurrency,
throttle_per_sec: *throttle,
retries: *retries,
backoff: if *backoff > 0 { Some(std::time::Duration::from_secs(*backoff)) } else { None },
summary: *summary,
timeout: Some(std::time::Duration::from_secs(timeout)),
output_dir: output_dir.clone(),
};
let engine = BurstEngine::new_with_timeout(requests, options, std::time::Duration::from_secs(cli.timeout));
let results = engine.run_with_verbosity(verbose, quiet).await;
if !summary && !quiet && !verbose {
let total = results.len();
let successes = results.iter().filter(|r| r.success).count();
let failures = total - successes;
println!("Burst complete: {} total | {} success | {} fail", total, successes, failures);
}
if fail_on_error && results.iter().any(|r| !r.success) {
return Err(IskraError::Other("One or more burst requests failed".into()));
}
Ok(())
},
IskraSubcommand::Get { url, header, query, output, range, resume } => {
let headers: Vec<(&str, &str)> = header.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let queries: Vec<(&str, &str)> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
if url.starts_with("file://") {
let path = &url[7..];
use std::fs;
match fs::read_to_string(path) {
Ok(contents) => {
if let Some(out_path) = output {
fs::write(&out_path, &contents).map_err(|e| IskraError::Io { source: e, path: out_path.to_string() })?;
if !quiet {
println!("Downloaded {} bytes to {}", contents.len(), out_path);
}
} else {
if !quiet {
println!("{}", contents);
}
}
Ok(())
}
Err(e) => Err(IskraError::Io { source: e, path: path.to_string() }),
}
}
else if url.starts_with("http://") || url.starts_with("https://") {
if let Some(path) = output {
let downloaded = client.download_with_range("GET", url, None, &headers, &queries, std::path::Path::new(&path), range.clone(), *resume, None).await?;
if !quiet {
println!("Downloaded {downloaded} bytes to {path}");
}
Ok(())
} else {
let resp = client.get(url, &headers, &queries).await?;
let status = resp.status();
let status_str = if status.is_success() {
color_green(&status.to_string())
} else if status.as_u16() == 408 {
color_yellow(&status.to_string())
} else {
color_red(&status.to_string())
};
if (cli.show_headers || !header.is_empty()) && !quiet {
println!("Headers:");
for (k, v) in resp.headers().iter() {
match v.to_str() {
Ok(val) => println!("{}: {}", k, val),
Err(_) => println!("{}: <binary>", k),
}
}
}
let body = resp.text().await.map_err(|e| IskraError::Http { source: e, url: url.clone() })?;
if !quiet {
println!("Status: {}\n{}", status_str, body);
}
if fail_on_error && !status.is_success() {
return Err(IskraError::Status { status: status.as_u16(), url: url.clone() });
}
Ok(())
}
} else {
Err(IskraError::Config { msg: format!("Unsupported URL scheme for get: {}", url) })
}
},
IskraSubcommand::Post { url, body, header, query, output, range, resume } => {
let headers: Vec<(&str, &str)> = header.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let queries: Vec<(&str, &str)> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
if let Some(path) = output {
let downloaded = client.download_with_range("POST", url, Some(body), &headers, &queries, std::path::Path::new(&path), range.clone(), *resume, None).await?;
if !quiet { println!("Downloaded {downloaded} bytes to {path}"); }
} else {
let resp = client.post(url, body, &headers, &queries).await?;
let status = resp.status();
let status_str = if status.is_success() {
color_green(&status.to_string())
} else if status.as_u16() == 408 {
color_yellow(&status.to_string())
} else {
color_red(&status.to_string())
};
if (cli.show_headers || !header.is_empty()) && !quiet {
println!("Headers:");
for (k, v) in resp.headers().iter() {
match v.to_str() {
Ok(val) => println!("{}: {}", k, val),
Err(_) => println!("{}: <binary>", k),
}
}
}
let body = resp.text().await.map_err(|e| IskraError::Http { source: e, url: url.clone() })?;
if !quiet { println!("Status: {}\n{}", status_str, body); }
if fail_on_error && !status.is_success() {
return Err(IskraError::Status { status: status.as_u16(), url: url.clone() });
}
}
Ok(())
},
IskraSubcommand::Put { url, body, header, query, output, range, resume } => {
let headers: Vec<(&str, &str)> = header.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let queries: Vec<(&str, &str)> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
if let Some(path) = output {
let downloaded = client.download_with_range("PUT", url, Some(body), &headers, &queries, std::path::Path::new(&path), range.clone(), *resume, None).await?;
if !quiet { println!("Downloaded {downloaded} bytes to {path}"); }
} else {
let resp = client.put(url, body, &headers, &queries).await?;
let status = resp.status();
let status_str = if status.is_success() {
color_green(&status.to_string())
} else if status.as_u16() == 408 {
color_yellow(&status.to_string())
} else {
color_red(&status.to_string())
};
if (cli.show_headers || !header.is_empty()) && !quiet {
println!("Headers:");
for (k, v) in resp.headers().iter() {
match v.to_str() {
Ok(val) => println!("{}: {}", k, val),
Err(_) => println!("{}: <binary>", k),
}
}
}
let body = resp.text().await.map_err(|e| IskraError::Http { source: e, url: url.clone() })?;
if !quiet { println!("Status: {}\n{}", status_str, body); }
if fail_on_error && !status.is_success() {
return Err(IskraError::Status { status: status.as_u16(), url: url.clone() });
}
}
Ok(())
},
IskraSubcommand::Delete { url, header, query, output, range, resume } => {
let headers: Vec<(&str, &str)> = header.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let queries: Vec<(&str, &str)> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
if let Some(path) = output {
let downloaded = client.download_with_range("DELETE", url, None, &headers, &queries, std::path::Path::new(&path), range.clone(), *resume, None).await?;
if !quiet { println!("Downloaded {downloaded} bytes to {path}"); }
} else {
let resp = client.delete(url, &headers, &queries).await?;
let status = resp.status();
let status_str = if status.is_success() {
color_green(&status.to_string())
} else if status.as_u16() == 408 {
color_yellow(&status.to_string())
} else {
color_red(&status.to_string())
};
if (cli.show_headers || !header.is_empty()) && !quiet {
println!("Headers:");
for (k, v) in resp.headers().iter() {
match v.to_str() {
Ok(val) => println!("{}: {}", k, val),
Err(_) => println!("{}: <binary>", k),
}
}
}
let body = resp.text().await.map_err(|e| IskraError::Http { source: e, url: url.clone() })?;
if !quiet { println!("Status: {}\n{}", status_str, body); }
if fail_on_error && !status.is_success() {
return Err(IskraError::Status { status: status.as_u16(), url: url.clone() });
}
}
Ok(())
},
IskraSubcommand::Custom { method, url, header, query, output, body, trailing_body, range, resume } => {
let headers: Vec<(&str, &str)> = header.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let queries: Vec<(&str, &str)> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let trailing_body_joined;
let body_str: Option<&str> = match (body, trailing_body.as_slice()) {
(Some(b), _) => Some(b.as_str()),
(None, [_first, ..]) => {
trailing_body_joined = trailing_body.join(" ");
Some(trailing_body_joined.as_str())
},
_ => None,
};
if let Some(path) = output {
let downloaded = client.download_with_range(method, url, body_str.as_deref(), &headers, &queries, std::path::Path::new(&path), range.clone(), *resume, None).await?;
if !quiet { println!("Downloaded {downloaded} bytes to {path}"); }
} else {
let resp = client.request(method, url, body_str.as_deref(), &headers, &queries).await?;
let status = resp.status();
let status_str = if status.is_success() {
color_green(&status.to_string())
} else if status.as_u16() == 408 {
color_yellow(&status.to_string())
} else {
color_red(&status.to_string())
};
if (cli.show_headers || !header.is_empty()) && !quiet {
println!("Headers:");
for (k, v) in resp.headers().iter() {
match v.to_str() {
Ok(val) => println!("{}: {}", k, val),
Err(_) => println!("{}: <binary>", k),
}
}
}
let body_text = resp.text().await.map_err(|e| IskraError::Http { source: e, url: url.clone() })?;
if !quiet { println!("Status: {}\n{}", status_str, body_text); }
if fail_on_error && !status.is_success() {
return Err(IskraError::Status { status: status.as_u16(), url: url.clone() });
}
}
Ok(())
},
IskraSubcommand::Head { url, header, query, compare_file, save_headers } => {
let headers: Vec<(&str, &str)> = header.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let queries: Vec<(&str, &str)> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
if url.starts_with("file://") {
let path = &url[7..];
use std::fs;
match fs::metadata(path) {
Ok(meta) => {
use std::time::UNIX_EPOCH;
let len = meta.len();
let modified = meta.modified().ok().and_then(|m| m.duration_since(UNIX_EPOCH).ok()).map(|d| d.as_secs());
let created = meta.created().ok().and_then(|c| c.duration_since(UNIX_EPOCH).ok()).map(|d| d.as_secs());
println!("{:<18}: {} bytes", "Content-Length", len);
if let Some(m) = modified { println!("{:<18}: {} (modified epoch)", "Last-Modified", m); }
if let Some(c) = created { println!("{:<18}: {} (created epoch)", "Created", c); }
println!("{:<18}: {}", "Path", path);
Ok(())
}
Err(e) => Err(IskraError::Io { source: e, path: path.to_string() }),
}
} else {
let resp = client.request("HEAD", url, None, &headers, &queries).await?;
let status = resp.status();
let status_str = if status.is_success() {
color_green(&status.to_string())
} else if status.as_u16() == 408 {
color_yellow(&status.to_string())
} else {
color_red(&status.to_string())
};
println!("Status: {}", status_str);
use std::collections::BTreeMap;
let mut current: BTreeMap<String, String> = BTreeMap::new();
for (k, v) in resp.headers().iter() {
let val = match v.to_str() {
Ok(s) => s.to_string(),
Err(_) => String::from_utf8_lossy(v.as_bytes()).to_string(),
};
current.insert(k.as_str().to_ascii_lowercase(), val);
}
if let Some(save_path) = save_headers {
let json = match serde_json::to_string_pretty(¤t) {
Ok(j) => j,
Err(e) => {
eprintln!("\x1b[1;31mFailed to serialize headers: {e}\x1b[0m");
return Err(IskraError::Other(format!("Failed to serialize headers: {e}")));
}
};
use std::fs;
use std::io::Write;
let mut file = match fs::File::create(save_path.clone()) {
Ok(f) => f,
Err(e) => {
eprintln!("\x1b[1;31mFailed to create file '{}': {}\x1b[0m", save_path, e);
return Err(IskraError::Io { source: e, path: save_path.clone() });
}
};
if let Err(e) = file.write_all(json.as_bytes()) {
eprintln!("\x1b[1;31mFailed to write to file '{}': {}\x1b[0m", save_path, e);
return Err(IskraError::Io { source: e, path: save_path.clone() });
}
println!("Headers saved to {}", save_path);
}
if let Some(compare_path) = compare_file {
use std::fs;
use std::io::Read;
let mut file = match fs::File::open(compare_path) {
Ok(f) => f,
Err(e) => {
eprintln!("\x1b[1;31mFailed to open compare file '{}': {}\x1b[0m", compare_path, e);
return Err(IskraError::Io { source: e, path: compare_path.clone() });
}
};
let mut contents = String::new();
if let Err(e) = file.read_to_string(&mut contents) {
eprintln!("\x1b[1;31mFailed to read compare file '{}': {}\x1b[0m\n\x1b[1;33mHint: Ensure the file is valid UTF-8 text or JSON.\x1b[0m", compare_path, e);
return Err(IskraError::Io { source: e, path: compare_path.clone() });
}
let mut old: BTreeMap<String, String> = BTreeMap::new();
let mut parse_error = false;
let mut parsed_any = false;
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) {
if let Some(obj) = json.as_object() {
for (k, v) in obj.iter() {
let val = v.as_str().map(|s| s.to_string()).unwrap_or_else(|| v.to_string());
old.insert(k.to_ascii_lowercase(), val);
parsed_any = true;
}
} else {
parse_error = true;
}
} else {
for line in contents.lines() {
if let Some((k, v)) = line.split_once(':') {
old.insert(k.trim().to_ascii_lowercase(), v.trim().to_string());
parsed_any = true;
} else if !line.trim().is_empty() {
parse_error = true;
}
}
}
if !parsed_any {
eprintln!("\x1b[1;33mWarning: Compare file '{}' contained no valid headers.\x1b[0m", compare_path);
} else if parse_error {
eprintln!("\x1b[1;33mWarning: Compare file '{}' could not be fully parsed as JSON or key-value pairs. Some lines may be ignored.\x1b[0m", compare_path);
}
use std::path::Path;
let abs_compare = Path::new(compare_path).canonicalize().map(|p| p.display().to_string()).unwrap_or_else(|_| {
let os_str: &std::ffi::OsStr = compare_path.as_ref();
os_str.to_string_lossy().into_owned()
});
println!("\n================ HEADER DIFF =================");
println!("[Response: {}]", url);
println!("[Compare: {}]", abs_compare);
println!("----------------------------------------------");
use std::collections::BTreeSet;
let mut added = 0;
let mut removed = 0;
let mut changed = 0;
let mut unchanged = 0;
for k in current.keys().chain(old.keys()).collect::<BTreeSet<_>>() {
let cur = current.get(k);
let oldv = old.get(k);
if let (Some(cur), Some(oldv)) = (cur, oldv) {
if cur == oldv {
println!("\x1b[2m{:<18}: [=] {}\x1b[0m", k, cur); unchanged += 1;
} else {
println!("\x1b[33m{:<18}: [~] {} [was: {}]\x1b[0m", k, cur, oldv); changed += 1;
}
} else if let Some(cur) = cur {
println!("\x1b[32m{:<18}: [+] {}\x1b[0m", k, cur); added += 1;
} else if let Some(oldv) = oldv {
println!("\x1b[31m{:<18}: [-] {}\x1b[0m", k, oldv); removed += 1;
}
}
println!("----------------------------------------------");
println!("[Summary] [+] {} [-] {} [~] {} [=] {}", added, removed, changed, unchanged);
if added == 0 && removed == 0 && changed == 0 {
println!("\x1b[1;32mHeaders are identical.\x1b[0m");
} else if changed == 1 && (current.get("date").is_some() && old.get("date").is_some()) && added + removed == 0 && current.get("date") != old.get("date") {
println!("\x1b[1;33mOnly the 'date' header differs (likely just a timestamp).\x1b[0m");
}
println!("==============================================\n");
} else {
let important = ["content-length", "content-type", "etag", "last-modified", "cache-control"];
let mut shown = std::collections::BTreeMap::new();
for (k, v) in ¤t {
if important.contains(&k.as_str()) {
shown.insert(k.clone(), v.clone());
}
}
for (k, v) in &shown {
println!("{:<18}: {}", k, v);
}
if cli.show_headers || cli.verbose {
println!("All headers:");
for (k, v) in ¤t {
println!("{:<18}: {}", k, v);
}
}
}
Ok(())
}
},
IskraSubcommand::Trace { url, header, query } => {
use std::time::Instant;
let headers: Vec<_> = header.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let queries: Vec<_> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
if url.starts_with("file://") {
return Err(IskraError::Config { msg: "TRACE does not support file:// URLs".to_string() });
}
let start = Instant::now();
let resp = client.request("TRACE", url, None, &headers, &queries).await?;
let elapsed = start.elapsed();
let status = resp.status();
let status_str = match status.as_u16() {
408 => color_yellow(&status.to_string()),
_code if status.is_success() => color_green(&status.to_string()),
_ => color_red(&status.to_string()),
};
println!("[Status]: {} [{} ms]", status_str, elapsed.as_millis());
println!("[Echoed request: TRACE]");
let header_count = resp.headers().iter().inspect(|(k, v)| {
match v.to_str() {
Ok(val) => println!("[{:<16}]: {}", k, val),
Err(_) => println!("[{:<16}]: <binary>", k),
}
}).count();
println!("[Headers]: {}", header_count);
let body = resp.text().await.map_err(|e| {
eprintln!("\x1b[1;31m[Error]: Failed to decode TRACE response body: {}\x1b[0m", e);
IskraError::Http { source: e, url: url.clone() }
})?;
if !body.trim().is_empty() {
if body.trim_start().starts_with("<html") || body.contains("<body>") {
eprintln!("\x1b[1;33m[Warning]: TRACE response body looks like an HTML error page.\x1b[0m");
}
println!("\n{}", body.trim());
} else {
println!("[Info]: (No body echoed by server)");
}
if matches!(status.as_u16(), 405 | 501 | 403) {
eprintln!("\x1b[1;33m[Hint]: TRACE is often disabled or blocked by servers for security reasons.\x1b[0m");
}
if fail_on_error && !status.is_success() {
return Err(IskraError::Status { status: status.as_u16(), url: url.clone() });
}
Ok(())
},
IskraSubcommand::Connect {
url,
header,
query,
tunnel,
stats: _stats,
hexdump: _hexdump,
log: _log,
mask_host: _mask_host,
prompt,
tunnel_timeout,
keepalive,
banner,
protocol_detect,
} => {
let headers: Vec<_> = header.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let _queries: Vec<_> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
if url.starts_with("file://") {
return Err(IskraError::Config { msg: "CONNECT does not support file:// URLs".to_string() });
}
let parsed = Url::parse(url).map_err(|e| IskraError::Config { msg: format!("Invalid URL: {e}") })?;
let host = parsed.host_str().ok_or_else(|| IskraError::Config { msg: "Missing host in URL".to_string() })?;
let port = parsed.port_or_known_default().ok_or_else(|| IskraError::Config { msg: "Missing port in URL".to_string() })?;
let addr = format!("{}:{}", host, port);
let now = Instant::now();
let mut stream = TcpStream::connect(&addr).await.map_err(|e| IskraError::Other(format!("TCP connect error: {e}")))?;
let mut req = format!("CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n", host, port, host, port);
for (k, v) in &headers {
req.push_str(&format!("{}: {}\r\n", k, v));
}
req.push_str("\r\n");
stream.write_all(req.as_bytes()).await.map_err(|e| IskraError::Other(format!("TCP write error: {e}")))?;
use tokio::io::AsyncBufReadExt;
let mut reader = tokio::io::BufReader::new(stream);
let mut status_line = String::new();
reader.read_line(&mut status_line).await.map_err(|e| IskraError::Other(format!("TCP read error: {e}")))?;
let elapsed = now.elapsed();
let mut status_code = 0u16;
let mut status_text = "";
let trimmed = status_line.trim_end();
if let Some((_, rest)) = trimmed.split_once(' ') {
if let Some((code, text)) = rest.split_once(' ') {
status_code = match code.parse() {
Ok(val) => val,
Err(_) => {
eprintln!("[Warning] Could not parse status code: {}", code);
0
}
};
status_text = text;
}
}
let status_str = match status_code {
408 => color_yellow(&format!("{} {}", status_code, status_text)),
_code if (200..300).contains(&_code) => color_green(&format!("{} {}", status_code, status_text)),
_ => color_red(&format!("{} {}", status_code, status_text)),
};
println!("[Status]: {} [{} ms]", status_str, elapsed.as_millis());
println!("[Target]: {}", url);
if (200..300).contains(&status_code) {
if *tunnel {
let stream = reader.into_inner();
let (mut stream_read, mut stream_write) = tokio::io::split(stream);
let keepalive_enabled = *keepalive;
if *banner {
println!("\x1b[1;35m================ ISKRA TUNNEL BANNER ================\x1b[0m");
println!("\x1b[1;35m Welcome to Iskra CONNECT Tunnel Mode!\x1b[0m");
println!("\x1b[1;35m Target: {}\x1b[0m", url);
}
let mut stdin = tokio_stdin();
let mut stdout = tokio_stdout();
let sent_bytes = Arc::new(AtomicUsize::new(0));
let recv_bytes = Arc::new(AtomicUsize::new(0));
let log_path_stdin = _log.clone();
let log_path_stdout = _log.clone();
let hexdump_enabled = *_hexdump;
let log_host_str = if *_mask_host { "masked".to_string() } else { host.to_string() };
let log_peer_str = if *_mask_host { "masked".to_string() } else { addr.clone() };
let stats_enabled = _stats.is_some();
let tunnel_start = Instant::now();
let mut protocol_detect_buf = Vec::new();
if *protocol_detect {
use tokio::io::AsyncReadExt;
let mut probe_buf = [0u8; 32];
match stream_read.read(&mut probe_buf).await {
Ok(n) if n > 0 => {
protocol_detect_buf.extend_from_slice(&probe_buf[..n]);
let hint = if n >= 4 && &probe_buf[..4] == b"SSH-" {
"SSH"
} else if n >= 3 && &probe_buf[..3] == b"GET" {
"HTTP (GET)"
} else if n >= 4 && &probe_buf[..4] == b"POST" {
"HTTP (POST)"
} else if n >= 3 && &probe_buf[..3] == b"PUT" {
"HTTP (PUT)"
} else if n >= 5 && &probe_buf[..5] == b"HEAD " {
"HTTP (HEAD)"
} else if n >= 3 && &probe_buf[..3] == b"TLS" {
"TLS/SSL (maybe)"
} else if n >= 1 && probe_buf[0] == 0x16 {
"TLS/SSL (maybe)"
} else if n >= 2 && &probe_buf[..2] == b"\x05\x00" {
"SOCKS5 (maybe)"
} else if n >= 1 && probe_buf[0] == 0x04 {
"SOCKS4 (maybe)"
} else {
"Unknown or binary protocol"
};
println!("\x1b[1;36m[Protocol Detect]: {}\x1b[0m", hint);
}
Ok(_) => {
println!("\x1b[1;36m[Protocol Detect]: No data received from remote.\x1b[0m");
}
Err(e) => {
println!("\x1b[1;33m[Protocol Detect]: Error reading from remote: {}\x1b[0m", e);
}
}
}
let sent_bytes_clone = sent_bytes.clone();
let recv_bytes_clone = recv_bytes.clone();
let (prompt_tx, mut prompt_rx) = if *prompt {
let (tx, rx) = tokio::sync::mpsc::channel::<String>(4);
(Some(tx), Some(rx))
} else {
(None, None)
};
let stdin_to_stream = {
let prompt_tx = prompt_tx.clone();
tokio::spawn(async move {
let mut buf = [0u8; 8192];
let mut total = 0u64;
let mut log_file = if let Some(path) = log_path_stdin {
Some(tokio::fs::OpenOptions::new().create(true).write(true).append(true).open(path).await.ok())
} else { None };
let mut keepalive_interval = if keepalive_enabled {
Some(tokio::time::interval(std::time::Duration::from_secs(30)))
} else { None };
loop {
tokio::select! {
read = stdin.read(&mut buf) => {
match read {
Ok(0) => break,
Ok(n) => {
if stream_write.write_all(&buf[..n]).await.is_err() { break; }
if let Some(Some(file)) = log_file.as_mut() {
let now = Local::now().format("%Y-%m-%d %H:%M:%S");
let mut line = format!("[{}] [{}] [SENT] | ", now, log_host_str);
for &b in &buf[..n] {
if b.is_ascii_graphic() || b == b' ' || b == b'\n' || b == b'\r' {
line.push(b as char);
} else {
line.push_str(&format!("\\x{:02x}", b));
}
}
if !line.ends_with('\n') {
line.push('\n');
}
let _ = file.write_all(line.as_bytes()).await;
}
sent_bytes_clone.fetch_add(n, Ordering::Relaxed);
total += n as u64;
},
Err(_) => break,
}
},
Some(cmd) = async { if let Some(ref mut rx) = prompt_rx { rx.recv().await } else { None } } => {
if cmd == "/exit" {
break;
}
},
_ = async {
if let Some(ref mut interval) = keepalive_interval {
interval.tick().await;
true
} else { false }
}, if keepalive_enabled => {
let _ = stream_write.write_all(&[0u8]).await;
}
}
}
Ok::<u64, std::io::Error>(total)
})
};
let stream_to_stdout = tokio::spawn(async move {
let mut buf = [0u8; 8192];
let mut total = 0u64;
let mut log_file = if let Some(path) = log_path_stdout {
Some(tokio::fs::OpenOptions::new().create(true).write(true).append(true).open(path).await.ok())
} else { None };
let mut replay_buf = protocol_detect_buf;
let mut replay_pos = 0;
loop {
if replay_pos < replay_buf.len() {
let n = std::cmp::min(buf.len(), replay_buf.len() - replay_pos);
buf[..n].copy_from_slice(&replay_buf[replay_pos..replay_pos + n]);
replay_pos += n;
} else {
match stream_read.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
if hexdump_enabled && n > 0 {
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
static HEXDUMP_HEADER_PRINTED: AtomicBool = AtomicBool::new(false);
if !HEXDUMP_HEADER_PRINTED.swap(true, AtomicOrdering::SeqCst) {
println!("[Hexdump] OFFSET | 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F | ASCII");
println!("[Hexdump] ---------+------------------------------------------------+----------------");
}
let mut line_offset = total;
let mut lines_printed = 0;
for chunk in buf[..n].chunks(16) {
print!("[Hexdump] {:08x}: ", line_offset);
for i in 0..16 {
if i < chunk.len() {
print!("{:02x} ", chunk[i]);
} else {
print!(" ");
}
}
print!(" ");
for i in 0..16 {
if i < chunk.len() {
let b = chunk[i];
let c = if b.is_ascii_graphic() || b == b' ' { b as char } else { '.' };
print!("{}", c);
} else {
print!(" ");
}
}
println!("");
line_offset += chunk.len() as u64;
lines_printed += 1;
}
if lines_printed > 0 {
println!("");
}
if n < buf.len() {
println!("[Hexdump] Dump complete: {} bytes received.", total + n as u64);
}
}
if let Some(Some(file)) = log_file.as_mut() {
let now = Local::now().format("%Y-%m-%d %H:%M:%S");
let mut line = format!("[{}] [{}] [RECV] | ", now, log_peer_str);
for &b in &buf[..n] {
if b.is_ascii_graphic() || b == b' ' || b == b'\n' || b == b'\r' {
line.push(b as char);
} else {
line.push_str(&format!("\\x{:02x}", b));
}
}
if !line.ends_with('\n') {
line.push('\n');
}
let _ = file.write_all(line.as_bytes()).await;
}
if stdout.write_all(&buf[..n]).await.is_err() { break; }
recv_bytes_clone.fetch_add(n, Ordering::Relaxed);
total += n as u64;
},
Err(_) => break,
}
continue;
}
let n = std::cmp::min(buf.len(), replay_buf.len() - (replay_pos - std::cmp::min(buf.len(), replay_buf.len() - replay_pos)));
if hexdump_enabled && n > 0 {
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
static HEXDUMP_HEADER_PRINTED: AtomicBool = AtomicBool::new(false);
if !HEXDUMP_HEADER_PRINTED.swap(true, AtomicOrdering::SeqCst) {
println!("[Hexdump] OFFSET | 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F | ASCII");
println!("[Hexdump] ---------+------------------------------------------------+----------------");
}
let mut line_offset = total;
let mut lines_printed = 0;
for chunk in buf[..n].chunks(16) {
print!("[Hexdump] {:08x}: ", line_offset);
for i in 0..16 {
if i < chunk.len() {
print!("{:02x} ", chunk[i]);
} else {
print!(" ");
}
}
print!(" ");
for i in 0..16 {
if i < chunk.len() {
let b = chunk[i];
let c = if b.is_ascii_graphic() || b == b' ' { b as char } else { '.' };
print!("{}", c);
} else {
print!(" ");
}
}
println!("");
line_offset += chunk.len() as u64;
lines_printed += 1;
}
if lines_printed > 0 {
println!("");
}
if n < buf.len() {
println!("[Hexdump] Dump complete: {} bytes received.", total + n as u64);
}
}
if let Some(Some(file)) = log_file.as_mut() {
let now = Local::now().format("%Y-%m-%d %H:%M:%S");
let mut line = format!("[{}] [{}] [RECV] | ", now, log_peer_str);
for &b in &buf[..n] {
if b.is_ascii_graphic() || b == b' ' || b == b'\n' || b == b'\r' {
line.push(b as char);
} else {
line.push_str(&format!("\\x{:02x}", b));
}
}
if !line.ends_with('\n') {
line.push('\n');
}
let _ = file.write_all(line.as_bytes()).await;
}
if stdout.write_all(&buf[..n]).await.is_err() { break; }
recv_bytes_clone.fetch_add(n, Ordering::Relaxed);
total += n as u64;
}
Ok::<u64, std::io::Error>(total)
});
let prompt_task = if *prompt {
let prompt_tx = prompt_tx.unwrap();
let sent_bytes = sent_bytes.clone();
let recv_bytes = recv_bytes.clone();
Some(tokio::spawn(async move {
use tokio::io::{AsyncBufReadExt, BufReader};
let stdin = tokio::io::stdin();
let mut reader = BufReader::new(stdin).lines();
loop {
print!("\x1b[1;32m[prompt]> \x1b[0m");
use std::io::Write;
let _ = std::io::stdout().flush();
match reader.next_line().await {
Ok(Some(line)) => {
let cmd = line.trim();
if cmd == "/exit" {
let _ = prompt_tx.send("/exit".to_string()).await;
break;
} else if cmd == "/stats" {
let sent = sent_bytes.load(Ordering::Relaxed);
let recv = recv_bytes.load(Ordering::Relaxed);
println!("[Tunnel stats] Sent: {} bytes | Recv: {} bytes", sent, recv);
} else {
println!("[prompt] Unknown command: {}", cmd);
}
}
Ok(None) => {
break;
}
Err(_) => {
break;
}
}
}
}))
} else { None };
use tokio::time::{timeout, Duration};
let tunnel_fut = async {
let _ = tokio::try_join!(stdin_to_stream, stream_to_stdout,
if let Some(task) = prompt_task { task } else { tokio::spawn(async {}) }
);
};
let tunnel_result = if let Some(secs) = tunnel_timeout {
match timeout(Duration::from_secs(*secs), tunnel_fut).await {
Ok(_) => Ok(()),
Err(_) => {
println!("\x1b[1;33m[Tunnel timeout]: Session closed after {} seconds.\x1b[0m", secs);
Ok(())
}
}
} else {
tunnel_fut.await;
Ok(())
};
let tunnel_duration = tunnel_start.elapsed();
println!("[Tunnel closed]");
if stats_enabled {
let sent = sent_bytes.load(Ordering::Relaxed);
let recv = recv_bytes.load(Ordering::Relaxed);
let dur_secs = tunnel_duration.as_secs_f64();
println!("\x1b[1;34m[Tunnel stats]\x1b[0m Duration: {:.3} s | Sent: {} bytes | Recv: {} bytes | Throughput: {:.1} KiB/s",
dur_secs,
sent,
recv,
if dur_secs > 0.0 { (sent + recv) as f64 / 1024.0 / dur_secs } else { 0.0 }
);
}
return tunnel_result;
}
} else {
println!("[Refused]: Tunnel not established.");
}
println!("");
println!("[Hint]: For most use cases, it's safer and more useful to tunnel into your own networks or trusted proxies.\n Avoid tunneling into random public endpoints unless you know what you're doing.\n Only tunnel with express permission. The Iskra team is not liable for misuse.");
if matches!(status_code, 405 | 501 | 403) {
eprintln!("\x1b[1;33m[Hint]: CONNECT is often disabled or blocked by servers for security reasons.\x1b[0m");
}
if fail_on_error && !(200..300).contains(&status_code) {
return Err(IskraError::Status { status: status_code, url: url.clone() });
}
Ok(())
}
}
}