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

Ai đang ở trong mạng nhà bạn? Kiểm kê thiết bị bằng Python

Câu hỏi xuyên suốt: mạng Wi-Fi của câu lạc bộ có bao nhiêu thiết bị, mỗi thiết bị là gì, và làm sao biết có thiết bị lạ vừa vào?

Thời lượng: khoảng 2 giờ, chia hai buổi: bước 1–5 (hiểu và quan sát), bước 6–8 (làm công cụ và phòng thủ). Có 5 bài tập, bài cuối làm ở nhà

Bốn việc kiểm kê mạng nhà: tìm thiết bị, nhận diện, xem cổng mở, so sánh với lần trước.
Bốn việc kiểm kê mạng nhà: tìm thiết bị, nhận diện, xem cổng mở, so sánh với lần trước.
01

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

5 phút
TìmAi đang ở đây?
Nhận diệnĐó là thiết bị gì?
Cổng mởNó mở cửa nào?
So sánhCó gì mới không?
Tình huốngCâu lạc bộ có một router Wi-Fi. Thủ quỹ báo: "Mạng chậm quá, mà máy in và camera cũng dùng chung Wi-Fi này." Việc của bạn: biết trong mạng có những thiết bị nào.

Việc này gọi là kiểm kê: đếm xem nhà mình có những gì. Không biết mình có gì thì không bảo vệ được (bài 1).

Bốn việc, làm theo thứ tự

ViệcCâu hỏiLàm ở
1. TìmAi đang ở trong mạng?Bước 2 và 3
2. Nhận diệnThiết bị đó là gì?Bước 4
3. Xem cổngNó đang mở cửa nào?Bước 4 và 7
4. So sánhCó ai mới vào không?Bước 7 và 8

Cần chuẩn bị

  • Máy tính đang nối vào đúng mạng Wi-Fi cần kiểm kê.
  • Python 3.8 trở lên và terminal (đã cài ở bài 6). Không cài thêm gì.
  • Mật khẩu router: không bắt buộc. Bước 7 có cách học mà không cần mạng nào.
Quy tắc an toànChỉ kiểm kê mạng của chính bạn, hoặc nơi có người cho phép rõ ràng. Quét mạng trường học, quán cà phê, nhà hàng xóm là vi phạm, dù bạn "chỉ xem". Công cụ trong bài chỉ đếm và mô tả thiết bị, không đăng nhập vào thiết bị nào.
02

Bước 1 · Ba thứ cần biết: IP, MAC, port

10 phút
RouterPhát số phòng
IPSố phòng
MACSố của card mạng
PortÔ cửa dịch vụ

Mạng Wi-Fi giống một tòa chung cư. Mỗi thiết bị là một căn phòng. Muốn tìm đúng phòng, bạn cần phân biệt ba thứ:

Mạng Wi-Fi giống một tòa chung cưRouter là bảo vệ: phát số phòng cho từng người vào ởIPSố phòng192.168.1.40Router cho mượnHôm nay .40, mai có thể .52MACSố in trên card mạng3c:22:fb:aa:bb:ccGắn với phần cứngDùng để nhận ra đúng thiết bịPORTÔ cửa dịch vụ631 = cửa in ấnMỗi dịch vụ một cửaCửa mở = dịch vụ đang chạy
Hình 1. Cùng một thiết bị có ba thứ cần phân biệt: IP (đổi được), MAC (gần như cố định), port (cửa của từng dịch vụ).
ThứVí dụGiống nhưCó đổi không?
192.168.1.40Số phòngCó. Router cho mượn, mai có thể đổi
3c:22:fb:aa:bb:ccSố in trên card mạngGần như không (trừ MAC ngẫu nhiên, ở bước 4)
631Ô cửa của một dịch vụ (in, web…)Cố định theo dịch vụ
Nhớ một điềuIP không phải danh tính của thiết bị. Hôm nay điện thoại của Lan là .40, mai có thể là .52. Muốn nhận ra đúng thiết bị, hãy nhìn MAC.

Ai phát số phòng?

giữ phòng số .1 (cửa ra Internet). Nó cũng làm việc : thiết bị nào vào mạng cũng được phát một IP tự động. Một mạng nhà thường có khoảng 254 phòng, từ 192.168.1.1 đến 192.168.1.254.

Đọc thêm: mạng /24 và /22 là gì? (không bắt buộc)

Số sau dấu / cho biết mạng to hay nhỏ. Cách viết này gọi là . /24 có 256 địa chỉ. Nhà bạn có thể lớn hơn: /22 có 1024. Công cụ Python ở bước 6 tự đọc kích thước mạng, bạn không cần đoán.

Mạng nhà ví dụ: 192.168.68.0/22 (mặt nạ 255.255.252.0)192.168.68.x256 địa chỉ192.168.69.x256 địa chỉ192.168.70.x256 địa chỉ192.168.71.x256 địa chỉ.68.0tên của mạng.68.1router (gateway).68.2 → .71.254các thiết bị trong mạng.71.255quảng báSo sánh nếu đoán:/24 → chỉ quét 192.168.68.0 đến .68.255 (256 địa chỉ): bỏ sót thiết bị ở .69, .70, .71/22 → quét đủ 192.168.68.0 đến .71.255 (1024 địa chỉ): đúng mạng thậtCông cụ Python ở phần D tự đọc mặt nạ của card mạng nên không phải đoán.
Hình 2. Ảnh arp -a của bạn có dòng 192.168.71.255. Địa chỉ quảng bá là địa chỉ cuối của dải, nên dải này kết thúc ở .71.255: bốn khối 256 địa chỉ, tức /22. Đoán nhầm là /24 chỉ nhìn thấy khối đầu tiên.
03

Bước 2 · Tìm thiết bị bằng ARP

20 phút
Biết IP192.168.1.40
Hỏi cả mạngAi là .40?
Nhận MACChỉ máy in trả lời
Ghi nhớVào bảng ARP

Laptop biết IP của máy in, nhưng chưa biết MAC. Nó cần MAC để giao dữ liệu đúng chỗ. Cách tìm MAC gọi là .

ARP giống gọi to cả xóm"Ai ở phòng 40? Cho tôi số card mạng!" Chỉ máy in trả lời. Laptop ghi lại vào bảng ARP để lần sau khỏi hỏi.
Laptop192.168.1.23Máy in192.168.1.40MAC 3c:22:fb:…TV192.168.1.41nghe nhưng im lặngĐiện thoại192.168.1.52im lặngmạng LAN (Wi-Fi hoặc Ethernet): ai cũng nghe được gói quảng bá① HỎI cả mạng (quảng bá)"Ai có IP 192.168.1.40? Báo cho .23"② TRẢ LỜI riêng cho laptop"Là tôi, MAC 3c:22:fb:…" (chỉ máy in)③ Laptop ghi vào bảng ARP:192.168.1.40 → 3c:22:fb:… (rồi mới gửi dữ liệu tới đúng MAC đó)
Hình 3. Bước ①: laptop hỏi cả mạng (quảng bá), mọi thiết bị đều nghe. Bước ②: chỉ thiết bị có đúng IP đó trả lời riêng cho laptop. Bước ③: laptop lưu cặp IP–MAC vào bảng ARP để lần sau khỏi hỏi lại.

Máy bạn tự làm việc này. Bạn không phải làm gì, nhưng xem được kết quả.

BƯỚC 1Xem bảng ARP trên máy bạn

MACOS
arp -a
WINDOWS
arp -a
LINUX
ip neigh
# hoặc: arp -a

Lệnh này chỉ đọc sổ ARP có sẵn trong máy bạn. Nó không gửi gì ra mạng, không quét Wi-Fi và không cần mật khẩu router.

KẾT QUẢ MẪU TRÊN MACOS (ĐỊA CHỈ MINH HỌA)
? (192.168.1.1) at f4:ec:38:11:22:33 on en0 ifscope [ethernet]
? (192.168.1.40) at 3c:22:fb:aa:bb:cc on en0 ifscope [ethernet]
? (192.168.1.77) at (incomplete) on en0 ifscope [ethernet]
? (192.168.1.255) at ff:ff:ff:ff:ff:ff on en0 ifscope [ethernet]
mdns.mcast.net (224.0.0.251) at 1:0:5e:0:0:fb on en0 ifscope permanent [ethernet]

BƯỚC 2Đọc một dòng

Lấy dòng ? (192.168.1.40) at 3c:22:fb:aa:bb:cc on en0 …. Chỉ cần nhìn bốn mảnh:

PhầnÝ nghĩa
(192.168.1.40)IP của thiết bị.
at 3c:22:fb:aa:bb:ccMAC của thiết bị đó.
on en0Card mạng của máy bạn đang dùng. Không phải của thiết bị kia.
at (incomplete)Không ai trả lời. Chưa phải thiết bị.

Dấu ? ở đầu dòng chỉ có nghĩa là chưa có tên máy. Không phải cảnh báo.

Đọc thêm: đọc đầy đủ một dòng, cách đọc trên Windows và Linux (không bắt buộc)
? (192.168.68.1) at bc:07:1d:xx:xx:xx on en0 ifscope [ethernet]Tên máychưa biếtĐịa chỉ IPcủa thiết bị3 byte đầu= HÃNG (OUI)3 byte cuốisố riêng cardGiao diệncủa MÁY BẠNPhạm vigắn với en0Kiểu liên kếtnhãn hiển thị
Hình 4. Đọc từ trái sang phải. Phần quan trọng nhất để kiểm kê là IP và MAC; ba byte đầu của MAC cho biết hãng. Các phần còn lại mô tả cách máy bạn lưu mục này. (Ba byte cuối của MAC được thay bằng xx.)
PhầnÝ nghĩa
ifscopeMục gắn với card mạng đó. Không phải tên thiết bị, cũng không phải lỗi.
[ethernet]Nhãn kiểu liên kết. Wi-Fi trên Mac cũng có thể hiện nhãn này.
permanentMục do chính máy tạo, không hết hạn. Không có nghĩa thiết bị luôn bật.
ff:ff:ff:ff:ff:ffĐịa chỉ "gọi cả mạng" (broadcast), không phải một máy.
224.0.0.251 / 01:00:5e:…Địa chỉ nhóm (multicast) cho mDNS, không phải một máy.

Windows: mỗi hàng có Internet Address (IP), Physical Address (MAC), Type (dynamic là học tự động). Linux: ip neigh cho dòng kiểu 192.168.1.40 dev wlan0 lladdr 3c:22:fb:aa:bb:cc REACHABLE. lladdr là MAC. REACHABLE là mới xác nhận gần đây, STALE là cần xác nhận lại, FAILED là không ai trả lời.

Bảng ARP chưa phải danh sách cả mạngNó chỉ gồm những máy mà máy bạn đã nói chuyện gần đây. Ít dòng không có nghĩa là mạng ít thiết bị.

Lệnh nào xem máy bạn, lệnh nào quét cả Wi-Fi?

Đây là chỗ dễ nhầm nhất. Cùng là "xem thiết bị", nhưng mỗi lệnh nhìn một phạm vi khác nhau:

LệnhLàm gìNhìn thấy
arp -aĐọc lại sổ ARP đã có sẵn trong máy. Không gửi gì ra mạng.Máy bạn. Chỉ thấy thiết bị máy bạn từng nói chuyện
ping IPHỏi đúng một thiết bị. Máy bạn hỏi ARP để tìm nó, nên sổ có thêm một dòngMột thiết bị
Vòng ping cả dải (bước 4 bên dưới)Ping từng IP từ .1 đến .254, rồi đọc sổ ARPCả Wi-Fi, không cần cài gì
nmap -sn (bước 5)Gõ cửa cả dải IP, rồi in ai trả lờiCả Wi-Fi (cùng dải mạng)
lan_inventory.py (bước 6–7)Gõ cửa cả dải, đọc lại sổ ARP, nhận diện từng thiết bịCả Wi-Fi
--demo, --only 127.0.0.1 (bước 7)Dữ liệu mẫu, hoặc chỉ quét cổng của chính máy bạnKhông quét Wi-Fi
Ví vonarp -a giống mở danh bạ điện thoại của bạn: chỉ có những số bạn từng gọi. Quét cả dải giống gọi thử từng số trong cả khu rồi xem ai bắt máy. Danh bạ ít số không có nghĩa là khu ít người.

