BẢO MẬTCĂN BẢNTUẦN 01–04
BÀI 11

Bên trong công cụ quét: đọc hiểu code Python tìm thiết bị và port

Câu hỏi xuyên suốt: bài 8 đã CHẠY lan_inventory.py và thấy danh sách thiết bị, hệ điều hành, cổng mở, tên gợi ý. Bài này lần theo đúng những dòng code tạo ra từng kết quả đó, theo đúng thứ tự chương trình thật sự chạy.

Thời lượng: khoảng 2 giờ trên lớp, chia hai buổi (Bước 1–2: quét và đoán hệ điều hành; Bước 3–5: lấy cổng, nhận diện máy, ranh giới), gồm Bài tập 1 sau Bước 3 (khoảng 30 phút), cộng Bài tập 2 làm ở nhà (30–40 phút). Có 7 câu luyện nhanh trong bài

Năm bước đọc hiểu công cụ quét, đúng thứ tự chương trình chạy: quét tìm thiết bị, đoán hệ điều hành, lấy cổng mở, ghép manh mối nhận diện máy, rồi tới ranh giới tự chặn.
Năm bước đọc hiểu công cụ quét, đúng thứ tự chương trình chạy: quét tìm thiết bị, đoán hệ điều hành, lấy cổng mở, ghép manh mối nhận diện máy, rồi tới ranh giới tự chặn.
01

Bài này làm gì?

5 phút
Quét IPTìm thiết bị tồn tại
Hệ điều hànhĐoán qua TTL
Cổng mởscan_ports()
Nhận diệnGhép manh mối
Ranh giớiTự chặn sai phạm vi
Tình huốngỞ bài 8, bạn đã chạy lan_inventory.py, đọc được danh sách thiết bị, hệ điều hành gợi ý, cổng mở, và cả tên loại thiết bị được đoán sẵn. Một bạn học hỏi: "Sao nó biết nhanh vậy? Nó có 'hack' gì không?" Bài này trả lời bằng cách đọc thẳng vào code, đúng thứ tự chương trình chạy — không viết app mới, không có lỗ hổng mới.

Năm việc theo đúng thứ tự chương trình chạy, rồi một bài tập về nhà

ViệcCâu hỏiLàm ở
1. Quét IP nội bộVì sao gửi một gói UDP lại tìm ra thiết bị đang tồn tại?Bước 1
2. Hệ điều hànhDòng code nào đoán được đây là Windows hay Linux?Bước 2
3. Cổng mởCode lấy danh sách cổng đang mở bằng cách nào?Bước 3
4. Nhận diện máyHàm nào ghép các manh mối lại thành một cái tên?Bước 4
5. Ranh giớiDòng code nào ngăn công cụ quét nhầm Internet?Bước 5
Bài tập về nhàTự viết một công cụ kiểm kê mini từ file khungCuối bài

Cần chuẩn bị

  • Đã học xong bài 8 (kiểm kê thiết bị) — bài này đọc sâu hơn đúng file bài 8 đã dùng, không có file mới nào phải cài.
  • Python 3.8 trở lên và terminal. Chỉ cần một máy; bài này chạy mọi thứ trên 127.0.0.1 (chính máy bạn).
Quy tắc an toànBài này không tạo ứng dụng có lỗi hay lỗ hổng mới. Mọi lệnh chỉ chạy trên 127.0.0.1 (chính máy bạn) hoặc dữ liệu mô phỏng. Khi áp dụng lên mạng thật, vẫn áp dụng đúng quy tắc bài 8: chỉ mạng của bạn hoặc nơi được phép rõ ràng.
02

Bước 1 · Quét dải IP nội bộ: tìm thiết bị đang tồn tại

30 phút
Gửi UDPTới port 9 của cả dải
OS tự hỏi ARPTrước khi gói UDP đi ra
Đợi 2 giâyCho thiết bị kịp trả lời
Đọc bảng ARPRa danh sách IP + MAC

Vì sao bước này nằm đầu tiên: mọi bước phía sau (đoán hệ điều hành, lấy cổng mở, nhận diện máy) đều cần một thứ có sẵn từ trước — danh sách IP nào đang thật sự tồn tại trong mạng. Chưa biết IP nào tồn tại thì chưa có gì để hỏi tiếp. Nhiệm vụ của Bước 1 không phải "học" một khái niệm mới, mà là hiểu vì sao một đoạn code trông rất lạ lại tìm ra được thiết bị.

Bài 8 đã dùng lan_inventory.py với một bước gọi là "gõ cửa" (poke()): gửi một gói UDP nhỏ tới port 9 của mỗi IP trong dải mạng, rồi đọc bảng ARP. Bước này trông kỳ lạ: port 9 không chạy dịch vụ gì cả, không có gì để "trả lời" cả — vậy gửi gói tới đó để làm gì? Giải pháp: không cần ai trả lời gói UDP đó; cái cần là ép hệ điều hành phải tự hỏi ARP trước khi gửi gói đi — giải thích chi tiết ngay dưới đây. Vì sao phải dùng đúng mẹo này, không dùng ping thường: ping (ICMP) có thể bị thiết bị chặn, nhưng không thiết bị nào thật sự có mặt trong mạng mà né được việc trả lời ARP — nên cách này tìm được nhiều thiết bị hơn ping.

Chưa biết Python cũng đọc được — 5 ký hiệu sẽ gặp lại suốt bài
Ký hiệuNghĩa là gì
def tên_hàm(...):"Định nghĩa một hàm" — một đoạn lệnh được đặt tên, gọi lại được nhiều lần. Chữ trong ngoặc là tham số (dữ liệu đưa vào khi gọi hàm).
Thụt lề (khoảng trắng đầu dòng)Python không dùng dấu { } như nhiều ngôn ngữ khác. Dòng nào thụt vào sâu hơn dòng phía trên thì "nằm bên trong" dòng đó (bên trong hàm, bên trong if, bên trong try…).
with ... as s:"Mở một thứ gì đó (ở đây là một socket), đặt tên tắt là s để dùng lại, và tự động đóng nó khi xong — kể cả khi có lỗi xảy ra bên trong."
try: / except ...:"Thử chạy khối lệnh trong try; nếu có lỗi đúng loại ghi trong except, nhảy xuống làm khối đó thay vì làm cả chương trình dừng hẳn."
return x"Hàm dừng lại ngay tại đây, gửi giá trị x về cho nơi đã gọi hàm."

Dòng bắt đầu bằng # là chú thích (comment) — Python bỏ qua hoàn toàn khi chạy, chỉ để người đọc hiểu thêm.

BƯỚC 1Đọc nguyên hàm poke()

Luồng chạy của poke(ip)

  1. Tạo socket UDPChỉ "ném" gói đi, không bắt tay, không chờ trả lời
  2. Gửi 1 byte tới port 9Hệ điều hành phải hỏi ARP trước khi gửi
  3. Có lỗi thì bỏ quaexcept … pass: kết quả UDP không quan trọng
PYTHON · poke() trong lan_inventory.py
def poke(ip):
    # MỤC ĐÍCH: "gõ cửa" một IP để máy phải hỏi ARP "ai giữ IP này?".
    # Thiết bị nào có thật cũng trả lời ARP (kể cả khi nó chặn ping), nên MAC của
    # nó sẽ nằm trong bảng ARP và ta đọc được.
    try:
        # [1] Tạo socket UDP (SOCK_DGRAM): chỉ "ném" gói đi, không bắt tay, không
        #     chờ trả lời
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            # [2] Gửi 1 byte rỗng tới port 9. Nội dung không quan trọng; điều quan
            #     trọng là hệ điều hành phải hỏi ARP để biết MAC của IP này TRƯỚC
            #     khi đưa gói ra card mạng
            s.sendto(b"\0", (ip, 9))
    except OSError:
        # [3] Thiết bị trả lời "port này không mở" cũng có thể gây lỗi; ta không cần
        #     kết quả đó -> bỏ qua
        pass

Đọc từng dòng một, đúng thứ tự Python sẽ chạy:

PhầnÝ nghĩa
def poke(ip):Định nghĩa hàm tên poke, nhận vào 1 giá trị: ip — địa chỉ cần "gõ cửa".
try:Thử chạy khối lệnh bên dưới; có lỗi đúng loại ghi ở except thì nhảy xuống đó thay vì làm chương trình dừng hẳn.
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:Tạo socket kiểu , đặt tên tắt s. UDP không bắt tay như TCP — chỉ "ném" gói đi, không chờ xác nhận.
s.sendto(b"\0", (ip, 9))Gửi đúng 1 byte tới port 9 của ip đó. Port 9 là cổng "Discard" theo quy ước cũ (RFC 863): định nghĩa sẵn là "nhận gói rồi bỏ đi, không cần làm gì". Không có ý định dịch vụ ở port 9 phải phản hồi gì cả.
except OSError:Gói UDP tới port không ai nghe thường khiến thiết bị trả một gói "ICMP port unreachable" — việc này khiến Python báo lỗi OSError. Dòng này bắt đúng lỗi đó.
passpass nghĩa là "không làm gì cả, bỏ qua". Chương trình không dừng vì lỗi này; phản hồi đó không phải điều công cụ quan tâm.
Điều công cụ thật sự muốn không phải là câu trả lời UDPPort 9 chỉ là một cái cớ. Cái công cụ cần là bước trước đó, xảy ra bên trong hệ điều hành, trước khi gói UDP kịp ra khỏi máy bạn.
Code Python · poke()Hệ điều hành (kernel)Thiết bị trong mạngsendto() 1 byte tới port 9Python chỉ thấy tới đây — phần sau xảy ra trong OSBảng ARP đã có MAC của IP này chưa?ARP request: "IP này là ai?"chỉ gửi khi CHƯA có MAC trong bảngCHƯA CÓ →ARP reply: "Tôi — đây là MAC của tôi"Gói UDP 1 byte tới port 9không ai lắng nghe port 9 — bị bỏ hoặc trả ICMP unreachableexcept OSError: passchương trình không cần thấy lỗi này
Hình 1. Khung nét đứt giữa hình là bước ẩn bên trong hệ điều hành: nếu bảng ARP chưa có MAC của IP đó, kernel tự dừng lại hỏi trước khi gửi tiếp gói UDP — bất kể gói UDP đó sau này có ai trả lời hay không.

BƯỚC 2Vì sao bước "trước đó" lại chính là ARP

Nhớ lại ví von ở bài 8: "ARP giống gọi to cả xóm — Ai ở phòng này? Cho tôi số card mạng!". Để gửi bất kỳ gói tin nào trong cùng mạng LAN, card mạng cần biết địa chỉ MAC của bên nhận, không chỉ IP — IP giống số phòng (dùng để định tuyến), MAC giống số thẻ của đúng người trong phòng đó (gói thật sự "bay" trên dây hay trên sóng Wi-Fi tới đúng ai). Nếu hệ điều hành chưa có MAC của IP đó trong bảng ARP, nó phải tự dừng lại, "gọi to cả xóm" một lần ("Ai giữ IP này? Cho tôi MAC"), rồi mới gửi tiếp gói UDP thật.

BướcViệc xảy raAi làm
1Chương trình gọi sendto() với IP đíchCode Python của bạn
2Hệ điều hành kiểm tra bảng ARP: đã có MAC của IP này chưa?Nhân hệ điều hành (kernel), không phải code Python
3aChưa có → gửi gói ARP request, chờ trả lời, ghi vào bảngKernel, tự động, trước khi gói UDP đi
3bĐã có → bỏ qua bước hỏi, gửi luônKernel
4Gói UDP thật sự được gửi ra card mạng, tới đúng MAC vừa biếtKernel

Thiết bị nào thật sự tồn tại trong mạng đều phải trả lời ARP — đó là quy tắc của tầng liên kết dữ liệu, không liên quan gì tới việc thiết bị có "đồng ý" bị quét hay không, và không phụ thuộc việc nó có chặn ping (ICMP) hay chặn port 9 (UDP) hay không. Đây chính là lý do bài 8 nói: "ARP thấy được cả thiết bị chặn ping".

LUYỆN NHANH 1 · 8 PHÚT

Vì sao cách này thấy được cả thiết bị chặn ping

Bài 8 nói: "ARP thấy được cả thiết bị chặn ping". Dựa vào bảng bước 2, giải thích bằng một câu: vì sao một thiết bị có thể chặn hoàn toàn gói ICMP (ping) và gói UDP tới port 9, nhưng vẫn không thể tránh việc trả lời ARP?

Xem đáp án

Chặn ping hay chặn port 9 là việc xảy ra ở tầng ứng dụng/tường lửa, sau khi gói đã tới đúng máy. ARP xảy ra trước đó, ở tầng liên kết dữ liệu, là yêu cầu bắt buộc để hai máy trong cùng mạng LAN nói chuyện được với nhau ở mức thấp nhất — không trả lời ARP nghĩa là không thể nhận bất kỳ gói nào cả, kể cả gói hợp lệ. Vì vậy tường lửa ở tầng cao hơn không can thiệp được vào bước này.

BƯỚC 3Gửi "gõ cửa" tới cả dải cùng lúc, rồi đợi và đọc bảng ARP

Trích từ hàm build_inventory() — hàm điều khiển toàn bộ bước quét IP:

Luồng chạy của build_inventory(network, oui, with_ports)

  1. Dựng danh sách hostsMỗi địa chỉ trong network, đổi thành chuỗi
  2. Gõ cửa cả dảipoke() cho mọi IP trong hosts, 64 luồng cùng lúc
  3. Đợi 2 giâyCho ARP kịp ghi vào bảng của hệ điều hành
  4. Đọc bảng ARP và lọcGiữ IP thuộc hosts, bỏ broadcast/multicast
PYTHON · trích build_inventory() trong lan_inventory.py
def build_inventory(network, oui, with_ports):
    hosts = [str(h) for h in network.hosts()] or [str(network.network_address)]
    # [1] "Gõ cửa" mọi địa chỉ trong dải cùng lúc bằng 64 luồng (poke() ở trên)
    with ThreadPoolExecutor(max_workers=64) as pool:
        list(pool.map(poke, hosts))
    # [2] Đợi 2 giây để các thiết bị kịp trả lời ARP và hệ điều hành kịp ghi vào
    #     bảng ARP
    time.sleep(2)
    # [3] Đọc bảng ARP (IP -> MAC), chỉ giữ IP thuộc dải đang quét và bỏ địa chỉ
    #     broadcast/multicast (không phải thiết bị thật)
    neighbors = {ip: mac for ip, mac in read_neighbors().items() if ip in hosts and is_unicast(ip, mac)}
PhầnÝ nghĩa
def build_inventory(network, oui, with_ports):Định nghĩa hàm tên build_inventory, nhận vào 3 giá trị: network (dải mạng cần quét, ví dụ "192.168.1.0/24" — một đối tượng Python hiểu được, không phải chuỗi chữ thường), oui (bảng tra hãng, dùng ở Bước 4), with_ports (đúng/sai — có quét cổng hay không).
hosts = [str(h) for h in network.hosts()] or [str(network.network_address)]Dựng danh sách hosts — đây chính là biến được dùng ở dòng pool.map(poke, hosts) bên dưới. network.hosts() trả về từng địa chỉ có thể dùng trong dải (bỏ địa chỉ mạng và broadcast), str(h) đổi mỗi địa chỉ đó thành chuỗi chữ (ví dụ "192.168.1.5"). Phần or [...] chỉ chạy khi dải chỉ có đúng 1 địa chỉ (ví dụ /32) — lúc đó .hosts() trả về rỗng, nên dùng tạm chính địa chỉ mạng.
with ThreadPoolExecutor(max_workers=64) as pool:Tạo một "hồ bơi" tối đa 64 luồng (thread) — các tiến trình con nhẹ, cùng chạy trong một chương trình Python, để không phải "gõ cửa" từng IP một cách lần lượt. Vì sao luồng giúp nhanh hơn sẽ học kỹ ở Bước 3, khi áp dụng y hệt kỹ thuật này cho việc quét cổng.
list(pool.map(poke, hosts))Giao hàm poke cho hồ bơi chạy với mỗi địa chỉ trong hosts — gửi "gõ cửa" tới mọi IP trong dải gần như cùng lúc.
time.sleep(2)Dừng chương trình lại đúng 2 giây, không làm gì cả. Gói ARP request và trả lời cần một ít thời gian đi qua mạng — ngay sau khi gửi hết các gói "gõ cửa", bảng ARP chưa chắc đã đầy đủ.
neighbors = {ip: mac for ip, mac in read_neighbors().items() if ip in hosts and is_unicast(ip, mac)}Đây là cách viết tắt của Python, gọi là "dict comprehension" — làm đúng việc một vòng lặp for sẽ làm, chỉ viết gọn trên một dòng. Xem bản viết dài tương đương ngay dưới bảng này.

Viết dài ra, dòng cuối ở bảng trên tương đương đúng đoạn sau — đọc bản này trước nếu dòng rút gọn còn khó hiểu:

TƯƠNG ĐƯƠNG VIẾT DÀI (CHỈ ĐỂ GIẢI THÍCH, KHÔNG CÓ TRONG FILE THẬT)
neighbors = {}
for ip, mac in read_neighbors().items():
    if ip in hosts and is_unicast(ip, mac):
        neighbors[ip] = mac

Nghĩa là: đọc toàn bộ bảng ARP hệ điều hành đang có (read_neighbors() — chỉ đọc sau khi đã chờ ở dòng trên, đọc ngay lúc gửi xong sẽ bỏ sót nhiều thiết bị chưa kịp trả lời), nhưng chỉ giữ lại những IP nằm trong dải đang kiểm tra (hosts) và không phải địa chỉ broadcast/multicast (is_unicast). Kết quả neighbors chính là danh sách "thiết bị đang tồn tại" — nguyên liệu đầu vào cho Bước 2, 3 và 4.

Đọc thêm: Vì sao code chọn cố định port 9, không phải port nào khác? (không bắt buộc)

Để ý: 9 trong s.sendto(b"\0", (ip, 9)) là một số viết cứng (hardcode) trong code, không phải tham số có thể đổi khi gọi hàm — poke() chỉ nhận vào ip, port luôn luôn là 9. Lý do chọn đúng số này:

  1. Không bắt buộc về mặt kỹ thuật. Cơ chế ARP xảy ra ở tầng thấp hơn, trước khi hệ điều hành biết port đích có ai lắng nghe hay không — nên gửi UDP tới bất kỳ port nào không ai dùng cũng ép được ARP xảy ra y hệt. Port 9 không có gì "đặc biệt" về mặt kỹ thuật đối với ARP.
  2. Nhưng được chọn vì đúng "vai diễn" nó mang sẵn. Port 9 là cổng chính thức của giao thức Discard (RFC 863, từ năm 1983): định nghĩa sẵn là "nhận gói, vứt đi, không cần phản hồi". Chọn đúng port có hợp đồng sẵn là im lặng này khiến việc gửi một gói "vô nghĩa" tới đó trở nên hợp lý, thay vì tùy tiện bịa ra một số port ngẫu nhiên.
  3. Và vì gần như không máy nào còn chạy dịch vụ đó. Discard là giao thức rất cũ, hầu như không thiết bị hiện đại nào bật dịch vụ thật ở port 9 — nên gần như chắc chắn gói UDP gửi tới đó sẽ không trúng một dịch vụ thật nào, không gây tác dụng phụ ngoài ý muốn.
  4. Và để tránh gây hiểu lầm. Nếu gửi tới port 80 hay 22, gói đó trông giống một phép thử nhắm vào đúng dịch vụ web hay SSH thật — dễ bị hiểu lầm là đang dò tìm lỗ hổng. Gửi tới port 9 (một cổng "chết" theo đúng định nghĩa chuẩn) nói rõ ý định: chỉ cần ép ARP, không quan tâm dịch vụ nào cả.

Tóm lại: 7, 13, hay bất kỳ port trống nào khác vẫn chạy được — port 9 chỉ là lựa chọn "sạch" nhất trong số các lựa chọn tương đương.

03

Bước 2 · Lấy thông tin hệ điều hành: đọc code đứng sau TTL

15 phút
Gửi 1 pingTới IP đã tìm thấy ở Bước 1
Đọc chữ in raTìm chữ "ttl=" bằng regex
So khung TTL≤64 / ≤128 / còn lại
Đoán nhóm HĐHChỉ là gợi ý, không chắc chắn

Vì sao bước này nằm ngay sau Bước 1: biết IP nào tồn tại rồi, câu hỏi tự nhiên tiếp theo luôn là "đây là máy loại gì?" — và câu trả lời đầu tiên, rẻ nhất, không cần cài thêm gì, là đoán hệ điều hành. Nhiệm vụ của Bước 2 là hiểu hai hàm nhỏ tạo ra dòng chữ "HDH: nhom..." bạn đã thấy khi chạy công cụ ở bài 8 — không học lại cách đọc TTL (đã học ở bài 8), mà xem code nào tính ra con số và lời đoán đó.

Giải pháp: gửi đúng 1 gói ping tới IP đã tìm thấy, rồi đọc một con số có sẵn trong gói trả lời, gọi là TTL. Vì sao chọn ping/TTL thay vì cài thêm phần mềm quét sâu: lệnh ping có sẵn trên mọi hệ điều hành, không cần quyền quản trị, không cần cài gì thêm — và mỗi hệ điều hành mặc định bắt đầu đếm TTL từ một số hơi khác nhau, nên một phép đo rẻ tiền vẫn cho một manh mối hữu ích. Bài 8 đã dạy cách đọc con số đó để đoán hệ điều hành (nhóm ~64 là Linux/macOS/iOS/Android, ~128 là Windows, ~255 là thiết bị mạng/máy in); phần này đọc code tạo ra con số đó.

TTL ở đây khác TTL của bài 7Bài 7 dùng chữ TTL cho "thời gian lưu cache" của bản ghi DNS. Chữ TTL ở đây là một khái niệm khác hẳn, dùng chung tên viết tắt: đây là "Time To Live" của một gói tin IP — một con số giảm đi 1 mỗi khi gói đi qua một thiết bị định tuyến, để tránh gói đi lạc mãi mãi trên mạng. Ping đọc được con số này trong gói trả lời.

BƯỚC 1Đọc nguyên hàm ttl_of()

Luồng chạy của ttl_of(ip)

  1. Chọn lệnh pingWindows, macOS, Linux dùng cờ khác nhau
  2. Chạy ping 1 góiChờ tối đa 4 giây; lỗi thì trả None
  3. Tìm "ttl=" trong chữ in raDùng regex, không phân biệt hoa thường
  4. Trả về số TTLKhông tìm thấy thì trả None
PYTHON · ttl_of() trong lan_inventory.py
def ttl_of(ip):
    # Gửi đúng 1 gói ping tới thiết bị ĐÃ TÌM THẤY để đọc TTL.
    # (Việc tìm thiết bị không dựa vào ping.)
    # [1] Mỗi hệ điều hành viết lệnh ping khác nhau: -n (Windows) / -c (macOS,
    #     Linux) là số gói; -w, -t, -W là thời gian chờ
    system = platform.system()
    if system == "Windows":
        cmd = ["ping", "-n", "1", "-w", "1000", ip]
    elif system == "Darwin":
        cmd = ["ping", "-c", "1", "-t", "2", ip]
    else:
        cmd = ["ping", "-c", "1", "-W", "1", ip]
    # [2] Chạy lệnh ping và đọc chữ nó in ra; chờ tối đa 4 giây. Lỗi hoặc quá hạn
    #     thì trả None (không rõ)
    try:
        out = subprocess.run(cmd, capture_output=True, text=True, timeout=4).stdout
    except (OSError, subprocess.SubprocessError):
        return None
    # [3] Tìm trong chữ đó đoạn "ttl=64" (hoặc "TTL=128"...) bằng regex; re.I =
    #     không phân biệt hoa thường
    m = re.search(r"ttl[=:]\s*(\d+)", out, re.I)
    # [4] Có thì trả về con số TTL, không thì trả None
    return int(m.group(1)) if m else None
PhầnÝ nghĩa
def ttl_of(ip):Định nghĩa hàm tên ttl_of, nhận vào 1 giá trị: ip — địa chỉ đã tìm thấy ở Bước 1.
system = platform.system()Hỏi hệ điều hành đang chạy chính chương trình này là gì — trả về chữ "Windows", "Darwin" (tên nội bộ của macOS), hoặc tên khác (Linux).
if system == "Windows": ... elif system == "Darwin": ... else: ...Ba nhánh song song, chọn đúng cú pháp lệnh ping cho Windows / macOS / Linux — tham số dòng lệnh của ping khác nhau giữa 3 hệ điều hành, nhưng ý nghĩa giống nhau: gửi đúng 1 gói, chờ tối đa khoảng 1–2 giây. Giống bài 10: lệnh được viết thành danh sách tham số, không ghép chuỗi.
out = subprocess.run(cmd, ...).stdoutChạy đúng lệnh ping vừa chọn, chờ tối đa 4 giây, đọc toàn bộ chữ nó in ra màn hình vào biến out.
except (OSError, subprocess.SubprocessError): return NoneNếu lệnh ping lỗi hoặc không chạy được, dừng hàm ngay, trả về None (nghĩa là "không đo được").
m = re.search(r"ttl[=:]\s*(\d+)", out, re.I)Tìm trong chữ vừa đọc một đoạn khớp mẫu "ttl=" hoặc "ttl:" theo sau là một số — đây là cách đọc chữ lệnh ping in ra để lấy con số, không phải Python tự tính TTL.
return int(m.group(1)) if m else NoneNếu tìm thấy, lấy đúng con số đó, đổi từ chữ thành số nguyên, trả về. Không tìm thấy thì trả về None.

BƯỚC 2Đọc nguyên hàm guess_os() — và một điều dễ hiểu lầm

Luồng chạy của guess_os(ttl)

  1. Không có TTL?Trả "không rõ", không đoán bừa
  2. TTL ≤ 64?Nhóm Linux/macOS/iOS/Android
  3. TTL ≤ 128?Nhóm Windows
  4. Còn lại (tới 255)Thiết bị mạng hoặc máy in
PYTHON · guess_os() trong lan_inventory.py
def guess_os(ttl):
    # TTL khởi đầu của gói trả lời: ~64 Linux/macOS/iOS/Android, ~128 Windows,
    # ~255 thiết bị mạng. Chỉ là gợi ý. (Gói đi qua mỗi router bị trừ 1 nên số
    # nhận được có thể nhỏ hơn số khởi đầu một chút, vì vậy ta chia theo KHOẢNG.)
    # [1] Không đọc được TTL (thiết bị chặn ping): không đoán bừa
    if ttl is None:
        return "khong ro (thiet bi chan ping)"
    # [2] TTL từ 64 trở xuống: nhóm Linux/macOS/iOS/Android
    if ttl <= 64:
        return f"nhom Linux/macOS/iOS/Android (TTL {ttl})"
    # [3] TTL trên 64 đến 128: nhóm Windows
    if ttl <= 128:
        return f"nhom Windows (TTL {ttl})"
    # [4] Còn lại (tới 255): thiết bị mạng hoặc máy in
    return f"thiet bi mang/may in (TTL {ttl})"
PhầnÝ nghĩa
def guess_os(ttl):Định nghĩa hàm tên guess_os, nhận vào 1 giá trị: ttl — kết quả của ttl_of() ở trên (một số, hoặc None).
if ttl is None: return "khong ro..."Nếu ttl_of() không đo được gì (thiết bị chặn ping), trả lời ngay là "không rõ", không so sánh số nào cả.
if ttl <= 64: return f"nhom Linux/macOS/iOS/Android (TTL {ttl})"f"..." là một f-string — chuỗi chữ có chèn sẵn giá trị biến vào giữa, ở đây chèn đúng số ttl vào chỗ {ttl}.
if ttl <= 128: return f"nhom Windows (TTL {ttl})"Dòng này chỉ có cơ hội chạy khi dòng if ngay trên không trả về gì (nghĩa là ttl đã lớn hơn 64).
return f"thiet bi mang/may in (TTL {ttl})"Không dòng if nào ở trên đúng (ttl lớn hơn 128) thì rơi xuống đây.
Để ý: đây là 3 câu if riêng, không phải if/elif/elseKhông có chữ elif nào trong hàm này! Nhưng vì mỗi nhánh đều return ngay, dòng bên dưới chỉ chạy được khi dòng trên không hề trả về — nên tác dụng giống hệt if/elif/else. Đây là một thói quen thường gặp khi đọc code người khác: không phải cứ thấy nhiều dòng if liên tiếp là chúng độc lập với nhau.
Gửi 1 gói ping tới IP đã tìm thấyđọc TTL trong gói trả lờiLinux / macOS / iOS / AndroidTTL gốc thường 64WindowsTTL gốc thường 128Thiết bị mạng, máy inTTL gốc thường 255vd: nhận được TTL = 62 → nhóm nàyChỉ là phỏng đoán theo nhóm — không phải bằng chứng chắc chắn, vì TTL có thể bị đổi.
Hình 2. Mỗi thiết bị định tuyến mà gói đi qua (một "hop") làm TTL giảm đi 1. Trong mạng nhà, số hop giữa hai thiết bị rất nhỏ (thường 0–2), nên TTL nhận được gần đúng TTL gốc — đủ để đoán nhóm, không đủ để biết chính xác hệ điều hành nào.

