Запомнить запись о представлении PR библиотеки Kotlin Ktor (самоподключение TCP)

Linux Открытый исходный код

В прошлом году я имел честь пригласить Ян Вэнь, лидера Go Night Reading, поделиться с нами темой открытого исходного кода, что меня очень воодушевило. Буквально некоторое время назад я протестировал официальную библиотеку Kotlin ktor и обнаружил очень непопулярную проблему, поэтому отправил PR. После долгого ожидания в течение месяца кто-то, наконец, рассмотрел и слил его с основной веткой.Предполагается, что следующий релиз можно будет увидеть.

Актуальный контент записывается здесь, а PR-адрес здесь:GitHub.com/KT или IO/KT или…

Основное содержание статьи - Что такое самоподключение TCP

  • Анализ четности назначения номеров портов для Connect и Bind(0) в ядре Linux High Version
  • Как исправить код для самоподключения TCP

Справочная информация, что это за пиар

Воспроизведенный код выглядит следующим образом:getAvailablePort()Используется для поиска доступного четного номера порта, который в тестовом примере возвращает 42064. Что касается того, почему номер порта здесь четный, я остановлюсь на этом позже.

fun testSelfConnect() {
    runBlocking {
        // Find a port that would be used as a local address.
        val port = getAvailablePort()

        val tcpSocketBuilder = aSocket(ActorSelectorManager(Dispatchers.IO)).tcp()
        // Try to connect to that address repeatedly.
        for (i in 0 until 100000) {
            try {
                val socket = tcpSocketBuilder.connect(InetSocketAddress("127.0.0.1", port))
                println("connect to self succeed: ${socket.localAddress} to ${socket.remoteAddress}")
                System.`in`.read()
                break
            } catch (ex: Exception) {
                // ignore
            }
        }
    }
}

Запустив приведенный выше код, будет установлено соединение с тем же номером исходного порта, что и номер порта назначения.

Это явно ненормально, если такое происходит, если серверная программа больше не может прослушивать порт 42064. Суть этой проблемы в самостоятельном подключении TCP, и данный PR как раз для решения этой проблемы. Далее давайте рассмотрим, что такое самоподключение TCP.

TCP самоподключение

Самоподключение TCP — интересное явление, и многие даже думают, что это баг ядра Linux. Давайте сначала посмотрим, что такое самоподключение TCP.

Создайте новый скрипт self_connect.sh со следующим содержимым:

while true
do
	telnet 127.0.0.1 50000
done

Перед выполнением этого скрипта используйте такие команды, как netstat, чтобы убедиться, что 50000 не имеет мониторинга процессов. Затем выполните сценарий, через некоторое время telnet действительно преуспел.

Trying 127.0.0.1...
telnet: connect to address 127.0.0.1: Connection refused
Trying 127.0.0.1...
telnet: connect to address 127.0.0.1: Connection refused
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.

Используйте netstat для просмотра текущего состояния подключения к порту 50000, как показано ниже.

Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp        0      0 127.0.0.1:50000         127.0.0.1:50000         ESTABLISHED 24786/telnet

Вы можете видеть, что исходный IP-адрес и исходный порт127.0.0.1:50000, целевой IP, целевой порт также127.0.0.1:50000, Через приведенный выше скрипт мы подключились к номеру порта, который не прослушивался.

Анализ причин самоподключения

Результат захвата пакета при успешном самоподключении показан на следующем рисунке.

Для самостоятельного подключения отправитель и получатель каждого пакета в wireshark на приведенном выше рисунке являются самими собой, поэтому можно понять, что всего пакетов шесть, а процесс взаимодействия пакетов показан на следующем рисунке.

自连接包交互过程

Эта картинка кажется знакомой? Процесс взаимодействия первых четырех пакетов является одновременно процессом открытия TCP.

