- Python 53.3%
- HTML 37%
- Shell 9.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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> |
||
| config | ||
| gemini | ||
| templates | ||
| www | ||
| .env.example | ||
| .gitignore | ||
| app.py | ||
| provision.sh | ||
| README.md | ||
| scan_capsules.py | ||
| setup.sh | ||
debster — Gemini Capsule Registration System
Self-service subdomain registration for [naam].gemini.debster.nl.
How it works
- User visits
https://debster.nl/claim→ fills in subdomain, email, SSH key - Verification email sent via Postmark (or logged if token is
PLACEHOLDER) - User clicks the verify link → status becomes
awaiting_approval. Nothing is provisioned yet; the admin gets a review email. - Admin opens the approve link → the capsule is provisioned:
- Linux user created (
/usr/sbin/nologinshell,gemini-usersgroup) - SSH key installed → SFTP-only access
- Directory
/var/gemini/[subdomain].gemini.debster.nl/created - Welcome
index.gmiwritten - Self-signed TLS cert generated (10 years)
- Agate restarted, picking up the new vhost
- Linux user created (
- 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/.