Skip to main content

1. Fail2Ban: Giải Phẫu Cơ Chế Hoạt Động Bên Trong

Fail2Ban là công cụ mà rất nhiều người dùng hàng ngày — cài, viết vài dòng jail.conf, thấy log báo "Ban 1.2.3.4" là yên tâm. Nhưng ít ai mở source code ra xem thật sự bên trong nó chạy như thế nào: vì sao có 2 lệnh fail2ban-clientfail2ban-server riêng biệt? Vì sao log bị xoay vòng (logrotate) mà fail2ban không đọc nhầm hay đọc sót? Cái gọi là "ticket" thực chất là gì? Và vì sao thiết kế cũ kỹ từ 2004 vẫn còn sống tốt đến tận 2026?

Tài liệu này trả lời đúng những câu hỏi đó — bằng cách đọc thẳng vào mã nguồn, không phải đoán hay suy diễn từ tài liệu người dùng. Toàn bộ nội dung được đọc trực tiếp từ github.com/fail2ban/fail2ban tại commit deffcbcd1712 (2026-08-22, bản 1.1.2.dev1), cộng thêm một vòng research để verify các claim so sánh ở cuối bài. Mọi khẳng định kỹ thuật đều kèm theo đường dẫn file và tên hàm/class cụ thể trong source — có thể tự mở ra đối chiếu trực tiếp.

Bạn không cần biết trước fail2ban là gì để đọc bài này — mọi khái niệm (jail, filter, action, ban) sẽ được giới thiệu ngay khi lần đầu xuất hiện. Nhưng đây không phải bài hướng dẫn cài đặt hay viết jail.conf — nó đi thẳng vào cơ chế bên dưới lớp cấu hình đó.

Mục lục


1. Mô hình tiến trình: client, server và luồng

Vì sao lại tách ra 2 binary?

Đây là điều đầu tiên đập vào mắt bất kỳ ai gõ fail2ban-client status: sao không gộp luôn vào 1 chương trình cho gọn? Câu trả lời nằm ở vai trò rất khác nhau của 2 tiến trình này.

fail2ban-server là một daemon sống dài hạn — nó phải chạy liên tục, giữ trong bộ nhớ toàn bộ trạng thái: bao nhiêu jail đang bật, IP nào đang bị đếm fail, IP nào đang bị ban, các regex đã compile sẵn. Nếu tiến trình này chết hoặc khởi động lại, toàn bộ ngữ cảnh đó mất (trừ phần đã lưu xuống database, xem mục 6).

fail2ban-client thì ngược lại hoàn toàn — nó là một tiến trình ngắn hạn, chỉ sống đúng thời gian thực hiện 1 lệnh. Mỗi lần gõ fail2ban-client status sshd là một tiến trình mới được tạo ra, kết nối tới server, gửi đúng 1 gói lệnh, nhận phản hồi, rồi thoát (fail2banclient.py, hàm __processCmd).

💡 Hình dung như thế này: server giống nhân viên trực tổng đài ngồi cố định cả ca, còn client giống một cuộc gọi ngắn tới tổng đài đó — gọi, hỏi 1 câu, cúp máy. Bạn không muốn "nhân viên tổng đài" phải khởi động lại mỗi lần có người gọi tới, và cũng không cần một cuộc gọi phải ngồi chờ nguyên ca làm việc.

Tách riêng như vậy mang lại lợi ích rất thực tế: fail2ban-client có thể được gọi từ cron, từ systemd unit, từ script bất kỳ — mà không cần biết gì về nội bộ jail đang chạy ra sao. Nó thuần túy là một client RPC. Và server thì không phải build lại toàn bộ trạng thái (bộ đếm fail, regex đã compile) mỗi lần có ai đó gõ 1 lệnh CLI.

Khi gõ fail2ban-client start, có 2 khả năng xảy ra (client/fail2banclient.py, hàm __startServer):

  • Chế độ nền (mặc định): client fork() ra một tiến trình fail2ban-server --async biệt lập (client/fail2banserver.py), rồi gửi cấu hình đã đọc được qua socket ngay khi server đó sẵn sàng.
  • Chế độ foreground: server chạy trực tiếp trong cùng tiến trình (hàm startServerDirect), còn việc giao tiếp client/server diễn ra trên các thread khác nhau trong cùng 1 process — cách này chủ yếu dùng cho test suite của chính fail2ban.

Giao thức bắt tay qua socket

AsyncServer (server/asyncserver.py) là một asyncore.dispatcher lắng nghe trên Unix domain socket (mặc định /var/run/fail2ban/fail2ban.sock, có hỗ trợ cả LISTEN_FDS để tích hợp socket-activation của systemd). Mỗi kết nối tới được bọc thành một RequestHandler (kế thừa asynchat.async_chat): gom byte cho tới khi gặp ký tự kết thúc cố định, giải mã (unpickle) gói tin, đưa cho Transmitter xử lý, rồi mã hóa (pickle) kết quả gửi ngược lại.

Điểm đáng chú ý nhất ở đây: đây là một socket server đơn luồng, hướng sự kiện (asyncore.poll() bên trong một vòng lặp tùy biến riêng) — không phải mô hình 1 thread cho mỗi kết nối. Đây là lựa chọn có chủ đích: mỗi kết nối client chỉ gửi 1 gói lệnh rồi đóng ngay, nên việc cấp hẳn 1 thread riêng cho mỗi kết nối là lãng phí không cần thiết. Socket server chạy trên đúng thread đã gọi Server.start() — thường là main thread sau khi daemon hóa.

💡 Hình dung như thế này: giống một quầy lễ tân chỉ có 1 người, xử lý khách lần lượt — vì mỗi khách chỉ hỏi đúng 1 câu rồi đi ngay, không có lý do gì phải mở thêm 10 quầy chờ sẵn.

fail2ban-client và fail2ban-server giao tiếp qua Unix socketfail2ban-clientFail2banCmdLine + Threadkết nối, gửi 1 lệnh,nhận kết quả rồi thoátstatus / ban / unban / reloadfail2ban-serverclass Server (daemon)giữ toàn bộ jail + DBtrong bộ nhớ, sống lâu dàiasyncore.dispatcher (đơn luồng)Unix socket① command list (pickled) →② reply (pickled) ←mặc định: /var/run/fail2ban/fail2ban.sock

Mô hình luồng: mỗi jail chạy 2 thread

