use anyhow_ext::{Context, Result, bail};
use chrono::{Duration as ChronoDuration, Local};
use clap::{ArgGroup, Parser};
use ssh2::Session;
use std::io::Read;
use std::process::exit;
use std::{path::PathBuf, time::Duration as StdDuration};
use tokio::fs::{File, OpenOptions, create_dir_all, read_to_string};
use tokio::io::AsyncWriteExt;
use tokio::sync::mpsc;
use tokio::time::{Duration as TokioDuration, sleep};
use tokio::{
    io::{AsyncBufReadExt, BufReader},
    net::TcpStream as TokioTcpStream,
};

const BANNER: &str = r#"
// ┌─────────────────────────────────────────────────────────────────────┐
// │ Matrix NARUTO                                                       │
// │                                                                     │
// │    1        0        1        0        1        2                   │
// │    0        9        0        1        6        1                   │
// │    1        0        8        1        0        0                   │
// │    0        1        0        9        1        1                   │
// │    1        0        1        0        0        0                   │
// │    0        1        0        1        0        5                   │
// │                                                                     │
// │  混乱并不是错误,它只是未被理解的秩序   *                               │
// └─────────────────────────────────────────────────────────────────────┘
"#;


#[derive(Parser, Debug)]
#[command(
    author,
    version,
    about = "Joker logs Auto Save Tools, By Bruce Shi",
    group(
        ArgGroup::new("mode")
            .required(true)
            .args(["standard", "pro"])
    )
)]

struct Cli {
    /// 标准基站
    #[arg(short = 's', long = "standard")]
    standard: bool,

    /// P基站
    #[arg(short = 'p', long = "pro")]
    pro: bool,

    /// 包含 IP 的 txt,每行一个
    #[arg(value_name = "IP_FILE")]
    ip_file: String,

    /// SSH 远端日志路径
    #[arg(long, default_value = "/var/log/messages", hide = true)]
    remote: String,

    /// SSH 端口
    #[arg(long, default_value_t = 22, hide = true)]
    port: u16,

    /// SSH 掉线重连间隔秒
    #[arg(long, default_value_t = 3, hide = true)]
    reconnect_delay: u64,
}

#[tokio::main]
async fn main() -> Result<()> {
    let cli = Cli::parse();
    print!("{}", BANNER);
    let ip_file = cli.ip_file;

    ctrlc::set_handler(|| {
        println!("get ctrl, not exit it ");
        exit(0)
    })
    .expect("ctrl c exit failed");

    if cli.pro {
        exec_pro(&ip_file).await?;
    }

    if cli.standard {
        exec_standard(&ip_file).await?;
    }

    Ok(())
}

/// 执行标准基站获取日志流程
async fn exec_standard(files: &str) -> Result<()> {
    let ip_file = files;
    let content = read_to_string(ip_file).await?;
    let ips: Vec<String> = content
        .lines()
        .filter(|s| !s.trim().is_empty())
        .map(|s| s.trim().to_string())
        .collect();

    if ips.is_empty() {
        bail!("IP file is empty!");
    }
    println!("Loaded {} IPs.", ips.len());

    // 创建日志目录
    create_dir_all(LOG_DIR).await?;

    // 为每个 IP 启动任务
    for ip in ips {
        tokio::spawn(async move {
            if let Err(e) = handle_ip(ip.clone()).await {
                eprintln!("Task for IP {} failed: {:?}", ip, e);
            }
        });
    }

    // 防止主线程退出
    loop {
        sleep(TokioDuration::from_secs(1800)).await;
    }
}

