Сидел на днях, в Pnetlab лабу делал, добавил туда пару объектов типа VPC — и заметил что к консоли их не могу подключится :( Пришлось разобраться Читать далее
Уровень сложностиСредний
Время на прочтение12 мин
Охват и читатели7K
Репортаж
Я вот очень люблю 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 30123IPv6 сокет создаётся и даже готов принимать подключения:
ss -lntp6 | grep 39999
*:39999Если теперь протелнетить порт 39 999 по IPv6 адресу Pnetlab‑а — то я ожидаем попадал в консоль VPC по IPv6 адресу. Бинго!
Бинго, да не бинго! Есть ряд проблем:
Надо модифицировать запуск VPC из пнетлаба — что бы он ещё подтягивал запуск враппера
Я не могу запустить враппер слушать тот же порт, на котором у меня висит консоль уже — сокет то уже занят!
2.1. То есть надо какую‑то дополнительную логику придумать — выбор НОВОГО свободного порта и маппинг его на существущий, где‑то какую‑то табличку хранить с маппингом
2.2. Донести это как‑то в web — что бы сообщать юзеру правильный, отвраппированный порт
2.3. Как‑то мусор за собой чистить потом ещё

Карина.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_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;
На С я не писал больше 20 лет, поэтому вообще не был уверен, что оно скомпилируется даже. Однако:
cd /root/vpcs-ipv6/src
./mk.sh 64gcc -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) = 0ss -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 в вебморде как и раньше, жамкать мышкой по нему — и открывается телнет сессия нормально. Всё просто оказалось.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | OPERATION MOONLIGHT 1.0: курс языка C, который я сам себе собрал | 0 | 10.83 | 01-09-2026 |
| 2 | DPI для любопытствующих | 0 | 9.58 | 09-08-2026 |
| 3 | Баги на диком западе: топ-10 ошибок в C и C++ проектах за 2025 год | 0 | 8.06 | 30-12-2025 |
| 4 | Тимлид и subnet-полукровка: как одна строчка в YAML стоила $50 000 и двенадцати часов прода | 0 | 8.44 | 10-08-2026 |
| 5 | Дайджест C++: новости, полезные материалы и «свой язык» на десерт | 0 | 7.94 | 27-05-2026 |
| 6 | Свой VPN на Rust: как я спорил с сетью, TLS и самим собой | 0 | 8.78 | 27-06-2026 |
| 7 | Свой VPN на Rust: как я спорил с сетью, TLS и самим собой | 7 | 8 | 27-06-2026 |
| 8 | Парсер C++ на своем DSL: попытка обогнать компилятор | 0 | 7.2 | 14-07-2026 |
| 9 | Docker Fundamentals: полный гайд по сетям и драйверам | 0 | 8.71 | 24-08-2026 |