Когда один TCP-порт может быть поделён

от автора

Вы замечали, как простые вопросы иногда приводят к сложным вопросам? Сегодня мы попытаемся подступиться к одному из таких вопросов. Категория – наша любимая: сетевые аспекты Linux.

Когда два TCP-сокета могут разделять локальный адрес?

Если я перейду по ссылке https://blog.cloudflare.com/, мой браузер подключится к удалённому TCP-адресу, в данном случае это может быть 104.16.132.229:443. Подключение пойдёт с локального IP-адреса, присвоенного моей машине с Linux, через выбранный случайным образом локальный TCP-порт, скажем, 192.0.2.42:54321. Что произойдёт, если я затем решу отправиться на другой сайт? Возможно ли установить другое TCP-соединение с того же локального IP-адреса и порта?

Чтобы найти ответ на этот вопрос, давайте выясним его опытным путём. Мы подготовили для вас восемь вопросов-задачек. Ответив на каждый из них, вы откроете для себя один из аспектов тех правил, что регулируют разделение локальных адресов между TCP-сокетами под Linux. Честно предупреждаем: от этого материала может зайти ум за разум.

Вопросы разделены на две группы в зависимости от тестового сценария:

В первом сценарии два сокета подключаются с одного и того же локального порта на одни и те же удалённые IP и порт. Однако локальный IP для обоих сокетов отличается.

В свою очередь, во втором сценарии локальный IP и порт одинаковы для всех сокетов, но отличается удалённый адрес или, в сущности, только IP-адрес.

Отвечая на поставленные вопросы, мы будем делать одно из двух:

1.      Позволять ОС автоматически выбирать локальный IP и/или порт для сокета или

2.      Явно присваивать локальный адрес при помощи bind(), прежде, чем подключиться (connect()) к сокету; такой метод также называется bind-before-connect.

Поскольку мы будем исследовать пограничные случаи, связанные с логикой bind(), нам нужно каким-то способом исчерпать все локальные адреса, то есть, пары (IP, порт). Можно было бы банально создать много сокетов, но проще было бы подкрутить конфигурацию системы и предположить, что на машине есть всего один локальный эфемерный порт, который ОС может присваивать сокетам:  

sysctl -w net.ipv4.ip_local_port_range='60000 60000'

В каждом вопросе-загадке вас ждёт краткий листинг на Python. Ваша задача – спрогнозировать, каков будет итог выполнения кода. Выполнится ли код успешно? Или нет? Спрашивать ChatGPT не разрешается ?.

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

from os import system from socket import *  # Недостающие константы IP_BIND_ADDRESS_NO_PORT = 24  # В пространстве имён нашей сети всего *один* эфемерный порт system("sysctl -w net.ipv4.ip_local_port_range='60000 60000'")  # Открываем слушающий сокет по *:1234. Именно к нему будем подключаться. ln = socket(AF_INET, SOCK_STREAM) ln.bind(("", 1234)) ln.listen(SOMAXCONN)

Избавившись от этих формальностей, начнём. На старт, внимание, марш!

Сценарий #1: Когда локальный IP уникален, но локальный порт не меняется

В сценарии #1 мы подключаем два сокета к одному и тому же удалённому адресу: 127.9.9.9:1234. Эти сокеты будут использовать отличающиеся локальные IP-адреса, но достаточно ли этого для разделения локального порта?

Локальный IP

Локальный порт

Удалённый IP

Удалённый порт

уникальный

одинаковый

одинаковый

Одинаковый

127.0.0.1
127.1.1.1
127.2.2.2

60_000

127.9.9.9

1234

Загадка #1

На стороне локальной машины привязываем два сокета к разным, явно указанным IP-адресам. Позволяем ОС выбирать локальный порт. Помните: в диапазоне локальных эфемерных портов на нашей машине содержится всего один порт (60 000).

s1 = socket(AF_INET, SOCK_STREAM) s1.bind(('127.1.1.1', 0)) s1.connect(('127.9.9.9', 1234)) s1.getsockname(), s1.getpeername()  s2 = socket(AF_INET, SOCK_STREAM) s2.bind(('127.2.2.2', 0)) s2.connect(('127.9.9.9', 1234)) s2.getsockname(), s2.getpeername()

А ВОТ Ответ #1