/// 单 IP 异步处理:读取 + 写入队列
async fn handle_ip(ip: String) -> Result<()> {
    println!("Connecting to {}:{} ...", ip, EW_LOG_PORT);

    let ip_port = format!("{}:{}", ip, EW_LOG_PORT);
    let stream = TokioTcpStream::connect(ip_port).await?;

    let reader = BufReader::new(stream);
    let mut lines = reader.lines();

    // 创建消息队列(一次最多缓存 10k 行)
    let (tx, rx) = mpsc::channel::<String>(10_000);

    let ip_for_write = ip.clone();
    println!("Connected to {}:{} success", ip, EW_LOG_PORT);
    // 启动写文件任务(独立)
    tokio::spawn(async move {
        if let Err(e) = log_writer(ip_for_write.clone(), rx).await {
            eprintln!("({}) log write error: {:?}", ip_for_write, e);
        }
    });
    // telnet 输入 → 写入队列
    while let Ok(Some(line)) = lines.next_line().await {
        if tx.send(line).await.is_err() {
            println!("({}) writer closed.", ip);
            break;
        }
    }

    println!("({}) Telnet disconnected", ip);
    Ok(())
}

/// 处理写文件任务(批量写 + 自动滚动)
async fn log_writer(ip: String, mut rx: mpsc::Receiver<String>) -> Result<()> {
    let mut file = create_new_log_file(&ip).await?;
    let mut file_time = Local::now();

    // 64KB 缓冲区(高速)
    let mut buffer = String::with_capacity(64 * 1024);
    loop {
        tokio::select! {
            maybe_line = rx.recv() => {
                match maybe_line {
                    Some(line) => {
                        buffer.push_str(&line);
                        buffer.push('\n');
                        // println!("recv {}",buffer);
                        if buffer.len() >= 64 * 1024 {
                            file.write_all(buffer.as_bytes()).await?;
                            buffer.clear();

                            // 文件滚动:超过 512MB
                            let metadata = file.metadata().await?;
                            if metadata.len() > MAX_FILE_SIZE {
                                println!("({}) Log file > 512MB, rotate...", ip);
                                file = create_new_log_file(&ip).await?;
                                file_time = Local::now();
                            }
                        }
                    }
                    None => {
                        // 发送端关闭,刷掉残留并退出
                        if !buffer.is_empty() {
                            file.write_all(buffer.as_bytes()).await?;
                            buffer.clear();
                        }
                        file.flush().await?;
                        break;
                    }
                }
            }

            // 每秒 flush 一次
            _ = sleep(TokioDuration::from_secs(1)) => {
                if !buffer.is_empty() {
                    file.write_all(buffer.as_bytes()).await?;
                    buffer.clear();
                }

                // 按天滚动
                if Local::now() - file_time > ChronoDuration::days(1) {
                    println!("({}) Log file > 1 day, rotate...", ip);
                    file = create_new_log_file(&ip).await?;
                    file_time = Local::now();
                }
            }
        }
    }

    Ok(())
}

/// 创建新的日志文件:格式为 `IP_YYYYMMDD_HHMMSS.log`
async fn create_new_log_file(ip: &str) -> Result<File> {
    let now = Local::now();
    let filename = format!(
        "{}_{}.log",
        ip.replace(":", "_"),
        now.format("%Y%m%d_%H%M%S")
    );

    let mut path = PathBuf::from(LOG_DIR);
    path.push(filename);

    println!("Creating new log file: {:?}", path);

    let file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
        .await?;

    Ok(file)
}

// ----------------------Pro基站处理逻辑---------------------------------- //

fn connect_ssh_sync(ip: &str) -> Result<Session> {
    let addr = format!("{}:22", ip);
    let tcp = std::net::TcpStream::connect(&addr)
        .with_context(|| format!("connect tcp {} error", addr))?;

    tcp.set_read_timeout(Some(StdDuration::from_secs(30))).ok();
    tcp.set_write_timeout(Some(StdDuration::from_secs(30))).ok();

    let mut sess = Session::new().context("create ssh session")?;
    sess.set_tcp_stream(tcp);
    sess.handshake().context("ssh handshake")?;
    sess.userauth_password(PRO_SSH_USER, PRO_SSH_PASSWORD)
        .context("auth by password")?;

    if !sess.authenticated() {
        bail!("ssh not authenticated");
    }
    Ok(sess)
}

// 简单 shell escape:把路径用单引号包起来并处理单引号
fn shell_escape(s: &str) -> String {
    if s.is_empty() {
        "''".to_string()
    } else if s
        .bytes()
        .all(|c| c.is_ascii_alphanumeric() || b"/._-".contains(&c))
    {
        s.to_string()
    } else {
        let escaped = s.replace('\'', r"'\''");
        format!("'{}'", escaped)
    }
}

