Я вот очень люблю IPv6. И в своей инфраструктуре на работе мы его во всю используем. И ещё у нас на работе есть сервера, на которых мы запускаем всякие лабораторные окружения. В общем, развернули, кароче, Pnetlab в Ipv6 only среде. Ну развернули и развернули — работает и вроде есть не просит. Запускаю я там всякие frr‑ы и прочие Аристы и в ус не дую. Как я обычно с этим работаю? Запустил ноду виртуальную какую‑то, у неё есть так называемая primary console — тот тип приложения с помощью которого ты можешь к ней подключится со своего компа.

Ну и при нажатии на объект в web‑морде у тебя появляется url типа telnet://Address_of_your_PNETLAB:PORT Ну и соответственно на компе запускается стандартное приложение, которое отвечает за телнет и ломится на ваш IPv6 адрес — дальше магическим образом это всё пробрасывается в серийную консоль эмулируемого устройства. Никогда особо не было с этим проблем, но тут появилась. Я использую стандартный объект VPC — некий контейнер с базовыми сетевыми функциями — IP там назначить, пинг проверить, трассировку. Для его настройки я обычно тоже захожу на адрес pnetlab‑а через telnet, там что‑то такое вот появляется.

фото на тапок, кстати, сделано
фото на тапок, кстати, сделано

Ну то есть — вот есть порт, и если провалится на этот порт 30 102 на адрес pnetlab — то я должен попасть в серийную консоль. Но что‑то идёт не так — нажимаю мышкой, ожидаемо запускается putty, но через несколько секунд выдаёт:

Нажимаю тут же на Аристу, к которой этот VPC подключен, там всё хорошо:

В общем, пришлось немножк потраблшутить:(

Первая мысль — pnetlab где‑то проебался и не прикрутил серийную консоль к VPC. Как проверить? Захожу в pnetlab через HTML5 и пробую через гвакомоль, там всё хорошо, ожидаемый фрейм в вебе появляется:

Значит сам по себе механизм серийной консоли для VPC работает и проблема ЯВНО СЕТЕВАЯ (кляты сетевики!)

Иду на хост с pnetlab, смотрю дампом по моему порту:

tcpdump -ni any 'tcp port 30102'

И вижу что‑то типа

ROMA_IPv6 > PNETLAB_IPv6.30123: Flags [S]
PNETLAB_IPv6.30123 > ROMA_IPv6: Flags [R.]
ROMA_IPv6 > PNETLAB_IPv6.30123: Flags [S]
PNETLAB_IPv6.30123 > ROMA_IPv6: Flags [R.]

Ну то есть я шлю SYN‑ы, хост говорит — «Ну не, мне это не интересно, я в этом учавствовать не буду»

На всякий случай, во пример нормального взаимодействия (на телнет порт Аристы — 30118):

ROMA_IPv6 > PNETLAB_IPv6.30118: Flags [S]
PNETLAB_IPv6.30118 > ROMA_IPv6: Flags [S.]
ROMA_IPv6> PNETLAB_IPv6.30118: Flags [.]

Обычный такой милкхендшшейк в общем, ну и дальше обмен трафиком as usuall

Теперь надо понять, а чё, точно хост порт 30 123 (проблемный) не готов обрабатывать? Чекаем

ss -lntp4 | grep -E '30118|30123'

и

ss -lntp6 | grep -E '30118|30123'

Для нашей VPC видем что‑то такое:

LISTEN ... 0.0.0.0:30123 ... users:(("vpcs",pid=16438,fd=5))

Тогда как для Аристы:

LISTEN ... *:30118 ... users:(("nc"...),("sh"...),("qemu_wrapper_te"...))

Ну то есть получается — что для VPC порт просто напросто не слушается на IPv6 адресе, а слушается только для Ipv4! Опять IPv6 обделили и сокета не создали! А из IPv4 адресов у меня только 127.0.0.1 — и это в целом объясняет то, что гуакамоле коннект устанавливает.

А кто вообще эти сокеты создавать то должен?

Почему одна console слушает [::], а вторая 0.0.0.0? Смотрим процессы. Для VPCS:

ps aux | grep '[v]pcs'

У меня команда примерно такого вида:

/opt/vpcsu/bin/vpcs -m 123 -N vrf-A-PC-1 -i 1 -p 30123 -e -d vunl123_0

То есть console‑port 30 123 передаётся не какому‑то универсальному PNETLab telnet‑сервису, а прямо самому VPCS:

-p 30123

И уже процесс vpcs владеет этим socket.

А у QEMU архитектура другая:

/opt/unetlab/wrappers/qemu_wrapper_telnet \
    -P 30118 \
    -t Leaf1 \
    -- nc -U /opt/unetlab/tmp/19/118/console.sock

Получаются совершенно разные схемы. Кему ещё какой‑то враппер использует промежуточный. Тут главный итог какой — я думал Pnetlab чудит и косячит с «публикацией» VPC консоли, но нет, ему вообще срать — он просто выбирает какой‑то свободный TCP порт и передаёт его в качестве параметра при запуске бинарника VPC — тот уже сам по себе является TCP‑сервером для сервиса серийной консоли.

Гоу немножко глубже в траблшут

Ок, на этом шаге понимаем, консоль VPC слушает IPv4:

0.0.0.0:30123   users:(("vpcs",pid=...,fd=...))

Но кто и в какой момент это вообще решает? Лучше чем strace нам на этот вопрос никто не ответит. Ну либо в исходниках копаться, да. Но в кодяре я вообще ничего не шарю. Напомню, что я вообще сетевик и по‑хорошему, даже strace запускать не должен. Но… Если совсем коротко, strace позволяет посмотреть системные вызовы, которые пользовательская программа делает в ядро Linux. Программулинка должна попросить батю (ядро) сделать сокет для себя, куда‑то его прибиндить и начать слушать входящий трафик. (тут бы код вставить, но я сетевик, ещё раз говорю, и в код не лезу). Именно эти вызовы и хочется увидеть. Запускаем новый экземпляр VPC с strace так:

strace -f -e trace=socket,bind,listen \
  /opt/vpcsu/bin/vpcs -p 39999

-f — просто говорит что нужно ещё и за детишками присматривать, мало ли, может там кто‑то шалит

-e trace=socket,bind,listen — некий фильтр того, что нам интересно — очевидно программа в линукс будет делать мильён всякого, но нам интересно только про то, как создаётся сокет, как он биндиться и как предполагается его использование вообще.

В выводе я ожидаю увидеть что нибудь типа

socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)

