Skip to content

FTP / SSH2 操作

PHP 提供了强大的远程文件操作扩展,包括 FTP 扩展和 SSH2 扩展。FTP 扩展用于与 FTP 服务器交互,SSH2 扩展则提供了安全的远程 shell 访问、文件传输和端口转发能力。本节将全面讲解这两个扩展的使用方法。

前置知识

阅读本节前,建议先了解:cURL 详解文件系统操作

基础概念

FTP 扩展

PHP 的 FTP 扩展提供了完整的 FTP 客户端功能,支持:

  • 文件上传、下载、删除、重命名
  • 目录创建、切换、列表
  • 被动/主动模式切换
  • SSL/TLS 加密连接(ftps)
  • 文件权限修改

SSH2 扩展

SSH2 扩展基于 libssh2 库,支持:

  • SSH 密码和密钥认证
  • 远程命令执行
  • SFTP(安全文件传输)
  • SCP 文件复制
  • 端口转发

安装

bash
# FTP 扩展(通常默认启用)
./configure --enable-ftp

# SSH2 扩展
# 先安装 libssh2
sudo apt-get install libssh2-1-dev
# 然后编译 PHP
pecl install ssh2
# 或在编译时
./configure --with-ssh2

FTP 操作

连接与认证

php
<?php
declare(strict_types=1);

/**
 * FTP 连接管理类
 */
class FtpConnection
{
    private \FTP\Connection $ftp;
    private bool $loggedIn = false;

    /**
     * 创建 FTP 连接
     */
    public function __construct(
        string $host,
        int $port = 21,
        int $timeout = 90
    ) {
        // PHP 8.1+ 使用 \FTP\Connection 类
        $this->ftp = ftp_connect($host, $port, $timeout);

        if ($this->ftp === false) {
            throw new RuntimeException("FTP 连接失败: {$host}:{$port}");
        }
    }

    /**
     * 密码登录
     */
    public function login(string $username, string $password): void
    {
        $result = ftp_login($this->ftp, $username, $password);

        if (!$result) {
            throw new RuntimeException("FTP 登录失败");
        }

        $this->loggedIn = true;
    }

    /**
     * SSL/TLS 加密登录
     */
    public function sslLogin(string $username, string $password): void
    {
        // 启用 TLS
        ftp_set_option($this->ftp, FTP_USE_TLS, true);
        $this->login($username, $password);
    }

    /**
     * 设置被动模式
     */
    public function setPassive(bool $passive = true): void
    {
        ftp_pasv($this->ftp, $passive);
    }

    /**
     * 获取连接句柄
     */
    public function getConnection(): \FTP\Connection
    {
        return $this->ftp;
    }

    /**
     * 关闭连接
     */
    public function close(): void
    {
        if ($this->loggedIn) {
            ftp_close($this->ftp);
        }
    }

    public function __destruct()
    {
        $this->close();
    }
}

// 使用示例
$ftp = new FtpConnection('ftp.example.com');
$ftp->login('username', 'password');
$ftp->setPassive(true); // 推荐使用被动模式

文件上传下载

php
<?php
declare(strict_types=1);

class FtpManager
{
    public function __construct(
        private readonly \FTP\Connection $ftp
    ) {}

    /**
     * 上传文件
     */
    public function upload(
        string $localPath,
        string $remotePath,
        int $mode = FTP_BINARY
    ): bool {
        if (!file_exists($localPath)) {
            throw new RuntimeException("本地文件不存在: {$localPath}");
        }

        // FTP_ASCII 或 FTP_BINARY
        $result = ftp_put($this->ftp, $remotePath, $localPath, $mode);

        if (!$result) {
            throw new RuntimeException(
                "上传失败: " . ftp_pwd($this->ftp)
            );
        }

        return true;
    }

    /**
     * 下载文件
     */
    public function download(
        string $remotePath,
        string $localPath,
        int $mode = FTP_BINARY
    ): bool {
        $result = ftp_get($this->ftp, $localPath, $remotePath, $mode);

        if (!$result) {
            throw new RuntimeException("下载失败: {$remotePath}");
        }

        return true;
    }

    /**
     * 断点续传上传
     */
    public function uploadResume(
        string $localPath,
        string $remotePath,
        int $startPos = 0,
        int $mode = FTP_BINARY
    ): bool {
        return ftp_put(
            $this->ftp,
            $remotePath,
            $localPath,
            $mode,
            $startPos
        );
    }