Когда одна сторона активно инициирует соединение, операционная система автоматически назначает временный номер порта стороне, которая инициирует соединение. Если только что выделенный эфемерный порт — это порт 50000, процесс выглядит следующим образом.

  • Первый пакет должен отправить пакет SYN на порт 50000.
  • Для отправителя он получает этот SYN-пакет, думая, что другая сторона хочет открыть его в то же время, и ответит SYN+ACK.
  • После ответа на SYN+ACK он сам получит этот SYN+ACK, думая, что это ответ другой стороны, успешно обменялся с ним рукопожатием и войдет в состояние ESTABLISHED.

Опасности самостоятельного подключения

Рассмотрим следующий сценарий:

  • Написанная вами бизнес-система B будет обращаться к локальной службе A, которая прослушивает порт 50000.
  • Код бизнес-системы B немного более надежен, в нем добавлена ​​логика отключения и повторного подключения к сервису A.
  • Если однажды служба A зависнет надолго и не запустится, бизнес-система B начнет постоянно подключаться и переподключаться
  • Система B автоматически подключится после повторной попытки в течение определенного периода времени.
  • В это время, если служба A захочет начать прослушивание на порту 50000, произойдет исключение, что адрес занят и не может быть запущен в обычном режиме.

Если происходит самоподключение, есть как минимум две очевидные проблемы:

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

Как исправить проблемы с самостоятельным подключением

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

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

Для первого метода случайным образом назначаемый диапазон на стороне клиента определяется как/proc/sys/net/ipv4/ip_local_port_rangeФайл определяет, что на моем Centos 8 это значение находится в диапазоне от 32768 до 60999. Пока порт, который прослушивает служба, меньше 32768, не будет ситуации, когда клиент и порт службы совпадают. Этот метод рекомендуется.

Как исправить этот вопрос

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

Проблема в том, чтобы написать тестовый пример. Во-первых, мне нужно получить доступный порт для тестирования. Если я просто случайно выберу номер порта, возможно, что сам порт заблокирован и контролируется программой на сервере, которая склонны к конфликтам номеров портов. Итак, вначале я использовал порт bind(0) для выделения свободного порта ядром, как показано ниже.

private fun getAvailablePort(): Int {
    val port = ServerSocket().apply {
                bind(InetSocketAddress("127.0.0.1", 0))
                close()
            }.localPort
}

Проблема здесь. Таким образом, я не могу воспроизвести проблему на Ubuntu 18.04. Ее можно воспроизвести, изменив на фиксированный порт, например 50000.

Почему приведенный выше тестовый код должен быть подключен к четному порту?

В более ранних версиях ядра Linux подключение нечетных портов также может быть воспроизведено.В новой версии ядра 4.2 была введена функция (некоторые дистрибутивы Linux имеют эту функцию обратного порта).git.kernel.org/universal/triplegate/lin…,Как показано ниже.

/proc/sys/net/ipv4/ip_local_port_rangeВ файле указывается нижняя граница low и верхняя граница high эфемерного номера порта.По умолчанию low — четное число.На моем компьютере значения low и high равны 32768 и 60999 соответственно.

Короче говоря, в новой версии ядра внесены некоторые коррективы в стратегию распределения портов:

  • Приоритет отдается назначению случайного порта с четностью, отличной от низкой, то есть порта с нечетным номером, для привязки (0). Если выделен нечетный номер порта, попробуйте назначить нечетный номер порта
  • Эфемерный порт с той же четностью, что и low, выделяется преимущественно для подключения, то есть четный порт. Если выделены четные номера портов, попробуйте выделить нечетные порты

Вы можете написать следующий код для тестирования.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

