# 🕸 itsybitsy [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![AI-DECLARATION: copilot](https://img.shields.io/badge/%E4%B7%BC%20AI--DECLARATION-copilot-fee2e2?labelColor=fee2e2)](https://ai-declaration.md) ![formats](https://img.shields.io/badge/formats-gemtext%20%C2%B7%20text%20%C2%B7%20html%20%C2%B7%20XHTML--MP%20%C2%B7%20WML-4b5563) Serve folders of Markdown to different domains over HTTP, [Gemini](https://geminiprotocol.net/docs/specification.gmi), [Spartan](https://portal.mozz.us/gemini/spartan.mozz.us/), [Nex](https://nightfall.city/nex/info/specification.txt) and [Gopher](https://www.rfc-editor.org/rfc/rfc1436), 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](https://code.randogoth.com/randogoth/md2txt) and a spinoff from that for WML, [wapdown](https://code.randogoth.com/randogoth/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 ```bash itsybitsy --config /etc/itsybitsy.toml # serve itsybitsy --config /etc/itsybitsy.toml --check # validate configuration only ``` A server file names the sites and the listeners: ```toml 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=` 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 | | `` | a card divider, for formats that paginate | | ``, `` | 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: ```bash 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: ```bash 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: ```bash 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 ```bash 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.