- Auto hunt (client-only): PythonPlayerAutoHunt.cpp + AutoHuntScript.inc as the embedded mt_autohunt module, AUTO touch button, F9/F8. - Account registration: tools/40250/register_server.py (rc.d mt_register, :11080) inserts into account.account; net.RegisterAccount/GetRegisterAccountResult (PythonAccountRegister.cpp) and the mt_register login-window dialog (RegisterScript.inc). - --py-exec also runs in --login-screen mode for scripted login-screen tests. - Touch controller, window manager and render command updates for the Android native client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
131 lines
4.6 KiB
Python
131 lines
4.6 KiB
Python
#!/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 <CODE>`, 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()
|