void print_local_port() {
    int sockfd;
    if (sockfd = socket(AF_INET, SOCK_STREAM, 0), -1 == sockfd) {
        perror("socket create error");
    }
    // 连接本地的 8080 端口的服务器
    const struct sockaddr_in remote_addr = {
            .sin_family = AF_INET,
            .sin_port   = htons(8080),
            .sin_addr   = htonl(INADDR_ANY)
    };
    if (connect(sockfd, (const struct sockaddr *) &remote_addr, sizeof(remote_addr)) < 0) {
        perror("connect error");
    }
    // 获取本地套接字地址
    const struct sockaddr_in local_addr;
    socklen_t local_addr_len = sizeof(local_addr);
    if (getsockname(sockfd, (struct sockaddr *) &local_addr, &local_addr_len) < 0) {
        perror("getsockname error");
    }
    printf("local port: %d\n", ntohs(local_addr.sin_port));
    close(sockfd);
}

int main() {
    int i;
    for (i = 0; i < 10; i++) {
        print_local_port();
    }
    return 0;
}

Запустите приведенный выше код, и вы увидите, что номера локальных портов для подключения 10 раз являются четными числами.

$ ./a.out 
local port: 49238
local port: 49240
local port: 49242
local port: 49244
local port: 49246
local port: 49248
local port: 49250
local port: 49252
local port: 49254
local port: 49256

Вы также можете написать аналогичный код для проверки того, что ядро ​​bind(0) будет случайным образом назначать нечетный номер порта, когда ресурсов порта будет достаточно.

// bind(0)
const struct sockaddr_in serv_addr = {
        .sin_family = AF_INET,
        .sin_port   = htons(0),
        .sin_addr   = htonl(INADDR_ANY)
};

if (bind(sockfd, (struct sockaddr *) &serv_addr, sizeof(serv_addr)) < 0) {
    perror("bind error");
}

// 获取本地套接字地址
const struct sockaddr_in local_addr;
socklen_t local_addr_len = sizeof(local_addr);
if (getsockname(sockfd, (struct sockaddr *) &local_addr, &local_addr_len) < 0) {
    perror("getsockname error");
}

printf("bind local port: %d\n", ntohs(local_addr.sin_port));
close(sockfd);

Вывод в новой версии ядра выглядит следующим образом: вы можете видеть, что все случайные номера портов, возвращаемые bind(0), являются нечетными числами.

$ ./a.out
bind local port: 37173
bind local port: 43605
bind local port: 51155
bind local port: 37209
bind local port: 45985
bind local port: 57833
bind local port: 39517
bind local port: 45873
bind local port: 42387
bind local port: 53887

Поэтому в предыдущем примере getAvailablePort все случайные порты, возвращаемые bind(0), являются нечетными, а временные номера портов соединения — четными. В этом случае самоподключение никогда не будет успешным, и нет способ проверить, нужны ли изменения.

bind(0) анализ исходного кода паритета номера порта

bind — это системный вызов. Эта функция, наконец, вызывается в функции inet_csk_find_open_port. Стек вызовов показан на следующем рисунке.

Вот очень важная линия

offset |= 1U

На самом деле смысл этого предложения в том, чтобы изменить сгенерированное случайное число на нечетное, поэтому следующий метод генерации порта

port = low + offset;

Таким образом, low добавляется к нечетному числу:

  • Если low — нечетное число, то port — четное число.
  • Если low — четное число, то порт — нечетное число.

Таким образом достигается, что сгенерированный номер порта поддерживает четность, противоположную нижней границе диапазона портов, низкому значению. В случае, когда нижнее значение по умолчанию равно четному числу, номер порта, случайно сгенерированный bind(0), является нечетным числом.

Анализ четности подключенных эфемерных номеров портов

Исходный код выделения номера временного порта подключения реализован в __inet_hash_connect

Можно видеть, что, в отличие от bind(0), он заставляет смещение быть четным следующим образом.

offset &= ~1U;

Таким образом, после добавления низкого уровня к порту, четность порта соответствует низкому уровню.

модификация тестового кода

Как это изменить? Вот простой цикл for, который пытается использовать доступный четный порт.