BƯỚC 3Thử trên máy của bạn

  1. Chạy lệnh ở trên. Chọn một dòng, chỉ ra đâu là IP, đâu là MAC.
  2. Nếu biết IP một thiết bị đang bật trong nhà, chạy ping IP_THIET_BI vài giây rồi nhấn Ctrl+C. Thay IP_THIET_BI bằng địa chỉ thật.
  3. Chạy arp -a lại. Dòng của thiết bị đó thường xuất hiện. Sổ của máy bạn vừa "lớn lên" vì bạn đã hỏi thêm một thiết bị. Quét cả dải (bước 6–7) chính là làm việc này cho mọi IP cùng lúc.

BƯỚC 4Quét cả Wi-Fi bằng vài dòng lệnh, chưa cần Python

Giờ làm điều ở bước 3: ping thử mọi IP trong dải, để máy bạn hỏi ARP tới từng cái. Rồi đọc lại sổ. Không cần cài gì thêm. Chỉ làm trên mạng của bạn.

Việc 1. Biết dải mạng của mình. Lấy IP của máy bạn:

MACOS
ipconfig getifaddr en0
WINDOWS
ipconfig
LINUX
hostname -I

Ví dụ ra 192.168.68.57, thì dải của bạn bắt đầu bằng 192.168.68. (Windows: tìm dòng "IPv4 Address"). Trong lệnh dưới, thay 192.168.68 bằng ba số đầu của máy bạn.

Việc 2. Ping thử cả dải (từ .1 đến .254), rồi xem sổ ARP:

MACOS (XONG TRONG KHOẢNG 2–3 GIÂY)
( for i in $(seq 1 254); do ping -c 1 -t 1 192.168.68.$i >/dev/null 2>&1 & done; wait )
arp -a
WINDOWS POWERSHELL (CHẬM HƠN, KHOẢNG 1 PHÚT)
1..254 | ForEach-Object { ping.exe -n 1 -w 200 192.168.68.$_ | Out-Null }
arp -a
LINUX (XONG TRONG KHOẢNG 2–3 GIÂY)
( for i in $(seq 1 254); do ping -c 1 -W 1 192.168.68.$i >/dev/null 2>&1 & done; wait )
ip neigh
PhầnÝ nghĩa
for i in … seq 1 254Lặp 254 lần, mỗi lần là một IP từ .1 đến .254.
ping -c 1 …Ping mỗi IP đúng một lần. Việc ping khiến máy bạn hỏi ARP tới IP đó.
>/dev/null 2>&1Giấu kết quả ping cho màn hình khỏi bị tràn. Ta chỉ cần sổ ARP.
& … waitPing song song cho nhanh, rồi chờ tất cả xong.
arp -aĐọc lại sổ. Lúc này sổ đã đầy hơn nhiều.

Việc 3. So sánh. Đếm số thiết bị thật (theo bước 3) trước và sau khi ping cả dải. Số sau thường lớn hơn, vì giờ bạn đã hỏi cả khu chứ không chỉ đọc danh bạ cũ.

Vẫn chỉ là "ít nhất N"Thiết bị đang ngủ có thể không trả lời, nên chạy lại 2–3 lần cách nhau vài phút. Nếu mạng của bạn rộng hơn /24 (ví dụ 192.168.68.0/22 gồm .68, .69, .70, .71), hãy lặp lại với 192.168.69, .70, .71. Công cụ Python ở bước 6 tự đọc kích thước mạng và nhận diện luôn từng thiết bị, nên đỡ phải làm tay.
Đọc thêm: vì sao ARP thấy cả thiết bị chặn ping? (không bắt buộc)

Ping dùng một cơ chế khác (ICMP), ARP thì hỏi địa chỉ MAC. Thiết bị có thể chặn ping nhưng vẫn trả lời ARP để nhận dữ liệu trong mạng. Dù vậy, máy đang ngủ hoặc ở mạng bị cách ly vẫn có thể bị sót. Kết quả luôn là "những gì quan sát được", không phải danh sách đầy đủ đã xác minh.

04

Bước 3 · Đếm cho đúng

15 phút
Lấy bảngarp -a
Loại dòngKhông phải thiết bị
Đếm theo MACKhông đếm theo IP

Không phải dòng nào trong bảng ARP cũng là một thiết bị. Dùng bảng này để lọc:

Dòng trông nhưLà gìTính là thiết bị?
(incomplete)Không ai trả lờiKhông
ff:ff:ff:ff:ff:ff, IP đuôi .255"Gọi cả mạng" (broadcast)Không
IP 224.…, MAC 01:00:5e…Địa chỉ nhóm (multicast)Không
IP 169.254.…IP tự đặt khi không có router cấpKhông
IP và MAC bình thườngMột thiết bị thậtCó

BƯỚC 1Thử đọc một bảng ARP thật

Bảng này lấy từ máy một học viên (ba byte cuối của MAC được che bằng xx). Đếm xem có mấy thiết bị thật, rồi mở đáp án.

KẾT QUẢ THẬT (ĐÃ CHE BA BYTE CUỐI CỦA MAC)
? (169.254.169.254) at (incomplete) on en0 [ethernet]
? (192.168.68.1) at bc:07:1d:xx:xx:xx on en0 ifscope [ethernet]
? (192.168.68.50) at e4:fe:43:xx:xx:xx on en0 ifscope [ethernet]
? (192.168.68.51) at e4:fe:43:xx:xx:xx on en0 ifscope [ethernet]
? (192.168.68.54) at 24:9e:7d:xx:xx:xx on en0 ifscope [ethernet]
? (192.168.68.58) at 6a:6c:4a:xx:xx:xx on en0 ifscope permanent [ethernet]
? (192.168.71.255) at ff:ff:ff:ff:ff:ff on en0 ifscope [ethernet]
mdns.mcast.net (224.0.0.251) at 1:0:5e:0:0:fb on en0 ifscope permanent [ethernet]
Xem đáp án
DòngKết luận
169.254.169.254Loại. incomplete và IP tự đặt
.68.1 · bc:07:1dRouter. IP đuôi .1, hãng TP-Link
.68.50 · e4:fe:43Thiết bị 1. Hãng Xiaomi
.68.51 · e4:fe:43Thiết bị 2. Cùng hãng nhưng ba byte cuối khác, nên là máy khác
.68.54 · 24:9e:7dThiết bị 3. Hãng Roborock, nhiều khả năng robot hút bụi
.68.58 · permanentChính máy bạn (chữ permanent)
192.168.71.255Loại. Broadcast
224.0.0.251Loại. Multicast

Đáp án: 5 thiết bị (router, hai thiết bị Xiaomi, một robot Roborock, và máy đang chạy lệnh). Còn một mẹo: dòng broadcast kết thúc ở .71.255 cho thấy mạng này to hơn /24 (xem Hình 2). Khi quét cả dải ở bước 7, chính mạng này ra tới 11 thiết bị: bảng ARP lúc đầu mới chỉ là một phần.

Hãng làm gì thì thiết bị chưa chắc là đóRoborock chỉ làm robot hút bụi nên đoán khá chắc. Xiaomi làm điện thoại, TV, ổ cắm, camera… nên chỉ riêng hãng chưa đủ. Bước 4 dạy ghép thêm manh mối.
Cách đếm đúng, ba bước(1) Lấy bảng ARP. (2) Loại các dòng không phải thiết bị. (3) Đếm theo MAC, không đếm theo IP. Rồi ghi "ít nhất N thiết bị đang trả lời", không ghi "mạng có đúng N thiết bị".

Vì sao đếm hoài vẫn lệch?

  • Thiết bị đang ngủ. Điện thoại tắt màn hình hay bỏ qua gói tin. Quét 2–3 lần, cách nhau vài phút.
  • MAC ngẫu nhiên. Một điện thoại có thể hiện như thiết bị mới sau khi đổi cài đặt (bước 4).
  • Mạng khách. Máy ở mạng khách không thấy máy ở mạng chính, và ngược lại.
  • Thêm router phụ hoặc Wi-Fi mesh. Một số thiết bị nằm ở dải IP khác.

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

Đọc bảng ARP: bao nhiêu thiết bị thật?

Bảng ARP của một mạng nhà (địa chỉ minh họa):

KẾT QUẢ · BẢNG ARP MINH HỌA
? (192.168.1.1)   at f4:ec:38:11:22:33
? (192.168.1.23)  at 6a:6c:4a:21:dc:21   (máy đang chạy lệnh)
? (192.168.1.40)  at 3c:22:fb:aa:bb:cc
? (192.168.1.41)  at 3a:5b:7c:12:34:56
? (192.168.1.60)  at 3a:5b:7c:12:34:56
? (192.168.1.77)  at (incomplete)
? (192.168.1.255) at ff:ff:ff:ff:ff:ff
mdns.mcast.net (224.0.0.251) at 1:0:5e:0:0:fb
  1. Mạng có bao nhiêu thiết bị thật?
  2. Hai dòng .41 và .60 cùng một MAC cho biết điều gì?
  3. Dòng .77 có nghĩa gì?
Xem đáp án
  1. Loại .255, 224.0.0.251 và .77. Còn 5 IP, nhưng .41 và .60 cùng MAC, nên là 4 thiết bị: router, máy đang chạy lệnh, thiết bị .40, thiết bị .41/.60.
  2. Một thiết bị giữ hai IP (ví dụ IP cũ chưa hết hạn). Vì vậy đếm theo MAC chính xác hơn đếm theo IP.
  3. Máy bạn đã hỏi ARP cho .77 nhưng không ai trả lời: IP trống, hoặc thiết bị đang ngủ. Không tính.
05

Bước 4 · Nhận diện: thiết bị này là gì?

30 phút
MACHãng sản xuất
Tên máyTên người đặt
Cổng mởDịch vụ đang chạy
TTLHọ hệ điều hành
Kết luậnCó bằng chứng, chưa chắc chắn

Có IP và MAC rồi, giờ đoán: thiết bị gì, hãng nào, hệ điều hành nào. Không manh mối nào đủ chắc một mình, nên ta làm như thám tử: ghép bốn manh mối.

Manh mốiHỏi gìLấy ở đâu
1. MACHãng nào làm card mạng?Ba byte đầu của MAC
2. Tên máyThiết bị tự đặt tên gì?Router, DNS, mDNS
3. Cổng mởNó đang chạy dịch vụ gì?Quét port
4. TTLHệ điều hành nào?Lệnh ping

BƯỚC 1Manh mối 1 · MAC cho biết hãng

Ba byte đầu của MAC là mã hãng (), giống đầu số điện thoại cho biết nhà mạng. Ba byte sau là số riêng của từng card.

Một địa chỉ MAC = 6 byte = 48 bitbc071dxxxxxxOUI: mã hãng (24 bit)IEEE cấp cho từng hãngSố riêng của card (24 bit)hãng tự đánh số từng thiết bịMở byte đầu ra thành 8 bit. Hai bit cuối (tô vàng) là hai "cờ":U/LI/Gbc : thiết bị thậtbc = 1011 110010111100U/L=0: địa chỉ do hãng cấpI/G=0: gửi cho một máy6a : MAC tự đặt6a = 0110 101001101010U/L=1: tự đặt/ngẫu nhiênI/G=0: gửi cho một máy01 : địa chỉ nhóm01 = 0000 000100000001U/L=0I/G=1: gửi cho cả nhómQuy tắc nhanh: chữ hex thứ hai là 2, 6, A hoặc E → MAC tự đặt (điện thoại bật "địa chỉ riêng").Chữ hex thứ hai là số lẻ (1, 3, 5, 7, 9, B, D, F) → địa chỉ nhóm, không phải một thiết bị.
Hình 5. Nhìn là biết hãng vì ba byte đầu do IEEE cấp riêng cho từng hãng. Nhưng phải xem byte đầu trước: nếu bit "tự đặt" bật (hex thứ hai là 2, 6, A hoặc E) thì không có hãng nào đứng sau địa chỉ đó.
Ba byte đầuTra ra hãngĐoán loại thiết bị
bc:07:1dTP-LinkRouter, bộ phát Wi-Fi
e4:fe:43XiaomiĐiện thoại, TV box, camera… cần thêm manh mối
24:9e:7dRoborockRobot hút bụi
MAC không tra ra hãng? Bình thườngNhiều điện thoại dùng MAC ngẫu nhiên (iPhone gọi là "Địa chỉ Wi-Fi riêng") để không bị theo dõi. Mẹo nhận biết: nhìn chữ thứ hai của MAC. Nếu là 2, 6, A hoặc E (như 6a:…, da:…) thì đó là MAC ngẫu nhiên. Bỏ qua manh mối 1, dùng ba manh mối còn lại. Đây là tính năng bảo vệ riêng tư, không phải thiết bị đáng ngờ.
Đọc thêm: vì sao chữ thứ hai nói lên điều đó, và cách tự tra hãng (không bắt buộc)

