ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FTP服务器从vsftpd搭建到Python脚本化上传下载全攻略

FTP服务器从vsftpd搭建到Python脚本化上传下载全攻略

简介:这是一份面向Java开发者的FTP/SFTP文件传输工具包,解决从服务器上传、下载文件这一常见需求,适合需要快速集成文件传输能力的初学者、运维人员及项目开发人员参考复用。包内共2个文件,包含一个Java源码文件与一个JSch依赖JAR包,压缩包整体仅106KB,体积小巧、结构清晰,可直接将源码和依赖引入工程后修改服务器地址、账号密码即用。源码中封装了与服务器建立连接、登录验证、上传与下载文件、切换工作目录、设置二进制传输模式以及断开连接等关键操作,并配有完整的调用示例,能帮助读者理解基于SSH的SFTP传输实现方式,同时也可对照FTP基础知识掌握两类协议在工作模式、异常处理上的差异。已有251人学习下载,适合作为Java网络编程入门的配套练习,也可作为轻量级文件传输模块的种子代码。

1. FTP从服务器上传下载文件:老协议为什么还值得认真学一遍

你手里握着一台服务器,要把打包好的静态资源传上去,或者把服务器上的日志拉回本地分析。很多人第一反应是装个图形化工具,但真正到了脚本化、批量同步、丢给定时任务自动跑的场景,FTP协议反而是最不容易出岔子的那条路。它虽然诞生得早,但传输模型简单、调试可观测性高,搞懂主动/被动模式、端口段、chroot边界之后,稳定程度远超直觉。

这篇笔记从服务端搭建开始,把上传、下载、目录批量同步、防火墙放行、编码和断点续传整个链路拆开讲。不需要图形界面,一条命令、一段 Python 脚本就能让文件在两台机器之间稳定流动。适合正在做课程设计、需要把本地资源部署到服务器的人,也适合定期从服务器拉取推送数据的开发者,对 FTP 只知其名不知其配置细节的人更要看。

2. 搭建可用的FTP服务端:从安装到联通的完整链路

2.1 服务端选型:为什么首选 vsftpd

提到 FTP 服务端,Linux 生态里最常见的开源实现是 vsftpd(Very Secure FTP Daemon),它配置精简、性能好、安全默认值高,几乎每个主流发行版的软件源里都有,安装包小、依赖少、配置文件只有一个,坏了也好排查。

另一个常见选择是 Pure-FTPd,配置风格偏“开箱即用”,不少虚拟主机面板会集成它。如果你只是想在自己的服务器上搭一个稳定的文件交换通道,我一般直接选 vsftpd。

有人会说 FTP 过时了,用 SFTP 不是更省事?SFTP 走的是 SSH 的 22 端口,如果你本身开通了 SSH,那确实 scp 一条命令就走人。但 FTP 的生命力在于:不少老旧的嵌入式设备、NAS、网络打印机只支持 FTP 协议;另外如果你需要的是一批只能读写特定目录、不能登录 shell 的账号,FTP 配合 chroot 就是一个干净又容易审计的隔离方案。所以下面整个链路以 vsftpd 为主线来演示。

2.2 安装与启动:三步确认服务在跑

# 在Linux服务器上安装vsftpd(按发行版选一条执行) # Debian/Ubuntu系: sudo apt-get update && sudo apt-get install -y vsftpd # RHEL类发行版: # sudo yum install -y vsftpd # 启动服务并设置开机自启 sudo systemctl start vsftpd sudo systemctl enable vsftpd # 检查端口监听状态,确认21号端口在LISTEN sudo ss -tlnp | grep ':21'

这段操作的价值在于快速确认服务真的起来了。systemctl enable解决服务器重启后 FTP 服务不自动拉起的问题;ss -tlnp是排查第一步,如果端口没出现,多半是安装失败或配置里有语法错误,systemctl status vsftpd能看到具体报错。注意默认端口就是 21,其他 Web 服务不要占掉它,否则会出现“服务明明装了,但客户端永远连不上”的怪象。

2.3 核心配置文件逐项拆解

