15d01d4dc1
设备 FTP 自鉴权落地起就**没有匿名通道**(实测 `USER anonymous` → 530、`admin`+错口令 → 530、`admin`/`admin` → 230;`lftpd` 源码里也没有 anonymous 特判),但三个老主机脚本、 `@test` 的自检记录抓取、以及测试指南都还写着匿名——同目录的 `ftp_auth_bench.py` / `ftp_abort_probe.py` 反而早就记着"匿名一律 530",可见当时只改了新增件。 **改动**: · `test/host/ftp_test.py`、`ftp_up_dl_test.py`、`ftp_speed_test.py`:写死的 `USER anonymous` / `PASS test@test.com` 改为 `--user` / `--password`(默认出厂 admin/admin);**USER 的期望码从 230 修成"230 或 331"**——要口令的服务器在 USER 后 回 331,脚本原先要求 USER 直接给 230,这是第二个 bug(就算口令对也过不了);登录 失败时打印口径提示(`/config.ini` 的 `[ftp]`、无匿名通道、连续 5 次失败锁 60 秒)。 · `.dsh/skills/serial-boot-test/scripts/run_boot_test.py`:`ftp.login()`(匿名)→ `ftp.login(user, pass)`,新增 `--ftp-user/--ftp-pass` 与 config 的 `ftp_user`/ `ftp_pass`(默认 admin);函数文档与 `--device-ip` 的 help 去掉 "(anonymous)"。 · `docs/测试说明/FTP_Test_Guide.md`:§1 的"匿名登录(`USER anonymous` / `PASS` 任意)" 改为鉴权口径(`user` / `pwd_hash` = SHA-256(user‖password) / `write`,出厂 admin/admin,默认口令在用有启动告警,5 次失败锁 60 秒,**无匿名通道**),§4 参数说明 补两个新参数,§4.1 期望改为 `USER admin`→331、`PASS admin`→230,FileZilla 的用户 由 `anonymous` 改 `admin`。 **这才是真正的危害**:`@test --require-selftest-pass` 的抓取走匿名登录 → 必然 530 → 落 进 `RUN_TEST_WARN … treated as no record (S7 PASS path)` → **每次都判 PASS**。也就是 说这条"联合判定自检记录"的门禁从写下那天起就没真正看过文件(假绿)。修完凭据后第一次 真抓到 `/log/selftest.log`(1856 B),立刻报出 29 条故障记录——而它们的**时间戳全是 2026-10-10 ~ 10-14**,没有一条来自本次启动:`/log/selftest.log` 是 512 条环形累计历史, 而 S7 的判据是"有任何 64B 记录就 FAIL" → **在任何做过故障注入或长跑的设备上,这条 门禁都不可能 PASS**。判据本身要改成"只看本次启动新增",已单独提出,不在本笔。 **验证(全部真机实跑)**:`ftp_up_dl_test.py` → TEST PASSED(10 项全过,含 STOR/LIST/ RETR/逐字节比对/DELE);`ftp_speed_test.py --size 128` → TEST PASSED(上行 225.8 KB/s、 下行 250.9 KB/s,131,072 B 逐字节一致);`@test` → PASS(625 ms 到 `net init: OK`); `@test --require-selftest-pass --device-ip 192.168.1.100` → `SELFTEST_FETCH: … (1856 bytes)`,抓取路径真跑通(旧代码在此步必 530)。 `py_compile` 四个脚本全过;`check_doc_links.py --all --sections-strict` 146 文档 0 问题; `check_ids.py --strict` 增量拦截通过;5 个文件字节卫生 BOM 无 / CR 0 / U+FFFD 0 / 无行尾空格。 **顺带暴露、未在本笔处理**:① `ftp_test.py` 的 `LIST 150->226` 断言失败——lftpd 的 LIST 数据本身正确(1512 B 全收到),但传输结束后控制通道回的是 **550** 而不是 226,需单独查 收尾路径;② 设备 `/` 根目录残留约 24 个旧探针文件(AB*/E12A/SZ*/A000x/R000x/DIAG_A); ③ `MDK-ARM/` 下 4 个一次性探针脚本仍写匿名,现在必然登录不上。未推送。
219 lines
7.3 KiB
Python
219 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
FTP Server 测试脚本 — 连接 STM32F4 上的 lftpd 服务器
|
||
用法:python ftp_test.py [--host 192.168.1.100] [--port 21] [--user admin] [--password admin]
|
||
"""
|
||
|
||
import socket
|
||
import sys
|
||
import time
|
||
import argparse
|
||
|
||
HOST = "192.168.1.100"
|
||
PORT = 21
|
||
# 设备侧 FTP 鉴权走 /config.ini 的 [ftp](user / pwd_hash),出厂默认 admin/admin;
|
||
# **没有匿名通道**(USER anonymous 一律回 530)。
|
||
USER = "admin"
|
||
PASSWORD = "admin"
|
||
TIMEOUT = 10.0
|
||
|
||
def recv_line(sock):
|
||
"""读取 FTP 响应行(以 \r\n 结尾)"""
|
||
data = b""
|
||
sock.settimeout(TIMEOUT)
|
||
while True:
|
||
try:
|
||
ch = sock.recv(1)
|
||
except socket.timeout:
|
||
print(f"[TIMEOUT] after receiving: {data!r}")
|
||
return None
|
||
if not ch:
|
||
return data if data else None
|
||
data += ch
|
||
if data.endswith(b"\r\n"):
|
||
return data
|
||
|
||
def send_cmd(sock, cmd):
|
||
"""发送 FTP 命令并读取响应"""
|
||
print(f">>> {cmd}")
|
||
sock.sendall((cmd + "\r\n").encode())
|
||
time.sleep(0.1)
|
||
response = recv_line(sock)
|
||
if response:
|
||
print(f"<<< {response.decode(errors='replace').rstrip()}")
|
||
else:
|
||
print(f"<<< [NO RESPONSE / CONNECTION CLOSED]")
|
||
return response
|
||
|
||
def parse_pasv(response):
|
||
"""解析 PASV 227 响应,返回 (ip, port)"""
|
||
import re
|
||
m = re.search(r"(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)", response.decode())
|
||
if m:
|
||
a, b, c, d, e, f = [int(x) for x in m.groups()]
|
||
ip = f"{a}.{b}.{c}.{d}"
|
||
port = e * 256 + f
|
||
return ip, port
|
||
return None, None
|
||
|
||
def test_ftp(host, port, user, password):
|
||
print(f"=== FTP Test: {host}:{port} ===\n")
|
||
checks = [] # (label, ok, detail)
|
||
|
||
def check(label, ok, detail=""):
|
||
checks.append((label, ok, detail))
|
||
mark = "PASS" if ok else "FAIL"
|
||
print(f" [{mark}] {label}{(' - ' + detail) if detail else ''}")
|
||
|
||
# 1. Connect
|
||
print("[1] Connecting to control channel...")
|
||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||
sock.settimeout(TIMEOUT)
|
||
try:
|
||
sock.connect((host, port))
|
||
print(f" Connected to {host}:{port}")
|
||
check("Connect", True)
|
||
except Exception as e:
|
||
print(f" CONNECT FAILED: {e}")
|
||
check("Connect", False, str(e))
|
||
return False
|
||
|
||
# 2. Read welcome
|
||
print("\n[2] Reading welcome banner...")
|
||
resp = recv_line(sock)
|
||
if resp:
|
||
print(f" {resp.decode(errors='replace').rstrip()}")
|
||
check("Welcome 220", resp.startswith(b"220"),
|
||
resp.decode(errors='replace').rstrip())
|
||
else:
|
||
print(" NO WELCOME BANNER")
|
||
check("Welcome 220", False, "no banner")
|
||
sock.close()
|
||
return False
|
||
|
||
# 3. Login
|
||
print("\n[3] Login...")
|
||
resp = send_cmd(sock, "USER " + user)
|
||
if not resp:
|
||
sock.close()
|
||
check("Login USER", False, "no response")
|
||
return False
|
||
|
||
resp = send_cmd(sock, "PASS " + password)
|
||
if not resp:
|
||
sock.close()
|
||
check("Login PASS", False, "no response")
|
||
return False
|
||
login_ok = b"230" in resp
|
||
check("Login (USER/PASS 230)", login_ok,
|
||
resp.decode(errors='replace').rstrip())
|
||
if not login_ok:
|
||
print(" 提示:lftpd 按 /config.ini 的 [ftp] 段校验(user / pwd_hash),出厂默认 admin/admin;"
|
||
"设备没有匿名通道。口令被改过就用 --user/--password 传;连续 5 次失败会锁定 60 秒。")
|
||
|
||
# 4. PWD
|
||
print("\n[4] PWD...")
|
||
resp = send_cmd(sock, "PWD")
|
||
pwd_ok = bool(resp) and resp.startswith(b"257")
|
||
check("PWD 257", pwd_ok,
|
||
resp.decode(errors='replace').rstrip() if resp else "no response")
|
||
|
||
# 5. PASV mode
|
||
print("\n[5] PASV...")
|
||
resp = send_cmd(sock, "PASV")
|
||
pasv_ip, pasv_port = (None, None)
|
||
if resp:
|
||
pasv_ip, pasv_port = parse_pasv(resp)
|
||
if pasv_port is None and resp and b"227" in resp:
|
||
# 227 响应但解析端口失败
|
||
pasv_port = None
|
||
if pasv_port is None:
|
||
# Try EPSV
|
||
print("\n[5b] Trying EPSV...")
|
||
resp = send_cmd(sock, "EPSV")
|
||
if resp and b"229" in resp:
|
||
import re
|
||
m = re.search(r"\(\|\|\|(\d+)\|\)", resp.decode())
|
||
if m:
|
||
pasv_port = int(m.group(1))
|
||
pasv_ip = host
|
||
print(f" EPSV: {pasv_ip}:{pasv_port}")
|
||
pasv_ok = pasv_port is not None
|
||
check("PASV/EPSV data port", pasv_ok,
|
||
f"{pasv_ip}:{pasv_port}" if pasv_ok else "no port")
|
||
|
||
# 6. LIST (correct PASV flow: connect data first, then send LIST)
|
||
if pasv_ok:
|
||
print(f" Data channel: {pasv_ip}:{pasv_port}")
|
||
print("\n[6] LIST...")
|
||
data_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||
data_sock.settimeout(5.0)
|
||
try:
|
||
data_sock.connect((pasv_ip, pasv_port))
|
||
print(f" Data connected to {pasv_ip}:{pasv_port}")
|
||
resp = send_cmd(sock, "LIST")
|
||
list_open = bool(resp) and resp.startswith(b"150")
|
||
data = b""
|
||
while True:
|
||
try:
|
||
chunk = data_sock.recv(1024)
|
||
if not chunk:
|
||
break
|
||
data += chunk
|
||
except socket.timeout:
|
||
break
|
||
print(f" LIST result ({len(data)} bytes):")
|
||
print(data.decode(errors='replace'))
|
||
data_sock.close()
|
||
resp = recv_line(sock)
|
||
list_close = bool(resp) and resp.startswith(b"226")
|
||
print(f" LIST response: "
|
||
f"{resp.decode(errors='replace').rstrip() if resp else 'none'}")
|
||
list_ok = list_open and list_close
|
||
check("LIST 150->226", list_ok,
|
||
f"open={list_open} close={list_close} bytes={len(data)}")
|
||
except Exception as e:
|
||
print(f" LIST FAILED: {e}")
|
||
data_sock.close()
|
||
check("LIST 150->226", False, str(e))
|
||
else:
|
||
print(" Skipping LIST (no data port)")
|
||
|
||
# 7. QUIT
|
||
print("\n[7] QUIT...")
|
||
resp = send_cmd(sock, "QUIT")
|
||
quit_ok = bool(resp) and resp.startswith(b"221")
|
||
check("QUIT 221", quit_ok,
|
||
resp.decode(errors='replace').rstrip() if resp else "no response")
|
||
sock.close()
|
||
|
||
# Summary
|
||
passed = all(ok for _, ok, _ in checks)
|
||
print("\n" + "=" * 40)
|
||
print("FTP Test Summary:")
|
||
for label, ok, _ in checks:
|
||
print(f" [{'PASS' if ok else 'FAIL'}] {label}")
|
||
print("=" * 40)
|
||
if passed:
|
||
print(">>> TEST PASSED <<<")
|
||
else:
|
||
print(">>> TEST FAILED <<<")
|
||
print("=== Test Complete ===")
|
||
return passed
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="FTP Server Test")
|
||
parser.add_argument("--host", default=HOST, help=f"FTP server IP (default: {HOST})")
|
||
parser.add_argument("--port", type=int, default=PORT, help=f"FTP server port (default: {PORT})")
|
||
parser.add_argument("--user", default=USER, help=f"FTP user (default: {USER})")
|
||
parser.add_argument("--password", default=PASSWORD,
|
||
help="FTP password (default: the factory password)")
|
||
args = parser.parse_args()
|
||
|
||
success = test_ftp(args.host, args.port, args.user, args.password)
|
||
sys.exit(0 if success else 1)
|
||
|
||
if __name__ == "__main__":
|
||
main()
|