Chuyện cái SSL lặng lẽ chết trên NAS

Hôm nay đẹp trời, viết về một cái vui vẻ mà Hùng gặp phải. Chuyện là cái NAS ở nhà tôi chặn toàn bộ kết nối từ nước ngoài. Chỉ IP Việt Nam mới gõ cửa được. Tôi setup cái tường lửa đó từ lâu rồi, chặn từ Router và tường lửa trên NAS, và suốt nhiều tháng nó chạy êm ru, đúng như tôi muốn.

Cho tới sáng hôm nay, trình duyệt bung ra một trang đỏ lòm. Chứng chỉ hết hạn. Không phải hôm nay, mà từ hai mươi chín ngày trước.

Hai mươi chín ngày. Cái oải không nằm ở chỗ nó hỏng, mà ở chỗ nó hỏng từ gần một tháng trước và không ai nói gì. Let's Encrypt gửi mail nhắc trước khi hết hạn, nhưng tôi tắt cái hộp thư đó lâu rồi. Cert cứ lặng lẽ chết. Còn cái cơ chế đáng ra phải tự gia hạn thì cũng lặng lẽ thất bại, mỗi tháng một lần, suốt ba tháng, không kêu lên một tiếng.

Cert chết, nhưng chết vì cái gì

Phản xạ đầu tiên của tôi là mở DSM lên bấm Renew. Nó quay một lúc rồi báo lỗi chung chung. Không nói vì sao.

Nên tôi làm cái việc đáng ra phải làm đầu tiên: đi hỏi từng lớp một.

DNS ổn. Domain trỏ đúng IP nhà tôi. Cổng 80 mở, curl vào thấy nginx trả về 301 chuyển sang HTTPS, đúng như cấu hình. Đường /.well-known/acme-challenge/ - chỗ Let's Encrypt sẽ đến lấy mã xác thực - cũng thông. Tôi thử một token giả, nó trả 404 chứ không đá sang HTTPS, nghĩa là nginx có sẵn location block chuẩn cho ACME. Cấu hình không sai một chữ nào.

Mọi thứ đều xanh. Mà cert vẫn không gia hạn được. Chỗ này là chỗ dễ đi lạc nhất, và vì tôi đang ngồi trong nhà, và tôi đang test cái cửa từ bên trong nhà, local.

Timeout không giống refused

Tôi gọi một dịch vụ bên ngoài, bảo nó fetch giùm cái URL của tôi. Nó trả về: timeout. Để chắc không phải dịch vụ đó hỏng, tôi bảo nó fetch example.com. Trả về 200, ngon lành.

Đây là chỗ đáng dừng lại một nhịp. Timeout và "connection refused" là hai câu chuyện hoàn toàn khác nhau. Refused nghĩa là gói tin tới nơi, có người trả lời "cổng này đóng". Timeout nghĩa là gói tin bay đi và không bao giờ có hồi âm - ai đó nuốt nó đi trong im lặng. Cổng đóng thì gửi RST. Firewall với luật DROP thì không gửi gì cả.

Cái tôi đang nhìn là DROP. Là firewall. Không phải cấu hình sai.

Để chắc, tôi đẩy lên hai mươi node ở hai mươi nơi trên thế giới, cùng lúc, cùng một cổng 80. Mười chín cái timeout. Một cái ở Kyiv báo kết nối được, 48 mili giây.

Bốn mươi tám mili giây từ Kyiv về Việt Nam. Khoảng cách vật lý gần tám nghìn cây số, ánh sáng trong sợi quang đi hết chừng đó mất tối thiểu chín mươi mili giây khứ hồi. Con số 48 là không thể. Cái node đó không thèm nói chuyện với con NAS của tôi - nó nói chuyện với một proxy trong suốt nào đó của nhà mạng sở tại.

Nói cách khác: không phải mười chín trên hai mươi. Là hai mươi trên hai mươi.

Rồi tôi tìm được một node đặt ở Sài Gòn. Cho nó thử.

Vào được. 46 mili giây.

Vậy là xong phần chẩn đoán. Cái tường lửa tôi tự tay bật lên hồi trước đang làm đúng việc của nó. Quá đúng.

Vì sao HTTP-01 không bao giờ cứu được anh

Cách xác thực mặc định của Let's Encrypt tên là HTTP-01. Nguyên tắc đơn giản: anh nói anh sở hữu cái domain này, vậy thì đặt một file có nội dung tôi chỉ định lên máy chủ đó, tôi sẽ ghé qua đọc. Đọc được thì tin, còn không thì thôi.

