scp 原理

因最近有一个需求是实现 scp,因此调研了一下 scp 的原理。

网上并没有一个对 scp 进行说明的文章,因此可以直接看 scp 实现的代码

https://github.com/openssh/openssh-portable/blob/master/scp.c

根据代码发现:scp 实现实际上就是在连接上远端服务器的 ssh 后又启动了一个 scp 进程。也就是 scp 使用必须依赖于目标服务器有 scp 存在。

本文暂时只介绍了从本地向远端发送文件的相关内容,从远端接收后续再写

通信原理

与远端连接

连接实际上就是利用 SSH 连接远程服务器后在远程服务器启动一个 scp 进程,利用 stdin 和 stdout/stderr 进行数据通信

首先在远程运行 scp -t 来告知 scp 运行模式是从标准输入读入数据写入至指定文件而非不加 -t 参数的文件拷贝。

-t 参数接收一个参数是将文件保存在哪里,即使用方式是 scp -t {target} 。如果目标文件不存在则会创建,但目标文件的父目录不存在则会报错。

如果目标文件是一个目录,应当指定 -d 参数

如果想要输入详细日志,应当指定 -v 参数。如果不指定 -v 参数那么所有的交互都是利用 stdin/stdout 完成的,指定了 -v 参数则会在 stderr 中输出调试日志。scp 的本身实现是如果调用时加了 -v 参数那么在启动远程 scp 服务时也加上 -v 参数

在交互时,本地 scp 可能发送一些消息,而远端会做出相应回复。

回复的规范是固定的

  • 第一个字节是 0x00 / 0x01 / 0x02,0x00 代表正确,0x01/0x02 代表有异常发生
    • 0x01 代表错误
    • 0x02 代表有严重错误,但因为有严重错误会导致进程直接被结束因此实际上客户端不可能收到 0x02 的返回
  • 如果第一个字节不是 0x00(也就是说发生了错误),那么紧接着会有若干字符出现写明错误原因,这些字符都是 ASCII 字符,以 \n (LF,0x0A)结尾

向远端发送消息

在以下情形时会利用向远端发送消息并期待远端回复。消息的发送使用 stdin、回复使用 stdout

  • 连接建立时不会向 stdin 写入内容,但远端会认为收到了一条内容而在 stdout 中回复
    • 如果直接返回了错误(例如文件不存在)那么整个 scp 连接可以认为没有建立成功,应当直接报错退出
  • 要创建文件时发送 C{perm} {size} {filename}\n {文件内容}\0
  • 要「进入」目录时发送 D{perm} 0 {filename}\n
  • 要「退出」目录时发送 E\n
  • 要设定文件时间发送 T…\n (暂时还没研究,后续研究的话会补上)

权限 perm 是 4 位数字字符,与 Linux 权限位相同,常见的目录为 0755 文件为 0644

文件内容的发送有两部分,分别代表着创建文件和写入内容,二者不可分割,但中间需要有一次检查 stdout 输出。如果写入目标是文件那么文件名没有意义,而如果写入目标是目录那么该文件名会是要写入的内容的文件名。文件内容就是原始的文件内容,字节数应当和创建时给定的 size 相同,全都发送完后使用 0x00 代表结束然后检查 stdout。如果 size 和实际写入的字符数不同或是文件结束没有输入 0x00 都会出错。

目录有「进入」和「退出」的概念,例如想要创建一个如下结构的目录

1
2
3
4
5
6
---- A
---- a
---- b
---- B
---- c
---- d

那么依次发送

  • D0755 0 A\n 创建 A 目录,权限为 755,进入 A 目录(后续的文件都是在 A 目录中的)
  • C0644 1 a\n a\0 在 A 目录中创建 a 文件,权限为 644,大小为 1 字节,内容设定为 a
  • C0644 1 b\n b\0 在 A 目录中创建 b 文件,权限为 644,大小为 1 字节,内容设定为 b
  • E\n 退出 A 目录(后续的文件不再在 A 目录中)
  • D0755 0 B\n 创建 B 目录,权限为 755,进入 B 目录(后续的文件都是在 B 目录中的)
  • C0644 2 c\n hh\0 在 B 目录中创建 c 文件,权限为 644,大小为 2 字节,内容设定为 hh
  • C0644 2 d\n XX\0 在 B 目录中创建 d 文件,权限为 644,大小为 2 字节,内容设定为 XX
  • E\n 退出 B 目录(后续的文件不再在 A 目录中)

参考

Talk is cheap, show me the code!

我感觉目前 Go 中的 scp 库没有符合我要求的,因此写了一个 Go 库

ImSingee/scp

该库中的 protocol.go 文件就是实现的我上面描述的通信,包括了

  • checkResponse() 检查 stdout 是否符合要求
  • WriteFile(perm string, size int64, filename string, data io.Reader) 写入文件
  • WriteDirectoryStart(perm string, filename string) 创建目录并进入
  • WriteDirectoryEnd() 退出目录

如果想要更直观准确了解 scp 的原理可以直接阅读源码

而一些基本的使用则封装在了 scp.go 中,包括了

  • CopyFile(session *ssh.Session, file io.Reader, filename string, size int64, mode os.FileMode, target string) 将本地的文件拷贝到目标服务器
  • CopyFolder(session *ssh.Session, from string, mode os.FileMode, target string) 将本地的目录递归拷贝到目标服务器

并提供了一个「智能」API

  • Copy(session *ssh.Session, from, target string) 将本地文件或目录拷贝到目标服务器

这一 API 旨在提供与 scp [-r] /path/to/local/file remote-addr:/path/to/remote/file 完全相同的行为(而不需要用户手动指定是否是目录)