LUYỆN NHANH 2 · 10 PHÚT

Dự đoán kết quả của guess_os() mà không cần mở lại bảng bài 8

Chỉ nhìn code guess_os() vừa đọc ở trên — không mở lại bảng TTL ở bài 8.

  1. Với ba giá trị ttl: 55, 120, 200 — hàm trả về chuỗi nào cho mỗi giá trị?
  2. Nếu ttl_of() trả về None (thiết bị chặn ping), dòng code nào chạy, và trả về gì?
Xem đáp án
  1. ttl=55 → đúng ngay ở nhánh đầu tiên (≤64) → "nhom Linux/macOS/iOS/Android (TTL 55)". ttl=120 → nhánh đầu sai (120 > 64) nên không dừng ở đó, rơi xuống nhánh ≤128, đúng → "nhom Windows (TTL 120)". ttl=200 → cả hai nhánh if đều sai, rơi tới dòng return cuối cùng → "thiet bi mang/may in (TTL 200)".
  2. Dòng if ttl is None: chạy và trả về "khong ro (thiet bi chan ping)" ngay — không chạy tới bất kỳ phép so sánh ttl <= ... nào, vì so sánh None <= 64 sẽ gây lỗi nếu lỡ chạy tới.
Đọc thêm: TTL có đáng tin tuyệt đối không? (không bắt buộc)

Không. TTL gốc chỉ là một con số mặc định của từng hệ điều hành — phần mềm hoàn toàn có thể tự đổi nó, và nhiều thiết bị nhúng (robot hút bụi, loa thông minh) cũng dùng chung TTL gốc 64 hoặc 255 dù không phải máy tính hay máy in. Bài 8 đã nhắc: luôn ghép TTL với hãng (MAC) và cổng mở, đừng kết luận chỉ từ một con số.

04

Bước 3 · Code lấy cổng mở bằng giải pháp nào

40 phút
Tạo socketỐng nói chuyện TCP
connect_ex() hoặc connect()Hai cách hỏi khác nhau
Đọc phản ứngMở? Không mở? Vì sao khác nhau?
LuồngVì sao hỏi hàng chục cổng vẫn nhanh

Vì sao bước này nằm ở đây: đã biết thiết bị nào tồn tại (Bước 1) và đoán được hệ điều hành (Bước 2), câu hỏi tiếp theo là "thiết bị đó đang chạy dịch vụ gì?" — và cách duy nhất để biết một dịch vụ mạng có đang chạy hay không là thử nối vào đúng cổng của nó, không có cách nào khác để hỏi câu đó mà không thử kết nối thật. Nhiệm vụ của Bước 3 là hiểu code làm việc "thử nối" đó, và vì sao cùng một bộ công cụ lại có hai cách viết khác nhau cho cùng một việc.

"Socket" là gì, và vì sao bắt buộc phải có nó: một chỉ là "đầu dây nói chuyện qua mạng" mà một chương trình tự mở ra — giống việc nhấc ống nghe điện thoại lên trước khi bấm số: nhấc ống nghe chưa nối được với ai, nhưng là việc bắt buộc phải làm trước, vì không có "đầu dây" thì không có gì để gửi tín hiệu qua. Muốn hỏi "cổng 80 của máy này có ai lắng nghe không", chương trình bắt buộc phải tạo một socket TCP rồi thử connect() vào đúng IP:port đó — đây là cách duy nhất, không có đường tắt nào khác.

Giải pháp cụ thể: "Quét port" chỉ là lặp lại việc mở socket, thử kết nối, đọc phản ứng rất nhiều lần, một cổng một lần. Điều thú vị là lan_inventory.py và port_scan.py (bài 8 cũng chạy file này) cùng làm việc đó, nhưng viết khác nhau — vì mục đích khác nhau, đọc kỹ ở hai mục dưới đây.

BƯỚC 1Đọc scan_ports() — lời giải dùng bên trong lan_inventory.py

build_inventory() (Bước 1) gọi hàm này cho mỗi thiết bị đã tìm thấy, để biết thiết bị đó đang mở cổng nào trong danh sách cổng quen thuộc. Danh sách đó là biến PORTS, khai báo ngay đầu file — đây đúng là dòng khai báo thật, lấy từ file:

PYTHON · khai báo PORTS, đầu file lan_inventory.py
PORTS = {21: "ftp", 22: "ssh", 23: "telnet", 53: "dns", 80: "http", 139: "netbios", 443: "https",
         445: "smb", 554: "rtsp (camera)", 631: "ipp (may in)", 3389: "rdp", 5000: "http-alt/airplay",
         7000: "airplay", 8000: "http-alt", 8008: "chromecast", 8080: "http-proxy", 8443: "https-alt",
         9100: "may in", 62078: "iphone-sync"}

PORTS là một dict — bảng tra: số cổng (khóa) → tên dịch vụ thường chạy ở đó (giá trị). scan_ports() chỉ thử đúng các cổng có trong bảng này, không quét toàn bộ 65535 cổng:

Luồng chạy của scan_ports(ip)

  1. Thử kết nối 1 portprobe(port): connect_ex() trả 0 là mở
  2. Thử mọi port cùng lúcNhiều luồng, không thử lần lượt
  3. Giữ port mởBỏ None, sắp xếp nhỏ đến lớn
  4. Ghi dịch vụ và bannerTên dịch vụ quen thuộc + câu tự giới thiệu
PYTHON · scan_ports() trong lan_inventory.py
def scan_ports(ip):
    # Tìm các port đang mở của MỘT thiết bị: thử kết nối TCP tới từng port trong PORTS.
    def probe(port):
        # [1] Thử kết nối TCP tới 1 port, chờ tối đa 0,5 giây. connect_ex() trả 0
        #     nếu kết nối thành công (= port mở), trả mã lỗi khác 0 nếu không (không
        #     ném exception như connect()). Mở -> trả số port, không mở -> None
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.settimeout(0.5)
            return port if s.connect_ex((ip, port)) == 0 else None
    # [2] Thử mọi port CÙNG LÚC bằng nhiều luồng (thread), không thử lần lượt: nhanh
    #     hơn rất nhiều vì phần lớn thời gian là ĐỢI
    with ThreadPoolExecutor(max_workers=len(PORTS)) as pool:
        # [3] Giữ lại port mở (bỏ None), sắp xếp từ nhỏ đến lớn
        open_ports = sorted(p for p in pool.map(probe, PORTS) if p)
    # [4] Với mỗi port mở: ghi tên dịch vụ quen thuộc và đọc "banner" (câu tự giới
    #     thiệu của dịch vụ, nếu có)
    return [{"port": p, "service": PORTS[p], "banner": grab_banner(ip, p)} for p in open_ports]
PhầnÝ nghĩa
def scan_ports(ip):Định nghĩa hàm tên scan_ports, nhận vào 1 giá trị: ip.
def probe(port):Một hàm lồng bên trong hàm khác — probe này chỉ tồn tại bên trong scan_ports(), dùng xong là mất, không gọi lại được ở nơi khác trong file. Khác với hàm probe() ở Bước dưới, dù trùng tên.
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:Tạo socket TCP, đặt tên tắt s, như đã học.
s.settimeout(0.5)Chờ tối đa 0,5 giây mỗi cổng — ngắn hơn port_scan.py (mặc định 2 giây), vì đây chỉ cần biết CÓ/KHÔNG, không cần phân biệt kỹ nguyên nhân.
return port if s.connect_ex((ip, port)) == 0 else NoneMột cách viết if/else gọn trên một dòng: nếu connect_ex() trả về 0 (mở), trả về chính port đó; nếu không, trả về None.
with ThreadPoolExecutor(max_workers=len(PORTS)) as pool:Tạo hồ bơi luồng — đúng kỹ thuật sẽ học kỹ ở Bước 3 phần dưới — với số luồng đúng bằng số cổng cần thử.
open_ports = sorted(p for p in pool.map(probe, PORTS) if p)Chạy probe cho mọi cổng trong PORTS, giữ lại những kết quả khác None (tức là có mở), rồi sắp xếp tăng dần.
return [{"port": p, ...} for p in open_ports]Với mỗi cổng mở, dựng một bộ thông tin (dịch vụ, banner) rồi trả về cả danh sách.

scan_ports() thật dùng vài cách viết gọn (ternary, generator, list comprehension) — gọn cho người quen đọc, nhưng dễ rối cho người mới. Đây là đúng hàm đó, viết lại bằng if/else và vòng lặp for cơ bản, chạy ra kết quả giống hệt:

TƯƠNG ĐƯƠNG VIẾT DÀI, DÙNG for VÀ if/else CƠ BẢN (CHỈ ĐỂ GIẢI THÍCH, KHÔNG CÓ TRONG FILE THẬT)
def scan_ports(ip):
    def probe(port):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.settimeout(0.5)
            ket_qua = s.connect_ex((ip, port))
            if ket_qua == 0:
                return port
            else:
                return None

    open_ports = []
    with ThreadPoolExecutor(max_workers=len(PORTS)) as pool:
        for ket_qua in pool.map(probe, PORTS):
            if ket_qua:
                open_ports.append(ket_qua)
    open_ports.sort()

    danh_sach = []
    for p in open_ports:
        danh_sach.append({"port": p, "service": PORTS[p], "banner": grab_banner(ip, p)})
    return danh_sach

Để ý: probe(port) ở đây dùng connect_ex() — trả thẳng về một số, không ném exception. Đây đúng là "người anh em giữ nguyên kiểu C" đã nhắc trong bài: gọn hơn try/except vì chỉ cần so sánh một con số.

LUYỆN NHANH 3 · 15 PHÚT

Tự chạy scan_ports() thật — không cần quét cả mạng

Phần A — lý thuyết, chỉ nhìn code vừa đọc, không chạy gì cả:

  1. scan_ports() dùng connect_ex(), không phân biệt closed và filtered như probe() ở port_scan.py (học ở mục dưới). Nếu một cổng trong PORTS bị tường lửa chặn âm thầm (không trả lời gì, phải đợi hết 0,5 giây mới biết), scan_ports() có đưa cổng đó vào danh sách cổng mở trả về không? Giải thích dựa theo đúng dòng code, không đoán.
  2. Nếu bạn tự sửa PORTS chỉ còn 5 cổng (thay vì 19 cổng như hiện tại), dòng with ThreadPoolExecutor(max_workers=len(PORTS)) as pool: sẽ tạo bao nhiêu luồng?

Phần B — tự chạy code thật: lan_inventory.py có sẵn một cờ --only để gọi thẳng scan_ports() trên một IP, không cần quét cả dải mạng (xem đoạn if args.only: trong main() nếu muốn đọc trước).

  1. Mở một terminal, chạy python3 -m http.server 8080 --bind 127.0.0.1 (hoặc py -m http.server 8080 --bind 127.0.0.1).
  2. Mở terminal khác (cùng thư mục đã tải lan_inventory.py), chạy python3 lan_inventory.py --only 127.0.0.1 (hoặc py lan_inventory.py --only 127.0.0.1). Dán lại toàn bộ kết quả in ra, rồi dừng server ở terminal đầu bằng Ctrl+C.
Xem đáp án
  1. Không. Dòng return port if s.connect_ex((ip, port)) == 0 else None chỉ coi == 0 là mở; bất kỳ giá trị khác 0 — dù là bị từ chối ngay (closed) hay hết 0,5 giây không ai trả lời (filtered) — đều rơi vào nhánh else None như nhau. scan_ports() không có cách nào phân biệt hai trường hợp đó; nó chỉ trả lời được "mở" hay "không mở".
  2. 5 luồng. max_workers=len(PORTS) luôn tính lại theo đúng số phần tử hiện có trong PORTS, không phải một số cố định viết sẵn — sửa PORTS thì số luồng tự đổi theo.
  3. Kết quả mẫu (đã chạy thử; cổng và banner trên máy bạn có thể khác):
    KẾT QUẢ ĐÃ CHẠY THỬ
    Port mo tren 127.0.0.1: 8080/http-proxy
        8080  http-proxy          SimpleHTTP/0.6 Python/3.9.6
    DOAN: chua du dau hieu
    Dòng đầu liệt kê đúng cổng 8080 (http.server vừa mở) kèm banner thật đọc được từ grab_banner(). Lưu ý nếu dùng macOS: có thể thấy thêm cổng 5000 và/hoặc 7000 "mở" dù bạn không mở gì ở đó — đó là dịch vụ AirPlay có sẵn của macOS (Control Center), không phải do lệnh bạn vừa chạy; khi đó dòng DOAN: có thể ra "Mac/Apple TV (AirPlay)" dù máy bạn chỉ đang chạy thử phần luyện nhanh — một ví dụ thật cho thấy guess_device() (Bước 4) chỉ là gợi ý, không phải kết luận chắc chắn.

BƯỚC 2So sánh với cách "chi tiết" hơn: probe() trong port_scan.py

Khi người dùng tự gõ lệnh quét một địa chỉ cụ thể (như bài 8 từng làm với port_scan.py), công cụ nên cho biết rõ cổng nào chắc chắn không ai nghe (closed, có bằng chứng) khác với cổng nào im lặng vì tường lửa (filtered, không chắc) — hữu ích để chẩn đoán. Khi tự động quét hàng chục thiết bị để làm kiểm kê như scan_ports() ở trên, chỉ cần biết CÓ mở hay KHÔNG là đủ; phân biệt kỹ 3 trạng thái chỉ thêm việc không cần thiết. Đây là lý do port_scan.py chọn cách viết khác hẳn:

Luồng chạy của probe(ip, port, timeout)

  1. Tạo socket TCPKiểu có bắt tay ba bước
  2. Đặt thời gian chờIm lặng quá timeout giây thì ngắt
  3. Thử connect() rồi phân loạiThành công = open · bị từ chối = closed · im lặng = filtered
PYTHON · probe() trong port_scan.py
def probe(ip, port, timeout):
    # Thử "gõ cửa" một port bằng một kết nối TCP.
    # Ba kết quả có thể: open / closed / filtered.
    # [1] Tạo socket TCP (SOCK_STREAM: có bắt tay ba bước)
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        # [2] Chỉ chờ tối đa `timeout` giây; im lặng quá lâu thì ngắt, không để
        #     chương trình treo
        s.settimeout(timeout)
        try:
            # [3] Kết nối thành công = bắt tay xong = có dịch vụ đang nghe -> "open"
            s.connect((ip, port))
            return port, "open"
        except ConnectionRefusedError:
            return port, "closed"      # có người trả lời "không có dịch vụ ở đây" (gói RST) -> "closed"
        except (socket.timeout, OSError):
            return port, "filtered"    # im lặng hoặc không tới được: thường là tường lửa chặn -> "filtered"
Trước khi đọc kỹ từng dòng, hiểu ý chính bằng một ví vonHàm này giống hành động gõ cửa một phòng, rồi lắng nghe phản ứng: có người ra mở cửa ngay (open); có tiếng vọng ra ngay "ở đây không có ai nghe cổng này, đừng gõ nữa" (closed — đây là gói TCP RST); hoặc gõ mãi, đợi mãi, không một tiếng động nào cho tới khi hết giờ chờ, mà không rõ vì phòng trống hay vì có khóa cố ý không trả lời (filtered). Hình dưới vẽ lại đúng ba cảnh đó bằng ba lần gọi connect_ex() thật.
Máy bạnconnect_ex((ip,port))Cổng 8800 · có người nghebắt tay xongOPEN== 0Máy bạnconnect_ex((ip,port))Cổng 8801 · không ai ngheRST — "đừng gõ nữa"CLOSEDConnectionRefusedErrorMáy bạnconnect_ex((ip,port))Cổng 59999 · tường lửa chặn…im lặng, không gói nào quay lại…⏱ chỉ dừng khi hết timeoutFILTEREDsocket.timeout / OSError
Hình 3. Cùng một dòng connect_ex(), ba cổng khác nhau cho ba phản ứng khác nhau. Filtered không có gói nào quay lại — chỉ biết được nhờ hết timeout, không phải một câu trả lời rõ ràng như hai trường hợp còn lại.

Giờ đọc từng dòng một, không bỏ sót dòng nào, đúng thứ tự Python sẽ chạy:

PhầnÝ nghĩa
def probe(ip, port, timeout):Định nghĩa hàm tên probe. Khi hàm này được gọi, nó nhận vào 3 giá trị theo đúng thứ tự: ip (địa chỉ cần gõ cửa), port (số cổng), timeout (chờ tối đa bao nhiêu giây).
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:Tạo một socket kiểu , đặt tên tắt là s. with bảo đảm s được đóng lại ngay khi xong việc, dù thành công hay lỗi — không cần tự tay đóng.
s.settimeout(timeout)Đặt giới hạn chờ cho s, đúng bằng số giây được truyền vào lúc gọi hàm. Không có dòng này, một port "im lặng" sẽ treo chương trình vô thời hạn.
try:Bắt đầu khối "thử làm". Nếu dòng bên trong gây lỗi, Python nhảy thẳng xuống đúng dòng except phù hợp bên dưới, bỏ qua phần còn lại của khối try.
s.connect((ip, port))Thử bắt tay ba bước của TCP (bài 6) — đây chính là hành động "gõ cửa". Dòng này không trả về số lỗi như connect_ex() ở scan_ports(); nếu có lỗi, Python ném ra một exception (một tín hiệu báo lỗi đặc biệt) thay vì chạy tiếp.
return port, "open"Chỉ chạy tới đây nếu dòng trên không lỗi (bắt tay xong). Hàm dừng ngay, trả về một cặp giá trị: đúng port vừa thử, và chữ "open".
except ConnectionRefusedError:Nếu dòng connect() ở trên gây đúng loại lỗi này — nghĩa là thiết bị có tồn tại và chủ động trả lời "ở đây không có ai nghe cổng này" bằng một gói TCP RST (đúng "tiếng vọng ra ngay" ở ví von trên) — thì nhảy vào đây.
return port, "closed"Trả về port đó cùng chữ "closed".
except (socket.timeout, OSError):Nếu lỗi là một trong hai loại này — hết giờ chờ mà không nhận được gì, hoặc gói bị tường lửa âm thầm bỏ — thì nhảy vào đây thay vì dòng except phía trên.
return port, "filtered"Trả về port đó cùng chữ "filtered" (nghĩa gần với "không biết chắc", không phải "chắc chắn đóng").
Ba trạng thái, không phải haiDễ nghĩ port chỉ có "mở" hoặc "đóng". Code cho thấy có ba khả năng, và "filtered" nghĩa là "chưa biết", không phải "an toàn".

LUYỆN NHANH 4 · 10 PHÚT

Đọc một đoạn log quét và gọi đúng tên trạng thái

Một công cụ quét khác (giả định, cùng logic) ghi log sau cho ba port:

LOG MINH HỌA
port 22  -> ket noi thanh cong
port 23  -> ConnectionRefusedError
port 445 -> het 2.0s, khong nhan duoc gi
  1. Gọi đúng tên trạng thái (open/closed/filtered) cho từng port.
  2. Port nào bạn chắc chắn nhất là không có dịch vụ nào đang chạy ở đó? Vì sao?
Xem đáp án
  1. 22: open. 23: closed (bị từ chối, có bằng chứng). 445: filtered (im lặng, hết timeout).
  2. Port 23 — vì có bằng chứng chủ động (gói RST) rằng không ai lắng nghe. Port 445 chỉ là "không trả lời trong lúc chờ", có thể do tường lửa, không phải bằng chứng là không có dịch vụ.
Đọc thêm: vì sao connect() dùng exception mà không trả về mã lỗi như C (không bắt buộc)

Thư viện socket của Python bọc lại các hàm hệ thống gốc (viết bằng C, nơi một hàm thường trả về số nguyên: 0 là thành công, số khác là mã lỗi — đúng con số connect_ex() dùng ở scan_ports()). Python chọn ném exception khi có lỗi, để code gọi không thể "quên kiểm tra" kết quả — thiếu except thì chương trình dừng hẳn và báo rõ dòng gây lỗi, thay vì âm thầm chạy tiếp với một con số sai. connect_ex() vẫn giữ kiểu C vì tiện khi chỉ cần một con số để so sánh ngay, không cần try/except.

BƯỚC 3Tự viết lại cùng logic trong một file — không gõ REPL

Thay vì gõ từng dòng vào chế độ gõ-tương-tác của Python, cách làm thật trong công việc là lưu thành file rồi chạy — dễ sửa lại, chạy lại, chia sẻ cho người khác. Mở một terminal, tạo server tạm ở port 8800 để có "một cổng chắc chắn mở" làm ví dụ:

MACOS
python3 -m http.server 8800 --bind 127.0.0.1
WINDOWS
py -m http.server 8800 --bind 127.0.0.1
LINUX
python3 -m http.server 8800 --bind 127.0.0.1

Ở terminal khác, tạo file nhỏ chỉ viết lại đúng logic connect_ex() vừa đọc, thử trên hai cổng — một cổng chắc chắn mở (8800), một cổng chắc chắn không mở (54321):

thu_port.py · Dùng lại file này nếu đã tải ở bài trước, hoặc tải vào thư mục lab đang dùng (lan-lab, vuln-lab, hoặc tạo mới code-lab).

↓ Tải thu_port.py
Xem mã Python · có tô màu và số dòng
PYTHON · thu_port.py
#!/usr/bin/env python3
# thu_port.py - ban tu viet lai dung logic cua connect_ex(), luu thanh FILE roi chay - khong go tung dong vao REPL.
# Truoc khi chay: mo mot cua so terminal khac, go  python3 -m http.server 8800 --bind 127.0.0.1  de co mot cong dang mo.
import socket

def thu(port):
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(1)
        return s.connect_ex(("127.0.0.1", port))

for port in (8800, 54321):
    ket_qua = thu(port)
    trang_thai = "MO (ket qua 0)" if ket_qua == 0 else f"KHONG mo (ket qua {ket_qua})"
    print(f"Cong {port}: {trang_thai}")
Tạo file bằng terminal · chọn hệ điều hành của bạn
MACOS: TẠO thu_port.py
cat > thu_port.py <<'EOF'
#!/usr/bin/env python3
# thu_port.py - ban tu viet lai dung logic cua connect_ex(), luu thanh FILE roi chay - khong go tung dong vao REPL.
# Truoc khi chay: mo mot cua so terminal khac, go  python3 -m http.server 8800 --bind 127.0.0.1  de co mot cong dang mo.
import socket

def thu(port):
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(1)
        return s.connect_ex(("127.0.0.1", port))

for port in (8800, 54321):
    ket_qua = thu(port)
    trang_thai = "MO (ket qua 0)" if ket_qua == 0 else f"KHONG mo (ket qua {ket_qua})"
    print(f"Cong {port}: {trang_thai}")
EOF
WINDOWS POWERSHELL: TẠO thu_port.py
@'
#!/usr/bin/env python3
# thu_port.py - ban tu viet lai dung logic cua connect_ex(), luu thanh FILE roi chay - khong go tung dong vao REPL.
# Truoc khi chay: mo mot cua so terminal khac, go  python3 -m http.server 8800 --bind 127.0.0.1  de co mot cong dang mo.
import socket

def thu(port):
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(1)
        return s.connect_ex(("127.0.0.1", port))

for port in (8800, 54321):
    ket_qua = thu(port)
    trang_thai = "MO (ket qua 0)" if ket_qua == 0 else f"KHONG mo (ket qua {ket_qua})"
    print(f"Cong {port}: {trang_thai}")
'@ | Set-Content -Path thu_port.py -Encoding utf8
LINUX: TẠO thu_port.py
cat > thu_port.py <<'EOF'
#!/usr/bin/env python3
# thu_port.py - ban tu viet lai dung logic cua connect_ex(), luu thanh FILE roi chay - khong go tung dong vao REPL.
# Truoc khi chay: mo mot cua so terminal khac, go  python3 -m http.server 8800 --bind 127.0.0.1  de co mot cong dang mo.
import socket

def thu(port):
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(1)
        return s.connect_ex(("127.0.0.1", port))

for port in (8800, 54321):
    ket_qua = thu(port)
    trang_thai = "MO (ket qua 0)" if ket_qua == 0 else f"KHONG mo (ket qua {ket_qua})"
    print(f"Cong {port}: {trang_thai}")
EOF
MACOS
python3 thu_port.py
WINDOWS
py thu_port.py
LINUX
python3 thu_port.py
KẾT QUẢ ĐÃ CHẠY THỬ
Cong 8800: MO (ket qua 0)
Cong 54321: KHONG mo (ket qua 61)

Số 61 có thể khác trên máy bạn (ví dụ 111 trên Linux) — không quan trọng bằng việc nó khác 0. Bạn vừa tự tay viết lại đúng cơ chế mà cả probe() và scan_ports() dựa vào, trong một file có thể sửa và chạy lại bất cứ lúc nào.

BƯỚC 4Chạy công cụ thật, rồi vì sao luồng giúp nó nhanh

Terminal B cần toàn bộ file port_scan.py (hàm probe() vừa đọc, cộng phần đọc tham số dòng lệnh và in kết quả) để chạy thật — vẫn dùng server ở port 8800 đang mở:

port_scan.py · Dùng lại file này nếu đã tải ở bài trước, hoặc tải vào thư mục lab đang dùng (lan-lab, vuln-lab, hoặc tạo mới code-lab).

↓ Tải port_scan.py
Xem mã Python · có tô màu và số dòng
PYTHON · port_scan.py
# port_scan.py - quét port TCP của MỘT thiết bị trong mạng của BẠN.
# Chỉ dùng thư viện có sẵn của Python 3.
# Chỉ chạy trên thiết bị của chính bạn hoặc nơi được cho phép.
# Chỉ nhận IP riêng (192.168.x.x, 10.x.x.x, 127.0.0.1...).
# Ví dụ: python3 port_scan.py 127.0.0.1            (quét các port phổ biến)
#        python3 port_scan.py 192.168.1.1 -p 1-1024
#        python3 port_scan.py 192.168.1.1 -p 22,80,443
import argparse
import ipaddress
import socket
import time
from concurrent.futures import ThreadPoolExecutor

COMMON = [21, 22, 23, 25, 53, 80, 110, 135, 139, 143, 443, 445, 554, 631, 993, 995, 1723, 3306, 3389,
          5000, 5900, 7000, 8000, 8008, 8080, 8443, 9100, 62078]
MAX_PORTS = 2000   # giới hạn số port mỗi lần quét, tránh gõ nhầm 1-65535 làm nghẽn máy


def parse_ports(text):
    # Đổi chuỗi người dùng gõ thành danh sách số port. Chấp nhận "22,80,443" hoặc
    # "1-1024" hoặc kết hợp "22,80,8000-8100"
    ports = set()
    for part in text.split(","):
        part = part.strip()
        if "-" in part:
            a, b = part.split("-", 1)
            ports.update(range(int(a), int(b) + 1))
        elif part:
            ports.add(int(part))
    if not ports or min(ports) < 1 or max(ports) > 65535:
        raise ValueError("port phai nam trong khoang 1-65535")
    return sorted(ports)


def probe(ip, port, timeout):
    # Thử "gõ cửa" một port bằng một kết nối TCP.
    # Ba kết quả có thể: open / closed / filtered.
    # [1] Tạo socket TCP (SOCK_STREAM: có bắt tay ba bước)
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        # [2] Chỉ chờ tối đa `timeout` giây; im lặng quá lâu thì ngắt, không để
        #     chương trình treo
        s.settimeout(timeout)
        try:
            # [3] Kết nối thành công = bắt tay xong = có dịch vụ đang nghe -> "open"
            s.connect((ip, port))
            return port, "open"
        except ConnectionRefusedError:
            return port, "closed"      # có người trả lời "không có dịch vụ ở đây" (gói RST) -> "closed"
        except (socket.timeout, OSError):
            return port, "filtered"    # im lặng hoặc không tới được: thường là tường lửa chặn -> "filtered"


def service_name(port):
    try:
        return socket.getservbyport(port, "tcp")
    except OSError:
        return "?"