"Jail" là đơn vị nhỏ nhất fail2ban quản lý — mỗi jail gắn với 1 dịch vụ cần bảo vệ (vd sshd, nginx-404), có filter riêng (quy tắc nhận diện tấn công) và action riêng (làm gì khi phát hiện). Điều thú vị là mỗi jail không chạy trên 1 thread, mà đúng 2 thread, xác nhận tại server/jail.py (hàm Jail.start):

  • một filter thread (Filter.run, tùy backend: FilterPoll, FilterPyinotify, hoặc FilterSystemd) — chuyên đọc log và đếm số lần thất bại,
  • một actions thread (Actions.run, server/actions.py) — chuyên rút hàng đợi ticket cần ban, quyết định ban/unban, và gọi script trong action.d.

Tách 2 việc này ra 2 thread riêng có lý do rõ ràng: đọc log là việc I/O-bound liên tục (đợi dòng mới), còn thực thi action là việc có thể bị block lâu (gọi subprocess iptables, đợi timeout). Nếu gộp chung 1 thread, một lệnh iptables chạy chậm sẽ làm nghẽn luôn việc đọc log mới.

Vậy với N jail, server chạy khoảng 2N + hằng số thread: 2N thread jail, cộng thêm 1 ObserverThread dùng chung cho toàn server (server/observer.py, singleton tại Observers.Main) — chuyên tính toán bantime tăng dần và ghi database ở nền, không chặn đường đi chính — cộng với main thread chạy vòng lặp socket. Bản thân Jails (server/jails.py) chỉ là 1 dict có khóa (lock) bảo vệ — việc thêm/xóa/tra jail được đồng bộ hóa, nhưng trạng thái bên trong mỗi jail thì không (docstring của class này ghi thẳng: "This class is thread-safe which is not the case of the jail itself").

Mô hình luồng: N jail → 2N thread + 1 observer chungJail: sshdFilter threadđọc log, đếm fail (FailManager)Actions threadquyết định ban/unban, gọi action.dJail: nginx-404Filter threadđọc log, đếm fail (FailManager)Actions threadquyết định ban/unban, gọi action.dObserverThread (dùng chung)tính bantime increment, ghi DB nềnMain threadasyncore socket loop (đơn luồng)

2. Cơ chế theo dõi log

Fail2ban không thể "chờ" service ghi log rồi báo cho nó biết — nó phải chủ động theo dõi file log thay đổi. Có 3 backend để làm việc này, được thử theo thứ tự ['pyinotify', 'polling', 'systemd'] khi cấu hình backend = auto (server/jail.py, hàm _setBackend), rơi xuống backend tiếp theo nếu backend hiện tại thiếu thư viện (ImportError).

Polling (server/filterpoll.py): đơn giản nhất — mỗi chu kỳ gọi os.stat() lên từng file đang theo dõi, so sánh (mtime, inode, size) với lần trước (hàm isModified). Portable, chạy được ở mọi nơi, nhưng tốn O(số file) lệnh stat() mỗi tick, độ trễ phụ thuộc vào sleeptime cấu hình.

pyinotify (server/filterpyinotify.py): dùng kernel event thay vì polling — theo dõi cả file log (IN_MODIFY) lẫn thư mục cha chứa nó (IN_CREATE, IN_MOVED_TO, IN_MOVE_SELF, IN_DELETE_SELF). Theo dõi luôn thư mục cha chính là mẹo để sống sót qua logrotate: khi logrotate chạy mv access.log access.log.1; touch access.log, watch gắn theo inode cũ trở nên vô nghĩa, nhưng watch gắn theo thư mục sẽ bắt được sự kiện IN_CREATE/IN_MOVED_TO cho file mới, kích hoạt gắn lại watch vào inode mới (hàm _refreshWatcher).

Cơ chế phát hiện rotation — phần lõi thật sự

Nhưng phần thú vị nhất không nằm ở pyinotify hay polling — nó nằm ở lớp FileContainer (server/filter.py), dùng chung cho cả 3 backend. Đây mới là cơ chế thật sự chống lại việc đọc sót hoặc đọc trùng dòng log khi file bị xoay vòng.

Cách nó hoạt động: lúc khởi tạo, FileContainer băm MD5 dòng đầu tiên của file và ghi nhớ luôn số inode. Mỗi lần open() để đọc tiếp, nó tự hỏi 3 câu:

  1. Kích thước file có nhỏ hơn vị trí đã đọc trước đó không? (dấu hiệu file bị truncate/ghi đè)
  2. Số inode có khác số đã ghi nhớ không? (dấu hiệu file đã bị mv/tạo lại)
  3. Hash dòng đầu có đổi khác không? (băm lại tối đa mỗi 30 giây để không tốn chi phí đọc thêm 1 dòng mỗi lần)

Chỉ cần 1 trong 3 điều kiện đúng, nó kết luận đã xảy ra rotation — và reset vị trí đọc về 0, bắt đầu đọc lại từ đầu file mới, thay vì tiếp tục đọc từ một offset byte đã thuộc về file khác.

💡 Hình dung như thế này: giống việc cầm 1 cuốn sổ tay đọc dở đến trang 50. Lần sau mở lại, nếu thấy sổ chỉ còn 10 trang (nhỏ hơn trang 50 đã đọc), hoặc bìa sổ khác hẳn (đổi "inode"), hoặc dòng đầu tiên của sổ không còn giống câu đã nhớ (đổi "hash") — biết ngay đây là cuốn sổ mới, phải đọc lại từ trang 1, chứ không lật thẳng tới trang 50 của sổ cũ.

Vì sao phải kiểm tra cả 3 chứ không dùng 1 tín hiệu là đủ? Vì mỗi tín hiệu chỉ bắt được 1 kiểu rotation:

  • Chỉ dựa vào kích thước sẽ bỏ lỡ trường hợp file bị mv sang tên khác rồi tạo file mới cùng kích thước ban đầu (kích thước không nhỏ đi ngay).
  • Chỉ dựa vào inode sẽ bỏ lỡ trường hợp một số công cụ rotate log tái sử dụng lại đúng inode cũ (tùy filesystem).
  • Riêng hash dòng đầu bắt được cả 2 trường hợp trên, vì dòng đầu của file mới gần như chắc chắn khác dòng đầu của file cũ.

Và vì pos chỉ bị reset khi rotation thật sự được phát hiện (không phải reset mỗi lần open()), nên không có chuyện đọc trùng dòng.

FileContainer.open(): phát hiện log rotation bằng 3 tín hiệuKích thước filesize < pos đã đọc?Inodest_ino khác cũ?Hash dòng đầuMD5 đổi khác?rotflg = TRUE nếu ≥1 đúng(size / inode / hash — bất kỳ tín hiệu nào)CÓ rotationreset pos = 0đọc lại từ đầu file MỚIKHÔNG rotationgiữ nguyên posđọc tiếp đúng chỗ cũtruefalse

