No description
Find a file
2026-10-09 16:40:21 +03:00
bin docs: drop the predecessor comparisons from comments and tests 2026-10-06 11:04:09 +03:00
content feat: serve gemini behind a tls terminator 2026-10-04 22:04:52 +03:00
core feat: gate a run of blocks to chosen output formats 2026-10-09 16:40:21 +03:00
docker build: package the server as an OCI container image 2026-10-06 11:04:06 +03:00
gemtext feat: gate a run of blocks to chosen output formats 2026-10-09 16:40:21 +03:00
text feat: gate a run of blocks to chosen output formats 2026-10-09 16:40:21 +03:00
wap feat: gate a run of blocks to chosen output formats 2026-10-09 16:40:21 +03:00
.dockerignore build: package the server as an OCI container image 2026-10-06 11:04:06 +03:00
.gitignore feat: add workspace skeleton and server configuration validation 2026-10-04 19:01:35 +03:00
AGENTS.md feat: add workspace skeleton and server configuration validation 2026-10-04 19:01:35 +03:00
AI-DECLARATION.md docs: condense the README, fold the audit summary in, and tighten the declaration 2026-10-06 11:04:09 +03:00
Cargo.lock feat: render wml decks with paginated card sub-documents 2026-10-04 21:05:05 +03:00
Cargo.toml feat: render fixed-width text for nex 2026-10-04 20:27:29 +03:00
devbox.json feat: serve rendered pages over http, spartan and nex 2026-10-04 19:55:30 +03:00
devbox.lock feat: add workspace skeleton and server configuration validation 2026-10-04 19:01:35 +03:00
Dockerfile build: package the server as an OCI container image 2026-10-06 11:04:06 +03:00
flake.lock build: package with Nix and publish a static release artifact 2026-10-06 08:46:19 +03:00
flake.nix build: package with Nix and publish a static release artifact 2026-10-06 08:46:19 +03:00
itsybitsy.toml feat: serve gemini behind a tls terminator 2026-10-04 22:04:52 +03:00
LICENSE feat: add workspace skeleton and server configuration validation 2026-10-04 19:01:35 +03:00
README.md feat: gate a run of blocks to chosen output formats 2026-10-09 16:40:21 +03:00
rustfmt.toml feat: add workspace skeleton and server configuration validation 2026-10-04 19:01:35 +03:00
THIRD_PARTY_NOTICES.md build: package with Nix and publish a static release artifact 2026-10-06 08:46:19 +03:00

🕸 itsybitsy

License: Apache-2.0 AI-DECLARATION: copilot formats Built with Devbox

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
![alt](art.txt) 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
<!-- only gemtext text --> … <!-- end --> a run of blocks only those formats get
<!-- except wml --> … <!-- end --> a run of blocks every other format gets

A gate names format ids, not protocols: gemtext is what Gemini, Spartan, Nex and Gopher all serve, so there is no "only on Gemini". Gates nest, each one closes inside the quote or list item it was opened in, and one left unclosed is refused rather than run on to the end of the page. An id this build does not provide matches nothing, so only wml is hidden everywhere when the wml feature is off — and so is a misspelled id.

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.