Mastodon

I Wanted an IPv4 Toast and Accidentally Built an IP Service



ip.hofstede.it showing an IPv6 address, provider, location, and AS201379

At 10:15 this morning I added a small feature to hofstede.it, my static personal website. Visitors whose providers still cannot deliver IPv6 would get this toast in the lower-right corner:

The Legacy IP detected toast on hofstede.it, explaining that the visitor arrived over IPv4

The text is deliberately direct:

Legacy IP detected

You reached this website on a legacy version of the Internet Protocol (IPv4, 1981). It seems your provider does not support the current generation of Internet technology. Do ask them about it — IPv6 has only been a standard since 1998.

Gently condescending, technically defensible, and dismissible. The dismissal lives in localStorage, so nobody gets lectured twice. I considered this an admirably restrained piece of web development.

There was one problem. The website is static, so its JavaScript cannot ask the server which address connected to it. It had to ask somebody else. The first version used ipify.

For most websites that would have been the end of the matter. For somebody who operates an autonomous system, announces his own address space, runs his own authoritative DNS, and has written an entire article insisting that IPv6 is the current Internet Protocol, outsourcing “which IP address made this request?” began to feel inappropriate almost immediately.

At 13:27 the website no longer depended on ipify. By then I had also written a Rust web service, added MaxMind lookups, built a server-rendered interface, configured three DNS names, hardened a reverse-proxy trust boundary, written an rc.d service, and deployed the whole thing in a dedicated FreeBSD VNET jail.

This article is about the intervening three hours.

Table of Contents

The Harmless Original Idea

The first implementation called two public endpoints:

const IP_ECHO_URL = 'https://api64.ipify.org';
const IPV6_PROBE_URL = 'https://api6.ipify.org';

The first name is dual-stack, so it reports whichever protocol the browser happened to use. The second is IPv6-only. That distinction matters because one IPv4 connection does not prove that the visitor lacks IPv6.

On a dual-stack network, the browser uses Happy Eyeballs: it attempts the available paths with carefully staggered timing and takes the one that wins. IPv4 can occasionally win that race even when IPv6 works perfectly. Toasting that user for having an obsolete provider would be satisfying but wrong.

The test is therefore deliberately conservative:

async function checkLegacyIp() {
    let address;

    try {
        address = await fetchText(IP_ECHO_URL);
    } catch (error) {
        return;
    }

    if (address.includes(':')) return;

    try {
        await fetchText(IPV6_PROBE_URL);
        return;
    } catch (error) {
        showLegacyIpNotice(address);
    }
}

If the dual-stack request returns an IPv6 address, all is well. If it returns IPv4, the browser gets one more chance against an AAAA-only name. Only an IPv4 result and a failed IPv6 probe produce the toast. A timeout, content blocker, captive portal, or other ambiguous failure keeps the site quiet rather than turning uncertainty into a lecture.

That part survived the afternoon unchanged. The only offensive lines were the two constants.

Surely I Can Replace Two URLs

An IP echo service can be one line in almost any server-side language. Accept a connection, return the peer address, go home. I did not do that.

Once the endpoint was going to exist under my own domain, it seemed rude to make it useful only to one function in one JavaScript file. curl should be able to use it. If it is useful from curl, it should return clean plain text. Machines might want JSON. MaxMind databases already exist in my infrastructure for GeoIP policy, so the service may as well expose the approximate location and ASN. If that data exists, a human visiting the root URL deserves something nicer than one undecorated line.

This is the precise point at which “replace two URLs” became hofstede-ip, a Rust application built with Axum:

Route Response
/ HTML for a browser, otherwise the address as text
/ip IP address
/json Full structured result
/asn Autonomous system number
/provider ISP or network organisation
/country ISO country code
/city Approximate GeoIP city
/healthz Internal health and database status

The root route negotiates on Accept. A normal browser gets a proper server-rendered page; a command-line client gets the simplest useful answer:

$ curl https://ip.hofstede.it
2a06:9801:1c:1000::42

$ curl https://ip.hofstede.it/asn
AS201379