def main():
    ap = argparse.ArgumentParser(description="Quet port TCP cua mot thiet bi trong mang cua ban")
    ap.add_argument("ip", help="IP rieng can quet, vi du 192.168.1.1 hoac 127.0.0.1")
    ap.add_argument("-p", "--ports", help="danh sach port, vi du 22,80,443 hoac 1-1024 (mac dinh: ~30 port pho bien)")
    ap.add_argument("--timeout", type=float, default=2.0, help="so giay cho moi port (mac dinh 2; router cham tra loi can timeout dai)")
    args = ap.parse_args()

    # [1] Phân tích chuỗi IP người dùng gõ (sai định dạng thì tự báo lỗi, dừng ngay)
    ip = ipaddress.ip_address(args.ip)
    # [2] Chốt chặn: chỉ nhận IP riêng hoặc 127.0.0.1; IP Internet bị từ chối ngay
    if not (ip.is_private or ip.is_loopback):
        raise SystemExit("Chi quet IP rieng (192.168.x.x, 10.x.x.x, 127.0.0.1...). Khong quet Internet.")
    # [3] Lấy danh sách port: người dùng gõ -p thì phân tích, không thì dùng bảng
    #     COMMON; gõ sai thì thoát kèm lý do
    try:
        ports = parse_ports(args.ports) if args.ports else COMMON
    except ValueError as e:
        raise SystemExit("Loi port: " + str(e))
    # [4] Giới hạn số port mỗi lần quét
    if len(ports) > MAX_PORTS:
        raise SystemExit(f"Toi da {MAX_PORTS} port moi lan. Ban nhap {len(ports)}.")

    print(f"Dang quet {ip} ({len(ports)} port), timeout {args.timeout}s moi port...")
    # [1] Ghi lại giờ bắt đầu để lát nữa tính xem quét mất bao lâu
    start = time.time()
    # [2] Tạo 100 luồng làm việc cùng lúc; mỗi luồng thử một port
    with ThreadPoolExecutor(max_workers=100) as pool:
        # [3] pool.map giao từng port cho một luồng đang rảnh, đợi tất cả xong rồi
        #     trả danh sách (port, trạng thái)
        results = list(pool.map(lambda p: probe(str(ip), p, args.timeout), ports))
    count = {"open": 0, "closed": 0, "filtered": 0}
    print("PORT    TRANG THAI  DICH VU")
    for port, state in results:
        count[state] += 1
        if state == "open":
            print(f"{port:<7} {state:<11} {service_name(port)}")
    print(f"\nmo: {count['open']}  dong: {count['closed']}  im lang (filtered): {count['filtered']}  "
          f"(xong sau {time.time() - start:.1f} giay)")


if __name__ == "__main__":
    main()
Tạo file bằng terminal · chọn hệ điều hành của bạn
MACOS: TẠO port_scan.py
cat > port_scan.py <<'EOF'
# port_scan.py - quét port TCP của MỘT thiết bị trong mạng của BẠN.
# Chỉ dùng thư viện có sẵn của Python 3.
# Chỉ chạy trên thiết bị của chính bạn hoặc nơi được cho phép.
# Chỉ nhận IP riêng (192.168.x.x, 10.x.x.x, 127.0.0.1...).
# Ví dụ: python3 port_scan.py 127.0.0.1            (quét các port phổ biến)
#        python3 port_scan.py 192.168.1.1 -p 1-1024
#        python3 port_scan.py 192.168.1.1 -p 22,80,443
import argparse
import ipaddress
import socket
import time
from concurrent.futures import ThreadPoolExecutor

COMMON = [21, 22, 23, 25, 53, 80, 110, 135, 139, 143, 443, 445, 554, 631, 993, 995, 1723, 3306, 3389,
          5000, 5900, 7000, 8000, 8008, 8080, 8443, 9100, 62078]
MAX_PORTS = 2000   # giới hạn số port mỗi lần quét, tránh gõ nhầm 1-65535 làm nghẽn máy


def parse_ports(text):
    # Đổi chuỗi người dùng gõ thành danh sách số port. Chấp nhận "22,80,443" hoặc
    # "1-1024" hoặc kết hợp "22,80,8000-8100"
    ports = set()
    for part in text.split(","):
        part = part.strip()
        if "-" in part:
            a, b = part.split("-", 1)
            ports.update(range(int(a), int(b) + 1))
        elif part:
            ports.add(int(part))
    if not ports or min(ports) < 1 or max(ports) > 65535:
        raise ValueError("port phai nam trong khoang 1-65535")
    return sorted(ports)


def probe(ip, port, timeout):
    # Thử "gõ cửa" một port bằng một kết nối TCP.
    # Ba kết quả có thể: open / closed / filtered.
    # [1] Tạo socket TCP (SOCK_STREAM: có bắt tay ba bước)
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        # [2] Chỉ chờ tối đa `timeout` giây; im lặng quá lâu thì ngắt, không để
        #     chương trình treo
        s.settimeout(timeout)
        try:
            # [3] Kết nối thành công = bắt tay xong = có dịch vụ đang nghe -> "open"
            s.connect((ip, port))
            return port, "open"
        except ConnectionRefusedError:
            return port, "closed"      # có người trả lời "không có dịch vụ ở đây" (gói RST) -> "closed"
        except (socket.timeout, OSError):
            return port, "filtered"    # im lặng hoặc không tới được: thường là tường lửa chặn -> "filtered"


def service_name(port):
    try:
        return socket.getservbyport(port, "tcp")
    except OSError:
        return "?"


def main():
    ap = argparse.ArgumentParser(description="Quet port TCP cua mot thiet bi trong mang cua ban")
    ap.add_argument("ip", help="IP rieng can quet, vi du 192.168.1.1 hoac 127.0.0.1")
    ap.add_argument("-p", "--ports", help="danh sach port, vi du 22,80,443 hoac 1-1024 (mac dinh: ~30 port pho bien)")
    ap.add_argument("--timeout", type=float, default=2.0, help="so giay cho moi port (mac dinh 2; router cham tra loi can timeout dai)")
    args = ap.parse_args()

    # [1] Phân tích chuỗi IP người dùng gõ (sai định dạng thì tự báo lỗi, dừng ngay)
    ip = ipaddress.ip_address(args.ip)
    # [2] Chốt chặn: chỉ nhận IP riêng hoặc 127.0.0.1; IP Internet bị từ chối ngay
    if not (ip.is_private or ip.is_loopback):
        raise SystemExit("Chi quet IP rieng (192.168.x.x, 10.x.x.x, 127.0.0.1...). Khong quet Internet.")
    # [3] Lấy danh sách port: người dùng gõ -p thì phân tích, không thì dùng bảng
    #     COMMON; gõ sai thì thoát kèm lý do
    try:
        ports = parse_ports(args.ports) if args.ports else COMMON
    except ValueError as e:
        raise SystemExit("Loi port: " + str(e))
    # [4] Giới hạn số port mỗi lần quét
    if len(ports) > MAX_PORTS:
        raise SystemExit(f"Toi da {MAX_PORTS} port moi lan. Ban nhap {len(ports)}.")

    print(f"Dang quet {ip} ({len(ports)} port), timeout {args.timeout}s moi port...")
    # [1] Ghi lại giờ bắt đầu để lát nữa tính xem quét mất bao lâu
    start = time.time()
    # [2] Tạo 100 luồng làm việc cùng lúc; mỗi luồng thử một port
    with ThreadPoolExecutor(max_workers=100) as pool:
        # [3] pool.map giao từng port cho một luồng đang rảnh, đợi tất cả xong rồi
        #     trả danh sách (port, trạng thái)
        results = list(pool.map(lambda p: probe(str(ip), p, args.timeout), ports))
    count = {"open": 0, "closed": 0, "filtered": 0}
    print("PORT    TRANG THAI  DICH VU")
    for port, state in results:
        count[state] += 1
        if state == "open":
            print(f"{port:<7} {state:<11} {service_name(port)}")
    print(f"\nmo: {count['open']}  dong: {count['closed']}  im lang (filtered): {count['filtered']}  "
          f"(xong sau {time.time() - start:.1f} giay)")


if __name__ == "__main__":
    main()
EOF
WINDOWS POWERSHELL: TẠO port_scan.py
@'
# port_scan.py - quét port TCP của MỘT thiết bị trong mạng của BẠN.
# Chỉ dùng thư viện có sẵn của Python 3.
# Chỉ chạy trên thiết bị của chính bạn hoặc nơi được cho phép.
# Chỉ nhận IP riêng (192.168.x.x, 10.x.x.x, 127.0.0.1...).
# Ví dụ: python3 port_scan.py 127.0.0.1            (quét các port phổ biến)
#        python3 port_scan.py 192.168.1.1 -p 1-1024
#        python3 port_scan.py 192.168.1.1 -p 22,80,443
import argparse
import ipaddress
import socket
import time
from concurrent.futures import ThreadPoolExecutor

COMMON = [21, 22, 23, 25, 53, 80, 110, 135, 139, 143, 443, 445, 554, 631, 993, 995, 1723, 3306, 3389,
          5000, 5900, 7000, 8000, 8008, 8080, 8443, 9100, 62078]
MAX_PORTS = 2000   # giới hạn số port mỗi lần quét, tránh gõ nhầm 1-65535 làm nghẽn máy


def parse_ports(text):
    # Đổi chuỗi người dùng gõ thành danh sách số port. Chấp nhận "22,80,443" hoặc
    # "1-1024" hoặc kết hợp "22,80,8000-8100"
    ports = set()
    for part in text.split(","):
        part = part.strip()
        if "-" in part:
            a, b = part.split("-", 1)
            ports.update(range(int(a), int(b) + 1))
        elif part:
            ports.add(int(part))
    if not ports or min(ports) < 1 or max(ports) > 65535:
        raise ValueError("port phai nam trong khoang 1-65535")
    return sorted(ports)


def probe(ip, port, timeout):
    # Thử "gõ cửa" một port bằng một kết nối TCP.
    # Ba kết quả có thể: open / closed / filtered.
    # [1] Tạo socket TCP (SOCK_STREAM: có bắt tay ba bước)
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        # [2] Chỉ chờ tối đa `timeout` giây; im lặng quá lâu thì ngắt, không để
        #     chương trình treo
        s.settimeout(timeout)
        try:
            # [3] Kết nối thành công = bắt tay xong = có dịch vụ đang nghe -> "open"
            s.connect((ip, port))
            return port, "open"
        except ConnectionRefusedError:
            return port, "closed"      # có người trả lời "không có dịch vụ ở đây" (gói RST) -> "closed"
        except (socket.timeout, OSError):
            return port, "filtered"    # im lặng hoặc không tới được: thường là tường lửa chặn -> "filtered"


def service_name(port):
    try:
        return socket.getservbyport(port, "tcp")
    except OSError:
        return "?"


def main():
    ap = argparse.ArgumentParser(description="Quet port TCP cua mot thiet bi trong mang cua ban")
    ap.add_argument("ip", help="IP rieng can quet, vi du 192.168.1.1 hoac 127.0.0.1")
    ap.add_argument("-p", "--ports", help="danh sach port, vi du 22,80,443 hoac 1-1024 (mac dinh: ~30 port pho bien)")
    ap.add_argument("--timeout", type=float, default=2.0, help="so giay cho moi port (mac dinh 2; router cham tra loi can timeout dai)")
    args = ap.parse_args()

    # [1] Phân tích chuỗi IP người dùng gõ (sai định dạng thì tự báo lỗi, dừng ngay)
    ip = ipaddress.ip_address(args.ip)
    # [2] Chốt chặn: chỉ nhận IP riêng hoặc 127.0.0.1; IP Internet bị từ chối ngay
    if not (ip.is_private or ip.is_loopback):
        raise SystemExit("Chi quet IP rieng (192.168.x.x, 10.x.x.x, 127.0.0.1...). Khong quet Internet.")
    # [3] Lấy danh sách port: người dùng gõ -p thì phân tích, không thì dùng bảng
    #     COMMON; gõ sai thì thoát kèm lý do
    try:
        ports = parse_ports(args.ports) if args.ports else COMMON
    except ValueError as e:
        raise SystemExit("Loi port: " + str(e))
    # [4] Giới hạn số port mỗi lần quét
    if len(ports) > MAX_PORTS:
        raise SystemExit(f"Toi da {MAX_PORTS} port moi lan. Ban nhap {len(ports)}.")

    print(f"Dang quet {ip} ({len(ports)} port), timeout {args.timeout}s moi port...")
    # [1] Ghi lại giờ bắt đầu để lát nữa tính xem quét mất bao lâu
    start = time.time()
    # [2] Tạo 100 luồng làm việc cùng lúc; mỗi luồng thử một port
    with ThreadPoolExecutor(max_workers=100) as pool:
        # [3] pool.map giao từng port cho một luồng đang rảnh, đợi tất cả xong rồi
        #     trả danh sách (port, trạng thái)
        results = list(pool.map(lambda p: probe(str(ip), p, args.timeout), ports))
    count = {"open": 0, "closed": 0, "filtered": 0}
    print("PORT    TRANG THAI  DICH VU")
    for port, state in results:
        count[state] += 1
        if state == "open":
            print(f"{port:<7} {state:<11} {service_name(port)}")
    print(f"\nmo: {count['open']}  dong: {count['closed']}  im lang (filtered): {count['filtered']}  "
          f"(xong sau {time.time() - start:.1f} giay)")


if __name__ == "__main__":
    main()
'@ | Set-Content -Path port_scan.py -Encoding utf8
LINUX: TẠO port_scan.py
cat > port_scan.py <<'EOF'
# port_scan.py - quét port TCP của MỘT thiết bị trong mạng của BẠN.
# Chỉ dùng thư viện có sẵn của Python 3.
# Chỉ chạy trên thiết bị của chính bạn hoặc nơi được cho phép.
# Chỉ nhận IP riêng (192.168.x.x, 10.x.x.x, 127.0.0.1...).
# Ví dụ: python3 port_scan.py 127.0.0.1            (quét các port phổ biến)
#        python3 port_scan.py 192.168.1.1 -p 1-1024
#        python3 port_scan.py 192.168.1.1 -p 22,80,443
import argparse
import ipaddress
import socket
import time
from concurrent.futures import ThreadPoolExecutor

COMMON = [21, 22, 23, 25, 53, 80, 110, 135, 139, 143, 443, 445, 554, 631, 993, 995, 1723, 3306, 3389,
          5000, 5900, 7000, 8000, 8008, 8080, 8443, 9100, 62078]
MAX_PORTS = 2000   # giới hạn số port mỗi lần quét, tránh gõ nhầm 1-65535 làm nghẽn máy


def parse_ports(text):
    # Đổi chuỗi người dùng gõ thành danh sách số port. Chấp nhận "22,80,443" hoặc
    # "1-1024" hoặc kết hợp "22,80,8000-8100"
    ports = set()
    for part in text.split(","):
        part = part.strip()
        if "-" in part:
            a, b = part.split("-", 1)
            ports.update(range(int(a), int(b) + 1))
        elif part:
            ports.add(int(part))
    if not ports or min(ports) < 1 or max(ports) > 65535:
        raise ValueError("port phai nam trong khoang 1-65535")
    return sorted(ports)


def probe(ip, port, timeout):
    # Thử "gõ cửa" một port bằng một kết nối TCP.
    # Ba kết quả có thể: open / closed / filtered.
    # [1] Tạo socket TCP (SOCK_STREAM: có bắt tay ba bước)
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        # [2] Chỉ chờ tối đa `timeout` giây; im lặng quá lâu thì ngắt, không để
        #     chương trình treo
        s.settimeout(timeout)
        try:
            # [3] Kết nối thành công = bắt tay xong = có dịch vụ đang nghe -> "open"
            s.connect((ip, port))
            return port, "open"
        except ConnectionRefusedError:
            return port, "closed"      # có người trả lời "không có dịch vụ ở đây" (gói RST) -> "closed"
        except (socket.timeout, OSError):
            return port, "filtered"    # im lặng hoặc không tới được: thường là tường lửa chặn -> "filtered"


def service_name(port):
    try:
        return socket.getservbyport(port, "tcp")
    except OSError:
        return "?"


def main():
    ap = argparse.ArgumentParser(description="Quet port TCP cua mot thiet bi trong mang cua ban")
    ap.add_argument("ip", help="IP rieng can quet, vi du 192.168.1.1 hoac 127.0.0.1")
    ap.add_argument("-p", "--ports", help="danh sach port, vi du 22,80,443 hoac 1-1024 (mac dinh: ~30 port pho bien)")
    ap.add_argument("--timeout", type=float, default=2.0, help="so giay cho moi port (mac dinh 2; router cham tra loi can timeout dai)")
    args = ap.parse_args()

    # [1] Phân tích chuỗi IP người dùng gõ (sai định dạng thì tự báo lỗi, dừng ngay)
    ip = ipaddress.ip_address(args.ip)
    # [2] Chốt chặn: chỉ nhận IP riêng hoặc 127.0.0.1; IP Internet bị từ chối ngay
    if not (ip.is_private or ip.is_loopback):
        raise SystemExit("Chi quet IP rieng (192.168.x.x, 10.x.x.x, 127.0.0.1...). Khong quet Internet.")
    # [3] Lấy danh sách port: người dùng gõ -p thì phân tích, không thì dùng bảng
    #     COMMON; gõ sai thì thoát kèm lý do
    try:
        ports = parse_ports(args.ports) if args.ports else COMMON
    except ValueError as e:
        raise SystemExit("Loi port: " + str(e))
    # [4] Giới hạn số port mỗi lần quét
    if len(ports) > MAX_PORTS:
        raise SystemExit(f"Toi da {MAX_PORTS} port moi lan. Ban nhap {len(ports)}.")

    print(f"Dang quet {ip} ({len(ports)} port), timeout {args.timeout}s moi port...")
    # [1] Ghi lại giờ bắt đầu để lát nữa tính xem quét mất bao lâu
    start = time.time()
    # [2] Tạo 100 luồng làm việc cùng lúc; mỗi luồng thử một port
    with ThreadPoolExecutor(max_workers=100) as pool:
        # [3] pool.map giao từng port cho một luồng đang rảnh, đợi tất cả xong rồi
        #     trả danh sách (port, trạng thái)
        results = list(pool.map(lambda p: probe(str(ip), p, args.timeout), ports))
    count = {"open": 0, "closed": 0, "filtered": 0}
    print("PORT    TRANG THAI  DICH VU")
    for port, state in results:
        count[state] += 1
        if state == "open":
            print(f"{port:<7} {state:<11} {service_name(port)}")
    print(f"\nmo: {count['open']}  dong: {count['closed']}  im lang (filtered): {count['filtered']}  "
          f"(xong sau {time.time() - start:.1f} giay)")


if __name__ == "__main__":
    main()
EOF

Quét ba port: một đang mở (8800), một gần như luôn đóng (8801), và một có khả năng bị lọc tùy máy (hãy thử port cao ít dùng, ví dụ 59999, với timeout rất ngắn để tăng khả năng gặp "filtered" nếu máy bạn có tường lửa cục bộ):

MACOS
python3 port_scan.py 127.0.0.1 -p 8800,8801,59999 --timeout 0.3
WINDOWS
py port_scan.py 127.0.0.1 -p 8800,8801,59999 --timeout 0.3
LINUX
python3 port_scan.py 127.0.0.1 -p 8800,8801,59999 --timeout 0.3
KẾT QUẢ MẪU (CỘT "?" LÀ DỊCH VỤ KHÔNG TRA RA ĐƯỢC TRÊN PORT LẠ)
Dang quet 127.0.0.1 (3 port), timeout 0.3s moi port...
PORT    TRANG THAI  DICH VU
8800    open        ?

mo: 1  dong: 2  im lang (filtered): 0

Chương trình chỉ in ra các port mở; phần tổng kết cuối cho biết số port đóng/im lặng mà không cần liệt kê từng cái. Dừng Terminal A bằng Ctrl+C khi xong. Bí quyết tốc độ nằm trong main(), ngay sau đoạn đọc tham số dòng lệnh:

Luồng chạy của đoạn quét trong main()

  1. Ghi giờ bắt đầutime.time() để tính quét mất bao lâu
  2. Tạo 100 luồngMỗi luồng thử một port
  3. Gom kết quảpool.map chờ tất cả xong, trả danh sách (port, trạng thái)
PYTHON · trích main() trong port_scan.py
    # [1] Ghi lại giờ bắt đầu để lát nữa tính xem quét mất bao lâu
    start = time.time()
    # [2] Tạo 100 luồng làm việc cùng lúc; mỗi luồng thử một port
    with ThreadPoolExecutor(max_workers=100) as pool:
        # [3] pool.map giao từng port cho một luồng đang rảnh, đợi tất cả xong rồi
        #     trả danh sách (port, trạng thái)
        results = list(pool.map(lambda p: probe(str(ip), p, args.timeout), ports))
PhầnÝ nghĩa
start = time.time()Ghi lại thời điểm hiện tại (một con số giây rất lớn) vào biến start, để lát nữa tính được đã trôi qua bao lâu.
with ThreadPoolExecutor(max_workers=100) as pool:Tạo một "hồ bơi" tối đa 100 luồng (thread) — đúng kỹ thuật đã thấy thoáng qua ở scan_ports() và poke() (Bước 1), giờ giải thích kỹ.
results = list(pool.map(lambda p: probe(str(ip), p, args.timeout), ports))lambda p: probe(...) là một hàm không tên, viết gọn tại chỗ: nó nhận một port p rồi gọi probe() với p đó (cộng ip, timeout đã có sẵn). pool.map(...) giao hàm này cho hồ bơi chạy với mỗi port trong ports, phân cho luồng nào đang rỗi, rồi gom hết kết quả lại đúng thứ tự thành một danh sách, gán vào results.

lambda dễ gây rối vì nó là một hàm không có tên, viết ngay trong dấu ngoặc. Cùng ý nghĩa, viết bằng một hàm bình thường, có tên, dễ đọc hơn:

TƯƠNG ĐƯƠNG VIẾT DÀI, DÙNG HÀM CÓ TÊN THAY VÌ lambda (CHỈ ĐỂ GIẢI THÍCH, KHÔNG CÓ TRONG FILE THẬT)
def tham_do_mot_cong(p):
    return probe(str(ip), p, args.timeout)

with ThreadPoolExecutor(max_workers=100) as pool:
    results = list(pool.map(tham_do_mot_cong, ports))

tham_do_mot_cong làm đúng một việc: nhận p (một port), gọi probe() với port đó cộng ip và args.timeout đã có sẵn từ bên ngoài. pool.map(tham_do_mot_cong, ports) gọi hàm này cho từng port trong ports, y hệt dòng lambda ở trên — chỉ khác là hàm có tên, có thể đọc tên để đoán việc nó làm.

Ví von: gọi 100 cuộc điện thoại hỏi "có ai ở nhà không?"Gọi từng số một: bấm số, nghe chuông reo, chờ hết 2 giây mới gọi số tiếp theo — 100 số × 2 giây = 200 giây. Có 100 cái điện thoại, bấm gọi gần hết 100 số cùng một lúc, rồi đi vòng quanh nghe từng cái khi nó có tín hiệu: tổng thời gian chỉ còn gần bằng một cuộc gọi lâu nhất, không phải cộng dồn cả 100. ThreadPoolExecutor làm đúng việc "100 cái điện thoại" đó — cho cả IP (Bước 1) lẫn port (ở đây).
Luồng không phải "nhiều lõi CPU cùng tính toán"Python có một cơ chế gọi là (Global Interpreter Lock): tại một thời điểm, chỉ một luồng được chạy mã Python. Nghe có vẻ luồng vô dụng — nhưng khi một luồng đang chờ mạng trả lời (gọi là chờ I/O, giống đang "nghe chuông reo" ở ví von trên), nó không cần CPU lúc đó, nên Python lập tức cho luồng khác chạy. Quét port là việc chờ nhiều, tính toán ít, nên luồng vẫn lợi rất nhiều, dù không "song song" theo nghĩa nhiều lõi CPU.

File so_sanh_toc_do.py đo trực tiếp "luồng giúp nhiều hay ít" bằng đúng kiểu code của probe(). Phần mới so với port_scan.py là hàm mô phỏng một port "im lặng" một cách ổn định, không phụ thuộc mạng hay tường lửa máy bạn:

Luồng chạy của slow_probe(n)

  1. Chờ SLOW_WAIT giâytime.sleep() giả lập cổng im lặng
  2. Trả về nĐể biết tác vụ nào đã xong
PYTHON · slow_probe() trong so_sanh_toc_do.py
def slow_probe(n):
    # MÔ PHỎNG một cổng "im lặng" (filtered): không ai trả lời, nên phải chờ
    # HẾT timeout mới biết. time.sleep() dùng thay cho connect() thật, chỉ
    # để demo an toàn và cho ra số giống nhau mỗi lần chạy. Code THẬT làm việc
    # này là probe() trong port_scan.py, khi gặp loại "filtered".
    # [1] Đứng chờ SLOW_WAIT giây, như một cổng im lặng làm ta phải chờ hết timeout
    time.sleep(SLOW_WAIT)
    # [2] Trả về số thứ tự của "cổng" này để biết tác vụ nào đã xong
    return n

Toàn bộ file, để chạy thật:

so_sanh_toc_do.py · Dùng lại file này nếu đã tải ở bài trước, hoặc tải vào thư mục lab đang dùng (lan-lab, vuln-lab, hoặc tạo mới code-lab).

↓ Tải so_sanh_toc_do.py
Xem mã Python · có tô màu và số dòng
PYTHON · so_sanh_toc_do.py
#!/usr/bin/env python3
# so_sanh_toc_do.py - đo tốc độ: quét TUẦN TỰ so với quét SONG SONG (threading).
# Phần A: quét THẬT cổng của CHÍNH MÁY BẠN (127.0.0.1) - an toàn, không gửi gì ra ngoài.
# Phần B: MÔ PHỎNG cổng "im lặng" (filtered) bằng time.sleep(), vì trên loopback
#         cổng đóng luôn trả lời ngay nên không thể thấy rõ lợi ích của threading.
import socket
import time
from concurrent.futures import ThreadPoolExecutor

HOST = "127.0.0.1"
PORTS_A = list(range(1, 4001))   # phần A: 4000 cổng đầu tiên trên máy bạn
TIMEOUT_A = 0.3                  # số giây chờ mỗi cổng ở phần A
SLOW_TASKS = 30                  # phần B: số "cổng" giả lập
SLOW_WAIT = 0.3                  # số giây mỗi "cổng" giả lập phải chờ - giống TIMEOUT_A


def probe(port):
    # Mở một socket TCP, thử connect(). connect_ex() trả về 0 nếu thành công,
    # trả về MÃ LỖI (số nguyên, ví dụ 61 là "từ chối") nếu không - khác với
    # connect() thường, sẽ ném exception thay vì trả về số.
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(TIMEOUT_A)
        return port, s.connect_ex((HOST, port)) == 0


def slow_probe(n):
    # MÔ PHỎNG một cổng "im lặng" (filtered): không ai trả lời, nên phải chờ
    # HẾT timeout mới biết. time.sleep() dùng thay cho connect() thật, chỉ
    # để demo an toàn và cho ra số giống nhau mỗi lần chạy. Code THẬT làm việc
    # này là probe() trong port_scan.py, khi gặp loại "filtered".
    # [1] Đứng chờ SLOW_WAIT giây, như một cổng im lặng làm ta phải chờ hết timeout
    time.sleep(SLOW_WAIT)
    # [2] Trả về số thứ tự của "cổng" này để biết tác vụ nào đã xong
    return n


def run_sequential(fn, items):
    return [fn(x) for x in items]


def run_threaded(fn, items, workers):
    # ThreadPoolExecutor: trong lúc một luồng đang CHỜ (I/O - chờ socket, chờ
    # sleep), Python chuyển sang chạy luồng khác ngay, nên tổng thời gian gần
    # bằng thời gian của phần CHẬM NHẤT, không phải TỔNG thời gian tất cả.
    with ThreadPoolExecutor(max_workers=workers) as pool:
        return list(pool.map(fn, items))


def phan_a():
    print(f"--- PHAN A: quet THAT {len(PORTS_A)} cong tren {HOST} ---")
    start = time.time()
    seq = run_sequential(probe, PORTS_A)
    seq_time = time.time() - start

    start = time.time()
    par = run_threaded(probe, PORTS_A, workers=200)
    par_time = time.time() - start

    print(f"TUAN TU   : {seq_time:6.2f} giay")
    print(f"SONG SONG : {par_time:6.2f} giay")
    print("Nhan xet: tren loopback, cong dong tra loi NGAY (RST), hau nhu khong co gi de 'cho'.")
    print("Vi vay threading it hoac khong giup gi o day - co khi con cham hon vi ton chi phi tao luong.\n")