private fun getAvailablePort(): Int {
    while (true) {
        val port = ServerSocket().apply {
            bind(InetSocketAddress("127.0.0.1", 0))
            close()
        }.localPort

        if (port % 2 == 0) {
            return port
        }

        try {
            // try bind the next even port
            ServerSocket().apply {
                bind(InetSocketAddress("127.0.0.1", port + 1))
                close()
            }
            return port + 1
        } catch (ex: Exception) {
            // ignore
        }
    }
}

Дополнительно

Эта проблема не уникальна для Kotlin или Java. В моем раннем коде подключения в исходном коде Golang также была эта проблема, но позже кто-то поднял проблему, чтобы исправить ее. Код выглядит следующим образом.


func (sd *sysDialer) doDialTCP(ctx context.Context, laddr, raddr *TCPAddr) (*TCPConn, error) {
	fd, err := internetSocket(ctx, sd.network, laddr, raddr, syscall.SOCK_STREAM, 0, "dial", sd.Dialer.Control)

	// TCP has a rarely used mechanism called a 'simultaneous connection' in
	// which Dial("tcp", addr1, addr2) run on the machine at addr1 can
	// connect to a simultaneous Dial("tcp", addr2, addr1) run on the machine
	// at addr2, without either machine executing Listen. If laddr == nil,
	// it means we want the kernel to pick an appropriate originating local
	// address. Some Linux kernels cycle blindly through a fixed range of
	// local ports, regardless of destination port. If a kernel happens to
	// pick local port 50001 as the source for a Dial("tcp", "", "localhost:50001"),
	// then the Dial will succeed, having simultaneously connected to itself.
	// This can only happen when we are letting the kernel pick a port (laddr == nil)
	// and when there is no listener for the destination address.
	// It's hard to argue this is anything other than a kernel bug. If we
	// see this happen, rather than expose the buggy effect to users, we
	// close the fd and try again. If it happens twice more, we relent and
	// use the result. See also:
	//	https://golang.org/issue/2690
	//	https://stackoverflow.com/questions/4949858/
	//
	// The opposite can also happen: if we ask the kernel to pick an appropriate
	// originating local address, sometimes it picks one that is already in use.
	// So if the error is EADDRNOTAVAIL, we have to try again too, just for
	// a different reason.
	//
	// The kernel socket code is no doubt enjoying watching us squirm.
	for i := 0; i < 2 && (laddr == nil || laddr.Port == 0) && (selfConnect(fd, err) || spuriousENOTAVAIL(err)); i++ {
		if err == nil {
			fd.Close()
		}
		fd, err = internetSocket(ctx, sd.network, laddr, raddr, syscall.SOCK_STREAM, 0, "dial", sd.Dialer.Control)
	}

	if err != nil {
		return nil, err
	}
	return newTCPConn(fd), nil
}

func selfConnect(fd *netFD, err error) bool {
	// If the connect failed, we clearly didn't connect to ourselves.
	if err != nil {
		return false
	}

	// The socket constructor can return an fd with raddr nil under certain
	// unknown conditions. The errors in the calls there to Getpeername
	// are discarded, but we can't catch the problem there because those
	// calls are sometimes legally erroneous with a "socket not connected".
	// Since this code (selfConnect) is already trying to work around
	// a problem, we make sure if this happens we recognize trouble and
	// ask the DialTCP routine to try again.
	// TODO: try to understand what's really going on.
	if fd.laddr == nil || fd.raddr == nil {
		return true
	}
	l := fd.laddr.(*TCPAddr)
	r := fd.raddr.(*TCPAddr)
	return l.Port == r.Port && l.IP.Equal(r.IP)
}

Вот подробное объяснение того, почему существует оценка метода selfConnect.Логика оценки того, является ли это самоподключением, заключается в том, чтобы определить, равны ли IP-адрес источника и IP-адрес назначения, а также являются ли номер порта источника и назначения номера портов равны.

небольшое впечатление

Код для отправки изменения pr может состоять всего из нескольких строк, но написать тестовый код по-прежнему сложно.