ping-rust 0.1.2

Menu-driven installer and manager for the shoes proxy server
use std::{
    fs::{self, File},
    io,
    net::{TcpListener, UdpSocket},
    path::{Component, Path, PathBuf},
    process::Command,
    time::{SystemTime, UNIX_EPOCH},
};

use anyhow::{bail, Context, Result};
use flate2::{read::GzDecoder, write::GzEncoder, Compression};
use tar::Archive;

use crate::{config, service, utils};

pub struct PortStatus {
    pub tcp_available: Option<Result<(), String>>,
    pub udp_available: Option<Result<(), String>>,
}

pub fn check_port(port: u16, tcp: bool, udp: bool) -> PortStatus {
    PortStatus {
        tcp_available: tcp.then(|| {
            TcpListener::bind(("0.0.0.0", port))
                .map(drop)
                .map_err(|error| error.to_string())
        }),
        udp_available: udp.then(|| {
            UdpSocket::bind(("0.0.0.0", port))
                .map(drop)
                .map_err(|error| error.to_string())
        }),
    }
}

pub fn enable_bbr() -> Result<()> {
    utils::require_linux_root()?;
    if utils::command_exists("modprobe") {
        let _ = Command::new("modprobe").arg("tcp_bbr").status();
    }
    let settings = b"net.core.default_qdisc=fq\nnet.ipv4.tcp_congestion_control=bbr\n";
    let sysctl_file = Path::new("/etc/sysctl.d/99-ping-rust-bbr.conf");
    utils::atomic_write(sysctl_file, settings, 0o644)?;
    let status = Command::new("sysctl")
        .arg("--system")
        .status()
        .context("无法执行 sysctl --system")?;
    if !status.success() {
        bail!(
            "sysctl --system 失败;设置文件保留在 {}",
            sysctl_file.display()
        );
    }
    let algorithms = fs::read_to_string("/proc/sys/net/ipv4/tcp_available_congestion_control")
        .context("无法读取内核拥塞控制算法列表")?;
    if !algorithms.split_whitespace().any(|name| name == "bbr") {
        bail!("当前内核未提供 BBR;请升级内核后重试");
    }
    let active = fs::read_to_string("/proc/sys/net/ipv4/tcp_congestion_control")
        .context("无法读取当前拥塞控制算法")?;
    if active.trim() != "bbr" {
        bail!("BBR 已写入 sysctl,但当前算法仍为 {}", active.trim());
    }
    Ok(())
}

pub fn backup(output: Option<PathBuf>) -> Result<PathBuf> {
    utils::require_linux_root()?;
    let _lock = utils::exclusive_lock(Path::new(utils::LOCK_FILE))?;
    let source = Path::new(utils::CONFIG_DIR);
    if !source.is_dir() {
        bail!("配置目录 {} 不存在", source.display());
    }
    let destination =
        output.unwrap_or_else(|| PathBuf::from(format!("shoes-backup-{}.tar.gz", timestamp())));
    if destination.exists() {
        bail!("备份文件 {} 已存在,拒绝覆盖", destination.display());
    }
    let parent = destination
        .parent()
        .filter(|path| !path.as_os_str().is_empty())
        .unwrap_or_else(|| Path::new("."));
    fs::create_dir_all(parent).context("创建备份目录失败")?;
    let temp = tempfile::NamedTempFile::new_in(parent).context("创建备份临时文件失败")?;
    {
        let encoder = GzEncoder::new(temp.as_file(), Compression::default());
        let mut tar = tar::Builder::new(encoder);
        tar.append_dir_all("shoes", source)
            .context("将配置加入备份归档失败")?;
        let encoder = tar.into_inner().context("结束 tar 归档失败")?;
        encoder.finish().context("结束 gzip 压缩失败")?;
    }
    temp.persist(&destination)
        .map_err(|error| error.error)
        .context("保存备份文件失败")?;
    set_private_permissions(&destination)?;
    Ok(destination)
}

pub async fn restore(archive_path: &Path) -> Result<Option<PathBuf>> {
    utils::require_linux_root()?;
    let _lock = utils::exclusive_lock(Path::new(utils::LOCK_FILE))?;
    if !archive_path.is_file() {
        bail!("备份文件 {} 不存在", archive_path.display());
    }
    let stage = tempfile::tempdir().context("创建恢复临时目录失败")?;
    extract_backup(archive_path, stage.path())?;
    let restored = stage.path().join("shoes");
    if !restored.join("config.yaml").is_file() {
        bail!("归档缺少 shoes/config.yaml");
    }
    let yaml = fs::read_to_string(restored.join("config.yaml"))?;
    let _: Vec<serde_yaml::Value> =
        serde_yaml::from_str(&yaml).context("备份中的 config.yaml 不是有效 YAML 数组")?;
    if restored.join("ping-rust-state.json").exists() {
        config::validate_managed_snapshot(
            &restored.join("config.yaml"),
            &restored.join("ping-rust-state.json"),
        )?;
    }

    let destination = Path::new(utils::CONFIG_DIR);
    let rollback = destination.with_extension(format!("pre-restore-{}", timestamp()));
    let had_existing = destination.exists();
    let unit_exists = Path::new(utils::SERVICE_FILE).exists();
    let was_active = unit_exists && service::is_active()?;
    if had_existing {
        fs::rename(destination, &rollback).context("暂存现有配置失败")?;
    }
    if let Err(error) = copy_tree(&restored, destination) {
        rollback_restore(destination, &rollback, had_existing)?;
        return Err(error.context("复制恢复文件失败,原配置已回滚"));
    }
    if let Err(error) = config::validate_with_shoes(Path::new(utils::CONFIG_FILE)).await {
        rollback_restore(destination, &rollback, had_existing)?;
        return Err(error.context("恢复配置未通过 shoes 校验,原配置已回滚"));
    }
    if was_active {
        if let Err(error) = service::execute(crate::service::ServiceAction::Restart) {
            rollback_restore(destination, &rollback, had_existing)?;
            if let Err(restart_error) = service::execute(crate::service::ServiceAction::Restart) {
                return Err(error.context(format!(
                    "恢复后的服务启动失败,文件已回滚,但原服务恢复也失败:{restart_error:#}"
                )));
            }
            return Err(error.context("恢复后的服务启动失败,原配置和服务已回滚"));
        }
    }
    Ok(had_existing.then_some(rollback))
}