Hai bit cuối của byte đầu là hai "công tắc":

Chữ thứ haiÝ nghĩaVí dụ
0, 4, 8, CMAC do hãng cấp: tra được hãngbc, e4, 24
2, 6, A, EMAC tự đặt: không tra được hãng6a, da, 3a
1, 3, 5, 7, 9, B, D, FĐịa chỉ nhóm, không phải một thiết bị01, ff

Tự tra hãng: tải danh sách công khai của IEEE (một lần, vài MB), rồi tìm ba byte đầu. File dùng dấu gạch nối và chữ hoa.

MACOS / LINUX
curl -o oui.txt https://standards-oui.ieee.org/oui/oui.txt
grep -i "^BC-07-1D" oui.txt
WINDOWS POWERSHELL
curl.exe -o oui.txt https://standards-oui.ieee.org/oui/oui.txt
Select-String -Path oui.txt -Pattern "^BC-07-1D"
LINUX
curl -o oui.txt https://standards-oui.ieee.org/oui/oui.txt
grep -i "^BC-07-1D" oui.txt
KẾT QUẢ THẬT
BC-07-1D   (hex)		TP-Link Systems Inc.

Hãng nhỏ đôi khi hiện tên "IEEE Registration Authority" vì dùng chung một mã hãng. Hãng chip Wi-Fi (Espressif, Realtek…) cũng có thể hiện thay cho hãng bán sản phẩm. Gặp tên lạ thì tra xem hãng đó làm gì. Người có chủ ý còn đổi được MAC, nên manh mối này là gợi ý, không phải chứng cứ.

BƯỚC 2Manh mối 2 · Tên máy

Nhiều thiết bị tự khai tên. Tên do người đặt, nên có thể sai, nhưng rất hữu ích:

Mẫu tênGợi ý
ten-macbook.local, MacBook-AirMáy Mac
iPhone-cua-Lan, iPadThiết bị Apple
galaxy-s24, android-xxxxĐiện thoại Android
npi-3f2a, HP…, EPSON…Máy in
DESKTOP-4F7A, PC-THUQUYMáy tính Windows
cam-phong-1, ipcamCamera

BƯỚC 3Manh mối 3 · Cổng mở

Mỗi dịch vụ mở một cửa (port) quen thuộc (bài 5). Thấy cửa nào mở thì đoán được máy làm gì.

PortDịch vụThường ởCần chú ý
22SSH (quản trị từ xa, có mã hóa)Máy Linux, NAS, routerTrung bình
23Telnet (quản trị từ xa, không mã hóa)Camera, router đời cũCao: nên tắt
80, 8080Web (không mã hóa)Router, camera, máy inTrung bình
443Web (có mã hóa)Như trênThấp
445Chia sẻ file WindowsMáy Windows, NASCao nếu mật khẩu yếu
554Truyền videoCamera IPTrung bình
631, 9100In ấnMáy in mạngThấp
3389Remote Desktop (điều khiển từ xa Windows)Máy WindowsCao: hay bị dò mật khẩu
62078Đồng bộ iPhoneiPhone, iPadThấp (chỉ là manh mối)
Nhớ hai cửa nguy hiểm nhấtPort 23 (Telnet) và 3389 (Remote Desktop). Thấy một trong hai mở thì đánh dấu để xem xét.

BƯỚC 4Manh mối 4 · TTL cho biết họ hệ điều hành

Khi bạn ping, thiết bị trả lời kèm một con số gọi là . Mỗi hệ điều hành bắt đầu từ một số riêng, nên con số này gợi ý hệ điều hành:

TTL thấy đượcNhóm hệ điều hànhVí dụ
64Linux, macOS, iOS, Android, và phần lớn router, cameraMacBook, iPhone, Android, router TP-Link
128WindowsMáy tính Windows
255Thiết bị mạng, nhiều máy in, và cả thiết bị nhúng nhỏ (robot hút bụi, ổ cắm thông minh)Switch, máy in, robot Roborock, thiết bị Xiaomi
MACOS
ping -c 1 192.168.68.1
WINDOWS
ping -n 1 192.168.68.1
LINUX
ping -c 1 192.168.68.1
KẾT QUẢ MẪU (ĐỌC DÒNG CÓ ttl=)
64 bytes from 192.168.68.1: icmp_seq=0 ttl=64 time=3.1 ms

Đọc: ttl=64, nên là nhóm Linux, macOS, iOS, Android. Trên Windows dòng này có dạng TTL=128. Số 64 chung cho nhiều hệ điều hành, và thiết bị chặn ping thì không có kết quả. Công cụ ở bước 6 tự ping và in nhóm này. Riêng số 255 dễ đoán nhầm: khi chạy thật ở nhà một học viên, robot hút bụi và thiết bị Xiaomi đều trả TTL 255 dù không phải máy in. Vì vậy hãy luôn ghép TTL với hãng và cổng.

Ghép các manh mối lại

Một thiết bịIP + MAC1. Hãng (OUI của MAC)TP-Link → thiết bị mạng2. Tên máy"galaxy-s24", "npi-3f2a"3. Cổng đang mở631 + 9100 → in · 62078 → iPhone4. TTL của gói trả lời64 → Linux/macOS/Android · 128 → Windows5. Dòng chào dịch vụ (banner)"Server: lighttpd", "SSH-2.0-OpenSSH"KẾT LUẬN"Nhiều khả nănglà …, vì A, B, C"ghi mức chắc chắnChốt bằng cách đối chiếu: xem MAC trong cài đặt thiết bị, hoặc ngắt Wi-Fi từng máy xem dòng nào biến mất.
Hình 6. Mỗi dấu hiệu riêng lẻ đều có thể sai hoặc bị làm giả. Càng nhiều dấu hiệu độc lập cùng chỉ một hướng thì kết luận càng chắc. Kết luận luôn ghi là "nhiều khả năng", và chốt bằng đối chiếu với chính thiết bị.
Cách ghi kết luận cho đúngViết: "nhiều khả năng là máy in, vì hãng máy in, cổng 631 và TTL 255". Đừng viết "chắc chắn là máy in". Muốn chắc, đối chiếu với chính thiết bị: mỗi thiết bị tự cho biết MAC trong cài đặt.
Thiết bịXem MAC Wi-Fi ở đâu
iPhone, iPadCài đặt → Wi-Fi → bấm (i) cạnh tên mạng → "Địa chỉ Wi-Fi"
AndroidCài đặt → Giới thiệu điện thoại → Trạng thái → Địa chỉ MAC Wi-Fi
Windowsipconfig /all, dòng "Physical Address" của card Wi-Fi
macOSifconfig en0 | grep ether
Máy in, camera, TVMenu thông tin, tem dưới thiết bị, hoặc trang web quản lý
Đọc thêm: thêm manh mối: dòng chào dịch vụ, mDNS, nmap (không bắt buộc)

Dòng chào (). Nhiều dịch vụ tự giới thiệu khi bạn nối vào, ví dụ máy chủ web trả Server: lighttpd, SSH trả SSH-2.0-OpenSSH_9.6 Ubuntu. Python ở bước 6 đọc giúp bạn.

Thiết bị tự công bố (). Máy Apple, máy in, Chromecast… công bố loại dịch vụ của mình:

Tên dịch vụThiết bị thường gặp
_airplay._tcpApple TV, loa, Mac
_ipp._tcp, _printer._tcpMáy in
_googlecast._tcpChromecast, TV Google
_smb._tcp, _ssh._tcpMáy tính, NAS

nmap đoán hệ điều hành kỹ hơn TTL. Chỉ dùng với thiết bị của bạn:

MACOS / LINUX (CẦN sudo)
sudo nmap -O 192.168.1.40

Kết quả có dạng Linux 4.15 - 5.19 kèm mức tin cậy. Thiết bị quá đặc biệt thì nmap báo "No exact OS matches".

Dấu hiệu gián tiếp: cổng 62078 mở → iPhone/iPad. Cổng 5000 và 7000 → Mac hoặc Apple TV. Banner có Microsoft-IIS → Windows. Banner có Ubuntu hoặc OpenSSH → Linux. Tất cả đều có thể bị sửa.

Dựa vào đâu để phân biệt? Sáu câu hỏi, hỏi theo thứ tự

Với mỗi thiết bị, tự hỏi lần lượt. Dừng lại khi đã đủ chắc:

Câu hỏiNếu có thì nghiêng vềChắc đến đâu
1. IP có đuôi .1 (hoặc là IP gateway của máy bạn)?RouterRất chắc
2. Hãng chỉ làm một loại thiết bị? (Roborock, Epson, Hikvision…)Đúng loại đó: robot hút bụi, máy in, cameraKhá chắc
3. Hãng làm nhiều loại? (Xiaomi, Samsung, Apple…)Chưa kết luận được. Cần cổng, tên hoặc đối chiếuThấp
4. Hãng mạng (TP-Link…) và mở cổng 80, 443?Thiết bị mạng có trang quản lý: router, bộ phát Wi-Fi, meshKhá chắc
5. MAC ngẫu nhiên (chữ thứ hai là 2, 6, A, E) và không mở cổng nào?Điện thoại, máy tính bảng, laptop hiện đạiTrung bình
6. Còn chưa rõ?Đối chiếu với chính thiết bị hoặc app của router, ghi "chưa xác minh"Chốt cuối
Vì sao điện thoại thường "im lặng"Điện thoại hiếm khi mở cổng nào cho người khác nối vào, nên quét port ra trống. Chính việc MAC ngẫu nhiên + không cổng nào + TTL 64 là dấu hiệu quen thuộc của điện thoại. Ngược lại, thiết bị mạng và máy in luôn có ít nhất một cổng (80 hoặc 443).

Ví dụ trọn vẹn: mạng 192.168.68.0/22

Đây là kết quả quét thật một mạng nhà (11 thiết bị, ba byte cuối của MAC được che). Áp dụng sáu câu hỏi cho từng dòng:

IPMAC · hãngCổng mởTTLKết luận và lý doCách xác minh
.68.1bc:07:1d · TP-Link80, 44364Router. IP đuôi .1 (câu 1), hãng mạng và có trang quản lý (câu 4)Mở http://192.168.68.1 trên trình duyệt
.71.248, .71.249, .71.25060:a4:b7, bc:07:1d, bc:07:1d · TP-Link80, 44364Ba bộ phát Wi-Fi mesh. Cùng hãng mạng, cùng mở 80 và 443 (câu 4). Ba MAC khác nhau nên là ba máy, không phải một máy có ba IPApp hoặc trang quản lý của TP-Link, mục danh sách thiết bị mesh
.68.5024:9e:7d · Roborockkhông255Robot hút bụi. Hãng chỉ làm một loại (câu 2)Mở app Roborock, so MAC
.68.54, .68.55e4:fe:43 · Xiaomikhông255Hai thiết bị Xiaomi, chưa biết loại. Xiaomi làm nhiều loại (câu 3). Hai MAC khác nhau nên là hai máyApp Mi Home xem danh sách thiết bị
.68.52, .68.56, .68.5976:…, 62:…, 42:… · MAC ngẫu nhiênkhông64Điện thoại hoặc máy tính bảng. Chữ thứ hai là 6, 2, 2 (câu 5)Hỏi người trong nhà. Xem MAC Wi-Fi trong cài đặt máy
.68.576a:… · MAC ngẫu nhiên5000, 700064Chính máy đang chạy lệnh (một Mac). Cổng AirPlay 5000 và 7000, chữ permanent trong arp -aĐối chiếu IP của máy bằng ipconfig getifaddr en0
Điều rút ra từ ví dụ này
  • Chỉ có router và robot là gần như chắc chắn. Còn lại là "nhiều khả năng", muốn chắc phải xác minh.
  • Cùng hãng, khác MAC nghĩa là nhiều máy, như ba bộ mesh TP-Link và hai thiết bị Xiaomi.
  • TTL 255 của Roborock và Xiaomi không phải máy in. Thiết bị nhúng nhỏ cũng dùng số này, nên TTL chỉ là manh mối phụ.
  • Mỗi lần quét có thể ra số khác nhau: mạng này lần đầu thấy 5, lần sau 8, rồi 11, vì điện thoại ngủ và bộ mesh trả lời chậm. Hãy quét vài lần rồi ghi "ít nhất N".

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

