Сетевое взаимодействие
Наиболее универсальный способ организации взаимодействия между двумя различными приложениями — так называемое «сетевое взаимодействие». Отметим, что этот термин не совсем соответствует общепринятому смыслу слова «сетевое». Например, в рамках одного компьютера (безо всякой компьютерной сети) сетевое взаимодействие тоже возможно (и, более того, очень активно используется).
Сетевой порт
Для того, чтобы программа могла получать данные «по сети», она должна зарезервировать у ОС сетевой порт.
Сетевой порт — это число в пределах от 0 до 65535. Каждая единица данных, отправляемая по сети, должна сопровождаться как минимум портом назначения. И именно той программе, которая сейчас резервирует соответствующий порт назначения, придёт эта единица данных (если, конечно, эта единица данных направлена на тот компьютер, на котором запущена программа, резервирующая упомянутый порт).
Обычно (но не всегда!) для свободного резервирования доступны порты из диапазона 1024-32767. Более того, весьма редко системные администраторы ОС настраивают права доступа пользователей к портам. При этом порты резервируются по принципу «кто первым встал — того и тапки». Поэтому правилом хорошего тона является не жёстко задавать используемые порты в программном коде, а давать пользователю программы возможность выбора портов, используемых программой.
Способ передачи данных
Перед тем, как передавать или получать данные «по сети», программа должна выбрать способ передачи данных. Таковых по факту используется два:
- TCP — аналог телефонной связи из реального мира; с точки зрения отправляющего и принимающего приложений между ними открывается двусторонний симметричный канал общения, по которому они могут пересылать друг другу данные; весь необходимый для этого обмен служебными сообщениями берёт на себя ОС
- UDP — почти полный аналог писем из реального мира; размер отправляемых данных ограничен (чуть менее чем 64 КиБ), судьба сетевого пакета, вообще говоря, неизвестна
Для большинства целей TCP гораздо предпочтительнее. Исключения — ситуации, требующие быстрейшей реакции (компьютерные игры, в которых важно время отклика; аудиоконференции; телефония и прочее подобное). Также UDP обычно используется при реализации каких-то собственных нетривиальных протоколов взаимодействия, отличных от TCP. В таких случаях говорят о «протоколе поверх UDP».
Адресация
Также для организации сетевого общения необходимо указать:
- порт назначения (какой программе предназначены данные)
- адрес назначения (на какой компьютер отправлять данные)
- порт отправителя (какая программа отправила данные)
- адрес отправителя (с какого компьютера отправлены данные)
При TCP-общении порт и адрес отправителя автоматически указываются операционной системой (у программиста к ним нет доступа). При UDP-общении эти адрес и порт можно указать любые (хотя многие ОС и языки программирования, в частности — Python, несколько затрудняют это действие).
Теперь по поводу настройки всего этого. Если с портами ситуация понята (они полностью управляются программистом), то с адресами всё сложнее (они управляются системным администратором, к тому же их смысл нестандартизирован).
Здесь мы опишем некие общие принципы, которых придерживаются почти все ОС и почти всё сетевое оборудование. При этом нужно понимать, что детали могут кардинально различаться.
Принципы следующие:
- компьютерные сети — это графы, вершины которых делятся на два класса: «компьютеры» и «мосты»(или, как их ещё называют, «звёзды»)
- пары (компьютер, выходящее из него ребро) называются сетевыми интерфейсами
- сетевой интерфейс может соответствать некоторому физическому способу подключения (обычно это либо RJ45-разъём, либо Wifi-модуль, но бывают и другие)
- также сетевой интерфейс может быть виртуальным — ОС делает полную видимость того, что у компьютера есть какой-то дополнительный разъём, но в реальности пользуется имеющимися
- соседями называются сетевые интерфейсы, между которыми существует путь, начинающийся в одном интерфейсе и заканчивающийся в другом, не содержащий при этом никаких других вершин-компьютеров (другими словами, любая промежуточная вершина должна быть мостом)
- каждый сетевой интерфейс имеет адрес
- доставка сетевых пакетов гарантируется только на адреса соседей, а также — на специальный loopback-адрес, который отождествляется с самим компьютером, отправляющим пакет
Адреса бывают двух видов: IPv4 и IPv6.
- каждый IPv4 адрес состоит из 32 бит, при этом традиционно записывается как 4 десятичных записи составляющих его октетов (восьмёрок бит), разделённые точками
- IPv4-loopback обозначается
127.0.0.1
- каждый IPv6 адрес состоит из 128 бит, традиционно записывающихся как восемь шестнадцатиричных записей составляющих его групп по 16 бит, разделённых двоеточиями
- если две и более группы подряд равны 0, то они могут быть опущены и заменены на двойное двоеточие
- IPv6-loopback обозначается
::1
(без сокращений0:0:0:0:0:0:0:1
)
При настройке сетевого интерфейса зачастую есть возможность разбить адрес на две части: номер подсети и номер узла. Это разбиение:
- не является частью адреса, а является лишь функцией, предоставляемой ОС
- обычно задаётся либо длиной префикса (количеством бит в подсети), либо
маской — «адресом», биты-единицы которого показывают,
какие из бит адреса относятся к номеру подсети; например, разбиения
10.23.45.67/10
(10 — длина префикса) и10.23.45.67/255.192.0.0
(255.192.0.0
— маска) эквивалентны - по идее должно быть таким, что разные сетевые интерфейсы одного компьютера имеют разные номера подсети, а любой сосед интерфейса имеет тот же номер подсети, что и сам интерфейс
Также есть ещё понятие шлюза (default gateway), который можно назначить сетевому интерфейсу. Это — адрес того соседа, на который будут отправлены все пакеты, адреса назначения которых не относятся ни к одной из подсетей адресов интерфейсов устройства. Что происходит при наличии нескольких шлюзов, при отсутствии шлюза, при отправке пакета на несуществующий адрес из подсети какого-нибудь интерфейса, и прочие подобные вопросы — те самые тонкости, которые на разных сетевых устройствах могут быть устроены совершенно по-разному.
Скажем лишь, что некоторые ОС предоставляют команду route
, дающую доступ к таблице маршрутизации, по которой можно понять, куда именно будет отправлен тот или иной пакет с адресом, отличным от адреса соседа. При желании в неё можно добавлять правила маршрутизации с семантикой «если адрес назначения такого-то вида, то пакет следует отправить такому-то соседу».
Также бывает команда arp
, показывающая те компьютеры (и т.н. MAC-адреса соответствующих их интерфейсов), которые устройство на данный момент считает своими соседями. Иногда (например, при отправке пакетов на адреса из подсети какого-нибудь из интерфейсов) устройство отправляет на этот интерфейс ARP-запрос с семантикой «если у тебя такой-то IP-адрес, сообщи мне свой MAC-адрес». Полученный устройством ответ на подобный запрос обновляет таблицу соседей.
Немного внутренностей
Операционная система с данными делает две вещи перед отправкой:
- сперва добавляет перед ними TCP/UDP-заголовок, в который входит информация о том, TCP или UDP выбран, а также — порты цели и отправителя
- затем перед TCP/UDP-заголовком добавляет IP-заголовок, в который входит информация об используемой версии IP, а также — адреса цели и отправителя
Знать эту внутреннюю структуру сетевого пакета полезно по весьма простой причине: поскольку протоколы TCP/UDP и IP появились существенно после ОС Unix, сетевая модель Unix не совсем точно ложится на эти протоколы. И в сложных случаях приходится вручную вмешиваться в эту модель (например, для того, чтобы выбрать адрес и порт отправителя при UDP-общении).
Ситуация ещё и осложняется тем, что многие языки программирования предоставляют свой взгляд на сетевое взаимодействие, отличный как от Unix-взгляда, так и от TCP/UDP/IP-взгляда. Но всё равно все альтернативные взгляды тем или иным образом транслируются в TCP/UDP/IP-модель.
Сетевое общение в Python
Python предоставляет низкоуровневый интерфейс (более-менее аналогичный
стандартному Unix-интерфейсу) сетевого взаимодействия посредством модуля
socket
.
Мы приведём основные схемы его использования (везде будем предполагать, что используются IPv4-адреса).
TCP-клиент
Устанавливает TCP-соединение с приложением по адресу 12.34.56.78
,
которое зарезервировало порт 90
.
import socket
sock = socket.socket()
sock.connect( ('12.34.56.78', 90) )
sock.send(b'Hello') ## отправка данных
msg = sock.recv(4096) ## приём данных
sock.close() ## завершает соединение
Число-вход метода recv
— это максимальное количество байт, которое
будет получено за этот вызов. Сам вызов блокирует исполнение программы,
но исполнение разблокируется в тот момент, как только будет получен
хотя бы один байт. Поэтому в качестве аргумента recv
можно писать
почти всё, что угодно (4096 — просто рекомендуемое значение).
Если соединение разрывается, то recv
становится неблокирующим и всегда
возвращает пустое сообщение.
Также отметим, что send
тоже может заблокировать дальнейшее исполнение
программы: это происходит, если мы отправили слишком много данных, а наш
«собеседник» почему-то данные не пытается от нас получить при
помощи recv
.
TCP-сервер
Ожидает соединение по порту 1234
.
import socket
sock = socket.socket()
sock.bind( ('', 1234) )
sock.listen() ## показывает нашу готовность принимать соединения
conn, addr = sock.accept() ## блокирует выполнение до установления соединения
conn.send(b'Hello') ## отправка данных
msg = conn.recv(4096) ## приём данных
conn.close() ## завершает соединение
Сервер по одному порту может принять сразу несколько соединений. С каждым
из них он общается через сокет, возвращённый методом accept
.
Методу listen
можно указать число-вход — максимальное количество
соединений, которые могут находиться в режиме ожидания accept
. Если
указать 0, то любому, кто будет пытаться соединиться с нашим сервером до
того момента, как он вызовет accept
, в этом соединении будет автоматически
отказано.
UDP-клиент
Отправляет пакет по адресу 12.34.56.78
на порт 90
.
import socket
sock = socket.socket(type=socket.SOCK_DGRAM)
sock.sendto(b'Hello', ('12.34.56.78', 90) )
При желании можно попытаться указать обратные адрес и порт при помощи
метода bind
. Вполне вероятно, что далеко не все возможные варианты
понравятся ОС.
Чтобы указать произвольные адрес и порт, нужно создать сокет типа
socket.SOCK_RAW
, который вообще убирает из пакета TCP/UDP-заголовок,
после чего приписать к своим данным правильный UDP-заголовок вручную
(ниже будет приведён пример).
UDP-сервер
Ожидает пакет на порт 1234
.
import socket
sock = socket.socket(type=socket.SOCK_DGRAM)
sock.bind( ('', 1234) )
msg, addr = sock.recvfrom(65536)
Длины сообщения 65536 хватает на любой UDP-пакет.
Внимание! В отличие от TCP, len(msg)
может быть равно 0 даже
при успешном получении сообщения. Впрочем, той ситуации, о которой
сигнализировало len(msg) == 0
в случае TCP (разрыв соединения),
в случае UDP не бывает вообще, так как UDP не предполагает никакого
«соединения».
Хитрый UDP-клиент
Если уж очень хочется явно указать порт (или даже адрес) отправителя при отправке UDP-пакета, то это можно сделать при помощи следующего кода:
## Обработка ошибок намеренно опущена, дабы не загромождать код.
import socket
def word16be(number):
return bytes([number//256, number%256])
def udp_send(sock, src_addr, src_port, dst_addr, dst_port, msg):
src_addr_bytes = bytes([int(x) for x in src_addr.split('.')])
dst_addr_bytes = bytes([int(x) for x in dst_addr.split('.')])
ip_header = (
bytes([4*16 + 5, 0]) +
word16be(ip_length) +
word16be(0) +
word16be(0) +
bytes([255, 17, 0, 0]) +
src_addr_bytes +
dst_addr_bytes
)
if len(ip_header) != 20:
raise RuntimeError("Wrong address length")
udp_length = len(msg) + 8
if udp_length > 65515:
raise RuntimeError("Message too long")
udp_header = (
word16be(src_port) +
word16be(dst_port) +
word16be(udp_length) +
word16be(0)
)
sock.sendto(ip_header+udp_header+msg, (dst_addr, dst_port))
foo = socket.socket(type=socket.SOCK_RAW, proto=socket.IPPROTO_RAW)
udp_send(foo, '123.45.67.89', 4321, '127.0.0.1', 1234, b'Hello, world!')
Можно при создании сокета указать proto=socket.IPPROTO_UDP
. Тогда
IP-заголовок будет сформирован автоматически (соответственно, его
не нужно будет добавлять к сообщению), но не будет возможности
указать произвольный адрес отправителя.
Также следует учитывать, что успешно выполнить такую программу можно, лишь имея соответствующие права (например, права администратора).
Домашнее задание
В файле реализован простейший TCP-сервер, отправляющий все данные, ему пришедшие, на стандартный выход в человекочитаемом виде.
Требуется:
- добавить к нему возможность перенаправления всех полученных им данных на указанный адрес (либо предварительно установив с этим адресом соединение, либо устанавливая соединение с этим адресом при каждом присоединении нового клиента)
- как более сложное задание: добавить перенаправление ответа, полученного с того адреса, с которым мы соединились, присоединённому клиенту, тем самым реализуя полноценный Man-in-the-middle (делаем вид, что мы — тот сервис, с которым хочет соединиться клиент, но при этом читаем весь трафик и, если нужно, его подменяем)