This document describes how NetLab is built, why some of its less obvious decisions exist, and how to work on it.
- Architecture overview
- Tech stack
- Project structure
- Running locally
- Data model
- How a node maps to Docker
- How a link maps to Docker
- Custom images
- The switch / hairpin-mode fix
- Self-healing Docker resources
- Error handling and localization
- IPC surface
- The native terminal
- Icon assets
NetLab is an Electron app with two processes:
- Main process (
electron/, Node.js) — owns all Docker access via dockerode, the app's persisted state, and everything platform-specific (native terminal launching, window chrome). Nothing here can be seen or reached directly by the renderer. - Renderer process (
frontend/, Angular + PrimeNG) — the UI. It never talks to Docker directly; every action goes throughwindow.electronAPI, a small surface exposed byelectron/preload.tsviacontextBridge(context isolation is on,nodeIntegrationis off).
Angular components → window.electronAPI.* → ipcRenderer.invoke → ipcMain.handle (electron/ipc-handlers.ts)
│
▼
backend/services/*.service.ts → dockerode → Docker
backend/ is plain TypeScript with no Electron or Angular dependency — it's the domain logic (nodes, links, Docker orchestration), imported by electron/ipc-handlers.ts. It's structured this way so the domain logic doesn't know or care that it's running inside Electron.
- Electron
- Angular (standalone components, signals) + PrimeNG (Aura theme)
- dockerode
- ngx-translate
- electron-builder
backend/
models/ domain types (LabNode, LabLink, AppError, IPC channel names)
services/ node.service.ts, network.service.ts, db.service.ts, docker.client.ts
electron/
main.ts app lifecycle, window creation, startup reconciliation
ipc-handlers.ts ipcMain.handle(...) for every channel, native terminal launching
preload.ts contextBridge surface, strips Electron's IPC error prefix
frontend/src/app/
components/ one folder per UI component (main-layout is the root shell)
services/ thin Angular wrappers around window.electronAPI, exposed as Observables
i18n/ it.ts / en.ts translation tables
shared/ small cross-component helpers (image-options.ts, app-error.ts)
docs/ this file, and the user guide
build/ app icon sources and generated icon.ico/.icns/.png
.github/workflows/ release.yml — CI build + publish on tag push
npm install
cd frontend && npm install && cd ..
npm run devThis runs the Angular dev server, a tsc --watch for the backend/electron TypeScript, and Electron pointed at http://localhost:4200, concurrently. Requires Docker running locally.
LabNode(backend/models/node.model.ts) — id, name, image, status,NetworkInterface[](each with a name and an optionallinkName), optional CPU/RAM limits, mounts,internetFacing/wanIfaceName,isSwitch.LabLink(backend/models/link.model.ts) — name, the Docker network id backing it, and a computedconnectedNodes(derived at read time from which nodes currently have an interface pointing at this link — not stored).- Interface names are not user-editable — the form assigns
eth0,eth1, … automatically, picking the lowest name not already in use, so removing and re-adding an interface can never produce a duplicate.
Persistence is a flat JSON store (backend/services/db.service.ts), not a database — this is a single-user desktop app, there's no concurrent-write concern to design around.
NodeService.start() (backend/services/node.service.ts):
- Reuses the existing container if one is already tracked and still exists; otherwise creates one (
Privileged: true,Tty: true,OpenStdin: true, noStdinOnce). That Tty/OpenStdin combination — a pty attached with stdin left open indefinitely — is what keeps a plainsh/bashCMD container alive without needing a custom entrypoint or asleep infinityhack; it works identically regardless of which base image is picked. - Immediately disconnects the container from Docker's default
bridgenetwork. This has to happen aftercontainer.start(), not before — Docker only actually attaches the default network once the container starts, so disconnecting earlier is a silent no-op. - If container creation fails with a 409 name conflict, checks whether the conflicting container is a NetLab-managed orphan not tracked by any other node (via the
netlab.managedlabel) and, if so, removes it and retries once — see Self-healing Docker resources.
Then, per interface (electron/ipc-handlers.ts, NODE_START handler):
- No link assigned →
NetworkService.createDummyInterface()adds adummy-type interface, purely so it shows up inip aas a disconnected NIC. - Link assigned →
NetworkService.attachInterface()connects the container to that link's Docker network, then execs into the container and renames the interface by matching its MAC address (the veth Docker just created has a MAC only known afternetwork.connect()resolves) to the name the student configured. This MAC-based matching, not connection order, is what makes it safe to run reconnects/switch nodes without ambiguity about which physical interface is which. NetworkService.applyInterfacesConfig()then runsifdown/ifupon every configured interface — this is what makes a student's/etc/network/interfacesconfig survive a restart (see the user guide).
A link is a Docker bridge network named netlab_<link-name>, created with enable_icc: true and no IPAM config (students assign IPs by hand, Docker's own address management would only get in the way). attachInterface refuses a third node on a link that already has two (LINK_AT_CAPACITY).
Alpine, Debian, and Ubuntu are not pulled from a public registry — they're built locally (backend/services/node.service.ts, CUSTOM_IMAGES) from a Dockerfile embedded as a string, layering the same network toolset on top of each base. They're built once at app startup (NodeService.ensureCustomImagesBuilt(), called from electron/main.ts) so a student is never stuck waiting mid-exercise, and are rebuilt on demand if missing (see below).
The one piece of behavior in this codebase that isn't obvious from reading the code alone, so it's worth writing down properly.
The problem. A student building a switch bridges two of a node's interfaces (ip link add br0 type bridge, ip link set ethN master br0). On Docker Desktop (Windows and macOS — not Linux with native Docker Engine), this produced an infinite L2 broadcast storm and 100% packet loss, even though the bridge itself was topologically correct (two ports, no redundancy, nothing that should be able to loop).
Root cause. Docker Desktop doesn't use docker-proxy for port publishing (unlike Docker Engine on Linux, which does by default), so it enables hairpin mode on the veth ports of its own bridge networks. A port in hairpin mode reflects flooded frames back out the same port they arrived on. With a container bridging two Docker networks, that turns a normal ARP/broadcast flood into: frame enters the container on port A → gets forwarded to port B → the host-level Docker bridge for that network reflects it straight back into port B → the container's own bridge forwards it back out port A → repeat forever. The container's bridge is innocent; the loop closes on the host-level bridges outside anything the container/student can see or configure.
The fix. NetworkService.disableHairpinForSwitch() (backend/services/network.service.ts), run automatically at every start of a node with isSwitch: true:
- For each of the node's interfaces, reads
/sys/class/net/<iface>/iflinkinside the container — this is the ifindex of the veth's peer, i.e. the host-side end. - Spawns a privileged helper container (
netlab-nsenter-helper, a minimal Alpine image with onlynsenter+iproute2) usingnsenter -t 1 -nto reach the real host's (or, on Docker Desktop, the VM's) network namespace — not the calling container's. - Inside that namespace, resolves the veth name from the ifindex and runs
bridge link set dev <veth> hairpin off.
This has to be redone on every start, since Docker creates fresh veths (new names, new ifindexes) every time a container (re)connects to a network. On native Linux this is a harmless no-op (hairpin is already off by default there), so the toggle is always safe to leave on.
Three categories of "a resource NetLab owns got deleted or is missing" are handled the same way — detect on next use, recreate transparently, log it, and don't bother the user with an error if it succeeds:
- A link's Docker network deleted externally (
docker network rm) →attachInterfacecatches the 404 on inspect, recreates the network with the same name/options, updates and persists the new id. - A custom image deleted externally (
docker image prune, manualdocker rmi) →ensureImagePresentrebuilds it instead of attempting adocker pull(which would fail — these images don't exist on any registry). - An orphaned same-named container (state file wiped/corrupted while a container with that name still exists) →
removeOrphanedContainerByNameremoves it, but only if it carries thenetlab.managedlabel and isn't tracked by a different active node, then the caller retries creation once.
Backend code (electron/, backend/) has no access to ngx-translate — that only exists in the Angular renderer. Hardcoding Italian (or English) strings in thrown errors would make them impossible to localize from the frontend. Instead:
backend/models/app-error.tsdefinesAppError, a typed{ code, params }pair, JSON-encoded intoError.message(the only part of a thrown error Electron's IPC reliably preserves across the renderer boundary — custom properties don't survive the trip).electron/ipc-handlers.ts'stoUserError()classifies raw Docker/system errors it doesn't already recognize as anAppErrorinto one, by pattern-matching the Docker daemon's own error text (e.g."no such image"→IMAGE_NOT_FOUND).electron/preload.tsstrips Electron's own"Error invoking remote method '<channel>': "prefix before the error ever reaches application code — this is the one place that raw wrapper text is visible, so it's the only place that can clean it.frontend/src/app/shared/app-error.tsparses the JSON back out and looks uperrors.<code>init.ts/en.ts, interpolatingparams.
Adding a new error: add the code to ERROR_CODES in backend/models/app-error.ts, throw new AppError('YOUR_CODE', { ...params }) wherever it happens, then add the errors.YOUR_CODE translation key (with {{param}} placeholders) to both it.ts and en.ts. Nothing else needs to change — every error dialog in the app already routes through translateAppError().
Blocking errors are shown via ConfirmationService.confirm() (a modal the user dismisses explicitly), not MessageService.add() toasts — a toast that auto-dismisses after a few seconds is the wrong affordance for "this action failed and here's why." Where a failure mode is common and predictable (duplicate name, a link still in use), the UI validates client-side and gives inline feedback before even calling the backend, rather than round-tripping to get an error back — see nameError/triggerNameError() in node-form.component.ts and link-list.component.ts for the pattern (a transient CSS class toggled via direct DOM manipulation + forced reflow, not an Angular class binding, so the shake animation reliably replays on every attempt regardless of whether it's triggered by a click or an Enter keypress).
Channel names live in one place, backend/models/ipc.model.ts (IPC_CHANNELS), imported by both electron/preload.ts and electron/ipc-handlers.ts so they can't drift apart. preload.ts exposes a narrow, purpose-built API (window.electronAPI.createNode(...), not a generic "invoke any channel") — the renderer never sees a raw ipcRenderer.
There's no embedded terminal emulator (no xterm.js, no node-pty) — "Attach" launches the OS's own terminal (electron/ipc-handlers.ts, openNativeTerminal), connected via a plain docker exec -it <container> sh -c "..." command, platform-specific:
- macOS: AppleScript driving Terminal.app, with a custom tab title.
- Windows: a temp
.batfile (avoids fightingcmd.exe's quoting rules when nestingstart+docker exec), with@echo off/clsso the batch script's own lines aren't echoed into the window. - Linux: tries
gnome-terminal,konsole,xfce4-terminal,xterm, thenx-terminal-emulatorlast (it has no reliable title flag, so it must lose to anything more specific).
One non-obvious detail: the exec command forces export TERM=screen before launching the shell. Debian/Ubuntu's default .bashrc resets the terminal window title via an OSC-0 escape sequence whenever $TERM matches xterm*/rxvt* — which would immediately clobber the title NetLab just set. screen isn't matched by that pattern and still has solid color/terminfo support.
Once launched, the terminal is a fully independent OS process — NetLab doesn't track, log, or manage the session afterward.
App icons live in build/ (icon.ico, icon.icns, icon.png, icons/ for Linux's various sizes), generated from build/icon-src/icon.html (an SVG rendered via Playwright, then converted with sharp/png2icons). To change the icon design, edit icon.html and regenerate — there's no npm script for this yet since it's a rare, manual step.