Ansible for runv.club, a small public unix server.
  • Python 79.9%
  • HTML 9.1%
  • Jinja 7.2%
  • Shell 3.7%
Find a file
2026-11-09 20:50:48 +00:00
host_vars about page goes up now, costs listed later 2026-10-30 21:49:24 +00:00
roles runvbot: ping the server itself instead of leaving a quiet channel 2026-11-09 20:50:48 +00:00
.gitignore status: one line about what you're up to, on web, gemini, gopher and finger 2026-11-09 18:10:29 +00:00
ansible.cfg no cows in the playbook output 2026-10-31 02:40:37 +00:00
hosts ansible base: ssh, firewall, fail2ban, tools 2026-10-24 14:29:01 +00:00
README.md mail: forward you@runv.club to an address you confirm 2026-11-09 19:10:04 +00:00
site.yml runvbot: what's new on runv.club, one thing at a time, in #runvclub 2026-11-09 19:58:34 +00:00

runv.club

Server config for the runv.club pubnix. The box is set up from this repo with Ansible, run on the server itself:

cp host_vars/srv1.yml.example host_vars/srv1.yml   # and edit it
sudo ansible-playbook site.yml

Two secrets live outside the repo and must exist before the first run:

  • /etc/runv/s3.env, S3 credentials for the backups
  • /etc/runv/restic.pass, the restic repository password

Mail (welcome messages, the weekly report and failure alerts) stays off until /etc/runv/mail.env exists. There is an example next to it.

Members

sudo runv-user add NAME --email ADDR --key-file key.pub
sudo runv-user suspend NAME --reason '...'
sudo runv-user unsuspend NAME
sudo runv-user delete NAME
sudo runv-user list

Each member gets 1G of RAM, 200 processes, one CPU, 2G of disk and 200k files. runv-user quota-sync brings existing members in line when the limits change (ansible runs it). Deleted homes sit in /var/lib/runv/deleted for 14 days before they are gone for good. What they left in /srv/square goes to the quarantine with them, and what's theirs in /tmp, /var/tmp and /dev/shm is removed, since uids get reused. A uid still in the quarantine isn't handed out again.

/srv/square is the shared directory: mode 1777, so anyone writes and nobody touches other people's files (no setgid: with umask 002, files in a members-group dir would be writable by everyone), and the kernel's protected_* settings cover links and fifos left there. It counts on quota and isn't backed up.

Web, Gemini and Gopher

nginx serves https://runv.club/~NAME/ from ~/public_html, molly-brown serves gemini://runv.club/~NAME/ and gophernicus serves gopher://runv.club/1/~NAME. Links to files a member doesn't own are not followed, and a suspended member's pages go dark on all three.

molly-brown only reads under /var/gemini, so each active member's public_gemini is bind mounted read-only into /var/gemini/users. runv-user keeps those mounts in sync, don't touch them by hand.

The Gemini certificate is self-signed and made once. Clients pin it, so don't regenerate it.

I2P

Every member gets an eepsite with an address of their own, served from ~/public_i2p. runv-user writes one i2pd tunnel per member in /etc/i2pd/tunnels.d and leaves the address in ~/.i2p-address. The key in /var/lib/i2pd/runv is the address, so it's backed up as soon as it exists and only goes away with the account. i2pd runs with no console and no proxies.

On the box

Members get help (topics live in /usr/local/share/runv/help, and runv-help-web puts them at /help too), a login banner that's different every time (motd), and the usual tools: editors, git, tmux, irssi, weechat, senpai, compilers and interpreters for Python, C, Go, Rust, Node, Ruby, Lua, Haskell, Scheme, Common Lisp and Racket, and games (help games, botany included). mosh works on UDP 60000-61000.

runv is the member dashboard, in the terminal: keys, tokens, pages, blog, pastes, access logs, stats and the state of every service. Every screen also prints as plain text (runv keys, runv analytics). The few things that need root go through runv-priv, which members can run with sudo. It only ever acts on whoever called it and takes no free arguments.

  • blog: markdown in ~/blog becomes a blog with an Atom feed on the web, Gemini and Gopher. A timer rebuilds it as the member, every 3 minutes, when something changed. runv-planet collects everyone's posts at /planet and on the home page, and writes /tilde.json, the member directory at /~ (with who changed their page in the last day, also as a feed at /updated.xml) and the webring at /ring. Members join the ring with touch ~/.ring.
  • pb: text from stdin to ~/public_html/p, with an expiry.
  • tw: twtxt in ~/public_html/twtxt.txt, with a timeline of all members.
  • board: a forum in the terminal. Posts are written by runv-priv into /var/lib/runv-board, so nobody can touch other people's posts.
  • finger on port 79 shows ~/.plan and who is on.
  • write NAME and mesg: Debian 13 dropped both, and utmp with them. These are ours. write goes through runv-priv, which finds the ptys the target owns and writes to the most recently used one that has the group write bit (mesg y), with control characters stripped and at most five messages a minute.

Guestbooks