Chữ quan trọng nhất trong câu đó là ghé qua. Let's Encrypt phải đi tới chỗ anh.

Mà nó không đi từ một chỗ. Từ 2020 Let's Encrypt dùng cái gọi là xác thực đa góc nhìn - cùng lúc gõ cửa từ nhiều điểm ở nhiều nơi khác nhau trên thế giới, để không ai giả mạo được bằng cách chiếm quyền định tuyến ở một khu vực. Đó là một thiết kế bảo mật tốt. Nó cũng có nghĩa là anh không thể chặn ai cả.

Và họ cố tình không công bố dải IP của mấy điểm đó. Đây không phải thiếu sót, đó là chủ ý. Nếu công bố, kẻ tấn công biết cần chiếm đường nào. Nên chuyện "mở firewall cho riêng IP của Let's Encrypt" là không tồn tại. Không có danh sách nào để mở cả.

Ghép hai điều đó lại: NAS chỉ cho IP Việt Nam vào, còn Let's Encrypt gõ cửa từ Mỹ và châu Âu. HTTP-01 không phải đang hỏng. Nó không bao giờ chạy được, chừng nào cái luật chặn kia còn đó.

Ba tháng nó fail âm thầm. Bây giờ thì tôi hiểu vì sao.

Tới đây có hai đường. Mở cổng 80 ra cho cả thế giới, gia hạn xong đóng lại - lặp lại mỗi sáu chục ngày, và mỗi lần mở là một lần phơi cái NAS ra internet. Hoặc chọn cách không cần mở cửa.

DNS-01: chứng minh mà không cần mở cửa

Vụ DNS-01 này đã lật ngược vấn đề. Thay vì bắt anh đặt file lên máy chủ để người ta tới đọc, nó bảo anh tạo một bản ghi TXT trên DNS của domain. Let's Encrypt tra bản ghi đó qua hệ thống DNS công cộng.

Khác biệt nằm ở hướng đi. Không ai cần kết nối vào máy anh cả. Máy anh gọi ra tới API của nhà cung cấp DNS, tạo bản ghi, rồi Let's Encrypt tra cứu bản ghi đó ở nơi khác hoàn toàn. Cổng 80 có thể đóng chặt. Firewall giữ nguyên. NAS vẫn nằm sau bảy lớp khoá.

Domain của tôi để DNS ở Cloudflare, có API, thế là đủ. Hầu hết nhà cung cấp DNS tử tế bây giờ đều có API và đều được acme.sh hỗ trợ sẵn — thư mục dnsapi/ trong đó có hơn trăm cái plugin.

Một chỗ cần nói rõ: Let's Encrypt tích hợp sẵn trong DSM chỉ làm được HTTP-01. Cái giao diện bấm-là-xong trong Control Panel không có lựa chọn DNS-01. Muốn đi đường này thì phải SSH vào và dùng acme.sh. Không có cách nào tránh.

Và đây là lúc mọi thứ bắt đầu thú vị.

Bốn cái bẫy dễ gặp trên đường đi

Tôi cứ tưởng phần còn lại là chép lệnh từ trang chủ acme.sh về dán. Không phải. DSM là một hệ Linux bị cắt gọt khá bạo, và mỗi chỗ bị cắt là một cái bẫy.

Bẫy thứ nhất: /tmp không chạy được file.

Lệnh cài chính thức của acme.sh tải một cái tarball về, giải nén, rồi chạy ./acme.sh ngay tại thư mục hiện hành. Tôi để nó chạy trong /tmp như phản xạ.

main: line 8154: ./acme.sh: Permission denied
Install error

/tmp trên DSM là tmpfs mount kèm cờ noexec. Không file nào trong đó được phép chạy, bất kể quyền có x hay không. Kiểm tra bằng mount | grep /tmp là thấy ngay.

Thú vị hơn: sh /tmp/file.sh thì lại chạy được, vì lúc đó sh chỉ đang đọc file chứ không thực thi nó. Cờ noexec chặn execve, không chặn việc đọc. Nhưng cái installer gọi ./acme.sh trực tiếp, nên không lách được.

Cách sửa nó là dựng ở một volume dữ liệu thay vì /tmp. Và nên kiểm tra chỗ đó thật sự chạy được trước khi tải gì về:

printf '#!/bin/sh\nexit 0\n' > ._probe.sh && chmod +x ._probe.sh
./._probe.sh || { echo "Chỗ này noexec, đổi chỗ khác"; exit 1; }
rm -f ._probe.sh

Ba dòng, nhưng nó biến một lỗi khó hiểu thành một câu mà loài người hiểu được.

Bẫy thứ hai: installer báo thành công trong khi vừa thất bại.

Cái này mới là chỗ làm tôi mất thời gian nhất. Script bọc ngoài của tôi in ra [OK] Đã cài xong - trong khi ngay bên trên nó, installer đã in Install error.

Vì cái installer thoát với mã 0. Nó fail, nó nói nó fail bằng chữ, nhưng nó trả về mã "thành công". set -e không bắt được. Script tôi tỉnh bơ chạy tiếp, tới lúc gọi acme.sh mới lòi ra No such file or directory.

Bài học: đừng tin exit code, hãy kiểm tra kết quả thật.

./acme.sh --install --home "$ACME_HOME" ...
if [ ! -x "$ACME_HOME/acme.sh" ]; then
  echo "Cài thất bại"; exit 1
fi

Hỏi "cái file tôi cần có nằm đó không" luôn đáng tin hơn hỏi "lệnh vừa rồi có ổn không".

Bẫy thứ ba: -- bị nuốt.

Unknown parameter: ----home

Bốn dấu gạch. Tôi viết sh -s -- --home /đường/dẫn, và cái wrapper get.acme.sh nối luôn -- với --home thành ----home.

Cái wrapper đó nhận tham số kiểu email=abc@xyz.com, không phải kiểu --cờ giá-trị. Tôi đọc lướt tài liệu rồi viết theo thói quen.

Cách thoát đơn giản là bỏ hẳn wrapper. Tải tarball, giải nén, gọi thẳng - đúng cái mà wrapper làm bên trong, nhưng không qua tầng phân tích tham số của nó:

curl -sL https://github.com/acmesh-official/acme.sh/archive/master.tar.gz -o m.tar.gz
tar xzf m.tar.gz && cd acme.sh-master && chmod +x acme.sh
./acme.sh --install --home /usr/local/share/acme.sh --accountemail anh@domain.com

Bẫy thứ tư: DSM không có crontab.

It is recommended to install crontab first.
Pre-check failed, cannot install.

Tôi tưởng lỗi giả. Đi tìm thì hoá ra thật: DSM 7.4 không có binary crontab nào cả. Không /usr/bin/crontab, không /var/spool/cron/crontabs. Chỉ có mỗi file /etc/crontab do DSM tự quản lý.

acme.sh chặn không cho cài vì nó không có cách nào đặt lịch tự gia hạn — mà cert không tự gia hạn thì cài làm gì. Nó cẩn thận, và nó đúng.

Thêm --nocron --force là qua. Nhưng qua rồi thì phải tự lo phần lịch, bằng Task Scheduler của DSM: Control Panel → Task Scheduler → Create → Scheduled Task → User-defined script, user root, chạy hằng ngày:

/usr/local/share/acme.sh/acme.sh --home /usr/local/share/acme.sh --cron

Chạy hằng ngày thì không phải xin cert mỗi ngày. --cron tự kiểm tra và chỉ thực sự gia hạn khi cert còn dưới ba mươi ngày. Đây thật ra là cách Synology khuyến nghị, vì DSM có thể ghi đè /etc/crontab sau mỗi lần cập nhật hệ điều hành, task trong Task Scheduler thì sống sót.

Đừng bỏ qua bước này. Nếu bỏ, mọi thứ vẫn chạy tốt ngày hôm nay, và hỏng lại đúng chín mươi ngày sau. Chín mươi ngày là đủ lâu để ta quên sạch mình đã làm gì.

Cái lỗi tôi suýt không thấy

Qua hết bốn cái trên, cert được cấp. Tôi kiểm tra file trên đĩa: có, Let's Encrypt ký, hạn ba tháng nữa. Log deploy báo thành công. DSM API trả HTTP 200.

Mở trình duyệt. Vẫn cert cũ. Vẫn hết hạn.

Mọi thứ báo xanh mà kết quả thì sai, đây là loại lỗi khó chịu nhất.

Câu trả lời nằm trong cách cái hook deploy của Synology tìm chứng chỉ. Nó không tìm theo tên miền. Nó tìm theo Description - cái ô mô tả tôi gõ tay trong Control Panel:

id = tìm cert có "desc" trùng đúng $SYNO_Certificate
nếu không thấy, mà SYNO_Create được bật  →  tạo cert MỚI
nếu thấy                                  →  ghi đè cert đó tại chỗ

Tôi để trống ô Description. Nó không tìm thấy cert nào có mô tả rỗng, nên nó tạo một cert mới. Cert mới tinh, hợp lệ, nằm im trong danh sách, và không được gán cho bất kỳ dịch vụ nào. DSM vẫn phục vụ cert cũ.

Sửa thì dễ: điền đúng Description của cert đang được dùng, nó sẽ thay ruột tại chỗ và mọi ràng buộc dịch vụ giữ nguyên.

Nhưng có một hệ quả đằng sau mà tôi chỉ thấy sau khi ngồi đọc mã nguồn cái hook. Giá trị Description đó được lưu lại cho những lần gia hạn sau. Nghĩa là nếu sau này anh vào DSM đổi tên mô tả của cert, mà quên cập nhật cấu hình acme.sh, thì lần gia hạn kế tiếp sẽ không khớp được nữa — và nó lại đẻ ra một cert mới nằm im. Hỏng lại y hệt, ba tháng sau, với một nguyên nhân chẳng liên quan gì tới cái anh vừa sửa.

Hai thứ đó phải đi cùng nhau. Đổi một cái thì phải đổi cái kia.

Và một bài học không liên quan gì tới SSL

Lúc chẩn đoán, tôi viết một script đổ hết cấu hình acme.sh ra màn hình để đọc cho tiện. Có lọc — tôi che token Cloudflare, che khoá API, che email.

Tôi quên SAVED_SYNO_PASSWORD.

Cái file .conf của acme.sh lưu mật khẩu DSM ở dạng base64. Base64 không phải mã hoá. Nó là cách viết lại cho máy dễ nuốt, và giải ra mất đúng một giây. Cái script tôi viết để gỡ rối vừa in trọn mật khẩu quản trị NAS ra màn hình.

Bên cạnh đó còn SAVED_SYNO_DEVICE_ID - token cho phép bỏ qua xác thực hai lớp ở những lần sau. Cũng lộ.

Kết cục là đổi mật khẩu, thu hồi thiết bị đã đăng nhập, làm lại từ đầu.

Bài học không phải "nhớ che mật khẩu". Ai cũng biết điều đó. Bài học là danh sách những thứ cần che luôn dài hơn anh tưởng, và cách viết bộ lọc theo kiểu liệt kê từng cái tên là sai ngay từ thiết kế, vì nó chỉ che được những cái ta nghĩ ra. Cái nguy hiểm luôn là cái ta không nghĩ ra.

Nếu phải làm lại, tôi sẽ không liệt kê cái cần che. Tôi sẽ liệt kê cái được phép hiện, và giấu tất cả phần còn lại.

Nhìn lại

Cert giờ chạy, chuỗi tin cậy hợp lệ, tự gia hạn đã có lịch. Firewall vẫn chặn cả thế giới y như cũ, đó vốn là điều tôi muốn, và tôi không phải đánh đổi nó lấy cái gì.

Nhưng thứ đáng nhớ nhất trong cả buổi chiều hôm đó không phải mấy dòng lệnh.

Cái geo-block kia là một quyết định bảo mật tốt. Tôi không hối hận vì đã bật nó. Chuyện là một lớp phòng thủ tốt vẫn có thể cắt đứt một thứ mà anh không nghĩ nó có liên quan - ở đây là cái cơ chế tự động phải nói chuyện được với thế giới bên ngoài để giữ cho anh an toàn. Tôi khoá cửa kỹ tới mức người phát chìa khoá mới không vào nổi.

Và nó fail âm thầm suốt ba tháng. Không cảnh báo, không mail, không gì cả. Nếu tôi có một cái theo dõi ngày hết hạn của cert, một dòng cron gọi openssl s_client mỗi tuần, gửi tin nhắn khi còn dưới hai mươi ngày, thì tôi đã biết từ tháng Năm, và sửa nó trong mười phút thay vì cả buổi chiều.

Cái đó thì tôi làm ngay sau khi viết xong bài này.


Bài này viết cho Synology DSM 7.4, acme.sh v3.1.5 và Cloudflare DNS. Nguyên lý thì áp dụng được cho mọi tổ hợp NAS và nhà cung cấp DNS có API - chỉ đổi cái plugin trong dnsapi/.