131 lines
7.5 KiB
Markdown
131 lines
7.5 KiB
Markdown
# 🕸 itsybitsy
|
||
|
||
[](LICENSE) [](https://ai-declaration.md)  [](https://github.com/jetify-com/devbox/)
|
||
|
||
|
||
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=<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 |
|
||
| `<!-- 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:
|
||
|
||
```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.
|