    /**
     * 断点续传下载
     */
    public function downloadResume(
        string $remotePath,
        string $localPath,
        int $mode = FTP_BINARY,
        int $resumePos = 0
    ): bool {
        return ftp_get(
            $this->ftp,
            $localPath,
            $remotePath,
            $mode,
            $resumePos
        );
    }

    /**
     * 非阻塞上传(8.2+ ftp_nb_put)
     */
    public function uploadNonBlocking(
        string $localPath,
        string $remotePath,
        int $mode = FTP_BINARY
    ): void {
        $result = ftp_nb_put($this->ftp, $remotePath, $localPath, $mode);

        while ($result === FTP_MOREDATA) {
            // 执行其他任务
            $result = ftp_nb_continue($this->ftp);
        }

        if ($result !== FTP_FINISHED) {
            throw new RuntimeException("非阻塞上传失败");
        }
    }
}

目录操作

php
<?php
declare(strict_types=1);

class FtpDirectoryManager
{
    public function __construct(
        private readonly \FTP\Connection $ftp
    ) {}

    /**
     * 列出目录内容
     */
    public function list(string $path = '.'): array
    {
        $items = ftp_nlist($this->ftp, $path);
        return $items !== false ? $items : [];
    }

    /**
     * 获取详细目录列表
     */
    public function rawList(string $path = '.'): array
    {
        $items = ftp_rawlist($this->ftp, $path);
        return $items !== false ? $items : [];
    }

    /**
     * 切换目录
     */
    public function changeDir(string $path): bool
    {
        return ftp_chdir($this->ftp, $path);
    }

    /**
     * 获取当前目录
     */
    public function currentDir(): string|false
    {
        return ftp_pwd($this->ftp);
    }

    /**
     * 创建目录(支持递归)
     */
    public function makeDir(string $path): bool
    {
        $parts = explode('/', trim($path, '/'));
        $currentPath = ftp_pwd($this->ftp);

        foreach ($parts as $part) {
            if (empty($part)) {
                continue;
            }
            if (!@ftp_chdir($this->ftp, $part)) {
                if (!ftp_mkdir($this->ftp, $part)) {
                    return false;
                }
                ftp_chdir($this->ftp, $part);
            }
        }

        ftp_chdir($this->ftp, $currentPath);
        return true;
    }

    /**
     * 删除目录(支持递归)
     */
    public function removeDir(string $path): bool
    {
        $list = ftp_rawlist($this->ftp, $path);
        if ($list === false) {
            return ftp_rmdir($this->ftp, $path);
        }

        foreach ($list as $item) {
            // 解析 rawlist 格式
            $parts = preg_split('/\s+/', $item);
            $type = substr($parts[0], 0, 1);
            $name = end($parts);

            if ($type === 'd') {
                $this->removeDir($path . '/' . $name);
            } else {
                ftp_delete($this->ftp, $path . '/' . $name);
            }
        }

        return ftp_rmdir($this->ftp, $path);
    }

    /**
     * 删除文件
     */
    public function deleteFile(string $path): bool
    {
        return ftp_delete($this->ftp, $path);
    }

    /**
     * 重命名文件/目录
     */
    public function rename(string $oldName, string $newName): bool
    {
        return ftp_rename($this->ftp, $oldName, $newName);
    }

    /**
     * 获取文件大小
     */
    public function fileSize(string $path): int|false
    {
        return ftp_size($this->ftp, $path);
    }

    /**
     * 获取最后修改时间
     */
    public function lastModified(string $path): int|false
    {
        return ftp_mdtm($this->ftp, $path);
    }

    /**
     * 获取文件权限
     */
    public function chmod(string $path, int $mode): bool
    {
        return ftp_chmod($this->ftp, $mode, $path);
    }

    /**
     * 系统类型标识
     */
    public function systemType(): string|false
    {
        return ftp_systype($this->ftp);
    }
}

SSH2 操作

SSH 连接与认证

php
<?php
declare(strict_types=1);

/**
 * SSH2 连接类
 */
class SshConnection
{
    private \SSH2\Connection $connection;
    private bool $authenticated = false;

    public function __construct(
        string $host,
        int $port = 22,
        array $methods = []
    ) {
        $this->connection = ssh2_connect($host, $port, $methods);

        if ($this->connection === false) {
            throw new RuntimeException("SSH 连接失败: {$host}:{$port}");
        }
    }

