golang

Туннелирование трафика: простое решение на Go

  • пятница, 13 марта 2026 г. в 00:00:17
https://habr.com/ru/articles/1009328/

Так сложилось, что периодами по несколько дней я нахожусь в двух разных локациях с двумя разными провайдерами. В одной, приходиться "пробивать окно" в штаты в стене на той стороне. Изначально, чтобы обойти ограничения со стороны google/gemini, необходимо выглядеть настоящим нью-йоркцем. Другим провайдером пользуюсь меньше и в основном не для работы, но ситуация с ним печальнее: как у всех, закручено всё, до чего смогли дотянуться. В том числе не могу достучаться до своего сервера по квн.

Что делать в этой ситуации, понятно - вариантов много, нужно устанавливать различные решения и проверять работоспособность. Но это дела админские, гораздо интереснее посмотреть, "как это работает", со стороны разработчика. Нашел себе такой повод поковырять технологии и повелосипедостроить.

Писать буду на golang под linux, но на маках, по идее, тоже будет работать, с небольшими изменениями. Задачу для начала максимально упрощу, оставлю только суть: сделаю два туннеля на одной машине. Один будет использоваться как точка входа трафика с этой машины, а второй - как способ отправки пакетов. Если эту программу разрезать на две части и как-то прокидывать между ними пакеты, получится relay. Весь трафик, проходящий через туннель, будет выходить "наружу" на другой машине, свой квн с блэк-джеком и шлюхами.

Небольшое отступление про TUN/TAP механизм. Это возможность создавать виртуальные сетевые интерфейсы, с которых можно читать и писать сырые пакеты (в моем случае IP, но можно и Ethernet). Штука удобная, но есть нюансы, из-за которых у меня далеко не сразу всё заработало.

Итак, нужно создать два туннеля и переливать пакеты между ними:

func main() {
	tun0 := mustCreateTun("tun0", "10.0.0.2/24")
	tun1 := mustCreateTun("tun1", "10.0.1.2/24")

	go pipe(tun0, tun1)
	go pipe(tun1, tun0)

	select {}
}

mustCreateTun создает туннель, и присвает ему IP. Открыть туннель можно разными способами: через пакет water или напрямую вызвать syscall.

func pipe(a io.Reader, b io.Writer) {
	buf := make([]byte, 2000)

	for {
		n, err := a.Read(buf)
		if err != nil {
			slog.Error("pipe read", "err", err)
			continue
		}

		_, err = b.Write(packet)
		if err != nil {
			slog.Error("pipe write", "err", err)
		}
	}
}

Проверять буду ping-ом через интерфейс tun0:

ping -I tun0 8.8.8.8

Программа прочтет пакет из tun0, запишет в tun1. Система посчитает, что получила пакет по сети из tun1, и перекинет его на настоящий интерфейс, откуда он уйдет в интернет. Ответ пойдёт похожим образом, но в обратную сторону: из tun1 в tun0. Всё очень просто, но работать не будет.

Два туннеля используются по-разному. Для tun0 ядро сформирует пакет и отправит его в туннель, на этом всё - ядро свою работу выполнило. Доставка пакета теперь это зона ответственности программы. Просто перекидываем пакет в другой туннель. Для ядра это равносильно получению пакета по сети. По задумке нужно, чтобы система форварднула полученный пакет в интернет через реальный сетевой интерфейс. Для этого нужно включить ip_forward командой:

sysctl -w net.ipv4.ip_forward=1

После этого ядро будет пересылать IP-пакеты между сетевыми интерфейсами, в том числе между tun1 и eth0 (физический интерфейс на конкретной машине).

Но этого недостаточно. Если пакет перекинуть "как есть", система его дропнет как невалидный: src адрес из чужой подсети. Для исходящего пакета нужно заменить src, для входящего - dst. Немного "портит" простоту не столько замена адресов, сколько необходимость пересчета контрольной суммы. Благо, что контрольная сумма для IP-пакета считается легко: грубо говоря, суммируем uint16-ами содержимое заголовка.

func IPChecksum(data []byte) uint16 {
	size := int(data[0]&0x0f) * 4

	sum := 0
	for i := 0; i < size-1; i += 2 {
		if i == ChecksummOffset {
			continue
		}
		sum += int(binary.BigEndian.Uint16(data[i:]))
	}
	if size%2 == 1 {
		sum += int(data[len(data)-1]) << 8
	}
	for (sum >> 16) > 0 {
		sum = (sum & 0xffff) + (sum >> 16)
	}
	return ^uint16(sum)
}

Между чтением и записью пакета, добавляем универсальный "nat", который может менять src, dst в зависимости от параметров.

func pipe(a io.Reader, b io.Writer, newSrc, newDst *net.IP) {
//...
		n, err := a.Read(buf)
//...
		nat(newSrc, packet, newDst)
//...
		_, err = b.Write(packet)
}
		
