Я вот очень люблю 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 адресу. Бинго!
Бинго, да не бинго! Есть ряд проблем:
Надо модифицировать запуск VPC из пнетлаба — что бы он ещё подтягивал запуск враппера
Я не могу запустить враппер слушать тот же порт, на котором у меня висит консоль уже — сокет то уже занят!
2.1. То есть надо какую‑то дополнительную логику придумать — выбор НОВОГО свободного порта и маппинг его на существущий, где‑то какую‑то табличку хранить с маппингом
2.2. Донести это как‑то в web — что бы сообщать юзеру правильный, отвраппированный порт
2.3. Как‑то мусор за собой чистить потом ещё

Лезу в код, короч:(
В общем, тут я сдался и решил что надо просто найти в исходнике 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_INET, SOCK_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 в вебморде как и раньше, жамкать мышкой по нему — и открывается телнет сессия нормально. Всё просто оказалось.

