[Перевод] Что вообще значит «прослушивать порт»?

image-loader.svg


В углу здания студенческого клуба есть кофейня, и в углу этой кофейни сидят два студента. Лиз стучит по клавиатуре потрёпанного древнего MacBook, который ей подарил брат перед отъездом в колледж. Слева от неё на диване Тим пишет уравнения в пружинном блокноте. Между ними стоит наполовину пустая кружка с кофе комнатной температуры, из которой Лиз время от времени потягивает напиток, чтобы не заснуть.

На другом конце кофейни бариста оторвался от своего телефона, чтобы окинуть взглядом помещение. Один наушник вставлен в ухо, второй висит, телефон воспроизводит видео с заданием, выданным на лекции по кинематографии. В кофейне, где работают студенты, есть неписанное правило: в ночную смену сотрудники могут пользоваться длинными промежутками между клиентами, чтобы выполнять домашние задания. Кроме Тима и Лиз в помещении по одиночке сидит ещё пара студентов, уже много часов сосредоточенно смотрящих в свои ноутбуки. Вся остальная часть кофейни пуста.

Тим останавливается на половине строки, вырывает лист из блокнота, комкает его и кладёт рядом с небольшой горкой других скомканных листов.
«Чёрт, а сколько времени?», — спрашивает он.

Лиз переводит взгляд на часы на экране ноутбука. «Два с небольшим».

Тим зевает и начинает писать с начала новой страницы, но Лиз его прерывает.

«Тим».

«Что?», — отвечает Тим, преувеличенно демонстрируя своё раздражение от того, что его прервали, когда он только начал писать.

«Что значит «прослушивать порт»?»

«Хм».

«Мне нужно написать веб-сервер для курса net», — Лиз сокращает полное название курса Computer Networks 201, который Тим прошёл в прошлом семестре.

«Ага, помню такое».

«И я слушаю соединения к порту».

«Порт 80», — уверенно отвечает Тим, надеясь прервать разговор, опередив её вопрос.

«На самом деле, мы должны прослушивать 8080, чтобы он мог работать без рута, но я не об этом».

«Ну ладно, тогда о чём?»

«Что значит прослушивание порта?»

«Это значит, что другие процессы могут подключаться к серверу по этому порту», — похоже, Тима этот вопрос сбил с толку.

«Да, это я знаю, но как

Прежде чем ответить, Тим несколько секунд размышляет.

«Наверно, у операционной системы есть большая таблица портов и слушающих их процессов. Когда ты привязываешься к порту, она помещает указатель на твой сокет в эту таблицу».

«Ага, наверно», — отвечает Лиз нерешительно.

Каждый из них возвращается к своей работе. Спустя какое-то время тишину прерывает победоносное тихое «Да!» Тима, он зачёркивает номер на распечатанном листе бумаги. Он наконец нашёл доказательство, которое с трудом искал для задачи по матанализу.

Лиз воспользовалась возможностью снова привлечь его внимание.

«Смотри, Тим, я запустила два процесса, одновременно привязанные к одному порту».

Она разворачивает два окна с кодом на Python:

# server1.py
import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('127.0.0.1', 8080))
sock.listen()
print(sock.accept())


И рядом с ним:

# server2.py
import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(('127.0.0.1', 8080))
print(sock.recv(1024))


Потом она показывает, что обе программы работают в отдельных окнах терминала через shell-подключение к Debian-серверу университета cslab3.

Тим разворачивает ноутбук к себе. Открывает третий терминал, на секунду останавливается, освежая воспоминания в своём усталом мозгу, и вводит netcat 127.0.0.1 8080.

netcat запускается и мгновенно завершается. В другом окне терминала завершается запущенная программа python server1.py, выводя следующее:

(type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 8080),
raddr=('127.0.0.1', 59558)>, ('127.0.0.1', 59558))

Он изучает код server1.py, рассуждая вслух.

«Итак, сервер выполняет привязку к порту, принимает первый сокет для подключения, а затем выполняет выход. Понятно, значит выведенный на экран кортеж был результатом вызова accept, после чего выполняется выход. Но теперь (он наводит курсор на редактор с кодом server2.py) этот код вообще что-то прослушивает?»

Он снова запускает netcat 127.0.0.1 8080 -v в том же терминале, что и раньше, и в нём выводится следующее:

netcat: connect to 127.0.0.1 port 8080 (tcp) failed: Connection refused

«Видишь», — говорит он — «в твоём коде баг. server2 по-прежнему запущен, но ты не вызываешь listen. На самом деле он ничего не делает с портом 8080».

«Да нет, точно делает», — отвечает Лиз, хватая ноутбук.

Она добавляет -u в конец команды netcat и нажимает Enter. На этот раз она не выдаёт ошибку и не выходит сразу же, а ждёт ввода с клавиатуры. Раздражённая тем, что Тим сразу предположил наличие бага в её коде, она набирает timmy, зная, что это имя его бесит.

