#!/usr/local/bin/python3.9 """Account registration service for the 40250 server (PORT: 40250 has no in-client registration). POST /register with a form body `login=..&password=..&social_id=..` inserts a row into account.account the same way the existing accounts are stored (password = MySQL PASSWORD()). The reply is one plain-text line the client shows directly: `OK` or `ERR `, where CODE is BAD_LOGIN, BAD_PASSWORD, BAD_SOCIAL_ID, EXISTS, RATE_LIMIT, BAD_REQUEST or SERVER. Deployed to /usr/metin2/server/register/ and started by /usr/local/etc/rc.d/mt_register. """ import argparse import re import subprocess import sys import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs LOGIN_RE = re.compile(r"^[A-Za-z0-9]{4,16}$") # LoginWindow ID_EditLine input_limit 16 PASSWORD_RE = re.compile(r"^[\x21-\x7e]{4,16}$") # printable ASCII, Password_EditLine input_limit 16 SOCIAL_ID_RE = re.compile(r"^[0-9]{7}$") # the delete code introselect asks for RATE_WINDOW = 3600 RATE_MAX = 5 # successful registrations per IP per hour MAX_BODY = 1024 _rate_lock = threading.Lock() _rate = {} # ip -> [timestamps of successful registrations] def log(msg): sys.stdout.write(time.strftime("%Y-%m-%d %H:%M:%S ") + msg + "\n") sys.stdout.flush() def rate_limited(ip): now = time.time() with _rate_lock: hits = [t for t in _rate.get(ip, []) if now - t < RATE_WINDOW] _rate[ip] = hits return len(hits) >= RATE_MAX def rate_record(ip): with _rate_lock: _rate.setdefault(ip, []).append(time.time()) def create_account(login, password, social_id): # Every value is validated above; the password additionally travels as hex so no quoting # can leak into the statement. sql = ("INSERT INTO account.account (login, password, social_id, status, create_time, last_play) " "VALUES ('%s', PASSWORD(UNHEX('%s')), '%s', 'OK', NOW(), NOW())" % (login, password.encode("ascii").hex(), social_id)) r = subprocess.run(["/usr/local/bin/mysql", "-uroot", "-N", "-e", sql], capture_output=True, text=True, timeout=10) if r.returncode == 0: return "OK" if "Duplicate entry" in r.stderr or "ERROR 1062" in r.stderr: return "ERR EXISTS" log("mysql error: " + r.stderr.strip()) return "ERR SERVER" class Handler(BaseHTTPRequestHandler): server_version = "mt-register/1" def log_message(self, fmt, *args): pass def reply(self, code, text): body = (text + "\n").encode("ascii") self.send_response(code) self.send_header("Content-Type", "text/plain") self.send_header("Content-Length", str(len(body))) self.send_header("Connection", "close") self.end_headers() self.wfile.write(body) def do_GET(self): if self.path == "/ping": self.reply(200, "OK") else: self.reply(404, "ERR BAD_REQUEST") def do_POST(self): ip = self.client_address[0] if self.path != "/register": return self.reply(404, "ERR BAD_REQUEST") try: n = int(self.headers.get("Content-Length", "0")) except ValueError: n = -1 if n < 0 or n > MAX_BODY: return self.reply(400, "ERR BAD_REQUEST") try: form = parse_qs(self.rfile.read(n).decode("ascii"), keep_blank_values=True) except (UnicodeDecodeError, ValueError): return self.reply(400, "ERR BAD_REQUEST") login = form.get("login", [""])[0] password = form.get("password", [""])[0] social_id = form.get("social_id", [""])[0] if not LOGIN_RE.match(login): result = "ERR BAD_LOGIN" elif not PASSWORD_RE.match(password): result = "ERR BAD_PASSWORD" elif not SOCIAL_ID_RE.match(social_id): result = "ERR BAD_SOCIAL_ID" elif rate_limited(ip): result = "ERR RATE_LIMIT" else: result = create_account(login.lower(), password, social_id) if result == "OK": rate_record(ip) log("%s register %s -> %s" % (ip, login, result)) self.reply(200, result) def main(): ap = argparse.ArgumentParser() ap.add_argument("--bind", default="0.0.0.0") ap.add_argument("--port", type=int, default=11080) args = ap.parse_args() httpd = ThreadingHTTPServer((args.bind, args.port), Handler) log("listening on %s:%d" % (args.bind, args.port)) httpd.serve_forever() if __name__ == "__main__": main()