def phan_b():
    print(f"--- PHAN B: mo phong {SLOW_TASKS} cong 'im lang', moi cong cho {SLOW_WAIT}s ---")
    start = time.time()
    run_sequential(slow_probe, range(SLOW_TASKS))
    seq_time = time.time() - start

    start = time.time()
    run_threaded(slow_probe, range(SLOW_TASKS), workers=SLOW_TASKS)
    par_time = time.time() - start

    print(f"TUAN TU   : {seq_time:6.2f} giay   (~ {SLOW_TASKS} x {SLOW_WAIT}s)")
    print(f"SONG SONG : {par_time:6.2f} giay   (~ 1 x {SLOW_WAIT}s, ca {SLOW_TASKS} cong cung cho)")
    if par_time > 0:
        print(f"Nhanh hon khoang {seq_time / par_time:.0f} lan - day moi la luc threading co ich thuc su.")


if __name__ == "__main__":
    phan_a()
    phan_b()
Tạo file bằng terminal · chọn hệ điều hành của bạn
MACOS: TẠO so_sanh_toc_do.py
cat > so_sanh_toc_do.py <<'EOF'
#!/usr/bin/env python3
# so_sanh_toc_do.py - đo tốc độ: quét TUẦN TỰ so với quét SONG SONG (threading).
# Phần A: quét THẬT cổng của CHÍNH MÁY BẠN (127.0.0.1) - an toàn, không gửi gì ra ngoài.
# Phần B: MÔ PHỎNG cổng "im lặng" (filtered) bằng time.sleep(), vì trên loopback
#         cổng đóng luôn trả lời ngay nên không thể thấy rõ lợi ích của threading.
import socket
import time
from concurrent.futures import ThreadPoolExecutor

HOST = "127.0.0.1"
PORTS_A = list(range(1, 4001))   # phần A: 4000 cổng đầu tiên trên máy bạn
TIMEOUT_A = 0.3                  # số giây chờ mỗi cổng ở phần A
SLOW_TASKS = 30                  # phần B: số "cổng" giả lập
SLOW_WAIT = 0.3                  # số giây mỗi "cổng" giả lập phải chờ - giống TIMEOUT_A


def probe(port):
    # Mở một socket TCP, thử connect(). connect_ex() trả về 0 nếu thành công,
    # trả về MÃ LỖI (số nguyên, ví dụ 61 là "từ chối") nếu không - khác với
    # connect() thường, sẽ ném exception thay vì trả về số.
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(TIMEOUT_A)
        return port, s.connect_ex((HOST, port)) == 0


def slow_probe(n):
    # MÔ PHỎNG một cổng "im lặng" (filtered): không ai trả lời, nên phải chờ
    # HẾT timeout mới biết. time.sleep() dùng thay cho connect() thật, chỉ
    # để demo an toàn và cho ra số giống nhau mỗi lần chạy. Code THẬT làm việc
    # này là probe() trong port_scan.py, khi gặp loại "filtered".
    # [1] Đứng chờ SLOW_WAIT giây, như một cổng im lặng làm ta phải chờ hết timeout
    time.sleep(SLOW_WAIT)
    # [2] Trả về số thứ tự của "cổng" này để biết tác vụ nào đã xong
    return n


def run_sequential(fn, items):
    return [fn(x) for x in items]


def run_threaded(fn, items, workers):
    # ThreadPoolExecutor: trong lúc một luồng đang CHỜ (I/O - chờ socket, chờ
    # sleep), Python chuyển sang chạy luồng khác ngay, nên tổng thời gian gần
    # bằng thời gian của phần CHẬM NHẤT, không phải TỔNG thời gian tất cả.
    with ThreadPoolExecutor(max_workers=workers) as pool:
        return list(pool.map(fn, items))


def phan_a():
    print(f"--- PHAN A: quet THAT {len(PORTS_A)} cong tren {HOST} ---")
    start = time.time()
    seq = run_sequential(probe, PORTS_A)
    seq_time = time.time() - start

    start = time.time()
    par = run_threaded(probe, PORTS_A, workers=200)
    par_time = time.time() - start

    print(f"TUAN TU   : {seq_time:6.2f} giay")
    print(f"SONG SONG : {par_time:6.2f} giay")
    print("Nhan xet: tren loopback, cong dong tra loi NGAY (RST), hau nhu khong co gi de 'cho'.")
    print("Vi vay threading it hoac khong giup gi o day - co khi con cham hon vi ton chi phi tao luong.\n")


def phan_b():
    print(f"--- PHAN B: mo phong {SLOW_TASKS} cong 'im lang', moi cong cho {SLOW_WAIT}s ---")
    start = time.time()
    run_sequential(slow_probe, range(SLOW_TASKS))
    seq_time = time.time() - start

    start = time.time()
    run_threaded(slow_probe, range(SLOW_TASKS), workers=SLOW_TASKS)
    par_time = time.time() - start

    print(f"TUAN TU   : {seq_time:6.2f} giay   (~ {SLOW_TASKS} x {SLOW_WAIT}s)")
    print(f"SONG SONG : {par_time:6.2f} giay   (~ 1 x {SLOW_WAIT}s, ca {SLOW_TASKS} cong cung cho)")
    if par_time > 0:
        print(f"Nhanh hon khoang {seq_time / par_time:.0f} lan - day moi la luc threading co ich thuc su.")


if __name__ == "__main__":
    phan_a()
    phan_b()
EOF
WINDOWS POWERSHELL: TẠO so_sanh_toc_do.py
@'
#!/usr/bin/env python3
# so_sanh_toc_do.py - đo tốc độ: quét TUẦN TỰ so với quét SONG SONG (threading).
# Phần A: quét THẬT cổng của CHÍNH MÁY BẠN (127.0.0.1) - an toàn, không gửi gì ra ngoài.
# Phần B: MÔ PHỎNG cổng "im lặng" (filtered) bằng time.sleep(), vì trên loopback
#         cổng đóng luôn trả lời ngay nên không thể thấy rõ lợi ích của threading.
import socket
import time
from concurrent.futures import ThreadPoolExecutor

HOST = "127.0.0.1"
PORTS_A = list(range(1, 4001))   # phần A: 4000 cổng đầu tiên trên máy bạn
TIMEOUT_A = 0.3                  # số giây chờ mỗi cổng ở phần A
SLOW_TASKS = 30                  # phần B: số "cổng" giả lập
SLOW_WAIT = 0.3                  # số giây mỗi "cổng" giả lập phải chờ - giống TIMEOUT_A


def probe(port):
    # Mở một socket TCP, thử connect(). connect_ex() trả về 0 nếu thành công,
    # trả về MÃ LỖI (số nguyên, ví dụ 61 là "từ chối") nếu không - khác với
    # connect() thường, sẽ ném exception thay vì trả về số.
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(TIMEOUT_A)
        return port, s.connect_ex((HOST, port)) == 0


def slow_probe(n):
    # MÔ PHỎNG một cổng "im lặng" (filtered): không ai trả lời, nên phải chờ
    # HẾT timeout mới biết. time.sleep() dùng thay cho connect() thật, chỉ
    # để demo an toàn và cho ra số giống nhau mỗi lần chạy. Code THẬT làm việc
    # này là probe() trong port_scan.py, khi gặp loại "filtered".
    # [1] Đứng chờ SLOW_WAIT giây, như một cổng im lặng làm ta phải chờ hết timeout
    time.sleep(SLOW_WAIT)
    # [2] Trả về số thứ tự của "cổng" này để biết tác vụ nào đã xong
    return n


def run_sequential(fn, items):
    return [fn(x) for x in items]


def run_threaded(fn, items, workers):
    # ThreadPoolExecutor: trong lúc một luồng đang CHỜ (I/O - chờ socket, chờ
    # sleep), Python chuyển sang chạy luồng khác ngay, nên tổng thời gian gần
    # bằng thời gian của phần CHẬM NHẤT, không phải TỔNG thời gian tất cả.
    with ThreadPoolExecutor(max_workers=workers) as pool:
        return list(pool.map(fn, items))


def phan_a():
    print(f"--- PHAN A: quet THAT {len(PORTS_A)} cong tren {HOST} ---")
    start = time.time()
    seq = run_sequential(probe, PORTS_A)
    seq_time = time.time() - start

    start = time.time()
    par = run_threaded(probe, PORTS_A, workers=200)
    par_time = time.time() - start

    print(f"TUAN TU   : {seq_time:6.2f} giay")
    print(f"SONG SONG : {par_time:6.2f} giay")
    print("Nhan xet: tren loopback, cong dong tra loi NGAY (RST), hau nhu khong co gi de 'cho'.")
    print("Vi vay threading it hoac khong giup gi o day - co khi con cham hon vi ton chi phi tao luong.\n")


def phan_b():
    print(f"--- PHAN B: mo phong {SLOW_TASKS} cong 'im lang', moi cong cho {SLOW_WAIT}s ---")
    start = time.time()
    run_sequential(slow_probe, range(SLOW_TASKS))
    seq_time = time.time() - start

    start = time.time()
    run_threaded(slow_probe, range(SLOW_TASKS), workers=SLOW_TASKS)
    par_time = time.time() - start

    print(f"TUAN TU   : {seq_time:6.2f} giay   (~ {SLOW_TASKS} x {SLOW_WAIT}s)")
    print(f"SONG SONG : {par_time:6.2f} giay   (~ 1 x {SLOW_WAIT}s, ca {SLOW_TASKS} cong cung cho)")
    if par_time > 0:
        print(f"Nhanh hon khoang {seq_time / par_time:.0f} lan - day moi la luc threading co ich thuc su.")


if __name__ == "__main__":
    phan_a()
    phan_b()
'@ | Set-Content -Path so_sanh_toc_do.py -Encoding utf8
LINUX: TẠO so_sanh_toc_do.py
cat > so_sanh_toc_do.py <<'EOF'
#!/usr/bin/env python3
# so_sanh_toc_do.py - đo tốc độ: quét TUẦN TỰ so với quét SONG SONG (threading).
# Phần A: quét THẬT cổng của CHÍNH MÁY BẠN (127.0.0.1) - an toàn, không gửi gì ra ngoài.
# Phần B: MÔ PHỎNG cổng "im lặng" (filtered) bằng time.sleep(), vì trên loopback
#         cổng đóng luôn trả lời ngay nên không thể thấy rõ lợi ích của threading.
import socket
import time
from concurrent.futures import ThreadPoolExecutor

HOST = "127.0.0.1"
PORTS_A = list(range(1, 4001))   # phần A: 4000 cổng đầu tiên trên máy bạn
TIMEOUT_A = 0.3                  # số giây chờ mỗi cổng ở phần A
SLOW_TASKS = 30                  # phần B: số "cổng" giả lập
SLOW_WAIT = 0.3                  # số giây mỗi "cổng" giả lập phải chờ - giống TIMEOUT_A


def probe(port):
    # Mở một socket TCP, thử connect(). connect_ex() trả về 0 nếu thành công,
    # trả về MÃ LỖI (số nguyên, ví dụ 61 là "từ chối") nếu không - khác với
    # connect() thường, sẽ ném exception thay vì trả về số.
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(TIMEOUT_A)
        return port, s.connect_ex((HOST, port)) == 0


def slow_probe(n):
    # MÔ PHỎNG một cổng "im lặng" (filtered): không ai trả lời, nên phải chờ
    # HẾT timeout mới biết. time.sleep() dùng thay cho connect() thật, chỉ
    # để demo an toàn và cho ra số giống nhau mỗi lần chạy. Code THẬT làm việc
    # này là probe() trong port_scan.py, khi gặp loại "filtered".
    # [1] Đứng chờ SLOW_WAIT giây, như một cổng im lặng làm ta phải chờ hết timeout
    time.sleep(SLOW_WAIT)
    # [2] Trả về số thứ tự của "cổng" này để biết tác vụ nào đã xong
    return n


def run_sequential(fn, items):
    return [fn(x) for x in items]


def run_threaded(fn, items, workers):
    # ThreadPoolExecutor: trong lúc một luồng đang CHỜ (I/O - chờ socket, chờ
    # sleep), Python chuyển sang chạy luồng khác ngay, nên tổng thời gian gần
    # bằng thời gian của phần CHẬM NHẤT, không phải TỔNG thời gian tất cả.
    with ThreadPoolExecutor(max_workers=workers) as pool:
        return list(pool.map(fn, items))


def phan_a():
    print(f"--- PHAN A: quet THAT {len(PORTS_A)} cong tren {HOST} ---")
    start = time.time()
    seq = run_sequential(probe, PORTS_A)
    seq_time = time.time() - start

    start = time.time()
    par = run_threaded(probe, PORTS_A, workers=200)
    par_time = time.time() - start

    print(f"TUAN TU   : {seq_time:6.2f} giay")
    print(f"SONG SONG : {par_time:6.2f} giay")
    print("Nhan xet: tren loopback, cong dong tra loi NGAY (RST), hau nhu khong co gi de 'cho'.")
    print("Vi vay threading it hoac khong giup gi o day - co khi con cham hon vi ton chi phi tao luong.\n")


def phan_b():
    print(f"--- PHAN B: mo phong {SLOW_TASKS} cong 'im lang', moi cong cho {SLOW_WAIT}s ---")
    start = time.time()
    run_sequential(slow_probe, range(SLOW_TASKS))
    seq_time = time.time() - start

    start = time.time()
    run_threaded(slow_probe, range(SLOW_TASKS), workers=SLOW_TASKS)
    par_time = time.time() - start

    print(f"TUAN TU   : {seq_time:6.2f} giay   (~ {SLOW_TASKS} x {SLOW_WAIT}s)")
    print(f"SONG SONG : {par_time:6.2f} giay   (~ 1 x {SLOW_WAIT}s, ca {SLOW_TASKS} cong cung cho)")
    if par_time > 0:
        print(f"Nhanh hon khoang {seq_time / par_time:.0f} lan - day moi la luc threading co ich thuc su.")


if __name__ == "__main__":
    phan_a()
    phan_b()
EOF
MACOS
python3 so_sanh_toc_do.py
WINDOWS
py so_sanh_toc_do.py
LINUX
python3 so_sanh_toc_do.py
KẾT QUẢ ĐÃ CHẠY THỬ (SỐ GIÂY CÓ THỂ KHÁC MỘT CHÚT TRÊN MÁY BẠN)
--- PHAN A: quet THAT 4000 cong tren 127.0.0.1 ---
TUAN TU   :   0.14 giay
SONG SONG :   0.25 giay
Nhan xet: tren loopback, cong dong tra loi NGAY (RST), hau nhu khong co gi de 'cho'.
Vi vay threading it hoac khong giup gi o day - co khi con cham hon vi ton chi phi tao luong.

--- PHAN B: mo phong 30 cong 'im lang', moi cong cho 0.3s ---
TUAN TU   :   9.11 giay   (~ 30 x 0.3s)
SONG SONG :   0.31 giay   (~ 1 x 0.3s, ca 30 cong cung cho)
Nhanh hon khoang 30 lan - day moi la luc threading co ich thuc su.
TUẦN TỰ — mỗi lần chờ xong mới tới lần sau1⋯309,11 giây — 30 lần chờ 0,3s, nối đuôi nhauSONG SONG — tất cả bắt đầu chờ cùng một lúc← 30 việc, cùng chờ, cùng xong0,31 giây
Hình 4. Mỗi ô vuông là một lần "chờ 0,3 giây" (một cổng filtered trong so_sanh_toc_do.py). Tuần tự: 30 ô nối đuôi nhau trên một trục thời gian dài. Song song: 30 ô xếp chồng, cùng bắt đầu, cùng kết thúc — vì các luồng cùng chờ I/O, không cùng cần CPU.

Đọc kết quả: Phần A (quét thật trên loopback) cho thấy threading không giúp gì nhiều — thậm chí hơi chậm hơn. Lý do đúng như bảng vừa đọc ở trên: port đóng trên chính máy bạn trả lời RST gần như ngay lập tức, không có gì để "chờ", nên 200 luồng chỉ tốn thêm công tạo luồng mà không tiết kiệm được thời gian chờ nào. Phần B mô phỏng bằng time.sleep() đúng tình huống một port filtered thật — phải chờ hết timeout mới biết. Ở đây luồng nhanh hơn khoảng 30 lần, vì 30 "lần chờ 0,3 giây" được xếp chồng lên nhau thay vì nối tiếp.

Vì sao không mô phỏng bằng port thật ngoài mạngĐể chương trình luôn cho cùng kết quả, không phụ thuộc tường lửa hay mạng của từng máy, phần B dùng time.sleep() thay cho một port filtered thật. Về bản chất, đây đúng là điều xảy ra khi probe() gặp một port filtered thật trên một thiết bị trong mạng (bài 8): chương trình cũng phải chờ hết timeout, chỉ khác là lúc đó luồng khác cũng đang chờ mạng trả lời thật, không phải đang "giả ngủ".

LUYỆN NHANH 5 · 10 PHÚT

Tính nhanh: tuần tự mất bao lâu?

port_scan.py quét 30 port mặc định, timeout 2 giây, dùng tối đa 100 luồng. Giả sử thiết bị đang quét có tường lửa chặn âm thầm đúng 20 trong 30 port đó (filtered), 10 port còn lại trả lời closed gần như ngay (0 giây).

  1. Nếu quét tuần tự (không luồng), tổng thời gian tối thiểu khoảng bao nhiêu giây?
  2. Với 100 luồng chạy song song, tổng thời gian khoảng bao nhiêu giây?
Xem đáp án
  1. Khoảng 20 × 2 = 40 giây (10 port closed gần như không tốn thời gian, cộng thêm).
  2. Khoảng 2 giây — vì 100 luồng đủ chỗ cho cả 30 port chờ cùng lúc, tổng thời gian chỉ bằng đúng một lần chờ timeout, không phải 20 lần.

LUYỆN NHANH 6 · 12 PHÚT

Tự sửa code: đổi số liệu, dự đoán trước khi chạy

Mở file so_sanh_toc_do.py đã tải ở trên bằng một trình soạn thảo bất kỳ (Notepad, VS Code, bất cứ gì gõ chữ được).

  1. Tìm 2 dòng hằng số gần đầu file: SLOW_TASKS = 30 và SLOW_WAIT = 0.3. Đổi thành SLOW_TASKS = 60 và SLOW_WAIT = 0.5. Lưu file.
  2. Trước khi chạy, tự tính trên giấy: TUẦN TỰ (Phần B) sẽ mất khoảng bao nhiêu giây? SONG SONG sẽ mất khoảng bao nhiêu giây?
  3. Chạy lại đúng lệnh ở trên (python3 so_sanh_toc_do.py hoặc py so_sanh_toc_do.py). So sánh số thực tế ở Phần B với số bạn vừa tính.
Xem đáp án
  1. Chỉ cần sửa đúng hai số, không đổi gì khác trong file.
  2. TUẦN TỰ ≈ 60 × 0,5 = 30 giây. SONG SONG ≈ 0,5 giây — vì run_threaded luôn tạo đúng số luồng bằng số việc (workers=SLOW_TASKS), nên cả 60 lần "chờ" vẫn xếp chồng lên nhau, không nối tiếp.
  3. Số thực tế trên máy bạn có thể lệch một chút (thêm vài phần trăm giây do thời gian tạo luồng và lịch chạy của hệ điều hành), nhưng tỉ lệ "nhanh hơn khoảng 60 lần" ở dòng in cuối vẫn đúng hướng. Nếu số bạn tính và số thực tế lệch quá xa, kiểm tra lại xem có sửa đúng hai dòng hằng số chưa.
Đọc thêm: GIL có luôn đúng như vậy không? Khi nào luồng KHÔNG giúp (không bắt buộc)

GIL chỉ "nhường chỗ" khi luồng đang chờ một việc bên ngoài Python (mạng, đọc file, time.sleep()). Nếu việc cần làm là tính toán thuần CPU trong Python (ví dụ: tính hàng triệu phép nhân), nhiều luồng không giúp gì vì GIL vẫn chỉ cho một luồng chạy mã Python tại một thời điểm — khi đó cần multiprocessing (nhiều tiến trình riêng, không chung GIL) thay cho threading. Quét mạng là việc chờ I/O, nên ThreadPoolExecutor là lựa chọn đúng; đừng áp dụng y nguyên bài học này cho một việc tính toán nặng.

BÀI TẬP 1Cổng nào đang mở trên máy của bạn?30 phút · chỉ dùng kiến thức học tới Bước 3 (chưa cần Bước 4, 5)

Bài tập này có hai phần: lý thuyết (nhìn lại bài, trả lời 4 câu) và viết code (tự viết một chương trình ngắn, chạy và so kết quả với mẫu).

PHẦN 1Lý thuyết · bốn câu nhìn lại bài

BÀI TẬP 1 · PHẦN LÝ THUYẾT · 10 PHÚT

Trả lời ngắn, mỗi câu 1–2 câu

  1. (Bước 1) poke() gửi gói UDP tới cổng 9. Điều công cụ thật sự cần là gì: (a) phản hồi của cổng 9, (b) câu trả lời ARP, hay (c) cổng 9 đang mở? Chọn một và nói vì sao.
  2. (Bước 2) Ping một thiết bị và thấy ttl=128. Hàm guess_os() sẽ đoán nhóm hệ điều hành nào? Nếu thiết bị chặn ping thì hàm trả về gì?
  3. (Bước 3) connect_ex() trả về 0 nghĩa là gì? Nếu trả về số khác 0, có thể do hai lý do nào?
  4. (Bước 3) Khi nhiều cổng bị tường lửa chặn âm thầm, vì sao quét bằng nhiều luồng lại nhanh hơn quét lần lượt?
Xem đáp án
  1. (b). Để gửi gói UDP, hệ điều hành phải hỏi ARP trước; thiết bị có thật nào cũng trả lời ARP. Cổng 9 chỉ là cái cớ, kết quả của nó không quan trọng.
  2. Nhóm Windows (TTL trong khoảng 65–128). Nếu thiết bị chặn ping thì ttl_of() trả None và guess_os() trả "khong ro (thiet bi chan ping)".
  3. 0 nghĩa là kết nối TCP thành công, tức cổng đang mở. Khác 0 là một trong hai: cổng bị từ chối ngay (closed) hoặc im lặng đến hết thời gian chờ (filtered, ví dụ do tường lửa).
  4. Vì phần lớn thời gian là chờ (hết timeout). Nhiều luồng cho các lần chờ xếp chồng lên nhau nên tổng thời gian chỉ bằng khoảng một lần chờ, thay vì cộng dồn.

PHẦN 2Viết code · tự viết chương trình từ đầu

BÀI TẬP 1 · 20 PHÚT

Cổng nào đang mở?

ĐỀ BÀI

Bật một server nhỏ ở cổng 8800 trên máy bạn. Viết một chương trình Python cho biết trong 4 cổng 22, 80, 8080, 8800, cổng nào đang mở và cổng nào không mở.

YÊU CẦU

  1. Tạo một file mới tên bai1.py và tự gõ (không cần tải gì).
  2. Có một danh sách cổng: [22, 80, 8080, 8800].
  3. Với mỗi cổng, thử nối vào 127.0.0.1 bằng connect_ex, chờ tối đa 0.5 giây (làm giống thu_port.py ở Bước 3).
  4. In mỗi cổng một dòng: Cong 8800: MO nếu mở, hoặc Cong 22: khong mo nếu không mở.
  5. Cuối cùng in một dòng: Tong so cong mo: N (N là số cổng đang mở).

CHƯƠNG TRÌNH CHẠY RA NHƯ THẾ NÀY

VÍ DỤ KẾT QUẢ
Cong 22: khong mo
Cong 80: khong mo
Cong 8080: khong mo
Cong 8800: MO
Tong so cong mo: 1

Ý TƯỞNG BẰNG CHỮ (không phải code, bạn tự đổi thành code)

Ý TƯỞNG
tạo biến dem = 0
với mỗi cổng trong danh sách:
    thử nối vào 127.0.0.1 tại cổng đó
    nếu nối được:  in "Cong ...: MO"  và  dem tăng thêm 1
    nếu không:     in "Cong ...: khong mo"
in "Tong so cong mo: " cùng với dem

Cách chạy thử. Mở một terminal, bật server ở cổng 8800 (để có một cổng chắc chắn mở):

MACOS
python3 -m http.server 8800 --bind 127.0.0.1
WINDOWS
py -m http.server 8800 --bind 127.0.0.1
LINUX
python3 -m http.server 8800 --bind 127.0.0.1

Mở terminal thứ hai, vào thư mục chứa bai1.py và chạy:

MACOS
python3 bai1.py
WINDOWS
py bai1.py
LINUX
python3 bai1.py
Bạn làm đúng khi
  • In ra 5 dòng như kết quả mẫu: 4 dòng cổng + 1 dòng tổng.
  • Dòng Cong 8800 là MO và tổng là 1.
  • Tắt server (Ctrl+C) rồi chạy lại: Cong 8800 phải thành khong mo, tổng là 0.
  • Máy bạn có thể có sẵn dịch vụ ở cổng 22/80/8080 (ví dụ bật SSH) nên thấy thêm dòng MO — đó là kết quả thật, không phải lỗi.

Nộp: file bai1.py và ảnh chụp kết quả hai lần chạy (có và không có server), cùng câu trả lời 4 câu lý thuyết.

Xem đáp án (code mẫu) — hãy tự làm trước

Code này đã chạy thử và ra đúng kết quả mẫu. Cách viết của bạn có thể khác miễn là ra cùng kết quả.

PYTHON · bai_tap_1_dap_an.py
# bai_tap_1_dap_an.py - ĐÁP ÁN Bài tập 1: trong 4 cổng 22, 80, 8080, 8800 của máy bạn, cổng nào đang mở?
import socket

danh_sach_cong = [22, 80, 8080, 8800]
dem = 0

for port in danh_sach_cong:
    # Mở một socket TCP, thử nối vào cổng này của chính máy bạn, chờ tối đa 0,5 giây.
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(0.5)
        ket_qua = s.connect_ex(("127.0.0.1", port))
    # connect_ex trả 0 nghĩa là nối được, tức cổng đang mở.
    if ket_qua == 0:
        print(f"Cong {port}: MO")
        dem = dem + 1
    else:
        print(f"Cong {port}: khong mo")

print(f"Tong so cong mo: {dem}")
05

Bước 4 · Hàm nhận diện máy: đọc code ghép manh mối

20 phút
MACdescribe_mac() tra hãng
Cổng mởSo với bảng HINTS
Tên hostSo với bảng NAME_HINTS
Ghép lạiDanh sách nhãn gợi ý

Vì sao bước này nằm ở cuối chuỗi "hiểu thiết bị": tới đây, mỗi thiết bị đã có đủ ba mảnh thông tin rời rạc — MAC (Bước 1), hệ điều hành gợi ý (Bước 2), danh sách cổng mở (Bước 3). Một mình MAC, hay một mình cổng mở, thường không đủ để đoán "đây là cái gì". Nhiệm vụ của Bước 4 là hiểu code ghép ba mảnh rời rạc đó lại thành một hoặc vài nhãn gợi ý dễ đọc — đúng việc bài 8 từng dạy làm bằng tay, giờ xem máy tự làm bằng dòng code nào: hai hàm describe_mac() và guess_device().

Giải pháp: không dùng gì phức tạp như trí tuệ nhân tạo — chỉ so khớp với hai bảng tra cứu cố định viết sẵn trong code (cổng nào thường gắn với thiết bị gì, tên host chứa chữ gì thường là thiết bị gì). Vì sao chọn cách đơn giản này: kết quả chỉ cần là "gợi ý" (bài 8 đã nhắc: không chắc chắn), và một bảng tra cố định thì dễ đọc, dễ tự thêm dòng mới (phần luyện nhanh cuối mục sẽ cho thử), không cần huấn luyện hay dữ liệu lớn.

BƯỚC 1Đọc describe_mac() — đoán hãng, và một trường hợp đặc biệt

Luồng chạy của describe_mac(mac, oui)

  1. Đọc byte đầu của MACBit 0x02 bật = MAC riêng/ngẫu nhiên → trả lời luôn
  2. Lấy mã hãng (OUI)8 ký tự đầu, tra trong bảng oui.txt
PYTHON · describe_mac() trong lan_inventory.py
def describe_mac(mac, oui):
    # [1] Đọc byte đầu của MAC. Bit 0x02 bật = MAC do thiết bị tự đặt/ngẫu nhiên
    #     (điện thoại hay làm vậy để khó bị theo dõi) -> không có hãng thật nào để
    #     tra, trả lời luôn
    if int(mac[:2], 16) & 0x02:
        return "MAC rieng/ngau nhien (dien thoai thuong bat tinh nang nay)"
    # [2] MAC thật: 8 ký tự đầu ("aa:bb:cc" = 3 byte OUI) là mã hãng; tra trong bảng
    #     oui.txt (không có thì nói rõ lý do)
    return oui.get(mac[:8], "khong co trong oui.txt" if oui else "chua co oui.txt")