func nat(packet []byte, newSrc, newDst *net.IP) {
	if newSrc != nil {
		iptool.PutSrc(packet, [4]byte(newSrc.To4()))
	}
	if newDst != nil {
		iptool.PutDst(packet, [4]byte(newDst.To4()))
	}
	if newSrc != nil || newDst != nil {
		cs := iptool.IPChecksum(packet)
		iptool.PutIPChecksum(packet, cs)
	}
}

Еще одна важная деталь. IP tun1: 10.0.1.2, но при перекладывании пакета tun0 > tun1 меняем адрес на 10.0.1.3. Для системы это выглядит так: получили пакет не для нас, но из нашей подсети; ip_forward включен, значит нужно отправить его дальше, куда он там идет - в интернет, например.

	tun0 := mustCreateTun("tun0", "10.0.0.2/24")
	tun1 := mustCreateTun("tun1", "10.0.1.2/24")
	
	go pipe(tun0, tun1, "10.0.1.3", nil)
	go pipe(tun1, tun0, nil, "10.0.0.2")

Система форвардит пакет из tun1 в физический интерфейс, но для этого "прыжка" тоже нужен nat, в данном случае системный. Иначе пакет уйдет в провайдера с серым IP 10.0.1.3, и ответа мы не дождемся.

iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE

При движении пакета в обратную сторону настоящий nat отслеживал бы соединения, но здесь всё проще: всегда меняем dst на заданный IP для tun0.

Пробуем, паралельно наблюдая через tcpdump:

ping -I tun0 8.8.8.8
PING 8.8.8.8 (8.8.8.8) from 10.0.0.2 tun0: 56(84) bytes of data.
64 bytes from 8.8.8.8: icmp_seq=1 ttl=105 time=42.2 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=105 time=42.2 ms
tcpdump -i any -n icmp
13:01:42.402490 tun0  Out IP 10.0.0.2 > 8.8.8.8: ICMP echo request
13:01:42.402529 tun1  In  IP 10.0.1.3 > 8.8.8.8: ICMP echo request
13:01:42.402548 enp5s0 Out IP 192.168.1.142 > 8.8.8.8: ICMP echo request
13:01:42.444645 enp5s0 In  IP 8.8.8.8 > 192.168.1.142: ICMP echo reply
13:01:42.444653 tun1  Out IP 8.8.8.8 > 10.0.1.3: ICMP echo reply
13:01:42.444681 tun0  In  IP 8.8.8.8 > 10.0.0.2: ICMP echo reply

Видно, как пакет появился на tun0, программа вычитала его, заменила IP и закинула в tun1. Дальше система перекинула пакет (также выполнив nat на внешнем интерфейсе) на реальный интерфейс, и он ушел в интернет. Ну и ответ проделал обратный путь.

Заметку пишу вместе с программой. А что насчет tcp?

curl --interface tun0 ifconfig.me

И - нет, ожидаемо не работает. У tcp своя контрольная сумма, которой покрыты также src и dst из IP-заголовка. Значит, она ломается, когда я меняю адреса. Что интересно, система отправляет бракованный пакет.

 sudo tcpdump -ni any -nn port 80
13:21:55.177738 tun0  Out IP 10.0.0.2.52998 > 34.160.111.145.80
13:21:55.177784 tun1  In  IP 10.0.1.3.52998 > 34.160.111.145.80
13:21:55.177795 enp5s0 Out IP 10.0.1.3.52998 > 34.160.111.145.80

Полностью пересчитывать контрольную сумму для tcp дорого (хотя в данном случае неважно). Хорошо, что можно её инкрементально обновить.

func RecalcTCPChecksum16(checksum uint16, oldVal uint16, newVal uint16) uint16 {
	sum := uint32(^checksum) + uint32(^oldVal) + uint32(newVal)

	sum = (sum & 0xffff) + (sum >> 16)
	sum = (sum & 0xffff) + (sum >> 16)

	return ^uint16(sum)
}

В nat нужно добавить пересчет суммы для tcp только при изменении адресов:

func nat(packet []byte, newSrc, newDst *net.IP) {
//...
		checksum := iptool.TCPChecksum(tcpPacket)
		if newSrc != nil {
			checksum = iptool.RecalcTCPChecksumIP(checksum, src, [4]byte(newSrc.To4()))
		}
		if newDst != nil {
			checksum = iptool.RecalcTCPChecksumIP(checksum, dst, [4]byte(newDst.To4()))
		}
		iptool.PutTCPChecksum(tcpPacket, checksum)
}

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

Практический смысл этого конкретного решения, честно говоря, неочевиден. Возможно, продолжу эксперименты - будет видно. Но только если доберусь: пока ковырялся, попались другие интересные вещи - gvisor netstack и ebpf, которые настоятельно требуют внимания.

Ссылка на репу.