vsftpd 的配置集中在/etc/vsftpd.conf,部分发行版还会读取/etc/vsftpd/下的子配置。下面这份是我在模拟项目X里常用的基准配置,每一行都直接影响后续上传下载能否成功:

# 允许本地系统账号登录FTP local_enable=YES # 允许写操作:上传、删除、重命名 write_enable=YES # 本地用户登录后限制在自己的home目录内,防止越权看系统文件 chroot_local_user=YES # 允许chroot后的目录可写(老版本默认禁止向home根目录写入) allow_writeable_chroot=YES # 启用被动模式并固定数据端口范围 pasv_enable=YES pasv_min_port=30000 pasv_max_port=31000 # 关闭匿名访问,避免服务裸奔 anonymous_enable=NO

很多人只记得开local_enable=YES,却忘了write_enable=YES,结果客户端能登录,但一旦上传就收到550 Permission denied。chroot_local_user=YES会把账号锁在 home 目录里,是个非常重要的安全边界;allow_writeable_chroot=YES则是为了兼容新版 vsftpd 默认禁止向 chroot 根目录写文件的策略。pasv_min_port和pasv_max_port是第 4 章排查卡死的关键,这 1000 个端口的射程范围要记牢。

2.4 创建专用FTP账号与目录

# 创建专用账号,不分配登录shell,不允许通过SSH进入服务器 sudo useradd -m -s /usr/sbin/nologin ftpuser # 设置登录密码 sudo passwd ftpuser # 建立数据目录并级联授权 sudo mkdir -p /home/ftpuser/data sudo chown ftpuser:ftpuser /home/ftpuser/data

-s /usr/sbin/nologin是这个账号安全性的核心:它让 FTP 用户无法通过 SSH 登录服务器,只能通过 FTP 协议访问文件。目录属主一定要改成ftpuser,否则客户端登录后能看目录列表,一旦尝试写入就会撞上权限错误。

如果你希望把多个业务目录挂进来,比直接改 home 路径更干净的做法是mount --bind /外部目录 /home/ftpuser/data/业务子目录,这样既不需要给账号开系统级访问权限,又能把数据目录收拢到 home 之下。

2.5 第一次连通性测试

# 在服务器本机先自测,排除网络因素 ftp localhost # 输入用户名 ftpuser 和密码后,依次执行: # pwd -> 显示当前FTP目录 # ls -> 查看文件列表 # quit -> 退出 # 看到 230 Login successful 即通过

本地自测能过,说明服务端配置基本没有硬伤;远程连不上,就往防火墙、被动模式、安全组方向排查。这一步把“服务端问题”和“网络问题”劈成两半,后续排错会快很多。

3. 上传与下载文件:三种脚本化姿势

3.1 Python ftplib:标准库里的免费午餐

Python 自带ftplib,不需要 pip 装任何依赖,写一个上传下载工具是几分钟的事。下面这段代码是我常用的骨架,加了超时控制和二进制模式:

# ftp_transfer.py # 用法: python3 ftp_transfer.py upload local.txt remote.txt import sys from ftplib import FTP def connect(host, user, pwd, port=21): ftp = FTP() ftp.connect(host, port, timeout=10) # 10秒超时,避免死等 ftp.login(user, pwd) return ftp def upload(ftp, local_path, remote_path): with open(local_path, 'rb') as f: ftp.storbinary(f'STOR {remote_path}', f) print(f'uploaded: {local_path} -> {remote_path}') def download(ftp, remote_path, local_path): with open(local_path, 'wb') as f: ftp.retrbinary(f'RETR {remote_path}', f.write) print(f'downloaded: {remote_path} -> {local_path}') if __name__ == '__main__': ftp = connect('你的服务器IP', 'ftpuser', '你的密码') if sys.argv[1] == 'upload': upload(ftp, sys.argv[2], sys.argv[3]) elif sys.argv[1] == 'download': download(ftp, sys.argv[2], sys.argv[3]) ftp.quit()