async fn exec_pro(ipfile: &str) -> Result<()> {
    let content = read_to_string(ipfile).await?;
    let ips: Vec<String> = content
        .lines()
        .filter(|s| !s.trim().is_empty())
        .map(|s| s.trim().to_string())
        .collect();

    if ips.is_empty() {
        bail!("IP file is empty!");
    }

    // 默认参数
    let reconnect_delay: u64 = 3;
    let tail_lines: u32 = 100;
    let remote = "/var/log/messages";
    create_dir_all(LOG_DIR).await?;

    for ip in ips {
        let ip_clone = ip.clone();
        let remote = remote.to_string();
        tokio::spawn(async move {
            if let Err(e) =
                tail_pro_host(ip_clone.clone(), &remote, tail_lines, reconnect_delay).await
            {
                eprintln!("Task for IP {} failed: {:?}", ip_clone, e);
            }
        });
    }

    loop {
        sleep(TokioDuration::from_secs(1800)).await;
    }
}

async fn tail_pro_host(
    ip: String,
    remote: &str,
    tail_lines: u32,
    reconnect_delay: u64,
) -> Result<()> {
    let remote = remote.to_string();

    loop {
        // pipeline:ssh(blocking) -> tx -> log_writer(async)
        let (tx, rx) = mpsc::channel::<String>(10_000);

        let ip_for_write = ip.clone();
        tokio::spawn(async move {
            if let Err(e) = log_writer(ip_for_write.clone(), rx).await {
                eprintln!("({}) log write error: {:?}", ip_for_write, e);
            }
        });

        eprintln!("[{}] connect -> tail {}", ip, remote);

        let ip_clone = ip.clone();
        let remote_clone = remote.clone();

        // 关键:把 ssh2 的阻塞读,丢到 blocking 线程池
        let join = tokio::task::spawn_blocking(move || -> Result<()> {
            let sess = connect_ssh_sync(&ip_clone)?;

            let mut channel = sess
                .channel_session()
                .with_context(|| format!("channel_session {}", ip_clone))?;

            let cmd = if tail_lines == 0 {
                format!("tail -F {} 2>&1", shell_escape(&remote_clone))
            } else {
                format!(
                    "tail -n {} -F {} 2>&1",
                    tail_lines,
                    shell_escape(&remote_clone)
                )
            };

            eprintln!("[ssh {}] exec: {}", ip_clone, cmd);
            channel
                .exec(&cmd)
                .with_context(|| format!("exec tail {}", ip_clone))?;

            let mut buf = [0u8; 8192];
            loop {
                match channel.read(&mut buf) {
                    Ok(0) => {
                        eprintln!("[ssh {}] remote closed stream", ip_clone);
                        break;
                    }
                    Ok(nread) => {
                        let chunk = String::from_utf8_lossy(&buf[..nread]).to_string();
                        if tx.blocking_send(chunk).is_err() {
                            break;
                        }
                    }
                    Err(e) => {
                        eprintln!("[read {}] {:#}", ip_clone, e);
                        break;
                    }
                }
            }

            let _ = channel.close();
            let _ = channel.wait_close();
            Ok(())
        });

        // 等这次 blocking tail 结束(断线/报错都会结束)
        if let Err(e) = join.await {
            eprintln!("[join {}] {:#}", ip, e);
        }

        // 关闭发送端,保证 writer 能刷盘退出/继续滚动
        // (这里 tx 已经 move 进 blocking 闭包了,随着闭包结束自动 drop)
        std::thread::sleep(StdDuration::from_secs(reconnect_delay));
    }
}

[package]
name = "autosave"
version = "0.1.0"
edition = "2024"


[dependencies]
ssh2 = "0.9"
anyhow_ext = "0.1.0"
clap = { version = "4", features = ["derive"] }
chrono = "0.4"
ctrlc = "3"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "net", "io-util", "sync", "time"] }

本地测试通过

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