DEV Community

thomas verhave
thomas verhave

Posted on

Tiny Alternative Internet

title: "I built a tiny alternative internet in pure Python — a protocol, a browser, and a firewall" published: false tags: python, networking, showdev, programming canonical_url: https://abouttime-d5a.pages.dev/project-weft
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:

text first, always
no client-side code, so there's no surveillance surface
a page is a file you can read with cat
the network is small enough to hold in your head
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:

follow that link
b back
r reload
g 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 # 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
Constraints are a design tool. "No client-side code" isn't a limitation, it's what makes the markup, the browser, and the privacy story all fall into place at once.
The stdlib is bigger than you think. socket, threading, dataclasses, re, pathlib, fnmatch, ipaddress — that's the entire dependency list, and it's plenty to build a protocol stack.
You understand a thing when you rebuild it. I've used firewalls for years; I only really got rule-order shadowing and default-deny posture after writing 380 lines that enforce them.
Try it
It runs on your machine in one command (./run.sh launches the three nodes and drops you into the browser). The write-up, the live firewall simulator, and links to the code are here:

👉 https://abouttime-d5a.pages.dev/project-weft

I'd genuinely love to hear how you'd have designed the transport layer differently — the half-close-as-framing trick feels elegant but I'm sure it has sharp edges I haven't hit yet.

Top comments (0)