$ curl https://ip.hofstede.it/json | jq .ip_version
6

There is no runtime JavaScript on the page. The application renders the address, protocol verdict, provider, ASN, approximate location, accuracy radius, and the modest collection of facts already volunteered in the HTTP headers: browser family, platform, language, Do Not Track, and Global Privacy Control. I wanted an endpoint for one JavaScript function and somehow responded by building a website with less JavaScript.

The map is an OpenStreetMap embed loaded lazily when it approaches the viewport. That is called out explicitly because it causes the visitor’s browser to contact a third party. GeoIP itself remains what it has always been: an approximate network-location hint, not a homing beacon, and the accuracy radius is displayed prominently enough to discourage fantasies of precision.

One Binary, Because Restraint Had to Appear Somewhere

Rust is not required for this job. A shell script behind inetd could return the address. So could twenty lines of Python, a tiny Go program, Caddy placeholders, nginx, or several sufficiently motivated pigeons.

I chose Rust because the result is one self-contained FreeBSD binary with no interpreter or application runtime left in production. Axum provides the HTTP routing, Tokio the runtime, maxminddb reads the local MMDB files, and serde produces the JSON. The HTML and CSS are compiled into the binary with include_str!.

The production process tree is consequently less dramatic than the source tree:

USER     PID %CPU %MEM    VSZ   RSS  STAT COMMAND
ipinfo 56973  0.0  0.0  13080  2496 IsJ  daemon: /usr/local/bin/hofstede-ip-env[56974]
ipinfo 56974  0.0  1.0 130932 81576 IJ   /usr/local/sbin/hofstede-ip

daemon(8) supervises the application, restarts it on failure, and gives rc.d a pidfile to work with. The process runs as an unprivileged ipinfo user. A small wrapper reads the root-controlled environment file before executing the binary because service configuration containing paths and address lists is easier to audit than an increasingly creative command_args line.

The application also refuses to start when a configured database is missing or invalid. Running without GeoIP data is fine for development and produces Unknown; claiming to run with a database while silently failing to open it is not.

The Reverse Proxy Knows Who You Are

The public service sits behind my existing Caddy ingress. That creates the only security-sensitive part of an IP echo service: the TCP peer seen by the application is Caddy, not the visitor.

Caddy passes the original address in X-Forwarded-For. Trust that header from everybody and the service becomes a form where visitors may type whichever address they would like to be today:

X-Forwarded-For: 2001:db8::definitely-me

The application therefore accepts forwarded addresses only when the immediate TCP peer belongs to an explicitly configured proxy network. Anything arriving from another jail, even with a beautifully forged header, is reported as the peer that actually connected. IPv4-mapped IPv6 peers are normalised before the trust check, and an invalid forwarded value falls back to the peer address with a warning in the log.

In production, the trust list contains the Caddy jail’s address as a /32. The application itself binds only to 10.254.254.36:8080, and the firewall permits that port only from the proxy. The same decision is enforced twice: by the network and by the application.

Caddy terminates TLS, replaces the forwarding headers at the public boundary, performs a health check every 30 seconds, and refuses public access to /healthz. Personalised responses carry Cache-Control: no-store, and the service does not log visitor addresses itself. Caddy has its own access-log policy, because claiming that the application does not log something is not the same as claiming that nothing in front of it does.

Naturally, It Needed Its Own Jail

The application could have joined an existing jail. It is one binary listening on one port. That line of reasoning had, however, already lost control of the afternoon.

Instead it runs in ipinfo, a FreeBSD 15.1 thin jail managed by Bastille:

[root@radon ~]# bastille list ipinfo
 JID  Name    Boot  Prio  State  Type  IP Address              Release
 15   ipinfo  on    99    Up     thin  10.254.254.36           15.1-RELEASE-p3
                                      2a06:9801:1c:1000::36

It is a VNET jail, so it has its own network stack and an epair connecting it to the host’s bastille0 bridge:

[root@radon ~]# bastille cmd ipinfo ifconfig vnet0