Главное тут именно AF_INET — это явно подтвердит, что в коде программы явно зашито отутствие поддержки IPv6, и она запускает именно IPV4 сокет, иначе было бы что‑то типа AF_INET6

В общем, запустили, ищем глазками полезное. Находим:

socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 4
bind(4, {
    sa_family=AF_INET,
    sin_port=htons(39999),
    sin_addr=inet_addr("0.0.0.0")
}, 16) = 0
listen(4, 5) = 0

То есть буквально socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 4 — создай потоковый TCP сокет IPv4! Создало, вернуло файловый описатель (file descriptor) = 4, далее биндим его к нашему порту и вешаем на ALL‑IPv4-Addreses слушать входящий трафик.

Я сетевик и не хочу лезть в код! Повторяю третий раз!

Ну ок, кажется нужно просто это принять — VPC реально не умеет нативно делать IPv6 сокеты. Ну подождём ещё лет 10 когда поддержку IPv6 довезут, делов‑то. Но что делать сейчас? Напомню, что у нас есть некий враппер, который запускается для QEMU‑машинок и заставляет их «перенаправлять» подключения в другие сокеты, то есть для Аристы это было так:

/opt/unetlab/wrappers/qemu_wrapper_telnet \
    -P 30118 \
    -t Leaf1 \
    -- nc -U /opt/unetlab/tmp/19/118/console.sock

Читаем как — хочешь пообщаться со мной по порту 30118? Общайся вон с сокетом в /opt/unetlab/tmp/19/118/console.sock. Порой это сложно осознать, но сокет — это файл, да.

В общем, если у нас VPC запустилась и «слушает», например порт 30123, то можно рядом враппер запустить, вот так например:

/opt/unetlab/wrappers/qemu_wrapper_telnet \
    -P 39999 \
    -t "VPCS-test" \
    -- nc 127.0.0.1 30123

IPv6 сокет создаётся и даже готов принимать подключения:

ss -lntp6 | grep 39999
*:39999