#!/usr/bin/env -S unshare --user --map-root-user --net -- strace -e %net -- python  """ Quiz #1 -------  >>> s1 = socket(AF_INET, SOCK_STREAM) >>> s1.bind(('127.1.1.1', 0)) >>> s1.connect(('127.9.9.9', 1234)) >>> s1.getsockname(), s1.getpeername() (('127.1.1.1', 60000), ('127.9.9.9', 1234)) >>> >>> s2 = socket(AF_INET, SOCK_STREAM) >>> s2.bind(('127.2.2.2', 0)) >>> s2.connect(('127.9.9.9', 1234)) >>> s2.getsockname(), s2.getpeername() (('127.2.2.2', 60000), ('127.9.9.9', 1234)) >>>  Outcome: SUCCESS. Local port is shared.  Cleanup:  >>> s1.close() >>> s2.close() """  from quiz_common import run_doctest from socket import *  if __name__ == "__main__":     run_doctest(__name__)

Загадка #2

Здесь постановка такая же, как и в предыдущей загадке. Однако мы запрашиваем ОС выбрать локальный IP-адрес и порт для первого сокета. Как вы думаете, результат будет отличаться от полученного в предыдущей загадке?

s1 = socket(AF_INET, SOCK_STREAM) s1.connect(('127.9.9.9', 1234)) s1.getsockname(), s1.getpeername()  s2 = socket(AF_INET, SOCK_STREAM) s2.bind(('127.2.2.2', 0)) s2.connect(('127.9.9.9', 1234)) s2.getsockname(), s2.getpeername()

А ВОТ Ответ #2.

#!/usr/bin/env -S unshare --user --map-root-user --net -- strace -e %net -- python  """ Quiz #2 -------  >>> s1 = socket(AF_INET, SOCK_STREAM) >>> s1.connect(('127.9.9.9', 1234)) >>> s1.getsockname(), s1.getpeername() (('127.0.0.1', 60000), ('127.9.9.9', 1234)) >>> >>> s2 = socket(AF_INET, SOCK_STREAM) >>> s2.bind(('127.2.2.2', 0)) >>> s2.connect(('127.9.9.9', 1234)) >>> s2.getsockname(), s2.getpeername() (('127.2.2.2', 60000), ('127.9.9.9', 1234)) >>>  Outcome: SUCCESS. Local port is shared.  Cleanup:  >>> s1.close() >>> s2.close() """  from quiz_common import run_doctest from socket import *  if __name__ == "__main__":     run_doctest(__name__)

Загадка #3

Загадка сформулирована точно, как и предыдущая. Мы просто поменяли порядок. Сначала мы подключаемся к сокету с явно указанного локального адреса. Затем приказываем системе выбрать для нас локальный адрес. Очевидно, при таком изменении порядка действий никаких изменений произойти не должно, верно?

s1 = socket(AF_INET, SOCK_STREAM) s1.bind(('127.1.1.1', 0)) s1.connect(('127.9.9.9', 1234)) s1.getsockname(), s1.getpeername()  s2 = socket(AF_INET, SOCK_STREAM) s2.connect(('127.9.9.9', 1234)) s2.getsockname(), s2.getpeername()

А ВОТ Ответ #3.

#!/usr/bin/env -S unshare --user --map-root-user --net -- strace -e %net -- python  """ Quiz #3 -------  >>> s1 = socket(AF_INET, SOCK_STREAM) >>> s1.bind(('127.1.1.1', 0)) >>> s1.connect(('127.9.9.9', 1234)) >>> s1.getsockname(), s1.getpeername() (('127.1.1.1', 60000), ('127.9.9.9', 1234))  >>> s2 = socket(AF_INET, SOCK_STREAM) >>> s2.connect(('127.9.9.9', 1234)) Traceback (most recent call last):   ... OSError: [Errno 99] Cannot assign requested address  Outcome: FAILURE. Local port can't be shared.  Cleanup:  >>> p1, _ = ln.accept() >>> p1.close() # TIME-WAIT on server side  >>> s1.close() >>> s2.close()  Solution #3 -----------  Use `IP_BIND_ADDRESS_NO_PORT` socket option.  >>> s1 = socket(AF_INET, SOCK_STREAM) >>> s1.setsockopt(SOL_IP, IP_BIND_ADDRESS_NO_PORT, 1) >>> s1.bind(('127.1.1.1', 0)) >>> s1.connect(('127.9.9.9', 1234)) >>> s1.getsockname(), s1.getpeername() (('127.1.1.1', 60000), ('127.9.9.9', 1234)) >>> >>> s2 = socket(AF_INET, SOCK_STREAM) >>> s2.connect(('127.9.9.9', 1234)) >>> s2.getsockname(), s2.getpeername() (('127.0.0.1', 60000), ('127.9.9.9', 1234)) >>>  Outcome: SUCCESS. Local port is shared when using `IP_BIND_ADDRESS_NO_PORT` option.  Cleanup:  >>> s1.close() >>> s2.close() """  from quiz_common import run_doctest from socket import *  if __name__ == "__main__":     run_doctest(__name__)

