对实验一的纯粹的实验性质程序加“一点点”小功能,可以更好的把玩一下。包含文件路径处理、多进程处理一系列简单改造。
服务器现阶段返回的固定是 HTML 文件,改造成根据客户端的请求返回目录下的任意文件。
使用 Python 自带的 mimetypes 模块(Python 神了),根据文件扩展名自动推断 MIME 类型。在读取文件时不再使用 read 来读,不能正确处理二进制文件,换成 open。发送的时候也应当避免使用 encode,否则会损坏二进制数据。
改造后的文件处理部分:
1
2
3
4
5
6
7
8
9
10
11
| content_type, _ = mimetypes.guess_type(filepath)
if content_type is None:
content_type = "application/octet-stream" # 默认二进制
# 读取文件(二进制)
with open(filepath, "rb") as f:
outputdata = f.read()
...
header += f"Content-Type: {content_type}\r\n"
...
connectionSocket.send(outputdata)
|
现阶段的文件访问没有做任何特别的处理,也就是说如果我在服务器上层放一张图片 HTTP.png,http://localhost:8080/../HTTP.png就可以直接访问到这个文件。这好像没什么但如果我是 /../../etc/passwd 你不炸了吗?
但我在用浏览器测试的时候先发现这样一个问题,确实我发现了我访问 http://localhost:8080/../HTTP.png 但浏览器地址栏却是 http://localhost:8080/HTTP.png 。并且发现服务器端报出 Unexpected error: list index out of range 的错误,这涉及浏览器的一个机制:浏览器的路径规范化。
localhost:8080/../HTTP.png 时,浏览器会路径归一化(Normalization)。/../HTTP.png 会被规整为 /HTTP.png,这是浏览器的“用户体验逻辑”,目的是防止用户在地址栏看到奇怪的 ..,也避免和文件系统“上级目录”混淆。这时你打出报文就会发现收到的报文其实是 GET /HTTP.png HTTP/1.1 。
那么为什么会报出 list index out of range 呢?能报这个错误的代码应当只有一句 filename = message.split()[1]。 而在访问 /../HTTP.png 时,浏览器在地址栏输入一个“特殊 URL”时,可能会先发一次探测请求(甚至是异常格式,比如只发 GET,没有路径),所以服务器就会解析崩溃,加上合理的健壮性判断即可。
1
2
3
4
5
6
7
8
9
| parts = message.split()
if len(parts) < 2:
# 非法请求,直接返回 400 Bad Request
header = "HTTP/1.1 400 Bad Request\r\n\r\n"
connectionSocket.send(header.encode())
connectionSocket.close()
continue
filename = parts[1]
|
至此我就不再打算用浏览器测试了,浏览器自己有很多隐含的机制,我又不是在测试浏览器。我用自己写的客户端试了试,确实服务器可以收到 Request message: GET /../index.html HTTP/1.1 并且成功返回,下面我们就解决这个问题。
HTTP 请求里的路径本质就是一个字符串,目录穿越漏洞(Directory Traversal),真实 Web 服务器都会防御。关键在于把 URL 里的路径归一化(处理 .、..、重复斜杠等情况)并且确保归一化后的路径仍然在你的根目录(document root)之下。
我们直接导入 GPT 给出的处理方案,并简单讲解。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| import os
from urllib.parse import unquote
# 定义允许访问的根目录(示例)
WEB_ROOT = os.path.abspath("./source")
def safe_path(request_path: str) -> str | None:
# 浏览器或客户端可能把 .. 编码成 %2e%2e。在解析前应把百分号编码解回来再处理。
request_path = unquote(request_path)
# 去掉开头的 "/"
rel_path = request_path.lstrip("/")
# 归一化路径(会处理 .. 和 . 以及多余的斜杠)
normalized = os.path.normpath(rel_path)
# 拼接成绝对路径
abs_path = os.path.abspath(os.path.join(WEB_ROOT, normalized))
# 检查是否仍然在 WEB_ROOT 内
if os.path.commonpath([WEB_ROOT, abs_path]) == WEB_ROOT:
return abs_path
else:
return None
filepath = safe_path(filename)
if filepath is None:
header = "HTTP/1.1 403 Forbidden\r\n\r\n"
connectionSocket.send(header.encode())
connectionSocket.close()
return
|
注意 Python 的函数语法,还是比较别致的。normpath 不会把相对路径“丢掉”开头的 ..,只合并冗余,而 os.path.abspath(...) 会解析 ..。最后再判断解析完成后的路径是否合规即可。
这里差评,直接询问 GPT Python 里面启动多进程的方式,GPT 竟然不给出进程回收的机制,问他他还说我问的专业 (也是把我夸的心花怒放了,感谢 ICS 课)。
和 ICS 课上的机制几乎一致,fork 再子进程父进程分开处理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| def handle_client(connectionSocket, addr):
try:
message = connectionSocket.recv(2048).decode()
print("Request message:", message)
# … 这里放你之前的文件处理逻辑 …
connectionSocket.close()
except Exception as e:
print("Error:", e)
connectionSocket.close()
signal.signal(signal.SIGCHLD, signal.SIG_IGN)
...
connectionSocket, addr = serverSocket.accept()
pid = os.fork()
if pid == 0: # 子进程
serverSocket.close() # 子进程里不用监听 socket
handle_client(connectionSocket, addr)
sys.exit(0) # 子进程结束
else: # 父进程
connectionSocket.close() # 父进程里不用这个连接
|
至于回收子进程,我学到一个以前课上完全没提到过的机制,自动回收。
直接注册信号 signal.signal(signal.SIGCHLD, signal.SIG_IGN),忽略这个信号。如果父进程把 SIGCHLD 设置成忽略,Linux 内核就认为:“既然你不关心子进程的退出状态,我就不需要给你留着了。”于是,子进程退出时,内核会立刻自动回收,不会留下僵尸进程。
这个我仔细询问了 GPT,查阅了一些资料放置大模型幻觉,是真的!这个是现在 Linux 内核的惯例的处理,而不是 POSIX 的强制标准,在 ICS 的课上都没有讲过这个机制。
这里我们直接就用了这个机制而没有注册一个回收函数,即通过传统的 waitpid 的方式来进行回收,这种方式确实非常简便,但是就父进程失去了从子进程处得到信息并记录日志的机会,我们这里就不做的太复杂就这样了。
至此我们基本完成了服务器的改造,可以完成基本的文件传输功能。下面我们对客户端端做出配套的改造。
我们需要的效果是输入一个文件,直接到对应的服务器下去下载目标文件,那么必须要有一个输入。然后我们要将服务器发回的报文分段,将 Head 分离出来解读,如果报文正常,将 Body 部分存储下来。
我们直接采用命令行的输入方式,即把请求的文件直接作为脚本运行时的参数代入,Python 没有 main 函数的设计还是十分诡异。参数直接存储在变量 sys.argv 中,显然这是一个参数列表。
1
2
3
4
5
| if len(sys.argv) < 2:
print(f"Usage: python3 {sys.argv[0]} <filename>")
sys.exit(1)
filename = sys.argv[1]
|
按照先前的方式接收报文,在收到报文之后重要的就是要对报文进行分割解读。根据 HTTP 协议的标准格式,只需要找到两个连续的换行符即可把 Head 和 Body 分割出来。
1
2
3
4
5
6
7
8
| separator = b'\r\n\r\n'
header_end = response.find(separator)
if header_end == -1:
print("Invalid HTTP response")
sys.exit(1)
header_bytes = response[:header_end].decode(errors='replace')
body = response[header_end + 4:]
|
头部可以直接解码判断,输出在终端上判断情况。
1
2
| print("===HTTP Header===")
print(header_bytes)
|
如果头部表明文件正常传输了,那么我们就根据头部存储文件即可,否则报出相应的错误。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| content_type = "application/octet-stream"
for line in header_bytes.split("\r\n"):
if line.lower().startswith("content-type:"):
content_type = line.split(":", 1)[1].strip()
break
status_line = header_bytes.split("\r\n")[0]
if not status_line.startswith("HTTP/1.1 200"):
print("Request failed:", status_line)
sys.exit(1)
with open(filename, "wb") as f:
f.write(body)
print(f"Body saved to {filename}")
|
可以注意到文件的默认类型为 application/octet-stream 即二进制文件,然后根据首部行的内容分割出实际的文件类型,但其实我们这里不需要文件类型了,只是为了展示实际应当采用的分割策略。
接着就只用读到什么写入什么,注意判断一下异常即可。
1
2
3
4
5
6
7
8
9
| status_line = header_bytes.split("\r\n")[0]
if not status_line.startswith("HTTP/1.1 200"):
print("Request failed:", status_line)
sys.exit(1)
with open(filename, "wb") as f:
f.write(body)
print(f"Body saved to {filename}")
|
至此基本就能实现一个简单的文件传输了,但是实际测试就会发现:传输效率实在不高。一个 200 MB 的视频文件几乎传了 20 min 才完成,核心是收发包的过程很多处理很低效。如果以后还有机会的话就调一调这一部分。