Что вообще значит «прослушивать порт»?
В углу здания студенческого клуба есть кофейня, и в углу этой кофейни сидят два студента. Лиз стучит по клавиатуре потрёпанного древнего 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
, выводя следующее:
(<socket.socket fd=4, family=AddressFamily.AF_INET,
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 <module>
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 <module>
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-сервер к сроку?»
Лиз пожала плечами: «У меня ещё есть сегодня время».
Тим покачал головой, как недовольный родитель.
Лиз закатила глаза и сказала: «Беги, Тим».
«В то же время на следующей неделе?»
«Ага».