PhầnÝ nghĩa
def describe_mac(mac, oui):Định nghĩa hàm tên describe_mac, nhận vào 2 giá trị: mac (địa chỉ MAC, dạng chữ "aa:bb:cc:dd:ee:ff") và oui (bảng tra hãng, đọc từ oui.txt).
if int(mac[:2], 16) & 0x02:mac[:2] lấy 2 ký tự đầu của MAC (ví dụ "aa"). int(..., 16) đổi từ hệ 16 sang số thường. Phép & 0x02 kiểm tra đúng 1 bit trong số đó — đây là quy ước phần cứng: bit này bật nghĩa là MAC được sinh ngẫu nhiên (riêng tư), không phải MAC gốc nhà sản xuất khắc sẵn.
return "MAC rieng/ngau nhien..."Nếu đúng là MAC ngẫu nhiên, trả lời ngay bằng câu này — không tra bảng oui nữa, vì tra cũng không ra hãng thật.
return oui.get(mac[:8], "khong co..." if oui else "chua co...")Nếu không phải MAC ngẫu nhiên, lấy 8 ký tự đầu (3 byte , dạng "aa:bb:cc") làm khóa, tra trong bảng oui. Không tìm thấy thì trả về chữ báo "chưa biết", tùy có hay không có sẵn file oui.txt.

BƯỚC 2Đọc guess_device() và hai bảng gợi ý HINTS, NAME_HINTS

Hai bảng dữ liệu đứng trước hàm, dùng để so khớp:

Cách hai bảng được dùng

  1. HINTS: cổng → nhãnMở cổng 631 hoặc 9100 → "máy in"
  2. NAME_HINTS: từ khóa tên → nhãnTên có "iphone" → "iPhone"
  3. guess_device() ghép cả haiGộp nhãn, bỏ trùng, sắp xếp
PYTHON · HINTS và NAME_HINTS trong lan_inventory.py
# Bảng 1 - đoán theo CỔNG: thiết bị mở MỘT TRONG các cổng bên trái
#          thì gợi ý nhãn bên phải.
HINTS = [({631, 9100}, "may in"), ({554}, "camera"), ({62078}, "iPhone/iPad"), ({8008, 8009}, "Chromecast/TV Google"),
         ({3389}, "may Windows (Remote Desktop)"), ({445, 139}, "chia se file Windows/NAS"), ({22}, "may Linux/NAS/router (SSH)"),
         ({5000, 7000}, "Mac/Apple TV (AirPlay)"), ({23}, "thiet bi cu (Telnet): coi chung")]
# Bảng 2 - đoán theo TÊN HOST: tên (viết thường) có chứa từ khóa bên trái
#          thì gợi ý nhãn bên phải.
NAME_HINTS = [("iphone", "iPhone"), ("ipad", "iPad"), ("galaxy", "Samsung Galaxy"), ("android", "Android"),
              ("macbook", "Mac"), ("printer", "may in"), ("tv", "TV"), ("cam", "camera"), ("router", "router")]

Đọc gọn hơn dưới dạng bảng — mỗi dòng của HINTS là một cặp (tập hợp cổng, nhãn gợi ý):

Cổng mở (một trong các số này)Nhãn gợi ý
631, 9100máy in
554camera
62078iPhone/iPad
8008, 8009Chromecast/TV Google
3389máy Windows (Remote Desktop)
445, 139chia sẻ file Windows/NAS
22máy Linux/NAS/router (SSH)
5000, 7000Mac/Apple TV (AirPlay)
23thiết bị cũ (Telnet): coi chừng

Và NAME_HINTS so khớp theo tên host thay vì cổng: tên host có chứa "iphone" → nhãn "iPhone", có "router" → nhãn "router", tương tự cho "ipad", "galaxy", "android", "macbook", "printer", "tv", "cam".

Luồng chạy của guess_device(device)

  1. Lấy tập cổng đang mởTừ danh sách ports của thiết bị
  2. Đối chiếu HINTSTập cổng giao với cổng mở → thêm nhãn
  3. Đổi tên host sang chữ thườngĐể so khớp không phân biệt hoa/thường
  4. Đối chiếu NAME_HINTSTừ khóa có trong tên → thêm nhãn
  5. Bỏ trùng và sắp xếpsorted(set(clues))
PYTHON · guess_device() trong lan_inventory.py
def guess_device(device):
    # Ghép các manh mối thành nhãn gợi ý: cổng mở (HINTS) + tên host (NAME_HINTS).
    # [1] Lấy tập các số port đang mở của thiết bị
    open_ports = {p["port"] for p in device["ports"]}
    # [2] Với mỗi dòng của HINTS: nếu tập cổng của dòng đó GIAO với cổng đang mở
    #     (phép &) thì lấy nhãn của dòng đó
    clues = [label for ports, label in HINTS if open_ports & ports]
    # [3] Đổi tên host sang chữ thường để so khớp không phân biệt hoa/thường
    name = device["hostname"].lower()
    # [4] Với mỗi từ khóa của NAME_HINTS có trong tên host: thêm nhãn tương ứng
    clues += [label for key, label in NAME_HINTS if key in name]
    # [5] set() bỏ nhãn trùng, sorted() sắp xếp để kết quả ổn định giữa các lần chạy
    return sorted(set(clues))
PhầnÝ nghĩa
def guess_device(device):Định nghĩa hàm tên guess_device, nhận vào 1 giá trị: device — một bộ thông tin (dict) của thiết bị đã có sẵn cổng mở và tên host.
open_ports = {p["port"] for p in device["ports"]}Một "set comprehension" (giống dict comprehension đã học ở Bước 1) — gom tất cả số cổng đang mở của thiết bị này vào một tập hợp (set), tự bỏ số trùng.
clues = [label for ports, label in HINTS if open_ports & ports]Với mỗi cặp (ports, label) trong bảng HINTS: dấu & giữa hai set nghĩa là "phần giao nhau" — nếu thiết bị có ít nhất một cổng trùng với cổng gợi ý, giữ lại label đó vào danh sách clues.
name = device["hostname"].lower()Lấy tên host, đổi hết thành chữ thường, để so khớp không phân biệt hoa/thường.
clues += [label for key, label in NAME_HINTS if key in name]Thêm tiếp vào clues: nếu tên host có chứa một từ khóa nào trong NAME_HINTS (ví dụ "iphone"), thêm nhãn tương ứng.
return sorted(set(clues))Bỏ nhãn trùng (đưa qua set lần nữa), sắp xếp theo thứ tự chữ cái, trả về danh sách cuối cùng — đúng dòng "DOAN:" bạn thấy khi chạy công cụ ở bài 8.

Bốn dòng dùng set/list comprehension ở trên gói khá nhiều việc vào một dòng. Cùng ý nghĩa, viết bằng vòng lặp for cơ bản, từng bước một:

TƯƠNG ĐƯƠNG VIẾT DÀI, DÙNG for CƠ BẢN (CHỈ ĐỂ GIẢI THÍCH, KHÔNG CÓ TRONG FILE THẬT)
def guess_device(device):
    open_ports = set()
    for p in device["ports"]:
        open_ports.add(p["port"])

    clues = []
    for ports, label in HINTS:
        if open_ports & ports:
            clues.append(label)

    name = device["hostname"].lower()
    for key, label in NAME_HINTS:
        if key in name:
            clues.append(label)

    return sorted(set(clues))

Đọc theo đúng thứ tự: dựng tập open_ports bằng cách thêm (.add()) từng cổng một; duyệt từng dòng của HINTS, dòng nào có cổng trùng thì thêm (.append()) nhãn vào clues; duyệt từng dòng của NAME_HINTS, dòng nào từ khóa khớp tên host thì thêm nhãn; cuối cùng bỏ trùng và sắp xếp. Chạy ra kết quả giống hệt bản gốc, chỉ dài hơn.

LUYỆN NHANH 7 · 15 PHÚT

Tự sửa code: thêm một dòng gợi ý mới vào HINTS

Cổng 5353 là cổng dùng cho mDNS/Bonjour — giao thức nhiều thiết bị Apple và loa/TV thông minh dùng để tự tìm nhau trong mạng LAN.

  1. Viết đúng một dòng (đúng cú pháp Python, đúng kiểu dữ liệu set + tuple như các dòng khác trong HINTS) để thêm gợi ý này, với nhãn "thiet bi Apple/loa thong minh (mDNS)".
  2. Dòng đó nên đặt ở đâu trong danh sách HINTS — đầu, giữa, hay cuối? Có quan trọng không? Vì sao?
Xem đáp án
  1. ({5353}, "thiet bi Apple/loa thong minh (mDNS)"), — lưu ý {5353} vẫn là một set dù chỉ có 1 phần tử, nên vẫn phải viết trong dấu { }; cặp này vẫn nằm trong một tuple (set, chuỗi) giống các dòng khác.
  2. Vị trí không quan trọng: guess_device() duyệt qua toàn bộ danh sách HINTS (vòng lặp bên trong list comprehension), không dừng lại ở gợi ý đầu tiên khớp — nên thêm ở đầu, giữa hay cuối đều cho cùng kết quả.

BƯỚC 3build_inventory() — nơi tất cả các hàm ở Bước 1–4 được gọi lại với nhau

Bước 1 chỉ dừng lại ở neighbors (danh sách IP → MAC). describe_mac(), scan_ports(), ttl_of()/guess_os(), guess_device() — bốn hàm đã đọc riêng lẻ ở Bước 2, 3, 4 — đều được gọi lại với nhau ngay trong phần còn lại của build_inventory(), cho từng thiết bị một. Đây chính là đoạn code tạo ra đủ các cột IP / MAC / Hãng / Tên / Cổng / HDH / DOAN bạn thấy khi chạy công cụ ở bài 8:

Luồng chạy của phần còn lại trong build_inventory()

  1. Biết IP của chính mìnhĐể đánh dấu riêng, không tra hãng
  2. Duyệt từng IP đã tìm thấyTheo đúng thứ tự số (nhỏ đến lớn)
  3. Tra hãng qua MACGọi describe_mac() — trừ khi là máy mình
  4. Tra tên hostGọi hostname()
  5. Quét cổng (nếu được yêu cầu)Gọi scan_ports()
  6. Đoán hệ điều hành (nếu được yêu cầu)Gọi ttl_of() rồi guess_os()
  7. Ghép manh mốiGọi guess_device() — cần có cổng và tên ở trên rồi mới gọi được
PYTHON · trích build_inventory() trong lan_inventory.py (phần ghép lại)
    me = local_ip()
    if me in hosts:
        neighbors.setdefault(me, "(may nay)")
    devices = []
    for ip in sorted(neighbors, key=lambda x: ipaddress.ip_address(x)):
        mac = neighbors[ip]
        is_self = mac == "(may nay)"
        devices.append({"ip": ip, "mac": mac,
                        "vendor": "may cua ban" if is_self else describe_mac(mac, oui),
                        "hostname": hostname(ip),
                        "ports": scan_ports(ip) if with_ports else []})
        devices[-1]["os_guess"] = guess_os(ttl_of(ip)) if with_ports else ""
        devices[-1]["guess"] = guess_device(devices[-1])
    return devices
PhầnÝ nghĩa
me = local_ip()Hỏi IP của chính máy đang chạy chương trình (đã đọc ở bài 8: dùng mẹo "connect UDP không gửi gói" để hệ điều hành tự chọn card mạng).
if me in hosts: neighbors.setdefault(me, "(may nay)")Nếu chính máy mình cũng nằm trong dải đang quét, đánh dấu riêng bằng chuỗi "(may nay)" thay vì một MAC thật — setdefault chỉ ghi khi me chưa có sẵn trong neighbors.
devices = []Danh sách rỗng, sẽ chứa thông tin đầy đủ của từng thiết bị — đây là giá trị hàm sẽ trả về ở dòng cuối.
for ip in sorted(neighbors, key=lambda x: ipaddress.ip_address(x)):Duyệt từng IP tìm thấy ở Bước 1, theo đúng thứ tự số (192.168.1.2 trước 192.168.1.10) thay vì thứ tự chữ. key=lambda x: ipaddress.ip_address(x) ở đây viết gọn hơn mức cần thiết — viết cơ bản là key=ipaddress.ip_address, không cần lambda gì cả, vì đó vốn đã là một hàm nhận đúng 1 giá trị.
mac = neighbors[ip]Lấy MAC của IP này từ bảng đã dựng ở Bước 1.
is_self = mac == "(may nay)"Kiểm tra đây có phải chính máy mình không (đánh dấu ở dòng thứ 2 bên trên).
devices.append({"ip": ip, "mac": mac, "vendor": ..., "hostname": ..., "ports": ...})Thêm một bộ thông tin mới vào devices. Đây chính là nơi gọi describe_mac(mac, oui) (Bước 4) để ra "vendor" — trừ khi is_self đúng thì ghi thẳng "may cua ban", không cần tra. "hostname" gọi hàm hostname(ip) (thử tra tên ngược từ IP, không ra thì để trống). "ports" gọi scan_ports(ip) (Bước 3) — nhưng chỉ khi with_ports là đúng, bằng không để danh sách rỗng.
devices[-1]["os_guess"] = guess_os(ttl_of(ip)) if with_ports else ""devices[-1] nghĩa là "phần tử cuối cùng vừa thêm" — ghi thêm một khóa mới vào đúng bộ thông tin vừa tạo. Đây là nơi gọi ttl_of(ip) rồi guess_os(...) (Bước 2) để ra lời đoán hệ điều hành.
devices[-1]["guess"] = guess_device(devices[-1])Gọi guess_device() (Bước 4) — phải đứng sau hai dòng trên, vì guess_device() cần đọc "ports" và "hostname" đã được ghi vào devices[-1] trước đó.
return devicesTrả về toàn bộ danh sách thiết bị, đủ cả MAC, hãng, tên, cổng, hệ điều hành gợi ý và nhãn nhận diện — đúng dữ liệu main() dùng để in ra màn hình và lưu file JSON.

Muốn xem lại hoặc chạy lại toàn bộ lan_inventory.py như ở bài 8:

lan_inventory.py · Dùng lại file này nếu đã tải ở bài trước, hoặc tải vào thư mục lab đang dùng (lan-lab, vuln-lab, hoặc tạo mới code-lab).

↓ Tải lan_inventory.py
Xem mã Python · có tô màu và số dòng
PYTHON · lan_inventory.py
# lan_inventory.py - liệt kê thiết bị trong mạng nhà của BẠN:
#                    IP, MAC, hãng sản xuất, tên, port mở.
# Chỉ chạy trên mạng của chính bạn hoặc nơi được cho phép.
# Chỉ dùng thư viện có sẵn của Python 3.
import argparse
import ipaddress
import json
import platform
import re
import socket
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

# Bảng port quen thuộc: số port -> tên dịch vụ thường chạy ở đó.
# Công cụ chỉ thử các port có trong bảng này.
PORTS = {21: "ftp", 22: "ssh", 23: "telnet", 53: "dns", 80: "http", 139: "netbios", 443: "https",
         445: "smb", 554: "rtsp (camera)", 631: "ipp (may in)", 3389: "rdp", 5000: "http-alt/airplay",
         7000: "airplay", 8000: "http-alt", 8008: "chromecast", 8080: "http-proxy", 8443: "https-alt",
         9100: "may in", 62078: "iphone-sync"}
MAX_HOSTS = 1024   # giới hạn số địa chỉ mỗi lần quét, để gõ nhầm dải quá lớn không làm nghẽn mạng


def local_ip():
    # Mục đích: biết IP của máy mình trong mạng LAN (một máy có thể có nhiều card mạng).
    # connect() với UDP KHÔNG gửi gói nào ra mạng; nó chỉ bắt hệ điều hành chọn
    # card mạng sẽ dùng để tới địa chỉ 192.0.2.1 (địa chỉ dành riêng cho tài liệu,
    # không ai dùng). Sau đó ta đọc IP của card mạng đó.
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
        s.connect(("192.0.2.1", 9))
        return s.getsockname()[0]


def detect_network(ip):
    # Đọc mặt nạ mạng thật của card mạng (cho biết dải IP gồm bao nhiêu địa chỉ).
    # Không đọc được thì coi là /24 (256 địa chỉ, mạng nhà phổ biến nhất).
    system = platform.system()
    try:
        if system == "Linux":
            out = subprocess.run(["ip", "-o", "-4", "addr"], capture_output=True, text=True).stdout
            m = re.search(re.escape(ip) + r"/(\d+)", out)
            if m:
                return ipaddress.ip_network(f"{ip}/{m.group(1)}", strict=False)
        elif system == "Windows":
            lines = subprocess.run(["ipconfig"], capture_output=True, text=True).stdout.splitlines()
            for i, line in enumerate(lines):
                if ip in line:
                    for nxt in lines[i + 1:i + 3]:
                        m = re.search(r"(255\.\d+\.\d+\.\d+)", nxt)
                        if m:
                            return ipaddress.ip_network(f"{ip}/{m.group(1)}", strict=False)
        else:
            out = subprocess.run(["ifconfig"], capture_output=True, text=True).stdout
            m = re.search(r"inet " + re.escape(ip) + r" netmask (0x[0-9a-f]+)", out)
            if m:
                prefix = bin(int(m.group(1), 16)).count("1")
                return ipaddress.ip_network(f"{ip}/{prefix}", strict=False)
    except (OSError, ValueError):
        pass
    return ipaddress.ip_network(f"{ip}/24", strict=False)


def normalize_mac(raw):
    # macOS in MAC không đệm số 0 ("a:b:c:1:2:3"), Windows dùng dấu gạch nối: đưa về
    # dạng chuẩn aa:bb:cc:dd:ee:ff
    parts = re.split(r"[:-]", raw.lower())
    return ":".join(p.zfill(2) for p in parts) if len(parts) == 6 else None


def read_neighbors():
    system = platform.system()
    if system == "Linux":
        out = subprocess.run(["ip", "neigh"], capture_output=True, text=True).stdout
        pattern = r"^(\d+\.\d+\.\d+\.\d+) .*lladdr ([0-9a-f:]{11,17})"
    else:
        out = subprocess.run(["arp", "-a"], capture_output=True, text=True).stdout
        pattern = r"(\d+\.\d+\.\d+\.\d+)\)? (?:at )?\s*([0-9a-fA-F]{1,2}(?:[:-][0-9a-fA-F]{1,2}){5})"
    found = {}
    for line in out.splitlines():
        m = re.search(pattern, line)
        if m and normalize_mac(m.group(2)):
            found[m.group(1)] = normalize_mac(m.group(2))
    return found


def is_unicast(ip, mac):
    a = ipaddress.ip_address(ip)
    return not (a.is_multicast or ip.endswith(".255") or mac == "ff:ff:ff:ff:ff:ff" or mac.startswith("01:00:5e"))


def poke(ip):
    # MỤC ĐÍCH: "gõ cửa" một IP để máy phải hỏi ARP "ai giữ IP này?".
    # Thiết bị nào có thật cũng trả lời ARP (kể cả khi nó chặn ping), nên MAC của
    # nó sẽ nằm trong bảng ARP và ta đọc được.
    try:
        # [1] Tạo socket UDP (SOCK_DGRAM): chỉ "ném" gói đi, không bắt tay, không
        #     chờ trả lời
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            # [2] Gửi 1 byte rỗng tới port 9. Nội dung không quan trọng; điều quan
            #     trọng là hệ điều hành phải hỏi ARP để biết MAC của IP này TRƯỚC
            #     khi đưa gói ra card mạng
            s.sendto(b"\0", (ip, 9))
    except OSError:
        # [3] Thiết bị trả lời "port này không mở" cũng có thể gây lỗi; ta không cần
        #     kết quả đó -> bỏ qua
        pass


def load_oui(path):
    table = {}
    if path and Path(path).exists():
        for line in Path(path).read_text(encoding="utf-8", errors="ignore").splitlines():
            if "(hex)" in line:
                prefix, _, name = line.partition("(hex)")
                table[prefix.strip().replace("-", ":").lower()] = name.strip()
    return table


def describe_mac(mac, oui):
    # [1] Đọc byte đầu của MAC. Bit 0x02 bật = MAC do thiết bị tự đặt/ngẫu nhiên
    #     (điện thoại hay làm vậy để khó bị theo dõi) -> không có hãng thật nào để
    #     tra, trả lời luôn
    if int(mac[:2], 16) & 0x02:
        return "MAC rieng/ngau nhien (dien thoai thuong bat tinh nang nay)"
    # [2] MAC thật: 8 ký tự đầu ("aa:bb:cc" = 3 byte OUI) là mã hãng; tra trong bảng
    #     oui.txt (không có thì nói rõ lý do)
    return oui.get(mac[:8], "khong co trong oui.txt" if oui else "chua co oui.txt")


def ttl_of(ip):
    # Gửi đúng 1 gói ping tới thiết bị ĐÃ TÌM THẤY để đọc TTL.
    # (Việc tìm thiết bị không dựa vào ping.)
    # [1] Mỗi hệ điều hành viết lệnh ping khác nhau: -n (Windows) / -c (macOS,
    #     Linux) là số gói; -w, -t, -W là thời gian chờ
    system = platform.system()
    if system == "Windows":
        cmd = ["ping", "-n", "1", "-w", "1000", ip]
    elif system == "Darwin":
        cmd = ["ping", "-c", "1", "-t", "2", ip]
    else:
        cmd = ["ping", "-c", "1", "-W", "1", ip]
    # [2] Chạy lệnh ping và đọc chữ nó in ra; chờ tối đa 4 giây. Lỗi hoặc quá hạn
    #     thì trả None (không rõ)
    try:
        out = subprocess.run(cmd, capture_output=True, text=True, timeout=4).stdout
    except (OSError, subprocess.SubprocessError):
        return None
    # [3] Tìm trong chữ đó đoạn "ttl=64" (hoặc "TTL=128"...) bằng regex; re.I =
    #     không phân biệt hoa thường
    m = re.search(r"ttl[=:]\s*(\d+)", out, re.I)
    # [4] Có thì trả về con số TTL, không thì trả None
    return int(m.group(1)) if m else None


def guess_os(ttl):
    # TTL khởi đầu của gói trả lời: ~64 Linux/macOS/iOS/Android, ~128 Windows,
    # ~255 thiết bị mạng. Chỉ là gợi ý. (Gói đi qua mỗi router bị trừ 1 nên số
    # nhận được có thể nhỏ hơn số khởi đầu một chút, vì vậy ta chia theo KHOẢNG.)
    # [1] Không đọc được TTL (thiết bị chặn ping): không đoán bừa
    if ttl is None:
        return "khong ro (thiet bi chan ping)"
    # [2] TTL từ 64 trở xuống: nhóm Linux/macOS/iOS/Android
    if ttl <= 64:
        return f"nhom Linux/macOS/iOS/Android (TTL {ttl})"
    # [3] TTL trên 64 đến 128: nhóm Windows
    if ttl <= 128:
        return f"nhom Windows (TTL {ttl})"
    # [4] Còn lại (tới 255): thiết bị mạng hoặc máy in
    return f"thiet bi mang/may in (TTL {ttl})"


# Bảng 1 - đoán theo CỔNG: thiết bị mở MỘT TRONG các cổng bên trái
#          thì gợi ý nhãn bên phải.
HINTS = [({631, 9100}, "may in"), ({554}, "camera"), ({62078}, "iPhone/iPad"), ({8008, 8009}, "Chromecast/TV Google"),
         ({3389}, "may Windows (Remote Desktop)"), ({445, 139}, "chia se file Windows/NAS"), ({22}, "may Linux/NAS/router (SSH)"),
         ({5000, 7000}, "Mac/Apple TV (AirPlay)"), ({23}, "thiet bi cu (Telnet): coi chung")]
# Bảng 2 - đoán theo TÊN HOST: tên (viết thường) có chứa từ khóa bên trái
#          thì gợi ý nhãn bên phải.
NAME_HINTS = [("iphone", "iPhone"), ("ipad", "iPad"), ("galaxy", "Samsung Galaxy"), ("android", "Android"),
              ("macbook", "Mac"), ("printer", "may in"), ("tv", "TV"), ("cam", "camera"), ("router", "router")]


def guess_device(device):
    # Ghép các manh mối thành nhãn gợi ý: cổng mở (HINTS) + tên host (NAME_HINTS).
    # [1] Lấy tập các số port đang mở của thiết bị
    open_ports = {p["port"] for p in device["ports"]}
    # [2] Với mỗi dòng của HINTS: nếu tập cổng của dòng đó GIAO với cổng đang mở
    #     (phép &) thì lấy nhãn của dòng đó
    clues = [label for ports, label in HINTS if open_ports & ports]
    # [3] Đổi tên host sang chữ thường để so khớp không phân biệt hoa/thường
    name = device["hostname"].lower()
    # [4] Với mỗi từ khóa của NAME_HINTS có trong tên host: thêm nhãn tương ứng
    clues += [label for key, label in NAME_HINTS if key in name]
    # [5] set() bỏ nhãn trùng, sorted() sắp xếp để kết quả ổn định giữa các lần chạy
    return sorted(set(clues))


def hostname(ip):
    try:
        return socket.gethostbyaddr(ip)[0]
    except OSError:
        return ""


def grab_banner(ip, port):
    try:
        with socket.create_connection((ip, port), timeout=0.6) as s:
            s.settimeout(0.6)
            if port in (80, 8000, 8008, 8080):
                s.sendall(b"HEAD / HTTP/1.0\r\n\r\n")
            data = s.recv(200).decode("utf-8", errors="replace")
    except OSError:
        return ""
    for line in data.splitlines():
        if line.lower().startswith("server:"):
            return line[7:].strip()
    return data.splitlines()[0][:60] if data else ""


def scan_ports(ip):
    # Tìm các port đang mở của MỘT thiết bị: thử kết nối TCP tới từng port trong PORTS.
    def probe(port):
        # [1] Thử kết nối TCP tới 1 port, chờ tối đa 0,5 giây. connect_ex() trả 0
        #     nếu kết nối thành công (= port mở), trả mã lỗi khác 0 nếu không (không
        #     ném exception như connect()). Mở -> trả số port, không mở -> None
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.settimeout(0.5)
            return port if s.connect_ex((ip, port)) == 0 else None
    # [2] Thử mọi port CÙNG LÚC bằng nhiều luồng (thread), không thử lần lượt: nhanh
    #     hơn rất nhiều vì phần lớn thời gian là ĐỢI
    with ThreadPoolExecutor(max_workers=len(PORTS)) as pool:
        # [3] Giữ lại port mở (bỏ None), sắp xếp từ nhỏ đến lớn
        open_ports = sorted(p for p in pool.map(probe, PORTS) if p)
    # [4] Với mỗi port mở: ghi tên dịch vụ quen thuộc và đọc "banner" (câu tự giới
    #     thiệu của dịch vụ, nếu có)
    return [{"port": p, "service": PORTS[p], "banner": grab_banner(ip, p)} for p in open_ports]


def build_inventory(network, oui, with_ports):
    hosts = [str(h) for h in network.hosts()] or [str(network.network_address)]
    # [1] "Gõ cửa" mọi địa chỉ trong dải cùng lúc bằng 64 luồng (poke() ở trên)
    with ThreadPoolExecutor(max_workers=64) as pool:
        list(pool.map(poke, hosts))
    # [2] Đợi 2 giây để các thiết bị kịp trả lời ARP và hệ điều hành kịp ghi vào
    #     bảng ARP
    time.sleep(2)
    # [3] Đọc bảng ARP (IP -> MAC), chỉ giữ IP thuộc dải đang quét và bỏ địa chỉ
    #     broadcast/multicast (không phải thiết bị thật)
    neighbors = {ip: mac for ip, mac in read_neighbors().items() if ip in hosts and is_unicast(ip, mac)}
    me = local_ip()
    if me in hosts:
        neighbors.setdefault(me, "(may nay)")
    devices = []
    for ip in sorted(neighbors, key=lambda x: ipaddress.ip_address(x)):
        mac = neighbors[ip]
        is_self = mac == "(may nay)"
        devices.append({"ip": ip, "mac": mac,
                        "vendor": "may cua ban" if is_self else describe_mac(mac, oui),
                        "hostname": hostname(ip),
                        "ports": scan_ports(ip) if with_ports else []})
        devices[-1]["os_guess"] = guess_os(ttl_of(ip)) if with_ports else ""
        devices[-1]["guess"] = guess_device(devices[-1])
    return devices


def _dev(ip, mac, vendor, host, ports=(), ttl=None):
    d = {"ip": ip, "mac": mac, "vendor": vendor, "hostname": host,
         "ports": [{"port": p, "service": PORTS.get(p, "?"), "banner": b} for p, b in ports]}
    d["os_guess"] = guess_os(ttl)
    d["guess"] = guess_device(d)
    return d