Сессия netcat завершается без вывода и одновременно программа python server2.py завершает работу, выведя:

b'timmy\n'

Тим замечает попытку Лиз поддеть его, но игнорирует её, не желая доставлять ей удовольствие. Он тянется к клавиатуре. Лиз поворачивает ноутбук к нему и он вводит man netcat, чтобы открыть документацию по команде netcat, в которой этот инструмент описывается как «швейцарский армейский нож для TCP/IP». Он доходит до флага -u, который в документации кратко описан как «UDP mode».

«Ага», — говорит он, вспомнив. «Понял, server1 слушает по TCP, а server2 слушает по UDP. Наверно, это и означает SOCK_DGRAM. То есть это разные протоколы. Думаю, у операционной системы есть отдельные таблицы для каждого. Кажется, тему UDP в курсе net проходят позже».

«Ага, я прочитала учебник заранее».

«Естественно. Как это у тебя есть время читать лишние темы, но нет времени выполнять задания так, чтобы не пришлось делать их ночью перед сдачей?»

«Могу задать тебе тот же вопрос про Counter Strike», — парирует Лиз.

Тим ворчит.

Они снова начинают работать в тишине, но спустя несколько минут Лиз её прерывает.

«Тим, посмотри-ка. Я могу прослушивать один порт двумя процессами, даже если они оба TCP».

Тим отвлекается от своей работы. На этот раз на экране у Лиз всего одна программа на Python, запущенная в двух терминалах:

# server3.py
import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
sock.bind(('127.0.0.1', 8080))
sock.listen()
print(sock.accept())


Лиз объясняет: «Видишь, эта команда показывает, что процесс прослушивает порт». Она вводит lsof -i:8080 и нажимает на Enter.

Программа выводит следующее:

> lsof -i:8080
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
python3 174265 liz 3u IPv4 23850797 0t0 TCP localhost:http-alt (LISTEN)
python3 174337 liz 3u IPv4 23853188 0t0 TCP localhost:http-alt (LISTEN)

«Что произойдёт, если к нему подключиться?», — спрашивает Тим, на этот раз с долей интереса.

«Смотри».

Лиз один раз выполняет netcat localhost 8080, и один из процессов сервера завершается, а второй продолжает работать. При повторном выполнении команды завершается и другой процесс.

Тим начинает изучать код и водит пальцем по экрану, чтобы читать его. Лиз ненавидит заляпанный экран, поэтому говорит «аккуратно!» и отталкивает его руку. «Я не буду касаться», — возражает Тим. Держа руку на подчёркнуто безопасном расстоянии, он указывает на строку с setsockopt и спрашивает: «А это что ещё за магия?»

«Здесь мы задаём опцию сокета, позволяющую многократно использовать порт».

«Хм, это есть в учебнике?»

«Не знаю, нашла это на Stack Overflow».

«Не думал, что можно так использовать порт несколько раз».

«Я тоже», — она остановилась и подумала. «То есть в операционной системе не может быть просто таблица портов к сокетам, это должна быть таблица портов к списку сокетов. И ещё одна для UDP. И, возможно, для других протоколов».

«Ага, вроде логично», — соглашается Тим.

«Хм-м-м», — говорит Лиз, внезапно её голос стал менее уверенным.

«Что?»

«Ой, да ладно», — отвечает она и начинает сосредоточенно что-то печатать.

Тим возвращается к своему заданию, и спустя несколько минут он перечёркивает ещё один вопрос. Он почти закончил, и его поза становиться более расслабленной. Лиз наклоняет к нему ноутбук и говорит: «Зацени». Она показывает ему две программы.

# server4.py
import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('127.0.0.2', 8080))
sock.listen()
print(sock.accept())
# server5.py
import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('127.0.0.3', 8080))
sock.listen()
print(sock.accept())


«Разве они не одинаковые?», — спрашивает Тим после изучения кода.

«Посмотри на IP привязки».

«А, так ты слушаешь один порт, но два разных IP. И это работает?»

«Похоже, да. И я могу подключиться к обоим».

Лиз выполнила netcat 127.0.0.2, а затем netcat 127.0.0.3.

Тим задумался. «Так, посмотрим. У операционной системы должна быть таблица от каждого сочетания порта и IP к сокету. Хотя нет, на самом деле, две: одна для TCP и одна для UDP».

«Ага», — кивнула Лиз. «И это может быть не один, а много сокетов. Но посмотри». Она меняет IP в коде сервера на 0.0.0.0.

# server6.py
import socket

sock socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('0.0.0.0', 8080))
sock.listen()
print(sock.accept())


«Теперь когда я запускаю сервер, привязанный к 127.0.0.2, то получаю следующее», — продолжает она.

Traceback (most recent call last):
File "server5.py", line 4, in
s.bind(('127.0.0.2', 8080))
OSError: [Errno 99] Cannot assign requested address

«Но если я выполню netcat 127.0.0.2 8080, то подключение к серверу будет по адресу 0.0.0.0».

