diff --git a/.gitignore b/.gitignore index cd5b93f..9577a63 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Python __pycache__ -#Vim +# Vim *.swp diff --git a/README.md b/README.md index 63bc378..35715a3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Python Aternos API An unofficial Aternos API written in Python. It uses requests, cloudscraper and lxml to parse data from [aternos.org](https://aternos.org/). -> Note for vim: if u have problem like this `IndentationError: unindent does not match any outer indentation level`, try out `retab`. +> Note for vim: if you have a problem like `IndentationError: unindent does not match any outer indentation level`, try out `retab`. ## Using First you need to install the module: @@ -13,7 +13,7 @@ To use Aternos API in your Python script, import it and login with your username and password (or MD5 hash of password). > Note: Logging in with Google or Facebook account is not supported yet. -Then get the servers list using get_servers method. +Then get the servers list using the `servers` field. You can start/stop your Aternos server now, calling `start()` or `stop()`. There is an example how to use the Aternos API: @@ -53,6 +53,31 @@ if testserv != None: ``` You can find full documentation on the [Project Wiki](https://github.com/DarkCat09/python-aternos/wiki). +## Changelog + +|Version|Description| +|:-----:|-----------| +|v0.1|The first release.| +|v0.2|Fixed import problem.| +|v0.3|Implemented files API, added typization.| +|v0.4|Implemented configuration API, some bugfixes.| +|v0.5|The API was updated corresponding to new Aternos security methods. Huge thanks to [lusm554](https://github.com/lusm554).| +|v0.6|Preventing detecting automated access is planned.| +|v0.7|Full implementation of config API and Google Drive backups is planned.| +|v0.8|Shared access API and permission management is planned.| +|v0.9.x|A long debugging before stable release, SemVer version code.| + ## License [License Notice](NOTICE): ``` diff --git a/js2py_test.py b/js2py_test.py deleted file mode 100755 index 48f2699..0000000 --- a/js2py_test.py +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env python3 -import base64 -import js2py - -# Emulate 'atob' function -#print(base64.standard_b64decode('MmlYaDVXNXVFWXE1ZldKSWF6UTY=')) - -# Test cases -tests = [ - """(() => {window[("A" + "J" + "AX_T" + "OKE" + "N")]=("2iXh5W5u" + "EYq" + "5fWJIa" + "zQ6");})();""", - """ (() => {window[["N","TOKE","AJAX_"].reverse().join('')]=["IazQ6","fWJ","h5W5uEYq5","2iX"].reverse().join('');})();""", - """(() => {window["AJAX_TOKEN"] = atob("SGVsbG8sIHdvcmxk")})();""", - """(() => {window[atob('QUpBWF9UT0tFTg==')]=atob('MmlYaDVXNXVFWXE1ZldKSWF6UTY=');})();""", - """(() => {window["AJAX_TOKEN"] = "1234" })();""", - """(() => {window[atob('QUpBWF9UT0tFTg==')]="2iXh5W5uEYq5fWJIazQ6";})();""", -] - -# Array function to ECMAScript 5.1 -def code(f): - return "(function() { " + f[f.index("{")+1 : f.index("}")] + "})();" - -# Emulation atob V8 -def atob(arg): - return base64.standard_b64decode(str(arg)).decode("utf-8") - -presettings = """ -let window = {}; -""" - -ctx = js2py.EvalJs({ 'atob': atob }) - -''' -ctx.execute(presettings + code(tests[3])) -print(ctx.window) -''' - -for f in tests: - try: - c = code(f) - ctx.execute(presettings + c) - print(ctx.window['AJAX_TOKEN']) - except Exception as e: - print(c, '\n', e) - diff --git a/python_aternos/__init__.py b/python_aternos/__init__.py index 4a567ef..0df4f92 100644 --- a/python_aternos/__init__.py +++ b/python_aternos/__init__.py @@ -10,8 +10,8 @@ class Client: def __init__( self, username:str, - md5:Optional[str]=None, - password:Optional[str]=None) -> None: + password:Optional[str]=None, + md5:Optional[str]=None) -> None: if (password == None) and (md5 == None): raise AttributeError('Password was not specified') @@ -52,7 +52,7 @@ class Client: atconnect.REQGET ) serverstree = lxml.html.fromstring(serverspage.content) - serverslist = serverstree.xpath('//div[@class="servers"]/div') + serverslist = serverstree.xpath('//div[contains(@class,"servers ")]/div') servers = [] for server in serverslist: diff --git a/python_aternos/atconnect.py b/python_aternos/atconnect.py index a05f948..55b6bea 100644 --- a/python_aternos/atconnect.py +++ b/python_aternos/atconnect.py @@ -7,23 +7,7 @@ from cloudscraper import CloudScraper from typing import Optional, Union from . import aterrors - -# TEST -import js2py -import base64 - -# Set obj for js -presettings = """ -let window = {}; -""" - -# Convert array function to CMAScript 5 function -def toECMAScript5Function(f): - return "(function() { " + f[f.index("{")+1 : f.index("}")] + "})();" - -# Emulation of atob - https://developer.mozilla.org/en-US/docs/Web/API/atob -def atob(s): - return base64.standard_b64decode(str(s)).decode("utf-8") +from . import atjsparse REQGET = 0 REQPOST = 1 @@ -31,144 +15,139 @@ REQUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:68.0) Gecko/20100101 Goann class AternosConnect: - def __init__(self) -> None: + def __init__(self) -> None: - pass + pass - def parse_token(self, response:Optional[Union[str,bytes]]=None) -> str: + def parse_token(self, response:Optional[Union[str,bytes]]=None) -> str: - if response == None: - loginpage = self.request_cloudflare( - f'https://aternos.org/go/', REQGET - ).content - pagetree = lxml.html.fromstring(loginpage) - else: - pagetree = lxml.html.fromstring(response) + if response == None: + loginpage = self.request_cloudflare( + f'https://aternos.org/go/', REQGET + ).content + pagetree = lxml.html.fromstring(loginpage) + else: + pagetree = lxml.html.fromstring(response) - try: - # fetch text - pagehead = pagetree.head - text = pagehead.text_content() + try: + pagehead = pagetree.head + text = pagehead.text_content() - #search - js_funcs = re.findall(r"\(\(\)(.*?)\)\(\);", text) - token_js_func = js_funcs[1] if len(js_funcs) > 1 else js_funcs[0] + js_code = re.findall(r'\(\(\)(.*?)\)\(\);', text) + token_func = js_code[1] if len(js_code) > 1 else js_code[0] - # run js - ctx = js2py.EvalJs({ 'atob': atob }) - jsf = toECMAScript5Function(token_js_func) - ctx.execute(presettings + jsf) - - self.token = ctx.window['AJAX_TOKEN'] - except (IndexError, TypeError): - raise aterrors.AternosCredentialsError( - 'Unable to parse TOKEN from the page' - ) + ctx = atjsparse.exec(token_func) + self.token = ctx.window['AJAX_TOKEN'] - return self.token + except (IndexError, TypeError): + raise aterrors.AternosCredentialsError( + 'Unable to parse TOKEN from the page' + ) - def generate_sec(self) -> str: + return self.token - randkey = self.generate_aternos_rand() - randval = self.generate_aternos_rand() - self.sec = f'{randkey}:{randval}' - self.session.cookies.set( - f'ATERNOS_SEC_{randkey}', randval, - domain='aternos.org' - ) + def generate_sec(self) -> str: - return self.sec + randkey = self.generate_aternos_rand() + randval = self.generate_aternos_rand() + self.sec = f'{randkey}:{randval}' + self.session.cookies.set( + f'ATERNOS_SEC_{randkey}', randval, + domain='aternos.org' + ) - def generate_aternos_rand(self, randlen:int=16) -> str: + return self.sec - rand_arr = [] - for i in range(randlen+1): - rand_arr.append('') + def generate_aternos_rand(self, randlen:int=16) -> str: - rand_alphanum = \ - self.convert_num(random.random(),36) + \ - '00000000000000000' - return (rand_alphanum[2:18].join(rand_arr)[:randlen]) + rand_arr = [] + for i in range(randlen+1): + rand_arr.append('') - def convert_num(self, num:Union[int,float], base:int) -> str: + rand_alphanum = \ + self.convert_num(random.random(),36) + \ + '00000000000000000' + return (rand_alphanum[2:18].join(rand_arr)[:randlen]) - result = '' - while num > 0: - result = str(num % base) + result - num //= base - return result + def convert_num(self, num:Union[int,float], base:int) -> str: - def request_cloudflare( - self, url:str, method:int, - retries:int=10, - params:Optional[dict]=None, - data:Optional[dict]=None, - headers:Optional[dict]=None, - reqcookies:Optional[dict]=None, - sendtoken:bool=False) -> Response: + result = '' + while num > 0: + result = str(num % base) + result + num //= base + return result - cftitle = 'Please Wait... | Cloudflare' + def request_cloudflare( + self, url:str, method:int, + retries:int=10, + params:Optional[dict]=None, + data:Optional[dict]=None, + headers:Optional[dict]=None, + reqcookies:Optional[dict]=None, + sendtoken:bool=False) -> Response: - if sendtoken: - if params == None: - params = {} - params['SEC'] = self.sec - params['TOKEN'] = self.token + cftitle = 'Please Wait... | Cloudflare' - if headers == None: - headers = {} - headers['User-Agent'] = REQUA + if sendtoken: + if params == None: + params = {} + params['SEC'] = self.sec + params['TOKEN'] = self.token - try: - cookies = self.session.cookies - except AttributeError: - cookies = None + if headers == None: + headers = {} + headers['User-Agent'] = REQUA - self.session = CloudScraper() - if cookies != None: - self.session.cookies = cookies + try: + cookies = self.session.cookies + except AttributeError: + cookies = None - if method == REQPOST: - req = self.session.post( - url, - data=data, - headers=headers, - cookies=reqcookies - ) - else: - req = self.session.get( - url, - params=params, - headers=headers, - cookies=reqcookies - ) + self.session = CloudScraper() + if cookies != None: + self.session.cookies = cookies - countdown = retries - while cftitle in req.text \ - and (countdown > 0): + if method == REQPOST: + req = self.session.post( + url, + data=data, + headers=headers, + cookies=reqcookies + ) + else: + req = self.session.get( + url, + params=params, + headers=headers, + cookies=reqcookies + ) - self.session = CloudScraper() - if cookies != None: - self.session.cookies = cookies - if reqcookies != None: - for cookiekey in reqcookies: - self.session.cookies.set(cookiekey, reqcookies[cookiekey]) + countdown = retries + while cftitle in req.text \ + and (countdown > 0): - time.sleep(1) - if method == REQPOST: - req = self.session.post( - url, - data=data, - headers=headers, - cookies=reqcookies - ) - else: - req = self.session.get( - url, - params=params, - headers=headers, - cookies=reqcookies - ) - countdown -= 1 + self.session = CloudScraper() + if cookies != None: + self.session.cookies = cookies + if reqcookies != None: + for cookiekey in reqcookies: + self.session.cookies.set(cookiekey, reqcookies[cookiekey]) - return req + time.sleep(1) + if method == REQPOST: + req = self.session.post( + url, + data=data, + headers=headers, + cookies=reqcookies + ) + else: + req = self.session.get( + url, + params=params, + headers=headers, + cookies=reqcookies + ) + countdown -= 1 + + return req diff --git a/python_aternos/atjsparse.py b/python_aternos/atjsparse.py new file mode 100644 index 0000000..c6918fe --- /dev/null +++ b/python_aternos/atjsparse.py @@ -0,0 +1,37 @@ +import re +import js2py +import base64 + +brkregex = re.compile(r'\((?!\)|[\'\"])(.+?)(?' in f: + inner = parse_brackets(f) + while brkregex.match(inner) != None: + inner = parse_brackets(inner) + + func = re.sub( + r'(\w+)\s*=>\s*(.+)', + r'function(\1){return \2}', inner + ) + start = f.find(inner) + end = start + len(inner) + f = f[:start] + func + f[end:] + return f + +def exec(f): + ctx = js2py.EvalJs({'atob': atob}) + ctx.execute(to_ecma5_function(f)) + return ctx diff --git a/python_aternos/atserver.py b/python_aternos/atserver.py index 193c003..33f00b9 100644 --- a/python_aternos/atserver.py +++ b/python_aternos/atserver.py @@ -159,7 +159,8 @@ class AternosServer: def motd(self, value:str) -> None: self.atserver_request( 'https://aternos.org/panel/ajax/options/motd.php', - atconnect.REQPOST, data={'motd': value} + atconnect.REQPOST, data={'motd': value}, + sendtoken=True ) @property diff --git a/setup.py b/setup.py index 80cc66e..0bbfbe7 100644 --- a/setup.py +++ b/setup.py @@ -1,31 +1,31 @@ import setuptools with open('README.md', 'rt') as readme: - long_description = readme.read() + long_description = readme.read() setuptools.setup( - name='python-aternos', - version='0.2', - author='Chechkenev Andrey (@DarkCat09)', - author_email='aacd0709@mail.ru', - description='An unofficial Aternos API', - long_description=long_description, - long_description_content_type='text/markdown', - url='https://github.com/DarkCat09/python-aternos', - project_urls={ - 'Bug Tracker': 'https://github.com/DarkCat09/python-aternos/issues', - 'Documentation': 'https://github.com/DarkCat09/python-aternos/wiki/Client-(entry-point)', - }, - classifiers=[ - 'Programming Language :: Python :: 3', - 'License :: OSI Approved :: Apache Software License', - 'Operating System :: OS Independent' - ], - install_requires=[ - 'lxml==4.6.2', - 'requests==2.25.1', - 'cloudscraper==1.2.58' - ], - packages=['python_aternos'], - python_requires=">=3.6", + name='python-aternos', + version='0.5', + author='Chechkenev Andrey (@DarkCat09)', + author_email='aacd0709@mail.ru', + description='An unofficial Aternos API', + long_description=long_description, + long_description_content_type='text/markdown', + url='https://github.com/DarkCat09/python-aternos', + project_urls={ + 'Bug Tracker': 'https://github.com/DarkCat09/python-aternos/issues', + 'Documentation': 'https://github.com/DarkCat09/python-aternos/wiki/Client-(entry-point)', + }, + classifiers=[ + 'Programming Language :: Python :: 3', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: OS Independent' + ], + install_requires=[ + 'lxml==4.6.2', + 'requests==2.25.1', + 'cloudscraper==1.2.58' + ], + packages=['python_aternos'], + python_requires=">=3.6", ) diff --git a/connect_test.py b/tests/connect_test.py similarity index 100% rename from connect_test.py rename to tests/connect_test.py diff --git a/tests/js2py_test.py b/tests/js2py_test.py new file mode 100644 index 0000000..a7870c7 --- /dev/null +++ b/tests/js2py_test.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +import re +import base64 +import js2py + +# Emulate 'atob' function +# print(base64.standard_b64decode('MmlYaDVXNXVFWXE1ZldKSWF6UTY=')) + +# Test cases +# tests = [ +# """(() => {window[("A" + "J" + "AX_T" + "OKE" + "N")]=("2iXh5W5u" + "EYq" + "5fWJIa" + "zQ6");})();""", +# """(() => {window[["N","TOKE","AJAX_"].reverse().join('')]=["IazQ6","fWJ","h5W5uEYq5","2iX"].reverse().join('');})();""", +# """(() => {window["AJAX_TOKEN"] = atob("SGVsbG8sIHdvcmxk")})();""", +# """(() => {window[atob('QUpBWF9UT0tFTg==')]=atob('MmlYaDVXNXVFWXE1ZldKSWF6UTY=');})();""", +# """(() => {window["AJAX_TOKEN"] = "1234" })();""", +# """(() => {window[atob('QUpBWF9UT0tFTg==')]="2iXh5W5uEYq5fWJIazQ6";})();""", +# ] + +# Use tests from a file +tests = [] +with open('../token.txt', 'rt') as f: + lines = re.split(r'[\r\n]', f.read()) + del lines[len(lines)-1] # Remove empty string + tests = lines + +brkregex = re.compile(r'\((?!\)|[\'\"])(.+?)(?' in f: + inner = parse_brackets(f) + while brkregex.match(inner) != None: + inner = parse_brackets(inner) + + func = re.sub( + r'(\w+)\s*=>\s*(.+)', + r'function(\1){return \2}', inner + ) + start = f.find(inner) + end = start + len(inner) + f = f[:start] + func + f[end:] + return f + +ctx = js2py.EvalJs({'atob': atob}) + +for f in tests: + try: + c = to_ecma5_function(f) + ctx.execute(c) + print(ctx.window['AJAX_TOKEN']) + except Exception as e: + print(c, '\n', e) + +# Expected output: +# 2rKOA1IFdBcHhEM616cb +# 2rKOA1IFdBcHhEM616cb +# 2rKOA1IFdBcHhEM616cb +# 2rKOA1IFdBcHhEM616cb +# 2rKOA1IFdBcHhEM616cb +# 2rKOA1IFdBcHhEM616cb +# 2rKOA1IFdBcHhEM616cb +# 2rKOA1IFdBcHhEM616cb +# 2rKOA1IFdBcHhEM616cb +# 2iXh5W5uEYq5fWJIazQ6 +# CuUcmZ27Fb8bVBNw12Vj +# YPPe8Ph7vzYaZ9PF9oQP +# (Note: The last three +# tokens are different) diff --git a/token.txt b/token.txt index adaa554..383f6ab 100644 --- a/token.txt +++ b/token.txt @@ -7,3 +7,6 @@ (() => {window[["A","JAX_","TOKEN"].join('')]=atob('MnJLT0ExSUZkQmNIaEVNNjE2Y2I=');})(); (() => {window["AJAX_TOKEN"]=["2rKOA1IFdB","cHhEM61","6cb"].join('');})(); (() => {window[atob('QUpBWF9UT0tFTg==')]=("2rKOA1IFdB" + "cHhEM616c" + "b");})(); +(() => {window[atob('QUpBWF9UT0tFTg==')]=atob('MmlYaDVXNXVFWXE1ZldKSWF6UTY=');})(); +(() => {window[["_XAJA","NEKOT"].map(s => s.split('').reverse().join('')).join('')]=!window[("encodeURI" + "Componen" + "t")] || atob('Q3VVY21aMjdGYjhiVkJOdzEyVmo=');})(); +(() => {window[["N","_TOKE","AJAX"].reverse().join('')]=!window[("en" + "co" + "deURICo" + "mpone" + "nt")] || ["zv7hP8ePPY","FP9ZaY","PQo9"].map(s => s.split('').reverse().join('')).join('');})();