def demo_inventory(day):
    # Dữ liệu MẪU để học đọc kết quả, không quét mạng thật. Hãng và tên là giả định.
    private = "MAC rieng/ngau nhien (dien thoai thuong bat tinh nang nay)"
    devices = [
        _dev("192.168.1.1", "f4:ec:38:11:22:33", "Hang router A", "router.lan", [(80, "Server: lighttpd")], 64),
        _dev("192.168.1.23", "6a:6c:4a:21:dc:21", private, "macbook-cua-ban.lan", ttl=64),
        _dev("192.168.1.40", "3c:22:fb:aa:bb:cc", "Hang may in X", "npi-3f2a",
             [(80, "Server: Embedded HTTP Server"), (631, ""), (9100, "")], 255),
        _dev("192.168.1.45", "9c:b2:00:44:55:66", "Hang camera Y", "cam-phong-1",
             [(23, ""), (80, "Server: CamHTTPD/1.0"), (554, "")], 64),
        _dev("192.168.1.60", "3a:5b:7c:12:34:56", private, "galaxy-s24.lan", ttl=64),
        _dev("192.168.1.70", "00:1b:21:aa:bb:cc", "Hang may tinh Z", "PC-THUQUY", [(445, ""), (3389, "")], 128),
    ]
    if day == 1:
        devices.append(_dev("192.168.1.52", "da:a1:19:00:00:01", private, "", [(62078, "")], 64))
    else:
        devices[-1] = _dev("192.168.1.70", "00:1b:21:aa:bb:cc", "Hang may tinh Z", "PC-THUQUY",
                           [(445, ""), (8000, "Server: SimpleHTTP/0.6 Python/3.9.6")], 128)
        devices.append(_dev("192.168.1.77", "c2:11:aa:bb:cc:dd", private, "", ttl=64))
    return sorted(devices, key=lambda d: ipaddress.ip_address(d["ip"]))


def show(devices):
    print(f"{'IP':<16}{'MAC':<19}{'Hang / ghi chu':<34}Ten")
    for d in devices:
        print(f"{d['ip']:<16}{d['mac']:<19}{d['vendor'][:32]:<34}{d['hostname'][:30]}")
        for p in d["ports"]:
            print(f"    mo port {p['port']:<6}{p['service']:<20}{p['banner']}")
        if d.get("guess") or d.get("os_guess"):
            print(f"    DOAN: {', '.join(d.get('guess', [])) or 'chua du dau hieu'} | HDH: {d.get('os_guess') or '-'}")
    print(f"\nTong cong: {len(devices)} thiet bi tra loi.")


def compare(devices, baseline_path):
    old = {d["mac"]: d for d in json.loads(Path(baseline_path).read_text(encoding="utf-8"))}
    now = {d["mac"]: d for d in devices}
    new = [d for mac, d in now.items() if mac not in old]
    gone = [d for mac, d in old.items() if mac not in now]
    print("\nThiet bi MOI so voi lan truoc:", [f"{d['ip']} ({d['mac']})" for d in new] or "khong co")
    print("Thiet bi KHONG con tra loi:", [f"{d['ip']} ({d['mac']})" for d in gone] or "khong co")
    for mac in sorted(now.keys() & old.keys()):
        before = {p["port"] for p in old[mac]["ports"]}
        after = {p["port"] for p in now[mac]["ports"]}
        if before != after:
            print(f"Port THAY DOI o {now[mac]['ip']}: moi mo {sorted(after - before)}, da dong {sorted(before - after)}")


def main():
    ap = argparse.ArgumentParser(description="Liet ke thiet bi trong mang nha cua ban")
    ap.add_argument("--net", help="dai mang, vi du 192.168.1.0/24 (mac dinh: tu doc mat na mang cua may ban)")
    ap.add_argument("--oui", default="oui.txt", help="file oui.txt cua IEEE de tra hang san xuat")
    ap.add_argument("--no-ports", action="store_true", help="khong quet port")
    ap.add_argument("--save", default="inventory.json", help="file luu ket qua")
    ap.add_argument("--baseline", help="file ket qua lan truoc de so sanh thiet bi moi")
    ap.add_argument("--demo", type=int, choices=[1, 2], help="dung du lieu MAU (ngay 1 hoac ngay 2), khong quet mang that")
    ap.add_argument("--only", help="chi quet port cua mot IP rieng (vi du 127.0.0.1), bo qua buoc tim thiet bi")
    args = ap.parse_args()
    if args.only:
        ip = ipaddress.ip_address(args.only)
        if not ip.is_private:
            raise SystemExit("Chi quet IP rieng (192.168.x.x, 10.x.x.x, 127.0.0.1...).")
        found = scan_ports(str(ip))
        print(f"Port mo tren {ip}:", ", ".join(f"{p['port']}/{p['service']}" for p in found) or "khong co")
        for p in found:
            print(f"    {p['port']:<6}{p['service']:<20}{p['banner']}")
        guess = guess_device({"ports": found, "hostname": hostname(str(ip))})
        print("DOAN:", ", ".join(guess) or "chua du dau hieu")
        return
    if args.demo:
        print(f"[DU LIEU MAU ngay {args.demo}: khong quet mang that]")
        devices = demo_inventory(args.demo)
        show(devices)
        Path(args.save).write_text(json.dumps(devices, ensure_ascii=False, indent=2), encoding="utf-8")
        print(f"Da luu {args.save}")
        if args.baseline:
            compare(devices, args.baseline)
        return
    # [1] Dải mạng cần quét: người dùng gõ --net thì dùng, không thì tự đọc từ card
    #     mạng của máy
    network = ipaddress.ip_network(args.net, strict=False) if args.net else detect_network(local_ip())
    # [2] Chốt chặn an toàn: chỉ nhận dải IP RIÊNG và tối đa 1024 địa chỉ; ngoài ra
    #     thoát ngay, chưa gửi gói nào
    if not network.is_private or network.num_addresses > MAX_HOSTS:
        raise SystemExit("Chi quet dai IP rieng (192.168.x.x, 10.x.x.x...) va khong qua 1024 dia chi.")
    print(f"Dang kiem tra {network} ({network.num_addresses} dia chi), cho khoang 5-30 giay...")
    devices = build_inventory(network, load_oui(args.oui), not args.no_ports)
    show(devices)
    Path(args.save).write_text(json.dumps(devices, ensure_ascii=False, indent=2), encoding="utf-8")
    print(f"Da luu {args.save}")
    if args.baseline:
        compare(devices, args.baseline)


if __name__ == "__main__":
    main()
Tạo file bằng terminal · chọn hệ điều hành của bạn
MACOS: TẠO lan_inventory.py
cat > lan_inventory.py <<'EOF'
# lan_inventory.py - liệt kê thiết bị trong mạng nhà của BẠN:
#                    IP, MAC, hãng sản xuất, tên, port mở.
# Chỉ chạy trên mạng của chính bạn hoặc nơi được cho phép.
# Chỉ dùng thư viện có sẵn của Python 3.
import argparse
import ipaddress
import json
import platform
import re
import socket
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

# Bảng port quen thuộc: số port -> tên dịch vụ thường chạy ở đó.
# Công cụ chỉ thử các port có trong bảng này.
PORTS = {21: "ftp", 22: "ssh", 23: "telnet", 53: "dns", 80: "http", 139: "netbios", 443: "https",
         445: "smb", 554: "rtsp (camera)", 631: "ipp (may in)", 3389: "rdp", 5000: "http-alt/airplay",
         7000: "airplay", 8000: "http-alt", 8008: "chromecast", 8080: "http-proxy", 8443: "https-alt",
         9100: "may in", 62078: "iphone-sync"}
MAX_HOSTS = 1024   # giới hạn số địa chỉ mỗi lần quét, để gõ nhầm dải quá lớn không làm nghẽn mạng


def local_ip():
    # Mục đích: biết IP của máy mình trong mạng LAN (một máy có thể có nhiều card mạng).
    # connect() với UDP KHÔNG gửi gói nào ra mạng; nó chỉ bắt hệ điều hành chọn
    # card mạng sẽ dùng để tới địa chỉ 192.0.2.1 (địa chỉ dành riêng cho tài liệu,
    # không ai dùng). Sau đó ta đọc IP của card mạng đó.
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
        s.connect(("192.0.2.1", 9))
        return s.getsockname()[0]


def detect_network(ip):
    # Đọc mặt nạ mạng thật của card mạng (cho biết dải IP gồm bao nhiêu địa chỉ).
    # Không đọc được thì coi là /24 (256 địa chỉ, mạng nhà phổ biến nhất).
    system = platform.system()
    try:
        if system == "Linux":
            out = subprocess.run(["ip", "-o", "-4", "addr"], capture_output=True, text=True).stdout
            m = re.search(re.escape(ip) + r"/(\d+)", out)
            if m:
                return ipaddress.ip_network(f"{ip}/{m.group(1)}", strict=False)
        elif system == "Windows":
            lines = subprocess.run(["ipconfig"], capture_output=True, text=True).stdout.splitlines()
            for i, line in enumerate(lines):
                if ip in line:
                    for nxt in lines[i + 1:i + 3]:
                        m = re.search(r"(255\.\d+\.\d+\.\d+)", nxt)
                        if m:
                            return ipaddress.ip_network(f"{ip}/{m.group(1)}", strict=False)
        else:
            out = subprocess.run(["ifconfig"], capture_output=True, text=True).stdout
            m = re.search(r"inet " + re.escape(ip) + r" netmask (0x[0-9a-f]+)", out)
            if m:
                prefix = bin(int(m.group(1), 16)).count("1")
                return ipaddress.ip_network(f"{ip}/{prefix}", strict=False)
    except (OSError, ValueError):
        pass
    return ipaddress.ip_network(f"{ip}/24", strict=False)


def normalize_mac(raw):
    # macOS in MAC không đệm số 0 ("a:b:c:1:2:3"), Windows dùng dấu gạch nối: đưa về
    # dạng chuẩn aa:bb:cc:dd:ee:ff
    parts = re.split(r"[:-]", raw.lower())
    return ":".join(p.zfill(2) for p in parts) if len(parts) == 6 else None


def read_neighbors():
    system = platform.system()
    if system == "Linux":
        out = subprocess.run(["ip", "neigh"], capture_output=True, text=True).stdout
        pattern = r"^(\d+\.\d+\.\d+\.\d+) .*lladdr ([0-9a-f:]{11,17})"
    else:
        out = subprocess.run(["arp", "-a"], capture_output=True, text=True).stdout
        pattern = r"(\d+\.\d+\.\d+\.\d+)\)? (?:at )?\s*([0-9a-fA-F]{1,2}(?:[:-][0-9a-fA-F]{1,2}){5})"
    found = {}
    for line in out.splitlines():
        m = re.search(pattern, line)
        if m and normalize_mac(m.group(2)):
            found[m.group(1)] = normalize_mac(m.group(2))
    return found


def is_unicast(ip, mac):
    a = ipaddress.ip_address(ip)
    return not (a.is_multicast or ip.endswith(".255") or mac == "ff:ff:ff:ff:ff:ff" or mac.startswith("01:00:5e"))


def poke(ip):
    # MỤC ĐÍCH: "gõ cửa" một IP để máy phải hỏi ARP "ai giữ IP này?".
    # Thiết bị nào có thật cũng trả lời ARP (kể cả khi nó chặn ping), nên MAC của
    # nó sẽ nằm trong bảng ARP và ta đọc được.
    try:
        # [1] Tạo socket UDP (SOCK_DGRAM): chỉ "ném" gói đi, không bắt tay, không
        #     chờ trả lời
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            # [2] Gửi 1 byte rỗng tới port 9. Nội dung không quan trọng; điều quan
            #     trọng là hệ điều hành phải hỏi ARP để biết MAC của IP này TRƯỚC
            #     khi đưa gói ra card mạng
            s.sendto(b"\0", (ip, 9))
    except OSError:
        # [3] Thiết bị trả lời "port này không mở" cũng có thể gây lỗi; ta không cần
        #     kết quả đó -> bỏ qua
        pass


def load_oui(path):
    table = {}
    if path and Path(path).exists():
        for line in Path(path).read_text(encoding="utf-8", errors="ignore").splitlines():
            if "(hex)" in line:
                prefix, _, name = line.partition("(hex)")
                table[prefix.strip().replace("-", ":").lower()] = name.strip()
    return table


def describe_mac(mac, oui):
    # [1] Đọc byte đầu của MAC. Bit 0x02 bật = MAC do thiết bị tự đặt/ngẫu nhiên
    #     (điện thoại hay làm vậy để khó bị theo dõi) -> không có hãng thật nào để
    #     tra, trả lời luôn
    if int(mac[:2], 16) & 0x02:
        return "MAC rieng/ngau nhien (dien thoai thuong bat tinh nang nay)"
    # [2] MAC thật: 8 ký tự đầu ("aa:bb:cc" = 3 byte OUI) là mã hãng; tra trong bảng
    #     oui.txt (không có thì nói rõ lý do)
    return oui.get(mac[:8], "khong co trong oui.txt" if oui else "chua co oui.txt")


def ttl_of(ip):
    # Gửi đúng 1 gói ping tới thiết bị ĐÃ TÌM THẤY để đọc TTL.
    # (Việc tìm thiết bị không dựa vào ping.)
    # [1] Mỗi hệ điều hành viết lệnh ping khác nhau: -n (Windows) / -c (macOS,
    #     Linux) là số gói; -w, -t, -W là thời gian chờ
    system = platform.system()
    if system == "Windows":
        cmd = ["ping", "-n", "1", "-w", "1000", ip]
    elif system == "Darwin":
        cmd = ["ping", "-c", "1", "-t", "2", ip]
    else:
        cmd = ["ping", "-c", "1", "-W", "1", ip]
    # [2] Chạy lệnh ping và đọc chữ nó in ra; chờ tối đa 4 giây. Lỗi hoặc quá hạn
    #     thì trả None (không rõ)
    try:
        out = subprocess.run(cmd, capture_output=True, text=True, timeout=4).stdout
    except (OSError, subprocess.SubprocessError):
        return None
    # [3] Tìm trong chữ đó đoạn "ttl=64" (hoặc "TTL=128"...) bằng regex; re.I =
    #     không phân biệt hoa thường
    m = re.search(r"ttl[=:]\s*(\d+)", out, re.I)
    # [4] Có thì trả về con số TTL, không thì trả None
    return int(m.group(1)) if m else None


def guess_os(ttl):
    # TTL khởi đầu của gói trả lời: ~64 Linux/macOS/iOS/Android, ~128 Windows,
    # ~255 thiết bị mạng. Chỉ là gợi ý. (Gói đi qua mỗi router bị trừ 1 nên số
    # nhận được có thể nhỏ hơn số khởi đầu một chút, vì vậy ta chia theo KHOẢNG.)
    # [1] Không đọc được TTL (thiết bị chặn ping): không đoán bừa
    if ttl is None:
        return "khong ro (thiet bi chan ping)"
    # [2] TTL từ 64 trở xuống: nhóm Linux/macOS/iOS/Android
    if ttl <= 64:
        return f"nhom Linux/macOS/iOS/Android (TTL {ttl})"
    # [3] TTL trên 64 đến 128: nhóm Windows
    if ttl <= 128:
        return f"nhom Windows (TTL {ttl})"
    # [4] Còn lại (tới 255): thiết bị mạng hoặc máy in
    return f"thiet bi mang/may in (TTL {ttl})"


# Bảng 1 - đoán theo CỔNG: thiết bị mở MỘT TRONG các cổng bên trái
#          thì gợi ý nhãn bên phải.
HINTS = [({631, 9100}, "may in"), ({554}, "camera"), ({62078}, "iPhone/iPad"), ({8008, 8009}, "Chromecast/TV Google"),
         ({3389}, "may Windows (Remote Desktop)"), ({445, 139}, "chia se file Windows/NAS"), ({22}, "may Linux/NAS/router (SSH)"),
         ({5000, 7000}, "Mac/Apple TV (AirPlay)"), ({23}, "thiet bi cu (Telnet): coi chung")]
# Bảng 2 - đoán theo TÊN HOST: tên (viết thường) có chứa từ khóa bên trái
#          thì gợi ý nhãn bên phải.
NAME_HINTS = [("iphone", "iPhone"), ("ipad", "iPad"), ("galaxy", "Samsung Galaxy"), ("android", "Android"),
              ("macbook", "Mac"), ("printer", "may in"), ("tv", "TV"), ("cam", "camera"), ("router", "router")]


def guess_device(device):
    # Ghép các manh mối thành nhãn gợi ý: cổng mở (HINTS) + tên host (NAME_HINTS).
    # [1] Lấy tập các số port đang mở của thiết bị
    open_ports = {p["port"] for p in device["ports"]}
    # [2] Với mỗi dòng của HINTS: nếu tập cổng của dòng đó GIAO với cổng đang mở
    #     (phép &) thì lấy nhãn của dòng đó
    clues = [label for ports, label in HINTS if open_ports & ports]
    # [3] Đổi tên host sang chữ thường để so khớp không phân biệt hoa/thường
    name = device["hostname"].lower()
    # [4] Với mỗi từ khóa của NAME_HINTS có trong tên host: thêm nhãn tương ứng
    clues += [label for key, label in NAME_HINTS if key in name]
    # [5] set() bỏ nhãn trùng, sorted() sắp xếp để kết quả ổn định giữa các lần chạy
    return sorted(set(clues))


def hostname(ip):
    try:
        return socket.gethostbyaddr(ip)[0]
    except OSError:
        return ""


def grab_banner(ip, port):
    try:
        with socket.create_connection((ip, port), timeout=0.6) as s:
            s.settimeout(0.6)
            if port in (80, 8000, 8008, 8080):
                s.sendall(b"HEAD / HTTP/1.0\r\n\r\n")
            data = s.recv(200).decode("utf-8", errors="replace")
    except OSError:
        return ""
    for line in data.splitlines():
        if line.lower().startswith("server:"):
            return line[7:].strip()
    return data.splitlines()[0][:60] if data else ""


def scan_ports(ip):
    # Tìm các port đang mở của MỘT thiết bị: thử kết nối TCP tới từng port trong PORTS.
    def probe(port):
        # [1] Thử kết nối TCP tới 1 port, chờ tối đa 0,5 giây. connect_ex() trả 0
        #     nếu kết nối thành công (= port mở), trả mã lỗi khác 0 nếu không (không
        #     ném exception như connect()). Mở -> trả số port, không mở -> None
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.settimeout(0.5)
            return port if s.connect_ex((ip, port)) == 0 else None
    # [2] Thử mọi port CÙNG LÚC bằng nhiều luồng (thread), không thử lần lượt: nhanh
    #     hơn rất nhiều vì phần lớn thời gian là ĐỢI
    with ThreadPoolExecutor(max_workers=len(PORTS)) as pool:
        # [3] Giữ lại port mở (bỏ None), sắp xếp từ nhỏ đến lớn
        open_ports = sorted(p for p in pool.map(probe, PORTS) if p)
    # [4] Với mỗi port mở: ghi tên dịch vụ quen thuộc và đọc "banner" (câu tự giới
    #     thiệu của dịch vụ, nếu có)
    return [{"port": p, "service": PORTS[p], "banner": grab_banner(ip, p)} for p in open_ports]


def build_inventory(network, oui, with_ports):
    hosts = [str(h) for h in network.hosts()] or [str(network.network_address)]
    # [1] "Gõ cửa" mọi địa chỉ trong dải cùng lúc bằng 64 luồng (poke() ở trên)
    with ThreadPoolExecutor(max_workers=64) as pool:
        list(pool.map(poke, hosts))
    # [2] Đợi 2 giây để các thiết bị kịp trả lời ARP và hệ điều hành kịp ghi vào
    #     bảng ARP
    time.sleep(2)
    # [3] Đọc bảng ARP (IP -> MAC), chỉ giữ IP thuộc dải đang quét và bỏ địa chỉ
    #     broadcast/multicast (không phải thiết bị thật)
    neighbors = {ip: mac for ip, mac in read_neighbors().items() if ip in hosts and is_unicast(ip, mac)}
    me = local_ip()
    if me in hosts:
        neighbors.setdefault(me, "(may nay)")
    devices = []
    for ip in sorted(neighbors, key=lambda x: ipaddress.ip_address(x)):
        mac = neighbors[ip]
        is_self = mac == "(may nay)"
        devices.append({"ip": ip, "mac": mac,
                        "vendor": "may cua ban" if is_self else describe_mac(mac, oui),
                        "hostname": hostname(ip),
                        "ports": scan_ports(ip) if with_ports else []})
        devices[-1]["os_guess"] = guess_os(ttl_of(ip)) if with_ports else ""
        devices[-1]["guess"] = guess_device(devices[-1])
    return devices


def _dev(ip, mac, vendor, host, ports=(), ttl=None):
    d = {"ip": ip, "mac": mac, "vendor": vendor, "hostname": host,
         "ports": [{"port": p, "service": PORTS.get(p, "?"), "banner": b} for p, b in ports]}
    d["os_guess"] = guess_os(ttl)
    d["guess"] = guess_device(d)
    return d


def demo_inventory(day):
    # Dữ liệu MẪU để học đọc kết quả, không quét mạng thật. Hãng và tên là giả định.
    private = "MAC rieng/ngau nhien (dien thoai thuong bat tinh nang nay)"
    devices = [
        _dev("192.168.1.1", "f4:ec:38:11:22:33", "Hang router A", "router.lan", [(80, "Server: lighttpd")], 64),
        _dev("192.168.1.23", "6a:6c:4a:21:dc:21", private, "macbook-cua-ban.lan", ttl=64),
        _dev("192.168.1.40", "3c:22:fb:aa:bb:cc", "Hang may in X", "npi-3f2a",
             [(80, "Server: Embedded HTTP Server"), (631, ""), (9100, "")], 255),
        _dev("192.168.1.45", "9c:b2:00:44:55:66", "Hang camera Y", "cam-phong-1",
             [(23, ""), (80, "Server: CamHTTPD/1.0"), (554, "")], 64),
        _dev("192.168.1.60", "3a:5b:7c:12:34:56", private, "galaxy-s24.lan", ttl=64),
        _dev("192.168.1.70", "00:1b:21:aa:bb:cc", "Hang may tinh Z", "PC-THUQUY", [(445, ""), (3389, "")], 128),
    ]
    if day == 1:
        devices.append(_dev("192.168.1.52", "da:a1:19:00:00:01", private, "", [(62078, "")], 64))
    else:
        devices[-1] = _dev("192.168.1.70", "00:1b:21:aa:bb:cc", "Hang may tinh Z", "PC-THUQUY",
                           [(445, ""), (8000, "Server: SimpleHTTP/0.6 Python/3.9.6")], 128)
        devices.append(_dev("192.168.1.77", "c2:11:aa:bb:cc:dd", private, "", ttl=64))
    return sorted(devices, key=lambda d: ipaddress.ip_address(d["ip"]))


def show(devices):
    print(f"{'IP':<16}{'MAC':<19}{'Hang / ghi chu':<34}Ten")
    for d in devices:
        print(f"{d['ip']:<16}{d['mac']:<19}{d['vendor'][:32]:<34}{d['hostname'][:30]}")
        for p in d["ports"]:
            print(f"    mo port {p['port']:<6}{p['service']:<20}{p['banner']}")
        if d.get("guess") or d.get("os_guess"):
            print(f"    DOAN: {', '.join(d.get('guess', [])) or 'chua du dau hieu'} | HDH: {d.get('os_guess') or '-'}")
    print(f"\nTong cong: {len(devices)} thiet bi tra loi.")


def compare(devices, baseline_path):
    old = {d["mac"]: d for d in json.loads(Path(baseline_path).read_text(encoding="utf-8"))}
    now = {d["mac"]: d for d in devices}
    new = [d for mac, d in now.items() if mac not in old]
    gone = [d for mac, d in old.items() if mac not in now]
    print("\nThiet bi MOI so voi lan truoc:", [f"{d['ip']} ({d['mac']})" for d in new] or "khong co")
    print("Thiet bi KHONG con tra loi:", [f"{d['ip']} ({d['mac']})" for d in gone] or "khong co")
    for mac in sorted(now.keys() & old.keys()):
        before = {p["port"] for p in old[mac]["ports"]}
        after = {p["port"] for p in now[mac]["ports"]}
        if before != after:
            print(f"Port THAY DOI o {now[mac]['ip']}: moi mo {sorted(after - before)}, da dong {sorted(before - after)}")


def main():
    ap = argparse.ArgumentParser(description="Liet ke thiet bi trong mang nha cua ban")
    ap.add_argument("--net", help="dai mang, vi du 192.168.1.0/24 (mac dinh: tu doc mat na mang cua may ban)")
    ap.add_argument("--oui", default="oui.txt", help="file oui.txt cua IEEE de tra hang san xuat")
    ap.add_argument("--no-ports", action="store_true", help="khong quet port")
    ap.add_argument("--save", default="inventory.json", help="file luu ket qua")
    ap.add_argument("--baseline", help="file ket qua lan truoc de so sanh thiet bi moi")
    ap.add_argument("--demo", type=int, choices=[1, 2], help="dung du lieu MAU (ngay 1 hoac ngay 2), khong quet mang that")
    ap.add_argument("--only", help="chi quet port cua mot IP rieng (vi du 127.0.0.1), bo qua buoc tim thiet bi")
    args = ap.parse_args()
    if args.only:
        ip = ipaddress.ip_address(args.only)
        if not ip.is_private:
            raise SystemExit("Chi quet IP rieng (192.168.x.x, 10.x.x.x, 127.0.0.1...).")
        found = scan_ports(str(ip))
        print(f"Port mo tren {ip}:", ", ".join(f"{p['port']}/{p['service']}" for p in found) or "khong co")
        for p in found:
            print(f"    {p['port']:<6}{p['service']:<20}{p['banner']}")
        guess = guess_device({"ports": found, "hostname": hostname(str(ip))})
        print("DOAN:", ", ".join(guess) or "chua du dau hieu")
        return
    if args.demo:
        print(f"[DU LIEU MAU ngay {args.demo}: khong quet mang that]")
        devices = demo_inventory(args.demo)
        show(devices)
        Path(args.save).write_text(json.dumps(devices, ensure_ascii=False, indent=2), encoding="utf-8")
        print(f"Da luu {args.save}")
        if args.baseline:
            compare(devices, args.baseline)
        return
    # [1] Dải mạng cần quét: người dùng gõ --net thì dùng, không thì tự đọc từ card
    #     mạng của máy
    network = ipaddress.ip_network(args.net, strict=False) if args.net else detect_network(local_ip())
    # [2] Chốt chặn an toàn: chỉ nhận dải IP RIÊNG và tối đa 1024 địa chỉ; ngoài ra
    #     thoát ngay, chưa gửi gói nào
    if not network.is_private or network.num_addresses > MAX_HOSTS:
        raise SystemExit("Chi quet dai IP rieng (192.168.x.x, 10.x.x.x...) va khong qua 1024 dia chi.")
    print(f"Dang kiem tra {network} ({network.num_addresses} dia chi), cho khoang 5-30 giay...")
    devices = build_inventory(network, load_oui(args.oui), not args.no_ports)
    show(devices)
    Path(args.save).write_text(json.dumps(devices, ensure_ascii=False, indent=2), encoding="utf-8")
    print(f"Da luu {args.save}")
    if args.baseline:
        compare(devices, args.baseline)


if __name__ == "__main__":
    main()
EOF
WINDOWS POWERSHELL: TẠO lan_inventory.py
@'
# lan_inventory.py - liệt kê thiết bị trong mạng nhà của BẠN:
#                    IP, MAC, hãng sản xuất, tên, port mở.
# Chỉ chạy trên mạng của chính bạn hoặc nơi được cho phép.
# Chỉ dùng thư viện có sẵn của Python 3.
import argparse
import ipaddress
import json
import platform
import re
import socket
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

# Bảng port quen thuộc: số port -> tên dịch vụ thường chạy ở đó.
# Công cụ chỉ thử các port có trong bảng này.
PORTS = {21: "ftp", 22: "ssh", 23: "telnet", 53: "dns", 80: "http", 139: "netbios", 443: "https",
         445: "smb", 554: "rtsp (camera)", 631: "ipp (may in)", 3389: "rdp", 5000: "http-alt/airplay",
         7000: "airplay", 8000: "http-alt", 8008: "chromecast", 8080: "http-proxy", 8443: "https-alt",
         9100: "may in", 62078: "iphone-sync"}
MAX_HOSTS = 1024   # giới hạn số địa chỉ mỗi lần quét, để gõ nhầm dải quá lớn không làm nghẽn mạng


def local_ip():
    # Mục đích: biết IP của máy mình trong mạng LAN (một máy có thể có nhiều card mạng).
    # connect() với UDP KHÔNG gửi gói nào ra mạng; nó chỉ bắt hệ điều hành chọn
    # card mạng sẽ dùng để tới địa chỉ 192.0.2.1 (địa chỉ dành riêng cho tài liệu,
    # không ai dùng). Sau đó ta đọc IP của card mạng đó.
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
        s.connect(("192.0.2.1", 9))
        return s.getsockname()[0]


