ZNetLab
Where each half actually runs, how to put licensed vendor images on the server, and what happens between pressing Start and a router printing its first prompt.
Part I
ZNetLab is two things with very different needs: a pile of static files, and a Linux machine with root. Hostinger can do both — but not on the same product, and one of the two obvious layouts does not work at all.
| Half | What it is | What it needs |
|---|---|---|
| The site | HTML, CSS, JavaScript — the home page, the lessons, the simulator, and the Real labs interface itself. All of it runs in the visitor's browser. | Somewhere to serve files over HTTPS. Nothing else. No PHP, no database, no Node. |
| The lab server | One Node process that boots vendor images and wires them together. | Root on Linux. Network namespaces, veth, bridges, tap devices,
/dev/net/tun, ideally /dev/kvm, and a process that stays
running. Node 16 or newer. |
require is a Node builtin or a file in the repo. There is no
npm install, no lockfile, nothing to go stale. node server.js is
the whole thing.| Hostinger product | The site | The lab server |
|---|---|---|
| Shared hosting (Premium, Business, Cloud) |
Yes. Upload the folder, point the domain at it, done. | No, and not with any amount of effort. No root, no kernel access, no
long-running process. ip netns add is not a command you can run
there. |
| VPS (KVM VPS) | Yes — nginx serves the files. | Yes. Full root Ubuntu. This is the one you need. |
wss:// + location.host. On a shared host that is the shared host,
which cannot proxy a WebSocket. There is no field for a user to type a different address;
that was deliberately removed, because the address belongs to the account's plan.
host only — any path is
discarded. So the lab server has to own the root of an origin. You cannot mount it at
https://yoursite.com/labs/.Put everything on one VPS. nginx serves the static site at / and hands
the WebSocket upgrade and /api/* to the Node process on
127.0.0.1:8080. The site and the lab server are then the same origin, which
makes the default in the sign-in box correct instead of wrong, and there is no CORS, no
mixed content and no second certificate.
Any Hostinger KVM VPS with Ubuntu 22.04 or 24.04. Size it by memory, because that is what runs out first: roughly 512 MB per IOSv node and 4 GB per CSR or Nexus, plus about 2 GB for the system. 8 GB is a sensible start; 16 GB if more than one person uses it. Give it a disk you will not regret — images are 1–4 GB each.
In Hostinger's DNS, add an A record for a subdomain pointing at the VPS IP:
# DNS labs.yourdomain.com. A 203.0.113.45
ssh root@203.0.113.45 apt-get update && apt-get install -y git nginx git clone <your znetlab repo> /opt/znetlab-src cd /opt/znetlab-src/znetlab/server ./deploy.sh
deploy.sh installs the packages, makes the directories, asks for the
administrator password, writes a systemd unit, verifies the kernel can actually do what
ZNetLab needs, starts the server and then proves it with a real lab. If the kernel checks
fail it stops rather than starting a server that would build labs which silently do not
forward.
server/ with its siblings. The server
requires ../assets/plans.js and ../assets/platforms.js
and spawns ../tools/kb.js. Copying the server folder on its own will
not run.mkdir -p /var/www/znetlab cp -r /opt/znetlab-src/znetlab/* /var/www/znetlab/ # the site is the HTML/JS/CSS; server/ and tools/ do not need to be public, # but leaving them there is harmless — nothing serves them as code.
By default it binds 0.0.0.0:8080 — every interface. Behind nginx it
should answer only nginx. Edit the systemd unit deploy.sh wrote:
nano /etc/systemd/system/znetlab.service # on the ExecStart line, add: --host 127.0.0.1 \ --lab-url wss://labs.yourdomain.com systemctl daemon-reload && systemctl restart znetlab
--lab-url is not optional here. Without it the
server hands out ws:// + its own internal hostname — something like
ws://srv123456:8080, which no browser on the internet can resolve, and
which an HTTPS page would refuse anyway as mixed content. Write the scheme:
wss://, not a bare host, or it defaults to insecure ws://.You can also give different plans different machines, which is how the free tier and a paid one end up on different boxes:
--lab-url-free wss://free-labs.yourdomain.com --lab-url-pro wss://labs.yourdomain.com --lab-url-enterprise wss://labs.yourdomain.com
# /etc/nginx/sites-available/znetlab
server {
listen 80;
server_name labs.yourdomain.com;
root /var/www/znetlab;
index index.html;
# The API and the image upload.
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Images are large. 8192 MB is the server's own default cap.
client_max_body_size 0;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# The WebSocket connects to the ROOT path, so this location has to serve
# both the home page and the upgrade. Told apart by the Upgrade header.
location / {
if ($http_upgrade = "websocket") {
proxy_pass http://127.0.0.1:8080;
}
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
# Consoles sit idle while somebody reads. There is no application-level
# keepalive, so a short read timeout silently kills live sessions.
proxy_read_timeout 86400s;
try_files $uri $uri/ /index.html;
}
}
ln -s /etc/nginx/sites-available/znetlab /etc/nginx/sites-enabled/ nginx -t && systemctl reload nginx # TLS, free, and it edits the config for you apt-get install -y certbot python3-certbot-nginx certbot --nginx -d labs.yourdomain.com
ufw allow 22/tcp ufw allow 80/tcp ufw allow 443/tcp ufw enable # NOT open: 8080 (nginx reaches it on localhost) # NOT open: 23000-23999, the raw telnet consoles
telnet <labhost> 23000–23999 — a direct TCP connection to the
box that does not go through nginx or TLS. On a public server, leave those ports
closed and use the console in the browser. If a team genuinely needs external terminals, put
them behind a VPN or an SSH tunnel; do not open a telnet range to the internet.A Hostinger VPS is itself a virtual machine. Whether it can run another virtual machine inside itself — nested virtualisation — decides how fast QEMU images boot.
# on the VPS, after deploy.sh ls -l /dev/kvm egrep -c '(vmx|svm)' /proc/cpuinfo
| Result | What it means |
|---|---|
/dev/kvm exists |
ZNetLab adds -enable-kvm -cpu host and QEMU images run at full speed. |
No /dev/kvm |
QEMU still works, under software emulation — roughly ten times slower. A CSR that boots in two minutes takes twenty. Dynamips, IOL and the three built-in devices are completely unaffected, so an IOL-based CCNA or CCNP lab is still perfectly usable. |
.metal instance. That comparison is in the Real labs guide, Part III.# the kernel /opt/znetlab-src/znetlab/server/verify.sh # the running server, driven over its own socket node /opt/znetlab-src/znetlab/server/verify-lab.js ws://127.0.0.1:8080 admin <password> # from your laptop — through nginx and TLS this time curl https://labs.yourdomain.com/api/info
Then the human check, which takes a minute and needs no image at all: open the site, sign in, drop two Virtual PCs and a LAN Switch on the canvas, cable them, press Start lab, and ping across. If that works, the datapath works.
Part II
ZNetLab ships no vendor software and never will. You supply the images you are licensed to run — and once one is on the server, nothing about it needs configuring by hand.
Everything ends up in the same folder, and ZNetLab takes it from there. There is no path to type and no form to fill in.
# the drop folder, printed by deploy.sh when it finishes /opt/znetlab/images/incoming/
| Route | How |
|---|---|
| From your laptop | scp csr1000vng-17.03.qcow2 root@labs.yourdomain.com:/opt/znetlab/images/incoming/ |
| The Images tab | Sign in as an administrator, open Images, drop the file on the upload area. It
goes over the same socket, through nginx — which is why
client_max_body_size 0 matters. |
| From cloud storage | aws s3 cp s3://your-images/… /opt/znetlab/images/incoming/ or
azcopy copy … /opt/znetlab/images/incoming/. Keeping the licensed
originals in a private bucket means a rebuilt VPS is a download, not a re-upload. |
| Already on the box | An existing image library can be copied or mounted. The naming is the same. |
The importer watches that folder. Nothing is typed; every step below is automatic.
| Step | What it does | Why |
|---|---|---|
| Settle | Waits until the file has stopped growing for 2.5 seconds and is at least 512 KB. | Copying 2 GB over a network takes time. Without this it would grab half a file. |
| Sniff | Reads the first bytes and decides what the file is. | Not the extension — vendors rename downloads, and a .img that is really
a zip is common. |
| Unpack | zip, gz, tar, xz, 7z. | You can drop the vendor's archive straight in. |
| Identify | Matches the name against 124 platforms. | This is what decides the emulator, the RAM, the NVRAM, the interface naming and the disk bus — so you never configure any of it. |
| Convert | qemu-img convert -O qcow2 when the disk needs it. |
A raw .img declared as qcow2 does not boot, and QEMU's error for it is
not obviously about the format. |
| Install | Files it as
library/<platform>-<version>/<disk>. |
Identity lives in the directory, which is why two builds of one platform never collide — both stay, both appear on the shelf. |
# one file, start to finish incoming/chr-7.15.3.img.zip → quiet for 2.5s → first bytes say PK, not QFI the extension was a lie; it is a zip → unpacked, chr-7.15.3.img falls out → matched mikrotik 7.15.3 → qemu-img convert -O qcow2 → library/mikrotik-7.15.3/virtioa.qcow2 → on the shelf, draggable
Get the name right and there is nothing else to configure. Get it wrong and ZNetLab does not guess: the file is set aside and said so.
c7200-adventerprisek9-mz.152-4.S6.image c3725-adventerprisek9-mz.124-15.T14.image # Cisco's own filename. Leave it exactly as it came.
i86bi-linux-l2-adventerprisek9-15.2d.bin i86bi-linux-l3-adventerprisek9-15.4.1T.bin # The build date is part of the name, not an extension.
iourc may sit
beside the image or in any folder above it, up to the top of the library — so one
licence can cover every IOL image on the server. ZNetLab copies it into the lab working
directory at boot; if there is none to find, the node will not start and the console says so.
library/iourc <- covers everything below it library/iol-15.2d/ ├── i86bi-linux-l2-adventerprisek9-15.2d.bin └── iourc <- or per image, keyed to the host
Cisco IOL/IOU images (i86bi…, x86_64…). Nothing else needs one. A plain-text file called iourc — a few hundred bytes, no extension. ZNetLab saves it under that name whatever your copy is called.
Three ways in, and no shell needed for any of them: Images → IOL/IOU licence
paste the licence as text; the same box takes the file if you have it; or drop the
iourc into incoming/ and it is filed as
library/iourc. Whichever you use, the file is read before it is kept — the
wrong file is refused there and then, with the format, instead of becoming a node that exits
at boot with an empty console.
# the format [license] <hostname> = <16 hex digits>; # for example, on a server whose hostname is znetlab-lab [license] znetlab-lab = a1b2c3d4e5f60718;
[license] lines with Unix endings, mode 0600. The same
normalising happens again when the licence is staged for a boot, so a file copied in by hand
with scp is covered too.# the DIRECTORY carries the identity; the disk keeps its own name library/vios-15.6.2T/virtioa.qcow2 library/csr1000vng-17.03.04a/virtioa.qcow2 library/asav-9.16/virtioa.qcow2
images/unidentified/ <- it went here, and the Images tab says so
Rename it the way the tables above show and drop it in again. ZNetLab refuses to guess on purpose: a guessed platform becomes a device that fails to boot twenty minutes later, in front of somebody who has already built a topology around it.
Open Images. An installed image shows its platform, which engine will run it, and whether it is on the shelf. Or from the shell:
ls -R /opt/znetlab/images/library/ ls /opt/znetlab/images/unidentified/ journalctl -u znetlab -f # the importer narrates every step
Before you put anything licensed on a new box, prove it with something free. All of these are legitimately downloadable and between them cover BGP, OSPF, IS-IS, MPLS L3VPN, VXLAN with EVPN, IPsec, multicast and QoS:
FRRouting · VyOS · MikroTik CHR · Cumulus VX · Arista vEOS · Open vSwitch · pfSense CE · OPNsense · FortiGate VM · Alpine · Ubuntu Server · Ostinato
Attach a second volume and mount it at the image directory before you fill it. Images outlive servers: when you rebuild the VPS, you want to reattach a disk rather than re-download 40 GB.
mkfs.ext4 /dev/sdb mkdir -p /opt/znetlab/images mount /dev/sdb /opt/znetlab/images echo '/dev/sdb /opt/znetlab/images ext4 defaults 0 2' >> /etc/fstab # and back up the OTHER directory — the one that cannot be re-downloaded tar czf znetlab-state.tgz /var/lib/znetlab # accounts, saved labs, billing, tickets
Part III
What happens between pressing Start lab and a router printing its first prompt — in the order it happens, with the command lines ZNetLab actually runs.
Doing these in any other sequence produces a lab that looks right and does not forward, which is the worst failure a teaching tool can have.
| # | Step | Why it must be here |
|---|---|---|
| 1 | Check the plan | Devices, memory and running labs against the licence. Free allows 2 devices per lab, Pro 10, Enterprise no ceiling. The refusal names the number. |
| 2 | Create the bridges | One Linux bridge per link. The cabling has to exist before anything is plugged into it. |
| 3 | Write the IOL NETMAP | IOL instances mesh with each other through UNIX sockets described by one file every instance reads. It cannot be written one node at a time. |
| 4 | Create the interfaces | A tap per interface, already enslaved to its bridge — because the emulator opens it by name and expects it to be there. |
| 5 | Plan the Dynamips slots | A 7200 has no FastEthernet1/0 until a PA-2FE-TX is in slot 1. The cards are worked out from how many interfaces you drew. |
| 6 | Start the processes | Each node gets its console port and its own process. |
| 7 | Bring up the built-ins | Virtual PCs are namespaces. There is nothing to boot. |
# one link becomes one bridge ip link add br-l1 type bridge ip link set br-l1 up # one interface becomes one tap, in that bridge, BEFORE the emulator opens it ip tuntap add dev n1i0 mode tap ip link set n1i0 master br-l1 ip link set n1i0 up
The tap name is <nodeId>i<interface index>. That is the name
handed to the emulator on its command line, which is how a drawn cable becomes a real one.
dynamips -P 7200 -r 256 -C 128 --idle-pc 0x60c08728 \
<slot cards> -s 0:0:tap:n1i0 -s 1:0:tap:n1i1 \
/opt/znetlab/images/library/c7200-…-mz.152-4.S6.image
-P chassis, -r RAM, -C NVRAM, and one
-s slot:port:tap:<tap> per interface. The idle-PC value is what stops a
7200 burning a whole core doing nothing.
# the image IS the binary; there is no emulator in front of it ./i86bi-linux-l2-adventerprisek9-15.2d.bin 3 -e 2 -s 0 -n 128 # argv[1] is the instance id, and it must match what NETMAP says # cwd is the shared lab directory; IOURC points at the staged licence
-e is ethernet slots — four interfaces per slot — -s serial
slots, -n NVRAM in KB.
iouyap, which is not part of ZNetLab. ZNetLab reports this as a
warning when the lab starts rather than leaving you to find it by pinging. Link IOL to IOL,
or use QEMU throughout.qemu-system-x86_64 -enable-kvm -cpu host \ -m 4096 -smp 2 \ -drive file=…/csr1000vng-17.03/virtioa.qcow2,if=virtio,format=qcow2 \ -nographic -serial mon:stdio \ -device virtio-net-pci,netdev=n0,mac=52:54:00:… \ -netdev tap,id=n0,ifname=n1i0,script=no,downscript=no
-enable-kvm -cpu host appear only if /dev/kvm exists.
Without it the same command runs under software emulation.# a Virtual PC is a network namespace with a real TCP/IP stack ip netns add pc1 ip link add pc1-eth0 type veth peer name br-l1-p0 ip link set pc1-eth0 netns pc1 # a LAN Switch is the bridge itself — real MAC learning, real flooding # a NAT Cloud is a bridge plus iptables masquerade onto the uplink
Every node gets a TCP port from 23000. The browser console and an external telnet client attach to the same session — what you type in either is seen in both, which is genuinely useful when two people are looking at one problem.
*** ZNetLab console: R1 *** R1> enable R1# show ip interface brief
For Dynamips and QEMU the process's own stdout is the console. For a Virtual PC there is no pty, so ZNetLab does the line editing itself.
The states that are reported honestly: stopped when the process exits — with its exit code — and error when it could not be run at all, with the reason on the device rather than buried in a log.
| What you see | Usually |
|---|---|
| Refused before anything starts | A plan ceiling. The message names it — free is 2 devices, 1 running lab. |
| Red immediately | The emulator binary is missing (apt-get install dynamips qemu-kvm), or
the image file has gone from under a registration that still points at it. |
| IOL exits at once | No iourc beside the image or above it, or one keyed to a different
hostname, or the binary is not executable. Images → IOL/IOU licence lists every
image that needs a licence with exactly which of those it is, and fixes the
executable bit for you. |
| Boots, but nothing pings | An IOL node cabled to a non-IOL node — see the warning in section 3. Or the lab was started before the link existed: interfaces are attached at boot. |
| Everything is very slow | No /dev/kvm. Check with ls -l /dev/kvm. |
| Nothing starts, on a fresh box | Not running as root, or not on Linux. verify.sh says which. |
Generated by tools/hosting-pdf.js. Ports, timings, ceilings, platform counts and command lines are read from assets/plans.js, assets/platforms.js, server/server.js and server/importer.js rather than transcribed, so this document goes stale only when the product does. ZNetLab ships no vendor software: every image referred to here is one you supply and are licensed to run.