manhkuong.com

Tồn tại đã quá mệt rồi. Hãy sống đi.

Cấu hình đã ghi không có nghĩa là cấu hình có hiệu lực

Viết bởi

trong

Tôi bỏ mấy tuần dựng một hệ thống ghi chú cho riêng mình trên một máy chủ ảo. Ý tưởng đơn giản: ghi chú viết trên máy tính, tự đẩy lên server, tự dựng thành một trang web nhỏ chỉ mình tôi đọc được. Một cái “não thứ hai” tự chủ, không phụ thuộc dịch vụ của ai.

Phần khó không nằm ở chỗ tôi tưởng. Docker, chứng chỉ TLS, cấu hình proxy — mấy thứ tôi lo nhất — chỉ tốn thời gian chứ không gây đau. Thứ làm tôi mất nhiều giờ nhất, và một lần suýt gây hậu quả thật, đều rơi vào đúng một khuôn:

Tôi ghi cấu hình. Lệnh chạy xong không báo lỗi. File nằm đúng chỗ. Và hệ thống vẫn không làm điều tôi nghĩ nó đang làm.

Dưới đây là sáu lần như vậy. Tôi kể ra không phải để khoe đã sửa được, mà vì cả sáu đều có chung một cái gốc, và cái gốc đó đáng để nói hơn từng lỗi riêng lẻ.

1. Tắt đăng nhập mật khẩu mà vẫn đăng nhập được bằng mật khẩu

Việc đầu tiên khi có một máy chủ mới là chặn đăng nhập bằng mật khẩu, chỉ cho vào bằng khóa. Tôi sửa file cấu hình SSH, đặt PasswordAuthentication no, khởi động lại dịch vụ. Không lỗi.

Mật khẩu vẫn vào được.

Nguyên do: cấu hình SSH không nằm gọn trong một file. Nó gồm một file chính cộng với cả một thư mục con chứa các mảnh cấu hình, được đọc theo thứ tự tên file. Nhà cung cấp máy chủ đã cài sẵn ở đó một mảnh tên bắt đầu bằng 50-, trong đó bật lại đăng nhập mật khẩu. Mảnh của tôi tên bắt đầu bằng số lớn hơn, nên bị đọc sau — và trong SSH, giá trị được đọc trước mới là giá trị thắng.

Cách sửa mất đúng ba giây: đổi tên file của mình thành 00- để nó được đọc đầu tiên. Cách phát hiện mới là thứ đáng giữ lại — tôi thôi đọc file, và hỏi thẳng chính dịch vụ đó xem nó đang hiểu cấu hình của mình ra sao:

sshd -T | grep passwordauthentication

Lệnh này in ra cấu hình có hiệu lực sau khi đã gộp mọi file. Đọc file cấu hình là đọc ý định của tôi. Lệnh này đọc kết luận của máy.

Từ đó thành thói quen: mỗi khi sửa cấu hình một dịch vụ, tôi tìm xem dịch vụ đó có cách nào tự khai báo trạng thái thật không, và hỏi nó thay vì tin vào file mình vừa lưu.

2. Mã lỗi nói thật, nhưng không nói hết

Tôi tự dựng một máy chủ Git riêng để chứa mã nguồn và ghi chú. Để tạo kho chứa bằng lệnh, cần một token truy cập. Tôi tạo token, gọi thử, nhận về 403.

Phản xạ đầu tiên: token hỏng. Tôi xóa, tạo lại, thử lại. Vẫn 403. Tạo lần thứ ba. Vẫn thế. Đến lúc đó tôi mới chịu đọc kỹ hai mã lỗi này khác nhau chỗ nào:

  • 401 — máy chủ không biết bạn là ai. Token sai, token hết hạn, token gõ nhầm.
  • 403 — máy chủ biết chính xác bạn là ai, và đang từ chối. Token đúng, chỉ là thiếu quyền cho việc bạn đang làm.

Suốt thời gian đó token của tôi hoàn toàn bình thường. Nó chỉ thiếu đúng một ô tích quyền — và trớ trêu là ô cần tích không nằm ở nhóm quyền có tên nghe giống việc tôi làm nhất.

Ba mươi phút mất đi vì tôi đọc mã lỗi như một tín hiệu nhị phân “được / không được”, trong khi nó là một câu nói có nội dung cụ thể. 403 đã nói với tôi từ lần đầu rằng token đúng. Tôi chỉ không nghe.

3. Cái bẫy đắt nhất: khi thử nghiệm thất bại trông y hệt thử nghiệm thành công

Đây là cái tôi vẫn nghĩ lại nhiều nhất.

Trang ghi chú của tôi khóa bằng xác thực HTTP. Mật khẩu lưu dưới dạng băm bcrypt, và chuỗi băm đó luôn chứa nhiều dấu $. Vấn đề là tôi khai báo nó trong một file cấu hình mà ở đó dấu $ mang nghĩa “bắt đầu một biến” — muốn giữ nguyên thì phải viết thành $$. Viết sai thì chuỗi băm hỏng, mà chuỗi băm hỏng nghĩa là không mật khẩu nào vào được, kể cả mật khẩu đúng.

Bây giờ hãy nhìn cách tôi kiểm tra. Tôi gọi vào trang bằng dòng lệnh và mong nhận về 401 Unauthorized:

curl -s -o /dev/null -w "%{http_code}\n" https://<trang-cua-toi>/

Nếu cấu hình đúng: 401. Trang đã được khóa. Tốt.

Nếu chuỗi băm hỏng hoàn toàn: cũng 401. Vì với máy chủ, “sai mật khẩu” và “không có mật khẩu nào đúng cả” là cùng một câu trả lời.

Phép thử của tôi trả về kết quả giống hệt nhau ở cả trường hợp đúng lẫn trường hợp hỏng nặng nhất. Nó không có khả năng phân biệt. Nếu tôi dừng ở đó, tôi sẽ tự khóa mình khỏi chính trang của mình mà vẫn yên tâm là mọi thứ ổn — và chỉ phát hiện ra vào một hôm nào đó cần đọc gấp một ghi chú.

Tôi phải mở vào tận bên trong container, đọc lại chuỗi băm mà nó thực sự đang giữ, đếm đủ 60 ký tự và kiểm đúng tiền tố, mới dám kết luận.

Bài học ở đây không phải về bcrypt. Nó là câu hỏi phải hỏi trước mỗi phép thử:

Nếu thứ tôi đang kiểm tra hỏng hoàn toàn, phép thử này có trả về kết quả khác đi không?

Nếu câu trả lời là không, thì đó không phải một phép thử. Đó là một nghi thức trấn an.

4. Cửa sau mà tôi quên mất rằng nó là cửa

Khi khóa trang, tôi kiểm tra rất kỹ các trang HTML. Tất cả đều trả 401. Xong.

Chưa xong. Trang tĩnh kiểu này thường sinh kèm một file dữ liệu phục vụ ô tìm kiếm — và để tìm kiếm chạy được ngay trên trình duyệt, file đó chứa toàn văn mọi ghi chú. Một file duy nhất, không phải HTML, nằm ngoài tầm mắt tôi lúc kiểm tra. Chặn hết trang mà quên nó thì coi như không chặn gì.

Cùng loại: công cụ dựng trang có một plugin nhận diện vài định dạng file đặc biệt và tự dựng chúng thành trang, nhưng đi đường riêng, không qua bộ lọc của tôi. Bộ lọc chạy hoàn hảo với mọi file nó nhìn thấy. Nó chỉ không nhìn thấy hai định dạng đó.

Điểm chung: tôi kiểm tra loại đối tượng tôi nghĩ tới, chứ không kiểm tra mọi thứ hệ thống thực sự phát ra ngoài. Cách chữa duy nhất tôi tìm được là đảo hướng nhìn: thay vì hỏi “tôi đã chặn những gì”, đi liệt kê tất cả những gì máy chủ đang phục vụ, rồi soi từng cái.

5. Đêm suýt lộ — chặn theo nhãn chỉ bắt được thứ ta đã nhớ dán nhãn

Đây là lỗi thật, không phải bài học lý thuyết.

Kho ghi chú của tôi có cả thứ riêng tư liên quan đến công việc. Tôi bảo vệ nó bằng một quy tắc: note nào mang nhãn chủ đề công việc thì bị loại khỏi trang web, không ngoại lệ. Quy tắc này được một script thực thi, có kiểm tra tự động chặn ngay lúc đẩy dữ liệu lên.

Một tối, tôi quyết định nới phạm vi xuất bản: bỏ bộ lọc chung, chỉ giữ lại chặn cứng theo nhãn. Đổi cấu hình, dựng lại trang, rồi mới đi kiểm tra kết quả.

Trang nhảy từ 39 lên 271 trang.

Cơ chế chặn theo nhãn chạy đúng như thiết kế: nó loại chính xác những note mang nhãn. Vấn đề là toàn kho chỉ có một note duy nhất mang nhãn đó. Mọi thứ viết từ trước khi tôi nghĩ ra hệ thống nhãn thì không mang nhãn nào — trong đó có cả một trang mục lục dẫn tới đúng những ghi chú công việc tôi không bao giờ định cho ai xem.

Trang lúc đó đã nằm sau xác thực nên không phơi ra Internet. Nhưng nếu tôi làm hai việc này ngược thứ tự vài ngày — mở trang trước, nới bộ lọc sau — thì đã là chuyện khác hẳn.

Nguyên nhân gốc không phải kỹ thuật. Tôi tắt bộ lọc rồi mới đi xem hậu quả, trong khi đúng thứ tự là quét nội dung trước, đổi cấu hình sau. Tệ hơn: tôi đã biết trong kho có mấy chục note cũ từ trước hệ thống nhãn — chính tài liệu tôi tự viết có ghi điều đó. Tôi chỉ không nối được “có note cũ không nhãn” với “tắt bộ lọc nghĩa là xuất bản tất cả”.

Chặn theo nhãn chỉ bắt được thứ ta đã nhớ dán nhãn. Dữ liệu cũ không tự dán nhãn cho mình. Trước bất kỳ thao tác nào mở rộng phạm vi công khai: quét nội dung thật trước, đổi cấu hình sau. Không bao giờ ngược lại.

Còn một hệ quả tôi chỉ thấy sau đó, khi rà lại script: một khối lệnh có thể đang gánh nhiều việc cùng lúc. Gỡ bộ lọc, tôi gỡ luôn phần báo cáo sức khỏe đi nhờ trong cùng khối đó — mất âm thầm, không lỗi, không cảnh báo, chỉ là một báo cáo lặng lẽ không bao giờ được cập nhật nữa. Tôi chỉ tình cờ phát hiện ra trong một lần rà soát tài liệu chẳng liên quan gì. Trước khi xóa một khối, hãy đếm xem nó đang gánh mấy việc.

6. Sửa một file mà không ai đọc

Cái này mới xảy ra hôm qua, khi tôi làm giao diện cho chính trang web bạn đang đọc.

Trang chủ đang đổ toàn văn mọi bài viết thay vì chỉ hiện đoạn mở đầu. Tôi biết chính xác file nào quy định bố cục trang chủ trong loại giao diện này, sửa nó, đẩy lên, xóa cache, tải lại.

Không đổi gì. Kiểm tra file đã lên đúng chưa — đúng rồi. Kiểm tra quyền file — ổn. Đẩy lại lần nữa. Vẫn không đổi, đến mức trang tải về giống nhau đến từng byte.

Sự thật là hệ thống có hai file có thể dùng cho trang chủ, và khi cả hai cùng tồn tại thì file kia được ưu tiên. File tôi hì hục sửa chưa từng được đọc lấy một lần.

Tôi đã hỏi sai câu hỏi trong suốt hai mươi phút. Tôi hỏi “file đã lên chưa?” trong khi câu cần hỏi là:

File này có được đọc không?

Khi một thay đổi không tạo ra khác biệt nào, khả năng cao không phải thay đổi bị mất — mà là bạn đang sửa đúng thứ ở sai chỗ.

Thứ còn lại sau tất cả

Sáu câu chuyện, một cái gốc. Ở cả sáu lần, hệ thống chưa từng nói dối tôi. Nó chỉ trả lời đúng câu tôi hỏi, còn tôi thì hỏi sai câu.

Cái tôi mang ra khỏi dự án này không phải mấy lệnh vặt, mà bốn câu hỏi giờ thành phản xạ:

  1. Cấu hình này ai đang đọc? File tôi sửa có nằm trong đường đọc thật không, và có ai đó đọc trước nó rồi ghi đè không?
  2. Có cách nào hỏi thẳng dịch vụ về trạng thái có hiệu lực không? Đọc file là đọc ý định của tôi. Hỏi dịch vụ là đọc kết luận của nó.
  3. Nếu thứ này hỏng hoàn toàn, phép thử của tôi có trả kết quả khác đi không? Nếu không, phép thử đó vô giá trị.
  4. Cái chặn này dựa trên thứ tôi phải nhớ làm, hay dựa trên thứ hệ thống tự thấy? Bảo vệ dựa vào trí nhớ thì hỏng đúng vào ngày ta quên.

Và một điều cuối, thiên về thái độ hơn kỹ thuật. Suốt dự án tôi ghi mọi cái bẫy vào một file duy nhất, kèm cách xử lý. Lúc ghi thấy rườm rà. Nhưng chính cái bảng đó, chứ không phải trí nhớ, là thứ giữ tôi khỏi mắc lại lần thứ hai — và là toàn bộ tư liệu của bài viết này.

Một hệ thống ghi chú tốt không phải chỗ cất thông tin. Nó là chỗ để những sai lầm của mình không bị lãng phí.

Câu hỏi tôi chưa trả lời được

Bốn câu hỏi ở trên đều là thứ phải nhớ mà hỏi — mà theo đúng bài học ở mục 5, bảo vệ dựa vào trí nhớ thì sớm muộn cũng hỏng. Tôi tự động hóa được một phần: có script kiểm tra, có bước chặn ngay lúc đẩy dữ liệu lên, có phép thử tự dừng nếu trang mất lớp khóa.

Nhưng lớp tự động ấy vẫn còn một lỗ tôi biết mà chưa bịt: khi tôi ghi chú từ điện thoại, công cụ trên đó không chạy được bước kiểm tra. Một note vi phạm quy tắc đẩy đi từ điện thoại sẽ lọt qua.

Cái lưới thứ hai — một lượt kiểm tra định kỳ chạy độc lập ở nơi khác — thì tôi vẫn chưa dựng. Nó nằm trong danh sách việc, và tôi đang tự nhắc mình rằng “biết mình có lỗ hổng” chưa bao giờ là cùng một chuyện với “đã bịt nó”.