Сценарий #2: когда локальный IP и порт одинаковы, но удалённый IP отличается

В сценарии #2 рассмотрим обратную постановку. Теперь у нас будет не множество локальных IP и один удалённый адрес, а один локальный адрес 127.0.0.1:60000 и два разных удалённых адреса. Всё тот же вопрос: могут ли два сокета совместно использовать локальный порт? Напоминаю: диапазон эфемерных портов у нас по-прежнему равен единице.

Локальный IP

Локальный порт

Удалённый IP

Удалённый порт

одинаковый

одинаковый

уникальный

одинаковый

127.0.0.1

60_000

127.8.8.8
127.9.9.9

1234

Загадка #4

Начнём с азов. Мы подключаемся (connect()) к двум разным удалённым адресам. Это для затравки ?

s1 = socket(AF_INET, SOCK_STREAM) s1.connect(('127.8.8.8', 1234)) s1.getsockname(), s1.getpeername()  s2 = socket(AF_INET, SOCK_STREAM) s2.connect(('127.9.9.9', 1234)) s2.getsockname(), s2.getpeername()

А ВОТ Ответ #4.

#!/usr/bin/env -S unshare --user --map-root-user --net -- strace -e %net -- python  """ Quiz #4 -------  >>> s1 = socket(AF_INET, SOCK_STREAM) >>> s1.connect(('127.8.8.8', 1234)) >>> s1.getsockname(), s1.getpeername() (('127.0.0.1', 60000), ('127.8.8.8', 1234)) >>> >>> s2 = socket(AF_INET, SOCK_STREAM) >>> s2.connect(('127.9.9.9', 1234)) >>> s2.getsockname(), s2.getpeername() (('127.0.0.1', 60000), ('127.9.9.9', 1234)) >>>  Outcome: SUCCESS. Local (IP, port) is shared.  Cleanup:  >>> s1.close() >>> s2.close() """  from quiz_common import run_doctest from socket import *  if __name__ == "__main__":     run_doctest(__name__)

Загадка #5

Что, если явно связаться (bind()) с локальным IP, но позволить ОС самой выбрать порт – изменится ли от этого что-либо?

s1 = socket(AF_INET, SOCK_STREAM) s1.bind(('127.0.0.1', 0)) s1.connect(('127.8.8.8', 1234)) s1.getsockname(), s1.getpeername()  s2 = socket(AF_INET, SOCK_STREAM) s2.bind(('127.0.0.1', 0)) s2.connect(('127.9.9.9', 1234)) s2.getsockname(), s2.getpeername()

А ВОТ Ответ #5.