«Ну да, 0.0.0.0 означает «привязаться ко всем локальным IP», разве в лекции об этом не говорили? И адреса, начинающиеся с 127. — это локальные IP loopback-а, поэтому логично, что они привязаны именно так».

«Ага, но как это работает? Есть примерно 16 миллионов IP, начинающихся с 127.. Операционная система ведь не создаёт большую таблицу со всеми этими адресами?»

«Думаю, нет», — у него не было ответа, поэтому он решил сменить тему. «Ну ладно, а как там дела с твоим HTTP-сервером?», — вопрос риторический, ведь он знает, что Лиз ещё не написала ни одной строки самого задания.

Она отвечает что-то неопределённое, потому что её уже поглотил другой эксперимент.

Прошло ещё немного времени. Завершив свою работу, Тим поглядывает на время на своём телефоне. Он подумывает, не пойти ли домой, к своему неровному матрасу в общежитии. Он решил, что диван кофейни такой же удобный и откинул голову на его высокую спинку.

Полусонный, он смотрит на потолок, и тут Лиз толкает его и говорит: «Тим, посмотри».

Она показывает ему ещё одну программу:

# server7.py
import socket

sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
sock.bind(('::', 8080))
sock.listen()
print(sock.accept())


«Зацени. Это IPv6-сервер».

Тим зевнул и придвинулся. К тому времени утреннее солнце начало светить через окно на диван, на котором они сидели. Два других студента незаметно вышли из кофейни в первые утренние часы и пришёл первый дневной клиент, ожидавший своего кофе на вынос.

«Что это за двоеточия?», — спросил Тим.

«Это краткая запись восьми нулей в IPv6, они означают то же, что и 0.0.0.0 в IPv4».

«То есть этот код приказывает прослушивать все локальные IP IPv6? Так работает IPv6?»

«Ну да, по сути, так».

Она ввела netcat "::1" 8080 -v и объяснила, что ::1 — это loopback-адрес в IPv6.

«То есть типа 127.0.0.1 для обычных IP».

«IPv4. Да, именно. Но посмотри сюда. По данным lsof, я слушаю только по IPv6, видишь?», — Лиз выполнила lsof -i :8080, и команда вывела одну строку

COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
python3 455017 liz 3u IPv6 25152485 0t0 TCP *:http-alt (LISTEN)

«Но я могу подключиться к нему через IP IPv4».

netcat 127.0.0.1 8080 -v

«Хм, а наоборот? Можно подключиться к IPv4-серверу с IP IPv6?»

«Неа, смотри».

Она запустила python3 server6.py, а затем netcat "::1" 8080 -v, получив такой результат:

netcat: connect to ::1 port 8080 (tcp) failed: Connection refused

Тим спросил: «А что будет, если попробовать прослушать IPv6 по 8080, когда IPv4-сервер продолжает работать?»

Лиз показала ему, запустив python server7.py.

Traceback (most recent call last):
File "server7.py", line 4, in
s.bind(('::', 8080))
OSError: [Errno 98] Address already in use

«Но посмотри», — сказала она, открыв код ещё одной программы.

# server8.py
import socket

sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
sock.bind(('::', 8080))
sock.listen()
print(sock.accept())


Она показала на строку с setsockopt, объяснив: «Если я добавляю это, то могу слушать по IPv6 и IPv4 через один порт из разных процессов».

Она запустила python server8.py, а затем lsof -i :8080.

COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
python3 460409 liz 3u IPv6 25188010 0t0 TCP *:http-alt (LISTEN)
python3 460813 liz 3u IPv4 25191765 0t0 TCP *:http-alt (LISTEN)

Тим подвёл итог тому, что ему показала Лиз. «То есть при прослушивании порта ты на самом деле слушает комбинацию порта, IP-адреса, протокола и версии IP? »

«Да, только если ты не прослушиваешь все локальные IP. И если ты прослушиваешь все IP IPv6 ты одновременно прослушиваешь все IP IPv4, если только специально не откажешься от этого перед вызовом bind».

«Понятно. То есть у операционной системы должна быть какая-то hash map от пары порта и IP к сокету для каждой комбинации TCP или UDP, IPv4 или IPv6».

«К списку сокетов», — поправила его Лиз. «Помнишь, что я могла прослушивать несколько сокетов?»

«А, ну да».

«Но ей нужно ещё и обрабатывать прослушивание всех «домашних» IP, и иметь возможность находить сокет, прослушивающий IPv6 с IP IPv4».

«Ну ладно, мне надо это сдать», — сказал Тим, показав на кучу листов бумаги. «А ты закончишь свой HTTP-сервер к сроку?»

Лиз пожала плечами: «У меня ещё есть сегодня время».

Тим покачал головой, как недовольный родитель.

Лиз закатила глаза и сказала: «Беги, Тим».

«В то же время на следующей неделе?»

«Ага».

© Habrahabr.ru