    /**
     * 密码认证
     */
    public function authPassword(string $username, string $password): void
    {
        if (!ssh2_auth_password($this->connection, $username, $password)) {
            throw new RuntimeException("SSH 密码认证失败");
        }
        $this->authenticated = true;
    }

    /**
     * 公钥认证
     */
    public function authPubkey(
        string $username,
        string $pubkeyFile,
        string $privkeyFile,
        string $passphrase = ''
    ): void {
        if (!ssh2_auth_pubkey_file(
            $this->connection,
            $username,
            $pubkeyFile,
            $privkeyFile,
            $passphrase
        )) {
            throw new RuntimeException("SSH 公钥认证失败");
        }
        $this->authenticated = true;
    }

    /**
     * Host Key 验证
     */
    public function verifyHostKey(
        string $knownHostsFile,
        int $knownHostsFormat = SSH2_HOSTKEY_TYPE_RSA
    ): bool {
        $fingerprint = ssh2_fingerprint(
            $this->connection,
            $knownHostsFormat
        );
        // 在实际应用中,应与 known_hosts 文件比对
        echo "服务器指纹: {$fingerprint}" . PHP_EOL;
        return true;
    }

    public function getConnection(): \SSH2\Connection
    {
        return $this->connection;
    }

    public function close(): void
    {
        // ssh2_disconnect($this->connection);
    }

    public function __destruct()
    {
        $this->close();
    }
}

// 使用示例
$ssh = new SshConnection('server.example.com', 22);

// 密码认证
$ssh->authPassword('user', 'password');

// 公钥认证(推荐)
// $ssh->authPubkey('user', '/home/user/.ssh/id_rsa.pub', '/home/user/.ssh/id_rsa');

远程命令执行

php
<?php
declare(strict_types=1);

class SshExecutor
{
    public function __construct(
        private readonly \SSH2\Connection $connection
    ) {}

    /**
     * 执行命令并返回输出
     */
    public function exec(string $command): string
    {
        $stream = ssh2_exec($this->connection, $command);

        if ($stream === false) {
            throw new RuntimeException("命令执行失败: {$command}");
        }

        stream_set_blocking($stream, true);
        $stdout = stream_get_contents($stream);
        fclose($stream);

        return $stdout;
    }

    /**
     * 执行命令并获取 stdout 和 stderr
     */
    public function execWithStderr(string $command): array
    {
        $stdoutStream = ssh2_exec($this->connection, $command);
        $stderrStream = ssh2_fetch_stream($stdoutStream, SSH2_STREAM_STDERR);

        stream_set_blocking($stdoutStream, true);
        stream_set_blocking($stderrStream, true);

        $stdout = stream_get_contents($stdoutStream);
        $stderr = stream_get_contents($stderrStream);

        fclose($stdoutStream);
        fclose($stderrStream);

        return [
            'stdout' => $stdout,
            'stderr' => $stderr,
            'exitCode' => 0, // 需要通过额外命令获取
        ];
    }

    /**
     * 交互式 Shell
     */
    public function shell(callable $callback): void
    {
        $stream = ssh2_shell($this->connection, 'xterm-256color');

        if ($stream === false) {
            throw new RuntimeException("创建 Shell 失败");
        }

        stream_set_blocking($stream, false);

        $callback($stream);

        fclose($stream);
    }

    /**
     * 获取退出状态码
     */
    public function execWithExitCode(string $command): array
    {
        // 先执行命令,再获取退出码
        $output = $this->exec($command);
        $exitCode = (int) trim($this->exec('echo $?'));

        return [
            'output' => $output,
            'exitCode' => $exitCode,
        ];
    }
}

// 使用示例
$ssh = new SshConnection('server.example.com');
$ssh->authPassword('user', 'password');
$executor = new SshExecutor($ssh->getConnection());

// 基本命令执行
$output = $executor->exec('ls -la /var/log');
echo $output;

// 带 stderr 的执行
$result = $executor->execWithStderr('some-command 2>&1');
echo "stdout: {$result['stdout']}";
echo "stderr: {$result['stderr']}";

// 带退出码的执行
$result = $executor->execWithExitCode('grep "pattern" /etc/config');
echo "输出: {$result['output']}";
echo "退出码: {$result['exitCode']}";

SFTP 文件操作

php
<?php
declare(strict_types=1);

