Що таке тризначний рукопашний?
Three-way handshake (триетапне рукостискання) - це процес встановлення TCP-з'єднання, під час якого клієнт і сервер обмінюються трьома пакетами (SYN, SYN-ACK, ACK), щоб підтвердити готовність обох сторін і узгодити sequence numbers перед передачею даних.
Теорія
TL;DR
- Як перевірка телефонної лінії: "чути?" (SYN), "чую, говоріть" (SYN-ACK), "прийнято, починаємо" (ACK). Обидві сторони переконуються що канал працює перед тим як щось передавати.
- Відбувається автоматично для кожного TCP-з'єднання. HTTP, HTTPS, SSH, підключення до БД, WebSockets - все починається з цього.
- Головна мета: узгодити початкові sequence numbers, щоб TCP міг виявляти втрачені або перемішані пакети.
- UDP пропускає рукостискання. Швидше, але без гарантії доставки.
- ECONNREFUSED = сервер відкинув SYN (порт закритий). ETIMEDOUT = SYN-ACK не прийшов (сервер недоступний або заблокований firewall).
Швидкий приклад
const net = require('net');
const socket = net.createConnection({ host: 'example.com', port: 80 });
socket.on('connect', () => {
// Рукостискання завершено - SYN -> SYN-ACK -> ACK відбулись в ядрі ОС
socket.write('GET / HTTP/1.1\r\nHost: example.com\r\n\r\n');
});
socket.on('error', (err) => {
// ECONNREFUSED: сервер живий, порт закритий
// ETIMEDOUT: SYN нікуди не дійшов
console.log('Connection failed:', err.code);
});Подія connect спрацьовує лише після того, як усі три пакети пройшли. Код застосунку не бачить самого рукостискання - ним займається ядро ОС.
Як це працює крок за кроком
Крок 1 (SYN): клієнт обирає випадковий початковий sequence number (ISN) і надсилає SYN-пакет. По суті: "хочу підключитися, мій лічильник байтів починається з цього числа".
Крок 2 (SYN-ACK): сервер обирає свій ISN і відповідає. Підтверджує ISN клієнта (додає 1) і одночасно повідомляє свій. Один пакет, два завдання.
Крок 3 (ACK): клієнт підтверджує ISN сервера. Стан з'єднання на обох сторонах переходить до ESTABLISHED. Можна передавати дані.
Саме тому потрібно три кроки, а не два. Два кроки підтвердили б, що сервер отримав SYN клієнта, але клієнт ніколи б не підтвердив, що отримав sequence number сервера. Третій крок закриває цю прогалину.
// Реальний вивід tcpdump під час рукостискання
// Клієнт 192.168.1.100:54321 -> Сервер 192.168.1.1:80
// Крок 1: SYN
192.168.1.100.54321 > 192.168.1.1.80: Flags [S], seq 1000000000
// Крок 2: SYN-ACK
192.168.1.1.80 > 192.168.1.100.54321: Flags [S.], seq 2000000000, ack 1000000001
// Крок 3: ACK
192.168.1.100.54321 > 192.168.1.1.80: Flags [.], ack 2000000001
// Починається передача даних
192.168.1.100.54321 > 192.168.1.1.80: Flags [P.] "GET / HTTP/1.1"Ключова різниця між TCP і UDP
Рукостискання не стосується передачі даних. Воно встановлює спільний контекст: обидві сторони знають, де починається байтовий потік одна одної. Після цього TCP може виявляти втрачені пакети, дублікати і відновлювати порядок. UDP надсилає пакети і забуває про них. Саме тому відеодзвінки використовують UDP (пропущений кадр - не катастрофа), а передача файлів - TCP (кожен байт має прийти по порядку).
Коли що використовувати
- TCP-з'єднання: рукостискання відбувається автоматично. Ти не обираєш його.
- UDP (без рукостискання): DNS, відеостримінг, онлайн-ігри. Там затримка важливіша за гарантію доставки.
- Діагностика зависань: якщо з'єднання не встановлюється, перевір чи взагалі повертається SYN-ACK. Firewall, який мовчки відкидає SYN-пакети, виглядає так само, як недоступний сервер.
- Безпека: SYN flood-атаки надсилають мільйони SYN-пакетів без завершення ACK. Сервер заповнює таблицю напіввідкритими з'єднаннями і вичерпує ресурси.
Типові помилки
Помилка 1: запис даних до готовності з'єднання
// Неправильно
const socket = net.createConnection({ host: 'example.com', port: 80 });
socket.write('GET / HTTP/1.1\r\n'); // рукостискання ще не завершено
// Правильно
socket.on('connect', () => {
socket.write('GET / HTTP/1.1\r\nHost: example.com\r\n\r\n');
});Помилка 2: таймаут надто короткий для повільних мереж
// Неправильно: 100ms не вистачить для міжконтинентального з'єднання
net.createConnection({ host: 'remote-server.com', port: 443, timeout: 100 });
// Правильно: рукостискання з віддаленим сервером може зайняти 200-500ms
net.createConnection({ host: 'remote-server.com', port: 443, timeout: 5000 });Помилка 3: ECONNREFUSED і ETIMEDOUT - різні проблеми
ECONNREFUSED означає, що сервер відповів RST: він працює, але порт закритий. ETIMEDOUT означає, що SYN взагалі не отримав відповіді: неправильний хост, firewall або мертвий сервер. Повторна спроба при ECONNREFUSED нічого не змінить.
Помилка 4: припускати, що sequence numbers передбачувані
Старі реалізації TCP використовували послідовні або часові ISN. Зловмисники могли їх вгадати і підробляти пакети. Сучасний TCP рандомізує ISN за RFC 6528. Не будуй логіку на основі передбачення sequence number іншої сторони.
Помилка 5: продакшен без захисту від SYN flood
# Linux: увімкни SYN cookies на рівні ОС
sysctl net.ipv4.tcp_syncookies=1
# У nginx: обмеж кількість з'єднань з одного IP
# limit_conn_zone $binary_remote_addr zone=addr:10m;
# limit_conn addr 100;SYN cookies дозволяють серверу відповідати на SYN-ACK без збереження стану для кожного напіввідкритого з'єднання. Якщо клієнт завершує рукостискання - стан відновлюється. Якщо ні - нічого не витрачено. Я бачив це неправильно налаштованим на внутрішніх сервісах: один навантажувальний тест зі staging виглядав для продакшен-сервера як SYN flood.
Де зустрічається в реальних проектах
- Запити браузера: кожен HTTP GET починається з SYN. Для HTTPS поверх TCP-рукостискання іде ще TLS handshake.
- SSH: затримка 1-2 секунди при
ssh user@host- це TCP-рукостискання плюс обмін ключами, а не повільна мережа. - Підключення до БД: MySQL, PostgreSQL, MongoDB проходять TCP handshake перед першим запитом. Connection pooling існує щоб не повторювати це на кожен запит.
- WebSockets: HTTP upgrade до WebSocket відбувається після того, як TCP-з'єднання вже встановлено.
- Load balancers: nginx і HAProxy стежать за невдалими рукостисканнями і позначають бекенди як недоступні.
Питання на співбесіді
Q: Чому три кроки, а не два?
A: Два кроки підтвердили б, що сервер отримав SYN клієнта, але клієнт ніколи не підтвердив би, що отримав sequence number сервера. Без третього кроку сервер не може знати, чи доходять його пакети.
Q: Що станеться, якщо фінальний ACK загубиться?
A: Сервер залишається в SYN_RECV і повторно надсилає SYN-ACK з експоненційним відступом (загалом близько 60 секунд). Клієнт переходить в ESTABLISHED і намагається відправити дані. Сервер отримує дані в неочікуваному стані, надсилає RST, і клієнт отримує "Connection reset by peer".
Q: Як пов'язані TIME_WAIT і рукостискання?
A: Безпосередньо не пов'язані. TIME_WAIT виникає при закритті з'єднання, а не при відкритті. Після чотириетапного FIN-обміну сторона, що ініціювала закриття, чекає 2 хвилини (2 x MSL), щоб старі пакети від попереднього сеансу не пошкодили нове з'єднання на тому самому порту. Через це іноді не вдається одразу перезапустити сервер.
Q: Чи можна надіслати дані всередині SYN-пакета?
A: Так, з TCP Fast Open (TFO, RFC 7413). Сервер видає cookie при першому з'єднанні. Наступні SYN-пакети несуть цей cookie разом з даними, скорочуючи один round-trip. Chrome і ядро Linux 3.7+ підтримують TFO.
Q: [Senior] Як виявити SYN flood і що з ним робити?
A: Стеж за netstat -an | grep SYN_RECV - якщо число росте, це підозрілий знак. Увімкни SYN cookies на рівні ОС. Постав nginx або HAProxy з обмеженням швидкості підключень на IP. Для критичних сервісів використовуй DDoS-захист на рівні мережі. У Node.js server.maxConnections обмежує прийняті з'єднання, але flood зазвичай потрапляє в чергу ядра до того, як твій процес його побачить.
Приклади
Базовий: TCP-сервер на Node.js
const net = require('net');
const server = net.createServer((socket) => {
// Callback викликається лише коли з'єднання досягло стану ESTABLISHED
// SYN / SYN-ACK / ACK вже пройшли в ядрі ОС
console.log('Client connected:', socket.remoteAddress);
socket.write('Connected\n');
socket.on('data', (data) => {
socket.write(`Echo: ${data}`);
});
socket.on('error', () => socket.destroy());
});
server.listen(3000, () => console.log('Listening on port 3000'));Ти ніколи не пишеш код для обробки окремих пакетів рукостискання. Ядро робить SYN / SYN-ACK / ACK і передає тобі вже готовий socket.
Середній: діагностика помилок підключення
const net = require('net');
function connectWithDiagnosis(host, port) {
const socket = net.createConnection({ host, port, timeout: 5000 });
socket.on('connect', () => {
console.log('Handshake done, connection ready');
});
socket.on('timeout', () => {
socket.destroy();
// SYN-ACK не отримано - перевір firewall або доступність хоста
console.log('Timeout: no SYN-ACK received');
});
socket.on('error', (err) => {
if (err.code === 'ECONNREFUSED') {
// SYN дійшов до сервера, але порт закритий
console.log('Port closed - check the port number');
} else if (err.code === 'EHOSTUNREACH') {
// Немає маршруту - перевір IP або мережу
console.log('No route to host');
} else {
console.log('Unexpected error:', err.message);
}
});
}
connectWithDiagnosis('example.com', 80);Кожен код помилки вказує на різну фазу невдалого рукостискання. ECONNREFUSED означає, що SYN дійшов. ETIMEDOUT означає, що ні. Ці два факти ведуть в абсолютно різні боки при діагностиці.
Просунутий: спостереження рукостискання через tcpdump
# Захоплення пакетів рукостискання в реальному часі
tcpdump -i any 'tcp[tcpflags] & (tcp-syn|tcp-ack) != 0' -n
# Приклад виводу:
# 10:01:01 client.54321 > server.80: Flags [S], seq 1000000000
# 10:01:01 server.80 > client.54321: Flags [S.], seq 2000000000, ack 1000000001
# 10:01:01 client.54321 > server.80: Flags [.], ack 2000000001
# 10:01:01 client.54321 > server.80: Flags [P.] "GET / HTTP/1.1..."[S] = SYN. [S.] = SYN-ACK. [.] = чистий ACK. [P.] = дані (PSH+ACK). Кожне TCP-з'єднання твоєї машини дає рівно таку саму послідовність на початку.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.