Đọc dòng chưa ghi xong. FileContainer.readline() đọc byte thô tới ký tự \n; nếu dòng đọc được không kết thúc bằng \r\n (tức tiến trình ghi log chưa flush xong dòng đó), nó trả về Noneseek ngược lại vị trí đọc — để lần poll sau đọc lại đúng dòng đó từ đầu, thay vì cắt dòng làm đôi.

Backend systemd journal (server/filtersystemd.py) hoạt động khác hẳn — dựa trên cursor thay vì vị trí byte trong file. Nó gọi journal.seek_tail() lúc khởi động để xác lập mốc "bắt đầu vận hành", rồi đọc tiếp theo cursor riêng của journal — nên hoàn toàn miễn nhiễm với khái niệm rotation/rename của file thông thường (journal không có khái niệm đó). (Đây là suy luận từ hình dạng API, tôi không lần sâu vào nội bộ module systemd.journal.)

3. Bộ máy regex: filter hoạt động ra sao

Tag <HOST> không phải phép màu — nó là 1 khối regex dựng sẵn

Khi viết failregex = Failed password for .* from <HOST>, <HOST> không phải cú pháp regex đặc biệt của Python — nó là tag riêng của fail2ban, được thay thế bằng regex thật trước khi re.compile() chạy. Việc thay thế này diễn ra ở Regex._resolveHostTag (server/failregex.py): quét chuỗi failregex tìm token dạng <TÊN>, rồi thế bằng đoạn regex tương ứng.

<HOST> cụ thể mở rộng thành:

(?:địa chỉ IPv4/IPv6 | tên host dạng chữ)

— một phép hoặc (OR) giữa nhánh địa chỉ IP và nhánh hostname dạng chữ, mỗi nhánh ghi vào 1 capture group riêng (ip4, ip6, dns). Nếu đặt usedns = no, <HOST> chỉ còn mở rộng ra nhánh địa chỉ IP — nghĩa là việc "tắt reverse DNS" được ép ở tầng compile regex, không phải lọc lại sau: một hostname xuất hiện trong log đơn giản là sẽ không khớp gì cả.

💡 Hình dung như thế này: <HOST> giống 1 mẫu điền sẵn (fill-in-the-blank template) dùng chung cho mọi filter — không phải tự viết lại regex địa chỉ IP dài dằng dặc mỗi lần viết 1 filter mới, chỉ cần gõ <HOST> và fail2ban tự lắp đúng khối regex chuẩn vào đó.

Ngoài <HOST>, còn có <IP4>, <IP6>, <ADDR>, <CIDR>, <SUBNET>, và cặp mở/đóng <F-TÊN>...</F-TÊN> cho phép tự đặt tên 1 capture group tùy ý — giá trị bắt được sẽ lưu vào dữ liệu ticket, dùng lại được trong action sau này.

Đường đi của 1 dòng log qua findFailure

Mỗi dòng log mới đi qua Filter.findFailure (server/filter.py) theo đúng trình tự sau:

  1. Nếu có prefRegex (bộ lọc sơ bộ, tùy chọn) — chạy trước như 1 cửa chặn rẻ tiền: không khớp thì bỏ qua ngay, khỏi phải chạy hết danh sách failregex tốn kém hơn.
  2. Lần lượt thử từng failregex theo đúng thứ tự khai báo trong jail, dừng lại ở regex đầu tiên khớp (mặc định checkAllRegex = False).
  3. ignoreregex được kiểm tra — nếu đoạn vừa khớp cũng khớp luôn 1 ignoreregex, thất bại này bị loại bỏ, không tính là 1 lần fail.
  4. Trích ra định danh: ưu tiên theo thứ tự ip4ip6dnsfid (do chính failregex khai báo). Nếu bắt được hostname (không phải IP literal), DNSUtils.textToIp sẽ resolve nó ra 1 hoặc nhiều IPAddr — nghĩa là 1 dòng log chứa hostname có thể sinh ra nhiều ứng viên bị ban cùng lúc.
Filter.findFailure(): đường đi của 1 dòng log1 dòng log mới(từ file/journal)prefRegex(lọc nhanh, tùy chọn)khớp mới đi tiếpDuyệt failregextheo đúng thứ tựdừng ở regex đầu khớpignoreregexcó khớp đoạnvừa bắt được?Trích IP/host→ FailTicket(ip4/ip6/dns/fid)Bỏ qua dòng / match nàykhông khớpkhớp ignore

Tự động nhận diện định dạng thời gian

Khi jail không khai báo datepattern, DateDetector.matchTime (server/datedetector.py) thử một danh sách ~16 mẫu regex dựng sẵn (ISO 8601, syslog kiểu asctime, format Apache [31/Oct/2006:09:22:55 -0000], epoch, TAI64N, kiểu MySQL 130322 11:46:11,...). Điều hay ở đây là nó không thử lại cả 16 mẫu cho mỗi dòng log: bộ dò nhớ lại mẫu vừa thắng và đúng vị trí khớp trên dòng trước, lần sau ưu tiên thử lại đúng vị trí đó trước khi mới quay về quét toàn bộ danh sách. Mẫu nào thắng nhiều lần được "đẩy" dần lên đầu danh sách (hàm _reorderTemplate), theo trọng số dựa trên số lần khớp và độ gần đây — nên với 1 log có định dạng cố định, việc dò ngày hội tụ về gần như O(1) chỉ sau vài dòng đầu, thay vì quét lại 16 mẫu mãi mãi.

Ghép nhiều dòng thành 1 thất bại

maxlines (Filter.setMaxLines, filter.py) quy định kích thước 1 bộ đệm dòng cuộn (rolling buffer). findFailure nối các dòng trong buffer lại thành 1 chuỗi và match với re.MULTILINE — cho phép 1 failregex trải dài qua nhiều dòng log liên tiếp.

Trường hợp khó hơn: khi thông tin cần thiết nằm rải rác ở 2 dòng không liền kề nhau — ví dụ dòng 1 ghi "mở kết nối ID=42 từ IP X", còn dòng 2 (cách xa vài dòng khác) ghi "kết nối ID=42: xác thực thất bại". Cơ chế mlfid (multi-line failure ID) xử lý đúng trường hợp này: Filter._mergeFailure (filter.py) cache tạm thông tin từng phần, khóa theo giá trị connection-id, trong 1 bộ nhớ đệm riêng (tối đa 100 mục, hết hạn sau 5 phút) — chờ tới khi dòng có IP thật sự xuất hiện mới ghép lại thành 1 ticket hoàn chỉnh.

