Thomas Verhave

Writing · Build Notes

I built a tiny alternative internet in pure Python — a protocol, a browser, and a firewall

Someone said "let's make a new internet" — sarcastically.

Someone said "let's make a new internet" — sarcastically. So I made a working one.

Not a metaphor, not a framework. weft is a small line-based protocol over raw TCP, with its own address scheme, its own markup, a terminal browser that speaks it, and an application-layer firewall you can drive in the browser. Three little servers link to each other to form a network. It's all pure Python standard library — zero dependencies — and the whole thing is a few hundred lines.

It will never scale to a billion users. That was the point.

The bet

The web we have is heavy. Every page drags a megabyte of JavaScript to show a paragraph of text, and something is always measuring you. weft is the opposite bet, and the whole design falls out of four rules:

Here's what building each layer taught me.

Layer 1 — the protocol

I didn't want HTTP. HTTP is enormous once you include everything real clients expect. So weft borrows its shape from Gemini: a request is one line, a response is one status line then the body.

address:   weft://host:port/path
request:   GET /path\n
response:  20 text/weft\n   ...then the body bytes

Four status codes, deliberately. That's the entire vocabulary:

20  ok, body follows
30  redirect, body is the new address
40  not found
50  server error

The client is almost nothing — one connection, one page, then the socket closes:

def fetch(addr, *, timeout=5.0):
    with socket.create_connection((addr.host, addr.port), timeout=timeout) as sock:
        sock.sendall(f"GET {addr.path}\n".encode())
        sock.shutdown(socket.SHUT_WR)          # I'm done writing; send me the page
        raw = b""
        while chunk := sock.recv(65536):
            raw += chunk
    header, _, body = raw.partition(b"\n")
    status, _, meta = header.decode(errors="replace").partition(" ")
    return Response(int(status), meta, body)

That shutdown(SHUT_WR) is the whole flow-control story: the client half-closes to say "I've sent my one line, everything you send back is the response." No content-length, no chunked encoding, no keep-alive. The connection is the framing.

(The default port is 1965 — a wink at Gemini. The year, not the protocol.)

Layer 2 — the markup

If a page is meant to be readable with cat, the markup has to be plain text that already looks right. So .weft pages are line-based, like Gemtext. A line starting with => is a link; # and ## are headings; everything else is a paragraph.

# a small manifesto

The web we have is heavy. weft is the opposite bet:
text first, no client-side code, a page you can read with cat.

=> / home
=> weft://127.0.0.1:1966/ visit the garden

No inline styling, no nesting, no ambiguity. The parser is a for loop over splitlines(). Because links live on their own lines, the terminal browser can just number them and let you follow a link by typing its number — no cursor, no mouse.

Layer 3 — the server

Each "node" serves one site (a folder of .weft files) with a thread per connection. The only real defensive code is path-traversal protection — map a request path to a file under the root, or refuse:

def resolve(root, path):
    rel = path.lstrip("/") or "index.weft"
    candidate = (root / rel).resolve()
    if root.resolve() not in candidate.parents and candidate != root.resolve():
        return None          # tried to escape the site root — deny
    return candidate if candidate.is_file() else None

Three nodes run on ports 1965/1966/1967, and their pages link to each other with weft:// addresses — so you get an actual little inter-network you can browse between.

Layer 4 — the browser

The browser is a terminal REPL. It fetches a page, renders the text, prints the links as a numbered list, and waits:

<number>   follow that link
b          back
r          reload
g <addr>   go to a weft:// address
q          quit

That's the entire client. It's oddly calming to use — no tabs, no spinner, no cookie banner. Just text and numbered doors.

Layer 5 — the firewall (the fun part)

This is where it stopped being a toy for me. I put an application-layer firewall in front of a node — a filtering gateway that inspects every connection (source IP + requested path), evaluates an ordered ruleset, enforces a default policy and rate limits, logs every decision, then transparently forwards allowed traffic to the backend.

The ruleset reads like an iptables chain or a pf config — first match wins, default-deny:

policy deny

# a loose overall cap, plus a tighter one just for search
ratelimit 10/10s
ratelimit 2/10s path /search*

deny  path /admin*                         # nobody reaches the admin area
allow from 127.0.0.0/8 path /              # loopback is trusted
allow from 127.0.0.0/8 path /manifesto
deny  from 192.0.2.0/24                    # a blocked network (RFC 5737 TEST-NET)
# everything else falls through to `policy deny`

Conditions AND together; a rule with no conditions is a catch-all. Matching is CIDR for sources and shell-glob for paths (/admin* catches /admin/keys). And because editing rules shouldn't drop live connections, it hot-reloads on SIGHUP:

kill -HUP <pid>     # re-reads firewall.rules without closing the socket

Writing a default-deny, first-match-wins engine by hand is the single best way I've found to actually understand why firewall rule order matters — you feel it the moment you put an allow after a deny that shadows it.

The part that surprised me

The same rule engine runs in the browser. The project page has a live simulator where you can type a source IP and a path, watch the rules evaluate top-to-bottom, and see exactly which line decides the verdict — driven by a JavaScript port of the same logic in firewall.py. Building it twice, in two languages, forced me to write the rules as data rather than control flow, which made both versions cleaner.

What I actually learned

A build-story · see the live project → · more writing →