storbinary负责把本地二进制流写入服务器,对应 FTP 的STOR指令;retrbinary把服务器文件取回并回调写入,对应RETR指令。文件必须用二进制模式打开,否则 Windows 下换行符会被悄悄改写,图片、压缩包会直接损坏。timeout=10是我刻意加的:默认阻塞模式在服务端不稳定时会让脚本一直挂住,设置超时能让失败早暴露、早重试。

3.2 目录批量同步:遍历、建目录、比大小

单文件传输只是开胃菜,实际工作里最常用的是目录级同步。下面这个脚本递归遍历本地目录,在服务器上自动创建缺失的目录,并只上传与服务器大小不一致的文件:

# sync_upload.py import os from ftplib import FTP def ensure_remote_dir(ftp, remote_dir): """在FTP服务器上递归创建目录,已存在则跳过""" parts = remote_dir.split('/') path = '' for p in parts: if not p: continue path += '/' + p try: ftp.mkd(path) except Exception: pass def should_upload(ftp, remote_path, local_size): try: remote_size = ftp.size(remote_path) return remote_size != local_size except Exception: return True def sync_upload(ftp, local_root, remote_root): for root, dirs, files in os.walk(local_root): rel = os.path.relpath(root, local_root) remote_dir = remote_root if rel == '.' else f'{remote_root}/{rel}' ensure_remote_dir(ftp, remote_dir) for f in files: local_path = os.path.join(root, f) remote_path = f'{remote_dir}/{f}' size = os.path.getsize(local_path) if should_upload(ftp, remote_path, size): with open(local_path, 'rb') as fp: ftp.storbinary(f'STOR {remote_path}', fp) print('upload:', remote_path) if __name__ == '__main__': ftp = FTP('你的服务器IP') ftp.login('ftpuser', '你的密码') sync_upload(ftp, './dist', '/data') ftp.quit()

这里的筛选逻辑是:先调ftp.size()拿远端文件大小,与本地不一致就上传。注意这只是初筛,更严格的做法是比 MD5,但 FTP 协议本身没有标准的 MD5 指令(很多服务端会扩展支持MD5指令),对部署场景来说大小比对已经能过滤掉绝大多数重复传输。os.walk返回的dirs列表可以按需剔除node_modules、.git这类大目录,避免把上百兆依赖包推上服务器。

3.3 老派 ftp 命令行与 curl:不同场景的取舍

# 用heredoc把命令一次喂给ftp,适合快速手工批量操作 ftp -n 你的服务器IP <<EOF user ftpuser 你的密码 cd /data mput *.tar.gz prompt off bye EOF

-n表示不自动登录,后面的user命令手动完成认证;mput支持通配符批量上传,mget批量下载,prompt off关闭逐文件确认,否则文件一多会反复问 Yes/No。

如果你只想快速推一个文件且本机装了 curl,用curl -T local.tar.gz ftp://服务器IP/data/也行,curl 会自动处理认证参数。但注意 curl 的 FTP 上传默认用的也是被动模式,服务器同样要放行数据端口段。命令行派适合临时应急,脚本里还是建议用 Python 加环境变量读密码,别把凭据硬编码在 shell 脚本里。

3.4 下载整个远程目录的小补充

# download_dir.py import os from ftplib import FTP def download_dir(ftp, remote_dir, local_dir): os.makedirs(local_dir, exist_ok=True) for name in ftp.nlst(remote_dir): # 列出远端目录所有条目 local_path = os.path.join(local_dir, os.path.basename(name)) with open(local_path, 'wb') as f: ftp.retrbinary(f'RETR {name}', f.write) print(f'downloaded: {remote_dir} -> {local_dir}') ftp = FTP('你的服务器IP') ftp.login('ftpuser', '你的密码') download_dir(ftp, '/data/logs', './local_logs') ftp.quit()

这个版本的局限在于nlst不递归子目录,碰到目录里还有目录时需要手动判断ftp.nlst返回的是文件还是目录,再决定是递归还是下载。更省事的方案见第 6 章的断点续传与校验组合。

4. 被动模式、防火墙与chroot:打通远程连接的三个关键

