- ymawky là một máy chủ HTTP tĩnh nhỏ gọn cho macOS, được viết hoàn toàn bằng assembly aarch64, chỉ dùng syscall thô của Darwin mà không có wrapper libc
- Hỗ trợ
GET, HEAD, PUT, OPTIONS, DELETE, yêu cầu byte range, liệt kê thư mục và trang lỗi tùy chỉnh, nhưng không nhằm thay thế nginx mà là một cách loại bỏ các lớp tiện lợi để hiểu web server thực sự hoạt động như thế nào
- Từ phân tích request, giải mã percent, kiểm tra header, chuyển đổi giá trị range, xử lý lỗi, đóng file đến tạo response đều phải tự viết thủ công; ngay cả những việc tương đương với tách chuỗi đơn giản hay
int(string) trong Python cũng trở thành hàng chục đến hàng trăm dòng mã kiểm chứng trong assembly
- Server dùng kiến trúc fork-on-request, gọi
fork() cho mỗi kết nối mới, nên dễ triển khai nhưng thông lượng xử lý kết nối đồng thời thấp và có thể dễ bị slowloris; vì vậy nó áp dụng timeout cho header và timeout cho body dựa trên Content-Length
- Với
PUT, dữ liệu được ghi trước vào file tạm .ymawky_tmp_<pid> rồi mới thay thế khi thành công; an toàn hệ thống tệp như chống path traversal, O_NOFOLLOW_ANY, fstat64(), mã hóa URL và escape HTML trong danh sách thư mục đều được xử lý thủ công
Tổng quan và các ràng buộc của ymawky
- ymawky là một máy chủ HTTP tĩnh nhỏ gọn cho macOS được viết hoàn toàn bằng assembly aarch64
- Nó chỉ dùng syscall thô của Darwin mà không có wrapper libc, không dùng thư viện ngoài hay parser có sẵn
- Các tính năng được hỗ trợ gồm
GET, HEAD, PUT, OPTIONS, DELETE, yêu cầu byte range, liệt kê thư mục và trang lỗi tùy chỉnh
- Các ràng buộc của dự án như sau
- chỉ dùng aarch64 assembly
- nhắm tới macOS/Darwin
- chỉ dùng raw syscalls, không có wrapper libc
- chỉ phục vụ file tĩnh
- không dùng parser có sẵn
- không dùng thư viện ngoài
- Mục tiêu không phải thay thế nginx, mà là loại bỏ các lớp tiện lợi để hiểu web server thực sự vận hành ra sao
Những gì cần làm khi viết web server bằng assembly
- Assembly là lớp trung gian giữa mã máy và ngôn ngữ bậc cao; các lệnh như
mov, add, ldr, str, cmp ánh xạ trực tiếp tới các byte trong binary thực thi
svc #0x80 là dạng con người có thể đọc của các byte D4 00 10 01 trong binary thực thi
- Không có kiểu chuỗi, nên chuỗi chỉ tồn tại như một vùng byte liên tiếp trong bộ nhớ; cũng không có các tính năng ngôn ngữ như
struct của C, nên phải tự biết offset của từng field và kích thước tổng thể
- Vì không có thư viện HTTP, tự động dọn dẹp, exception hay object, nên mọi việc như phân tích request, xử lý lỗi, đóng file, tạo response đều phải tự viết
- Dù chương trình chạy sai, CPU vẫn tiếp tục thực thi mà không cảnh báo, nên vấn đề nằm ở chính các lệnh và truy cập bộ nhớ đã viết
Syscall thô và luồng hoạt động của server
-
Syscall Darwin
- ymawky gọi trực tiếp kernel thay vì dùng wrapper libc
- Trên Darwin aarch64, số syscall được đặt trong thanh ghi
x16, còn trên Linux aarch64 là x8
- Số syscall của
open() là 5; sau khi đặt trực tiếp các đối số như tên file và mode vào thanh ghi, kernel được gọi bằng svc #0x80
- Khi
open() thất bại, carry flag được bật; có thể kiểm tra carry flag bằng nhánh như b.cs open_failed để chuyển tới mã xử lý lỗi
-
Hoạt động cơ bản của server
- Luồng cơ bản của web server là nhận request, xử lý rồi trả về mã trạng thái và file cần thiết
- Thiết lập socket gồm các bước như
socket(AF_INET, SOCK_STREAM, 0), setsockopt(... SO_REUSEADDR ...), bind(sockfd, &addr, 16), listen(sockfd, 5), accept(sockfd, NULL, NULL)
- ymawky là server fork-on-request, gọi
fork() cho mỗi kết nối mới
- Cách này không chia sẻ bộ nhớ giữa các lần xử lý request nên dễ hiểu và dễ triển khai, nhưng tốn tài nguyên hơn do không gian bộ nhớ theo tiến trình, và thông lượng kết nối đồng thời thấp hơn mô hình event-driven bất đồng bộ non-blocking của nginx
- Khi số kết nối đồng thời tăng lên, kernel sẽ dành nhiều thời gian hơn cho việc chuyển đổi tiến trình thay vì chạy mã bên trong từng tiến trình
-
Những việc cần làm khi xử lý request
- Xác định request dùng phương thức nào trong
GET, HEAD, OPTIONS, PUT, DELETE
- Trích xuất đường dẫn request và giải mã percent như
%20
- Kiểm tra an toàn đường dẫn và phân tích các header do client gửi lên
- Lấy thông tin file được yêu cầu rồi phân biệt đó là thư mục hay file thường
- Với request
PUT, ghi body vào file tạm và tạo header cùng body cho response
- Đóng các file đã mở và xử lý lỗi để server không bị crash
Tự triển khai HTTP parsing
-
Dòng request và kết thúc header
-
Trích xuất đường dẫn
- ymawky so sánh các phương thức được hỗ trợ và các byte đầu tiên để xác định loại request rồi mới trích xuất đường dẫn
- Nó quét header từng byte để tìm
/ hoặc *, nhưng để không nhầm / trong HTTP/1.0 là đường dẫn, nó kiểm tra xem byte ngay trước / có phải là dấu cách hay không
- Ví dụ, trong
GET HTTP/1.0\r\n\r\n có / bên trong HTTP/1.0, nên nếu byte trước đó không phải dấu cách thì phải trả về 400 Bad Request
- Vì trên hầu hết hệ thống
PATH_MAX là 4096 byte, ymawky dùng filename_buffer: .skip 4097 để có buffer tên file 4096 byte cộng thêm 1 byte cho ký tự kết thúc null
- Nếu đường dẫn request dài hơn buffer thì phải trả về
414 URI Too Long thay vì ghi đè bộ nhớ tùy ý
- Một thao tác gần tương đương
text.split("GET /")[1].split(" ")[0] trong Python, khi viết bằng assembly và kèm kiểm tra tính hợp lệ của HTTP, lại thành khoảng 200 dòng mã
-
Giải mã percent và kiểm tra header field
- Khi gặp
% trong đường dẫn, chương trình kiểm tra hai byte tiếp theo có phải là hex hợp lệ trong 0-9, a-f, A-F hay không, rồi chuyển thành giá trị byte tương ứng
GET có thể có header Range:, còn PUT thì bắt buộc cần Content-Length:
- Các header này không nằm ở vị trí cố định như URL trong request, nên phải duyệt toàn bộ header từng ký tự một
- Nếu sau
\r không có \n, hoặc xuất hiện \n mà không có \r trước đó, thì header bị coi là sai và trả về 400 Bad Request
- Nếu một dòng header mới bắt đầu bằng khoảng trắng thì cũng trả về
400 Bad Request, vì header field không được bắt đầu bằng khoảng trắng
-
So sánh chuỗi và chuyển đổi số
Xử lý PUT và chiến lược file tạm
PUT là phương thức idempotent, nghĩa là gửi cùng một request nhiều lần vẫn đưa server về cùng một trạng thái cuối cùng
PUT /file.txt sẽ tạo file.txt hoặc ghi đè hoàn toàn file cũ; gửi 1234 hai lần thì nội dung file vẫn là 1234, không phải 12341234
- Mở
PUT toàn cục có thể nguy hiểm, và các vấn đề cần cân nhắc khi xử lý gồm
- tiến trình bị crash trong lúc xử lý request
- client khai
Content-Length là 2KB nhưng chỉ gửi 100 byte
- client khai
Content-Length cực lớn như 50GB
MAX_BODY_SIZE trong config.S mặc định là 1GB; nếu Content-Length vượt quá mức này thì trả về 413 Content Too Large
- Nếu mở trực tiếp file hiện có để ghi, khi thất bại có thể để lại file bị ghi dở; vì vậy ymawky trước tiên ghi vào file tạm dạng
.ymawky_tmp_<pid>
- Nó lấy pid bằng syscall
getpid() số 20, rồi chuyển sang chuỗi bằng itoa() tự viết, đồng thời kiểm tra tràn buffer
- Sau khi ghi toàn bộ body của client vào file tạm và thành công, file tạm được đổi tên tại chỗ để trở thành file đích trên server
- Nếu client ngắt kết nối bất ngờ, hết thời gian chờ, hoặc gửi body không hợp lệ, file tạm sẽ bị xóa bằng syscall
unlink() số 10 hoặc unlinkat() số 472
- File hiện có chỉ bị ghi đè sau khi toàn bộ request hợp lệ đã được truyền thành công
Liệt kê thư mục và xử lý escape
Bảo mật mạng và timeout
An toàn hệ thống tệp
-
Thứ tự kiểm tra thông tin file
- Với
GET và HEAD, sau khi mở đường dẫn được yêu cầu, server gọi syscall fstat64() số 339 trên file descriptor để lấy loại file, kích thước và các thông tin khác
- Nếu trước tiên gọi syscall
stat64() số 338 trên path rồi mới mở file, có thể phát sinh TOCTOU race condition khi file thay đổi giữa thời điểm kiểm tra và thời điểm sử dụng
-
Docroot và chống path traversal
- Mọi đường dẫn request đều được gắn thêm docroot ở phía trước
- Docroot mặc định là
www/, tức DEFAULT_DIR trong config.S
- Request
/etc/shadow sẽ trở thành www/etc/shadow, nên nếu www/etc/shadow thực sự không tồn tại thì sẽ trả về 404
- Nhưng
/../../../../etc/shadow sẽ thành www/../../../../etc/shadow và có thể được diễn giải ra ngoài docroot, nên cần thêm biện pháp phòng vệ
- ymawky không đơn giản từ chối mọi đường dẫn có chứa chuỗi
.., mà chỉ từ chối khi một path segment chính xác là ..
- Vì
%2E%2E sau khi giải mã sẽ trở thành .., nên kiểm tra này phải được thực hiện sau bước giải mã percent
-
Xử lý symbolic link
- Cờ
O_NOFOLLOW của POSIX làm cho open() thất bại nếu thành phần cuối cùng của đường dẫn là symbolic link
O_NOFOLLOW_ANY của Darwin làm cho việc mở thất bại nếu bất kỳ thành phần nào trong đường dẫn là symbolic link
- Nếu ai đó đã có thể cài một symbolic link cụ thể vào bên trong docroot thì có lẽ hệ thống đã có vấn đề khác từ trước, nhưng cờ này vẫn là một lớp phòng vệ bổ sung
Hành vi riêng của Apple
-
Xử lý timeout và sigaction()
- Để triển khai timeout cho request, cần dùng syscall
setitimer() số 83 để gửi SIGALRM sau một khoảng thời gian nhất định
- Mặc định
SIGALRM sẽ giết child process, nhưng ymawky cần gửi 408 Request Timeout trước
- Vì vậy nó dùng syscall
sigaction() số 46
- Cấu trúc
sigaction thô của Darwin để lộ field sa_tramp
- Thông thường libc sẽ thiết lập
sa_tramp để lưu stack và thanh ghi, chuẩn bị sigreturn, rồi mới nhảy vào handler
- Nhưng timeout handler của ymawky chỉ cần gửi
408 Request Timeout, đóng những gì cần đóng rồi thoát child process, nên không cần quay lại
- Vì thế nó cho slot trampoline trỏ thẳng tới mã thực hiện response timeout, bỏ qua
sa_handler và sigreturn
-
proc_info() và giới hạn số child process
- Trên Apple có syscall
proc_info() số 336, ít được tài liệu hóa nhưng có thể lấy thông tin về tiến trình đang chạy và các child của nó
- Syscall này thường được dùng trong các công cụ như
ps, lsof, top
- ymawky dùng
proc_info() để đếm số child process đang hoạt động
- Vì số kết nối tối đa có thể cấu hình được, nó cần biết có bao nhiêu child còn sống
proc_info() ghi thông tin child process vào buffer, và do kích thước mỗi phần tử là đã biết, có thể tính số child từ tổng số byte đã ghi
- Nếu số child vượt quá
MAX_PROCS thì các kết nối mới sẽ bị từ chối với 503 Service Unavailable
Kết luận và thông tin dự án
- Phần khó của một web server tĩnh không phải là mở socket và listen, mà là phân tích request và xử lý mọi điều kiện biên
- Request, path và response đều chỉ là byte; range request phải chính xác, và tên file phải được escape khác nhau tùy vị trí sử dụng
- Assembly buộc người viết phải tự xử lý mọi thứ như parsing request, quản lý bộ nhớ, xử lý lỗi, chuyển đổi chuỗi, timeout và an toàn file
- ymawky được duy trì bởi imtomt
1 bình luận
Ý kiến trên Lobste.rs
Ghê thật. Trước đây tôi từng làm công việc tích hợp với một công ty nhỏ làm thiết bị thông minh, và kỹ sư duy nhất ở đó chỉ biết ngôn ngữ assembly
Từ mã điều khiển phần cứng, hệ điều hành máy chủ cho đến JSON Web API mà bên tôi dùng đều được viết thủ công hoàn toàn bằng assembly
Có lần chúng tôi gặp một lỗi khiến web API trả về dữ liệu của nhầm thiết bị, rồi hóa ra là do hệ thống lập lịch của hệ điều hành có lỗi off-by-one nên “cơ sở dữ liệu” đã trả sai hàng cho dịch vụ web
Khi đề cập đến những cách diễn đạt như “tự sát”, xin hãy gắn cảnh báo nội dung. Tốt hơn nữa là đừng nhắc tới ngay từ đầu
Sau khi đọc bình luận này tôi cũng quay lại tìm mà vẫn không thấy, có phải tôi đã bỏ sót gì không?
Nghe nói là “mọi thứ đều được viết bằng assembly” làm tôi nhớ tới báo cáo điều tra Therac-25