Gemini subdomain registration system for debster.nl
  • Python 53.3%
  • HTML 37%
  • Shell 9.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Sparky eb45a6ee93 Wachttijd naar een uur, en wij in plaats van een mens
De verificatiemail deed dezelfde belofte in andere woorden; die is
meegegaan, anders spreken de twee elkaar tegen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 10:54:24 +00:00
config Rol-adressen weren, rate limiting op het echte IP, en capsules scannen 2026-09-22 10:28:36 +00:00
gemini Add deployment configs, Gemini capsules, and website 2026-03-27 21:45:39 +00:00
templates Wachttijd naar een uur, en wij in plaats van een mens 2026-09-22 10:54:24 +00:00
www Add deployment configs, Gemini capsules, and website 2026-03-27 21:45:39 +00:00
.env.example Handmatige goedkeuring vereisen voor nieuwe capsules 2026-09-22 10:13:15 +00:00
.gitignore Rol-adressen weren, rate limiting op het echte IP, en capsules scannen 2026-09-22 10:28:36 +00:00
app.py Wachttijd naar een uur, en wij in plaats van een mens 2026-09-22 10:54:24 +00:00
provision.sh Dubbele punt uit de GECOS-comment van useradd halen 2026-09-22 10:13:44 +00:00
README.md Rol-adressen weren, rate limiting op het echte IP, en capsules scannen 2026-09-22 10:28:36 +00:00
scan_capsules.py Rol-adressen weren, rate limiting op het echte IP, en capsules scannen 2026-09-22 10:28:36 +00:00
setup.sh Initial commit: Gemini capsule registration system 2026-03-16 23:38:58 +00:00

debster — Gemini Capsule Registration System

Self-service subdomain registration for [naam].gemini.debster.nl.

How it works

  1. User visits https://debster.nl/claim → fills in subdomain, email, SSH key
  2. Verification email sent via Postmark (or logged if token is PLACEHOLDER)
  3. User clicks the verify link → status becomes awaiting_approval. Nothing is provisioned yet; the admin gets a review email.
  4. Admin opens the approve link → the capsule is provisioned:
    • Linux user created (/usr/sbin/nologin shell, gemini-users group)
    • SSH key installed → SFTP-only access
    • Directory /var/gemini/[subdomain].gemini.debster.nl/ created
    • Welcome index.gmi written
    • Self-signed TLS cert generated (10 years)
    • Agate restarted, picking up the new vhost
  5. User gets a second email with the upload instructions

Statuses: pending → awaiting_approval → provisioning → verified, or banned when the admin rejects. A banned row keeps the subdomain, email and IP occupied so the same operator cannot simply register again.

Why approval is manual

In september 2026 a link-farm bot registered skdesubl as backlink@skdesu.com, verified its own address and had SEO gemtext live within three minutes. Email verification proves nothing when the spammer owns the domain, so a human decides.

Admin

All admin routes take the token from .env as ?k=; a wrong or missing key returns 404, not 403.

https://debster.nl/admin/queue?k=<ADMIN_TOKEN>
https://debster.nl/admin/approve/<token>?k=<ADMIN_TOKEN>
https://debster.nl/admin/reject/<token>?k=<ADMIN_TOKEN>

The token travels in the query string because the links are clicked from email. Nginx redacts the query string for /admin paths so it stays out of access.log.

Upload files

scp myfile.gmi alice@debster.nl:myfile.gmi

Stack

  • Python 3 + Flask
  • SQLite (/opt/debster/debster.db)
  • Agate (Gemini server, port 1965, vhosts auto-discovered from /var/gemini/*.*/)
  • Nginx on port 80 → Flask; TLS is terminated upstream by NginxProxyManager
  • Postmark (email)

Config

Copy .env.example to .env and set values:

POSTMARK_TOKEN=your-token-here
SECRET_KEY=random-64-char-hex
ADMIN_TOKEN=random-url-safe-token
ADMIN_EMAIL=you@example.com

When POSTMARK_TOKEN=PLACEHOLDER, emails are logged to /var/log/debster-emails.log instead.

Client IP behind two proxies

Requests arrive through NginxProxyManager and then nginx, both on the LAN, so X-Real-IP and $remote_addr are always 192.168.178.24. The real client sits in X-Forwarded-For, counted from the right: last entry as nginx receives it, second to last by the time Flask sees it (nginx appends NPM). See TRUSTED_PROXY_HOPS in app.py and $real_client_ip in the nginx config. Counting from the left is spoofable — a client can prepend entries, but cannot append after our own proxies.

Anti-abuse

  • Provisioning requires manual approval
  • Max 1 active (or banned) capsule per email address
  • Max 1 active (or banned) capsule per IP address
  • Link-seller role addresses (backlink@, seo@, guestpost@, …) rejected
  • Weaker signals (marketing@, campaign markers in the SSH key comment) are flagged in the approval email rather than rejected
  • Rate limiting on /claim (3/min) and /check-subdomain (30/min), keyed on the real client IP
  • Verification link expires after 24 hours
  • Subdomain blacklist (admin, www, mail, …)
  • Format: a-z0-9-, 3–32 chars, no leading/trailing dash

Capsule content scan

scan_capsules.py walks the capsules daily and mails the admin when it sees new outbound http(s) domains, a wall of external links, or byte-identical .gmi files — the fingerprint of the original spam capsule. It reports only what changed since the previous run; state lives in scan_state.json.

systemctl enable --now debster-scan.timer

Real-time availability check

The claim form calls /check-subdomain?name=xxx (debounced, 400 ms) while the user types. Blacklisted names are rejected client-side instantly, without a server round-trip. The submit button stays disabled until the subdomain is confirmed available. The check fails open, so a throttled response never blocks a legitimate submit.

Deployment

systemctl enable --now debster-app
systemctl enable --now debster-scan.timer

Unit files are mirrored in config/.