6.8 KiB
🕸 itsybitsy
Serve folders of Markdown to different domains over HTTP, Gemini, Spartan, Nex and Gopher, rendered on request with no build step. One process serves many virtual hosts, each output format is a separate crate behind a cargo feature, and configuration lives in TOML files. Gemini expects TLS to be terminated in front of it; that is the one deployment requirement itsybitsy does not satisfy by itself.
The output formats are Rust ports of previous projects: the standalone Markdown to esoteric text format converter md2txt and a spinoff from that for WML, wapdown.
Deployments
Itsybitsy can be seen in action running the Gemini/Spartan/Nex/Gopher content for https://smol.place, the Gemini/Spartan capsules for https://randogoth.com and https://wap.randogoth.com
Usage
itsybitsy --config /etc/itsybitsy.toml # serve
itsybitsy --config /etc/itsybitsy.toml --check # validate configuration only
A server file names the sites and the listeners:
version = 1
[site.smol]
root = "/srv/smol/content"
hosts = ["smol.place", "www.smol.place"]
[listener.web]
protocol = "http"
bind = "0.0.0.0:8080"
formats = ["xhtmlmp", "html"]
default_site = "smol"
[listener.nex]
protocol = "nex"
bind = "0.0.0.0:1900"
formats = ["gemtext"]
site = "smol"
HTTP and Spartan route by the hostname the request carries, falling back to default_site; Nex and Gopher carry none, so their listener names one site outright. Every format a listener serves must be compiled in, which --check reports. A .itsybitsy.toml in a content directory sets rendering options for that directory — [defaults] for all its Markdown files, [page."name.md"] for one — with no inheritance from parent directories. Editing it re-renders on the next request.
Formats and protocols
| Format | Served as | Behind feature |
|---|---|---|
gemtext |
text/gemini |
default |
xhtmlmp, html |
WAP and desktop markup | default |
text |
text/plain, wrapped to 80 columns |
default |
wml |
WML 1.3 decks for WAP 1.x handsets | wml |
FIGlet banners, hyphenation for text |
figlet, hyphenation |
HTTP negotiates formats from Accept and ?format=<id> overrides it. Two decorative text features are off by default to save binary size: cargo build --release --features "figlet hyphenation".
Markdown
Markdown is parsed once per document and rendered to every format. Everything itsybitsy adds to CommonMark stays portable:
| Written | Means |
|---|---|
![[path]] |
splice that file's lines in here |
 |
an image whose target is a text file, inlined verbatim |
<!-- card Title --> |
a card divider, for formats that paginate |
<!-- center -->, <!-- right margin=4 --> |
alignment for the block that follows |
Links should be root-relative and extensionless ([about](/about)), so the same link resolves identically from every protocol:
| Request | Serves |
|---|---|
/ |
index.md |
/foo |
foo.md, else foo/index.md |
/foo.md |
redirects to /foo |
/img.png |
the file itself, by media type |
Nothing outside a content root is reachable, dot-prefixed components are refused, and include expansion is depth-, line- and byte-capped. There are no generated directory listings — only index.md.
Gemini and TLS
itsybitsy does not speak TLS. A TLS wrapper (stunnel, ghostunnel) or a load balancer owns 1965 and forwards plaintext to a loopback port the Gemini listener binds; it supports SNI, so each virtual host can present its own certificate. Use a long-lived self-signed certificate rather than an ACME one: Gemini clients pin the fingerprint they first saw. A TLS terminator in front is also how the container deployment below runs.
Deployment
The image builds from the repository root and runs on any OCI container host:
docker build -t itsybitsy .
docker run -p 8080:8080 -p 3000:3000 -p 1900:1900 -p 7070:7070 itsybitsy
It serves the demo site with docker/itsybitsy.toml, which binds 0.0.0.0 where the checkout's own server file binds loopback, so published ports are reachable. A real site mounts over those paths:
docker run -p 8080:8080 \
-v /srv/mysite.toml:/srv/itsybitsy/itsybitsy.toml:ro \
-v /srv/mysite:/srv/content:ro itsybitsy
The container runs as an unprivileged user. Port 1965 stays unexposed on the host: the Gemini listener is plaintext and a TLS terminator must own it in front, as always.
The flake deploys without Docker:
nix run . -- --config ./itsybitsy.toml # build and serve
nix build .#static # fully static musl binary
scp result/bin/itsybitsy host:/usr/local/bin/
packages.static links against musl with no C dependency, so the one binary runs unchanged on any Linux host, including one that has never seen Nix; nix run .#release-static publishes it as a release asset.
Security audit
An adversarial audit of integration, containment, protocol abuse and load (2026-10-04, 138 checks over raw sockets) found three defects, all fixed with regression tests: deeply nested documents overflowed the stack and aborted the process (parsing now refuses past 100 levels), a file name with CR LF could inject a response header via redirect targets (generated URLs are now percent-encoded), and a large Markdown file was read into memory before the 8 MiB expansion cap rejected it (reads are now capped). After the fixes, 135 traversal attempts across all five protocols returned nothing outside the content roots and include bombs were bounded, at roughly 8–9k req/s with flat memory.
Outstanding: the render cache is unbounded (6.2× the source size across formats), an oversized request header yields a TCP reset instead of a 400, a listener's width is ignored, and there is no graceful shutdown. The TLS leg in front of Gemini was not tested, and the threat model assumes the content tree is author-controlled.
Development
devbox run check # fmt --check, clippy -D warnings, test
devbox run test
devbox run build
devbox run run # serve ./content on localhost
RUST_LOG sets the log level, which defaults to info: one line per request, and the real cause on failure.