fn extract_backup(archive_path: &Path, destination: &Path) -> Result<()> {
    let file = File::open(archive_path).context("打开备份归档失败")?;
    let decoder = GzDecoder::new(file);
    let mut archive = Archive::new(decoder);
    for entry in archive.entries().context("读取备份归档失败")? {
        let mut entry = entry.context("读取备份条目失败")?;
        let path = entry.path().context("备份条目路径无效")?.into_owned();
        if !is_safe_relative_path(&path) {
            bail!("备份包含不安全路径 {}", path.display());
        }
        let kind = entry.header().entry_type();
        if !(kind.is_file() || kind.is_dir()) {
            bail!("备份包含不允许的链接或特殊文件 {}", path.display());
        }
        if !entry.unpack_in(destination).context("解压备份条目失败")? {
            bail!("备份条目越过恢复目录 {}", path.display());
        }
    }
    Ok(())
}

fn is_safe_relative_path(path: &Path) -> bool {
    !path.is_absolute()
        && path
            .components()
            .all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
}

fn copy_tree(source: &Path, destination: &Path) -> Result<()> {
    fs::create_dir_all(destination)?;
    for entry in fs::read_dir(source)? {
        let entry = entry?;
        let target = destination.join(entry.file_name());
        if entry.file_type()?.is_dir() {
            copy_tree(&entry.path(), &target)?;
        } else if entry.file_type()?.is_file() {
            fs::copy(entry.path(), target)?;
        } else {
            bail!("恢复源包含链接或特殊文件 {}", entry.path().display());
        }
    }
    Ok(())
}

fn rollback_restore(destination: &Path, rollback: &Path, had_existing: bool) -> Result<()> {
    if destination != Path::new(utils::CONFIG_DIR) {
        bail!("内部安全检查失败:恢复目标不是预期配置目录");
    }
    if destination.exists() {
        fs::remove_dir_all(destination).context("清理失败的恢复目录失败")?;
    }
    if had_existing {
        fs::rename(rollback, destination).context("回滚原配置失败")?;
    }
    Ok(())
}

fn timestamp() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}

#[cfg(unix)]
fn set_private_permissions(path: &Path) -> io::Result<()> {
    use std::os::unix::fs::PermissionsExt;
    fs::set_permissions(path, fs::Permissions::from_mode(0o600))
}

#[cfg(not(unix))]
fn set_private_permissions(_path: &Path) -> io::Result<()> {
    Ok(())
}

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

    #[test]
    fn rejects_archive_traversal_paths() {
        assert!(!is_safe_relative_path(Path::new("../etc/passwd")));
        assert!(!is_safe_relative_path(Path::new("/etc/passwd")));
        assert!(is_safe_relative_path(Path::new("shoes/config.yaml")));
    }

    #[test]
    fn port_check_reports_requested_protocols() {
        let status = check_port(0, true, false);
        assert!(matches!(status.tcp_available, Some(Ok(()))));
        assert!(status.udp_available.is_none());
    }

    #[test]
    fn extracts_safe_backup_round_trip() {
        let dir = tempfile::tempdir().unwrap();
        let archive_path = dir.path().join("backup.tar.gz");
        {
            let file = File::create(&archive_path).unwrap();
            let encoder = GzEncoder::new(file, Compression::default());
            let mut builder = tar::Builder::new(encoder);
            let contents = b"- address: 0.0.0.0:443\n";
            let mut header = tar::Header::new_gnu();
            header.set_size(contents.len() as u64);
            header.set_mode(0o600);
            header.set_cksum();
            builder
                .append_data(&mut header, "shoes/config.yaml", Cursor::new(contents))
                .unwrap();
            builder.finish().unwrap();
        }
        let output = dir.path().join("output");
        fs::create_dir(&output).unwrap();
        extract_backup(&archive_path, &output).unwrap();
        assert_eq!(
            fs::read(output.join("shoes/config.yaml")).unwrap(),
            b"- address: 0.0.0.0:443\n"
        );
    }

    #[test]
    fn rejects_symlink_backup_entry() {
        let dir = tempfile::tempdir().unwrap();
        let archive_path = dir.path().join("link.tar.gz");
        {
            let file = File::create(&archive_path).unwrap();
            let encoder = GzEncoder::new(file, Compression::default());
            let mut builder = tar::Builder::new(encoder);
            let mut header = tar::Header::new_gnu();
            header.set_entry_type(tar::EntryType::Symlink);
            header.set_size(0);
            header.set_mode(0o777);
            header.set_link_name("/etc/passwd").unwrap();
            header.set_cksum();
            builder
                .append_data(&mut header, "shoes/key.pem", io::empty())
                .unwrap();
            builder.finish().unwrap();
        }
        let output = dir.path().join("output");
        fs::create_dir(&output).unwrap();
        let error = extract_backup(&archive_path, &output).unwrap_err();
        assert!(error.to_string().contains("链接或特殊文件"));
    }
}