4. Bộ máy quyết định ban

Ticket là gì, thực chất?

"Ticket" trong fail2ban không phải một khái niệm trừu tượng — nó là 1 object Python cụ thể (server/ticket.py, class Ticket, dùng __slots__ để tiết kiệm bộ nhớ vì có thể tồn tại hàng nghìn ticket cùng lúc). Một ticket giữ: định danh (thường là địa chỉ IP), timestamp, số lần đã bị ban, thời gian ban, và 1 dict dữ liệu tự do (các dòng log khớp, các group regex bắt được).

FailTicket (kế thừa Ticket) đại diện cho 1 IP đang bị đếm thất bại, chưa tới ngưỡng ban. BanTicket (kế thừa FailTicket) đại diện cho 1 IP đã bị ban. Điều bất ngờ: khi 1 ticket "thăng cấp" từ fail lên ban, fail2ban không tạo object mới — nó chỉ đổi thẳng con trỏ __class__ của object đó (FailTicket.wrap/BanTicket.wrap). Ticket đủ điều kiện ban không bị "tái tạo hồ sơ" — nó chỉ đổi "nhãn" ngay tại chỗ.

💡 Hình dung như thế này: giống 1 tờ phiếu phạt tích lũy — đủ 3 lần vi phạm (maxretry) trong 1 khung thời gian (findtime) thì chính tờ phiếu đó được đóng dấu "CẤM", không cần lập hồ sơ mới từ đầu.

findtime hoạt động thế nào, thật ra?

FailManager (server/failmanager.py) là 1 dict (có khóa bảo vệ) ánh xạ từ định danh (IP) sang FailTicket. Điều đáng chú ý: fail2ban không lưu lại từng timestamp của mọi lần thất bại rồi dọn dần các mốc cũ — cách đó sẽ tốn bộ nhớ tỉ lệ thuận với số lần thất bại. Thay vào đó, nó chỉ giữ 3 con số: _firstTime, _time (lần thất bại gần nhất), _retry (số lần đếm được).

Khi có 1 thất bại mới tới (hàm adjustTime, ticket.py): nếu _firstTime đã cũ hơn now - findtime, fail2ban ước lượng lại tỉ lệ số lần thất bại theo đúng khung thời gian mới (_retry được nhân tỉ lệ findtime / (thời gian đã trôi qua)), rồi trượt _firstTime tiến lên. Đây là kiểu cửa sổ trượt ước lượng (estimation-based sliding window) — đánh đổi độ chính xác tuyệt đối lấy chi phí O(1) bộ nhớ/cập nhật cho mỗi IP, thay vì O(số lần thất bại).

Khi số lần đếm được (getRetry()) chạm maxretry, FailManager.toBan() trả về ticket đó ngay — và điều quan trọng: việc này được kích hoạt ngay trong cùng lượt xử lý dòng log vừa làm số đếm chạm ngưỡng (filter.py, các hàm addAttempt/processLineAndAdd), không phải đợi tới tick định kỳ tiếp theo. Ban xảy ra tức thì, không có độ trễ "chờ vòng lặp".

Từ hàng đợi tới quyết định ban thật sự

Actions.__checkBan (server/actions.py) rút các ticket ra khỏi hàng đợi của jail, bọc thành BanTicket, đưa cho BanManager.addBanTicket (server/banmanager.py). Hàm này tính thời điểm kết thúc ban (eob), và nếu IP đó đã bị ban rồi thì kéo dài thời gian ban thay vì tạo bản ghi trùng — chỉ kéo dài nếu thời điểm kết thúc mới xa hơn thời điểm cũ. Ban lặp lại trên 1 IP đã bị ban không cộng dồn thời gian, chỉ đẩy hạn kết thúc ra xa hơn nếu hợp lý.

Vòng đời 1 ticket: FailTicket → BanTicket → unbanFail #1FailTicket tạo mớiFail #2retry += 1Fail #3 = maxretrytoBan() kích hoạtngay lập tức, không đợi tickBANcon trỏ class đổi thànhBanTicket (wrap tại chỗ)UNBANsau khi hết bantime(có thể bị prolong)chờ bantime

bantime.increment — trừng phạt tăng dần

Đây là phần thú vị nhất của bộ máy ban: khi bật bantime.increment, thời gian ban của 1 IP tăng dần theo số lần tái phạm. Công thức mặc định, dựng trong Jail.setBanTimeExtra (server/jail.py):

formula = 'ban.Time * (1<<(ban.Count if ban.Count<20 else 20)) * banFactor'

Tức là bantime × 2^min(banCount, 20) × factor — và công thức này thật sự được chạy qua eval() trên 1 expression đã compile(), nghĩa là bantime.formula là Python thật sự tùy biến được (đáng lưu ý nếu jail.conf từng nhận cấu hình từ nguồn không tin cậy). maxtime/rndtime bọc thêm quanh công thức này để giới hạn trần và thêm nhiễu ngẫu nhiên — mục đích thêm nhiễu (rndtime) là để 1 botnet theo dõi log không thể tính chính xác được thời điểm IP hết hạn ban.

💡 Hình dung như thế này: giống hình phạt tăng dần cho tái phạm — vi phạm lần đầu bị cấm 10 phút, tái phạm lần 2 bị cấm 20 phút, lần 3 bị cấm 40 phút... cứ nhân đôi theo mỗi lần tái phạm, cho tới khi chạm trần.

bantime.increment: thời gian ban tăng theo số lần tái phạm (minh họa)10 phútbanCount=020 phútbanCount=140 phútbanCount=280 phútbanCount=3công thức mặc định: bantime × 2^banCount × factor(giới hạn bởi maxtime, cộng nhiễu ngẫu nhiên nếu đặt rndtime)

Công thức này được tính bất đồng bộ, ở nền, bởi ObserverThread — không chạy trực tiếp trong BanManager để không làm chậm đường xử lý chính. Điều kiện tiên quyết: cơ chế này hoàn toàn vô hiệu nếu jail không cấu hình database — cả failureFound lẫn incrBanTime (observer.py) đều thoát sớm nếu jail.databaseNone.

Xem trạng thái ban thật qua CLI

Muốn nhìn thấy đúng những gì vừa mô tả bằng lý thuyết diễn ra thật trên máy đang chạy, dùng fail2ban-client:

fail2ban-client status sshd
# Output minh họa
Status for the jail: sshd
|- Filter
| |- Currently failed: 2
| |- Total failed: 47
| `- File list: /var/log/auth.log
`- Actions
|- Currently banned: 1
|- Total banned: 9
`- Banned IP list: 203.0.113.42

(Output minh họa — cấu trúc và tên field đúng theo lệnh thật, số liệu cụ thể chỉ mang tính ví dụ.)

Currently failed chính là kích thước hiện tại của FailManager.__failList — số IP đang bị đếm nhưng chưa chạm maxretry. Currently banned là kích thước của BanManager — số IP đang thật sự bị chặn. Hai con số này độc lập nhau: 1 IP không nằm trong Currently failed không có nghĩa nó "an toàn" — có thể nó đã vượt ngưỡng và chuyển hẳn sang danh sách ban rồi.

5. Mô hình thực thi action

Thứ tự chạy action

Actions của 1 jail là 1 dict (server/actions.py) — và vì Python dict giữ đúng thứ tự chèn, các action chạy theo đúng thứ tự khai báo trong config (thường chỉ có banaction, nhưng hoàn toàn có thể khai nhiều action, vd vừa chặn iptables vừa gửi email). Lúc dừng jail, thứ tự này bị đảo ngược (hàm stopActions) — action khai báo sau được actionstop trước, đúng kỷ luật "vào sau ra trước" (giống unwind một stack).

Vòng đời 1 action, và điểm hay ho: khởi động theo yêu cầu

Một action (CommandAction, server/action.py) trải qua vòng đời start()ban()/unban()flush()stop(), mỗi bước ánh xạ tới 1 đoạn shell trong config (actionstart, actionban,...).

Điểm đáng chú ý: nếu action có khai báo điều kiện theo họ địa chỉ (kiểu section [Init?family=inet6]), actionstart không chạy ngay lúc jail khởi động — nó bị hoãn tới lần ban đầu tiên của đúng họ địa chỉ đó (hàm _startOnDemand). Đây là lý do vì sao chain iptables cho IPv6 không được tạo ra cho tới khi có 1 IP IPv6 thật sự bị ban.

Ngoài ra còn có cơ chế tự phục hồi: trước khi retry 1 lệnh ban/unban vừa thất bại, action chạy lại actioncheck để xác nhận môi trường vẫn còn nguyên vẹn (vd chain f2b-sshd còn tồn tại không); nếu không, nó chạy actionrepair hoặc fallback về stop() + start() để dựng lại từ đầu — đây chính là cơ chế xử lý tình huống "có ai đó flush iptables ngay khi fail2ban đang chạy".

Hai tầng thay tag — và vì sao phải tách riêng

Đây là phần liên quan trực tiếp tới bảo mật. Việc thay các tag như <ip>, <name>, <chain> trong template action bằng giá trị thật được chia làm 2 tầng riêng biệt, không gộp chung — vì 2 loại tag này có mức độ tin cậy hoàn toàn khác nhau:

  • Tag tĩnh (hàm replaceTag): các giá trị đến từ chính file cấu hình jail (<name>, <chain>, <port>) — nguồn tin cậy, đến từ người vận hành. Cho phép thay thế đệ quy (tag lồng tag), có cache theo action để không phải tính lại mỗi lần.
  • Tag động (hàm replaceDynamicTags): các giá trị đến từ log<ip>, <failures>, <matches>, bất kỳ group F-* nào filter bắt được. Đây là dữ liệu attacker gián tiếp kiểm soát được (qua nội dung log). Nên tầng này không đệ quy, không cache, và quan trọng nhất: nếu giá trị chứa ký tự đặc biệt của shell (`, $, ;, |,...), nó không được nối trực tiếp vào chuỗi lệnh — mà được gán vào 1 biến shell riêng ($f2bV_<tag>) rồi lệnh tham chiếu tới biến đó.

💡 Hình dung như thế này: giống 1 mẫu hợp đồng có 2 loại chỗ trống — chỗ trống điền thông tin nội bộ đã biết trước (tên chain, tên port) thì điền thẳng vào văn bản không sao; nhưng chỗ trống điền thông tin do bên ngoài đưa tới (địa chỉ IP của khách lạ) phải viết riêng ra 1 dòng ghi chú kèm theo, không được nhét thẳng vào giữa câu — để không ai có thể "chèn" thêm điều khoản lạ vào hợp đồng chỉ bằng cách ghi tên khác thường.

Đây chính xác là cơ chế chống command injection: một attacker gửi tên đăng nhập SSH chứa backtick hay dấu chấm phẩy sẽ không thể "thoát" ra khỏi câu lệnh shell, vì giá trị đó không bao giờ nằm trực tiếp trong chuỗi lệnh — nó luôn đi qua đường biến môi trường riêng.

Vòng đời action + 2 tầng thay tagactionstartactionbanactionunbanactionstopVì sao tách 2 tầng? — tag từ log (foreign input) phải xử lý khác tag từ configTag tĩnh (replaceTag)<name> <chain> <port> <iptables>đệ quy, cache theo actionnguồn: jail.conf (tin cậy)Tag động (replaceDynamicTags)<ip> <failures> <matches> F-*KHÔNG đệ quy, KHÔNG cachenguồn: log (KHÔNG tin cậy)→ luôn bind thành biến shell ($f2bV_...)

Chạy subprocess thế nào

Utils.executeCmd (server/utils.py) gọi subprocess.Popen(realCmd, shell=True, preexec_fn=os.setsid, ...). Việc gọi os.setsid đặt tiến trình con vào 1 process group riêng, để khi timeout, fail2ban killpg() được toàn bộ nhóm tiến trình (kể cả tiến trình con của tiến trình con), không chỉ giết đúng 1 shell cấp 1. Timeout xử lý theo kiểu "nhẹ tay trước, mạnh tay sau": gửi SIGTERM cho cả group, đợi 1 khoảng ngắn, rồi SIGKILL nếu vẫn còn sống.

Một chi tiết quan trọng cho phần mục 8: toàn bộ executeCmd được bọc trong 1 khóa (threading.Lock) duy nhất cấp module (_cmd_lock, action.py) — dùng chung cho mọi jail, mọi action trong cùng 1 tiến trình server.

Nếu 1 action thất bại, _processCmd chỉ retry đúng 1 lần sau khi chạy actioncheck sửa lỗi; nếu vẫn thất bại, __checkBan bắt lỗi riêng cho từng action (không dừng cả vòng lặp) — nghĩa là 1 action sendmail cấu hình sai không cản trở action iptables vẫn chạy đúng cho cùng 1 IP đó.

Thử xem thứ tự action thật của 1 jail:

fail2ban-client get sshd actions
# Output minh họa
iptables-multiport
sendmail-whois