Если теперь протелнетить порт 39 999 по IPv6 адресу Pnetlab‑а — то я ожидаем попадал в консоль VPC по IPv6 адресу. Бинго!

Бинго, да не бинго! Есть ряд проблем:

  1. Надо модифицировать запуск VPC из пнетлаба — что бы он ещё подтягивал запуск враппера

  2. Я не могу запустить враппер слушать тот же порт, на котором у меня висит консоль уже — сокет то уже занят!

    1. 2.1. То есть надо какую‑то дополнительную логику придумать — выбор НОВОГО свободного порта и маппинг его на существущий, где‑то какую‑то табличку хранить с маппингом

    2. 2.2. Донести это как‑то в web — что бы сообщать юзеру правильный, отвраппированный порт

    3. 2.3. Как‑то мусор за собой чистить потом ещё

Карина.jpg
Карина.jpg

Лезу в код, короч:(

В общем, тут я сдался и решил что надо просто найти в исходнике VPC что‑то типа socket(AF_INET, ...) и заменить на socket(AF_INET6, ...), хехе PNETLab запускает бинарник — /opt/vpcsu/bin/vpcs, посему просто спрашиваем его версию /opt/vpcsu/bin/vpcs -v. В ответ получаем:

Welcome to Virtual PC Simulator, version 1.3 (0.8.1)
Build time: May 7 2022 ...
...
Modified version for EVE-NG.

Вот здесь сразу появляется важная информация. Во‑первых, в основе установленного бинарника лежит VPCS 0.8.1. Во‑вторых:

Modified version for EVE-NG — бубубу, что ж они там модифицировали, мы конечно не узнаем. Ну лан, хер с ним. Исходники этого всего лежат тут — https://github.com/GNS3/vpcs

Немного системно‑контрольно‑версионной магии:

cd /root
git clone --branch v0.8.1 --depth 1 https://github.com/GNS3/vpcs.git /root/vpcs-ipv6
cd /root/vpcs-ipv6
git checkout v0.8.1

Так, у нас есть исходники, которые мы оптимистично будем считать базой для IPv6-ready модификации.

А вот что делать дальше не понятно — когда не понятно надо спросить у кого‑то. Так как у меня нет друзей, которые шарят — пора спрашивать у LLM:). В общем, искали мы вместе, где же может хранится кодяра, которая сокеты создаёт по ключевым словам из strace — AF_INETSOCK_STREAM и IPPROTO_TCP Поиск привёл нас в src/daemon.c:

/* daemon socket */
sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock < 0) {
    perror("Daemon socket");
    goto err;
}

(void) setsockopt(sock, SOL_SOCKET, SO_REUSEADDR,
    (char *)&on, sizeof(on));

bzero((char *) &serv, sizeof(serv));
serv.sin_family = AF_INET;
serv.sin_addr.s_addr = htonl(INADDR_ANY);
serv.sin_port = htons(port);

if (bind(sock, (struct sockaddr *) &serv, sizeof(serv)) < 0) {
    ...
}

listen(sock, 5);

Вот же оно! Создание сокета:

sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

и какой‑то мутный бинд:

bind(sock, (struct sockaddr *) &serv, sizeof(serv))

А потом слушаем:

listen(sock, 5);

Всё один в один (ну почти) как было в выводе strace:

socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 4
bind(4, {
    sa_family=AF_INET,
    sin_port=htons(39999),
    sin_addr=inet_addr("0.0.0.0")
}, 16) = 0
listen(4, 5) = 0

Сам кусок, кода, который начинается с /* daemon socket */ — это часть функции daemonize() внутри модуля daemon.c В качестве одного из аргументов функция получает номер порта. Функция daemonize() бызывается из main()‑а основного модуля запуска VPC — vpcs.c и туда передаётся номер порта, который парсится из аргумента, который передаётся бинарнику через параметр -p То есть, запускается vpcs вот так: ./vpcs -p 39999 То, что после -p записывается в переменную daemon_port, которая передаётся аргументом в daemonize()

Разобрались что к чему — кодируем!

Для начала мы заменяем тип данных для переменной serv со структуры sockaddr_in на структуру sockaddr_in6 — нам же Ipv6 адрес нужен!

то есть меняем:

struct sockaddr_in serv;

на:

struct sockaddr_in6 serv;