runv-guestbook (flask on 127.0.0.1:8002, user runvgb) serves /guestbook/NAME, an iframe version at /guestbook/NAME/embed and the approved notes as json. A book is off until its owner runs guestbook on, and notes show up only after guestbook ok N. Moderation goes through runv-priv guestbook, which runs gbctl.py as runvgb with the caller's name, so nobody touches someone else's book and the database is never root's. Against spam: a signed one-use token (runvform.py, shared with the signup form), a honeypot, 3 notes per book and 10 overall per address a day (kept as a salted hash for 30 days), and nginx limits. Suspending a member closes their book, purging deletes it.

IRC bouncer

soju at irc.runv.club:6697, gamja at https://chat.runv.club. Every member gets a bouncer account on tilde.chat in #runvclub, with the password in ~/.config/runv/irc-token. chat opens senpai on it. Members can add up to 3 networks. Accounts idle for 30 days get disconnected until they log in again, and suspending a member locks theirs.

Tunnels and apps

Whatever listens on /run/runv-apps/NAME/app.sock is served at https://NAME.tun.runv.club: a tunnel from the member's machine (ssh -R /run/runv-apps/NAME/app.sock:localhost:8000 NAME@runv.club -N) or an app under systemd --user (lingering is on for members). Members may only forward to sockets: no -L, -D or TCP -R, so the box can't be used as a proxy. runv-tun-cert keeps one certificate with every active member's name on it.

Stats

nginx logs ~member requests to their own file. runv-stats runs every hour and gives each member an access log with addresses cut down, kept 14 days, and, if they turned analytics on, counts of unique visitors (hashed with a salt that's thrown away every day), pages and referrers, kept a year. No address is stored.

ntfy and feeds

ntfy.runv.club is closed by default. runv token ntfy gives a member a user whose only topics start with their name. feeds.runv.club is miniflux on postgres, with login through Forgejo only.

Mail

From outside, the box only forwards. admin@, postmaster@, abuse@, dmarc@ and root@ go to mail_forward_to in host_vars, with SRS so SPF still passes on the other end. Members can forward their own address with runv mail forward set ADDRESS: a code goes to that address through Mailgun, and only after runv mail forward confirm CODE does runv-user mail-sync add it to runv-forward (virtual), runv-forward-transport (the only other addresses mail may leave for) and runv-inbound.regexp (who outside may write to). The data lives in /var/lib/runv/forwards.json. Anything else is refused at RCPT and nothing is relayed.

rspamd sits in front as a milter: spam is refused during the SMTP conversation (score 12), nothing is greylisted, each address takes at most 100 messages a day from outside (valkey keeps the counts), mail that comes in gets an ARC seal and mail from runv.club addresses a DKIM signature (selector s2026, key in /var/lib/rspamd/dkim). unbound on 127.0.0.1:8053 answers rspamd's DNS blocklist lookups. Mail goes out over IPv4 only, one message every 3 seconds per destination. A forward whose every message bounces for three days is switched off by runv-user forward-check, and the member is told.

Inside, members mail each other: runv.club is a local domain, delivered to /var/mail (50MB each). Nothing leaves: default_transport is an error, and transport_maps lets out only the exact mail_forward_to address. Only root and active members may use sendmail, each under their own name (local_login_sender_maps). runv-user keeps the list of senders and the suspended members (whose mail bounces) in step with the member list, and builds the mailing lists (mail_lists, sent to list+NAME) from each member's ~/.lists. A deleted member's mailbox goes to the quarantine with their home.

Bounces for forwarded mail don't go back to the original sender outside: SRS addresses are refused at RCPT, since nothing could take the bounce out anyway. A destination that refuses mail is caught by forward-check.

Forgejo, closed to the outside: no signups, nothing visible without logging in. Git over SSH is on port 2222 with the same key as the shell, and each member has 1GiB. The binary comes from Codeberg and is checked against its GPG signature, so updating means changing forgejo_version and the checksum in the role defaults.

linkding runs from a venv and has no login form of its own. People log in through Forgejo (OIDC), so only members get in.

runv-user creates the Forgejo account along with the shell account and leaves the first password in ~/.forgejo-password. Suspending a member locks them out of both, and the purge 14 days after a delete removes the accounts and the repos.

Signups

The form at /join, and ssh join@runv.club (a user with no password whose only command asks the same questions and posts to the same form), only drop requests into /var/lib/runv-join/queue. Nothing gets created until someone runs:

sudo runv-queue list
sudo runv-queue approve ID
sudo runv-queue reject ID --reason '...'

At most 5 approvals a week and 100 people in line. Every 60 days in line people are asked if they still want in, and they're dropped if they don't answer within a week. Set signup_state: open in host_vars to open the form.

Backups

restic to S3. A full backup runs on the 1st and the 16th and the last 6 are kept. The member list, the signup queue and the I2P keys are also sent right after they change. The Forgejo and linkding databases are copied with SQLite's backup API into /var/backups/runv-db before each full run, along with the soju and ntfy databases and a pg_dump of miniflux, and those copies are what gets backed up.

sudo runv-backup status