/**
 * SFTP 文件管理器
 */
class SftpManager
{
    private \SSH2\Connection $connection;
    private $sftp;

    public function __construct(\SSH2\Connection $connection)
    {
        $this->connection = $connection;
        $this->sftp = ssh2_sftp($connection);

        if ($this->sftp === false) {
            throw new RuntimeException("初始化 SFTP 子系统失败");
        }
    }

    /**
     * 上传文件
     */
    public function upload(
        string $localPath,
        string $remotePath
    ): bool {
        if (!file_exists($localPath)) {
            throw new RuntimeException("本地文件不存在: {$localPath}");
        }

        $stream = @fopen("ssh2.sftp://{$this->sftp}{$remotePath}", 'w');

        if ($stream === false) {
            throw new RuntimeException("无法打开远程文件: {$remotePath}");
        }

        $data = file_get_contents($localPath);
        $written = fwrite($stream, $data);
        fclose($stream);

        return $written === strlen($data);
    }

    /**
     * 使用 ssh2_scp_send 上传
     */
    public function scpSend(
        string $localPath,
        string $remotePath,
        int $permissions = 0644
    ): bool {
        return ssh2_scp_send(
            $this->connection,
            $localPath,
            $remotePath,
            $permissions
        );
    }

    /**
     * 下载文件
     */
    public function download(string $remotePath, string $localPath): bool
    {
        $stream = @fopen("ssh2.sftp://{$this->sftp}{$remotePath}", 'r');

        if ($stream === false) {
            throw new RuntimeException("无法读取远程文件: {$remotePath}");
        }

        $content = stream_get_contents($stream);
        fclose($stream);

        return file_put_contents($localPath, $content) !== false;
    }

    /**
     * 使用 ssh2_scp_recv 下载
     */
    public function scpRecv(string $remotePath, string $localPath): bool
    {
        return ssh2_scp_recv($this->connection, $remotePath, $localPath);
    }

    /**
     * 列出目录
     */
    public function listDir(string $path = '.'): array
    {
        $handle = @opendir("ssh2.sftp://{$this->sftp}{$path}");

        if ($handle === false) {
            throw new RuntimeException("无法打开目录: {$path}");
        }

        $items = [];
        while (false !== ($name = readdir($handle))) {
            if ($name === '.' || $name === '..') {
                continue;
            }
            $stat = $this->stat("{$path}/{$name}");
            $items[] = [
                'name' => $name,
                'isDir' => is_dir("ssh2.sftp://{$this->sftp}{$path}/{$name}"),
                'size' => $stat['size'] ?? 0,
                'mtime' => $stat['mtime'] ?? 0,
            ];
        }

        closedir($handle);
        return $items;
    }

    /**
     * 创建目录
     */
    public function mkdir(string $path, int $mode = 0755, bool $recursive = true): bool
    {
        $result = ssh2_sftp_mkdir($this->sftp, $path, $mode, $recursive);
        return $result;
    }

    /**
     * 删除文件
     */
    public function unlink(string $path): bool
    {
        return ssh2_sftp_unlink($this->sftp, $path);
    }

    /**
     * 删除目录
     */
    public function rmdir(string $path): bool
    {
        return ssh2_sftp_rmdir($this->sftp, $path);
    }

    /**
     * 重命名
     */
    public function rename(string $from, string $to): bool
    {
        return ssh2_sftp_rename($this->sftp, $from, $to);
    }

    /**
     * 获取文件信息
     */
    public function stat(string $path): array|false
    {
        return ssh2_sftp_stat($this->sftp, $path);
    }

    /**
     * 获取符号链接信息
     */
    public function lstat(string $path): array|false
    {
        return ssh2_sftp_lstat($this->sftp, $path);
    }

    /**
     * 修改权限
     */
    public function chmod(string $path, int $mode): bool
    {
        return ssh2_sftp_chmod($this->sftp, $path, $mode);
    }

    /**
     * 判断文件是否存在
     */
    public function exists(string $path): bool
    {
        return file_exists("ssh2.sftp://{$this->sftp}{$path}");
    }
}

// 使用示例
$ssh = new SshConnection('server.example.com');
$ssh->authPubkey('deploy', '/home/deploy/.ssh/id_rsa.pub', '/home/deploy/.ssh/id_rsa');
$sftp = new SftpManager($ssh->getConnection());