4.1 主动模式与被动模式,到底谁连谁

FTP 和 HTTP 有一个本质区别:它不只用 21 端口传命令,还会额外开端口传数据。主动模式下,客户端开一个随机端口并告诉服务器“你来连我这个端口”,服务器用 20 端口主动连回去。这个模型在内网环境下大概率被防火墙拦死,因为服务器对外主动发起连接通常不受允许。被动模式下,服务器开放一个高端口区间等待客户端来连,对客户端更友好,所以现代 FTP 客户端和 curl 默认都在用被动模式。

选型结论很简单:服务端能开被动模式就开被动模式,并固定端口段,这是解决“连接通了但数据传不动”的最优路径。主动模式只有在极端网络策略下才会被拉出来应急,日常开发别去折腾它。

4.2 放行21端口与数据端口段

# 先在/etc/vsftpd.conf中确认或添加: # pasv_enable=YES # pasv_min_port=30000 # pasv_max_port=31000 # 重启服务使配置生效 sudo systemctl restart vsftpd # 以常见的firewalld举例,放行端口 sudo firewall-cmd --permanent --add-port=21/tcp sudo firewall-cmd --permanent --add-port=30000-31000/tcp sudo firewall-cmd --reload

这里最容易犯的错误是只放行 21 端口。很多人的现象是账号密码输完能过,但一敲ls界面就像“卡死”一样长时间无响应,十有八九是 30000-31000 这一段没放行。固定端口段的好处是给防火墙规则一个确定范围,如果你在云服务器上,安全组规则要同步放行这一整段 TCP 端口。

4.3 chroot边界与allow_writeable_chroot

chroot_local_user=YES的意义在于:FTP 账号登录后,它看到的根目录就是自己的 home 目录,往上无法访问/etc/passwd、/var/www这类系统路径,从根上杜绝越权读取。

启用 chroot 后,老版本 vsftpd 出于安全考虑会禁止用户向 home 根目录直接写文件,如果你创建的账号 home 目录本身可写且你尝试往根目录放文件,服务端会回500 OOPS。显式加入allow_writeable_chroot=YES,是解决这个历史坑的标准动作。

需要注意,chroot 后mount --bind进来的外部目录依然可见可用,但如果你绑定的目录属主不是该账号,写入同样会权限失败。这个边界在设计目录归属时就要想清楚。

4.4 NAT场景下的pasv_address

# 当服务器在NAT网关后面,客户端拿到的PASV地址会是内网IP,导致数据连接失败 # 在vsftpd.conf里显式指定公网IP或域名 pasv_address=你的公网IP或域名

这个配置我在云服务器上实践时踩过好几次:客户端能登录、能列出部分目录,但传输大文件时随机中断。原因是服务器响应 PASV 指令时返回的是内网 IP,客户端按照内网 IP 去连自然连不上。指定pasv_address后,PASV 响应里的地址才会变成客户端能触达的公网地址。

5. FTP踩坑实录:五个高频问题的现象、原因与解决

5.1 登录成功后ls卡死,界面长时间无响应

现象:21 端口能通,账号密码认证也通过了,但敲入ls后客户端界面长时间停住,最终超时断开。

原因:被动模式下,服务器的数据端口段没有放行,客户端去连30000-31000被防火墙或安全组拦截。另一个少见原因是 PASV 返回的地址是内网 IP,客户端无法触达。

解决:检查vsftpd.conf里pasv_min_port与pasv_max_port是否正确,用firewall-cmd放行对应端口段;云服务器安全组也同步放行。如果服务器在 NAT 后面,按 4.4 节设置pasv_address。

5.2 530 Login incorrect,但密码明明没错

现象:服务器上ftp localhost能登录,远程客户端报530 Login incorrect。

原因:vsftpd 默认走 PAM 认证,某些系统上nologin类型的账号会被 PAM 拒绝;或者配置文件里开启了userlist_enable=YES,而账号恰好被写进了/etc/vsftpd.user_list拒绝名单。