#!/usr/bin/env -S unshare --user --map-root-user --net -- strace -e %net -- python  """ Quiz #5 -------  >>> s1 = socket(AF_INET, SOCK_STREAM) >>> s1.bind(('127.0.0.1', 0)) >>> s1.connect(('127.8.8.8', 1234)) >>> s1.getsockname(), s1.getpeername() (('127.0.0.1', 60000), ('127.8.8.8', 1234)) >>> >>> s2 = socket(AF_INET, SOCK_STREAM) >>> s2.bind(('127.0.0.1', 0)) Traceback (most recent call last):   ... OSError: [Errno 98] Address already in use >>>  Outcome: FAILURE. Local (IP, port) can't be shared.  Cleanup:  >>> p1, _ = ln.accept() >>> p1.close() # TIME-WAIT on server side >>> s1.close() >>> s2.close()  Solution #5 -----------  Use `IP_BIND_ADDRESS_NO_PORT` socket option.  >>> s1 = socket(AF_INET, SOCK_STREAM) >>> s1.setsockopt(SOL_IP, IP_BIND_ADDRESS_NO_PORT, 1) >>> s1.bind(('127.0.0.1', 0)) >>> s1.connect(('127.8.8.8', 1234)) >>> s1.getsockname(), s1.getpeername() (('127.0.0.1', 60000), ('127.8.8.8', 1234)) >>> >>> s2 = socket(AF_INET, SOCK_STREAM) >>> s2.setsockopt(SOL_IP, IP_BIND_ADDRESS_NO_PORT, 1) >>> s2.bind(('127.0.0.1', 0)) >>> s2.connect(('127.9.9.9', 1234)) >>> s2.getsockname(), s2.getpeername() (('127.0.0.1', 60000), ('127.9.9.9', 1234)) >>>  Outcome: SUCCESS. Local (IP, port) is shared when using `IP_BIND_ADDRESS_NO_PORT`.  Cleanup:  >>> s1.close() >>> s2.close() """  from quiz_common import run_doctest from socket import *  if __name__ == "__main__":     run_doctest(__name__)

Загадка #6

На этот раз мы явно укажем локальный адрес и порт. Иногда необходимо указывать локальный порт.

s1 = socket(AF_INET, SOCK_STREAM) s1.bind(('127.0.0.1', 60_000)) s1.connect(('127.8.8.8', 1234)) s1.getsockname(), s1.getpeername()  s2 = socket(AF_INET, SOCK_STREAM) s2.bind(('127.0.0.1', 60_000)) s2.connect(('127.9.9.9', 1234)) s2.getsockname(), s2.getpeername()

А ВОТ Ответ #6.

#!/usr/bin/env -S unshare --user --map-root-user --net -- strace -e %net -- python  """ Quiz #6 -------  >>> s1 = socket(AF_INET, SOCK_STREAM) >>> s1.bind(('127.0.0.1', 60_000)) >>> s1.connect(('127.8.8.8', 1234)) >>> s1.getsockname(), s1.getpeername() (('127.0.0.1', 60000), ('127.8.8.8', 1234)) >>> >>> s2 = socket(AF_INET, SOCK_STREAM) >>> s2.bind(('127.0.0.1', 60_000)) Traceback (most recent call last):   ... OSError: [Errno 98] Address already in use >>>  Outcome: FAILURE. Local (IP, port) can't be shared.  Cleanup:  >>> p1, _ = ln.accept() >>> p1.close() # TIME-WAIT on server side >>> s1.close() >>> s2.close() >>>  Solution #6 -----------  Use `SO_REUSEADDR` socket option.  >>> s1 = socket(AF_INET, SOCK_STREAM) >>> s1.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) >>> s1.bind(('127.0.0.1', 60_000)) >>> s1.connect(('127.8.8.8', 1234)) >>> s1.getsockname(), s1.getpeername() (('127.0.0.1', 60000), ('127.8.8.8', 1234)) >>> >>> s2 = socket(AF_INET, SOCK_STREAM) >>> s2.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) >>> s2.bind(('127.0.0.1', 60_000)) >>> s2.connect(('127.9.9.9', 1234)) >>> s2.getsockname(), s2.getpeername() (('127.0.0.1', 60000), ('127.9.9.9', 1234)) >>>  Outcome: SUCCESS. Local (IP, port) is shared when using `SO_REUSEADDR`.  Cleanup:  >>> s1.close() >>> s2.close() """  from quiz_common import run_doctest from socket import *  if __name__ == "__main__":     run_doctest(__name__)

Загадка #7

На случай, если вы полагали, что страньше быть уже не может – давайте добавим в эту смесь SO_REUSEADDR.

Сперва запрашиваем ОС, чтобы она выделила для нас локальный адрес. Затем
явно свяжемся с тем же локальным адресом, который, как мы уже знаем, ОС обязана
была присвоить первому сокету. Мы включим возможность переиспользовать локальный
адрес для обоих сокетов. Это разрешено?

s1 = socket(AF_INET, SOCK_STREAM) s1.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) s1.connect(('127.8.8.8', 1234)) s1.getsockname(), s1.getpeername()  s2 = socket(AF_INET, SOCK_STREAM) s2.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) s2.bind(('127.0.0.1', 60_000)) s2.connect(('127.9.9.9', 1234)) s2.getsockname(), s2.getpeername()