(Output minh họa — đây chính là thứ tự ban() sẽ chạy; lúc stop() thứ tự này bị đảo ngược lại.)

6. Tầng lưu trữ (persistence)

server/database.py, dùng SQLite3 (__version__ = 4). 4 bảng:

BảngNội dungVai trò
jailstên jail, trạng thái bật/tắttheo dõi jail nào đang active
logsjail, đường dẫn file, hash dòng đầu, vị trí đã đọcresume đúng chỗ đang đọc dở sau restart — dùng lại chính cơ chế hash-dòng-đầu ở mục 2
bansjail, ip, thời điểm ban, thời lượng ban, số lần ban, dữ liệu JSONlịch sử đầy đủ, mỗi dòng là 1 sự kiện ban
bipsip, jail, thời điểm ban, thời lượng, số lần"IP xấu" — 1 dòng cho mỗi cặp IP–jail, phục vụ tra cứu tăng dần bantime ở mục 4

Kết nối SQLite mở với PRAGMA synchronous = OFFPRAGMA journal_mode = MEMORY — đây là đánh đổi tốc độ lấy độ bền dữ liệu một cách có chủ đích: nếu máy bị crash cứng (mất điện, kill -9) đúng lúc đang ghi, có thể mất hoặc hỏng vài bản ghi ban gần nhất, vì cả fsync ở tầng OS lẫn rollback journal trên đĩa đều không được dùng. Điều này hợp lý vì database ở đây chỉ là tiện ích vận hành (khôi phục sau restart, tránh ban lại IP đã biết) chứ không phải hệ thống ghi nhận cần độ tin cậy tuyệt đối.

Restart khác reload thế nào?

  • Restart (jail.py, hàm restoreCurrentBans): khi jail khởi động lại, nó truy vấn database lấy các ticket ban còn hiệu lực và đẩy lại vào hàng đợi xử lý. Nhưng rule/set thật trên firewall đã mất (bị gỡ lúc dừng service) — nên "khôi phục" ở đây thực chất là "ban lại ngay khi daemon khởi động", tính đúng thời gian ban còn lại. Đây là lý do vì sao restart tạo ra 1 khoảng thời gian ngắn IP từng bị ban lại được thông qua, cho tới khi actions thread xử lý xong hàng đợi khôi phục.
  • Reload (server.py, hàm reloadJails): jail được đưa vào trạng thái idle, regex/config được nạp lại — nhưng trạng thái firewall thật (chain iptables, ipset, nft set) không bị dỡ bỏ trừ khi truyền rõ --restart. Ban đang active vẫn tiếp tục có hiệu lực xuyên suốt 1 lần reload thường.

7. Tích hợp với firewall backend

Cùng 1 sự kiện "ban" ánh xạ hoàn toàn khác nhau tùy backend, đọc trực tiếp từ config/action.d/:

BackendLệnh actionbanCấu trúc dữ liệuChi phí match mỗi gói tin
iptables thuầniptables -I f2b-<name> 1 -s <ip> -j <blocktype> — chèn 1 rule mớidanh sách rule tuyến tínhO(n) — kernel duyệt từng rule
iptables + ipsetipset add <ipmset> <ip> — thêm vào 1 hash set; rule iptables chỉ tạo 1 lần duy nhấthash table (kernel module ipset)O(1), không phụ thuộc số IP
nftablesnft add element ... <addr_set> { <ip> } — thêm vào 1 named set; 1 rule tĩnh tham chiếu set đóset gốc của nftablesO(1)/O(log n)
firewalld + ipsetchuyển tiếp qua ipset hoặc firewall-cmd --ipset=...như hàng ipsetO(1), cộng chi phí gọi D-Bus

Đây là bảng có ý nghĩa thực chiến rất rõ: iptables thuần chèn 1 rule cho mỗi IP bị ban — nghĩa là cả chi phí kernel match mỗi gói tin lẫn chi phí gọi lệnh iptables -I/-D phía fail2ban đều tăng theo kích thước danh sách ban. Các backend dựa trên set (ipset/nftables) tách hẳn số lượng ban ra khỏi số lượng rule — luôn chỉ có 1 (hoặc 2, theo IPv4/IPv6) rule duy nhất; chỉ có thành viên trong set tăng lên, và tra cứu trong set là dựa trên hash.

💡 Hình dung như thế này: iptables thuần giống 1 danh sách cấm cửa viết tay từng dòng — muốn biết 1 người có bị cấm không phải đọc lần lượt từ dòng 1. ipset/nftables giống 1 cuốn sổ tra cứu theo bảng chữ cái — mở đúng trang là biết ngay, không cần đọc hết sổ.

Ban 1000 IP: rule-per-IP (iptables) so với set-based (ipset/nftables)-I f2b-<name> 1 -s <ip> -j <blocktype>rule: IP #1rule: IP #2rule: IP #3rule: IP #1000mỗi gói tin: kernel duyệtrule 1 → rule N (O(n))Số rule TĂNGtheo số IP bị ban-m set --match-set <set> -j <blocktype>1 rule TĨNH, không đổihash set (ipset/nft set)IP #1, IP #2, IP #3, ...IP #1000mỗi gói tin: tra hash trực tiếpO(1), không phụ thuộc số IPLuôn CHỈ 1 ruledù ban bao nhiêu IP

ipsettime/default-ipsettime cho phép ipset tự hết hạn 1 entry ở tầng kernel thay vì fail2ban phải chủ động gọi ipset del lúc unban — đẩy việc dọn dẹp sang cho kernel khi thời lượng ban cố định và đã biết trước.

8. Giới hạn về concurrency và scaling

Ba điểm nghẽn thấy rõ trực tiếp từ source code:

_cmd_lock — 1 khóa dùng chung cho toàn server. Đã nhắc ở mục 5: mọi lệnh actionstart/actionban/actionunban/actioncheck, của mọi jail, đều xếp hàng qua đúng 1 threading.Lock(). Khi nhiều jail cùng ban đồng thời, việc gọi subprocess trở thành tuần tự ở mức toàn server — lệnh iptables -I của jail A và lệnh ipset add của jail B không thể chạy song song, dù về logic chúng hoàn toàn độc lập nhau. Nhiều khả năng khóa này tồn tại để tránh việc 2 thread cùng sửa firewall theo kiểu "kiểm tra tồn tại rồi mới chèn" (iptables -C ... || iptables -I ...) giẫm chân nhau — nhưng nó cũng là 1 trần cứng cho throughput của toàn bộ hành động ban khi số jail tăng lên.

