Intro
Most subnet calculators give you the numbers and stop there: network,
broadcast, host range. That is fine when you already know why a /22 starts
at 10.0.0.0 and not at 10.0.1.0. It does not help when you are still
learning, and IPv6 is where most people I know still are. The rules for ::,
why a LAN is always a /64, and what fe80:: is doing on every interface
all feel arbitrary until someone maps them back to IPv4 habits.
So I wrote ipcalc, a calculator where every value comes with a short “why”, and put it on the internet at ipcalc.franzramadhan.dev. It was also the excuse I needed for a real workload on the Wasm runtime I had just added to my cluster (Part 39).
TL;DR: ipcalc is one WebAssembly component (
wasi:http/incoming-handler, 356 KB) written in Rust, with no filesystem, no outbound network, no database and no logs. Each pod uses 6 MiB of memory. The catch was the WASI version: the runwasi Wasmtime shim serves WASI 0.2.6, and Rust’swasm32-wasip2target already imports 0.2.12, so the component would not start. It is built forwasm32-wasip1and wrapped with the Wasmtime 36 proxy adapter instead, and the build fails if any import is too new. Source and a free JSON API: github.com/franzramadhan/ipcalc (MIT).
The Figure
The figure is a desk calculator with a row of 32 lamps above the keypad: the
address 192.168.10.37 in binary, eight lamps to an octet. The slide on the
rail is the prefix length. Every lamp left of it is a network bit and is lit,
every lamp right of it is a host bit. Press + or − (or the arrow keys) to
move it, and watch the mask on the display and the range and host count in
the readout follow, the same answers ipcalc gives with its “why”. I built it
with the anatomy skill (see References).
What It Does
- Explain takes any address with an optional prefix and shows the
network, range, mask and wildcard, address type, IPv6 compression, EUI-64,
multicast scope and embedded IPv4. It can split the block (a
/22into/24s, a/56into/64s). Explain my IP does it for the address you connect from. - Inside? checks whether an address or subnet is inside a network, with the masking step a router does, and explains why IPv4 is never inside IPv6 (IPv4-mapped, NAT64).
- Summarize takes up to 64 subnets and returns the exact minimal set of prefixes, the single supernet, and how many extra addresses that supernet would also cover.
- IPv4 vs IPv6 is a cheat sheet: each IPv4 habit and its IPv6 version.
- API documents the JSON API the page itself uses.
The answer sits in a card on top, and every row below it carries its “why”.
Under each result there is Ask your own AI: a prompt built from the
verified values, to paste into any chat model. The point is that the model
explains instead of recomputing, because models still get :: and prefix
boundaries wrong often enough that I would not trust their math. Each result
also shows the curl command for the same answer:
curl -s 'https://ipcalc.franzramadhan.dev/api/v1/contains?net=10.0.0.0/22&ip=10.0.1.77' \
| jq -r '.facts[] | select(.label == "Result").value'
# inside
Nothing you type is stored. Explain my IP reads Cloudflare’s
CF-Connecting-IP header and shows it back to you only.
The Situation
- ipcalc 0.2.4, Rust 1.99,
wstd0.6.8 for the HTTP server,serde_json, nothing else at runtime. 19 tests, 97% line coverage. - Runtime: the runwasi
containerd-shim-wasmtimev0.6.1 on k0s 1.36, behind RuntimeClasswasmtime(Part 39). The shim is built on Wasmtime 36. - Image:
FROM scratchwithipcalc.wasmas the entrypoint, built aslinux/amd64(Part 39 explains why notwasip1/wasm). - Edge: Cloudflare in front, the same public gateway and tunnel as the Homie demo.
I looked at Zig first, since I like it for small binaries, but it has no
component model toolchain yet. A wasi:http component needs the component
model, so Rust it was.
How a Request Gets Served
flowchart LR U[Browser or curl] --> CF[Cloudflare<br/>WAF rate limit on /api/] CF -->|06:00 to 23:30| T[Tunnel] --> GW[public gateway] --> P[ipcalc pod<br/>wasmtime shim] CF -->|23:30 to 06:00| W[Offline Worker<br/>503 page or JSON]
There is no web server in the pod in the usual sense. The shim loads the
component and calls its incoming-handler export once per request, on port
8080. The component routes the path to plain Rust functions, which is also
what makes the routing testable natively with cargo test, without Wasm in
the loop.
The pod spec looks like any other pod in the cluster, with one extra line:
spec:
runtimeClassName: wasmtime
containers:
- name: ipcalc
image: ghcr.io/franzramadhan/ipcalc:0.2.4@sha256:...
resources:
requests: {cpu: 10m, memory: 16Mi}
limits: {cpu: 200m, memory: 64Mi}
securityContext:
runAsNonRoot: true
readOnlyRootFilesystem: true
capabilities: {drop: ["ALL"]}
The same Kyverno rules apply as for every container (pinned image, requests and limits, the securityContext above), and the shim accepts all of it. The CiliumNetworkPolicy allows ingress on 8080 and lists no egress peers at all, so the pod cannot reach anything, not even DNS. It does not need to.
$ kubectl -n ipcalc top pod
NAME CPU(cores) MEMORY(bytes)
ipcalc-97c8dcd8d-79wk6 1m 6Mi
ipcalc-97c8dcd8d-fbrtw 1m 6Mi
The lab is switched off at night. While it is off, a Cloudflare Worker serves
a short notice page instead, and API calls get a JSON error with HTTP 503
and a Retry-After header, so scripts can tell when to try again.
Issue 1: The Component Would Not Start
Symptom
The first build was for Rust’s wasm32-wasip2 target. In the cluster, the
pod failed at start:
component imports instance `wasi:http/types@0.2.12`, but a matching implementation was not found in the linker
Cause
A component declares the exact WASI version of every interface it imports,
and the host must provide that version or a newer one. The shim v0.6.1 is
built on Wasmtime 36, which serves WASI 0.2.6. Rust’s own wasm32-wasip2
standard library already imports 0.2.12, so pinning crates in
Cargo.toml could never fix it: the newer imports come from std itself.
Dead end: a newer shim
The Spin shim v0.26.0 is on Wasmtime 48, which would serve 0.2.12. But it brings the SpinKube operator and its own app format, which is a lot of extra machinery for one calculator. runwasi itself has not moved past Wasmtime 36 yet.
Fix
Build a plain WASI preview 1 module and turn it into a component myself, with the adapter from the same Wasmtime version the shim uses:
flowchart LR
S[Rust source] -->|cargo build<br/>wasm32-wasip1| M[core module]
M -->|wasm-tools component new<br/>Wasmtime 36 proxy adapter| C[component]
C --> K{imports newer<br/>than 0.2.6?}
K -->|yes| F[build fails]
K -->|no| I[scratch image<br/>linux/amd64]The HTTP bindings come from the wasip2 crate pinned to =1.0.1, which is
WASI 0.2.4. The adapter file is pinned by sha256 in build.sh. The last step
lists the component’s imports with wasm-tools and fails the build if any of
them is newer than 0.2.6, so the next crate update that drags in a newer WASI
breaks the build on my laptop and not the pod in the cluster. Once runwasi
moves to a newer Wasmtime, this goes back to a plain wasm32-wasip2 build.
Issue 2: The New Tabs Did Not Work for Visitors
Symptom
Version 0.2.1 replaced the single long page with tabs. On my machine the tabs worked. For anyone who had opened the page before, the page looked stuck: clicking a tab showed nothing.
Cause
The HTML was new but app.js was not. The component sent assets with
max-age=300, but Cloudflare’s browser cache setting for the zone stretches
that to a day, so browsers kept the old script and ran it against the new
markup.
Fix
Version 0.2.2 links the assets as /app.js?v=0.2.2, and the component fills
in its own crate version at request time, so every release changes the URL.
The HTML itself is sent with no-cache, so the new URLs arrive right away. A
test checks that the page carries the current version.
Issue 3: One Answer Was Off by a Factor of Two
Symptom
Summarizing 2001:db8::/64 and 2001:db8:0:ff::/64 gives the supernet
2001:db8::/56. The page said it covers “about 2^71” extra addresses.
Cause
The supernet holds 2^72 addresses and the inputs 2^65, so the extra is 2^72 minus 2^65, about 2^71.98. I rounded the log down, which turned “almost 2^72” into 2^71.
Fix
Round to the nearest power of two, which gives 2^72. A test keeps it there. This is exactly the kind of mistake the “why” column is meant to expose: the explanation said 2^72 minus 2^65 in the same row.
Lessons
- A Wasm component is pinned to a WASI version, and so is your host. Check which Wasmtime your shim is built on before you pick a Rust target, and fail the build on newer imports instead of finding out at pod start.
- Version your asset URLs as soon as a CDN is in front. A short
max-agefrom the origin is a suggestion; the edge can stretch it. - Wasm is a good fit for small, stateless HTTP services. 356 KB, 6 MiB of memory, no filesystem and no network to lock down, and the pod still goes through the same policies as every container.
References
- GitHub: franzramadhan/ipcalc
- GitHub: containerd/runwasi
- GitHub: bytecodealliance/wasm-tools
- Wasmtime: Adapters for WASI preview 1
- WASI: wasi:http
- crates.io: wstd
- GitHub: wheresryan22/anatomy