чуть выше в коде.

Дальше меняем сам тип создаваемого сокета. Было:

sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

Здесь AF_INET означает IPv4. Меняем на:

sock = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);

Теперь VPCS просит ядро создать уже IPv6 TCP‑сокет. Но просто создать IPv6-сокет неинтересно. Важно, чтобы он при этом продолжал принимать и IPv4-подключения. Иначе мы бы просто заменили одну проблему на другую. Для этого добавляем ещё одну переменную:

int v6only = 0;

и после SO_REUSEADDR явно говорим ядру, что сокет не должен быть IPv6-only:

if (setsockopt(sock, IPPROTO_IPV6, IPV6_V6ONLY,
    (char *)&v6only, sizeof(v6only)) < 0) {
    perror("Daemon IPV6_V6ONLY");
    goto err;
}

Cмысл буквально такой — это, конечно, IPv6-сокет, но и про IPv4 забывать не стоит. Дальше надо поправить адрес, который передаётся в bind().

Раньше структура serv заполнялась как IPv4:

bzero((char *) &serv, sizeof(serv));
serv.sin_family = AF_INET;
serv.sin_addr.s_addr = htonl(INADDR_ANY);
serv.sin_port = htons(port);

INADDR_ANY здесь означает — 0.0.0.0. То есть «слушать на всех IPv4-адресах сервера». Но теперь‑то у нас IPv6, поэтому и поля у неё другие! Меняем этот кусок на:

bzero((char *) &serv, sizeof(serv));
serv.sin6_family = AF_INET6;
serv.sin6_addr = in6addr_any;
serv.sin6_port = htons(port);

Здесь in6addr_any — это IPv6-аналог INADDR_ANY, то есть адрес ::

В итоге раньше мы готовили для bind() адрес: 0.0.0.0:<port>, а теперь готовим: [::]:<port>

Такие вот сетевые C‑дела, Кариночка.

Всё? Нет, не всё!

Сокет‑то мы создали, биндинги сделали, даже повесили что бы слушал трафик входящий. А что дальше‑то будет, когда трафик то придёт? Нада ещё accept() для трафика править, значит. А что такое этот наш accept()? Это стандартный системный вызов socket API, который используется сервером для того, чтобы забрать очередное входящее TCP‑соединение. То есть, сделали listen() — перевели созданный сокет в состояние «готов принимать соединение». Работы сделано много, VPCS устал и хочет поспать, но перед тем как идти спать он делает accept() и как бы говорит ядру: «Так, я спать, разбуди как кто придёт по мою душу»

Когда кто‑то извне на этот порт стучится, ядро линукса поздоровается с ним (хандшейк тот самый), создаст ещё один socket — на этот раз с полноценным 4-tuple в состоянии ESTABLISHED. Дальше ядро такое «Алло, VPCS, спишь? Лови дескриптор с соединением, я сделаль». VPCS просыпается (реально же спал) и получает дескриптор на созданный полноценный tcp‑сокет — может там читать\писать.

Кароче, этот accept() тоже какую‑то переменную использует которая в формате sockaddr_in — тут тоже немножко бы поправить, потому что то что мы правили для создания сокета — это про нас, про сервер, а accept у нас история про клиентский IP — а он тоже же IPv6 будет

Поэтому заменили sockaddr_in на структуру sockaddr_storage. В этот раз использовалась более универсальная sockaddr_storage, потому что здесь мы уже не формируем адрес сами, как в случае с bind(), а отдаём ядру буфер, куда оно запишет адрес подключившегося клиента. sockaddr_storage как раз и нужен как универсальный контейнер, который подходит и для IPv4, и для IPv6.

В общем, основное меняем struct sockaddr_in cli; на struct sockaddr_storage cli;

Собираем и проверяем IPv6-патч

На С я не писал больше 20 лет, поэтому вообще не был уверен, что оно скомпилируется даже. Однако:

cd /root/vpcs-ipv6/src
./mk.sh 64
gcc -O2 -m64  -DLinux -Dx86_64 -DHV -Wall -DTAP -c vpcs.c
gcc -O2 -m64 vpcs.o daemon.o readline.o packets.o utils.o queue.o \
command.o dev.o dhcp.o command6.o packets6.o ip.o tcp.o inet6.o dns.o \
remote.o help.o dump.o relay.o hv.o frag.o frag6.o \
-o vpcs -lpthread -lutil