[ipinfo]:
vnet0: flags=1008843<UP,BROADCAST,RUNNING,SIMPLEX,MULTICAST,LOWER_UP> mtu 1500
        description: jail interface for bastille0
        inet 10.254.254.36 netmask 0xffffff00 broadcast 10.254.254.255
        inet6 2a06:9801:1c:1000::36 prefixlen 64
        media: Ethernet 10Gbase-T (10Gbase-T <full-duplex>)
        status: active

The complete request path looks like this:

IPv4 visitor ── A record ─────┐
                              ├── Caddy jail ── HTTP + X-Forwarded-For ── ipinfo jail
IPv6 visitor ── AAAA record ──┘       :443                              :8080
                                                                           |
                                                                  MaxMind MMDBs (ro)

The GeoIP databases already live in the Caddy jail, where geoipupdate maintains them. They are mounted read-only into ipinfo with nullfs. The application loads them into memory at startup, and a host-side update job can restart the service after an atomic database replacement. The account ID and MaxMind licence key never enter the application jail.

This is not the smallest topology, but it is consistent with the rest of the machine. Caddy is the only public ingress. Application jails are private backends. Each service gets its own filesystem, process namespace, network stack, credentials, and firewall policy. The implementation may be silly in proportion to the original toast; the deployment is at least boring in exactly the way production infrastructure should be.

Three Names for Two Protocols

The public DNS is where the original detection logic finally becomes independent of a third party:

Name Records Purpose
ip.hofstede.it A and AAAA Normal dual-stack service
v4.ip.hofstede.it A only Force or test IPv4
v6.ip.hofstede.it AAAA only Force or test IPv6

All three names point to Caddy, never directly to the application jail. The connection accepted by Caddy determines which address reaches the backend through the trusted forwarding header.

The website now contains the two lines I wanted in the first place:

const IP_ECHO_URL = 'https://ip.hofstede.it/ip';
const IPV6_PROBE_URL = 'https://v6.ip.hofstede.it/ip';

The IPv4-only name is not required by the toast, but once one has built a dual-stack IP-information service, declining to provide explicit protocol-test endpoints would look like an outbreak of restraint at an implausibly late stage.

The Escalation, in Git Timestamps

Git preserved the incident rather neatly:

Time Event
10:15 Add the legacy-IP toast using ipify
12:13 Commit the initial hofstede-ip service
12:17 Point the website at the self-hosted endpoints
13:16 Harden proxy handling and add HTTP-level tests
13:17 Declare this somehow version 0.2.0
13:27 Add the new service to the website’s projects section

The initial service commit contains 1,982 lines across Rust, CSS, deployment files, documentation, and the dependency lockfile. The original website feature added 159 lines. This means removing a two-line third-party dependency produced more than two thousand lines of owned software before lunch had properly settled.

That is not a defence of the process. It is simply the kind of arithmetic one should occasionally perform while operating a keyboard unsupervised.

Was This Overengineering?

Obviously.

It was also a useful service I did not previously have. I can now type curl https://ip.hofstede.it from any machine and see the address it uses on the public Internet. I can force either protocol family with v4 and v6. Scripts get stable plain-text and JSON endpoints under a domain I control. The website no longer tells a third party each time it checks a visitor’s connection. The deployment reuses the Caddy, Bastille, firewall, DNS, and GeoIP patterns already present in my infrastructure.

The distinction I would draw is between needless novelty and enthusiastic disproportion. There is nothing novel in the production design. It is the same reverse proxy, the same jail boundary, the same VNET layout, the same read-only database sharing, and the same service supervision I use elsewhere. The ridiculous part is only that all of it was summoned into existence by a 21-rem-wide toast whose purpose is to tease IPv4 users.

At the start of the morning, my website asked ipify for an address. By early afternoon, ip.hofstede.it, v4.ip.hofstede.it, and v6.ip.hofstede.it existed in my own infrastructure, backed by a Rust binary in a dedicated dual-stack FreeBSD jail.

The toast still looks exactly the same.

That may be my favourite part. ^_^

Comments

You can use your Mastodon or other ActivityPub account to comment on this article by replying to the associated post.

Search for the copied link on your Mastodon instance to reply.

Loading comments...