Nhận diện năm thiết bị: loại và hệ điều hành

Kết quả quét (hãng và tên là minh họa). TTL là TTL của gói trả lời ping:

#MAC / hãngTênPort mởTTL
AHãng máy in Xnpi-3f2a80, 631, 9100255
BMAC ngẫu nhiên(không tên)6207864
CHãng camera Ycam-phong-123, 80, 55464
DMAC ngẫu nhiêngalaxy-s24không có64
EIntelPC-THUQUY445, 3389128

Với mỗi thiết bị: đoán loại, đoán nhóm hệ điều hành, nêu bằng chứng, và nói thiết bị nào cần xem xét về bảo mật.

Xem đáp án
  1. A: máy in mạng. Hãng máy in, tên kiểu máy in, port 631 và 9100, TTL 255. Rất chắc. Nên đặt mật khẩu cho trang quản lý ở port 80.
  2. B: nhiều khả năng iPhone hoặc iPad. Port 62078 là của Apple, MAC ngẫu nhiên khớp "Địa chỉ Wi-Fi riêng", TTL 64. Khá chắc, nên đối chiếu trong cài đặt máy.
  3. C: camera IP. Hãng camera, tên có chữ cam, port 554. Port 23 (Telnet) mở là rủi ro lớn: không mã hóa, hay dùng mật khẩu mặc định. Cần tắt Telnet, đổi mật khẩu, cập nhật firmware.
  4. D: điện thoại Android. Tên gợi ý, MAC ngẫu nhiên, không mở cổng nào (bình thường), TTL 64. Chắc ở mức vừa vì tên do người đặt.
  5. E: máy Windows của thủ quỹ. Cần kiểm tra: có thật sự cần Remote Desktop không, mật khẩu có mạnh không. Máy giữ dữ liệu lệ phí nên ưu tiên cao.
06

Bước 5 · Công cụ có sẵn: nhìn nhanh, chưa cần code

8 phút
arp / ip neighBảng ARP
nmap -snQuét ping
Ứng dụngFing, LanScan…

Trước khi tự viết code, biết vài công cụ có sẵn để đối chiếu kết quả sau này. Phần này chỉ xem, không thay đổi gì trên thiết bị nào.

Công cụLàm gìNhìn thấyCần gì
arp -a (bước 2)Đọc sổ ARP có sẵn, không gửi gì ra mạngMáy bạn: chỉ thiết bị đã nói chuyệnKhông cần gì thêm
nmap -snGõ cửa cả dải, in MAC và hãngCả Wi-FiCài nmap (bài 6)
Fing, Advanced IP Scanner, LanScanỨng dụng có giao diện, tra hãng sẵnCả Wi-FiChỉ cài từ trang chính thức
Trang quản trị routerDanh sách thiết bị router đã cấp IPCả Wi-Fi, theo góc nhìn của routerMật khẩu router (không bắt buộc)

Quét ping bằng nmap

MACOS (sudo ĐỂ NMAP DÙNG ARP, THẤY ĐỦ HƠN)
sudo nmap -sn 192.168.1.0/24
WINDOWS (POWERSHELL, QUYỀN ADMINISTRATOR)
nmap -sn 192.168.1.0/24
LINUX
sudo nmap -sn 192.168.1.0/24

Thay 192.168.1.0/24 bằng dải mạng của bạn (xem IP máy ở bài 6). sudo hỏi mật khẩu đăng nhập máy tính của bạn, không phải mật khẩu router.

KẾT QUẢ MẪU (MINH HỌA)
Nmap scan report for 192.168.1.1
Host is up (0.0021s latency).
MAC Address: F4:EC:38:11:22:33 (Hang router A)
Nmap scan report for 192.168.1.40
Host is up (0.11s latency).
MAC Address: 3C:22:FB:AA:BB:CC (Hang dien thoai B)
Nmap done: 256 IP addresses (2 hosts up) scanned in 2.4 seconds
Không có mật khẩu router? Không saoMọi thứ trong bài chạy được mà không cần nó. Nhờ người quản trị mở giúp trang "Connected Devices" để đối chiếu, hoặc ghi "chưa xác minh" cho thiết bị chưa nhận ra. Hoặc tập trên mạng của chính bạn (bước 7).
Đừng đoán mật khẩu router, kể cả mật khẩu mặc địnhĐăng nhập vào thiết bị không thuộc quyền của mình là truy cập trái phép. Nếu bạn chính là người quản trị mà mật khẩu vẫn là mặc định, hãy đổi ngay (bài 2).
Đọc thêm: xem thiết bị tự công bố bằng mDNS (không bắt buộc)

Liệt kê loại dịch vụ đang có trong mạng:

MACOS (NHẤN Ctrl+C SAU KHOẢNG 5 GIÂY)
dns-sd -B _services._dns-sd._udp local
WINDOWS
# Windows không có sẵn công cụ tương đương.
# Dùng ứng dụng như Advanced IP Scanner.
LINUX (CẦN: sudo apt install avahi-utils)
avahi-browse -a -t

Đọc: _airplay là thiết bị nhận AirPlay, _ipp là máy in, _googlecast là Chromecast hoặc TV Google.

07

Bước 6 · Lab: tạo công cụ kiểm kê bằng Python

12 phút
Gõ cửaÉp máy hỏi ARP
Đọc ARPIP ↔ MAC
Quét portThử kết nối
Lưu JSONSo với lần trước

Giờ ghép mọi thứ thành một chương trình lan_inventory.py. Một lệnh ra bảng thiết bị (IP, MAC, hãng, tên, cổng mở), rồi lưu lại để so sánh lần sau. Chỉ dùng thư viện có sẵn của Python. Không cần mật khẩu router.

Chương trình làm bốn việc
  1. Gõ cửa cả dải IP bằng một gói UDP nhỏ, để máy bạn tự hỏi ARP.
  2. Đọc bảng ARP và loại các dòng không phải thiết bị.
  3. Nhận diện từng thiết bị: hãng, tên, cổng mở, TTL.
  4. Lưu ra file JSON và so với lần trước.
XEM TỪNG CẢNH · MÔ PHỎNG MINH HỌA

Xem trước: một lần kiểm kê diễn ra thế nào?

Theo chân chiếc laptop: từ hỏi địa chỉ đến ghi sổ. Mỗi cảnh tương ứng một việc trong code.

1Chọn đúng mạng
“Mình đang ở mạng nào?”Laptop của bạn192.168.1.23Bắt đầuWi-Fi nhà bạn192.168.1.0/24Dải minh họaCông cụ Pythonlan_inventory.pyĐã tạo file

Chương trình đọc IP và mặt nạ mạng của máy bạn. /24 trong ví dụ có địa chỉ từ .0 đến .255; không lấy dải ví dụ để áp vào mọi nhà.

Điều cần hiểu: Biết phạm vi cần hỏi trước khi tìm thiết bị.

2Gõ cửa để hỏi ARP
“Ai ở .40? Cho mình biết MAC!”Laptop hỏipoke(ip)Gửi UDP nhỏMạng cục bộARP hỏi trong LANKhông đi InternetMáy in .403c:22:fb:aa:bb:ccTrả lời ARP

Gửi UDP khiến hệ điều hành tìm MAC bằng ARP nếu chưa có trong bộ nhớ. Việc này được lặp cho các IP trong dải; thiết bị chặn ping vẫn có thể trả lời ARP.

Điều cần hiểu: Học được cặp IP ↔ MAC của thiết bị trả lời.

3Đọc và lọc sổ ARP
“Giữ thiết bị thật, bỏ dòng gọi cả mạng.”Sổ ARPread_neighbors()Đọc trên máy bạnDòng được giữIP .40 + MAC thậtThiết bị quan sát đượcDòng bị loạiff:ff:ff:ff:ff:ffBroadcast

Một dòng ARP chưa chắc là một thiết bị. Chương trình bỏ dòng chưa có MAC và địa chỉ quảng bá/nhóm; nhận diện theo MAC đầy đủ.

Điều cần hiểu: Có danh sách thiết bị quan sát được, có thể còn thiếu.

4Ghép các manh mối
“Cổng 631 và 9100: có vẻ là máy in!”Mã hãng + tênOUI · npi-3f2aManh mối 1Thiết bị .40631 · 9100 đang mởManh mối 2Cổng dịch vụThử kết nối TCPKhông đăng nhập

Cổng mở nghĩa là một dịch vụ chấp nhận kết nối. Hãng, tên, cổng và TTL cùng gợi ý loại thiết bị; chúng chưa chứng minh được danh tính.

Điều cần hiểu: Kết luận: nhiều khả năng là máy in; cần đối chiếu.

5Lưu để lần sau so sánh
“Chụp lại danh sách hôm nay.”Kết quả trên màn hìnhIP · MAC · portBạn đọc đượcbaseline.jsonBản ghi lần đầuLưu trên máyLần kiểm kê sauSo với baseline.jsonTìm thay đổi

File JSON là cuốn sổ lưu kết quả, không phải một chương trình khác. Lần chạy sau dùng --baseline baseline.json để đọc sổ cũ và so với quan sát mới.

Điều cần hiểu: Chu trình: hỏi → đọc → nhận diện → lưu → so sánh.

Hình minh họa không chạy Python và không gửi gói tin. Bấm Tiếp để học theo nhịp của bạn; Phát tự chuyển cảnh mỗi 9 giây.

BƯỚC 1Tạo thư mục làm việc

MACOS
mkdir ~/lan-lab
cd ~/lan-lab
pwd
WINDOWS POWERSHELL
mkdir $HOME\lan-lab
cd $HOME\lan-lab
pwd
LINUX
mkdir ~/lan-lab
cd ~/lan-lab
pwd

mkdir tạo thư mục, cd đi vào, pwd in ra bạn đang đứng ở đâu (bài 6). Mọi file của lab nằm trong thư mục này.

BƯỚC 2Tạo file lan_inventory.py

Cách dễ nhất: bấm tải file dưới đây, chuyển vào thư mục lan-lab. Muốn xem chương trình, mở “Xem mã Python”. Nếu tự dán mã vào trình soạn thảo, lưu đuôi .py, không phải .txt. Nếu chọn “Tạo file bằng terminal”, sao chép trọn khối lệnh của hệ điều hành bạn đang dùng.

lan_inventory.py · Tải file vào thư mục lan-lab, hoặc mở mã để sao chép vào trình soạn thảo.

↓ 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

BƯỚC 3Kiểm tra file đã tạo

Chạy ls (macOS/Linux) hoặc dir (Windows), phải thấy lan_inventory.py. Rồi xem trợ giúp. Lệnh này chưa quét gì:

MACOS
python3 lan_inventory.py --help
WINDOWS
py lan_inventory.py --help
LINUX
python3 lan_inventory.py --help

BƯỚC 4Tải danh sách hãng sản xuất (nên làm)

File oui.txt của IEEE liệt kê mọi mã hãng. Tải về, đặt cạnh lan_inventory.py:

MACOS / LINUX
curl -o oui.txt https://standards-oui.ieee.org/oui/oui.txt
WINDOWS POWERSHELL
curl.exe -o oui.txt https://standards-oui.ieee.org/oui/oui.txt
LINUX
curl -o oui.txt https://standards-oui.ieee.org/oui/oui.txt
Kiểm tra thư mục lan-lab của bạn
  • lan_inventory.py: chương trình sẽ chạy.
  • oui.txt: danh sách để tra hãng, không phải chương trình.
  • baseline.json: chưa có lúc này; được tạo khi chạy với --save ở bước 7.

