ZNetLab

Hosting ZNetLab, and running real IOS on it

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 — Hosting. What Hostinger can and cannot run, why the obvious two-machine layout fails, and the one that works.
Part II — Getting IOS onto the server: the drop folder, the naming, the IOL licence, and what the importer does with a file.
Part III — Boot. The exact sequence from Start lab to a live console, with the command lines ZNetLab runs for each engine.
Generated 2026-10-10 from this ZNetLab source tree.

Part I

Where each half runs

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.

1What each half actually needs

HalfWhat it isWhat 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.
One good piece of news. The server has zero npm dependencies — every 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.

2What Hostinger can and cannot run

Hostinger productThe siteThe 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.
The layout that looks obvious and does not work: site on shared hosting, lab server on a VPS. Two reasons, both in the code rather than in policy.

1. When the sign-in box needs a server it defaults to its own origin — 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.

2. The address is normalised down to 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/.

3The layout that does work

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.

Browser https://labs.you.com Hostinger VPS · Ubuntu, root nginx :443 — TLS serves the site · proxies /api and the socket node server.js :8080 bound to 127.0.0.1 — never public bridges · veth · taps · qemu · dynamips · iol Your images /opt/znetlab/images/incoming /opt/znetlab/images/library on their own disk, so a rebuilt VPS does not cost you the library
One origin, one certificate, one machine. The Node process never faces the internet directly.
Shared hosting is still useful — as a marketing site or a redirect on your main domain. It just cannot be the thing the app talks to.

4Build the server, step by step

1 · Take a VPS and point a name at it

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
2 · Install ZNetLab
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.

Keep 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.
3 · Put the site where nginx can serve it
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.
4 · Bind the server to localhost only

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
5 · nginx: serve the site, proxy the socket
# /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
6 · Close everything else
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
About external terminals. The external-terminal option hands out 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.

5The KVM question on a VPS

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
ResultWhat 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.
If you need KVM and the VPS will not give it, the choices are a bare-metal box, or a provider whose ordinary VMs nest — Azure's v3-and-newer sizes do, and AWS needs a .metal instance. That comparison is in the Real labs guide, Part III.

6Prove it before you trust it

# 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

Getting real IOS onto the server

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.

The licensing part, plainly. Cisco IOS, IOSv, CSR1000v, ASAv, Juniper Junos and the rest are licensed by their vendors, and redistributing them is infringement. ZNetLab takes the position every tool that bundles nothing takes: the platform is yours, the images are yours to obtain. A Cisco Modeling Labs licence, a partner account, a DevNet entitlement or a support contract are the usual routes. Do not download IOS images from a forum.

1Four ways in, one destination

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/
RouteHow
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.

2What happens to the file

The importer watches that folder. Nothing is typed; every step below is automatic.

StepWhat it doesWhy
SettleWaits 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.
SniffReads 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.
Unpackzip, gz, tar, xz, 7z. You can drop the vendor's archive straight in.
IdentifyMatches 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.
Convertqemu-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.
InstallFiles 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

3Naming — the only thing it asks of you

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.

Dynamips — classic IOS

c7200-adventerprisek9-mz.152-4.S6.image
c3725-adventerprisek9-mz.124-15.T14.image
# Cisco's own filename. Leave it exactly as it came.

IOL / IOU — L2 and L3 images

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.
IOL needs its licence file. An 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

The licence file itself

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;
Whatever shape your copy is in, the file ZNetLab keeps is canonical. A licence saved on Windows carries CRLF line endings and often a byte-order mark, and IOL will not read it — so every route in (paste, upload, drop folder) parses the licence and writes it back as plain [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 hostname is the part people lose an evening to. IOL compares the hostname in the licence against the hostname of the machine it runs on and exits at once if they differ — with nothing on the console to say why. ZNetLab checks it for you: the Images tab shows the server's hostname beside the licence, and verifying an image warns when the two do not match.

QEMU — disk images

# 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

4When it will not recognise a file

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.

5Check it landed

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

6Test the server with images that need no licence

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

And three devices need no image at all, on every plan including free: Virtual PC, LAN Switch and NAT Cloud. If those ping each other, your server is sound and anything after that is a licensing question, not a hosting one.

7Keep the library off the system disk

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

How a device boots from real IOS

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.

1The order, and why it is that order

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.

#StepWhy it must be here
1Check 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.
2Create the bridges One Linux bridge per link. The cabling has to exist before anything is plugged into it.
3Write 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.
4Create the interfaces A tap per interface, already enslaved to its bridge — because the emulator opens it by name and expects it to be there.
5Plan 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.
6Start the processes Each node gets its console port and its own process.
7Bring up the built-ins Virtual PCs are namespaces. There is nothing to boot.

2Step 4, in detail — the wiring

# 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.

3Step 6 — what each engine is actually given

Dynamips — classic IOS on an emulated CPU

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.

IOL — IOS as an ordinary Linux process

# 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.

An IOL node linked to a QEMU or Dynamips node will not pass traffic. IOL meshes through its own UNIX sockets, and bridging those onto a kernel interface needs 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 — full disk images

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

The three built-ins — no process at all

# 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

4The console

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.

5What "starting" and "running" actually mean

Be precise about this one. A node goes from starting to running on a two-second timer after its process is spawned. That means the process started — not that IOS has finished booting. A CSR1000v still takes minutes to reach a prompt, and the green dot will have appeared long before it does. Watch the console, not the dot, when you are waiting for a real image.

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.

6When a device will not start

What you seeUsually
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.