def detect_network(ip):
    # Đọc mặt nạ mạng thật của card mạng (cho biết dải IP gồm bao nhiêu địa chỉ).
    # Không đọc được thì coi là /24 (256 địa chỉ, mạng nhà phổ biến nhất).
    system = platform.system()
    try:
        if system == "Linux":
            out = subprocess.run(["ip", "-o", "-4", "addr"], capture_output=True, text=True).stdout
            m = re.search(re.escape(ip) + r"/(\d+)", out)
            if m:
                return ipaddress.ip_network(f"{ip}/{m.group(1)}", strict=False)
        elif system == "Windows":
            lines = subprocess.run(["ipconfig"], capture_output=True, text=True).stdout.splitlines()
            for i, line in enumerate(lines):
                if ip in line:
                    for nxt in lines[i + 1:i + 3]:
                        m = re.search(r"(255\.\d+\.\d+\.\d+)", nxt)
                        if m:
                            return ipaddress.ip_network(f"{ip}/{m.group(1)}", strict=False)
        else:
            out = subprocess.run(["ifconfig"], capture_output=True, text=True).stdout
            m = re.search(r"inet " + re.escape(ip) + r" netmask (0x[0-9a-f]+)", out)
            if m:
                prefix = bin(int(m.group(1), 16)).count("1")
                return ipaddress.ip_network(f"{ip}/{prefix}", strict=False)
    except (OSError, ValueError):
        pass
    return ipaddress.ip_network(f"{ip}/24", strict=False)


def normalize_mac(raw):
    # macOS in MAC không đệm số 0 ("a:b:c:1:2:3"), Windows dùng dấu gạch nối: đưa về
    # dạng chuẩn aa:bb:cc:dd:ee:ff
    parts = re.split(r"[:-]", raw.lower())
    return ":".join(p.zfill(2) for p in parts) if len(parts) == 6 else None


def read_neighbors():
    system = platform.system()
    if system == "Linux":
        out = subprocess.run(["ip", "neigh"], capture_output=True, text=True).stdout
        pattern = r"^(\d+\.\d+\.\d+\.\d+) .*lladdr ([0-9a-f:]{11,17})"
    else:
        out = subprocess.run(["arp", "-a"], capture_output=True, text=True).stdout
        pattern = r"(\d+\.\d+\.\d+\.\d+)\)? (?:at )?\s*([0-9a-fA-F]{1,2}(?:[:-][0-9a-fA-F]{1,2}){5})"
    found = {}
    for line in out.splitlines():
        m = re.search(pattern, line)
        if m and normalize_mac(m.group(2)):
            found[m.group(1)] = normalize_mac(m.group(2))
    return found


def is_unicast(ip, mac):
    a = ipaddress.ip_address(ip)
    return not (a.is_multicast or ip.endswith(".255") or mac == "ff:ff:ff:ff:ff:ff" or mac.startswith("01:00:5e"))


def poke(ip):
    # MỤC ĐÍCH: "gõ cửa" một IP để máy phải hỏi ARP "ai giữ IP này?".
    # Thiết bị nào có thật cũng trả lời ARP (kể cả khi nó chặn ping), nên MAC của
    # nó sẽ nằm trong bảng ARP và ta đọc được.
    try:
        # [1] Tạo socket UDP (SOCK_DGRAM): chỉ "ném" gói đi, không bắt tay, không
        #     chờ trả lời
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            # [2] Gửi 1 byte rỗng tới port 9. Nội dung không quan trọng; điều quan
            #     trọng là hệ điều hành phải hỏi ARP để biết MAC của IP này TRƯỚC
            #     khi đưa gói ra card mạng
            s.sendto(b"\0", (ip, 9))
    except OSError:
        # [3] Thiết bị trả lời "port này không mở" cũng có thể gây lỗi; ta không cần
        #     kết quả đó -> bỏ qua
        pass


def load_oui(path):
    table = {}
    if path and Path(path).exists():
        for line in Path(path).read_text(encoding="utf-8", errors="ignore").splitlines():
            if "(hex)" in line:
                prefix, _, name = line.partition("(hex)")
                table[prefix.strip().replace("-", ":").lower()] = name.strip()
    return table


def describe_mac(mac, oui):
    # [1] Đọc byte đầu của MAC. Bit 0x02 bật = MAC do thiết bị tự đặt/ngẫu nhiên
    #     (điện thoại hay làm vậy để khó bị theo dõi) -> không có hãng thật nào để
    #     tra, trả lời luôn
    if int(mac[:2], 16) & 0x02:
        return "MAC rieng/ngau nhien (dien thoai thuong bat tinh nang nay)"
    # [2] MAC thật: 8 ký tự đầu ("aa:bb:cc" = 3 byte OUI) là mã hãng; tra trong bảng
    #     oui.txt (không có thì nói rõ lý do)
    return oui.get(mac[:8], "khong co trong oui.txt" if oui else "chua co oui.txt")


def ttl_of(ip):
    # Gửi đúng 1 gói ping tới thiết bị ĐÃ TÌM THẤY để đọc TTL.
    # (Việc tìm thiết bị không dựa vào ping.)
    # [1] Mỗi hệ điều hành viết lệnh ping khác nhau: -n (Windows) / -c (macOS,
    #     Linux) là số gói; -w, -t, -W là thời gian chờ
    system = platform.system()
    if system == "Windows":
        cmd = ["ping", "-n", "1", "-w", "1000", ip]
    elif system == "Darwin":
        cmd = ["ping", "-c", "1", "-t", "2", ip]
    else:
        cmd = ["ping", "-c", "1", "-W", "1", ip]
    # [2] Chạy lệnh ping và đọc chữ nó in ra; chờ tối đa 4 giây. Lỗi hoặc quá hạn
    #     thì trả None (không rõ)
    try:
        out = subprocess.run(cmd, capture_output=True, text=True, timeout=4).stdout
    except (OSError, subprocess.SubprocessError):
        return None
    # [3] Tìm trong chữ đó đoạn "ttl=64" (hoặc "TTL=128"...) bằng regex; re.I =
    #     không phân biệt hoa thường
    m = re.search(r"ttl[=:]\s*(\d+)", out, re.I)
    # [4] Có thì trả về con số TTL, không thì trả None
    return int(m.group(1)) if m else None


def guess_os(ttl):
    # TTL khởi đầu của gói trả lời: ~64 Linux/macOS/iOS/Android, ~128 Windows,
    # ~255 thiết bị mạng. Chỉ là gợi ý. (Gói đi qua mỗi router bị trừ 1 nên số
    # nhận được có thể nhỏ hơn số khởi đầu một chút, vì vậy ta chia theo KHOẢNG.)
    # [1] Không đọc được TTL (thiết bị chặn ping): không đoán bừa
    if ttl is None:
        return "khong ro (thiet bi chan ping)"
    # [2] TTL từ 64 trở xuống: nhóm Linux/macOS/iOS/Android
    if ttl <= 64:
        return f"nhom Linux/macOS/iOS/Android (TTL {ttl})"
    # [3] TTL trên 64 đến 128: nhóm Windows
    if ttl <= 128:
        return f"nhom Windows (TTL {ttl})"
    # [4] Còn lại (tới 255): thiết bị mạng hoặc máy in
    return f"thiet bi mang/may in (TTL {ttl})"


# Bảng 1 - đoán theo CỔNG: thiết bị mở MỘT TRONG các cổng bên trái
#          thì gợi ý nhãn bên phải.
HINTS = [({631, 9100}, "may in"), ({554}, "camera"), ({62078}, "iPhone/iPad"), ({8008, 8009}, "Chromecast/TV Google"),
         ({3389}, "may Windows (Remote Desktop)"), ({445, 139}, "chia se file Windows/NAS"), ({22}, "may Linux/NAS/router (SSH)"),
         ({5000, 7000}, "Mac/Apple TV (AirPlay)"), ({23}, "thiet bi cu (Telnet): coi chung")]
# Bảng 2 - đoán theo TÊN HOST: tên (viết thường) có chứa từ khóa bên trái
#          thì gợi ý nhãn bên phải.
NAME_HINTS = [("iphone", "iPhone"), ("ipad", "iPad"), ("galaxy", "Samsung Galaxy"), ("android", "Android"),
              ("macbook", "Mac"), ("printer", "may in"), ("tv", "TV"), ("cam", "camera"), ("router", "router")]


def guess_device(device):
    # Ghép các manh mối thành nhãn gợi ý: cổng mở (HINTS) + tên host (NAME_HINTS).
    # [1] Lấy tập các số port đang mở của thiết bị
    open_ports = {p["port"] for p in device["ports"]}
    # [2] Với mỗi dòng của HINTS: nếu tập cổng của dòng đó GIAO với cổng đang mở
    #     (phép &) thì lấy nhãn của dòng đó
    clues = [label for ports, label in HINTS if open_ports & ports]
    # [3] Đổi tên host sang chữ thường để so khớp không phân biệt hoa/thường
    name = device["hostname"].lower()
    # [4] Với mỗi từ khóa của NAME_HINTS có trong tên host: thêm nhãn tương ứng
    clues += [label for key, label in NAME_HINTS if key in name]
    # [5] set() bỏ nhãn trùng, sorted() sắp xếp để kết quả ổn định giữa các lần chạy
    return sorted(set(clues))


def hostname(ip):
    try:
        return socket.gethostbyaddr(ip)[0]
    except OSError:
        return ""


def grab_banner(ip, port):
    try:
        with socket.create_connection((ip, port), timeout=0.6) as s:
            s.settimeout(0.6)
            if port in (80, 8000, 8008, 8080):
                s.sendall(b"HEAD / HTTP/1.0\r\n\r\n")
            data = s.recv(200).decode("utf-8", errors="replace")
    except OSError:
        return ""
    for line in data.splitlines():
        if line.lower().startswith("server:"):
            return line[7:].strip()
    return data.splitlines()[0][:60] if data else ""


def scan_ports(ip):
    # Tìm các port đang mở của MỘT thiết bị: thử kết nối TCP tới từng port trong PORTS.
    def probe(port):
        # [1] Thử kết nối TCP tới 1 port, chờ tối đa 0,5 giây. connect_ex() trả 0
        #     nếu kết nối thành công (= port mở), trả mã lỗi khác 0 nếu không (không
        #     ném exception như connect()). Mở -> trả số port, không mở -> None
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.settimeout(0.5)
            return port if s.connect_ex((ip, port)) == 0 else None
    # [2] Thử mọi port CÙNG LÚC bằng nhiều luồng (thread), không thử lần lượt: nhanh
    #     hơn rất nhiều vì phần lớn thời gian là ĐỢI
    with ThreadPoolExecutor(max_workers=len(PORTS)) as pool:
        # [3] Giữ lại port mở (bỏ None), sắp xếp từ nhỏ đến lớn
        open_ports = sorted(p for p in pool.map(probe, PORTS) if p)
    # [4] Với mỗi port mở: ghi tên dịch vụ quen thuộc và đọc "banner" (câu tự giới
    #     thiệu của dịch vụ, nếu có)
    return [{"port": p, "service": PORTS[p], "banner": grab_banner(ip, p)} for p in open_ports]


def build_inventory(network, oui, with_ports):
    hosts = [str(h) for h in network.hosts()] or [str(network.network_address)]
    # [1] "Gõ cửa" mọi địa chỉ trong dải cùng lúc bằng 64 luồng (poke() ở trên)
    with ThreadPoolExecutor(max_workers=64) as pool:
        list(pool.map(poke, hosts))
    # [2] Đợi 2 giây để các thiết bị kịp trả lời ARP và hệ điều hành kịp ghi vào
    #     bảng ARP
    time.sleep(2)
    # [3] Đọc bảng ARP (IP -> MAC), chỉ giữ IP thuộc dải đang quét và bỏ địa chỉ
    #     broadcast/multicast (không phải thiết bị thật)
    neighbors = {ip: mac for ip, mac in read_neighbors().items() if ip in hosts and is_unicast(ip, mac)}
    me = local_ip()
    if me in hosts:
        neighbors.setdefault(me, "(may nay)")
    devices = []
    for ip in sorted(neighbors, key=lambda x: ipaddress.ip_address(x)):
        mac = neighbors[ip]
        is_self = mac == "(may nay)"
        devices.append({"ip": ip, "mac": mac,
                        "vendor": "may cua ban" if is_self else describe_mac(mac, oui),
                        "hostname": hostname(ip),
                        "ports": scan_ports(ip) if with_ports else []})
        devices[-1]["os_guess"] = guess_os(ttl_of(ip)) if with_ports else ""
        devices[-1]["guess"] = guess_device(devices[-1])
    return devices


def _dev(ip, mac, vendor, host, ports=(), ttl=None):
    d = {"ip": ip, "mac": mac, "vendor": vendor, "hostname": host,
         "ports": [{"port": p, "service": PORTS.get(p, "?"), "banner": b} for p, b in ports]}
    d["os_guess"] = guess_os(ttl)
    d["guess"] = guess_device(d)
    return d


def demo_inventory(day):
    # Dữ liệu MẪU để học đọc kết quả, không quét mạng thật. Hãng và tên là giả định.
    private = "MAC rieng/ngau nhien (dien thoai thuong bat tinh nang nay)"
    devices = [
        _dev("192.168.1.1", "f4:ec:38:11:22:33", "Hang router A", "router.lan", [(80, "Server: lighttpd")], 64),
        _dev("192.168.1.23", "6a:6c:4a:21:dc:21", private, "macbook-cua-ban.lan", ttl=64),
        _dev("192.168.1.40", "3c:22:fb:aa:bb:cc", "Hang may in X", "npi-3f2a",
             [(80, "Server: Embedded HTTP Server"), (631, ""), (9100, "")], 255),
        _dev("192.168.1.45", "9c:b2:00:44:55:66", "Hang camera Y", "cam-phong-1",
             [(23, ""), (80, "Server: CamHTTPD/1.0"), (554, "")], 64),
        _dev("192.168.1.60", "3a:5b:7c:12:34:56", private, "galaxy-s24.lan", ttl=64),
        _dev("192.168.1.70", "00:1b:21:aa:bb:cc", "Hang may tinh Z", "PC-THUQUY", [(445, ""), (3389, "")], 128),
    ]
    if day == 1:
        devices.append(_dev("192.168.1.52", "da:a1:19:00:00:01", private, "", [(62078, "")], 64))
    else:
        devices[-1] = _dev("192.168.1.70", "00:1b:21:aa:bb:cc", "Hang may tinh Z", "PC-THUQUY",
                           [(445, ""), (8000, "Server: SimpleHTTP/0.6 Python/3.9.6")], 128)
        devices.append(_dev("192.168.1.77", "c2:11:aa:bb:cc:dd", private, "", ttl=64))
    return sorted(devices, key=lambda d: ipaddress.ip_address(d["ip"]))


def show(devices):
    print(f"{'IP':<16}{'MAC':<19}{'Hang / ghi chu':<34}Ten")
    for d in devices:
        print(f"{d['ip']:<16}{d['mac']:<19}{d['vendor'][:32]:<34}{d['hostname'][:30]}")
        for p in d["ports"]:
            print(f"    mo port {p['port']:<6}{p['service']:<20}{p['banner']}")
        if d.get("guess") or d.get("os_guess"):
            print(f"    DOAN: {', '.join(d.get('guess', [])) or 'chua du dau hieu'} | HDH: {d.get('os_guess') or '-'}")
    print(f"\nTong cong: {len(devices)} thiet bi tra loi.")


def compare(devices, baseline_path):
    old = {d["mac"]: d for d in json.loads(Path(baseline_path).read_text(encoding="utf-8"))}
    now = {d["mac"]: d for d in devices}
    new = [d for mac, d in now.items() if mac not in old]
    gone = [d for mac, d in old.items() if mac not in now]
    print("\nThiet bi MOI so voi lan truoc:", [f"{d['ip']} ({d['mac']})" for d in new] or "khong co")
    print("Thiet bi KHONG con tra loi:", [f"{d['ip']} ({d['mac']})" for d in gone] or "khong co")
    for mac in sorted(now.keys() & old.keys()):
        before = {p["port"] for p in old[mac]["ports"]}
        after = {p["port"] for p in now[mac]["ports"]}
        if before != after:
            print(f"Port THAY DOI o {now[mac]['ip']}: moi mo {sorted(after - before)}, da dong {sorted(before - after)}")


def main():
    ap = argparse.ArgumentParser(description="Liet ke thiet bi trong mang nha cua ban")
    ap.add_argument("--net", help="dai mang, vi du 192.168.1.0/24 (mac dinh: tu doc mat na mang cua may ban)")
    ap.add_argument("--oui", default="oui.txt", help="file oui.txt cua IEEE de tra hang san xuat")
    ap.add_argument("--no-ports", action="store_true", help="khong quet port")
    ap.add_argument("--save", default="inventory.json", help="file luu ket qua")
    ap.add_argument("--baseline", help="file ket qua lan truoc de so sanh thiet bi moi")
    ap.add_argument("--demo", type=int, choices=[1, 2], help="dung du lieu MAU (ngay 1 hoac ngay 2), khong quet mang that")
    ap.add_argument("--only", help="chi quet port cua mot IP rieng (vi du 127.0.0.1), bo qua buoc tim thiet bi")
    args = ap.parse_args()
    if args.only:
        ip = ipaddress.ip_address(args.only)
        if not ip.is_private:
            raise SystemExit("Chi quet IP rieng (192.168.x.x, 10.x.x.x, 127.0.0.1...).")
        found = scan_ports(str(ip))
        print(f"Port mo tren {ip}:", ", ".join(f"{p['port']}/{p['service']}" for p in found) or "khong co")
        for p in found:
            print(f"    {p['port']:<6}{p['service']:<20}{p['banner']}")
        guess = guess_device({"ports": found, "hostname": hostname(str(ip))})
        print("DOAN:", ", ".join(guess) or "chua du dau hieu")
        return
    if args.demo:
        print(f"[DU LIEU MAU ngay {args.demo}: khong quet mang that]")
        devices = demo_inventory(args.demo)
        show(devices)
        Path(args.save).write_text(json.dumps(devices, ensure_ascii=False, indent=2), encoding="utf-8")
        print(f"Da luu {args.save}")
        if args.baseline:
            compare(devices, args.baseline)
        return
    # [1] Dải mạng cần quét: người dùng gõ --net thì dùng, không thì tự đọc từ card
    #     mạng của máy
    network = ipaddress.ip_network(args.net, strict=False) if args.net else detect_network(local_ip())
    # [2] Chốt chặn an toàn: chỉ nhận dải IP RIÊNG và tối đa 1024 địa chỉ; ngoài ra
    #     thoát ngay, chưa gửi gói nào
    if not network.is_private or network.num_addresses > MAX_HOSTS:
        raise SystemExit("Chi quet dai IP rieng (192.168.x.x, 10.x.x.x...) va khong qua 1024 dia chi.")
    print(f"Dang kiem tra {network} ({network.num_addresses} dia chi), cho khoang 5-30 giay...")
    devices = build_inventory(network, load_oui(args.oui), not args.no_ports)
    show(devices)
    Path(args.save).write_text(json.dumps(devices, ensure_ascii=False, indent=2), encoding="utf-8")
    print(f"Da luu {args.save}")
    if args.baseline:
        compare(devices, args.baseline)


if __name__ == "__main__":
    main()
'@ | Set-Content -Path lan_inventory.py -Encoding utf8
LINUX: TẠO lan_inventory.py
cat > lan_inventory.py <<'EOF'
# lan_inventory.py - liệt kê thiết bị trong mạng nhà của BẠN:
#                    IP, MAC, hãng sản xuất, tên, port mở.
# Chỉ chạy trên mạng của chính bạn hoặc nơi được cho phép.
# Chỉ dùng thư viện có sẵn của Python 3.
import argparse
import ipaddress
import json
import platform
import re
import socket
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

# Bảng port quen thuộc: số port -> tên dịch vụ thường chạy ở đó.
# Công cụ chỉ thử các port có trong bảng này.
PORTS = {21: "ftp", 22: "ssh", 23: "telnet", 53: "dns", 80: "http", 139: "netbios", 443: "https",
         445: "smb", 554: "rtsp (camera)", 631: "ipp (may in)", 3389: "rdp", 5000: "http-alt/airplay",
         7000: "airplay", 8000: "http-alt", 8008: "chromecast", 8080: "http-proxy", 8443: "https-alt",
         9100: "may in", 62078: "iphone-sync"}
MAX_HOSTS = 1024   # giới hạn số địa chỉ mỗi lần quét, để gõ nhầm dải quá lớn không làm nghẽn mạng


def local_ip():
    # Mục đích: biết IP của máy mình trong mạng LAN (một máy có thể có nhiều card mạng).
    # connect() với UDP KHÔNG gửi gói nào ra mạng; nó chỉ bắt hệ điều hành chọn
    # card mạng sẽ dùng để tới địa chỉ 192.0.2.1 (địa chỉ dành riêng cho tài liệu,
    # không ai dùng). Sau đó ta đọc IP của card mạng đó.
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
        s.connect(("192.0.2.1", 9))
        return s.getsockname()[0]


def detect_network(ip):
    # Đọc mặt nạ mạng thật của card mạng (cho biết dải IP gồm bao nhiêu địa chỉ).
    # Không đọc được thì coi là /24 (256 địa chỉ, mạng nhà phổ biến nhất).
    system = platform.system()
    try:
        if system == "Linux":
            out = subprocess.run(["ip", "-o", "-4", "addr"], capture_output=True, text=True).stdout
            m = re.search(re.escape(ip) + r"/(\d+)", out)
            if m:
                return ipaddress.ip_network(f"{ip}/{m.group(1)}", strict=False)
        elif system == "Windows":
            lines = subprocess.run(["ipconfig"], capture_output=True, text=True).stdout.splitlines()
            for i, line in enumerate(lines):
                if ip in line:
                    for nxt in lines[i + 1:i + 3]:
                        m = re.search(r"(255\.\d+\.\d+\.\d+)", nxt)
                        if m:
                            return ipaddress.ip_network(f"{ip}/{m.group(1)}", strict=False)
        else:
            out = subprocess.run(["ifconfig"], capture_output=True, text=True).stdout
            m = re.search(r"inet " + re.escape(ip) + r" netmask (0x[0-9a-f]+)", out)
            if m:
                prefix = bin(int(m.group(1), 16)).count("1")
                return ipaddress.ip_network(f"{ip}/{prefix}", strict=False)
    except (OSError, ValueError):
        pass
    return ipaddress.ip_network(f"{ip}/24", strict=False)


def normalize_mac(raw):
    # macOS in MAC không đệm số 0 ("a:b:c:1:2:3"), Windows dùng dấu gạch nối: đưa về
    # dạng chuẩn aa:bb:cc:dd:ee:ff
    parts = re.split(r"[:-]", raw.lower())
    return ":".join(p.zfill(2) for p in parts) if len(parts) == 6 else None


def read_neighbors():
    system = platform.system()
    if system == "Linux":
        out = subprocess.run(["ip", "neigh"], capture_output=True, text=True).stdout
        pattern = r"^(\d+\.\d+\.\d+\.\d+) .*lladdr ([0-9a-f:]{11,17})"
    else:
        out = subprocess.run(["arp", "-a"], capture_output=True, text=True).stdout
        pattern = r"(\d+\.\d+\.\d+\.\d+)\)? (?:at )?\s*([0-9a-fA-F]{1,2}(?:[:-][0-9a-fA-F]{1,2}){5})"
    found = {}
    for line in out.splitlines():
        m = re.search(pattern, line)
        if m and normalize_mac(m.group(2)):
            found[m.group(1)] = normalize_mac(m.group(2))
    return found


def is_unicast(ip, mac):
    a = ipaddress.ip_address(ip)
    return not (a.is_multicast or ip.endswith(".255") or mac == "ff:ff:ff:ff:ff:ff" or mac.startswith("01:00:5e"))


def poke(ip):
    # MỤC ĐÍCH: "gõ cửa" một IP để máy phải hỏi ARP "ai giữ IP này?".
    # Thiết bị nào có thật cũng trả lời ARP (kể cả khi nó chặn ping), nên MAC của
    # nó sẽ nằm trong bảng ARP và ta đọc được.
    try:
        # [1] Tạo socket UDP (SOCK_DGRAM): chỉ "ném" gói đi, không bắt tay, không
        #     chờ trả lời
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            # [2] Gửi 1 byte rỗng tới port 9. Nội dung không quan trọng; điều quan
            #     trọng là hệ điều hành phải hỏi ARP để biết MAC của IP này TRƯỚC
            #     khi đưa gói ra card mạng
            s.sendto(b"\0", (ip, 9))
    except OSError:
        # [3] Thiết bị trả lời "port này không mở" cũng có thể gây lỗi; ta không cần
        #     kết quả đó -> bỏ qua
        pass


def load_oui(path):
    table = {}
    if path and Path(path).exists():
        for line in Path(path).read_text(encoding="utf-8", errors="ignore").splitlines():
            if "(hex)" in line:
                prefix, _, name = line.partition("(hex)")
                table[prefix.strip().replace("-", ":").lower()] = name.strip()
    return table


def describe_mac(mac, oui):
    # [1] Đọc byte đầu của MAC. Bit 0x02 bật = MAC do thiết bị tự đặt/ngẫu nhiên
    #     (điện thoại hay làm vậy để khó bị theo dõi) -> không có hãng thật nào để
    #     tra, trả lời luôn
    if int(mac[:2], 16) & 0x02:
        return "MAC rieng/ngau nhien (dien thoai thuong bat tinh nang nay)"
    # [2] MAC thật: 8 ký tự đầu ("aa:bb:cc" = 3 byte OUI) là mã hãng; tra trong bảng
    #     oui.txt (không có thì nói rõ lý do)
    return oui.get(mac[:8], "khong co trong oui.txt" if oui else "chua co oui.txt")


def ttl_of(ip):
    # Gửi đúng 1 gói ping tới thiết bị ĐÃ TÌM THẤY để đọc TTL.
    # (Việc tìm thiết bị không dựa vào ping.)
    # [1] Mỗi hệ điều hành viết lệnh ping khác nhau: -n (Windows) / -c (macOS,
    #     Linux) là số gói; -w, -t, -W là thời gian chờ
    system = platform.system()
    if system == "Windows":
        cmd = ["ping", "-n", "1", "-w", "1000", ip]
    elif system == "Darwin":
        cmd = ["ping", "-c", "1", "-t", "2", ip]
    else:
        cmd = ["ping", "-c", "1", "-W", "1", ip]
    # [2] Chạy lệnh ping và đọc chữ nó in ra; chờ tối đa 4 giây. Lỗi hoặc quá hạn
    #     thì trả None (không rõ)
    try:
        out = subprocess.run(cmd, capture_output=True, text=True, timeout=4).stdout
    except (OSError, subprocess.SubprocessError):
        return None
    # [3] Tìm trong chữ đó đoạn "ttl=64" (hoặc "TTL=128"...) bằng regex; re.I =
    #     không phân biệt hoa thường
    m = re.search(r"ttl[=:]\s*(\d+)", out, re.I)
    # [4] Có thì trả về con số TTL, không thì trả None
    return int(m.group(1)) if m else None


def guess_os(ttl):
    # TTL khởi đầu của gói trả lời: ~64 Linux/macOS/iOS/Android, ~128 Windows,
    # ~255 thiết bị mạng. Chỉ là gợi ý. (Gói đi qua mỗi router bị trừ 1 nên số
    # nhận được có thể nhỏ hơn số khởi đầu một chút, vì vậy ta chia theo KHOẢNG.)
    # [1] Không đọc được TTL (thiết bị chặn ping): không đoán bừa
    if ttl is None:
        return "khong ro (thiet bi chan ping)"
    # [2] TTL từ 64 trở xuống: nhóm Linux/macOS/iOS/Android
    if ttl <= 64:
        return f"nhom Linux/macOS/iOS/Android (TTL {ttl})"
    # [3] TTL trên 64 đến 128: nhóm Windows
    if ttl <= 128:
        return f"nhom Windows (TTL {ttl})"
    # [4] Còn lại (tới 255): thiết bị mạng hoặc máy in
    return f"thiet bi mang/may in (TTL {ttl})"


# Bảng 1 - đoán theo CỔNG: thiết bị mở MỘT TRONG các cổng bên trái
#          thì gợi ý nhãn bên phải.
HINTS = [({631, 9100}, "may in"), ({554}, "camera"), ({62078}, "iPhone/iPad"), ({8008, 8009}, "Chromecast/TV Google"),
         ({3389}, "may Windows (Remote Desktop)"), ({445, 139}, "chia se file Windows/NAS"), ({22}, "may Linux/NAS/router (SSH)"),
         ({5000, 7000}, "Mac/Apple TV (AirPlay)"), ({23}, "thiet bi cu (Telnet): coi chung")]