Không có file này chương trình vẫn chạy, chỉ hiện "chua co oui.txt" ở cột hãng. Đến đây bạn đã có công cụ. Bước 7 chạy nó.

Đọc thêm: từng hàm trong code làm gì (không bắt buộc)
PhầnÝ nghĩa
local_ip()Hỏi hệ điều hành "bạn dùng IP nào để ra mạng?" bằng một socket UDP. Lệnh connect của UDP không gửi gói nào, chỉ chọn card mạng.
detect_network(ip)Đọc mặt nạ mạng thật của card để biết mạng là /24, /22… Không đọc được thì dùng /24.
read_neighbors()Chạy arp -a (hoặc ip neigh) và tách cặp IP–MAC. Đưa mọi dạng viết MAC về aa:bb:cc:dd:ee:ff.
is_unicast()Loại địa chỉ quảng bá và địa chỉ nhóm (bước 3).
poke(ip)Gửi một byte UDP tới port 9 của IP đó. Mục đích duy nhất: buộc máy bạn hỏi ARP. Chạy song song 64 luồng nên cả dải /24 xong trong vài giây.
load_oui(), describe_mac()Đọc oui.txt và tra ba byte đầu. MAC ngẫu nhiên thì in "MAC riêng" thay vì tra sai.
hostname(ip)Tra ngược DNS để lấy tên máy nếu thiết bị có khai.
ttl_of(ip), guess_os(ttl)Ping một lần tới thiết bị đã tìm thấy, đọc TTL, quy ra nhóm hệ điều hành.
guess_device(device)Đoán loại thiết bị từ cổng mở và tên máy, in ở dòng DOAN:. Chỉ là gợi ý.
scan_ports(ip)Thử kết nối TCP tới khoảng 20 port phổ biến, timeout 0,5 giây. Kết nối được là port đang mở. Đây là dạng quét nhẹ nhất.
grab_banner(ip, port)Với mỗi port mở, đọc dòng chào của dịch vụ.
build_inventory()Ghép các bước: gõ cửa cả dải, chờ 2 giây, đọc ARP, nhận diện từng thiết bị.
compare()So với file lần trước: thiết bị mới, thiết bị không còn trả lời, và port thay đổi.
demo_inventory(day)Trả về danh sách thiết bị mẫu để học đọc kết quả mà không quét mạng.
main()Đọc tham số dòng lệnh. Từ chối dải không phải IP riêng hoặc lớn hơn 1024 địa chỉ, để không quét nhầm Internet hay mạng người khác.
08

Bước 7 · Chạy công cụ, kể cả khi không có mật khẩu router

20 phút
Dữ liệu mẫuKhông cần mạng
Thiết bị giảNgay trên máy bạn
HotspotMạng của riêng bạn
Mạng thậtCó phép của chủ mạng

Công cụ chỉ cần máy bạn đang nối vào mạng cần xem. Không cần mật khẩu router. Chọn một trong bốn cách, đi từ dễ đến thật:

CáchQuét ở đâuCần gìHợp khi
1. Dữ liệu mẫu (--demo)Không quét gì, chỉ in dữ liệu có sẵnChỉ PythonChưa có mạng nào, hoặc trình diễn trên lớp
2. Thiết bị giả (--only 127.0.0.1)Chỉ máy bạn (quét cổng của chính nó)Chỉ PythonMuốn xem quét port chạy thật, không chạm mạng nào
3. Hotspot điện thoạiCả mạng hotspot (vài thiết bị)Điện thoại, laptopKhông có mật khẩu router, muốn tập trên mạng thật an toàn
4. Mạng thậtCả Wi-Fi của bạnĐược phép của chủ mạngMạng nhà bạn, hoặc câu lạc bộ đã đồng ý

Nếu giảng cho cả lớp: cả lớp cùng làm cách 1, rồi cách 2, rồi mỗi người làm cách 3 hoặc 4.

Cách 1 · Dữ liệu mẫu: học đọc kết quả, không cần mạng

XEM TỪNG CẢNH · MÔ PHỎNG MINH HỌA

Hoạt họa: hôm nay khác hôm qua ở đâu?

Dùng đúng ba thay đổi của dữ liệu mẫu ở bên dưới. Đây là mô phỏng; không quét mạng của bạn.

1Ngày 1: giữ một bản gốc
“Ghi lại để ngày mai còn đối chiếu.”Điện thoại .52da:a1:19:00:00:01Có trả lờiMáy thủ quỹ .70445 · 3389Có trả lờingay1.jsonLưu lần đầuBản gốc

Chương trình lấy dữ liệu mẫu ngày 1 và ghi vào ngay1.json. Hãy giữ file này trước khi chạy lệnh thứ hai.

Điều cần hiểu: Có mốc để so sánh.

2Ngày 2: thêm một MAC
“MAC này chưa có trong sổ ngày 1.”Sổ ngày 1Không có c2:11:aa…Đối chiếuThiết bị mới .77c2:11:aa:bb:cc:ddMỚI · chưa xác minhViệc cần làmHỏi chủ thiết bịChưa kết luận xấu

Chương trình so MAC đầy đủ giữa hai lần. MAC mới có thể là thiết bị mới, hoặc điện thoại quen vừa đổi MAC riêng.

Điều cần hiểu: Mới trong quan sát ≠ kẻ xâm nhập.

3Ngày 2: một máy im lặng
“Không thấy .52 trả lời lần này.”Sổ ngày 1Có MAC của .52Đã từng thấyĐiện thoại .52Không còn trong kết quảKHÔNG TRẢ LỜIGiải thích có thểNgủ · rời mạng · cách lyKiểm tra lại

Không trả lời chỉ mô tả lần quan sát này. Chưa thể kết luận điện thoại đã rời mạng hoặc bị tấn công.

Điều cần hiểu: Thử lại và đối chiếu với người dùng.

4Ngày 2: cùng máy, đổi cổng
“Vẫn MAC cũ, nhưng cửa dịch vụ đã đổi.”Vẫn máy .7000:1b:21:aa:bb:ccCùng MAC3389 → đóngRemote DesktopKhông còn thấy mở8000 → mởDịch vụ mớiCần hỏi người dùng

Chương trình theo cùng MAC rồi so danh sách cổng. Hỏi người dùng máy thủ quỹ có vừa chạy máy chủ thử không; tắt dịch vụ nếu không cần.

Điều cần hiểu: Kiểm kê phải theo dõi cả thiết bị lẫn dịch vụ.

Hình minh họa không chạy Python và không gửi gói tin. Bấm Tiếp để học theo nhịp của bạn; Phát tự chuyển cảnh mỗi 9 giây.

--demo tạo một mạng giả định gồm bảy thiết bị, không gửi gói nào ra mạng. Chạy "ngày 1", rồi "ngày 2" và so với ngày 1:

MACOS
python3 lan_inventory.py --demo 1 --save ngay1.json
python3 lan_inventory.py --demo 2 --baseline ngay1.json --save ngay2.json
WINDOWS
py lan_inventory.py --demo 1 --save ngay1.json
py lan_inventory.py --demo 2 --baseline ngay1.json --save ngay2.json
LINUX
python3 lan_inventory.py --demo 1 --save ngay1.json
python3 lan_inventory.py --demo 2 --baseline ngay1.json --save ngay2.json
KẾT QUẢ NGÀY 1 (RÚT GỌN, LẤY TỪ CHÍNH CÔNG CỤ)
[DU LIEU MAU ngay 1: khong quet mang that]
IP              MAC                Hang / ghi chu                    Ten
192.168.1.1     f4:ec:38:11:22:33  Hang router A                     router.lan
    mo port 80    http                Server: lighttpd
    DOAN: router | HDH: nhom Linux/macOS/iOS/Android (TTL 64)
192.168.1.40    3c:22:fb:aa:bb:cc  Hang may in X                     npi-3f2a
    mo port 80    http                Server: Embedded HTTP Server
    mo port 631   ipp (may in)
    mo port 9100  may in
    DOAN: may in | HDH: thiet bi mang/may in (TTL 255)
192.168.1.45    9c:b2:00:44:55:66  Hang camera Y                     cam-phong-1
    mo port 23    telnet
    mo port 80    http                Server: CamHTTPD/1.0
    mo port 554   rtsp (camera)
    DOAN: camera, thiet bi cu (Telnet): coi chung | HDH: nhom Linux/macOS/iOS/Android (TTL 64)
