< the skull

BUILDING RUNV CLUB
==================
2026-07-02

After never getting a reply from a few tildes, which honestly made me sad
because I tried more than once and waited more than once, I decided to build my
own pubnix.

Just to make things clearer, a pubnix is a shared Unix or Linux server, usually
accessed through SSH, where people have their own user accounts. They can host
personal pages, learn the terminal, write code, exchange messages, use email,
run small services, and take part in a more handmade kind of internet culture. I
like to think of it as a small digital condominium, built around community,
experimentation, the small web, and autonomy. Very far from the closed,
polished, locked-down logic of the big platforms.

The project slowly became something close to an obsession. From the outside, I
started studying every pubnix I could find. I went from SDF to Tilde Town, one
by one, looking at their code, their architecture, their habits, and the way
they handled administration. Not because I wanted to copy them, but because I
wanted to understand how these places actually worked, especially behind the
scenes.

One curious thing I noticed is that many pubnixes are built on the BSD family.

I could understand the appeal. BSD is stable, elegant, and maybe even a little
sophisticated. But beyond that, I did not find a strong reason why I could not
use another operating system.

So I thought: fine, I will use Debian.

Another thing I noticed is that a lot of their administrative tools are written
in C, and some in Go. I decided to go in another direction and build around
Python and its ecosystem. That was the path I wanted to follow. Python would
help me administer the place, glue things together, automate the boring parts,
and keep the whole thing readable enough for me to maintain.

The basic idea behind Runv is simple: each member can publish personal content
through four different protocols, each one mapped to a folder inside their home
directory.

HTTP is served by Apache 2 with `mod_userdir`, on ports 80 and 443, using
`~/public_html`. Each member gets a URL like `/~username/`, with TLS handled
through Certbot.

Gopher is served by Gophernicus on port 70, using `~/public_gopher`, with a
`gophermap` and `/var/gopher` as the root.

Gemini is served by Molly Brown on port 1965, using `~/public_gemini`. Each user
directory is exposed through a bind mount at `/var/gemini/users/<user>`, with
TLS certificates from Let's Encrypt.

Nex is served by my own small `nexd`, written with the Python standard library,
on port 1900, using `~/public_nex`. No bind mount, no complexity, just plain
text and a very small protocol doing exactly what it needs to do.

The base stack is Debian, Apache 2 with `mod_userdir`, `mod_rewrite`,
`mod_headers`, and `mod_proxy`, OpenSSH, systemd, ext4 quotas with `usrquota`,
Certbot, and Let's Encrypt. Transactional email can go through Mailgun over HTTP
or through sendmail/msmtp, while member email is handled through Postfix. For
IRC, the default path is WeeChat on `irc.tilde.chat`, especially the `#runv`
channel.

After writing the first scripts, I started to see where the project was going.
It was no longer just an idea. It was taking shape in front of me. But something
important was still missing: the entrance. The welcome. The first contact.

So I built the onboarding flow around a special account called `entre`.

New people do not get automatic invites. Entry is manually approved. A visitor
connects through SSH as `entre@runv.club`, and `sshd` uses `ForceCommand` to run
`entre_app.py`. That application only queues the request. It never creates
accounts by itself.

The flow is intentionally simple.

First, the visitor chooses a language: Portuguese or English. That choice
follows the whole session through the i18n system.

Then Runv shows the ASCII art and the introduction and warning templates.

After that, `entre_core.py` validates the request. It checks the username with
the pattern `^[a-z][a-z0-9_-]{1,31}$`, blocks reserved names, validates the
email, asks for online presence, and checks the public SSH key. Only allowed key
types are accepted, and the fingerprint is generated through `ssh-keygen`.

The request is then written as an atomic JSON file, using `O_EXCL`, into
`/var/lib/runv/entre-queue/<uuid>.json`, including the selected language.

Finally, the admin is notified through logs and email, and the visitor sees a
goodbye screen with the request number.

The visitor-facing strings live in `terminal/i18n.py`, with Portuguese and
English parity enforced by `tests/test_i18n_strings.py`. The admin templates are
intentionally only in Portuguese, because that part is for me.

At that point, another question appeared: how would I give people accounts
without exposing things they should not see? How could I keep the privacy
principle alive while still giving users a real shell environment?

That led me to the account provisioning system.

The canonical way to create accounts is `scripts/admin/create_runv_user.py`. It
runs as root and is protected by `admin_guard`. The operator has to be
`pmurad-admin`, `root`, or someone listed in `RUNV_ADMIN_USERS`.

The pipeline follows a strict order.

First, the script creates the user with `adduser --disabled-password`, copying
Debian's default skeleton.

Then it installs the SSH key with the right permissions: `~/.ssh` as `700` and
`authorized_keys` as `600`.

After that, it creates the public folders: `public_html` with an `index.html`,
`public_gopher` with a `gophermap`, `public_gemini` with an `index.gmi` and the
Gemini bind mount, and `public_nex` with an `index`.

Then it consolidates permissions: home directories as `755`, public folders as
`755`, and files as `644`.

There is also an optional legacy SSH jail mode through `--with-jail`, but by
default members are not jailed, because I want them to be able to use the global
commands.

The script applies ext4 quotas using `setquota`, with the default being 450/500
MiB and 10k/12k inodes.

Then it runs a final permission check, applies the IRC patch that enables the
`chat` command, updates `users.json` using `flock`, syncs the landing page with
`genlanding --sync-public-only`, and sends the welcome email and the admin
notification.

If something fails after the user is created, the script rolls back with
`deluser --remove-home`. If only the quota step fails, the account is left in a
`partial_quota` state, so I know exactly what needs attention instead of
pretending everything is fine.

Approving requests from the queue is also straightforward.

To approve a specific request:

  sudo python3 scripts/admin/create_runv_user.py --request-id <uuid>

To approve all pending requests:

  sudo python3 scripts/admin/create_runv_user.py --all-pending

And to create a user manually, without the queue:

  sudo python3 scripts/admin/create_runv_user.py --username maria \
       --email maria@example.org --public-key-file maria.pub

Building Runv Club has been genuinely fun. Not fake startup fun, not polished
product-launch fun, but real fun. The kind where you break things, understand
why they broke, fix them, and suddenly realize you learned more in a few nights
than you would have learned from weeks of passive reading.

It is still small, still imperfect, and still becoming what it wants to be. But
it already feels alive.

https://runv.club [1]

Links:
  [1] https://runv.club


< the skull