# Bảng 2 - đoán theo TÊN HOST: tên (viết thường) có chứa từ khóa bên trái
#          thì gợi ý nhãn bên phải.
NAME_HINTS = [("iphone", "iPhone"), ("ipad", "iPad"), ("galaxy", "Samsung Galaxy"), ("android", "Android"),
              ("macbook", "Mac"), ("printer", "may in"), ("tv", "TV"), ("cam", "camera"), ("router", "router")]


def guess_device(device):
    # Ghép các manh mối thành nhãn gợi ý: cổng mở (HINTS) + tên host (NAME_HINTS).
    # [1] Lấy tập các số port đang mở của thiết bị
    open_ports = {p["port"] for p in device["ports"]}
    # [2] Với mỗi dòng của HINTS: nếu tập cổng của dòng đó GIAO với cổng đang mở
    #     (phép &) thì lấy nhãn của dòng đó
    clues = [label for ports, label in HINTS if open_ports & ports]
    # [3] Đổi tên host sang chữ thường để so khớp không phân biệt hoa/thường
    name = device["hostname"].lower()
    # [4] Với mỗi từ khóa của NAME_HINTS có trong tên host: thêm nhãn tương ứng
    clues += [label for key, label in NAME_HINTS if key in name]
    # [5] set() bỏ nhãn trùng, sorted() sắp xếp để kết quả ổn định giữa các lần chạy
    return sorted(set(clues))


def hostname(ip):
    try:
        return socket.gethostbyaddr(ip)[0]
    except OSError:
        return ""


def grab_banner(ip, port):
    try:
        with socket.create_connection((ip, port), timeout=0.6) as s:
            s.settimeout(0.6)
            if port in (80, 8000, 8008, 8080):
                s.sendall(b"HEAD / HTTP/1.0\r\n\r\n")
            data = s.recv(200).decode("utf-8", errors="replace")
    except OSError:
        return ""
    for line in data.splitlines():
        if line.lower().startswith("server:"):
            return line[7:].strip()
    return data.splitlines()[0][:60] if data else ""


def scan_ports(ip):
    # Tìm các port đang mở của MỘT thiết bị: thử kết nối TCP tới từng port trong PORTS.
    def probe(port):
        # [1] Thử kết nối TCP tới 1 port, chờ tối đa 0,5 giây. connect_ex() trả 0
        #     nếu kết nối thành công (= port mở), trả mã lỗi khác 0 nếu không (không
        #     ném exception như connect()). Mở -> trả số port, không mở -> None
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.settimeout(0.5)
            return port if s.connect_ex((ip, port)) == 0 else None
    # [2] Thử mọi port CÙNG LÚC bằng nhiều luồng (thread), không thử lần lượt: nhanh
    #     hơn rất nhiều vì phần lớn thời gian là ĐỢI
    with ThreadPoolExecutor(max_workers=len(PORTS)) as pool:
        # [3] Giữ lại port mở (bỏ None), sắp xếp từ nhỏ đến lớn
        open_ports = sorted(p for p in pool.map(probe, PORTS) if p)
    # [4] Với mỗi port mở: ghi tên dịch vụ quen thuộc và đọc "banner" (câu tự giới
    #     thiệu của dịch vụ, nếu có)
    return [{"port": p, "service": PORTS[p], "banner": grab_banner(ip, p)} for p in open_ports]


def build_inventory(network, oui, with_ports):
    hosts = [str(h) for h in network.hosts()] or [str(network.network_address)]
    # [1] "Gõ cửa" mọi địa chỉ trong dải cùng lúc bằng 64 luồng (poke() ở trên)
    with ThreadPoolExecutor(max_workers=64) as pool:
        list(pool.map(poke, hosts))
    # [2] Đợi 2 giây để các thiết bị kịp trả lời ARP và hệ điều hành kịp ghi vào
    #     bảng ARP
    time.sleep(2)
    # [3] Đọc bảng ARP (IP -> MAC), chỉ giữ IP thuộc dải đang quét và bỏ địa chỉ
    #     broadcast/multicast (không phải thiết bị thật)
    neighbors = {ip: mac for ip, mac in read_neighbors().items() if ip in hosts and is_unicast(ip, mac)}
    me = local_ip()
    if me in hosts:
        neighbors.setdefault(me, "(may nay)")
    devices = []
    for ip in sorted(neighbors, key=lambda x: ipaddress.ip_address(x)):
        mac = neighbors[ip]
        is_self = mac == "(may nay)"
        devices.append({"ip": ip, "mac": mac,
                        "vendor": "may cua ban" if is_self else describe_mac(mac, oui),
                        "hostname": hostname(ip),
                        "ports": scan_ports(ip) if with_ports else []})
        devices[-1]["os_guess"] = guess_os(ttl_of(ip)) if with_ports else ""
        devices[-1]["guess"] = guess_device(devices[-1])
    return devices


def _dev(ip, mac, vendor, host, ports=(), ttl=None):
    d = {"ip": ip, "mac": mac, "vendor": vendor, "hostname": host,
         "ports": [{"port": p, "service": PORTS.get(p, "?"), "banner": b} for p, b in ports]}
    d["os_guess"] = guess_os(ttl)
    d["guess"] = guess_device(d)
    return d


def demo_inventory(day):
    # Dữ liệu MẪU để học đọc kết quả, không quét mạng thật. Hãng và tên là giả định.
    private = "MAC rieng/ngau nhien (dien thoai thuong bat tinh nang nay)"
    devices = [
        _dev("192.168.1.1", "f4:ec:38:11:22:33", "Hang router A", "router.lan", [(80, "Server: lighttpd")], 64),
        _dev("192.168.1.23", "6a:6c:4a:21:dc:21", private, "macbook-cua-ban.lan", ttl=64),
        _dev("192.168.1.40", "3c:22:fb:aa:bb:cc", "Hang may in X", "npi-3f2a",
             [(80, "Server: Embedded HTTP Server"), (631, ""), (9100, "")], 255),
        _dev("192.168.1.45", "9c:b2:00:44:55:66", "Hang camera Y", "cam-phong-1",
             [(23, ""), (80, "Server: CamHTTPD/1.0"), (554, "")], 64),
        _dev("192.168.1.60", "3a:5b:7c:12:34:56", private, "galaxy-s24.lan", ttl=64),
        _dev("192.168.1.70", "00:1b:21:aa:bb:cc", "Hang may tinh Z", "PC-THUQUY", [(445, ""), (3389, "")], 128),
    ]
    if day == 1:
        devices.append(_dev("192.168.1.52", "da:a1:19:00:00:01", private, "", [(62078, "")], 64))
    else:
        devices[-1] = _dev("192.168.1.70", "00:1b:21:aa:bb:cc", "Hang may tinh Z", "PC-THUQUY",
                           [(445, ""), (8000, "Server: SimpleHTTP/0.6 Python/3.9.6")], 128)
        devices.append(_dev("192.168.1.77", "c2:11:aa:bb:cc:dd", private, "", ttl=64))
    return sorted(devices, key=lambda d: ipaddress.ip_address(d["ip"]))


def show(devices):
    print(f"{'IP':<16}{'MAC':<19}{'Hang / ghi chu':<34}Ten")
    for d in devices:
        print(f"{d['ip']:<16}{d['mac']:<19}{d['vendor'][:32]:<34}{d['hostname'][:30]}")
        for p in d["ports"]:
            print(f"    mo port {p['port']:<6}{p['service']:<20}{p['banner']}")
        if d.get("guess") or d.get("os_guess"):
            print(f"    DOAN: {', '.join(d.get('guess', [])) or 'chua du dau hieu'} | HDH: {d.get('os_guess') or '-'}")
    print(f"\nTong cong: {len(devices)} thiet bi tra loi.")


def compare(devices, baseline_path):
    old = {d["mac"]: d for d in json.loads(Path(baseline_path).read_text(encoding="utf-8"))}
    now = {d["mac"]: d for d in devices}
    new = [d for mac, d in now.items() if mac not in old]
    gone = [d for mac, d in old.items() if mac not in now]
    print("\nThiet bi MOI so voi lan truoc:", [f"{d['ip']} ({d['mac']})" for d in new] or "khong co")
    print("Thiet bi KHONG con tra loi:", [f"{d['ip']} ({d['mac']})" for d in gone] or "khong co")
    for mac in sorted(now.keys() & old.keys()):
        before = {p["port"] for p in old[mac]["ports"]}
        after = {p["port"] for p in now[mac]["ports"]}
        if before != after:
            print(f"Port THAY DOI o {now[mac]['ip']}: moi mo {sorted(after - before)}, da dong {sorted(before - after)}")


def main():
    ap = argparse.ArgumentParser(description="Liet ke thiet bi trong mang nha cua ban")
    ap.add_argument("--net", help="dai mang, vi du 192.168.1.0/24 (mac dinh: tu doc mat na mang cua may ban)")
    ap.add_argument("--oui", default="oui.txt", help="file oui.txt cua IEEE de tra hang san xuat")
    ap.add_argument("--no-ports", action="store_true", help="khong quet port")
    ap.add_argument("--save", default="inventory.json", help="file luu ket qua")
    ap.add_argument("--baseline", help="file ket qua lan truoc de so sanh thiet bi moi")
    ap.add_argument("--demo", type=int, choices=[1, 2], help="dung du lieu MAU (ngay 1 hoac ngay 2), khong quet mang that")
    ap.add_argument("--only", help="chi quet port cua mot IP rieng (vi du 127.0.0.1), bo qua buoc tim thiet bi")
    args = ap.parse_args()
    if args.only:
        ip = ipaddress.ip_address(args.only)
        if not ip.is_private:
            raise SystemExit("Chi quet IP rieng (192.168.x.x, 10.x.x.x, 127.0.0.1...).")
        found = scan_ports(str(ip))
        print(f"Port mo tren {ip}:", ", ".join(f"{p['port']}/{p['service']}" for p in found) or "khong co")
        for p in found:
            print(f"    {p['port']:<6}{p['service']:<20}{p['banner']}")
        guess = guess_device({"ports": found, "hostname": hostname(str(ip))})
        print("DOAN:", ", ".join(guess) or "chua du dau hieu")
        return
    if args.demo:
        print(f"[DU LIEU MAU ngay {args.demo}: khong quet mang that]")
        devices = demo_inventory(args.demo)
        show(devices)
        Path(args.save).write_text(json.dumps(devices, ensure_ascii=False, indent=2), encoding="utf-8")
        print(f"Da luu {args.save}")
        if args.baseline:
            compare(devices, args.baseline)
        return
    # [1] Dải mạng cần quét: người dùng gõ --net thì dùng, không thì tự đọc từ card
    #     mạng của máy
    network = ipaddress.ip_network(args.net, strict=False) if args.net else detect_network(local_ip())
    # [2] Chốt chặn an toàn: chỉ nhận dải IP RIÊNG và tối đa 1024 địa chỉ; ngoài ra
    #     thoát ngay, chưa gửi gói nào
    if not network.is_private or network.num_addresses > MAX_HOSTS:
        raise SystemExit("Chi quet dai IP rieng (192.168.x.x, 10.x.x.x...) va khong qua 1024 dia chi.")
    print(f"Dang kiem tra {network} ({network.num_addresses} dia chi), cho khoang 5-30 giay...")
    devices = build_inventory(network, load_oui(args.oui), not args.no_ports)
    show(devices)
    Path(args.save).write_text(json.dumps(devices, ensure_ascii=False, indent=2), encoding="utf-8")
    print(f"Da luu {args.save}")
    if args.baseline:
        compare(devices, args.baseline)


if __name__ == "__main__":
    main()
EOF
06

Bước 5 · Những dòng code ngăn công cụ quét nhầm chỗ

15 phút
Kiểm tra IPis_private / is_loopback
Giới hạn số lượngMAX_HOSTS, MAX_PORTS
Không ghép chuỗi lệnhshell=False, list tham số
Thoát sớm, nói rõ lý doSystemExit

Vì sao bước này nằm ở cuối bài: bốn bước trước đã hiểu toàn bộ quy trình công cụ LÀM GÌ. Câu hỏi cuối cùng, luôn nên tự hỏi với bất kỳ công cụ quét nào, là "nó có thể bị dùng SAI chỗ không, và code có tự ngăn việc đó không?". Nhiệm vụ của Bước 5 không phải học khái niệm mới — đã học kỹ ở bài 10 — mà là nhận ra đúng nguyên tắc đó đang nằm ở đâu trong hai file đã đọc suốt bài.

Giải pháp: trước khi làm bất cứ việc gì ở Bước 1–4, cả hai file đều kiểm tra đầu vào (địa chỉ IP hoặc dải mạng người dùng gõ) theo đúng một khuôn mẫu, từ chối thẳng phần còn lại — không phải để chặn lệnh chèn vào shell như bài 10, mà để chặn chính người dùng công cụ (có thể là bạn, lúc gõ nhầm) khỏi quét sai chỗ. Vì sao phải chặn trước khi quét, không phải sau: một khi gói tin đã được gửi ra ngoài mạng riêng, việc đó không thể rút lại — nên phải chặn ngay từ dòng đầu tiên, trước khi bất kỳ gói nào rời khỏi máy.

BƯỚC 1Trong port_scan.py

Luồng kiểm tra đầu vào của port_scan.py

  1. Phân tích IPSai định dạng thì báo lỗi ngay
  2. Là IP riêng hoặc loopback?Không → thoát, chưa gửi gói nào
  3. Phân tích danh sách portGõ sai → thoát kèm lý do
  4. Số port ≤ 2000?Quá nhiều → thoát
PYTHON · trích main() trong port_scan.py
    # [1] Phân tích chuỗi IP người dùng gõ (sai định dạng thì tự báo lỗi, dừng ngay)
    ip = ipaddress.ip_address(args.ip)
    # [2] Chốt chặn: chỉ nhận IP riêng hoặc 127.0.0.1; IP Internet bị từ chối ngay
    if not (ip.is_private or ip.is_loopback):
        raise SystemExit("Chi quet IP rieng (192.168.x.x, 10.x.x.x, 127.0.0.1...). Khong quet Internet.")
    # [3] Lấy danh sách port: người dùng gõ -p thì phân tích, không thì dùng bảng
    #     COMMON; gõ sai thì thoát kèm lý do
    try:
        ports = parse_ports(args.ports) if args.ports else COMMON
    except ValueError as e:
        raise SystemExit("Loi port: " + str(e))
    # [4] Giới hạn số port mỗi lần quét
    if len(ports) > MAX_PORTS:
        raise SystemExit(f"Toi da {MAX_PORTS} port moi lan. Ban nhap {len(ports)}.")
PhầnÝ nghĩa
ip = ipaddress.ip_address(args.ip)Thử phân tích chuỗi IP người dùng gõ vào, gán kết quả vào biến ip. Sai định dạng thì dòng này tự báo lỗi, dừng trước khi quét gì.
if not (ip.is_private or ip.is_loopback):Hỏi: IP vừa đọc có phải là IP riêng hoặc loopback (127.0.0.1) không? not đảo ngược câu hỏi thành "nếu KHÔNG phải".
raise SystemExit("Chi quet IP rieng...")Nếu đúng là "không phải" ở dòng trên, dừng hẳn chương trình ngay tại đây và in ra đúng dòng chữ này — : gõ nhầm một IP công khai trên Internet, chương trình tự chặn, không chạy thêm dòng nào nữa.
try:Thử chạy dòng đọc danh sách port bên dưới; có lỗi thì nhảy xuống except.
ports = parse_ports(args.ports) if args.ports else COMMONMột cách viết if/else gọn trên một dòng: nếu người dùng có gõ -p (args.ports có giá trị), dùng hàm parse_ports để đọc nó; nếu không, dùng sẵn danh sách COMMON (~30 port phổ biến).
except ValueError as e:Nếu parse_ports gặp port viết sai (chữ không phải số, port ngoài 1–65535…), nó tự báo lỗi ValueError. Dòng này bắt đúng lỗi đó, đặt tên tắt là e.
raise SystemExit("Loi port: " + str(e))Dừng chương trình, in ra chữ "Loi port:" cộng với nội dung lỗi thật (e) để người dùng biết chính xác đã gõ sai gì.
if len(ports) > MAX_PORTS:Hỏi: số lượng port trong danh sách có vượt quá 2000 không?
raise SystemExit(f"Toi da {MAX_PORTS}...")Nếu vượt quá, dừng chương trình. Không có hai dòng này, gõ nhầm -p 1-65535 có thể vô tình tạo 65535 luồng/kết nối cùng lúc, dễ làm nghẽn máy hoặc mạng.

Dòng ports = parse_ports(args.ports) if args.ports else COMMON viết bằng if/else đầy đủ, cùng ý nghĩa:

TƯƠNG ĐƯƠNG VIẾT DÀI (CHỈ ĐỂ GIẢI THÍCH, KHÔNG CÓ TRONG FILE THẬT)
if args.ports:
    ports = parse_ports(args.ports)
else:
    ports = COMMON

BƯỚC 2Trong lan_inventory.py

Luồng kiểm tra đầu vào của lan_inventory.py

  1. Xác định dải mạngNgười dùng gõ --net, hoặc tự đọc từ card mạng
  2. Dải riêng và ≤ 1024 địa chỉ?Không → thoát, chưa gửi gói nào
  3. Qua hết chốt chặnLúc này mới bắt đầu quét
PYTHON · trích main() trong lan_inventory.py
    # [1] Dải mạng cần quét: người dùng gõ --net thì dùng, không thì tự đọc từ card
    #     mạng của máy
    network = ipaddress.ip_network(args.net, strict=False) if args.net else detect_network(local_ip())
    # [2] Chốt chặn an toàn: chỉ nhận dải IP RIÊNG và tối đa 1024 địa chỉ; ngoài ra
    #     thoát ngay, chưa gửi gói nào
    if not network.is_private or network.num_addresses > MAX_HOSTS:
        raise SystemExit("Chi quet dai IP rieng (192.168.x.x, 10.x.x.x...) va khong qua 1024 dia chi.")
PhầnÝ nghĩa
network = ipaddress.ip_network(...) if args.net else detect_network(local_ip())Lại một dòng if/else viết gọn: nếu người dùng có gõ --net (args.net có giá trị), dùng đúng dải đó; nếu không, tự động đoán dải mạng của máy mình bằng detect_network(local_ip()) — đọc lại địa chỉ mạng thật từ hệ điều hành.
if not network.is_private or network.num_addresses > MAX_HOSTS:Hỏi: dải mạng này có không phải dải riêng, HOẶC có nhiều hơn 1024 địa chỉ không?
raise SystemExit("Chi quet dai IP rieng...")Nếu một trong hai điều trên đúng, dừng hẳn chương trình — : quét cả Internet hoặc một mạng khổng lồ không phải mục đích của công cụ này.

Dòng đầu bảng trên, viết bằng if/else đầy đủ:

TƯƠNG ĐƯƠNG VIẾT DÀI (CHỈ ĐỂ GIẢI THÍCH, KHÔNG CÓ TRONG FILE THẬT)
if args.net:
    network = ipaddress.ip_network(args.net, strict=False)
else:
    network = detect_network(local_ip())

Hàm detect_network() được gọi ở dòng đầu bảng trên cũng áp dụng đúng nguyên tắc đã học ở bài 10: khi cần đọc địa chỉ mạng thật từ hệ điều hành, nó gọi lệnh hệ thống bằng danh sách tham số (ví dụ ["ip", "-o", "-4", "addr"]), không ghép chuỗi — không có chỗ nào đưa dữ liệu người dùng vào một lệnh shell.

IP / dải mạng người dùng gõvd: "8.8.8.8" hoặc "10.0.0.0/8"ALLOWLIST CHECKis_private hoặcis_loopback ?KHÔNGCÓraise SystemExit(...)dừng NGAY, in rõ lý dokhông gửi một gói tin nàoTiếp tụcbắt đầu quét / kiểm trađúng dải đã xin phépCùng một nguyên tắc với bài 10: chỉ nhận dữ liệu đúng khuôn mẫu đã định (IP riêng hoặc loopback),từ chối thẳng phần còn lại — không hỏi lại, không "thử sửa giúp".
Hình 5. Đây chính là hai dòng if not (ip.is_private or ip.is_loopback): raise SystemExit(...) đọc ở phần dưới — vẽ lại thành luồng rẽ nhánh để thấy rõ: không có nhánh thứ ba, sai khuôn mẫu là dừng hẳn.
Một nguyên tắc, nhiều chỗ áp dụngBài 10 dùng allowlist để chặn lệnh lạ chạy ngoài ý. Bài này cho thấy đúng nguyên tắc đó còn dùng để chặn phạm vi quét sai. Khi đọc code của người khác, một câu hỏi luôn nên tự hỏi: "input này được kiểm tra ở đâu, theo khuôn mẫu nào, và điều gì xảy ra khi kiểm tra đó thất bại?"
07

Bài tập về nhà · Bài 2: Chỉ quét IP của mình

30–40 phút, làm ở nhà
Đọc IPTừ dòng lệnh
Kiểm traIP riêng hay lạ?
Quét 4 cổngNhư Bài 1
In kết quảMO / khong mo

Bài 1 quét cổng của chính máy bạn. Bài 2 nâng cấp nó theo đúng điều đã học ở Bước 5: chương trình phải tự từ chối khi bị chỉ vào một IP không phải của mình. Bài này làm ở nhà, tự viết, không có file khung.

BÀI TẬP 2 · 35 PHÚT

Nâng cấp Bài 1: chỉ quét IP của mình

ĐỀ BÀI

Sao chép bai1.py thành bai2.py, rồi sửa lại để chương trình nhận IP từ dòng lệnh và chỉ quét khi IP đó là IP của mình (IP riêng hoặc 127.0.0.1).

YÊU CẦU

  1. Chạy dạng python3 bai2.py 127.0.0.1. Nếu không gõ IP thì mặc định dùng 127.0.0.1. (IP nằm ở sys.argv[1].)
  2. IP viết sai định dạng (ví dụ abc) thì in IP khong hop le: abc rồi dừng.
  3. IP không phải riêng và không phải loopback (ví dụ 8.8.8.8) thì in Khong duoc quet 8.8.8.8 rồi dừng — chưa gửi gói nào.
  4. IP hợp lệ thì in OK: duoc phep quet 127.0.0.1, rồi quét 4 cổng 22, 80, 8080, 8800 y như Bài 1, nhưng nối vào IP đó.

CHƯƠNG TRÌNH CHẠY RA NHƯ THẾ NÀY

VÍ DỤ KẾT QUẢ
$ python3 bai2.py 127.0.0.1
OK: duoc phep quet 127.0.0.1
Cong 22: khong mo
Cong 80: khong mo
Cong 8080: khong mo
Cong 8800: MO
Tong so cong mo: 1

$ python3 bai2.py 8.8.8.8
Khong duoc quet 8.8.8.8

$ python3 bai2.py abc
IP khong hop le: abc

Ý TƯỞNG BẰNG CHỮ (không phải code, bạn tự đổi thành code)

Ý TƯỞNG
lấy text = IP người dùng gõ (không gõ thì lấy "127.0.0.1")
thử đọc text thành IP
    nếu sai định dạng:  dừng với lời nhắn "IP khong hop le: ..."
nếu IP không phải riêng và không phải loopback:  dừng với lời nhắn "Khong duoc quet ..."
in "OK: duoc phep quet ..."
quét 4 cổng như Bài 1, nối vào IP đó
Gợi ý, dùng lại những gì đã học ở Bước 5
  • import ipaddress, rồi ip = ipaddress.ip_address(text) đọc chuỗi thành IP; sai định dạng thì Python báo ValueError (bắt bằng try/except).
  • ip.is_private và ip.is_loopback cho biết IP riêng hay loopback; kiểm tra if not (ip.is_private or ip.is_loopback):.
  • Dừng chương trình kèm lời nhắn: raise SystemExit("lời nhắn").
  • Nối vào IP vừa đọc: s.connect_ex((str(ip), port)).

Cách chạy thử — chạy lần lượt ba lệnh (Bài 1 và server cổng 8800 làm tương tự):

MACOS
python3 bai2.py 127.0.0.1
WINDOWS
py bai2.py 127.0.0.1
LINUX
python3 bai2.py 127.0.0.1
MACOS
python3 bai2.py 8.8.8.8
WINDOWS
py bai2.py 8.8.8.8
LINUX
python3 bai2.py 8.8.8.8
MACOS
python3 bai2.py abc
WINDOWS
py bai2.py abc
LINUX
python3 bai2.py abc
Bạn làm đúng khi
  • Ba lệnh cho ra đúng ba kết quả như ví dụ ở trên (kể cả chữ in hoa/thường).
  • Lệnh 8.8.8.8 không in dòng cổng nào — chương trình dừng trước khi quét.
  • Chạy python3 bai2.py (không gõ IP) vẫn quét 127.0.0.1 bình thường.

Nộp: file bai2.py và ảnh chụp kết quả ba lệnh ở trên. Trả lời thêm bằng 1–2 câu: nếu không có đoạn kiểm tra IP, gõ nhầm 8.8.8.8 thì điều gì xảy ra?

Đọc thêm: Làm thêm cho vui (không tính điểm) (không bắt buộc)
  • Thêm một cổng bạn chọn vào danh sách (ví dụ 3306) và chạy lại.
  • Sau dòng tổng, in thêm DOAN: may chu web nếu có ít nhất một cổng 80, 8080 hoặc 8800 đang mở (ý tưởng của guess_device() ở Bước 4).
Xem đáp án (code mẫu) — hãy tự làm trước

Code này đã chạy thử và ra đúng kết quả mẫu. Cách viết của bạn có thể khác miễn là ra cùng kết quả.

PYTHON · bai_tap_2_dap_an.py
# bai_tap_2_dap_an.py - ĐÁP ÁN Bài tập 2: chỉ quét IP của mình (IP riêng hoặc 127.0.0.1).
# Chạy:  python3 bai_tap_2_dap_an.py 127.0.0.1     hoặc     python3 bai_tap_2_dap_an.py 8.8.8.8
import ipaddress
import socket
import sys

# 1. Lấy IP người dùng gõ sau tên file; không gõ gì thì dùng 127.0.0.1.
if len(sys.argv) > 1:
    text = sys.argv[1]
else:
    text = "127.0.0.1"

# 2. Đọc chuỗi thành IP; viết sai (ví dụ "abc") thì dừng ngay.
try:
    ip = ipaddress.ip_address(text)
except ValueError:
    raise SystemExit("IP khong hop le: " + text)

# 3. Chỉ cho quét IP riêng hoặc 127.0.0.1; IP khác thì dừng, chưa gửi gói nào.
if not (ip.is_private or ip.is_loopback):
    raise SystemExit("Khong duoc quet " + text)

print(f"OK: duoc phep quet {ip}")

# 4. Quét 4 cổng như Bài 1, nhưng nối vào IP vừa kiểm tra.
danh_sach_cong = [22, 80, 8080, 8800]
dem = 0
for port in danh_sach_cong:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(0.5)
        ket_qua = s.connect_ex((str(ip), port))
    if ket_qua == 0:
        print(f"Cong {port}: MO")
        dem = dem + 1
    else:
        print(f"Cong {port}: khong mo")

print(f"Tong so cong mo: {dem}")
Đọc thêm: Đáp án câu hỏi cuối (không bắt buộc)

Chương trình sẽ gửi các kết nối TCP thật tới 8.8.8.8 — một địa chỉ Internet không thuộc quyền của bạn. Đó là quét cổng trái phép, dù chỉ là gõ nhầm. Vì vậy phải chặn trước khi gửi gói nào.

Tóm tắt

Tự kiểm tra · 7 câu có giải thích +
1. Gửi một gói UDP tới port 9 ("discard") của một IP trong mạng giúp tìm thiết bị vì sao?
2. Trong guess_os(), nếu ttl_of() trả về None thì hàm làm gì?
3. connect_ex() trả về 0 có nghĩa là gì?
4. Vì sao scan_ports() trong lan_inventory.py dùng connect_ex() thay vì connect()+try/except như port_scan.py?
5. Vì sao quét 4000 port đóng trên 127.0.0.1 không nhanh hơn rõ rệt khi dùng luồng?
6. Trong guess_device(), dấu & giữa open_ports và ports (một phần tử của HINTS) dùng để làm gì?
7. Dòng "if not (ip.is_private or ip.is_loopback): raise SystemExit(...)" trong port_scan.py áp dụng cùng kỹ thuật nào đã học ở bài 10?

Ghi chú cá nhân +

Tự lưu trên trình duyệt. Không nhập thông tin bí mật.

Có thể bỏ đánh dấu bất cứ lúc nào.
Tài liệu tham khảo chính thức

Tra thuật ngữ