А ВОТ Ответ #7.

#!/usr/bin/env -S unshare --user --map-root-user --net -- strace -e %net -- python  """ Quiz #7 -------  >>> s1 = socket(AF_INET, SOCK_STREAM) >>> s1.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) >>> s1.connect(('127.8.8.8', 1234)) >>> s1.getsockname(), s1.getpeername() (('127.0.0.1', 60000), ('127.8.8.8', 1234)) >>> >>> s2 = socket(AF_INET, SOCK_STREAM) >>> s2.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) >>> s2.bind(('127.0.0.1', 60_000)) >>> s2.connect(('127.9.9.9', 1234)) >>> s2.getsockname(), s2.getpeername() (('127.0.0.1', 60000), ('127.9.9.9', 1234)) >>>  Outcome: SUCCESS. Local (IP, port) is shared.  Cleanup:  >>> s1.close() >>> s2.close() """  from quiz_common import run_doctest from socket import *  if __name__ == "__main__":     run_doctest(__name__)

Загадка #8

Наконец, вишенка на торте. Эта загадка – точно как и #7, но наоборот. Здравый смысл не оставляет сомнений, что ответ должен быть точно как в #7, но так ли это?

s1 = socket(AF_INET, SOCK_STREAM) s1.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) s1.bind(('127.0.0.1', 60_000)) s1.connect(('127.9.9.9', 1234)) s1.getsockname(), s1.getpeername()  s2 = socket(AF_INET, SOCK_STREAM) s2.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) s2.connect(('127.8.8.8', 1234)) s2.getsockname(), s2.getpeername()

А ВОТ Ответ #8.

#!/usr/bin/env -S unshare --user --map-root-user --net -- strace -e %net -- python  """ Quiz #8 -------  >>> s1 = socket(AF_INET, SOCK_STREAM) >>> s1.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) >>> s1.bind(('127.0.0.1', 60_000)) >>> s1.connect(('127.9.9.9', 1234)) >>> s1.getsockname(), s1.getpeername() (('127.0.0.1', 60000), ('127.9.9.9', 1234)) >>> >>> s2 = socket(AF_INET, SOCK_STREAM) >>> s2.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) >>> s2.connect(('127.8.8.8', 1234)) Traceback (most recent call last):   ... OSError: [Errno 99] Cannot assign requested address >>>  Outcome: FAILURE. Local (IP, port) can't be shared.  Cleanup:  >>> s1.close() >>> s2.close()  Solution #8 -----------  There is NONE.  """  from quiz_common import run_doctest from socket import *  if __name__ == "__main__":     run_doctest(__name__)

Тайная жизнь локального TCP-порта – в трёх состояниях

Сейчас всё понятно? Мы как будто занимаемся обратной разработкой чёрного ящика. Пожалуй, ничего не понятно, как и должно быть при обратной разработке черного ящика. Так что же происходит за кулисами? Давайте туда заглянем.

Linux отслеживает все находящиеся в использовании TCP-порты в хеш-таблице, которая называется bhash. Не перепутайте её с таблицей ehash; в ней отслеживаются сокеты, которым уже присвоены как локальные, так и удалённые адреса.

Каждая запись в хеш-таблице указывает на цепочку так называемых «корзин привязки» (bind bucket). В такой корзине группируются сокеты, совместно использующие один локальный порт. Точнее говоря, сокеты группируются в корзины по следующим признакам:

  • Сетевое пространство имён, к которым они относятся и

  • Устройство VRF, с которым они связаны, и

  • Номер локального порта, с которым они связаны.

Но простейшая возможная конфигурация — всего одно сетевое пространство имён, никаких VRF – можно сказать, что сокеты сгруппированы в корзине привязки по соответствующему им локальному номеру порта.

Набор сокетов в каждой корзине привязки, совместно использующих локальный порт, подкрепляется связным списком именованных обладателей.

Когда мы приказываем ядру присвоить локальный адрес сокету, наша задача
– проверить, не возникает ли конфликт с каким-либо существующим сокетом. Дело в
том, что разделять номер локального порта можно лишь при соблюдении определённых условий:

/* Есть несколько простых правил, следуя которым модно переиспользовать локальный порт   * в приложении.  В сущности:  *  *   1) Сокеты, связанные с разными интерфейсами, могут совместно использовать локальный порт   *      Если это условие не выполняется – переход к тесту 2.  *   2) Если у всех сокетов есть набор sk->sk_reuse, и ни один из них не находится в состоянии  *      TCP_LISTEN, допускается совместное использование порта.  *      Если это условие не выполняется – переход к тесту 3.  *   3) Если все сокеты связаны с конкретным локальным адресом inet_sk(sk)->rcv_saddr, и  * одинаковых среди них нет, то допускается совместное использование порта  *      Если это условие не выполняется, то порт совместно использовать нельзя.  *  * Здесь наиболее интересен тест #2.  Именно этим весь день и занимается FTP-сервер.  * Чтобы оптимизировать этот случай, мы используем конкретный флаговый бит, определённый ниже.    * Добавляя сокеты в список корзины привязки, мы проверяем:   * (newsk->sk_reuse && (newsk->sk_state != TCP_LISTEN))  * В случае, если все сокеты из корзины привязки пройдут этот тест,   * будет установлен флаговый бит.  * ...  */

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

struct inet_bind_bucket {         /* ... */         signed char          fastreuse;         signed char          fastreuseport;         kuid_t               fastuid; #if IS_ENABLED(CONFIG_IPV6)         struct in6_addr      fast_v6_rcv_saddr; #endif         __be32               fast_rcv_saddr;         unsigned short       fast_sk_family;         bool                 fast_ipv6_only;         /* ... */ };

Давайте обратим внимание только на первом совокупном свойстве — fastreuse. Оно существует в Linux со времён ныне доисторической версии 2.1.90pre1. Как указано в комментарии, исходно в форме флагового бита, а в процессе развития он превратился в поле, размер которого определяется в байтах.

Остальные шесть полей появились гораздо позже с введением SO_REUSEPORT в Linux 3.9. Ведь они играют роль только при наличии сокетов, у которых установлен флаг SO_REUSEPORT. В рамках этой статьи мы их проигнорируем.

Всякий раз, когда ядру Linux требуется привязать сокет к локальному порту, сначала нужно поискать корзину привязки для этого порта. Нам несколько усложняет жизнь следующий аспект: поиск TCP-корзины привязки в ядре осуществляется в двух точках ядра. Поиск корзины привязки может осуществляться заранее, во время — at bind() или поздно, во время at connect(). Которая из этих функций будет вызвана – зависит от того, как именно был настроен подключённый сокет:

Однако, оказываясь в inet_csk_get_port или __inet_hash_connect, нам всегда приходится обходить цепочку корзин в bhash, подыскивая корзину с совпадающим номером порта. Такая корзина может уже существовать, либо нам предварительно придётся её создать. Но, когда она возникнет, её поле fastreuse окажется в одном из трёх возможных состояний. Это -1, 0 или +1. Создаётся впечатление, будто Linux-разработчики вдохновлялись квантовой механикой.

Состояние отражает два аспекта корзины привязки:

1.      Какие сокеты находятся в корзине?

2.      Когда возможно совместное использование локального порта?

Поэтому давайте попробуем расшифровать три возможных состояния fastreuse и посмотрим, что они означают в каждом из случаев.

Во-первых, что нам говорит свойство fastreuse о владельцах корзины, то есть, о сокетах, использующих данный локальный порт?

Значение fastreuse

В списке владельцев содержатся

-1

Сокеты, подключённые (connect()) из эфемерного порта

0

Сокеты, привязанные без SO_REUSEADDR

+1

Сокеты, привязанные с SO_REUSEADDR

Это, конечно, не вся, но почти вся правда. Вскоре мы доберёмся до сути.

Когда же дело доходит до совместного использования портов, ситуация получается гораздо менее прямолинейной:

Можно ли, … когда …

fastreuse = -1

fastreuse = 0

fastreuse = +1

Связаться (bind()) с тем же самым портом (эфемерным или указанным)

Да, тогда и только тогда, когда локальный IP уникален ①

← idem

← idem

Связаться (bind()) с конкретным портом при помощи SO_REUSEADDR

Да, тогда и только тогда, когда локальный IP уникален ИЛИ конфликтующий сокет использует SO_REUSEADDR ①

← idem

да ②

Подсоединиться (connect()) с того же эфемерного порта к тому же удалённому (IP, порту)

Да, тогда и только тогда, когда локальный IP уникален ③

нет ③

нет ③

Подсоединиться (connect()) с того же эфемерного порта к уникальному удалённому (IP, порту)

да ③

нет ③

нет ③

① Определяется в зависимости от inet_csk_bind_conflict(), вызываемой из inet_csk_get_port() (привязка к конкретному порту) или inet_csk_get_port() → inet_csk_find_open_port() (привязка к эфемерному порту).

② Поскольку inet_csk_get_port() пропускает проверку конфликтов для fastreuse == 1 buckets.

③ Поскольку inet_hash_connect() → __inet_hash_connect() пропускает корзины с fastreuse != -1.

Притом, что на первый взгляд всё это выглядит довольно сложно, вышеприведённая таблица вполне сводима к двум утверждениям, которые должны быть верными – в таком виде она воспринимается немного проще:

  • bind() или раннее выделение локального адреса всегда проходит успешно при отсутствии каких-либо конфликтов между локальным IP-адресом и каким-либо из существующих сокетов,

  • connect() или позднее выделение локального адреса всегда оканчивается неудачей, в случае, если корзина TCP-привязки для локального порта находится в любом состоянии кроме fastreuse = -1,

  • connect() оканчивается успехом только при отсутствии каких-либо конфликтов между локальными и удалёнными адресами,

  • сокетная опция SO_REUSEADDR обеспечивает совместное использование локальных адресов, если эта опция также используется всеми конфликтующими сокетами (и ни один из них не находится в состоянии слушателя).

Это безумие. Я вам не верю

К счастью, вы и не обязаны. При помощи программируемого отладчика drgn можно проверить состояние корзины привязки прямо в действующем ядре:

#!/usr/bin/env drgn  """ dump_bhash.py - List all TCP bind buckets in the current netns.  Script is not aware of VRF. """  import os  from drgn.helpers.linux.list import hlist_for_each, hlist_for_each_entry from drgn.helpers.linux.net import get_net_ns_by_fd from drgn.helpers.linux.pid import find_task   def dump_bind_bucket(head, net):     for tb in hlist_for_each_entry("struct inet_bind_bucket", head, "node"):         # Пропустить корзины не из этой сети         if tb.ib_net.net != net:             continue          port = tb.port.value_()         fastreuse = tb.fastreuse.value_()         owners_len = len(list(hlist_for_each(tb.owners)))          print(             "{:8d}  {:{sign}9d}  {:7d}".format(                 port,                 fastreuse,                 owners_len,                 sign="+" if fastreuse != 0 else " ",             )         )   def get_netns():     pid = os.getpid()     task = find_task(prog, pid)     with open(f"/proc/{pid}/ns/net") as f:         return get_net_ns_by_fd(task, f.fileno())   def main():     print("{:8}  {:9}  {:7}".format("TCP-PORT", "FASTREUSE", "#OWNERS"))      tcp_hashinfo = prog.object("tcp_hashinfo")     net = get_netns()      # Перебрать все слоты bhash      for i in range(0, tcp_hashinfo.bhash_size):         head = tcp_hashinfo.bhash[i].chain         # Перебрать корзины привязки в слоте         dump_bind_bucket(head, net)   main()

Давайте испробуем этот скрипт и попробуем подтвердить вещи, подаваемые в таблице 1 как истинные. Учитывайте, что для получения приведённых ниже сеансовых сниппетов ipython —classic я пользовался той же конфигурацией, которая делалась для ответов на загадки.

Два подключённых сокета совместно используют эфемерный порт 60 000:

>>> s1 = socket(AF_INET, SOCK_STREAM) >>> s1.connect(('127.1.1.1', 1234)) >>> s2 = socket(AF_INET, SOCK_STREAM) >>> s2.connect(('127.2.2.2', 1234)) >>> !./dump_bhash.py TCP-PORT  FASTREUSE  #OWNERS     1234          0        3    60000         -1        2 >>>

Два привязанных сокета повторно используют порт 60 000:

>>> s1 = socket(AF_INET, SOCK_STREAM) >>> s1.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) >>> s1.bind(('127.1.1.1', 60_000)) >>> s2 = socket(AF_INET, SOCK_STREAM) >>> s2.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) >>> s2.bind(('127.1.1.1', 60_000)) >>> !./dump_bhash.py TCP-PORT  FASTREUSE  #OWNERS     1234          0        1    60000         +1        2 >>>

