< 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