Появился новый бинарник по адресу /root/vpcs-ipv6/src/vpcs. Ну я боялся его просто так запускать — вдруг мой новый код это завуалированный rm -rf / или три цыфры с карточки кому‑то передаст, поэтому я бахнул strace, чтобы наверняка:

strace -f \
  -e trace=socket,setsockopt,bind,listen \
  ./vpcs -p 39999

Ну и норм кароче стало:

socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP) = 4
setsockopt(4, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
setsockopt(4, SOL_IPV6, IPV6_V6ONLY, [0], 4) = 0
bind(4, {
    sa_family=AF_INET6,
    sin6_port=htons(39999),
    inet_pton(AF_INET6, "::", ...)
}, 28) = 0
listen(4, 5) = 0

ss -lntp6 | grep 39999 показывает нужное LISTEN 0 5 :39999 :* users:(("vpcs",pid=...,fd=4)) и даже внешний telnet на ipv6 адрес по порту 39 999 возвращал консоль VPC

Последняя засада

Казалось бы, всё уже готово: новый VPCS собирается, слушает одновременно IPv4 и IPv6, telnet работает. Но перед заменой штатного бинарника я решил посмотреть, как именно PNETLab запускает VPCS. И заметил в командной строке вот такой параметр -N vrf-A-PC-1 Например:

/opt/vpcsu/bin/vpcs \
  -m 123 \
  -N vrf-A-PC-1 \
  -i 1 \
  -p 30123 \
  -e \
  -d vunl123_0

При этом в оригинальном upstream VPCS 0.8.1 параметра ‑N вообще нет. Если запустить наш новый бинарник так же:

./vpcs -N TEST-PC -p 39998

он честно отвечает — «не знаю», то есть invalid option -- 'N'

То есть просто заменить штатный бинарник пока нельзя — пнетлаб будет запускать его с неизвестным аргументом. Интуитивно я сначала решил, что -N как‑то передаёт в VPCS hostname или имя устройства. Он запускает его примерно так -N vrf-A-PC-1, что очень похоже на имя ноды. Проверили — не. Prompt от этого параметра не меняется.

Дальше начался небольшой отдельный квест: пришлось залезть в штатный EVE‑NG‑бинарник с gdb и «немного» поковырять ассемблер.

Два часа времени с LLM b выяснилось, что всё гораздо прозаичнее: -N используется только для передачи имени в заголовок telnet‑сессии. На сам VPCS, его hostname, сеть и работу консоли этот параметр не влияет.

Кароч, заглушку бахнул для параметра прост и всё:

- "?c:efhm:p:r:Rs:t:uvFi:d:"
+ "?c:efhm:p:r:Rs:t:N:uvFi:d:"

И обработчкие main()‑е:

case 'N':
    /* EVE-NG/PNETLab compatibility:
       terminal window name is ignored by ROMOCHKA*/
    break;

Заменил бинарник и сижу довольный

Пересобрал ещё разок

cd /root/vpcs-ipv6/src
./mk.sh 64

Чекнул с опцией — ./vpcs -N TEST-PC -p 39998 — на опцию не ругается, функционал пашет. Можно подкидывать пнетлабу эту историю

Фиксируем инфу про старый бинарник stat /opt/vpcsu/bin/vpcs:

Access: (0777/-rwxrwxrwx)
Uid:    root
Gid:    unl

Хеши

sha256sum \
  /opt/vpcsu/bin/vpcs \
  /root/vpcs-ipv6/src/vpcs

Делаем резервную копию штатного бинарника:

cp -a \
  /opt/vpcsu/bin/vpcs \
  /opt/vpcsu/bin/vpcs.original-20260826

=

Проверили:

ls -l /opt/vpcsu/bin/vpcs*
-rwxrwxrwx 1 root unl 221720 ... /opt/vpcsu/bin/vpcs
-rwxrwxrwx 1 root unl 221720 ... /opt/vpcsu/bin/vpcs.original-20260826

После этого уже заменили штатный бинарник нашим.

Ну и всё кароче — теперь можно просто добавлять ручками VPC в вебморде как и раньше, жамкать мышкой по нему — и открывается телнет сессия нормально. Всё просто оказалось.