diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30887bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Python cache and build artifacts +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ + +# Virtual environment directories +venv/ +.env/ +env/ +.venv/ +ENV/ + +# Log files +*.log +logs/ + +# IDE or OS-specific files +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo +*~ +Thumbs.db +.project +.settings/ +.classpath + +# Project-specific sensitive files +.registration_token +config.yaml +registrations.json +banned_emails.txt +banned_ips.txt +banned_usernames.txt + +# Backup directories +backup/ +*_backup/ +*.bak + +# Test cache +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# Dependency management +pip-log.txt +pip-delete-this-directory.txt + +# Environment variables +.env* +!.env.example diff --git a/README.md b/README.md index af4bbfe..741b552 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,95 @@ -# hand_of_morpheus +# Matrix Registration System -A FastAPI-based web application that manages Matrix account registration requests for homeservers that do not offer SMTP authentication (like conduwuit). It provides a registration token to users via email, with automatic token rotation and various safety features. \ No newline at end of file +A FastAPI-based web application that manages Matrix account registration requests for homeservers that do not offer SMTP authentication (like conduwuit). It provides a registration token to users via email, with automatic token rotation and various safety features. + +## Features + +- Daily rotating registration tokens +- Rate limiting per email address +- Multiple account restrictions +- IP and email address banning +- Username pattern banning with regex support +- Automatic downtime before token rotation +- Gruvbox-themed UI with responsive design + +## Setup + +1. Install dependencies: +```bash +pip install fastapi uvicorn jinja2 httpx pyyaml python-multipart +``` + +2. Configure your settings: +```bash +cp config.yaml.example config.yaml +# Edit config.yaml with your settings +``` + +3. Create required files: +```bash +touch banned_ips.txt banned_emails.txt banned_usernames.txt +mkdir static +# Add your logo.png to static/ +# Add favicon.ico to static/ +``` + +4. Generate initial registration token: +```bash +openssl rand -base64 32 | tr -d '/+=' | head -c 32 > .registration_token +``` + +## Configuration + +The `config.yaml` file supports these options: + +```yaml +port: 6626 +homeserver: "your.server" +token_reset_time_utc: 0 # 24-hour format (e.g., 0 = 00:00 UTC) +downtime_before_token_reset: 30 # minutes +email_cooldown: 3600 # seconds between requests per email +multiple_users_per_email: false # allow multiple accounts per email? + +smtp: + host: "smtp.example.com" + port: 587 + username: "your@email.com" + password: "yourpassword" + use_tls: true +``` + +## Token Rotation + +Add this to your crontab to rotate the registration token daily at 00:00 UTC: + +```bash +# Edit crontab with: crontab -e +0 0 * * * openssl rand -base64 32 | tr -d '/+=' | head -c 32 > /path/to/your/.registration_token +``` + +## Running the Server + +Development: +```bash +python registration.py +``` + +Production: +```bash +uvicorn registration:app --host 0.0.0.0 --port 6626 +``` + +## Security Features + +- **IP Banning**: Add IPs to `banned_ips.txt`, one per line +- **Email Banning**: Add emails to `banned_emails.txt`, one per line +- **Username Patterns**: Add regex patterns to `banned_usernames.txt`, one per line +- **Registration Tracking**: All requests are logged to `registrations.json` + + +## Security Notes + +- Place behind a reverse proxy with HTTPS +- Consider placing the registration token file outside web root +- Regularly backup `registrations.json` +- Monitor logs for abuse patterns diff --git a/example-.registration_token b/example-.registration_token new file mode 100644 index 0000000..f56dcf8 --- /dev/null +++ b/example-.registration_token @@ -0,0 +1 @@ +Cc8eeOtozcf2lHl6WhKQ3SGfHK9rda1j diff --git a/example-banned_emails.txt b/example-banned_emails.txt new file mode 100644 index 0000000..e69de29 diff --git a/example-banned_ips.txt b/example-banned_ips.txt new file mode 100644 index 0000000..e69de29 diff --git a/example-banned_usernames.txt b/example-banned_usernames.txt new file mode 100644 index 0000000..a2a5cb3 --- /dev/null +++ b/example-banned_usernames.txt @@ -0,0 +1,5 @@ +.*admin.* +.*loli.* +.*shota.* +.*pedo.* +.*pthc.* diff --git a/example-config.yaml b/example-config.yaml new file mode 100644 index 0000000..1be4e7b --- /dev/null +++ b/example-config.yaml @@ -0,0 +1,35 @@ +port: 6626 +homeserver: "we2.ee" + +# Token reset configuration +token_reset_time_utc: 0 # 00:00 UTC +downtime_before_token_reset: 30 # 30 minutes before that time, registration is closed + +# Email rate limiting and multiple account settings +email_cooldown: 3600 # 1 hour cooldown between requests for the same email +multiple_users_per_email: false # If false, each email can only be used once + +# SMTP configuration +smtp: + host: "smtp.protonmail.ch" + port: 587 + username: "email@pm.me" + password: "YourPassword" + use_tls: true + +# Email templates +email_subject: "Your Registration Token for {homeserver}" + +email_body: | + Hello, + + Thank you for your interest in {homeserver}, {requested_username}. + + The registration token today is: {registration_token} + + This registration token is valid for {time_until_reset}. If you do not register in that period, you will need to request the new registration token. + + Please ensure you use the username {requested_username} when you register. Using a different username may result in your account being deleted at a later time without forewarning. + + Regards, + {homeserver} registration team diff --git a/registration.py b/registration.py new file mode 100644 index 0000000..845469d --- /dev/null +++ b/registration.py @@ -0,0 +1,397 @@ +import os +import re +import yaml +import json +import smtplib +import httpx +import logging +from datetime import datetime, timedelta +from email.message import EmailMessage +from typing import List, Dict, Optional, Tuple, Set, Pattern +from fastapi import FastAPI, Request, Form, HTTPException +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.templating import Jinja2Templates +from fastapi.staticfiles import StaticFiles +from starlette.middleware.base import BaseHTTPMiddleware + +# --------------------------------------------------------- +# 1. Load configuration and setup paths +# --------------------------------------------------------- +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +CONFIG_PATH = os.path.join(BASE_DIR, "config.yaml") +with open(CONFIG_PATH, "r") as f: + config = yaml.safe_load(f) + +# Initialize or load registrations.json +REGISTRATIONS_PATH = os.path.join(BASE_DIR, "registrations.json") +def load_registrations() -> List[Dict]: + try: + with open(REGISTRATIONS_PATH, "r") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return [] + +def save_registration(data: Dict): + registrations = load_registrations() + registrations.append(data) + with open(REGISTRATIONS_PATH, "w") as f: + json.dump(registrations, f, indent=2) + +# Load banned IPs and emails +def load_banned_list(filename: str) -> Set[str]: + try: + with open(os.path.join(BASE_DIR, filename), "r") as f: + return {line.strip() for line in f if line.strip()} + except FileNotFoundError: + return set() + +def load_banned_usernames() -> List[Pattern]: + """Load banned usernames file and compile regex patterns.""" + patterns = [] + try: + with open(os.path.join(BASE_DIR, "banned_usernames.txt"), "r") as f: + for line in f: + line = line.strip() + if line: + try: + patterns.append(re.compile(line, re.IGNORECASE)) + except re.error: + logging.error(f"Invalid regex pattern in banned_usernames.txt: {line}") + except FileNotFoundError: + pass + return patterns + +banned_ips = load_banned_list("banned_ips.txt") +banned_emails = load_banned_list("banned_emails.txt") +banned_username_patterns = load_banned_usernames() + +# Read the registration token +def read_registration_token(): + token_path = os.path.join(BASE_DIR, ".registration_token") + try: + with open(token_path, "r") as f: + return f.read().strip() + except FileNotFoundError: + return None + +# --------------------------------------------------------- +# 2. Logging Configuration +# --------------------------------------------------------- +# Set up logging format +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) + +# Configure loggers +logging.getLogger("uvicorn.access").setLevel(logging.WARNING) +logging.getLogger("uvicorn.error").setLevel(logging.WARNING) +logger = logging.getLogger(__name__) + +class CustomLoggingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + # Don't process /api/time or favicon requests at all + if request.url.path == "/api/time" or request.url.path.endswith('favicon.ico'): + return await call_next(request) + + # For all other requests, log them + response = await call_next(request) + logger.info(f"Request: {request.method} {request.url.path} - Status: {response.status_code}") + return response + +# --------------------------------------------------------- +# 3. Time Calculation Functions +# --------------------------------------------------------- +def get_current_utc() -> datetime: + return datetime.utcnow() + +def get_next_reset_time(now: datetime) -> datetime: + """Return the next reset time (possibly today or tomorrow) from config.""" + reset_h = config["token_reset_time_utc"] // 100 + reset_m = config["token_reset_time_utc"] % 100 + candidate = now.replace(hour=reset_h, minute=reset_m, second=0, microsecond=0) + if candidate <= now: + # If we've passed today's reset time, it must be tomorrow. + candidate += timedelta(days=1) + return candidate + +def get_downtime_start(next_reset: datetime) -> datetime: + """Return the downtime start time (minutes before next_reset).""" + return next_reset - timedelta(minutes=config["downtime_before_token_reset"]) + +def format_timedelta(td: timedelta) -> str: + """Format a timedelta as 'X hours and Y minutes' (or similar).""" + total_minutes = int(td.total_seconds() // 60) + hours = total_minutes // 60 + minutes = total_minutes % 60 + + parts = [] + if hours == 1: + parts.append("1 hour") + elif hours > 1: + parts.append(f"{hours} hours") + + if minutes == 1: + parts.append("1 minute") + elif minutes > 1: + parts.append(f"{minutes} minutes") + + if not parts: # If total is less than a minute + return "0 minutes" + + return " and ".join(parts) + +def get_time_until_reset_str(now: datetime) -> str: + """Return a string like '3 hours and 41 minutes' until next reset.""" + nr = get_next_reset_time(now) + delta = nr - now + return format_timedelta(delta) + +def is_registration_closed(now: datetime) -> Tuple[bool, str]: + """ + Determine if registration is closed based on config. + Return (closed_bool, message). + """ + nr = get_next_reset_time(now) + ds = get_downtime_start(nr) + + if ds <= now < nr: + # We are within downtime + time_until_open = nr - now + msg = ( + f"Registration is closed. " + f"It reopens in {format_timedelta(time_until_open)} at {nr.strftime('%H:%M UTC')}." + ) + return True, msg + else: + # Registration is open + if now > ds: + # We've passed ds, so next downtime is tomorrow + nr += timedelta(days=1) + ds = get_downtime_start(nr) + + time_until_close = ds - now + msg = ( + f"Registration is open. " + f"It will close in {format_timedelta(time_until_close)} at {ds.strftime('%H:%M UTC')}." + ) + return False, msg + +# --------------------------------------------------------- +# 4. Registration Validation +# --------------------------------------------------------- +def is_username_banned(username: str) -> bool: + """Check if username matches any banned patterns.""" + return any(pattern.search(username) for pattern in banned_username_patterns) + +def check_email_cooldown(email: str) -> Optional[str]: + """Check if email is allowed to register based on cooldown and multiple account rules.""" + registrations = load_registrations() + email_entries = [r for r in registrations if r["email"] == email] + + if not email_entries: + return None + + if not config.get("multiple_users_per_email", True): + return "This email address has already been used to register an account." + + if email_cooldown := config.get("email_cooldown"): + latest_registration = max( + datetime.fromisoformat(e["datetime"]) + for e in email_entries + ) + time_since = datetime.utcnow() - latest_registration + if time_since.total_seconds() < email_cooldown: + wait_time = email_cooldown - time_since.total_seconds() + return f"Please wait {int(wait_time)} seconds before requesting another account." + + return None + +async def check_username_availability(username: str) -> bool: + """Check if username is available on Matrix and in our registration records.""" + # Check banned usernames first + if is_username_banned(username): + logger.info(f"[USERNAME CHECK] {username}: Banned by pattern") + return False + + # Check local registrations + registrations = load_registrations() + if any(r["requested_name"] == username for r in registrations): + logger.info(f"[USERNAME CHECK] {username}: Already requested") + return False + + # Check Matrix homeserver + url = f"https://{config['homeserver']}/_matrix/client/v3/register/available?username={username}" + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, timeout=5) + if response.status_code == 200: + data = response.json() + is_available = data.get("available", False) + logger.info(f"[USERNAME CHECK] {username}: {'Available' if is_available else 'Taken'}") + return is_available + elif response.status_code == 400: + logger.info(f"[USERNAME CHECK] {username}: Taken (400)") + return False + except httpx.RequestError as ex: + logger.warning(f"[USERNAME CHECK] Could not reach homeserver: {ex}") + return False + return False + +# --------------------------------------------------------- +# 5. FastAPI Setup and Routes +# --------------------------------------------------------- +app = FastAPI() +app.add_middleware(CustomLoggingMiddleware) +app.mount("/static", StaticFiles(directory="static"), name="static") +templates = Jinja2Templates(directory="templates") + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request): + now = get_current_utc() + closed, message = is_registration_closed(now) + + return templates.TemplateResponse( + "index.html", + { + "request": request, + "registration_closed": closed, + "homeserver": config["homeserver"], + "message": message + } + ) + +@app.get("/api/time") +async def get_server_time(): + now = get_current_utc() + return JSONResponse({"utc_time": now.strftime("%H:%M:%S")}) + +@app.post("/register", response_class=HTMLResponse) +async def register( + request: Request, + requested_username: str = Form(...), + email: str = Form(...) +): + now = get_current_utc() + client_ip = request.client.host + + logger.info(f"Registration attempt - Username: {requested_username}, Email: {email}, IP: {client_ip}") + + # Check if registration is closed + closed, message = is_registration_closed(now) + if closed: + logger.info("Registration rejected: Registration is closed") + return templates.TemplateResponse( + "error.html", + { + "request": request, + "message": message + } + ) + + # Check bans + if client_ip in banned_ips: + logger.info(f"Registration rejected: Banned IP {client_ip}") + return templates.TemplateResponse( + "error.html", + { + "request": request, + "message": "Registration not allowed from your IP address." + } + ) + + if email in banned_emails: + logger.info(f"Registration rejected: Banned email {email}") + return templates.TemplateResponse( + "error.html", + { + "request": request, + "message": "Registration not allowed for this email address." + } + ) + + # Check email cooldown + if error_message := check_email_cooldown(email): + logger.info(f"Registration rejected: Email cooldown - {email}") + return templates.TemplateResponse( + "error.html", + { + "request": request, + "message": error_message + } + ) + + # Check username availability + available = await check_username_availability(requested_username) + if not available: + logger.info(f"Registration rejected: Username unavailable - {requested_username}") + return templates.TemplateResponse( + "error.html", + { + "request": request, + "message": f"The username '{requested_username}' is not available." + } + ) + + # Read token and prepare email + token = read_registration_token() + if token is None: + logger.error("Registration token file not found") + raise HTTPException(status_code=500, detail="Registration token file not found.") + + time_until_reset = get_time_until_reset_str(now) + email_body = config["email_body"].format( + homeserver=config["homeserver"], + registration_token=token, + requested_username=requested_username, + utc_time=now.strftime("%H:%M:%S"), + time_until_reset=time_until_reset + ) + + msg = EmailMessage() + msg.set_content(email_body) + msg["Subject"] = config["email_subject"].format(homeserver=config["homeserver"]) + msg["From"] = config["smtp"]["username"] + msg["To"] = email + + # Send email + try: + smtp_conf = config["smtp"] + with smtplib.SMTP(smtp_conf["host"], smtp_conf["port"]) as server: + if smtp_conf.get("use_tls", True): + server.starttls() + server.login(smtp_conf["username"], smtp_conf["password"]) + server.send_message(msg) + logger.info(f"Registration email sent successfully to {email}") + except Exception as ex: + logger.error(f"Failed to send email: {ex}") + raise HTTPException(status_code=500, detail=f"Error sending email: {ex}") + + # Log registration + registration_data = { + "requested_name": requested_username, + "email": email, + "datetime": datetime.utcnow().isoformat(), + "ip_address": client_ip + } + save_registration(registration_data) + + logger.info(f"Registration successful - Username: {requested_username}, Email: {email}") + + return templates.TemplateResponse( + "success.html", + { + "request": request, + "homeserver": config["homeserver"] + } + ) + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "registration:app", + host="0.0.0.0", + port=config["port"], + reload=True, + access_log=False + ) diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..798ec00 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/logo.png b/static/logo.png new file mode 100644 index 0000000..5762012 Binary files /dev/null and b/static/logo.png differ diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..b9cde42 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,185 @@ +/* Base styles */ +body { + background-color: #282828; + color: #ebdbb2; + font-family: sans-serif; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + min-height: 100vh; +} + +/* Main container */ +.main-container { + background-color: #32302f; + border-radius: 12px; + padding: 2rem; + margin: 2rem auto; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2); + width: 90%; + max-width: 800px; + display: flex; + flex-direction: column; + align-items: center; +} + +/* Logo styling */ +.logo-container { + width: 320px; + height: auto; + margin: 1rem 0 2rem 0; + display: flex; + justify-content: center; + background: transparent; /* Explicitly set transparent background */ +} + +.logo { + max-width: 100%; + height: auto; + background: transparent; /* Explicitly set transparent background */ +} + +/* Header styling */ +header { + text-align: center; + margin-bottom: 2rem; + width: 100%; +} + +h1 { + margin: 0; + font-size: 1.8rem; +} + +/* Clock styling */ +.clock { + font-size: 96px; + font-weight: bold; + color: #8ec07c; + text-align: center; + margin: 1.5rem 0; + font-family: monospace; +} + +/* Message containers and content */ +.message-container { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin: 2rem 0; +} + +.status-message, +.error-message, +.success-message { + width: 100%; + max-width: 600px; + font-size: 1.1rem; + text-align: center; + margin: 1.5rem 0; +} + +.status-message, +.success-message { + color: #b8bb26; +} + +.error-message { + color: #fb4934; +} + +.success-message p { + margin: 0.5rem 0; +} + +/* Form styling */ +form { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + margin: 1rem 0; +} + +input[type="text"], +input[type="email"] { + width: 80%; + max-width: 400px; + padding: 12px; + margin-bottom: 1rem; + font-size: 16px; + background-color: #3c3836; + border: 1px solid #665c54; + color: #ebdbb2; + border-radius: 6px; +} + +input[type="submit"] { + padding: 12px 24px; + font-size: 16px; + background-color: #689d6a; + border: none; + border-radius: 6px; + color: #ebdbb2; + cursor: pointer; + transition: background-color 0.2s; +} + +input[type="submit"]:hover { + background-color: #8ec07c; +} + +/* Button styling */ +.button-container { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + margin: 2rem 0; +} + +.back-button { + display: inline-block; + padding: 12px 24px; + color: #83a598; + text-decoration: none; + font-size: 16px; + border: 1px solid #83a598; + border-radius: 6px; + transition: all 0.2s; + text-align: center; +} + +.back-button:hover { + background-color: #83a598; + color: #282828; +} + +/* Footer styling */ +.footer-spacer { + flex-grow: 1; +} + +footer { + text-align: center; + color: #a89984; + font-size: 0.9rem; + margin-top: 2rem; + width: 100%; +} + +footer p { + margin: 0; +} + +/* Focus states for accessibility */ +input:focus, +.back-button:focus { + outline: 2px solid #83a598; + outline-offset: 2px; +} diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..5a2a9d7 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Error - {{ homeserver }}</title> + <link rel="icon" href="/static/favicon.ico"> + <link rel="stylesheet" href="/static/styles.css"> +</head> +<body> + <div class="main-container"> + <div class="logo-container"> + <img src="/static/logo.png" alt="{{ homeserver }} logo" class="logo"> + </div> + + <div class="message-container"> + <div class="error-message"> + {{ message }} + </div> + </div> + + <div class="button-container"> + <a href="/" class="back-button">Back to Home</a> + </div> + + <div class="footer-spacer"></div> + <footer> + <p>Matrix Homeserver: {{ homeserver }}</p> + </footer> + </div> +</body> +</html> diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..f9d589c --- /dev/null +++ b/templates/index.html @@ -0,0 +1,57 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Request an account on {{ homeserver }}</title> + <link rel="icon" href="/static/favicon.ico"> + <link rel="stylesheet" href="/static/styles.css"> +<script> + function updateClock() { + const now = new Date(); + const utcHours = String(now.getUTCHours()).padStart(2, '0'); + const utcMinutes = String(now.getUTCMinutes()).padStart(2, '0'); + const utcSeconds = String(now.getUTCSeconds()).padStart(2, '0'); + document.getElementById("clock").innerText = + `${utcHours}:${utcMinutes}:${utcSeconds}`; + } + + // Wait for DOM to be ready before starting the clock + document.addEventListener('DOMContentLoaded', function() { + updateClock(); + setInterval(updateClock, 1000); + }); +</script> +</head> +<body> + <div class="main-container"> + <div class="logo-container"> + <img src="/static/logo.png" alt="{{ homeserver }} logo" class="logo"> + </div> + + <header> + <h1>Request an account on {{ homeserver }}</h1> + </header> + + <div id="clock" class="clock">--:--:--</div> + + <div class="status-message"> + {{ message }} + </div> + + {% if registration_closed %} + <!-- Registration is closed --> + {% else %} + <form action="/register" method="post"> + <input type="text" name="requested_username" placeholder="Requested Username" required> + <input type="email" name="email" placeholder="Valid Email Address" required> + <input type="submit" value="Submit"> + </form> + {% endif %} + + <div class="footer-spacer"></div> + <footer> + <p>Matrix Homeserver: {{ homeserver }}</p> + </footer> + </div> +</body> +</html> diff --git a/templates/success.html b/templates/success.html new file mode 100644 index 0000000..ba2d05a --- /dev/null +++ b/templates/success.html @@ -0,0 +1,32 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Registration Success - {{ homeserver }}</title> + <link rel="icon" href="/static/favicon.ico"> + <link rel="stylesheet" href="/static/styles.css"> +</head> +<body> + <div class="main-container"> + <div class="logo-container"> + <img src="/static/logo.png" alt="{{ homeserver }} logo" class="logo"> + </div> + + <div class="message-container"> + <div class="success-message"> + <p>Registration token sent to your email address.</p> + <p>Please check your inbox.</p> + </div> + </div> + + <div class="button-container"> + <a href="/" class="back-button">Back to Home</a> + </div> + + <div class="footer-spacer"></div> + <footer> + <p>Matrix Homeserver: {{ homeserver }}</p> + </footer> + </div> +</body> +</html>