A static, animated console that answers one question: can these two hosts talk — and if not, exactly which networking rule blocks them?
Type two interfaces (IP + subnet mask, exactly like NetPractice), press AUDIT, and watch a packet travel through six validation gates. Each gate scans the packet and lights up green (pass), amber (needs routing), or red (dropped) — with a one-sentence, plain-language reason. On success the packet makes the full NetPractice round trip: A → B, then the reply B → A.
Everything is computed client-side. No server, no network calls, no dependencies at runtime.
The only host requirement is Docker (Linux, macOS Intel/Apple Silicon, or
Windows — both base images are official multi-arch builds). No local Node,
no local node_modules.
make # build the image (tests run inside!) + serve at http://localhost:8090
make dev # live-reload dev server at http://localhost:4321
make test # one-shot run of the 41 unit tests
make dist # export the plain static site into ./dist (no server)
make logs # follow the web server logs
make down # stop everything
make clean # down + drop ./dist
make fclean # clean + remove the built images and volumes
make re # fclean, then rebuild and serve(Every target is a thin wrapper over docker compose — see the Makefile; use
docker compose up web etc. directly if you prefer.)
The image build is gated: npm test runs inside the Dockerfile, so a failing
test fails the build — there is no way to produce a broken image.
Want just the static files (deploy to any host, no web server image)?
docker build --target dist --output dist . # writes plain HTML/CSS/JS into dist/The dev and test services bind-mount your source tree for hot reload while
keeping node_modules inside an anonymous container volume — it never appears
on your machine. If you change package.json, rebuild once with
docker compose --profile tools build.
Alternative: local Node (if you happen to have it)
npm install
npm run dev # develop at http://localhost:4321
npm test # unit tests
npm run build # emit the static site into dist/| # | Gate | Rule | Fails as |
|---|---|---|---|
| 1 | SRC IP | An IPv4 address is four octets, each 0–255, stored as one 32-bit integer. | 400 MALFORMED_SOURCE_IP |
| 2 | SRC MASK | A mask must be contiguous 1s then 0s (255.255.255.0 = /24 ok, 255.0.255.0 is meaningless). Dotted-decimal and CIDR both accepted. |
422 INVALID_SOURCE_MASK |
| 3 | SRC HOST | Host bits all 0 = the network's name; all 1 = broadcast. A machine must sit strictly between them. | 422 SOURCE_IS_NETWORK / _BROADCAST |
| 4 | DST CHECK | The destination passes the same three checks. | 400 / 422 dest variants |
| 5 | SUBNET | IP AND mask must land on the same network from both hosts' viewpoints — asymmetric masks can make A see B as local while B sees A as foreign (the reply dies). |
falls through to gate 6 |
| 6 | GATEWAY | Crossing subnets needs a default gateway per host that is a valid, usable address inside that host's own subnet. | 403 DIFFERENT_SUBNET (no gateway) / 502 BAD_GATEWAY |
Success is 200 REACHED — directly, or via the gateways when the router bridged two networks.
The single most important NetPractice insight is that every routing decision is one binary AND:
HOST A ip 11000000.10101000.00000001.00001010 192.168.1.10
mask 11111111.11111111.11111111.00000000 /24
AND = 11000000.10101000.00000001.00000000 192.168.1.0 <- network
Two places show it: the show the math toggle in the top bar (live, as you type), and clicking any gate after an audit (the rule, this run's verdict, and the binary computation behind it).
The input panel also live-computes each interface's network, broadcast, and usable host range as you type — the numbers you'd otherwise work out on paper.
One button per NetPractice failure mode:
- same subnet · OK — the happy path, direct delivery.
- via router · OK — different /24s bridged by on-link gateways.
- no gateway · 403 — different subnets, no way out.
- broken mask · 422 —
255.0.255.0, the non-contiguous trap. - IP = network · 422 — host bits all 0.
- IP = broadcast · 422 — host bits all 1.
- /30 trap · 403 —
.1and.5look adjacent but live in different 4-address subnets. The mask decides everything. - off-link gateway · 502 — a gateway the host can't even reach.
Dockerfile multi-stage: deps -> test+build gate -> dist export / nginx
compose.yaml web (:8090), dev (:4321), test — all containerized
src/
lib/
net.ts pure IPv4 math: parse, mask, network/broadcast, ranges
audit.ts the 6-gate pipeline (canReach) + per-gate teaching text
presets.ts the canned scenarios
*.test.ts Vitest suites for all of the above
scripts/
console.ts client controller: inputs, animation choreography, details
pages/
index.astro the one page: markup + the terminal-console styling
The networking logic is framework-agnostic TypeScript with zero imports from the UI — read net.ts top to bottom (~150 lines) and you have the whole theory. The one JS trap worth knowing: bitwise operators return signed 32-bit values, so every operation ends in >>> 0 to stay unsigned.
/31and/32have no usable hosts (RFC 3021 point-to-point is out of scope, as in NetPractice).- One router: the two gateways are assumed to be the two interfaces of a single router bridging the subnets. Multi-hop topologies are not modelled.
- Asymmetric-mask setups where only one direction is on-link are treated as "needs a router" rather than modelling one-way asymmetric routing.
Keyboard-first (every control is a real <button>/<input>, Enter submits from any field), aria-live result banner, visible focus rings, and a full prefers-reduced-motion fallback that applies the identical final state with zero animation.