Building Runv Club
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-pendingAnd 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.pubBuilding 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.
Member discussion