Kỹ thuật lập trình với socket IP

    0

    Socket thô (raw socket), hay socket Ip, là loại socket giúp bạn gọi trực tiếp đến dịch vụ truyền thông host-to-host do giao thức IP cung cấp, bỏ qua dịch vụ truyền end-to-end của các giao thức ở tầng giao vận (TCP hoặc UDP). Loại socket này rất hữu dụng khi bạn cần xây dựng những chương trình như ping, tracert – loại chương trình cần sử dụng trực tiếp giao thức IP để truyền dữ liệu.

    Socket Ip (socket thô)

    Trước khi bắt đầu với lập trình socket thô, hãy cùng trao đổi vì sao lại cần làm việc trực tiếp với dịch vụ truyền thông host-to-host của IP.

    Khi cấu hình mạng trên các máy tính, dù là Windows hay Linux, bạn thường xuyên sử dụng lệnh ping để kiểm tra xem các máy đã có thể trao đổi thông tin hay chưa.

    Ping là một tiện ích hệ thống rất thông dụng trên các hệ điều hành desktop và thường chạy ở giao diện dòng lệnh.

    Dưới đây là ví dụ khi ping default gateway trong mạng LAN gia đình trên Windows Command Prompt.

    Lệnh ping trên windows command prompt

    Bạn đã bao giờ đặt câu hỏi làm sao để xây dựng được những chương trình như ping?

    Vấn đề ở chỗ, bạn có thể ping IP của một máy khác, và bạn đồng thời cũng có thể ping được IP của các thiết bị mạng như router, tường lửa – nơi không có chương trình ứng dụng hoạt động.

    Qua các nội dung về lập trình socket TCP và UDP, có lẽ bạn đoán được rằng chúng ta không thể sử dụng hai loại socket này để xây dựng những chương trình như ping.

    Thực tế, ping là một trong số những loại ứng dụng không sử dụng truyền thông end-to-end do TCP và UDP cung cấp. Thay vào đó chúng sử dụng truyền thông host-to-host do IP cung cấp.

    Chương trình ping sử dụng một giao thức truyền thông có tên gọi là ICMP – Internet Control Message Protocol.

    ICMP có mục tiêu là giúp trao đổi thông tin giữa các host (truyền thông host-to-host). Để thực hiện mục tiêu giúp các host trao đổi thông tin, ICMP cần phương tiện mang dữ liệu là giao thức IP. Thiết bị nào có khả năng hiểu giao thức IP thì thường cũng được cài đặt giao thức ICMP.

    TCP hay UDP lại giúp trao đổi dữ liệu giữa các tiến trình hoạt động trên các host (truyền thông end-to-end). Vì vậy, ICMP không sử dụng được TCP hay UDP cho mục tiêu của mình. Mặc dù vậy, ICMP lại không phải là một giao thức cùng cấp độ với TCP hay UDP vì nó không giúp trao đổi dữ liệu người dùng.

    Từ ví dụ với giao thức ICMP đặt ra yêu cầu là chương trình phải có khả năng sử dụng trực tiếp giao thức IP, thay vì sử dụng TCP hay UDP. Do vậy, các hệ thống cũng đều cung cấp socket API để truy xuất trực tiếp dịch vụ truyền thông của IP. Người ta gọi loại socket này là socket thô hay socket Ip.

    Kỹ thuật lập trình với socket thô

    Kỹ thuật lập trình với socket thô không khác biệt nhiều với UDP socket.

    Để tạo object socket thô, bạn có thể sử dụng overload sau của lớp Socket:

    Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Raw, ProtocolType.Icmp);

    Hãy để ý tham số thứ hai SocketType.Raw. Đây là giá trị bắt buộc phải sử dụng để tạo ra object cho socket thô.

    Tham số thứ ba có thể nhận nhiều giá trị khác nhau, tùy thuộc vào giao thức bạn muốn sử dụng. Trong ví dụ trên chúng ta tạo socket thô để gửi đi gói tin ICMP, vì vậy chúng ta sử dụng giá trị ProtocolType.Icmp.

    Để gửi gói tin qua socket thô, bạn phải sử dụng phương thức SendTo của lớp Socket, tương tự như với UDP socket.

    IPEndPoint endpoint = new IPEndPoint(IPAddress.Parse("192.168.1.2"), 0);
    socket.SendTo(packet, endpoint);

    Do phương thức SendTo yêu cầu tham số thứ hai là một object có kiểu IPEndPoint, bạn cần khởi tạo object IPEndPoint. Tuy nhiên, do socket thô gửi gói tin trực tiếp qua giao thức IP nên giá trị port không được sử dụng. Vì vậy khi tạo object IPEndPoint bạn có thể để port là 0 hay bất kỳ giá trị nào.

    Tham số packet là chuỗi byte bạn cần gửi đi đến host đích. Khi gọi phương thức SendTo, chuỗi byte này sẽ được gắn vào một IP header để tạo thành một IP dgram. Giao thức IP sẽ gửi dgram này tới host đích.

    Bạn phải tự mình tạo chuỗi byte packet theo cấu trúc gói tin của giao thức sử dụng. Ví dụ, nếu cần gửi gói tin ICMP, bạn phải tự mình tạo chuỗi byte theo đúng cấu trúc của ICMP. Giao thức IP không can thiệp gì vào chuỗi byte này (ngoại trừ gắn thêm IP header).

    Để nhận gói tin qua socket thô, bạn sử dụng phương thức ReceiveFrom của lớp Socket (tương tự như Udp).

    public int ReceiveFrom (byte[] buffer, ref System.Net.EndPoint remoteEP);

    Tuy nhiên, khi nhận gói tin thô có một điểm khác biệt quan trọng cần lưu ý. Khi gọi ReceiveFrom, cái bạn nhận được (qua tham số buffer) là cả gói tin IP nguyên vẹn (bao gồm cả IP header). Do vậy, để lấy ra phần dữ liệu, bạn cần tự mình cắt bỏ IP header. Đây là điều bạn không gặp khi làm việc với TCP hay UDP socket.

    Một điều may mắn là IP header có độ dài cố định 20 byte. Vì vậy, khối dữ liệu của dgram bắt đầu từ byte thứ 21, nghĩa là bắt đầu từ phần tử buffer[20].

    Cũng vì lý do này, mảng byte buffer phải có kích thước đủ lớn để chứa thêm cả 20 byte của IP header (bên cạnh phần dữ liệu).

    Để học cách làm việc với socket thô, chúng ta sẽ cùng xây dựng một chương trình mô phỏng hoạt động của ứng dụng ping sử dụng giao thức ICMP.

    Giao thức ICMP

    ICMP – định nghĩa trong RFC 792 – là một giao thức được thực thi trên nhiều loại thiết bị mạng (host) giúp các thiết bị này trao đổi thông tin. Đây là giao thức được sử dụng trong các ứng dụng hỗ trợ quản lý mạng như ping hay trace route.

    Gói tin của ICMP đặt trong gói tin IP

    Toàn bộ gói tin ICMP được đặt trong phần dữ liệu của gói tin IP.

    Gói tin ICMP có cấu trúc khá đơn giản:

    Trường type (1 byte): xác định loại thông điệp. Giá trị của trường type cũng được gọi là type code.

    Mỗi type code có ý nghĩa riêng. Ví dụ: type code 0 – echo reply (gói tin phản hồi của thông điệp echo); type code 8 – echo request (gói tin yêu cầu của thông điệp echo).

    Thông điệp echo được sử dụng cho ứng dụng ping. Chúng ta sẽ quay lại với thông điệp echo sau.

    Trường code (1 byte): xác định thông tin chi tiết và làm rõ hơn ý nghĩa mô tả trong trường type.

    Ví dụ, code 0 – network unreachable, code 1 – host unreachable.

    Trường checksum (2 byte) – dùng để kiểm soát lỗi. Trường này được tính theo một thuật toán riêng.

    Trường message có độ dài biến đổi và có thể chứa nhiều loại thông tin khác nhau. Hai loại thông tin thường gặp trong trường mesage là số định danh của gói tin (identifier) và số thứ tự (sequence number).

    Xây dựng chương trình ping

    Chương trình ping là một trong những ứng dụng phổ biến của ICMP. Chương trình này sử dụng socket thô để gửi gói tin echo request của ICMP và nhận lại gói tin echo reply.

    Gói tin Echo Request có type = 8 và chứa một thông điệp bất kỳ. Khi máy đích nhận được gói tin này sẽ gửi lại gói tin Echo Reply với type = 0 và thông điệp lặp lại từ Echo Request.

    Sử dụng ICMP trong ping

    Chúng ta cùng xây dựng chương trình ping dựa trên những kiến thức vừa học.

    Bước 1. Tạo một project mới thuộc kiểu ConsoleApp và đặt tên là Ping.

    Bước 2. Thêm vào project file mã nguồn Icmp.cs và viết code như sau:

    using System;
    using System.Collections.Generic;
    using System.Net;
    namespace Ping {
    	internal class Icmp {
    		public byte Type { get; set; } = 0x08;
    		public byte Code { get; set; } = 0x00;
    		public ushort Checksum { get; set; } = 0;
    		private ushort _identifier;
    		public ushort Identifier {
    			get => (ushort)IPAddress.NetworkToHostOrder((short)_identifier);
    			set => _identifier = (ushort)IPAddress.HostToNetworkOrder((short)value);
    		}
    		private ushort _sequence;
    		public ushort Sequence {
    			get => (ushort)IPAddress.NetworkToHostOrder((short)_sequence);
    			set => _sequence = (ushort)IPAddress.HostToNetworkOrder((short)value);
    		}
    		public byte[] Payload { get; set; }
    		public int PayloadSize => Payload.Length;
    		public Icmp() {
    		}
    		public Icmp(byte[] ipDgram, int ipDgramLength) {
    			Type = ipDgram[20];
    			Code = ipDgram[21];
    			Checksum = BitConverter.ToUInt16(ipDgram, 22);
    			_identifier = BitConverter.ToUInt16(ipDgram, 24);
    			_sequence = BitConverter.ToUInt16(ipDgram, 26);
    			var payloadSize = ipDgramLength - 28;
    			Payload = new byte[payloadSize];
    			Array.Copy(ipDgram, 28, Payload, 0, payloadSize);
    		}
    		public byte[] GetBytes() {
    			var bytes = new List<byte>();
    			bytes.Add(Type);
    			bytes.Add(Code);
    			bytes.AddRange(BitConverter.GetBytes(Checksum));
    			bytes.AddRange(BitConverter.GetBytes(_identifier));
    			bytes.AddRange(BitConverter.GetBytes(_sequence));
    			bytes.AddRange(Payload);
    			return bytes.ToArray();
    		}
    		public static void SetChecksum(Icmp packet) {
    			packet.Checksum = 0;
    			uint checksum = 0;
    			byte[] data = packet.GetBytes();
    			for (int i = 0; i < data.Length; i += 2) {
    				checksum += Convert.ToUInt32(BitConverter.ToUInt16(data, i));
    			}
    			checksum = (checksum >> 16) + (checksum & 0xffff);
    			checksum += (checksum >> 16);
    			packet.Checksum = (ushort)(~checksum);
    		}
    	}
    }

    Bước 3. Thêm vào project file mã nguồn mới Ping.cs và viết code như sau:

    using System;
    using System.Diagnostics;
    using System.Net;
    using System.Net.Sockets;
    using System.Text;
    using System.Threading;
    namespace Ping {
    	internal class Ping {
    		public Action<string> Log { get; set; }
    		public void Run(IPAddress address, int loops = 4, int timeout = 3000, string message = "1234567890", int sleep = 1500) {
    			
    			var _socket = new Socket(AddressFamily.InterNetwork, SocketType.Raw, ProtocolType.Icmp);
    			_socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReceiveTimeout, timeout);
    			IPEndPoint remoteEP = new IPEndPoint(address, 0);
    			EndPoint dumpEP = new IPEndPoint(IPAddress.Any, 0);
    			byte[] data = new byte[1024];
    			var id = (ushort)Process.GetCurrentProcess().Id;
    			var req = new Icmp { Identifier = id, Sequence = 0, Payload = Encoding.ASCII.GetBytes(message) };
    			Icmp.SetChecksum(req);
    			Log?.Invoke($"Pinging {remoteEP.Address} with {req.PayloadSize} bytes of message, process id={req.Identifier}");
    			Stopwatch sw = new Stopwatch();
    			for (int i = 0; i < loops; i++) {
    				sw.Start();
    				_socket.SendTo(req.GetBytes(), remoteEP);
    				try {
    					int ipDgramLength = _socket.ReceiveFrom(data, ref dumpEP);
    					sw.Stop();
    					var res = new Icmp(data, ipDgramLength);
    					Log?.Invoke($"Reply from {remoteEP}: {ipDgramLength - 28} bytes, id={res.Identifier}, seq={res.Sequence}, {sw.ElapsedTicks} ticks ({sw.ElapsedMilliseconds} ms)");
    				} catch (SocketException) {
    					Log?.Invoke("Timeout");
    				}
    				
    				req.Sequence++;
    				Icmp.SetChecksum(req);
    				sw.Reset();
    				Thread.Sleep(sleep);
    			}
    		}
    	}
    }

    Bước 4. Viết lại file Program.cs như sau:

    using System;
    using System.Net;
    namespace Ping {
    	internal class Program {
    		private static void Main(string[] args) {
    			Console.Title = "My Ping";
    			IPAddress getIp(string arg) {
    				if (IPAddress.TryParse(arg, out IPAddress ip)) return ip;
    				else throw new Exception("Invalid Ip address");
    			}
    			var ping = new Ping() { Log = Console.WriteLine };
    			while (true) {
    				Console.Write("Ip > ");
    				var ip = Console.ReadLine();
    				ping.Run(getIp(ip));
    				Console.WriteLine();
    			}
    			
    		}
    	}
    }

    Bước 5. Chạy thử nghiệm chương trình từ giao diện dòng lệnh

    Kết quả hoạt động của chương trình ping vừa xây dựng

    Nhập một địa chỉ IP và ấn enter, chúng ta sẽ thu được kết quả như Hình. Kết quả của chương trình ping này tương tự như cách ứng dụng ping của hệ thống hoạt động.

    Mặc định chương trình sẽ gửi đi 4 gói tin ICMP echo request. Mỗi gói tin này chứa PID của tiến trình đang hoạt động, một số thứ tự, và chuỗi thông điệp 1234567890. Cứ mỗi 1,5 giây chương trình sẽ phát đi một gói tin ICMP echo request như vậy.

    Ứng với mỗi gói request phát đi, chương trình sẽ chờ nhận gói tin ICMP echo response trong 3 giây. Nếu quá 3 giây mà chưa nhận được phản hồi, chương trình coi đây là lỗi. Thời gian từ lúc phát gói tin đi đến lúc nhận được gói tin phản hồi về được tính bằng đơn vị ticks và quy đổi ra mili giây. Nếu ping trong mạng LAN hoặc địa chỉ loopback, thời gian này thường rất nhỏ.

    Vận dụng lập trình socket thô với ICMP

    Như ở phần trước đã nói, khi khai thác socket thô, chúng ta phải tự mình tạo ra gói tin của giao thức ICMP. Vì gói tin ICMP thực chất cũng chỉ là một chuỗi byte, chúng ta có thể dễ dàng tạo ra được chuỗi byte phù hợp. Tuy nhiên có một số điểm đặc điểm cần lưu ý.

    Để tiện lợi cho việc sinh ra chuỗi byte ICMP, chúng ta xây dựng một class riêng với các thuộc tính tương đương với các trường của gói tin ICMP: trường Type (byte), Code (byte), Checksum (ushort). Các trường này được gán giá trị mặc định của gói tin Echo Request, dùng cho chương trình ping.

    internal class Icmp {
        public byte Type { get; set; } = 0x08;
        public byte Code { get; set; } = 0x00;
        public ushort Checksum { get; set; } = 0;
    ...
    

    Phần dữ liệu của ICMP khi áp dụng với ping được tách làm 3 phần: Identifier (ushort) dùng để chứa số định danh của tiến trình gửi truy vấn; Sequence (ushort) chứa số thứ tự của gói tin; Payload (byte[]) chứa một thông điệp bất kỳ muốn gửi đi cùng gói tin này. Giá trị của Identifier và Sequence được xử lý hơi khác thường.

    private ushort _identifier;
    public ushort Identifier {
       get => ushort)IPAddress.NetworkToHostOrder((short)_identifier);
       set => _identifier = ushort)IPAddress.HostToNetworkOrder((short)value);
       }
    private ushort _sequence;
    public ushort Sequence {
       get => (ushort)IPAddress.NetworkToHostOrder((short)_sequence);
       set => _sequence = (ushort)IPAddress.HostToNetworkOrder((short)value);
    

    Các hệ thống sử dụng cách biểu diễn byte khác nhau. Windows sử dụng kiểu biểu diễn Little Endian, còn các hệ thống khác sử dụng kiểu biểu diễn Big Endian. Dữ liệu trên mạng được quy định biểu diễn kiểu Big Endian. Do vậy, giá trị số của Identifier và Sequence phải được chuyển đổi sang dạng Big Endian trước khi đóng gói thông qua phương thức HostToNetworkOrder của lớp IPAddress. Tương tự, hai trường này phải được chuyển đổi trở lại dạng Little Endian của Windows để sử dụng trong chương trình qua lời gọi phương thức NetworkToHostOrder. Nếu không thực hiện các biến đổi này, giá trị gửi đi sẽ bị thiết bị đích hiểu sai, và máy nguồn cũng sẽ xử lý sai.

    Giá trị thuộc tính Checksum chỉ được tính toán khi các thuộc tính khác đã có đủ giá trị phù hợp, do thuật toán tính Checksum áp dụng với tất cả các byte của gói tin ICMP. Thuật toán này được công bố trên RFC của ICMP. Chúng ta chỉ được áp dụng, không thể điều chỉnh. Nếu Checksum tính theo cách khác, thiết bị đích sẽ xem đây là gói tin hỏng và sẽ không phản hồi.

    public static void SetChecksum(Icmp packet) {
        packet.Checksum = 0;
        uint checksum = 0;
        byte[] data = packet.GetBytes();
        for (int i = 0; i < data.Length; i += 2) {
            checksum += Convert.ToUInt32(BitConverter.ToUInt16(data, i));
        }
        checksum = (checksum >> 16) + (checksum & 0xffff);
        checksum += (checksum >> 16);
        packet.Checksum = (ushort)(~checksum);
    }
    

    Khi có class ICMP như trên, chúng ta áp dụng kỹ thuật lập trình socket thô để thực hiện các công việc sau:

    1. Khởi tạo object của socket thô;
    2. Khởi tạo một đồng hồ;
    3. Tạo gói tin Echo Request;
    4. Gửi truy vấn tới thiết bị đích;
    5. Bắt đầu tính giờ;
    6. Chờ nhận phản hồi;
    7. Nếu nhận được phản hồi => dừng đồng hồ đếm và in ra kết quả;
    8. Nếu quá 3 giây không có phản hồi => báo lỗi;
    9. Tăng số thứ tự và lặp lại từ bước 3.

    Tất cả công việc này được thực hiện bởi lớp Ping.

    + Nếu bạn thấy site hữu ích, trước khi rời đi hãy giúp đỡ site bằng một hành động nhỏ để site có thể phát triển và phục vụ bạn tốt hơn.
    + Nếu bạn thấy bài viết hữu ích, hãy giúp chia sẻ tới mọi người.
    + Nếu có thắc mắc hoặc cần trao đổi thêm, mời bạn viết trong phần thảo luận cuối trang.
    Cảm ơn bạn!

    Kết luận

    Trong bài học này chúng ta đã học cách làm việc với socket IP (socket thô) thông qua xây dựng một ứng dụng đơn giản (ping) sử dụng giao thức ICMP.

    Socket thô cho phép bỏ qua dịch vụ truyền thông end-to-end do giao thức UDP hoặc TCP cung cấp (tầng transport) để sử dụng dịch vụ truyền thông host-to-host do giao thức IP cung cấp.

    Khi lập trình với socket thô vẫn sử dụng lớp Socket của .NET với các phương thức gửi/nhận dữ liệu gần tương tự như với UDP socket nhưng chương trình cần tự mình loại bỏ 20 byte của IP header trước khi xử lý phần dữ liệu.

    Theo dõi
    Thông báo của
    guest

    0 Thảo luận
    Phản hồi nội tuyến
    Xem tất cả bình luận