Смесь привязанных сокетов с активированной и активированной опцией REUSEADDR совместно использует порт 60 000:

>>> s1 = socket(AF_INET, SOCK_STREAM) >>> s1.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) >>> s1.bind(('127.1.1.1', 60_000)) >>> !./dump_bhash.py TCP-PORT  FASTREUSE  #OWNERS     1234          0        1    60000         +1        1 >>> s2 = socket(AF_INET, SOCK_STREAM) >>> s2.bind(('127.2.2.2', 60_000)) >>> !./dump_bhash.py TCP-PORT  FASTREUSE  #OWNERS     1234          0        1    60000          0        2 >>>

Если работать с такими инструментами, то доказать истинность информации, содержащейся в таблице 2, требуется всего лишь написать комплект исследовательских тестов.

Но что произошло в последнем сниппете? Корзина привязки явно перешла из одного состояния fastreuse в другое. Именно это упускается в таблице 1. И это означает, что полной картины происходящего мы себе по-прежнему не представляем.

Нам ещё предстоит выяснить, когда именно может измениться состояние fastreuse у корзины. Здесь явно требуется конечный автомат.

Конечный автомат

Как мы только что могли убедиться, корзине привязки не требуется на протяжении всего жизненного цикла оставаться в исходном состоянии fastreuse. Добавление сокетов в корзину может спровоцировать изменение состояния. Оказывается, возможен переход только в состояние fastreuse = 0, если нам доведётся привязать (bind()) сокет, который:

1.      не конфликтует с уже имеющимися владельцами и

2.      у него не включена опция SO_REUSEADDR.

Притом, что мы могли бы выяснить всё это, внимательно прочитав код в inet_csk_get_port → inet_csk_update_fastreuse, нам определённо не повредило бы подкрепить то, что мы уже поняли, и для этого нужно провести ещё несколько тестов.

Теперь, когда мы смогли восстановить полную картину, напрашивается вопрос…

Зачем вы мне всё это рассказываете?

Во-первых, на случай, когда в следующий раз системный вызов bind() отклонит ваш запрос с мотивацией EADDRINUSE, или connect() упрётся, выдав ошибку EADDRNOTAVAIL – чтобы вы знали, что происходит, или, как минимум, обладали инструментарием, позволяющим это выяснить.

Во-вторых, поскольку ранее мы рекламировали, как можно открывать соединения из конкретного набора портов. Такая практика сопряжена с привязкой сокетов, у которых выставлена опция SO_REUSEADDR. Тогда мы ещё не осознавали, что существует пограничный случай, в котором порт не поддаётся совместному использованию обычными сокетами, подключёнными при помощи connect(). Притом, что это погоды не делает, важно понимать потенциальные последствия.

Чтобы поправить ситуацию, мы совместно с сообществом Linux надстроили в API ядра
новую сокетную опцию, позволяющую пользователю указать диапазон
локальных портов
. Ожидается, что новая опция будет доступна в готовящейся к выходу новой версии Linux 6.3. Имея такую опцию, мы сможем больше не прибегать к уловкам с bind(). Опять же, так открывается возможность разделять локальный порт между сокетами, подключёнными при помощи обычного connect().

Заключительные мысли

В этой статье был сформулирован относительно прямолинейный вопрос – в каких случаях два TCP-сокета могут совместно использовать локальный адрес? – после чего был проработан путь к ответу. Этот ответ настолько сложен, что в одном предложении его не выразить. Более того, это даже не полный ответ. В конце концов, мы решили проигнорировать фичу SO_REUSEPORT, а также не рассматривали конфликтов, в которые вовлечены слушающие TCP-сокеты.

Если из этого исследования и есть простой вывод, то вот он: привязывая сокет при помощи bind(), можно нарваться на коварные последствия. При использовании bind() для выбора исходящего IP-адреса, лучше всего сочетать этот выбор с сокетной опцией IP_BIND_ADDRESS_NO_PORT, а присваивание портов оставить ядру. В противном случае можно непреднамеренно перекрыть возможность переиспользования локальных TCP-портов.

Прискорбно, что этот совет неприменим к UDP, где IP_BIND_ADDRESS_NO_PORT на настоящий момент, в сущности, не работает. Но это уже другая история.

До новых встреч ?.


ссылка на оригинал статьи https://habr.com/ru/post/725144/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *