270 lines
15 KiB
Go
270 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
// context: Используется для управления жизненным циклом приложения.
|
|
// Позволяет мягко (Graceful Shutdown) передать сигнал отмены всем воркерам.
|
|
"context"
|
|
|
|
// encoding/binary: Позволяет читать и писать числа в сетевом байтовом порядке
|
|
// (BigEndian / Network Byte Order). Незаменим для парсинга и сборки STUN-заголовков.
|
|
"encoding/binary"
|
|
|
|
// errors: Предоставляет базовые инструменты для создания и сравнения ошибок.
|
|
// Используется для создания pre-allocated (заранее созданных) констант ошибок.
|
|
"errors"
|
|
|
|
// flag: Реализует парсинг флагов командной строки (аргументов запуска).
|
|
// Нужен для ручной передачи внешнего IP через параметр, например, `--public-ip=91.210.106.64`.
|
|
"flag"
|
|
|
|
// log/slog: Современный пакет структурированного логирования.
|
|
// Позволяет выводить системные сообщения в формате "ключ=значение" с высокой скоростью.
|
|
"log/slog"
|
|
|
|
// net: Базовый сетевой пакет Go. Используется для открытия сетевого сокета
|
|
// через net.ListenUDP и работы с традиционными UDP-соединениями.
|
|
"net"
|
|
|
|
// net/netip: Сверхбыстрый и современный пакет для работы с IP-адресами (с Go 1.18).
|
|
// В отличие от старого net.IP, структуры netip.Addr распределяются в стеке (Zero-Alloc).
|
|
"net/netip"
|
|
|
|
// os: Позволяет взаимодействовать с операционной системой хоста.
|
|
// Используется для чтения переменных окружения (os.Getenv) и выходов в случае критических сбоев.
|
|
"os"
|
|
|
|
// os/signal: Перехватывает системные сигналы от ОС (например, нажатие Ctrl+C).
|
|
// Нужен для того, чтобы сервер не умирал мгновенно, а корректно закрывал сокеты при остановке.
|
|
"os/signal"
|
|
|
|
// runtime: Позволяет управлять планировщиком Go и исследовать среду выполнения.
|
|
// Вызов runtime.NumCPU() определяет количество доступных ядер для пула воркеров.
|
|
"runtime"
|
|
|
|
// sync: Предоставляет примитивы синхронизации горутин.
|
|
// Структура sync.WaitGroup гарантирует, что сервер не закроется, пока все воркеры не завершат работу.
|
|
"sync"
|
|
|
|
// syscall: Низкоуровневый интерфейс к системным вызовам операционной системы.
|
|
// Нужен для точного перехвата сигнала завершения процесса (syscall.SIGTERM) от Docker.
|
|
"syscall"
|
|
)
|
|
|
|
// Константы протокола STUN (RFC 5389 / RFC 8489)
|
|
const (
|
|
Port = 3478 // Стандартный UDP-порт для STUN-сервисов
|
|
BufferSize = 1500 // Размер буфера под MTU (максимальный пакет без фрагментации IP)
|
|
STUNHeaderSize = 20 // Фиксированный размер заголовка STUN (20 байт)
|
|
MagicCookie = 0x2112A442 // Магическое число STUN для идентификации протокола
|
|
AttrResponseOrigin = 0x802B // Код атрибута, указывающий исходный IP/порт сервера
|
|
)
|
|
|
|
// Преаллоцированные ошибки для защиты от лишних выделений памяти в рантайме
|
|
var (
|
|
errPacketTooShort = errors.New("packet too short")
|
|
errInvalidMethod = errors.New("invalid message type or method")
|
|
errInvalidCookie = errors.New("invalid magic cookie")
|
|
)
|
|
|
|
func main() {
|
|
// Инициализируем быстрый структурированный логгер (slog) в текстовом формате
|
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
slog.SetDefault(logger)
|
|
|
|
// Регистрируем флаг командной строки для ручного ввода публичного IP
|
|
flagStr := flag.String("public-ip", "", "Внешний публичный IPv4-адрес сервера (для логов)")
|
|
flag.Parse()
|
|
|
|
// Логика приоритета: если флаг пустой, ищем значение в переменных окружения (Docker/.env)
|
|
publicIPStr := *flagStr
|
|
if publicIPStr == "" {
|
|
publicIPStr = os.Getenv("PUBLIC_IP")
|
|
}
|
|
|
|
// Парсим и валидируем IP-адрес
|
|
var publicIP netip.Addr
|
|
if publicIPStr != "" {
|
|
var err error
|
|
publicIP, err = netip.ParseAddr(publicIPStr)
|
|
// Сервер строго IPv4, поэтому проверяем Is4()
|
|
if err != nil || !publicIP.Is4() {
|
|
slog.Error("Указан некорректный IPv4 public-ip", "val", publicIPStr)
|
|
os.Exit(1)
|
|
}
|
|
} else {
|
|
// Дефолтный фолбек на localhost, если адрес вообще нигде не указан
|
|
publicIP = netip.AddrFrom4([4]byte{127, 0, 0, 1})
|
|
}
|
|
|
|
// Определяем количество логических ядер процессора для запуска оптимального пула воркеров
|
|
numWorkers := runtime.NumCPU()
|
|
|
|
// Настраиваем контекст, который слушает сигналы завершения работы ОС (Ctrl+C / SIGTERM от Docker)
|
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer cancel()
|
|
|
|
// Открываем единый UDP-сокет на всех интерфейсах (0.0.0.0) и порту 3478
|
|
addr := &net.UDPAddr{IP: net.IPv4zero, Port: Port}
|
|
conn, err := net.ListenUDP("udp4", addr)
|
|
if err != nil {
|
|
slog.Error("Не удалось запустить UDP сервер", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Настройка буферов сокета на уровне ядра ОС (4 Мегабайта).
|
|
// Защищает от потери пакетов, пока Go-горутины переключают контекст.
|
|
_ = conn.SetReadBuffer(4194304)
|
|
_ = conn.SetWriteBuffer(4194304)
|
|
|
|
// Запускаем пул параллельных воркеров по числу ядер CPU
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < numWorkers; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
startWorker(ctx, conn, publicIP)
|
|
}()
|
|
}
|
|
|
|
slog.Info("STUN-сервер запущен (True Zero-Alloc, IPv4)", "port", Port, "workers", numWorkers)
|
|
|
|
// Блокируем главный поток, ожидая системного сигнала на остановку
|
|
<-ctx.Done()
|
|
slog.Info("Завершение работы сервера...")
|
|
|
|
// Принудительно закрываем сокет. Это мгновенно разблокирует методы Read во всех воркерах.
|
|
conn.Close()
|
|
wg.Wait() // Ждем, пока все воркеры корректно завершат текущую итерацию цикла
|
|
slog.Info("Сервер успешно остановлен")
|
|
}
|
|
|
|
func startWorker(ctx context.Context, conn *net.UDPConn, publicIP netip.Addr) {
|
|
// КЛЮЧЕВАЯ ОПТИМИЗАЦИЯ: Буфер выделяется один раз ПЕРЕД циклом для каждого воркера.
|
|
// Переменная не "убегает" в кучу (heap alloc = 0) и переиспользуется для каждого пакета.
|
|
buf := make([]byte, BufferSize)
|
|
|
|
for {
|
|
// Неблокирующая быстрая проверка — не приказал ли контекст завершить работу
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
// Читаем пакет из сетевого сокета прямо в преаллоцированный буфер.
|
|
// addrPort возвращает netip.AddrPort (аллокация структуры в стеке, zero-alloc).
|
|
n, addrPort, err := conn.ReadFromUDPAddrPort(buf)
|
|
if err != nil {
|
|
if errors.Is(err, net.ErrClosed) {
|
|
return // Нормальный выход, если сокет был закрыт главным потоком
|
|
}
|
|
slog.Debug("Ошибка чтения из UDP сокета", "error", err)
|
|
continue
|
|
}
|
|
|
|
// Быстрая фильтрация невалидных пакетов или IPv6-запросов (так как сервер под IPv4)
|
|
if !addrPort.IsValid() || !addrPort.Addr().Is4() {
|
|
continue
|
|
}
|
|
|
|
// Передаем пакет на разбор и формирование ответа
|
|
_ = handleSTUN(conn, addrPort, buf, n, publicIP)
|
|
}
|
|
}
|
|
|
|
func handleSTUN(conn *net.UDPConn, remoteAddr netip.AddrPort, buf []byte, packetLen int, publicIP netip.Addr) error {
|
|
// Базовая проверка: STUN-пакет не может быть меньше своего заголовка
|
|
if packetLen < STUNHeaderSize {
|
|
return errPacketTooShort
|
|
}
|
|
|
|
// Читаем первые 2 байта (Message Type). Сервер обрабатывает только Binding Request (0x0001)
|
|
messageType := binary.BigEndian.Uint16(buf[0:2])
|
|
if messageType != 0x0001 {
|
|
return errInvalidMethod
|
|
}
|
|
|
|
// Проверяем корректность Magic Cookie (байты 4-7)
|
|
magicCookie := binary.BigEndian.Uint32(buf[4:8])
|
|
if magicCookie != MagicCookie {
|
|
return errInvalidCookie
|
|
}
|
|
|
|
// Извлекаем уникальный Transaction ID (12 байт), чтобы клиент понял, на какой запрос пришел ответ
|
|
var transactionID [12]byte
|
|
copy(transactionID[:], buf[8:20])
|
|
|
|
// Получаем IP-адрес клиента в виде массива из 4 байт (стековая структура)
|
|
clientIP := remoteAddr.Addr().As4()
|
|
|
|
// Фокус оптимизации: Расширяем видимость среза до полной емкости буфера (1500 байт),
|
|
// чтобы писать ответ прямо поверх данных запроса без аллокации новой памяти.
|
|
buf = buf[:cap(buf)]
|
|
|
|
// Смещение (указатель записи). Заголовок пока пропускаем, начнем писать атрибуты с 20-го байта.
|
|
pos := STUNHeaderSize
|
|
|
|
// --------------------------------------------------------------------------
|
|
// АТРИБУТ 1: XOR-MAPPED-ADDRESS (0x0020) — возвращает клиенту его внешний IP/Порт с XOR-шифрованием
|
|
// --------------------------------------------------------------------------
|
|
binary.BigEndian.PutUint16(buf[pos:pos+2], 0x0020) // Type
|
|
binary.BigEndian.PutUint16(buf[pos+2:pos+4], 8) // Length (8 байт на данные)
|
|
buf[pos+4] = 0 // Зарезервировано
|
|
buf[pos+5] = 0x01 // Protocol Family: IPv4
|
|
|
|
// Шифруем порт: делаем XOR порта клиента с первыми 16 битами Magic Cookie
|
|
xorPort := remoteAddr.Port() ^ uint16(MagicCookie>>16)
|
|
binary.BigEndian.PutUint16(buf[pos+6:pos+8], xorPort)
|
|
|
|
// Шифруем IP: делаем XOR IP-адреса клиента с полным 32-битным Magic Cookie
|
|
xorIP := binary.BigEndian.Uint32(clientIP[:]) ^ MagicCookie
|
|
binary.BigEndian.PutUint32(buf[pos+8:pos+12], xorIP)
|
|
pos += 12 // Сдвигаем указатель на 12 байт вперед
|
|
|
|
// --------------------------------------------------------------------------
|
|
// АТРИБУТ 2: MAPPED-ADDRESS (0x0001) — старый формат без XOR (для совместимости со старым софтом)
|
|
// --------------------------------------------------------------------------
|
|
binary.BigEndian.PutUint16(buf[pos:pos+2], 0x0001) // Type
|
|
binary.BigEndian.PutUint16(buf[pos+2:pos+4], 8) // Length
|
|
buf[pos+4] = 0 // Зарезервировано
|
|
buf[pos+5] = 0x01 // Protocol Family: IPv4
|
|
binary.BigEndian.PutUint16(buf[pos+6:pos+8], remoteAddr.Port()) // Порт в чистом виде
|
|
copy(buf[pos+8:pos+12], clientIP[:]) // IP в чистом виде
|
|
pos += 12
|
|
|
|
// --------------------------------------------------------------------------
|
|
// АТРИБУТ 3: RESPONSE-ORIGIN (0x802B) — сообщает клиенту, с какого IP сервера отправлен ответ
|
|
// --------------------------------------------------------------------------
|
|
binary.BigEndian.PutUint16(buf[pos:pos+2], AttrResponseOrigin) // Type
|
|
binary.BigEndian.PutUint16(buf[pos+2:pos+4], 8) // Length
|
|
buf[pos+4] = 0 // Зарезервировано
|
|
buf[pos+5] = 0x01 // Protocol Family: IPv4
|
|
binary.BigEndian.PutUint16(buf[pos+6:pos+8], uint16(Port)) // Порт нашего STUN сервера
|
|
serverIPBytes := publicIP.As4()
|
|
copy(buf[pos+8:pos+12], serverIPBytes[:]) // Внешний IP нашего сервера из переменной окружения
|
|
pos += 12
|
|
|
|
// 计算 Длина ответа
|
|
totalLen := pos // Полный размер пакета (20 байт заголовок + 36 байт атрибуты = 56 байт)
|
|
attrLen := totalLen - STUNHeaderSize // Чистый размер только блока атрибутов (36 байт)
|
|
|
|
// --------------------------------------------------------------------------
|
|
// ЗАПОЛНЕНИЕ СТАРТОВОГО ЗАГОЛОВКА STUN (Первые 20 байт пакета)
|
|
// --------------------------------------------------------------------------
|
|
binary.BigEndian.PutUint16(buf[0:2], 0x0101) // Message Type: Binding Success Response
|
|
binary.BigEndian.PutUint16(buf[2:4], uint16(attrLen)) // Записываем длину атрибутов в заголовок
|
|
binary.BigEndian.PutUint32(buf[4:8], MagicCookie) // Возвращаем Magic Cookie
|
|
copy(buf[8:20], transactionID[:]) // Возвращаем оригинальный Transaction ID клиента
|
|
|
|
// Отправляем сформированный срез байт обратно клиенту по UDP
|
|
if _, err := conn.WriteToUDPAddrPort(buf[:totalLen], remoteAddr); err != nil {
|
|
if !errors.Is(err, net.ErrClosed) {
|
|
slog.Debug("Ошибка отправки ответа", "remote", remoteAddr.String(), "error", err.Error())
|
|
}
|
|
return err
|
|
}
|
|
|
|
return nil // Пакет успешно обработан без единой аллокации памяти в куче
|
|
}
|