💡 Hình dung như thế này: giống 1 cửa hàng có nhiều gian hàng độc lập nhưng chỉ có 1 quầy thu ngân duy nhất — dù khách gian A và khách gian B chẳng liên quan gì tới nhau, vẫn phải xếp hàng chung 1 quầy để thanh toán.

GIL. Vì mọi thread filter/action/observer đều chạy trong cùng 1 interpreter Python, phần việc nặng CPU (regex phức tạp, tag substitution, encode/decode JSON để ghi DB) tranh chấp GIL giữa các jail. Các lệnh gọi subprocess/os.stat() nhả GIL trong lúc chờ I/O nên phần đó không bị ảnh hưởng, nhưng 1 jail có log dồn dập kèm failregex nặng (nhiều alternation, multi-line) có thể làm đói CPU-time của các jail khác đang chạy cùng lúc. (Đây là suy luận dựa trên kiến trúc — threading thuần của CPython, không có multiprocessing ở đâu trong các file đã đọc — không phải kết quả benchmark thực tế.)

Socket server đơn luồng. Một loạt lệnh fail2ban-client gọi dồn dập (vd script giám sát poll status nhiều jail liên tục) được xử lý tuần tự qua asyncore.poll() — dưới tải lệnh CLI cao có thể làm tăng độ trễ, dù từng lệnh riêng lẻ vẫn nhanh (chủ yếu là tra dict trong bộ nhớ).

3 điểm nghẽn khi số jail hoặc tải tăngGIL1 interpreter Python cho N filter/action threadRegex nặng ở 1 jail có thể làm đói CPU-timecủa các jail khác đang chạy song songcmd_lock1 threading.Lock() dùng chung MỌI action, MỌI jailiptables của jail A và ipset của jail Bkhông chạy song song — luôn xếp hàng chungsocketasyncore.poll() đơn luồng cho mọi clientBurst lệnh fail2ban-client dồn dậpbị xử lý tuần tự (từng lệnh vẫn nhanh)

9. Mô hình bảo mật và giới hạn cố hữu

Fail2ban tin nội dung log là dữ liệu, nhưng giá trị lấy từ log vẫn chạm tới shell. Cơ chế 2 tầng thay tag ở mục 5 chính là để đối phó với điều này — <ip>, các group F-*, hay bất kỳ giá trị regex bắt được nào cũng được bind thành biến shell thay vì nối thẳng vào chuỗi lệnh. Đây là biện pháp phòng thủ thật sự và có chủ đích cho lớp tấn công "chèn shell qua log" (vd username SSH cố tình chứa backtick). Nhưng nó không giải quyết được 1 giới hạn kiến trúc sâu hơn: việc phát hiện của fail2ban dựa hoàn toàn vào nội dung log — mà nội dung log, xét cho cùng, là dữ liệu do kẻ tấn công gián tiếp kiểm soát. Một kẻ tấn công có thể đoán trước (hoặc ảnh hưởng) chính xác chuỗi mà failregex sẽ khớp, từ đó giả mạo thất bại nhằm vào IP của người khác — bằng cách tạo ra dòng log trông giống như IP nạn nhân gây ra (dễ xảy ra nhất ở các định dạng log mà "địa chỉ nguồn" là 1 trường text do client tự khai báo — header mail, trường kiểu X-Forwarded-For mà 1 số filter regex thẳng vào mà không xác thực, log ứng dụng echo lại input người dùng). Đây là hệ quả kiến trúc của việc là 1 bộ phát hiện dựa trên log, ngoài băng thông (out-of-band) — không phải lỗi code có thể vá bằng cách escape kỹ hơn. (Đoạn này là phân tích/suy luận từ kiến trúc, không phải 1 CVE cụ thể đã truy vết trong phiên làm việc này.)

Khoảng trễ giữa phát hiện và thực thi. Luôn có 1 khoảng thời gian giữa lúc "hành vi vi phạm được ghi nhận" (dịch vụ gốc ghi log, thường sau khi việc xác thực đã hoàn tất — vd sshd chỉ ghi log thất bại sau khi kết thúc handshake) và lúc "rule ban thật sự có hiệu lực" (chạy xong actionban, còn phải xếp hàng qua _cmd_lock). Kẻ tấn công có thể tranh thủ thêm vài lần thử trong khoảng đó — và vì ban chỉ chặn kết nối tương lai, đúng lần thử cuối cùng khiến bộ đếm chạm ngưỡng lại chưa bao giờ bị chặn bởi chính lần ban đó. Đây là hệ quả tất yếu của mô hình phản ứng dựa-trên-log, không phải 1 bug cụ thể.

IPv6 dễ vỡ âm thầm. IPv4 và IPv6 được coi là 2 họ địa chỉ hoàn toàn riêng biệt, cần action set khởi động theo điều kiện riêng (xem _startOnDemandmục 5). Một jail chỉ dùng IPv6 mà quên không kiểm tra allowipv6, hoặc 1 file action.d tự viết chỉ có [Definition] mà thiếu hẳn [Init?family=inet6], có thể âm thầm chỉ ban được IPv4 trong khi trông như hoạt động bình thường (tấn công IPv4 vẫn bị ban, "chứng minh" hệ thống chạy đúng; tấn công IPv6 từ cùng kẻ tấn công không bao giờ kích hoạt việc tạo chain/set riêng).

Vì sao lại là mô hình phản ứng dựa-trên-log, chứ không phải rate-limiter thời gian thực? Toàn bộ pipeline của fail2ban — đọc log, match regex 1 dòng dịch vụ gốc đã ghi sẵn, đếm trong khung findtime, rồi gọi firewall — về bản chất luôn là xử lý sau sự việc đã xảy ra. So với 1 rate-limiter ở tầng kernel (hashlimit/recent của iptables), cách này đổi lấy sự linh hoạt rất lớn: hoạt động đồng nhất với bất kỳ dịch vụ nào ghi log dạng text, không cần viết code parser riêng cho từng giao thức, cấu hình failregex dễ đọc/audit, mở rộng được cho bất kỳ định nghĩa "thất bại" nào chỉ bằng regex — đổi lại độ trễ giữa hành vi xấu và lúc bị chặn (bị chặn dưới bởi độ trễ flush log + độ trễ poll/inotify + thời gian match regex + hàng đợi thực thi action tuần tự), không bảo vệ được chính request/connection vừa vượt ngưỡng, và phụ thuộc hoàn toàn vào việc dịch vụ gốc ghi log đầy đủ, đúng định dạng, không tự giới hạn/trì hoãn việc ghi log của chính nó. Nó là 1 lớp phòng thủ đặt phía sau dịch vụ dễ tổn thương, không phải thứ thay thế cho việc giới hạn tốc độ phía trước dịch vụ đó.

10. So sánh với các thiết kế khác

(Phần này thiên về đối chiếu/kiến thức chung hơn là đọc trực tiếp từ source, có kèm 1 vòng research để verify — xem mục Nguồn tham khảo cuối bài.)

  • Fail2Ban (bài này đang mổ xẻ, ra đời từ 2004): mô hình log-regex + bộ đếm + gọi script ngoài. Điểm mạnh cốt lõi là tính phổ quát — bất cứ thứ gì ghi ra log dạng đọc được đều có thể trở thành điều kiện ban — cộng khả năng tùy biến hoàn toàn qua file config filter.d/action.d, không cần biên dịch hay viết plugin. Điểm yếu cốt lõi đúng như mục 9 đã nói: phản ứng, cô lập theo từng host, không có threat intelligence chia sẻ, và (theo mục 8) bị giới hạn kiến trúc bởi GIL + 1 khóa thực thi lệnh dùng chung toàn server — nó scale bằng cách chạy nhiều instance hơn, chứ không scale sâu hơn trong 1 instance.
  • CrowdSec (Go, ra đời muộn hơn nhiều): vẫn giữ đúng hình dạng cốt lõi "đọc log, phát hiện pattern, thực hiện hành động" — "scenario" của nó về khái niệm tương đương failregex, chỉ khác là viết bằng YAML — nhưng thêm 2 thứ fail2ban không có sẵn: (1) runtime Go chạy đa luồng thật sự, không bị giới hạn kiểu GIL như Python, nên việc xử lý theo từng scenario song song hóa được trên nhiều core; (2) tách hẳn phần phát hiện (Log Processor + scenario YAML) khỏi phần thực thi (Remediation Component/"Bouncer" gọi qua Local API), và có Community Blocklist — dữ liệu phát hiện cục bộ có thể tùy chọn chia sẻ lên, và tham chiếu ngược lại từ 1 blocklist trung tâm tổng hợp từ cộng đồng — nghĩa là 1 máy chạy CrowdSec có thể chặn trước cả những IP nó chưa từng tự quan sát thấy tấn công, điều mà 1 bộ theo dõi log thuần cục bộ (kể cả fail2ban) không làm được theo đúng thiết kế. Đánh đổi: mất đi sự đơn giản "mọi thứ ở lại trên máy này" của fail2ban, đổi lấy 1 mô hình phòng thủ có hiệu ứng mạng — với chính những đánh đổi về niềm tin/riêng tư của việc chia sẻ dữ liệu đó.
  • sshguard: phạm vi hẹp hơn nhiều (ban đầu tập trung SSH/mail, viết bằng C, tối thiểu dependency). Điểm cần đính chính so với suy nghĩ thông thường: sshguard không dùng regex — nó dùng parser dựng sẵn riêng cho từng định dạng log để ưu tiên tốc độ và độ tin cậy hơn là tính linh hoạt của regex. Đánh đổi lấy footprint tối thiểu, không phụ thuộc runtime ngôn ngữ script nào (có ý nghĩa trên hệ thống nhúng/BSD), nhưng đổi lại hệ sinh thái filter/action nhỏ hơn hẳn so với 2 thập kỷ tích lũy của fail2ban.
  • iptables recent/hashlimit thuần: hoàn toàn không phải công cụ phân tích log — chỉ là cơ chế giới hạn theo tốc độ kết nối/gói tin, chạy thuần trong kernel, không regex, không parse log, không lưu trạng thái ngoài bảng trạng thái riêng của kernel module, và không biết vì sao 1 host bị giới hạn (không phân biệt được "5 lần đăng nhập SSH thất bại" với "5 lần đăng nhập thành công" — nó chỉ thấy tốc độ kết nối/gói tin). Đổi lại độ trễ thấp nhất có thể và zero tương tác userspace cho mỗi quyết định, cái giá là hoàn toàn mù trước ngữ nghĩa tầng ứng dụng. Trong thực tế, fail2ban và hashlimit thường được triển khai cùng nhau chứ không phải chọn 1 trong 2 — hashlimit làm tuyến phòng thủ đầu tiên, tức thời, chặn flood kết nối thô; fail2ban nằm phía sau, đưa ra phán đoán ngữ nghĩa "đây thật sự là 1 nỗ lực credential-stuffing" mà chỉ nội dung log mới trả lời được.

Xét đúng thời điểm fail2ban ra đời (2004 — thời của máy đơn nhân là chuẩn, Python là ngôn ngữ glue dễ đọc/dễ script, iptables gần như là lựa chọn firewall Linux duy nhất), kiến trúc này đọc lên vẫn là 1 lựa chọn hợp lý và còn giá trị tới bây giờ: tùy biến qua text-config thay vì hệ plugin biên dịch sẵn, tách client/daemon đơn giản thay vì bất cứ thứ gì phân tán phức tạp hơn, và theo dõi log thay vì hook ở tầng kernel — vì tầng kernel về cơ bản không có khả năng nhìn thấy ngữ nghĩa thất bại ở tầng ứng dụng (vd "response HTTP này là 1 lần đăng nhập thất bại") nếu chính ứng dụng chưa ghi lại điều đó ở đâu đó trước.


Nguồn tham khảo

  • asyncore/asynchat bị deprecate từ Python 3.6 và gỡ hẳn khỏi thư viện chuẩn ở Python 3.12 (PEP 594); fail2ban 1.1.0 vendor lại 2 module này trong thư mục fail2ban/compat/ để vẫn chạy được trên Python 3.12+ — 2026-08-24, Python 3.12 What's New, fail2ban issue #3487
  • CrowdSec (Go) tách phần phát hiện (Log Processor + scenario YAML) khỏi phần thực thi (Remediation Component/Bouncer gọi Local API), Community Blocklist tổng hợp báo cáo từ nhiều cài đặt CrowdSec khác nhau trên cộng đồng — 2026-08-24, CrowdSec Concepts, CrowdSec Community Blocklist
  • sshguard viết bằng C nhưng không dùng regex — dùng parser dựng sẵn riêng cho từng định dạng log để ưu tiên tốc độ và độ tin cậy hơn tính linh hoạt — 2026-08-24, sshguard docs
  • Toàn bộ trích dẫn file/hàm trong bài đọc trực tiếp từ github.com/fail2ban/fail2ban, commit deffcbcd1712 (số dòng có thể lệch ở các commit sau do code cập nhật thường xuyên, nên chỉ trích tên file/hàm) — 2026-08-22, fail2ban/fail2ban trên GitHub