解决:先用grep -v '^#' /etc/vsftpd.conf | grep userlist看看是否开启了用户名单限制;再查看/var/log/secure或/var/log/auth.log,搜pam关键字定位 PAM 拒绝的具体原因。多数时候把账号从拒绝名单里拿掉即可。

5.3 上传报553或550 Permission denied

现象:建目录正常,put上传文件失败,报553 Could not create file或550 Permission denied。

原因:两层可能:一是write_enable=NO根本没开写权限;二是目标目录属主不是当前 FTP 用户,账号没有写权限。如果系统启用了 SELinux,还要考虑 FTP 写策略被拦。

解决:确认write_enable=YES;chown ftpuser:ftpuser把目录属主改过来;SELinux 环境下执行setsebool -P allow_ftpd_full_access on放行写入。改完任何配置记得systemctl restart vsftpd。

5.4 中文文件名全部变成乱码

现象:Windows 下用客户端上传“项目报告_v2.0.docx”,服务器上看到绋�稿一类乱码;下载中文文件名反过来一样乱。

原因:本地 Windows 编码是 GBK/GB2312,服务器和现代 FTP 客户端默认用 UTF-8。FTP 协议本身不规定文件名字符集,全靠两端自行约定。

解决:客户端强制指定 UTF-8 编码;命令行下用lftp并设置set ftp:charset=UTF-8;用ftplib时对拿到的文件名按 UTF-8 解码,如果服务端是 GBK 环境就按 GBK 解码。规范建议是服务端、客户端全链路统一成 UTF-8,从源头上消灭乱码。

5.5 传了一半的文件残留,大小对不上

现象:大文件传输中途网络抖动,连接断开,服务器上留下一个不完整文件,重跑又可能覆盖成更短的残次品。

原因:FTP 的STOR是覆盖式写入,中途断开不会自动回滚,已经写入的字节会保留在服务器上。

解决:上传大文件时先传.tmp后缀,传完再rename成正式文件名;脚本侧配合断点续传(见第 6 章),避免每次断线都从头开始。这套“临时文件 + 重命名”的套路能显著降低脏数据被下游误用的风险。

6. 进阶技巧:断点续传、定时同步与完整性校验

6.1 用REST参数实现断点续传

from ftplib import FTP import os ftp = FTP('你的服务器IP') ftp.login('ftpuser', '你的密码') remote = '/data/big.tar.gz' local = 'big.tar.gz' offset = os.path.getsize(local) if os.path.exists(local) else 0 ftp.voidcmd('TYPE I') ftp.sendcmd(f'REST {offset}') with open(local, 'ab') as f: ftp.retrbinary(f'RETR {remote}', f.write)

这段代码先看本地已有多少字节,用REST告诉服务器偏移量,服务器只发送剩余部分;本地用ab追加模式写入。注意断点续传依赖服务器端文件没有被改动过,否则拼接出来的文件错位但大小一致,隐蔽性很强。所以我续传后一定会再做一次校验。

6.2 用crontab做定时同步

# 每天凌晨2点执行一次同步脚本,日志落到固定文件 0 2 * * * /usr/bin/python3 /opt/sync_upload.py >> /var/log/ftp_sync.log 2>&1

日志一定要重定向到文件,否则定时任务环境下任何报错都会被系统邮件吞掉,排错时什么都看不到。

6.3 MD5二次确认

import hashlib def md5(path): h = hashlib.md5() with open(path, 'rb') as f: for chunk in iter(lambda: f.read(65536), b''): h.update(chunk) return h.hexdigest() # 若服务端支持MD5指令,可拿返回值与本地比对 # server_md5 = ftp.sendcmd('MD5 /data/big.tar.gz')

流式读取分块计算 MD5 能避免一次性把大文件载入内存。如果服务器不支持MD5指令,就下载完自行比对本地已算好的值。

从那以后,我每次搭 FTP 服务都会强制走一遍“被动端口放行、chroot 目录属主、全链路 UTF-8”这三件事,给某公司做文件交换时也坚持“先传临时文件、校验后 rename”的规范。脏数据造成的返工远比多写几行脚本更痛。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进