// 上传部署文件
$sftp->scpSend('/local/app.tar.gz', '/remote/app.tar.gz');

// 执行远程解压命令
$executor = new SshExecutor($ssh->getConnection());
$executor->exec('cd /remote && tar xzf app.tar.gz');

// 列出远程目录
$files = $sftp->listDir('/remote/app');
print_r($files);

实战示例

自动化部署脚本

php
<?php
declare(strict_types=1);

/**
 * 基于 SSH2 的自动化部署工具
 */
class Deployer
{
    private SshConnection $ssh;
    private SshExecutor $executor;
    private SftpManager $sftp;

    public function __construct(
        private readonly string $host,
        private readonly string $user,
        private readonly string $pubKey,
        private readonly string $privKey,
        private readonly string $deployPath,
        private readonly string $remoteApp
    ) {
        $this->ssh = new SshConnection($host);
        $this->ssh->authPubkey($user, $pubKey, $privKey);
        $this->executor = new SshExecutor($this->ssh->getConnection());
        $this->sftp = new SftpManager($this->ssh->getConnection());
    }

    public function deploy(string $localArchive): void
    {
        echo "=== 开始部署 ===" . PHP_EOL;

        // 1. 上传归档文件
        echo "1. 上传文件..." . PHP_EOL;
        $remoteArchive = "/tmp/deploy_" . uniqid() . '.tar.gz';
        $this->sftp->scpSend($localArchive, $remoteArchive);

        // 2. 创建备份
        echo "2. 创建备份..." . PHP_EOL;
        $backupDir = $this->deployPath . '_backup_' . date('YmdHis');
        $this->executor->exec("cp -r {$this->deployPath} {$backupDir}");

        // 3. 解压新版本
        echo "3. 解压新版本..." . PHP_EOL;
        $this->executor->exec("mkdir -p {$this->deployPath}");
        $this->executor->exec("tar xzf {$remoteArchive} -C {$this->deployPath}");

        // 4. 清理临时文件
        echo "4. 清理临时文件..." . PHP_EOL;
        $this->executor->exec("rm -f {$remoteArchive}");

        // 5. 重启服务
        echo "5. 重启服务..." . PHP_EOL;
        $result = $this->executor->execWithExitCode(
            "cd {$this->deployPath} && php artisan down && "
            . "composer install --no-dev && "
            . "php artisan migrate --force && "
            . "php artisan cache:clear && "
            . "php artisan config:cache && "
            . "php artisan up"
        );
        echo $result['output'];
        if ($result['exitCode'] !== 0) {
            // 回滚
            echo "部署失败,正在回滚..." . PHP_EOL;
            $this->executor->exec("rm -rf {$this->deployPath}");
            $this->executor->exec("mv {$backupDir} {$this->deployPath}");
            throw new RuntimeException("部署失败,已回滚");
        }

        echo "=== 部署完成 ===" . PHP_EOL;
    }
}

注意事项

FTP 安全

安全提示

  • FTP 传输是明文的,敏感数据应使用 FTPS 或 SFTP
  • 生产环境推荐使用 SFTP(SSH2)代替 FTP
  • 避免将密码硬编码在代码中,使用环境变量或配置文件

SSH 安全

  • 使用公钥认证代替密码认证
  • 在连接前验证服务器指纹
  • 限制 SSH 密钥的使用范围

错误处理

php
<?php
declare(strict_types=1);

// FTP 错误处理
function safeFtpPut(\FTP\Connection $ftp, string $remote, string $local): bool
{
    $result = @ftp_put($ftp, $remote, $local, FTP_BINARY);
    if (!$result) {
        echo "FTP 错误: 上传 {$remote} 失败" . PHP_EOL;
        // 获取详细错误信息
        echo "当前目录: " . ftp_pwd($ftp) . PHP_EOL;
        echo "系统类型: " . ftp_systype($ftp) . PHP_EOL;
        return false;
    }
    return true;
}

最佳实践

  1. 使用 SFTP 代替 FTP:SFTP 基于 SSH,天然加密
  2. 被动模式:FTP 客户端设置被动模式避免防火墙问题
  3. 超时设置:设置合理的连接和数据传输超时
  4. 资源释放:确保连接在使用后正确关闭
  5. 异常处理:封装所有远程操作到 try-catch 中
  6. 日志记录:记录远程操作便于排查问题

下一节

继续学习:SOAP

参考链接