192.168.1.52    da:a1:19:00:00:01  MAC rieng/ngau nhien (dien thoai
    mo port 62078 iphone-sync
    DOAN: iPhone/iPad | HDH: nhom Linux/macOS/iOS/Android (TTL 64)
192.168.1.70    00:1b:21:aa:bb:cc  Hang may tinh Z                   PC-THUQUY
    mo port 445   smb
    mo port 3389  rdp
    DOAN: chia se file Windows/NAS, may Windows (Remote Desktop) | HDH: nhom Windows (TTL 128)
...
Tong cong: 7 thiet bi tra loi.

Đọc: dòng mo port là cổng mở. Dòng DOAN là bước ghép manh mối của bước 4 làm tự động. Dòng HDH là họ hệ điều hành đoán từ TTL. Cả hai chỉ là gợi ý.

PHẦN SO SÁNH CỦA NGÀY 2
Thiet bi MOI so voi lan truoc: ['192.168.1.77 (c2:11:aa:bb:cc:dd)']
Thiet bi KHONG con tra loi: ['192.168.1.52 (da:a1:19:00:00:01)']
Port THAY DOI o 192.168.1.70: moi mo [8000], da dong [3389]

Đọc như một nhà kiểm kê:

  • .77 mới, MAC ngẫu nhiên, không tên: cần xác minh (bước 8).
  • .52 không còn trả lời: có thể điện thoại đã về.
  • .70 (máy thủ quỹ) đã đóng Remote Desktop (tốt), nhưng mới mở port 8000: ai đó vừa chạy máy chủ thử trên máy giữ dữ liệu lệ phí. Cần hỏi và tắt nếu không cần.
  • Camera .45 vẫn mở Telnet cả hai ngày: rủi ro chưa được xử lý.

Đó là toàn bộ ý nghĩa của việc so sánh hai lần quét.

Cách 2 · Thiết bị giả trên chính máy bạn

XEM TỪNG CẢNH · MÔ PHỎNG MINH HỌA

Hai terminal, cùng một chiếc laptop

Terminal A mở dịch vụ giả lập. Giữ A chạy và dùng B để tìm các cổng của chính máy đó.

1A: mở các cửa giả lập
“Mình mở cửa ở 127.0.0.1 thôi.”Terminal Afake_devices.pyĐang chạy4 cổng giả lập3389 · 80809100 · 62078Wi-Fi bên ngoàiKhông thuộc phép thửKhông quét

127.0.0.1 là địa chỉ quay về chính máy bạn. Chương trình giả lập chỉ lắng nghe ở địa chỉ này. Windows dùng py thay python3.

Điều cần hiểu: Giữ Terminal A mở; đừng chạy lệnh B trong A.

2B: thử từng cửa
“Cửa nào đang nhận kết nối?”Terminal Blan_inventory.pyQuét đúng một IP127.0.0.1Thử kết nối TCPCổng mở trả lờiTerminal AVẫn chạy giả lậpCùng máy với B

--only bỏ qua bước tìm thiết bị trong Wi-Fi. B kết nối tới các cổng của máy bạn; A nhận kết nối ở các cổng giả lập.

Điều cần hiểu: Thấy 4 cổng giả lập và có thể thêm cổng thật của máy.

3Đọc manh mối, đừng đếm nhầm
“Bốn cửa không có nghĩa bốn thiết bị!”Một laptop127.0.0.1Chỉ một máyNhiều dịch vụRDP · in · iPhoneCác gợi ýDòng chào giảCamHTTPD/1.0Có thể giả mạo

Công cụ đoán theo cổng nên một máy có thể nhận nhiều nhãn. Dòng chào cũng có thể được lập trình giả. Đây là lý do phải ghép bằng chứng.

Điều cần hiểu: Cổng và banner mô tả dịch vụ; chưa xác minh loại máy.

4Dừng A, kiểm tra lại bằng B
“Đóng giả lập rồi thử tìm lại.”Terminal ANhấn Ctrl+CĐã dừngCổng giả lậpKhông còn lắng ngheSẽ không thấy mởTerminal BChạy lại --onlyQuan sát thay đổi

Các cổng do fake_devices.py mở sẽ biến mất khi dừng chương trình. Các dịch vụ khác có sẵn trên máy vẫn có thể được tìm thấy.

Điều cần hiểu: Bạn vừa chứng kiến: dừng dịch vụ → cổng đóng.

Hình minh họa không chạy Python và không gửi gói tin. Bấm Tiếp để học theo nhịp của bạn; Phát tự chuyển cảnh mỗi 9 giây.

Cách này tạo dịch vụ thật chạy ở 127.0.0.1 (chính máy bạn), giả làm một thiết bị đáng ngờ, rồi dùng công cụ quét port để tìm ra nó. Tạo thêm file fake_devices.py:

fake_devices.py · Tải file vào thư mục lan-lab, hoặc mở mã để sao chép vào trình soạn thảo.

↓ Tải fake_devices.py
Xem mã Python · có tô màu và số dòng
PYTHON · fake_devices.py
# fake_devices.py - gia lap mot "thiet bi dang ngo" ngay tren may ban de thu quet port. Chi nghe 127.0.0.1.
# Chay: python3 fake_devices.py   roi o terminal khac: python3 lan_inventory.py --only 127.0.0.1
import socket
import threading

# port -> noi dung tra loi (None = nhan ket noi roi dong im lang)
FAKE = {
    8080: b"HTTP/1.0 200 OK\r\nServer: CamHTTPD/1.0 (camera gia lap)\r\n\r\n",
    9100: None,
    62078: None,
    3389: None,
}


def serve(port, reply):
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        s.bind(("127.0.0.1", port))
        s.listen(5)
        while True:
            conn, _ = s.accept()
            with conn:
                if reply:
                    conn.settimeout(1)
                    try:
                        conn.recv(200)
                    except OSError:
                        pass
                    conn.sendall(reply)


for port, reply in FAKE.items():
    threading.Thread(target=serve, args=(port, reply), daemon=True).start()
print("Dang gia lap thiet bi o 127.0.0.1, cac port:", ", ".join(str(p) for p in FAKE))
print("Nhan Ctrl+C de dung.")
threading.Event().wait()
Tạo file bằng terminal · chọn hệ điều hành của bạn
MACOS: TẠO fake_devices.py
cat > fake_devices.py <<'EOF'
# fake_devices.py - gia lap mot "thiet bi dang ngo" ngay tren may ban de thu quet port. Chi nghe 127.0.0.1.
# Chay: python3 fake_devices.py   roi o terminal khac: python3 lan_inventory.py --only 127.0.0.1
import socket
import threading

# port -> noi dung tra loi (None = nhan ket noi roi dong im lang)
FAKE = {
    8080: b"HTTP/1.0 200 OK\r\nServer: CamHTTPD/1.0 (camera gia lap)\r\n\r\n",
    9100: None,
    62078: None,
    3389: None,
}


def serve(port, reply):
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        s.bind(("127.0.0.1", port))
        s.listen(5)
        while True:
            conn, _ = s.accept()
            with conn:
                if reply:
                    conn.settimeout(1)
                    try:
                        conn.recv(200)
                    except OSError:
                        pass
                    conn.sendall(reply)


for port, reply in FAKE.items():
    threading.Thread(target=serve, args=(port, reply), daemon=True).start()
print("Dang gia lap thiet bi o 127.0.0.1, cac port:", ", ".join(str(p) for p in FAKE))
print("Nhan Ctrl+C de dung.")
threading.Event().wait()
EOF
WINDOWS POWERSHELL: TẠO fake_devices.py
@'
# fake_devices.py - gia lap mot "thiet bi dang ngo" ngay tren may ban de thu quet port. Chi nghe 127.0.0.1.
# Chay: python3 fake_devices.py   roi o terminal khac: python3 lan_inventory.py --only 127.0.0.1
import socket
import threading

# port -> noi dung tra loi (None = nhan ket noi roi dong im lang)
FAKE = {
    8080: b"HTTP/1.0 200 OK\r\nServer: CamHTTPD/1.0 (camera gia lap)\r\n\r\n",
    9100: None,
    62078: None,
    3389: None,
}


def serve(port, reply):
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        s.bind(("127.0.0.1", port))
        s.listen(5)
        while True:
            conn, _ = s.accept()
            with conn:
                if reply:
                    conn.settimeout(1)
                    try:
                        conn.recv(200)
                    except OSError:
                        pass
                    conn.sendall(reply)


for port, reply in FAKE.items():
    threading.Thread(target=serve, args=(port, reply), daemon=True).start()
print("Dang gia lap thiet bi o 127.0.0.1, cac port:", ", ".join(str(p) for p in FAKE))
print("Nhan Ctrl+C de dung.")
threading.Event().wait()
'@ | Set-Content -Path fake_devices.py -Encoding utf8
LINUX: TẠO fake_devices.py
cat > fake_devices.py <<'EOF'
# fake_devices.py - gia lap mot "thiet bi dang ngo" ngay tren may ban de thu quet port. Chi nghe 127.0.0.1.
# Chay: python3 fake_devices.py   roi o terminal khac: python3 lan_inventory.py --only 127.0.0.1
import socket
import threading

# port -> noi dung tra loi (None = nhan ket noi roi dong im lang)
FAKE = {
    8080: b"HTTP/1.0 200 OK\r\nServer: CamHTTPD/1.0 (camera gia lap)\r\n\r\n",
    9100: None,
    62078: None,
    3389: None,
}


def serve(port, reply):
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        s.bind(("127.0.0.1", port))
        s.listen(5)
        while True:
            conn, _ = s.accept()
            with conn:
                if reply:
                    conn.settimeout(1)
                    try:
                        conn.recv(200)
                    except OSError:
                        pass
                    conn.sendall(reply)


for port, reply in FAKE.items():
    threading.Thread(target=serve, args=(port, reply), daemon=True).start()
print("Dang gia lap thiet bi o 127.0.0.1, cac port:", ", ".join(str(p) for p in FAKE))
print("Nhan Ctrl+C de dung.")
threading.Event().wait()
EOF

Mở hai terminal. Terminal A giữ chương trình giả lập chạy. Terminal B quét 127.0.0.1 (--only bỏ qua bước tìm thiết bị, chỉ quét port của đúng một IP):

MACOS
# Terminal A
python3 fake_devices.py
# Terminal B
python3 lan_inventory.py --only 127.0.0.1
WINDOWS
# Terminal A
py fake_devices.py
# Terminal B
py lan_inventory.py --only 127.0.0.1
LINUX
# Terminal A
python3 fake_devices.py
# Terminal B
python3 lan_inventory.py --only 127.0.0.1
KẾT QUẢ MẪU Ở TERMINAL B (MÁY BẠN CÓ THỂ HIỆN THÊM PORT KHÁC)
Port mo tren 127.0.0.1: 3389/rdp, 8080/http-proxy, 9100/may in, 62078/iphone-sync
    3389  rdp
    8080  http-proxy         CamHTTPD/1.0 (camera gia lap)
    9100  may in
    62078 iphone-sync
DOAN: iPhone/iPad, may Windows (Remote Desktop), may in
Thấy gì lạ?Công cụ tìm được bốn port và còn đọc được dòng chào CamHTTPD/1.0 (camera gia lap). Nhưng một thiết bị lại giống cả iPhone, máy Windows và máy in cùng lúc. Đó là dấu hiệu của thiết bị "lạ" hoặc giả mạo. Nếu máy bạn hiện thêm port 5000, 7000 (AirPlay của macOS) hay 8000 (máy chủ thử ở bài 6), đó là dịch vụ có thật trên máy bạn: đúng là thứ kiểm kê để bạn nhìn thấy. Dừng bằng Ctrl+C ở Terminal A khi xong.

Cách 3 · Hotspot điện thoại: mạng của riêng bạn

XEM TỪNG CẢNH · MÔ PHỎNG MINH HỌA

Hotspot: điện thoại đóng vai router

Hình minh họa một mạng của riêng bạn. Địa chỉ thực tế có thể khác hình.

1Tạo mạng nhỏ
“Laptop vào đúng Wi-Fi do điện thoại phát.”Điện thoại phát Wi-FiHotspot của bạnĐóng vai routerLaptop của bạnNối vào hotspotChạy công cụThiết bị tham giaĐược chủ máy đồng ýCó thể thêm

Điện thoại phát Wi-Fi trở thành điểm kết nối của mạng thử. Kiểm tra tên Wi-Fi trên laptop để tránh quét nhầm mạng đang nối.

Điều cần hiểu: Chỉ kiểm kê mạng thử mà bạn quản lý.

2Đọc đúng phạm vi
“/28 nhỏ hơn /24: hỏi ít địa chỉ hơn.”Ví dụ hotspot172.20.10.0/2816 địa chỉCông cụ Python--net 172.20.10.0/28Dùng khi khớp dảihotspot.jsonKết quả quan sátLưu trên laptop

Chỉ dùng dải ví dụ khi nó khớp IP/mặt nạ trên laptop. Có thể bỏ --net để công cụ tự đọc cấu hình mạng.

Điều cần hiểu: Kết quả phụ thuộc dải thật và khả năng trả lời của các máy.

3Đối chiếu số lượng
“Không thấy nhau có thể do cách ly.”Màn hình hotspotSố máy đang kết nốiMột nguồn kiểm traSổ kiểm kêMáy có thể quan sátNguồn thứ haiChưa thấy thiết bị?Ngủ hoặc bị cách lyKhông kết luận vội

Một số hotspot cách ly các máy khách, nên laptop không nhìn thấy mọi máy đang kết nối. Kết quả ít hơn không chứng minh thiết bị không tồn tại.

Điều cần hiểu: Ghi rõ giới hạn của lần quan sát.

Hình minh họa không chạy Python và không gửi gói tin. Bấm Tiếp để học theo nhịp của bạn; Phát tự chuyển cảnh mỗi 9 giây.

Biến điện thoại thành "router" của riêng bạn. Bạn là người quản trị mạng này nên không phải xin mật khẩu của ai.

  1. Bật điểm phát Wi-Fi trên điện thoại (iPhone: Điểm truy cập cá nhân, Android: Điểm phát sóng). Đặt mật khẩu dài.
  2. Nối laptop vào Wi-Fi đó. Có thể nối thêm một thiết bị của bạn bè đồng ý tham gia.
  3. Chạy công cụ. iPhone cấp dải nhỏ 172.20.10.0/28:
MACOS
python3 lan_inventory.py --net 172.20.10.0/28 --save hotspot.json
WINDOWS
py lan_inventory.py --net 172.20.10.0/28 --save hotspot.json
LINUX
python3 lan_inventory.py --net 172.20.10.0/28 --save hotspot.json

Hotspot Android thường dùng 192.168.x.0/24: bỏ --net để chương trình tự chọn. Bạn nên thấy điện thoại (thường .1), laptop và thiết bị bạn nối thêm. Đối chiếu: số thiết bị đếm được phải khớp với màn hình hotspot (Android thường liệt kê thiết bị). Thiếu thì chạy lại vài lần.

Nhớ tắt hotspot khi xongHotspot dùng dữ liệu di động và là một mạng mở ra xung quanh.

Cách 4 · Mạng thật (cần được phép)

XEM TỪNG CẢNH · MÔ PHỎNG MINH HỌA

Mạng thật: từ bản ghi đầu tới lần kiểm tra sau

Đi từng cảnh để hiểu hai tham số --save và --baseline trước khi chạy lệnh.

1Lần đầu: ghi sổ
“Lưu những gì thấy hôm nay.”Wi-Fi được phépĐúng mạng cần kiểm kêĐã kiểm traLaptop của bạnChạy công cụQuan sátbaseline.jsonBản ghi lần đầuGiữ làm mốc

--save đặt tên file kết quả. Lần đầu chưa có bản cũ nên chưa biết thiết bị nào mới so với hôm qua.

Điều cần hiểu: Có bản ghi đầu để đối chiếu.

2Lần sau: mở sổ cũ
“Đọc mốc cũ, lưu lần này thành file khác.”baseline.jsonĐọc bằng --baselineSổ cũLần kiểm kê mớiSo theo MAC + portTìm thay đổitoday.jsonGhi bằng --saveSổ mới

--baseline là file đem ra so, --save là nơi lưu kết quả mới. Dùng hai tên khác nhau để giữ bản gốc cho việc đối chiếu.

Điều cần hiểu: Nhận danh sách mới, không trả lời và thay đổi cổng.

3Biến kết quả thành việc làm
“Dòng nào cần hỏi, dịch vụ nào cần tắt?”Thiết bị chưa rõGhi MAC + thời điểmCần xác minhDịch vụ không dùngHỏi người quản lýTắt khi phù hợpBảng kiểm kêBằng chứng + người xử lýTheo dõi lần sau

Kết quả công cụ là đầu vào cho bảng kiểm kê. Ghi loại thiết bị, bằng chứng và mức chắc chắn; không gán nhãn nguy hiểm chỉ vì một dòng mới xuất hiện.

Điều cần hiểu: Bước 8 hướng dẫn xác minh và xử lý.

Hình minh họa không chạy Python và không gửi gói tin. Bấm Tiếp để học theo nhịp của bạn; Phát tự chuyển cảnh mỗi 9 giây.

Dùng khi bạn đang ở mạng nhà hoặc mạng câu lạc bộ mà bạn được phép kiểm kê. Chạy lần đầu để lấy "ảnh chụp" ban đầu:

macOS: nhớ cấp quyền "Mạng cục bộ" trước khi chạyNếu kết quả chỉ có một dòng là chính máy bạn, thường là macOS đang chặn. Vào Cài đặt hệ thống → Quyền riêng tư và bảo mật → Mạng cục bộ, bật cho ứng dụng bạn đang gõ lệnh (Terminal hoặc Visual Studio Code), thoát hẳn ứng dụng rồi mở lại, chạy lại lệnh.
Vẫn một dòng? Xem dòng đầu terminal: nếu có (.venv) hoặc bạn cài Python bằng uv, pyenv, conda, thì python3 đang là bản Python riêng và macOS có thể chặn nó. Chạy bằng Python có sẵn của macOS: /usr/bin/python3 lan_inventory.py --save baseline.json.
MACOS
python3 lan_inventory.py --save baseline.json
WINDOWS
py lan_inventory.py --save baseline.json
LINUX
python3 lan_inventory.py --save baseline.json
PhầnÝ nghĩa
--save baseline.jsonLưu kết quả vào file JSON: "ảnh chụp" mạng hôm nay để so sánh lần sau.
--net 10.0.0.0/22Chỉ cần khi mạng không phải /24 và công cụ đoán sai. Bình thường cứ bỏ qua.
--no-portsBỏ bước quét port: nhanh hơn, chỉ ra IP, MAC, hãng, tên.
Khác gì arp -a ở bước 2?Lệnh này gõ cửa mọi IP trong dải (nên chờ vài giây), rồi mới đọc sổ ARP. Vì vậy danh sách thường dài hơn kết quả arp -a bạn chạy lúc đầu. Hãy so số dòng hai lần.

Chờ khoảng 5 đến 30 giây. Chương trình in không dấu để không lỗi font trong terminal. Kết quả mẫu (hãng, tên, địa chỉ là minh họa):

KẾT QUẢ MẪU
Dang kiem tra 192.168.1.0/24 (256 dia chi), cho khoang 5-30 giay...
IP              MAC                Hang / ghi chu                    Ten
192.168.1.1     f4:ec:38:11:22:33  Hang router A                     router.lan
    mo port 80    http                Server: lighttpd
    DOAN: router | HDH: nhom Linux/macOS/iOS/Android (TTL 64)
192.168.1.23    6a:6c:4a:21:dc:21  MAC rieng/ngau nhien (dien thoai  macbook-cua-ban.lan
    DOAN: Mac | HDH: nhom Linux/macOS/iOS/Android (TTL 64)
192.168.1.40    3c:22:fb:aa:bb:cc  Hang may in X                     npi-3f2a
    mo port 80    http                Server: Embedded HTTP Server
    mo port 631   ipp (may in)
    mo port 9100  may in
    DOAN: may in | HDH: thiet bi mang/may in (TTL 255)
192.168.1.52    da:a1:19:00:00:01  MAC rieng/ngau nhien (dien thoai
    DOAN: chua du dau hieu | HDH: nhom Linux/macOS/iOS/Android (TTL 64)

Tong cong: 4 thiet bi tra loi.
Da luu baseline.json
Đã chạy thử thậtCông cụ này đã được chạy trên mạng nhà thật 192.168.68.0/22 (1024 địa chỉ): mỗi lần quét xong trong khoảng 25 giây, tra đúng hãng TP-Link, Roborock, Xiaomi. Số thiết bị ra 5, rồi 8, rồi 11 qua các lần quét (thiết bị ngủ thức dậy, bộ mesh trả lời chậm). Bảng nhận diện đầy đủ của mạng này nằm ở Bước 4. Mạng của bạn ra nhiều hơn hoặc ít hơn đều bình thường.
  • .1: router. Cổng 80 là trang quản lý.
  • .40: hãng máy in, hai cổng in ấn, TTL 255. Rất chắc là máy in.
  • .52: MAC riêng, không mở cổng, TTL 64. Nhiều khả năng là điện thoại.

Vài giờ hoặc vài ngày sau, chạy lại và chỉ cho chương trình file cũ để so sánh:

MACOS
python3 lan_inventory.py --baseline baseline.json --save today.json
WINDOWS
py lan_inventory.py --baseline baseline.json --save today.json
LINUX
python3 lan_inventory.py --baseline baseline.json --save today.json

Thiết bị mới chưa chắc là kẻ lạ: điện thoại đổi MAC ngẫu nhiên, hoặc khách vừa đến. Cách xử lý ở bước 8.

Đọc thêm: lỗi thường gặp khi chạy công cụ (không bắt buộc)
Dấu hiệuNguyên nhân thường gặpCách xử lý
Chỉ thấy vài thiết bịĐiện thoại đang ngủ, hoặc máy bạn ở mạng kháchChạy lại 2–3 lần cách vài phút. Nối đúng mạng chính
Chỉ thấy đúng máy mình, hoặc báo "No route to host" (macOS mới)macOS chưa cho Terminal hoặc VS Code truy cập mạng cục bộKhi macOS hỏi, chọn Cho phép. Đã từ chối: Cài đặt hệ thống → Quyền riêng tư và bảo mật → Mạng cục bộ, bật cho Terminal hoặc Visual Studio Code, rồi mở lại ứng dụng. Vẫn không được và terminal có (.venv): dùng /usr/bin/python3 thay cho python3
Quét hotspot iPhone chậmDải mặc định rộng hơn dải /28 của hotspotThêm --net 172.20.10.0/28
Báo "Chi quet dai IP rieng …"Dải IP không phải dải riêng, hoặc quá lớnKiểm tra --net. Đây là bảo vệ có chủ ý
Quét ra dải lạ như 10.8.0.0/24Máy đang bật VPNTắt VPN rồi chạy lại
Cột hãng ghi "chua co oui.txt"Chưa tải bước 4 của lab, hoặc sai thư mụcTải oui.txt vào cùng thư mục với lan_inventory.py
Rất nhiều "MAC rieng"Thiết bị bật MAC ngẫu nhiênBình thường. Dựa vào tên và port
python3: command not foundChưa cài Python hoặc chưa mở lại terminalXem bước 0 của bài 6
Đọc thêm: hỏi thiết bị tự giới thiệu bằng SSDP (không bắt buộc)

Một đoạn Python ngắn gửi một gói UDP hỏi "ai là thiết bị UPnP?" và in tên thiết bị trả lời. Hữu ích để nhận diện TV, router, máy in. Chỉ chạy trên mạng của bạn:

ssdp_discover.py · Tải file vào thư mục lan-lab, hoặc mở mã để sao chép vào trình soạn thảo.

↓ Tải ssdp_discover.py
Xem mã Python · có tô màu và số dòng
PYTHON · ssdp_discover.py
# ssdp_discover.py - hoi "ai la thiet bi UPnP o day?" bang goi UDP multicast. TV, router, may in thuong tra loi.
# Chi chay tren mang cua ban. Mat khoang 4 giay.
import socket
import time

MESSAGE = ("M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\n"
           'MAN: "ssdp:discover"\r\nMX: 2\r\nST: ssdp:all\r\n\r\n').encode()

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
    s.settimeout(1)
    s.sendto(MESSAGE, ("239.255.255.250", 1900))
    seen = {}
    deadline = time.time() + 4
    while time.time() < deadline:
        try:
            data, addr = s.recvfrom(4096)
        except socket.timeout:
            continue
        headers = {}
        for line in data.decode("utf-8", errors="replace").splitlines()[1:]:
            key, _, value = line.partition(":")
            headers[key.strip().upper()] = value.strip()
        seen.setdefault(addr[0], headers)

for ip, h in sorted(seen.items()):
    print(f"{ip:<16} SERVER={h.get('SERVER', '?')}")
    print(f"{'':<16} LOCATION={h.get('LOCATION', '?')}")
print(f"\nCo {len(seen)} thiet bi tra loi SSDP (thiet bi khong ho tro UPnP se im lang).")
Tạo file bằng terminal · chọn hệ điều hành của bạn
MACOS: TẠO ssdp_discover.py
cat > ssdp_discover.py <<'EOF'
# ssdp_discover.py - hoi "ai la thiet bi UPnP o day?" bang goi UDP multicast. TV, router, may in thuong tra loi.
# Chi chay tren mang cua ban. Mat khoang 4 giay.
import socket
import time

MESSAGE = ("M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\n"
           'MAN: "ssdp:discover"\r\nMX: 2\r\nST: ssdp:all\r\n\r\n').encode()

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
    s.settimeout(1)
    s.sendto(MESSAGE, ("239.255.255.250", 1900))
    seen = {}
    deadline = time.time() + 4
    while time.time() < deadline:
        try:
            data, addr = s.recvfrom(4096)
        except socket.timeout:
            continue
        headers = {}
        for line in data.decode("utf-8", errors="replace").splitlines()[1:]:
            key, _, value = line.partition(":")
            headers[key.strip().upper()] = value.strip()
        seen.setdefault(addr[0], headers)

for ip, h in sorted(seen.items()):
    print(f"{ip:<16} SERVER={h.get('SERVER', '?')}")
    print(f"{'':<16} LOCATION={h.get('LOCATION', '?')}")
print(f"\nCo {len(seen)} thiet bi tra loi SSDP (thiet bi khong ho tro UPnP se im lang).")
EOF
WINDOWS POWERSHELL: TẠO ssdp_discover.py
@'
# ssdp_discover.py - hoi "ai la thiet bi UPnP o day?" bang goi UDP multicast. TV, router, may in thuong tra loi.
# Chi chay tren mang cua ban. Mat khoang 4 giay.
import socket
import time

MESSAGE = ("M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\n"
           'MAN: "ssdp:discover"\r\nMX: 2\r\nST: ssdp:all\r\n\r\n').encode()

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
    s.settimeout(1)
    s.sendto(MESSAGE, ("239.255.255.250", 1900))
    seen = {}
    deadline = time.time() + 4
    while time.time() < deadline:
        try:
            data, addr = s.recvfrom(4096)
        except socket.timeout:
            continue
        headers = {}
        for line in data.decode("utf-8", errors="replace").splitlines()[1:]:
            key, _, value = line.partition(":")
            headers[key.strip().upper()] = value.strip()
        seen.setdefault(addr[0], headers)

for ip, h in sorted(seen.items()):
    print(f"{ip:<16} SERVER={h.get('SERVER', '?')}")
    print(f"{'':<16} LOCATION={h.get('LOCATION', '?')}")
print(f"\nCo {len(seen)} thiet bi tra loi SSDP (thiet bi khong ho tro UPnP se im lang).")
'@ | Set-Content -Path ssdp_discover.py -Encoding utf8
LINUX: TẠO ssdp_discover.py
cat > ssdp_discover.py <<'EOF'
# ssdp_discover.py - hoi "ai la thiet bi UPnP o day?" bang goi UDP multicast. TV, router, may in thuong tra loi.
# Chi chay tren mang cua ban. Mat khoang 4 giay.
import socket
import time

MESSAGE = ("M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\n"
           'MAN: "ssdp:discover"\r\nMX: 2\r\nST: ssdp:all\r\n\r\n').encode()

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
    s.settimeout(1)
    s.sendto(MESSAGE, ("239.255.255.250", 1900))
    seen = {}
    deadline = time.time() + 4
    while time.time() < deadline:
        try:
            data, addr = s.recvfrom(4096)
        except socket.timeout:
            continue
        headers = {}
        for line in data.decode("utf-8", errors="replace").splitlines()[1:]:
            key, _, value = line.partition(":")
            headers[key.strip().upper()] = value.strip()
        seen.setdefault(addr[0], headers)

for ip, h in sorted(seen.items()):
    print(f"{ip:<16} SERVER={h.get('SERVER', '?')}")
    print(f"{'':<16} LOCATION={h.get('LOCATION', '?')}")
print(f"\nCo {len(seen)} thiet bi tra loi SSDP (thiet bi khong ho tro UPnP se im lang).")
EOF
MACOS
python3 ssdp_discover.py
WINDOWS
py ssdp_discover.py
LINUX
python3 ssdp_discover.py
KẾT QUẢ MẪU (MINH HỌA)
192.168.1.1      SERVER=Linux/4.4 UPnP/1.0 router-hang-A/1.0
                 LOCATION=http://192.168.1.1:5000/rootDesc.xml
192.168.1.30     SERVER=Linux/3.10 UPnP/1.0 Samsung-TV/1.0
                 LOCATION=http://192.168.1.30:9197/dmr

Co 2 thiet bi tra loi SSDP (thiet bi khong ho tro UPnP se im lang).

Thiết bị không hỗ trợ UPnP sẽ không trả lời, nên đoạn này chỉ bổ sung, không thay thế các bước trên. Giao thức này gọi là .

BÀI TẬP 3 · 10 PHÚT

Chạy công cụ và lập bảng kiểm kê

Chạy lan_inventory.py theo một trong bốn cách. Lập bảng có các cột: IP, MAC, hãng, tên, port mở, loại thiết bị (đoán), bằng chứng. Ghi "chưa rõ" cho thiết bị chưa nhận ra. Đếm: bao nhiêu thiết bị bạn đoán được chắc, bao nhiêu còn chưa rõ?

Xem đáp án

Không có đáp án duy nhất. Một bảng tốt: mỗi dòng có loại thiết bị kèm bằng chứng cụ thể (ví dụ "máy in vì hãng X và port 631/9100"), thiết bị nào không chắc thì ghi mức chắc chắn. Với dòng "chưa rõ": nhờ người trong nhà nhận, ngắt Wi-Fi từng thiết bị xem dòng nào biến mất, hoặc xem MAC trong cài đặt thiết bị. Thường sẽ thấy: router, máy tính, điện thoại, TV, máy in, vài thiết bị thông minh. Ít nhất một số dùng MAC riêng nên không tra được hãng.

09

Bước 8 · Thấy thiết bị lạ thì làm gì?

15 phút
Phát hiệnThiết bị chưa biết
Xác minhHỏi người, đối chiếu MAC
Xử lýĐổi mật khẩu, tách mạng
XEM TỪNG CẢNH · MÔ PHỎNG MINH HỌA

Thiết bị mới: xác minh trước khi xử lý

Một tình huống minh họa có hai kết quả có thể xảy ra. Hãy đi hết các cảnh để phân biệt chúng.

1Phát hiện một MAC mới
“Mình chưa biết đây là máy của ai.”Lần kiểm kê mới192.168.1.77MAC chưa có trong sổThiết bị chưa rõc2:11:aa:bb:cc:ddCHƯA XÁC MINHChưa đủ bằng chứngMới ≠ xâm nhậpBình tĩnh đối chiếu

MAC riêng/ngẫu nhiên là tính năng thường gặp trên điện thoại. Chỉ nhìn IP, hãng hoặc việc không mở cổng thì chưa biết chủ thiết bị.

Điều cần hiểu: Việc đầu tiên: hỏi những người đang dùng mạng.

2Hỏi và kiểm tra trên thiết bị
“MAC trong cài đặt có khớp không?”Máy khách vừa đếnMở cài đặt Wi-FiXem MAC của mạng nàyDòng mới trong sổSo MAC đầy đủKhông chỉ so tiền tốGhi chú bằng chứngTên chủ + cách đối chiếuCó thể kiểm tra lại

Có thể ngắt Wi-Fi thiết bị nghi là tương ứng rồi quét lại để bổ sung bằng chứng. Bảng ARP cũ có thể còn giữ dòng, nên không dùng một lần biến mất/xuất hiện để kết luận.

Điều cần hiểu: Đối chiếu trực tiếp giúp xác minh danh tính.

3Nếu khớp: ghi nhận thiết bị quen
“Đó là điện thoại của khách đã được phép.”Thiết bị đã đối chiếuMAC khớp cài đặtĐÃ XÁC MINHBảng kiểm kêThêm tên người dùngGhi bằng chứngMạng kháchCho khách sử dụngCấu hình cách ly phù hợp

Ghi tên thiết bị và chủ sử dụng. Nếu có mạng khách được cách ly đúng, cho khách dùng mạng đó để giảm truy cập tới máy giữ dữ liệu.

Điều cần hiểu: Hoàn thành nhánh đã nhận ra chủ thiết bị.

4Nếu vẫn không ai nhận: xử lý mạng
“Người quản trị loại truy cập chưa rõ.”Người quản trịĐổi mật khẩu Wi-FiKiểm tra cấu hìnhThiết bị đã biếtNối lại và đối chiếuCho phép sử dụngNhật ký xử lýThời điểm + hành độngKiểm kê lại

Theo quyền quản trị của bạn: đổi mật khẩu Wi-Fi, kiểm tra WPA2/WPA3, tắt WPS, ngắt các phiên kết nối chưa rõ và nối lại thiết bị đã biết. Ghi việc đã làm và kiểm kê lại để xác nhận.

Điều cần hiểu: Đây là nhánh thay thế khi chưa nhận ra thiết bị.

Hình minh họa không chạy Python và không gửi gói tin. Bấm Tiếp để học theo nhịp của bạn; Phát tự chuyển cảnh mỗi 9 giây.

Đừng vội kết luận có người xâm nhập. Làm theo thứ tự:

  1. Hỏi trước. Ai vừa mang thiết bị mới đến? Khách có xin mật khẩu Wi-Fi hôm nay không?
  2. Đối chiếu. Tên, hãng, port có khớp thiết bị nào bạn biết không? Tắt Wi-Fi từng thiết bị, xem dòng nào biến mất.
  3. Vẫn không nhận ra thì coi như không đáng tin. Đổi mật khẩu Wi-Fi sang mật khẩu dài mới (bài 3), rồi nối lại các thiết bị đã biết. Thiết bị lạ sẽ bị loại.
  4. Ghi lại thời điểm, MAC, IP và việc bạn đã làm (bài 4).
Chặn theo MAC không phải biện pháp bảo mậtMAC rất dễ bị đổi, và điện thoại bật MAC ngẫu nhiên còn tự đổi. Cái giữ được là mật khẩu Wi-Fi và cách cấu hình mạng.

Giảm rủi ro cho mạng Wi-Fi

Việc cần làmVì sao
Dùng WPA3 (hoặc ít nhất WPA2) và mật khẩu Wi-Fi dàiMật khẩu yếu cho kẻ ở gần đoán ra và vào hẳn mạng
Tắt WPS (nút kết nối nhanh bằng mã PIN)WPS từng có lỗi cho phép đoán PIN
Tạo mạng khách cho kháchKhách không chạm được vào máy in, máy thủ quỹ
Đặt camera, TV, thiết bị thông minh vào mạng riêng hoặc mạng kháchChúng hay ít được cập nhật. Nếu một cái bị chiếm, nó không nên thấy máy giữ dữ liệu
Cập nhật firmware, đổi mật khẩu quản trị mặc địnhPhần lớn vụ xâm nhập dùng mật khẩu mặc định hoặc lỗi đã có bản vá
Tắt dịch vụ không dùng (Remote Desktop, Telnet, chia sẻ file)Mỗi cổng mở là một điểm có thể bị tấn công
Quét kiểm kê định kỳ và so với lần trướcThiết bị mới hoặc port mới được phát hiện sớm

Nối với bài 2: kiểm kê là bước nhận biết tài sản. Máy thủ quỹ giữ dữ liệu lệ phí, thiệt hại cao hơn TV phòng sinh hoạt, nên ưu tiên hơn.

BÀI TẬP 4 · 8 PHÚT

Chẩn đoán bốn tình huống

Với mỗi tình huống, nêu giải thích có thể nhất và việc làm đầu tiên:

  1. Trang router báo 12 thiết bị, nhưng câu lạc bộ chỉ có khoảng 7.
  2. Công cụ thấy một thiết bị mới có MAC ngẫu nhiên, tên rỗng, không mở cổng nào, xuất hiện lúc 22:00 rồi biến mất sáng hôm sau.
  3. Camera có port 23 (Telnet) mở.
  4. Bạn chạy công cụ ở nhà và chỉ thấy máy mình và router, dù nhà có nhiều máy khác đang bật.
Xem đáp án
  1. Router thường liệt kê cả thiết bị đã rời đi, hoặc thiết bị phụ (loa, bóng đèn). Đối chiếu từng MAC với bản kiểm kê. Việc đầu tiên: ghi tên cho từng thiết bị trong trang router.
  2. Nhiều khả năng là điện thoại của người đến buổi tối, dùng MAC ngẫu nhiên. Hỏi người trong nhà, đối chiếu MAC trong cài đặt máy. Không ai nhận thì đổi mật khẩu Wi-Fi.
  3. Rủi ro cao: Telnet không mã hóa, hay dùng mật khẩu mặc định. Tắt Telnet, đổi mật khẩu, cập nhật firmware, đưa camera sang mạng riêng.
  4. Có thể máy bạn ở mạng khách, hoặc bật cách ly Wi-Fi, hoặc bật VPN. Kiểm tra mạng đang nối, tắt VPN, xem trang router.

BÀI TẬP 5 · Về nhà, 25 PHÚT

Viết báo cáo kiểm kê mạng và kế hoạch xử lý

Với mạng của bạn (hoặc mạng thử được phép), làm ba việc:

  1. Chạy lan_inventory.py --save baseline.json, rồi chạy lại sau ít nhất một ngày với --baseline baseline.json.
  2. Lập bảng kiểm kê (như bài tập 3). Đánh dấu thiết bị cần xem xét: mở Telnet, mở Remote Desktop, MAC ngẫu nhiên chưa nhận ra, thiết bị không ai nhận.
  3. Viết ngắn: ba rủi ro lớn nhất theo mức độ, mỗi rủi ro kèm một việc cụ thể và người chịu trách nhiệm.

Không đưa file JSON hay bảng chứa MAC và IP thật của người khác lên nơi công khai.

Xem đáp án

Không có đáp án duy nhất. Một báo cáo tốt: mỗi thiết bị có bằng chứng nhận diện; có phân biệt "đã xác minh" với "đoán"; rủi ro xếp theo thiệt hại (máy giữ dữ liệu quan trọng, dịch vụ không mã hóa, thiết bị không ai nhận) chứ không theo số lượng; biện pháp cụ thể, ví dụ "tắt Telnet trên camera phòng 1, người phụ trách: Minh, trước thứ Sáu".

Tóm tắt

Tự kiểm tra · 5 câu có giải thích +
1. Bảng ARP có 6 dòng: router, 3 thiết bị có MAC khác nhau, một dòng broadcast và một dòng "(incomplete)". Mạng có bao nhiêu thiết bị đang hoạt động?
2. Vì sao quét bằng cách ép máy hỏi ARP đáng tin hơn ping?
3. Một thiết bị có MAC bắt đầu bằng "da:a1:19". Điều gì đúng nhất?
4. Camera có port 23 mở. Nên xếp loại thế nào?
5. Bạn thấy thiết bị lạ trong danh sách. Việc đầu tiên nên làm là gì?

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ữ